复刻阿波罗制导计算机:从指令集到DSKY的完整模拟
拿到“Apollo-11”这个项目标题大多数人的第一反应可能是回望那场著名的登月任务。但作为一个常年折腾复古系统和模拟器的开发者我看到的其实是另一层东西一台内存只有几十KB、CPU主频刚过1MHz的计算机是怎么在极端约束下完成实时导航、姿态控制和人机交互的这个标题背后真正有价值的事情不是复述历史而是用现代工具去复刻和理解那套被称为“AGC”的导航计算机系统。我最近正好完成了这样一个模拟项目核心是把阿波罗制导计算机Apollo Guidance Computer简称AGC的指令系统、操作系统内核、以及那块标志性的DSKY显示键盘面板从头到尾用软件重建了一遍。模拟器跑起来之后你可以像当年的宇航员一样通过DSKY面板输入动词、名词和数字控制虚拟飞船完成一次入轨参数计算。这篇文章就把我整个项目的拆解过程、踩过的坑和关键实现细节完整记录下来希望能给正在做复古计算、指令集模拟器或者航天软件仿真相关项目的朋友一些参考。1. 项目拆解复刻一台1960年代的“飞行大脑”1.1 这个项目的核心难点在哪里很多人会以为模拟一台老计算机很简单——无非是读入ROM镜像然后写一个指令循环不断取指、译码、执行。但AGC和现代CPU有几个完全不同的地方导致它不能套用现成的模拟器框架。第一个难点是字长和寻址方式的差异。AGC是15位字长的机器不是常见的8位、16位或32位。它的内存组织结构也不是平坦的而是分成若干个bank通过特殊指令切换访问。你在模拟Physical内存的时候不能简单地开一个uint16_t mem[32768]数组就完事必须把bank切换逻辑、内存区和固定区的重叠映射关系一起实现否则程序一跑就乱跳。第二个难点是解释器Interpreter和原生指令共存的混合架构。AGC的软件层大部分是用一种类似高级语言的解释器指令编写的运行时会先命中解释器代码入口再由CPU执行一段内置的解释逻辑去解析后续的指令操作码。这意味着模拟器不仅要模拟硬件指令还得模拟那段“用于解释字节码的原生代码”相当于在虚拟机里再套一层虚拟机。第三个难点是实时任务调度。AGC上有十来个后台任务包括惰性漂移修正、姿态稳定、仪表驱动等。它不是现代操作系统那种抢占式调度而是合作式调度——每个任务必须主动让出执行权。模拟器里的计时器、中断和任务等待队列必须按原版逻辑重写少一个环节整个系统就会因为任务堆栈溢出而挂死。所以这个项目本质上不是“写个模拟器”这么简单而是要对数十万行历史汇编代码的微观行为做精确重建。任何时序偏差或字长截断错误最终都会表现为DSKY面板上数字乱跳、或者程序计数器跑到奇奇怪怪的地址去。1.2 技术选型为什么不用现成的JS模拟器改造做这个项目之前我专门看过网上已有的AGC模拟实现。有几个基于Web的项目做得相当完整不仅画出了DSKY面板还集成了舱载仪表显示。按理说我直接拿去用就完事了为什么还要自己从头写原因主要有三个。第一那些项目把“硬件模拟”和“场景可视化”耦合得太紧我想拆出一个纯逻辑内核方便后续在命令行环境跑回归测试也方便接入不同的前端界面。把CPU、内存、任务调度和I/O全部做成独立模块比在网页代码里找出业务逻辑要清爽得多。第二现有项目在指令集精确性上做得不够严谨。部分模拟器为了性能把AGC上的某些指令周期数和内存bank切换时序近似化了这在跑简单Demo时看不出问题但在执行处理机状态切换、等待队列操作等关键路径时就会行为异常。我自己做的过程中发现AGC的INDEX指令会通过修改低八位地址码来实现变址跳转如果模拟器没有正确处理这个地址拼接过程中断返回后程序几乎必挂。第三我想在复刻的AGC上做一些原版环境下很难做的诊断分析比如全量指令覆盖率统计、watchpoint跟踪、以及逐周期完成的trace导出。这需要在模拟器内核里埋入大量可插拔的监控接口从头写反而更容易控制。所以最终我选择了一条偏“重”的路线用C语言写一个可嵌入的AGC内核再配合脚本语言做测试断言和场景驱动。C的位运算能力和内存控制能力更适合这种精度敏感的模拟工作。1.3 系统的整体模块划分整个工程我分成了几层每一层只负责一件事指令模拟核心负责取指、译码、执行、中断响应。这里保证了所有原生指令的周期数、寄存器和内存访问行为一致。解释器运行时模拟内置的解释器执行环境维护解释器栈指针、累加器、返回地址栈等专用寄存器。操作系统Executive实现任务队列、等待队列、中断优先级管理。这是AGC软件栈的核心也是复刻难度最高的部分。I/O外设层包括DSKY键盘扫描与显示驱动、警报蜂鸣器、以及和虚拟惯性测量单元的数据接口。监控与调试层这是自己增加的附加层原版硬件没有主要提供断点、单步、trace日志输出等能力。这样的分层让每个模块都能单独做单元测试也方便后面扩展新的外设——比如有人想模拟登月雷达或者通讯系统直接在I/O层接一个新设备就行。2. 核心细节拆解指令集、内存与任务调度的三重挑战2.1 15位字长的每一比特都利用到了极致AGC是15位字长最高位通常用来做奇偶校验实际有效数据是14位。这种设计对现代开发者来说非常别扭因为无论你用uint16_t还是int存储都必须时刻注意掩码操作——一不小心就会让本应截断到15位的值溢出到第16位然后CPU状态就彻底失控。我在内存操作上提了一个公共接口所有读改写操作都走同一套宏或者函数#define AGCMEM_MASK 0377777 // 15位全1八进制表示原版习惯用八进制 uint16_t agc_mem_read(uint16_t addr) { return mem[addr] AGCMEM_MASK; } void agc_mem_write(uint16_t addr, uint16_t value) { mem[addr] value AGCMEM_MASK; }注意原版AGC的文档里大量使用八进制表示地址和数值都有前导零。我在代码里保留了八进制的注释习惯方便对照原始汇编源码避免十进制和八进制之间来回换算出错。除了数值截断AGC还有个特点让很多人栽跟头内存bank切换。它的固定内存Fixed Memory有2K字可切换内存Erasable Memory也是2K字其余地址空间通过EXTEND指令配合特定操作码来切换bank。翻译成人话就是程序访问某个地址时看到的数据可能来自不同的物理bank具体是哪一个取决于之前写入内存映射寄存器Channel寄存器的值。这种行为和现代CPU上的段寄存器类似但细节要变态得多——因为部分地址区域是“固定映射”部分区域才是“可切换映射”到底能不能切换还要看地址属于哪个区间。实现的时候我维护了一个bank_reg变量记录当前选中的bank号每次访存都根据地址区间做一次逻辑判断uint16_t agc_mem_effective_addr(uint16_t addr) { if (addr 000000 addr 002000) { // erasable memory直接访问无需切换 return addr; } else if (addr 004000 addr 010000) { // fixed memory无需切换 return addr; } else if (addr 002000 addr 004000) { // fixed-fixed / fixed可切换混合区将地址加上bank偏移 return addr (bank_reg 10); } // 其他区域默认强制取低有效地址位 return addr 0x1FF; }这里的地址区间划分实际上是按原版AGC硬件手册严格模拟的。我最初因为偷懒直接把bank寄存器对应的偏移拼接到地址上结果程序在涉及到高地址区域访问时反复出现数据错乱后来逐条对照原始汇编才追到问题。2.2 解释器机制的模拟在虚拟机里跑字节码语言如果你打开AGC的软件清单会发现大量以EXTEND开头、或者以INDEX等指令构成的序列这些其实是在解释执行一套类高级语言的字节码。原版把这种解释器叫做“Interpretive Programming”目的是让宇航员任务软件用更接近自然语言的指令编写。模拟这套机制的关键是要理解解释器本身也是一个程序。它存储在固定内存中每一个解释器指令比如VAC、VAD、VSL等都是这段解释器代码的入口点。当CPU执行到解释器入口时跳进解释器的原生代码段由这些代码去读取后续的参数、操作数再执行相应运算。所以模拟器不能简单地把解释器指令当作一个独立的操作码来实现——那样虽然运行结果可能差不多但你无法复现真实的执行时序也无法处理某些解释器指令对累加器状态的特殊依赖。我的做法是在指令模拟核心中当遇到解释器入口地址时不直接执行某个C函数而是把CPU控制权交给我用C重写的一段“解释器运行时”这个运行时内部维护一份独立的字节码指针寄存器IQ按字节码序列逐条执行。这里有一个非常容易踩坑的点解释器的返回地址栈和原生指令的子程序调用栈是分离的。如果模拟器只有一个栈解释器在处理嵌套子程序时会把返回地址弄混最终必然导致程序跑飞。我花了整整一晚上排查为什么某次函数的返回总是跳到一个奇怪的地址最后发现是栈没有拆分导致的。对于想复刻其他经典系统比如PDP-8、CDC等的朋友这个经验同样适用——模拟时要把“硬件行为”和“软件约定”分清楚。栈的用法是软件基于硬件栈指针的约定而硬件本身可能只有一两个寄存器能做到“地址回退”的功能。拆分不清后面所有逻辑都会跟着错。2.3 合作式任务调度和时序模拟AGC的操作系统部分官方文档里叫“Executive”它的核心功能就是任务调度。按现代眼光看这套调度器非常简单有一个主循环轮询各种标志位发现某个任务触发了就切换到对应上下文执行。但真正动手模拟的时候你就会发现它比想象中要“拧巴”。原因是AGC的任务调度是合作式的任务自身通过特定指令比如RELINT、INHINT来开启或屏蔽中断并在关键代码段里手动调用等待函数来让出CPU。模拟这些时序行为最关键的是准确处理中断窗口。原版硬件有一个外部中断信号输入模拟器里你需要周期性地检查是否有外部事件比如DSKY按键、IMU采样触发中断。但中断的插入时机不能太随意——必须在CPU执行完当前指令之后且在响应新中断之前允许任务执行一两条指令来修改中断屏蔽状态。我用一个简单的优先队列来模拟中断扫描void agc_interrupt_check() { if (int_pending int_enabled) { // 保存当前PC和状态字 push_pc_to_stack(current_pc); // 跳转到中断向量地址 current_pc INT_VECTOR; int_pending 0; } }这个逻辑看起来简单但执行顺序有一个隐蔽陷阱某些AGC指令本身就会改变中断使能状态比如EXTEND前缀指令会对下一条指令产生特殊影响。如果在取指阶段没注意前缀存在中断就可能在指令序列的中间被插入导致程序状态不一致。我的解决办法是为每条指令建立一个阶段机模型取指阶段、执行阶段、写回阶段。在每个阶段完成后检查中断这样能最大限度贴近原版行为。3. 实操过程从一条裸指令到完整飞控模拟3.1 先造一个最小可运行的内核我没有一上来就追求跑通完整AGC软件而是先做一个“最小指令集模拟器”只支持最基本的ADD、SUB、TC、INDEX等指令保证能执行一个从内存读取数值、累加、跳转的小程序。这个阶段的目标是把指令执行路径跑通。我用一个八进制写的极小程序做冒烟测试// 伪代码把地址100的内容加到累加器然后跳转到地址200 uint16_t prog[] { 00100, // 操作数地址实际指令从别处开始 // ... };实际上我参照原版AGC汇编的手写了一个简单程序然后通过汇编器生成二进制镜像加载到模拟器中执行。如果PC跑完整个程序后停留在预期的结束地址并且累加器的数值正确说明基础指令路径没有问题。从这里我能体会到模拟器开发最怕一步到位。你一次性把数千条指令全部实现遇到bug时根本无从下手。先精确认证二十来个基础指令把取指、中断、内存读写这个循环彻底稳定下来后面再扩展其他指令就只是体力活了。3.2 用原始源码和已知测试向量做验证只跑自己写的小程序远远不够因为你无法确定模拟器是否符合原版行为。我找到一些公开的AGC软件测试向量以及原版飞行软件的汇编列表逐条核对我实现的指令周期数和状态变化。这个环节我建议一定写自动化测试。我写了一个专门的测试框架把所有指令的“输入寄存器状态 - 输出寄存器状态 - 内存变化”都以JSON格式保存然后逐条跑模拟器比对。每条指令至少要覆盖正常路径和边界路径比如累加器溢出的进位如何处理、减法的符号位如何保存等等。比如ADD指令如果累加器当前值是0177777再加上0000001结果应该是0000000并且溢出标记置位。这个测试看起来简单但如果你的模拟器忽略了15位截断结果会变成0200000溢出标记也不会正确触发。我测试时这类边界值问题就抓到了好几个。3.3 DSKY面板还原动词-名词交互DSKY全称是“Display and Keyboard”是宇航员与AGC交互的主要界面。它由数字按键0-9、动词键VERB、名词键NOUN以及一个五位数字显示屏组成。操作方式是先按动词键再输入一位或两位动词编号再按名词键继续输入名词编号然后输入数据。这套交互逻辑放到今天看依然很有设计感——它把人机对话抽象成了“操作类型 操作对象 数据”三个维度。模拟DSKY的软件逻辑比想象中要麻烦因为显示和按键扫描走的是不同的通道寄存器。按键扫描是CPU主动查询键盘矩阵的行列状态显示则是CPU去刷新通道寄存器的值再由显示驱动去更新七段数码管。我在模拟器里实现了一个简单的矩阵扫描// 键盘扫描四个行每个行对应若干按键 // 按键按下时对应通道寄存器的某一位为1 uint16_t dsky_scan_key() { for (row 0; row 4; row) { for (col 0; col 5; col) { if (key_matrix[row][col] 1) { return (row 3) | col; // 编码行和列 } } } return NO_KEY; }为了让DSKY的交互体验贴近真实我还模拟了按键的“胶滞”行为——原版硬件因为机械结构按键信号不是干净的边沿触发而是会出现抖动和重复。模拟器里我增加了一个简单的去抖延迟如果一个键被持续按下超过一定时间只响应一次。虽然真实AGC不一定有这个逻辑按键处理都是软件查询的但加了这个副作用整体交互手感反而更自然。3.4 场景驱动让虚拟飞船按剧本飞行纯模拟AGC的指令执行还不够直观。我要做的是让整个场景“活”起来——虚拟飞船在飞AGC在实时计算DSKY每隔一段时间显示新的数据。我设计了一个简单的场景脚本机制用逗号分隔的文本文件描述每个时间点的飞船状态比如姿态角、发动机状态、速度增量然后由一个场景解释器把这些状态转化为AGC的输入数据——模拟惯性测量单元IMU的读数。AGC感知到输入变化后会更新自己的计算然后通过通道寄存器向DSKY输出新数值。这部分的实现其实没有太多高深技术核心就是时间同步。AGC运行速度和场景推进速度之间必须有一个映射关系。我默认设置是模拟器的每100万次指令周期对应虚拟世界里的1秒。这个比值不是来自原版硬件原版时钟比这个高而是为了在普通PC上跑起来不至于太快或者太卡。跑这个场景最大的收获是终于能亲眼看到AGC在某个任务阶段如何自动更新显示器上的数字然后通过键盘输入一个中断指令来切换到下一阶段。那种“你敲进去一个数字屏幕上马上给出反应”的体验和玩现代模拟游戏完全是两码事。4. 常见问题与排查技巧实录4.1 问题一程序在特定地址上反复死循环这是我遇到的第一个诡异问题。模拟器跑了几千条指令后程序计数器开始在一个地址区间反复跳转始终不往前推进。我加了很多printf打印出每100条指令的PC和累加器值发现规律是总在某个地址做TC转移操作跳到的地方又执行一条指令跳回来。后来我仔细对照原版汇编发现这个死循环其实是AGC的软件特意为之——这是空闲等待循环。当系统没有任务可做时执行器会把CPU放进一个循环不断检查某个标志位是否被置位。这本身不是bug而是系统设计的一部分。我把它当成一个问题去查才意识到自己对AGC的任务调度模型理解还不到位。这个案例教给我一个经验调试模拟器时不要假设程序卡住就是模拟器错了。一些看似异常的循环行为可能就是目标系统的正常实现。先排查“软件本身是否在这种状态下就是如此”再下手改动模拟器。4.2 问题二换bank后地址数据错乱有一次我在跑一段涉及内存bank切换的代码时发现某块区域的数据总是莫名其妙变成其他的值。排查路径很长最后定位到我在EXTEND指令处理时更新了bank寄存器但更新时机过早——在原版机器上bank寄存器的切换要等到下一条指令实际访存时才生效而我却在执行到当前指令的写回阶段就改了bank。也就是说有一条指令需要先读取旧bank的数据然后在执行期再切换bank给接下来的指令用。我之前把顺序弄反了。这个问题如果只看指令集文档不关注硬件实现细节是很容易踩进去的。修好这个时序问题后程序的怪异行为完全消失。4.3 问题二添解释器栈混乱导致随机跳飞这是最让我抓狂的一个问题。某条解释器指令的结果总是在特定输入下会跳到一个随机地址最终导致停机。我花了几天时间反复对照原版解释器的汇编代码最后发现是对INDEX指令的理解出了问题。在解释器模式下INDEX指令不是普通的变址跳转而是会把累加器里的值和当前指令字的低八位相加形成一个新的地址。我之前的实现直接做了拼接但没注意加法会产生进位影响高位。原版的行为是允许这个进位存在的并且会与当前的内存bank号相加形成最终地址。这个细节文档里写得很隐晦我是靠反复对照测试向量才定位出来的。解决之后就一个感觉复刻老系统数据位宽和进位传播的细节是魔鬼中的魔鬼。4.4 调试工具与流程建议给准备做类似项目的朋友一个建议尽早把trace日志机制做好不要等到出了问题再补救。我用的trace格式很简单——每执行一条指令就输出一行PC、八进制指令字、指令助记符、执行前累加器值、执行后累加器值、内存是否被改写。有了这个日志配合脚本做一些简单统计比如按地址统计执行次数、计算指令覆盖率大部分“为什么走到这”、“为什么数据变了”的问题都能快速定位。我还建议给模拟器引入“确定性回放”机制。每次执行前把所有随机种子固定下来确保同样的输入序列产生同样的输出。如果某个bug出现了你能保存现场反复在调试器里观察而不会因为时序随机性导致问题不可复现。4.5 工具链和调试器选择的经验谈做这个项目我用了一套比较朴素的工具链Visual Studio Code写代码GCC编译配合GDB调试。有人可能觉得这种老旧的C项目用个IDE更顺手但我的体会是模拟器这种程序核心逻辑集中在几个关键源文件里GDB的命令行反而是最顺手的。尤其是GDB的断点条件比如“当内存地址02444被写入时暂停”在这种内存模型复杂的项目里极其有用。你不需要在每次内存写入时都打印日志只需要设置条件断点命中了就停下来仔细观察。比起无脑加日志效率高了几倍。5. 这个项目还能怎么扩展复刻AGC只是一个起点。做完基础模拟之后我脑子里已经冒出来好几个后续计划简单分享几个方向。第一个方向是接入真实飞行数据。有人整理过登月任务各阶段的遥测数据如果能把那些数据流喂给模拟DSKY就能复现真实任务中面板数字的变化过程用于博物馆展览或课堂教学。第二个方向是增加可视化界面。目前我主要工作在命令行和简单的窗口界面如果想要更沉浸的体验可以在前端用图形引擎画出虚拟仪表盘把AGC计算出来的数据实时渲染成指针和数字表盘。第三个方向是做一个“反汇编/调试平台”。既然模拟器能把AGC的指令级行为摸透那就可以在上面做交互式反汇编器以源代码方式展示原始汇编并在断点位置高亮当前正在执行的指令。这对学习历史软件遗产非常有价值。还有更偏研究性的方向是把AGC的软件从现代软件工程视角去做分析——比如绘制任务调用关系图、分析中断与任务切换的最坏响应时间、统计不同代码模块的执行频率等。这些分析在原版环境下几乎不可能做但在模拟器上只是加几行插桩的问题。对我个人来说这个项目的最大收获并不是“我把一段历史代码跑了起来”而是理解了在极致硬件约束下软件设计能迸发出多么惊人的创造力和严谨性。AGC没有现代操作系统那么多抽象层但它用一个极简的调度器和一套巧妙的解释器完成了当时被认为是“不可能”的任务。这种设计智慧在今天的嵌入式系统里仍然适用——很多时候限制反而能逼出最优雅的方案。如果你也想动手试试类似的项目我的建议是先别急着追求跑通完整系统把它拆成指令集模拟、外设交互、场景驱动三个独立阶段一个阶段一个阶段地验证。模拟器的世界里慢就是快——每一条指令的时序、每一次内存bank切换的行为都值得你花时间去吃透。等你哪天真地看到自己写的模拟器在DSKY面板上跳出正确的数字序列那种满足感和被现代框架快速带过的爽感完全不是一个量级。