☰
复刻登月计算机AGC模拟器:从指令集到DSKY交互
2026/10/15 1:06:19 网站建设 项目流程

前阵子我给自己定了个小目标:用一个月时间,把五十多年前那台登月计算机的交互逻辑,完整复刻成一个能跑的软件模拟器。项目代号就叫 Apollo-11,不是要去复现什么航天工程,纯粹是我对计算机发展史里最特殊的一台机器着了迷。当年的硬件规模放在现在连一个电子表都打不过,但它跑过的那套操作逻辑、指令设计和人机交互理念,放到今天依然有很强的参考价值。

这篇博文,我会把自己踩过的坑、验证过的方法、以及我对那套计算机架构的理解,全部摊开写出来。它能做什么?你可以跟着复现一个能编译 AGC 汇编代码、能点亮 DSKY 面板的模拟器,亲手按一遍 VERB/NOUN 操作流程,感受六十年代的「实时操作系统」是怎么转起来的。适合三类人看:对计算机考古感兴趣的开发者、想研究极简指令集设计的嵌入式爱好者,以及纯粹想找一个有意思的周末项目来折腾的动手派。

整个项目做下来,最大的感受是:我们嘴里天天念叨的"人在回路""逐级降级""优先级抢占",原来半个世纪前就被一块 36K 只读存储的板子玩明白了。下面按我的推进顺序,把思路、核心细节、实现路径和排坑过程逐一拆解。

1. 项目整体设计与思路拆解

1.1 我到底在复刻什么:一台五十年前的计算机

在开工之前,我强迫自己先回答一个问题:我要复刻的是"硬件"还是"行为"?

硬件的角度,你需要搞定磁芯存储器的读写时序、三个 CPU 寄存器之间的数据通路、各种专用警报电路的物理逻辑。行为的角度,你只需要让外部表现出来的一切——显示屏上的数字、按键后的响应、程序的执行流——和原版一致即可。

我选择了行为仿真。原因很实际:第一个月时间不够做硬件仿真,第二那台机器的存储介质早就停产了,第三,行为仿真的收益最高——我能用最少的成本,亲手体会当年工程师每天对着的那台设备到底是怎么运转的。

于是 Apollo-11 这个项目的定位就清晰了:一个运行在现代 PC 上的 AGC 机器模拟器,外加一套配套的汇编器,能把我写的汇编代码编译成机器可读的指令序列。它模拟的是"一台能执行指令的计算机",而不是"一块能点亮的电路板"。

1.2 为什么用现代 Web 技术,而不是单片机

最初我其实想用树莓派加一块物理按键矩阵来做,接线图都画好了。后来算了笔账:复位电路、信号抖动消抖、电平转换、供电隔离,这些硬件工程问题会吃掉我 70% 的精力,剩下 30% 才是真正想研究的技术逻辑。

所以我改成纯 Web 方案:前端用 Canvas 渲染 DSKY 面板,后端逻辑用 JavaScript 写模拟器核心,汇编器也一并写在同一个项目里。理由有三个。

第一,Canvas 渲染 DSKY 的荧光管显示效果非常方便,我可以精确控制每个数字段的亮灭状态,做出那种老式电子管的辉光感。第二,JS 的语法灵活,写模拟器的指令分发循环和内存管理都很顺手,调试也方便,按一下 F12 就能看到内存和寄存器状态,这比在真实单片机上调试点亮个数码管高效太多。第三,浏览器的分享成本为零,做完之后发个链接,什么环境都不用装,对方就能直接上手玩。

技术选型这件事上,我始终相信一个原则:能用逻辑层解决的问题,不要急着上物理层。你真正在研究的东西是"那台计算机的思维",而不是"怎么把按键信号送进 CPU"。

1.3 设计目标:从硬件形态到交互体验的三层钩沉

定完方向之后,我把整个复刻项目拆成了三个层次,按依赖关系排序,像搭金字塔一样逐层往上垒:

第一层是寄存器与指令集。这是最底层、最核心的部分,模拟器必须精确实现 AGC 的三大寄存器和指令流转逻辑。第二层是存储与程序加载。这一步负责把汇编后的指令序列写入模拟内存,并支持从"只读存储"区域启动程序。第三层是 DSKY 人机交互。这一层负责把按键输入变成系统可识别的动词/名词操作,再把程序结果渲染成面板上的灯光和数字显示。

三层对应三种不同的研究价值。第一层讲的是"计算机如何思考",第二层讲的是"程序如何被存储和引导",第三层讲的是"人与机器如何对话"。任何一个做过真实系统的人都知道,这三件事在现代计算机里依然是最核心的设计命题。

所以 Apollo-11 的最终形态是一个可以被"操作"的模拟器:你能按键、能看到面板反馈、能感受到一台真实计算机的运行脉搏,而不是看一段冷冰冰的指令清单。

2. 核心细节解析与实操要点

2.1 AGC 的存储架构与 16 位指令字长

复刻的第一步,是搞清楚这台机器最基础的存储参数。这步做对了,后面所有代码都顺理成章;这里错了,后面全盘皆错。

AGC 的存储由两部分组成:只读存储(相当于现在的 ROM)和可擦写存储(相当于现在的 RAM)。只读容量是 36K 字(每个字 16 位),可擦写只有 2K 字。你没看错,总共不到 40K 字的地址空间,其中真正能当内存用的只有 2K,剩下的全是固化好的程序区。

每个存储字长 16 位,其中数据位是 15 位,最高位是符号位(1 表示负数,0 表示正数)。这设计很有意思,它的运算单元并不是标准的补码逻辑,而是采用"一补加一"的方式实现负数运算。模拟器里我直接用十六进制有符号数存储每个字,但加减法运算函数里单独处理了符号位的进位逻辑,否则后面做指令仿真时,计数的溢出行为会跟原机对不上。

内存布局上,可擦写存储被分成了多个存储体,每个存储体有 256 个字。这套"存储体切换"的机制,是理解 AGC 寻址的钥匙。CPU 靠一个专门的存储体选择寄存器来切换当前访问的内存段,而程序员写代码时,必须显式地维护这个切换状态。听上去很原始,对吧?但正是这套机制,构成了后面我实现程序计数器跨段跳转的核心约束。

2.2 那套反直觉的指令集

AGC 的指令集非常精简,我后来数了一下,核心指令一共就 10 条左右。这也是我选它做复刻对象的原因:现代 CPU 动辄几百条指令,根本没法一条条读完;而 AGC 的指令集,我可以用半天时间全部吃透,而且每一条背后都有非常明确的工程动机。

这里挑三条体验最深刻的来说。

第一条是 TC,即"跳转"指令。它把程序计数器当前值放进一个专用的返回地址寄存器,然后跳转到目标地址。用高级语言类比,TC 既像一个函数调用,又像一个无条件跳转——因为它同时隐含了"记住我来处"的语义。如果你连续用 TC 做跳转却不准备返回,它的行为就和普通跳转完全一致;但如果你后面对返回地址寄存器做操作,它又具备了栈调用的灵魂。这种"一指令双语义"的做法,体现了当年设计者对硬件空间的极致节约。

第二条是 CCS,全称是"计数、比较、跳转"。这条指令读一个内存字,根据该字的值同时更新条件码,然后根据条件码跳转到四个后续地址之一。用现代说法,这就是一条"读取数据的同时完成分支决策"的复合指令。模拟器实现它时,最关键的细节是:CCS 会先把被读取的字的绝对值减一存回原位,再根据减一前后的符号状态设置条件码。第一次做的时候我漏了这个"隐式修改内存"的行为,导致程序计数逻辑混乱,排查了整整一个晚上。

第三条是 INDEX,它把内存字的内容当作一个偏移量,加到下一条指令的地址上。这套机制被大量用于表格查找和多路分支。你在模拟器里看到的效果是:一条 INDEX 指向数据表,下一条指令实际执行的地址,会依赖于表格里存的值。这本质上就是现在"间接寻址"的老祖宗。

我提供了一个关键建议给想复刻指令集的朋友:不要急着一次性把所有指令都实现完。先实现 TC、CCS、INDEX、XCH(交换累加器与内存)这四条核心指令,跑通一个简单的计数器循环,再把剩余的算术和移位类指令补上。这样做的好处是,你能在一开始就验证 CPU 控制循环的主干是否正确,而不是被某个不常用指令的边界条件卡住进程。

2.3 DSKY 面板的数字与人机交互逻辑

DSKY 是这台机器与人对话的唯一窗口。面板上有一组数字显示屏,可以显示 3 个 5 位的数字量;有一组键盘,包含了十余个按键;还有一大片警示灯,用来指示系统当前的工作状态。

人机交互的核心模型是"动词-名词"组合。这是我觉得整套系统设计里最精彩的部分。所谓动词,就是指用户想让系统做的事情,比如"显示"、"计算"、"调整";所谓名词,就是指用户操作的对象,比如"某号寄存器"、"某个轨道参数"。

我模拟出来的交互流程是:你先输入一个两位的动词代码,再输入一个两位的名词代码,最后按下执行键。系统根据这组组合,决定把什么数据展示到面板上,或者把哪个程序调度进运行队列。这相当于一个最原始的"命令行界面",只是它的快捷键从字符串变成了数字代码。

在实际复刻 DSKY 面板时,我遇到的最大挑战是显示刷新的时序问题。原版的荧光管不是整屏同时点亮的,而是逐位扫描刷新。如果你想忠实复刻那种"数字像是呼吸一样稳定跳变"的观感,就必须在你的模拟器里也做一个逐位刷新的渲染循环,而不是一次性把所有数字都画出来。

具体的渲染参数我是这样定的:刷新率取 60Hz,每个显示位占用 1/60 秒,三个显示组共 15 位数字,完成一轮完整的扫描约需 250 毫秒。这个速度既不会让人眼感到明显闪烁,又能还原老设备的质感。

3. 实操过程与核心环节实现

3.1 搭建基础模拟器框架

项目一开始,我没急着写具体指令,而是先搭了模拟器的基础骨架。核心思路用一个无限循环来表示 CPU 的时钟周期:读取程序计数器指向的存储字,把它翻译成指令和参数,执行对应操作,更新程序计数器,然后重复。

伪代码大概是这样的:

function cpuCycle() { const word = memory.read(registers.programCounter); const instruction = decode(word); instruction.execute(registers, memory); if (!instruction.changedPc()) { registers.programCounter = (registers.programCounter + 1) & 0x3FFF; } }

这段代码虽然简单,却包含了一个容易忽略的设计点:programCounter 用掩码限制在 14 位范围内,对应原版存储体的真实寻址空间。很多模拟器写到最后跑飞了,问题往往就出在这个计数器没有做位宽限制,加减到头之后溢出到了非法地址段。

框架搭好后,我开始丰富指令表。每实现一条指令,我都会配上一条最小的汇编测试代码,用汇编器编译后加载进模拟器,手工检查执行结果是否符合预期。这个习惯帮我在后续的工作里躲过了至少三次逻辑差错。

再往后,我加了一个监控页面,实时显示寄存器、内存数据、以及当前正在执行的指令反汇编文本。别小看这个页面,它让整个调试过程从"摸着黑调时钟"变成了"开着灯看内部状态",效率提升非常明显。你现在打开我的演练页面,依然能看到这个监控面板。

3.2 实现 DSKY 显示与按键响应

处理完计算核心,我开始做交互面板。

显示部分我用 Canvas 画了 15 个 7 段数码管位,每个位由 7 个段组成,每个段有两种状态:亮或灭。为了做出老荧光管的质感,我给每个亮起的段叠加了一层浅蓝色辉光,并给熄灭的段保留了一点极淡的轮廓,模拟玻璃管的反光。

按键部分则需要注意一个重要问题:硬件上的按钮存在机械抖动,而浏览器里的按钮事件本身就带有防抖逻辑,所以这一步反而好做。真正麻烦的是按键事件与模拟器执行时序的同步。

最初我让按键事件直接改内存里的名词/动词寄存器,结果发现会出现一种诡异现象:当模拟器的 CPU 正在处理某个程序的紧要关头,按键事件切断了它的执行流,导致系统状态被撕裂。

解决办法是引入一个输入命令队列:按键事件只把操作码塞进队列,模拟器在每条指令执行完之后检查队列,有新的输入就处理,没有就继续跑当前的程序。这个设计和现代操作系统里的中断机制在精神上是一模一样的。

至此,交互流程的闭环基本完成:用户按数字键 → 数字进入命令队列 → CPU 在指令边界读取并处理 → DSKY 显示结果 → 用户看到反馈。整个循环稳定、无撕裂,体验很接近真机。

3.3 让指令跑起来:从启动自检到程序加载

我最兴奋的时刻,是把一段真实可运行的汇编程序加载进模拟器,看到数字开始按预期的规则跳变。整个过程可以拆成三个阶段。

先说程序加载。汇编器会把源码转成二进制指令字序列,模拟器启动时会把这些指令字依次写入指定的只读存储区域。开始我还想着要有文件选择、拖拽上传这些功能,后来简化成直接把指令序列拼接在加载脚本里,既省事又够用。

然后是启动自检逻辑。老机器上有一连串的电源接通自检流程:初始化寄存器、清空可擦写存储、点亮全部显示位、让操作员确认面板可用。我顺手把这些流程也写进了模拟环境里,开机后你会在 DSKY 上看到所有数字同时闪亮,这个效果非常怀旧。

最后是执行用户程序。我第一版写了一个简单的校验和程序。程序读取一块内存区里的多个数据字,对它们执行累加,最后把结果送到 DSKY 的显示寄存器。这个程序虽然简单,却把存储读取、算术运算、输出显示这条完整链路全部打通了。看到最终的和值准确出现在数字显示屏上那一瞬间,我知道这个项目已经成功了八成。

我还做了一次更进阶的验证:手动通过 DSKY 按键,输入动词"显示"、名词"某某寄存器"的代码,让模拟器把指定内存地址的内容打印出来。当面板上显示出我预期的数字那一刻,我觉得自己真正摸到了操作系统"实时交互"的脉搏。

4. 常见问题与排查技巧实录

4.1 经典报错与解决对照表

调试过程中攒了不少排错经验,我整理成了一张对照表,按照踩坑频率排序:

症状根因解决办法
程序跑了一会儿后数值溢出且不回滚累加器进位逻辑未按 16 位有符号规则截断检查符号位与进位链的交互,补全溢出掩码处理
跳转到了奇怪地址程序计数器没有按存储体切换规则重新定位在存储体切换指令后同步刷新计数值
DSKY 数字显示闪烁严重模拟器用单次全量绘制替代了逐位扫描刷新切断为 15 位逐段刷新循环
按键输入毫无响应事件在异步线程里直接修改寄存器,导致时序错乱改为命令队列,在指令边界消费输入
CCS 指令行为不稳定忽略了隐式修改被读取内存字的行为在指令内部先更新数据,再设置条件码
汇编器报错地址越界指令源的偏移量算错,未考虑延迟槽给汇编器加诊断器,列出每条跳转的落点

这张表我在博客里反复核对过,每一项都是我一次一次跑测试、一遍一遍过代码细节验证过的,不是那种浅显的纸上诊断。

4.2 三条独家调试心得

第一条心得:给模拟器加一个单步执行按钮。你可以在任意一条指令边界暂停 CPU,观察全部寄存器的状态变化。真实硬件上不可能做到这件事,但在模拟器里,这一步能让你像做解剖实验一样看清每条指令的副作用。我做 CCS 指令时,就是靠单步执行发现了它隐式修改内存的问题。

第二条心得:把汇编器的诊断信息做得冗余一些。我的汇编器在生成指令时,会输出一份符号表、一份交叉引用表、以及每条跳转指令的落点预测。哪怕这些信息看上去冗余,实际调试时它们往往就是最快的定位手段。有一次程序老往错误分支跳,我打开交叉引用表,一眼就看出某个常量表的位置偏移错了。

第三条心得:构建测试程序时,从小处着手,每次只增加一个新特性。我最早的测试程序只是一个把数字加一的循环,后来慢慢添加了表格查找、条件跳转、DSKY 输出。每加一个特性,我都回归运行一遍旧测试,确保新代码不会破坏已通过的逻辑。这套流程耗时不长,却让整个项目始终处于一种"随时可以演示"的状态,反而节省了大量排错时间。

4.3 一个让我印象深刻的边界问题

最后提一个调试经历。有一次模拟器在跑一个带除法的程序时,结果总是多出一个固定差值。我检查了除法指令本身,它没有别的分支,看起来完全正确;检查存储也没发现数据损坏。后来我把中间值逐个打印出来,才发现问题根本不出在指令上,而是汇编器把十进制常量转二进制时多翻转了一个位。

这种"问题不在模拟器里,而在工具链里"的情况,是模拟器开发里最难查、也最值得警惕的一种。它让我养成一个习惯:开发任何系统时,都先怀疑工具链,再怀疑执行环境,最后才怀疑核心逻辑。顺序对了,定位问题的速度自然就上去了。

5. 复盘:这台机器教会我的三件事

整个 Apollo-11 项目的代码量不算多,核心模拟器加上配套工具大约几千行,但带给我的收获却比想象中大得多。

第一件事是在资源受限的前提下做平衡的智慧。那台机器只有不到 40K 的存储空间,全部代码都放在只读区域,可擦写内存只有 2K,但它照样完成了复杂的导航与控制运算。它给出的方案是:把关键的运算固化成专用程序,把需要频繁调整的变量放在狭小的读写区里,再用高效的动词/名词机制让人与机器保持沟通。这套方法放到今天的嵌入式开发里,依然是最正统的架构思路。

第二件事是"优雅"不等于"复杂"。现代人容易陷入一种幻觉,觉得软件越复杂就越先进。但 AGC 的指令集只有十几条指令,交互模型只是一个动词加一个名词,它甚至没有现代意义上的操作系统。可是它的设计环环相扣,每一处都足够简洁,也没有一处是多余的。这种极简设计带来的美感,远胜于堆叠功能的堆砌感。

第三件事是历史代码的可读性比你想象的高。翻开那个年代的程序源码,你会发现注释非常密集,几乎每段指令都有大段文字解释意图。这和现在某些"只有上帝能看懂"的代码风格形成了鲜明对比。在复刻过程中,我多次对照原版流程注释来校正自己的理解,也因此对"写代码是为了与人沟通,而不仅是为了让机器执行"这句话有了更深的体会。

如果你也想做类似的项目,我的建议很直接:别从现代操作系统的角度去设计你的模拟器,先回到那台机器本身的逻辑里,把寄存器、指令、存储空间之间的结构关系理解透,再动手写第一行代码。要卡壳的时候,就想想当年那些工程师手边没有调试器,只能靠面板灯光和打印纸来排查问题,一个人对着灰扑扑的机器一遍遍算、一遍遍核对。技术会老化,但那种一遍遍逼近真实的理解过程,无论在哪个年代都不过时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询