从“Apollo-11”这个标题说起:我到底在复刻什么
如果你在技术社区里看到一个项目标题叫“Apollo-11”,大概率不是真的要把土星五号再发射一遍,而是瞄着那个经典的“阿波罗计划”产物——导航计算机去的。我最初看到这个标题时,第一反应是“又有人要折腾AGC(Apollo Guidance Computer)仿真了”。后来真上手才发现,这个标题背后能拆出来的东西远比想象中多:有指令译码、有实时任务调度、有那套奇特的“核心内存”存储逻辑,甚至还有当年的宇航员操作界面DSKY。
这篇文章就围绕我自己用软件手段“复刻”Apollo-11核心逻辑的实际经历来写。我会把整个项目从需求拆解、方案选型、模块实现到踩坑复盘全都摊开讲。不管你是做嵌入式、模拟器、复古计算还是纯粹对航天史感兴趣,都能从里面找到一些能搬到现实项目里用的东西。
先说结论:这个项目不是让你去还原每一颗电阻和每一根导线,而是用现代工具去还原那台计算机“思考”的方式。理解了AGC的指令设计逻辑,你对“资源受限下如何做实时系统”这件事的理解会上一个台阶——它是所有现代飞控计算机的“老祖宗”级别参照物。
1. 整体设计与思路拆解:为什么“Apollo-11”值得被重新实现
1.1 核心需求解析:标题背后藏着三个层次
光看“Apollo-11”这个项目名,你可能觉得核心就是“登月”。但拆到工程层面,需求其实是分层的:
- 第一层:指令系统。AGC用的是自定义的指令集,字长15位,带1位奇偶校验,所以内存访问和算术运算都围绕15位展开。如果你想复刻,第一步就是要让这台“虚拟AGC”能正确执行它那套指令,包括但不限于加、减、转储、变址跳转等基础操作。
- 第二层:实时调度。当年的AGC要同时处理姿态控制、导航解算、发动机点火时序和宇航员的手动输入。这在今天来看就是一个分时多任务实时系统。要在仿真里做出来,就必须理解它那个约12Hz主循环里怎么切任务、怎么挂起、怎么恢复现场。
- 第三层:人机交互。你可能见过那个像收银机一样的显示键盘面板DSKY。它不只是一个输入输出设备,更是一个“操作员界面协议”的早期样板:动词、名词、程序序号、数据寄存器显示,全都有固定格式。标题里虽然没写,但要做完一个“能跑起来的”Apollo-11仿真,DSKY的交互逻辑几乎避不开。
当初我看到这个项目标题时,脑子里迅速闪过的这层拆解其实帮了很大忙——它决定了后面所有技术选型的方向。
1.2 方案选型:软件仿真还是硬件重建
这是一个绕不开的分叉口。围绕“Apollo-11”标题的复刻项目,基本分成三类流派:
- 纯软件仿真:用Python、C或Rust写一个指令级模拟器,把AGC的二进制镜像(很经典的Luminary099之类)加载进来执行。优点是门槛低、可调试性强,适合理解架构;缺点是缺少真实时序和硬件中断的细节。
- FPGA硬件重建:用FPGA实现AGC的处理器逻辑和外设接口,跑起来快、时序接近真实,但工程量大,得懂Verilog或VHDL,调试也更麻烦。
- 外围系统仿真:不碰AGC本体,只做它的输入输出环境,比如DSKY面板的图形界面、仪表盘、遥测数据可视化。适合做展览、教学或游戏。
我当时选择的是“纯软件仿真优先,局部接上图形DSKY”的混合路线。理由很简单:优先把指令系统和调度逻辑跑通,用可观测的调试器看清楚每条指令的流向;等逻辑通透了,再来补交互面板。
1.3 为什么不是“照抄一个模拟器”
有人可能会说,网上已经有开源的AGC仿真器,直接拿来改不更快吗?话是没错,但如果只是为了“能跑”,你会错过最核心的收获——理解那15位指令字长带来的设计约束。
举个例子:AGC的指令里,有的操作码只有3位,有的地址字段要挤在6位里还要配合特殊寄存器区。当你自己动手把二进制指令一位一位拆开,再去对应指令手册核对助记符,你对“指令集架构”的理解会完全不一样。这也是把项目命名为“Apollo-11”而不是“跑一个AGC模拟器”的潜在动机:标题暗示了你要重建的是一整套任务系统,而不是一个孤立的CPU核。
2. 核心细节解析与实操要点:必须啃下的几块硬骨头
2.1 15位字长与奇偶校验:最容易被忽略的基础
在动手写模拟器之前,一个很容易踩的坑就是“字长”。现在让你写代码处理数据,你脑海里默认就是8位、16位、32位。但AGC偏偏是15位数据加1位校验位。
这意味着什么?意味着它的地址空间是32768个字,而算术运算天然就有溢出和截断的问题。仿真时如果用int16_t来存,一不小心就把符号位吃掉了。实操中我建议直接用一个专门的类型,比如Python里就封装一个类,专门处理15位无符号数运算,避开原生整数给你带来的“额外高位”干扰。
具体拆分一下:
- 内存读取:每次从内存数组取值,先做掩码 0x7FFF,把最高校验位剥掉。
- 写入:写回时要重新计算校验位,虽然纯逻辑仿真时校验位可以不参与,但如果你想连诊断特性一起复刻,就得保留。
- 算术标志:溢出位OV、负数标志等都需要单独记录,不能依赖现代CPU的eflags。
这段代码写法不复杂,但它决定了后续所有模块的数据边界。很多人仿真到一半出现莫名奇妙跳转错误,往往就是字长截断没处理好。
2.2 指令集实现:不仅要“能跑”,还要“跑对”
AGC的指令集大致分成四类:
- TC(Transfer Control)类:相当于现代CPU的函数调用和返回,比如TC、TCF。
- 算术类:ADD、SUB、MP、DV等。
- 内存类:INDEX、XCH、CS等,涉及寄存器区和内存交换。
- 特殊指令:比如涉及I/O通道的READ、WRITE,以及EXTEND语法下的扩展指令。
表面上看,每条指令就那么几十行的事。但真正的难度在指令的边界情况:比如变址指令INDEX会先把它的操作数加载到变址寄存器,下一条指令的地址要加这个变址值;又比如DV除法指令要求被除数高10位绝对值必须小于除数,否则要触发陷阱。这些边界条件如果不看“当年的编程手册”,光靠猜一定会漏。
我当时就是对照着公开的指令手册,把每条指令的“前置条件—操作语义—结果标志”整理成一张表,再逐条用已知的测试向量去验证。这样做虽然慢,但换来的是后面跑真实飞行程序时的稳定表现。
2.3 DSKY交互协议:那套“动词-名词”逻辑的价值
DSKY的输入不是随便敲数字,而是按键序列被解读成“动词(Verb)+名词(Noun)+程序序号”。比如按“动词06、名词35”可能代表“检查某个传感器数据”。这背后其实是早期人机交互的一个标准化设计思想:把“用户意图”拆成操作和对象两个维度。
仿真DSKY时,不需要真的做出一个收银机一样的硬件,但逻辑层一定得“较真”:它有一组数码管显示寄存器的值,有操作灯(比如“TEMP”闪烁、 “NO ATT”告警),还有键盘布局。我在实现里把DSKY逻辑做成独立模块,对外只暴露按键事件、显示刷新事件和灯光状态事件,这样主模拟器不用关心UI细节。
这个交互协议值得你多花时间研究。它和现代命令行工具的“命令+参数”模型本质一致,但在资源和显式反馈极其有限的环境下,能做到让宇航员快速准确操作,这种克制和精确很值得借鉴。
3. 实操过程与核心环节实现:从零搭起一个虚拟AGC
3.1 第一步:搭一个指令级模拟器骨架
我用的语言是Python,虽然性能不是极致,但做原型验证和调试幸福感很高。整体骨架是:
- 内存区:列表长度 0x8000,每个元素是一个0~0x7FFF的整数。
- 寄存器组:A(累加器)、L(寄存器L)、Q(返回地址寄存器)、Z(程序计数器)、EB(基数寄存器)、FB(返回基数寄存器)等。
- 指令译码器:从内存取15位数据,提取操作码和地址字段,分发到对应执行函数。
- 循环节拍:每执行一条指令为一个节拍,支持单步、断点、连续运行。
伪代码大致长这样(这是我自己写的精简版,方便说明):
class AGC: def __init__(self, memory_image): self.memory = memory_image[:] # 0x8000 words self.A = 0 self.L = 0 self.Q = 0 self.Z = 0 self.overflow = False self.cycles = 0 def step(self): raw_inst = self.memory[self.Z] opcode = (raw_inst >> 12) & 0x7 # 这里要结合具体编码表 address = raw_inst & 0xFFF self.Z = (self.Z + 1) & 0x7FFF self.execute(opcode, address) self.cycles += 1注意:上面opcode和address字段的定义,严格说应按AGC手册的“低地址段、特殊寄存器段”来重新划分,这里仅仅示意。
3.2 第二步:加载真实的飞行软件镜像
没有飞行软件镜像的AGC仿真器就像没有菜谱的厨房。我找到了公开的Luminary099(登月指令舱软件早期版本)镜像文件。这个镜像文件里面每一行都是用八进制表示的内存初始值,或者一句标记指令(比如把某个地址对到某标签)。
加载镜像的过程没有太多玄学,就是读文本、解析八进制、写入内存数组。但这个环节有一个必须注意的点:内存里某些地址是“固定地址”和“特殊寄存器”的映射区,不能粗暴当作普通RAM处理。比如地址0x80附近的区域可能直接对应I/O通道,写入时会产生外设效果。
刚加载完,程序计数器Z的初值通常是某个人为设定的启动地址。之后运行的第一步就是烟花般的跳转和自检。如果模拟器指令集实现有问题,几乎几分钟内就会卡进死循环或异常跳飞。
3.3 第三步:让DSKY“活”过来
在这个项目中,我给DSKY做了一个基于终端字符界面的交互面板。用键盘模拟按键,用终端绘制数字管和灯的状态。具体交互流程是:
- 用户输入一个动词数字(两位)。
- 输入一个名词数字(两位)。
- 系统根据当前程序状态执行操作,并在显示寄存器区输出计算结果。
实际操作里,你会惊讶于“一个简单的读传感器数据”的操作,背后要走完一段完整的中断处理和显示刷新逻辑。在这个时候,你对“实时系统状态切换”的感知会比看任何教材都真切。
关键代码示意如下:
class DSKY: def __init__(self, agc): self.agc = agc self.verb = None self.noun = None self.program = None self.display_registers = [0] * 5 def on_key(self, key): if key == "VERB": self.verb = int(self.pending) self.pending = "" # 其他按键逻辑省略,重点是状态机 def refresh_display(self): # 从AGC寄存器区取值更新显示3.4 实时循环与时序:让仿真“感觉对”
纯指令仿真如果只按顺序跑,会丢掉“实时系统”的魂。AGC的软件靠一个固定频率的中断驱动,主任务循环会在中断触发时进行数据采样和控制计算。我在仿真里用了一个独立的定时线程,以模拟那个中断信号:定时向AGC注入中断事件,由它跳入对应的中断服务程序。
这部分的难点在于:中断处理里要保存现场、切换任务、完成新一轮导航计算、再恢复现场。调试这类代码比顺序执行恶心得多,因为你不能在“错误的时候”暂停太久,否则中断堆积会把系统时序打乱。
实际操作中我的建议是:先关掉中断,用纯顺序模式跑通“从启动到显示一个静态结果”;确定无误后再引入中断,逐步增加真实感。分阶段排查,能省掉很多“到底是谁改了寄存器”的争论。
4. 常见问题与排查技巧实录:那些仿真中绕不开的坑
4.1 寄存器被神秘改写怎么办
我碰到最典型的问题是,程序跑着跑着,某条指令的结果莫名其妙被“篡改”,追查半天发现在循环体里有一条Index指令偷偷把变址寄存器值加了进去。这个问题的根源在于没有注意到采用“变址预取”机制——当前正在执行的指令可能已经被放在特殊寄存器里,而变址寄存器的值会影响下一条指令的有效地址。
排查方法:在模拟器里加一个“指令流水显示窗”,把当前指令、下一条指令、各关键寄存器值打在一行里,来回跑几步,看是哪个瞬间发生跳变。
4.2 DSKY按键“失灵”
按键无反应很可能不是UI的问题,而是你的DSKY状态机压根没进入接收输入模式。AGC软件并非任何时候都响应操作,它通过程序状态字来决定当前是否允许键盘输入。如果仿真时序里没让程序进入正确的模式,按键事件就被软件丢弃了。
解决思路是:先详细读目标飞行程序中DSKY输入处理的部分,确认它轮询的是哪些地址的I/O通道,然后再让UI层只往那些通道上写值,模拟按键冲断。我在第一次实现时直接把按键写到普通内存,结果永远没反应,折腾了一下午才发现通道区别。
4.3 中断风暴导致系统卡死
如果中断定时逻辑设置得比真实系统快,或者中断服务程序里没能及时重新使能中断,就会出现中断风暴,系统反复进入中断流程,正常任务永远得不到执行。真实AGC有一个“中断禁止”标志位,但不同版本细节略有差异。
排查要点:检查你的中断触发指令会不会清除标志,会不会执行RETURN指令跳回主程序。我建议在状态显示面板里直接可视化中断开关状态和当前“发生中事件”队列深度,这样能一眼定位问题。
这里整理一个快速排查速查表:
| 现象 | 可能原因 | 排查优先级 | 常用处理 |
|---|---|---|---|
| 跳转地址乱飞 | 指令译码时地址字段截断不对 | 高 | 检查15位掩码 |
| 算术结果溢出 | 未处理15位截断,导致进位污染 | 高 | 写专门的算术函数 |
| DSKY按键无响应 | I/O通道没有正确映射 | 中 | 检查内存映射表 |
| 卡在循环里 | 变址寄存器被意外改动 | 中 | 单步跟踪Index指令 |
| 中断风暴 | 中断优先级和使能位逻辑错 | 低 | 可视化中断状态 |
4.4 一点独家心得:先跑“着陆程序中断严格模式”
很多人做AGC仿真喜欢直接跑主流程,但我后来意识到一个更高效的路径:先找程序里依赖中断最重的部分(比如点火过程中对时间的精确处理),单独拎出来测试。把中断周期按真实运行时序设置,切断所有其他干扰,只验证“每次点火脉冲是不是精确落在期望的时刻”。
这种“极限片段测试”能帮你检验模拟器的时序是否正确,也会迫使你理解AGC如何用那点可怜的资源实现高精度控制。等这段跑通了,其他部分自然水到渠成。
5. 工具选型与扩展方向:这套东西还能怎么玩
5.1 从Python迁移到更高性能方案
如果只是复现核心逻辑并学习,Python足够。但如果你想追求“实时”体验——比如把仿真输出接上飞行仪表可视化面板、甚至联动MAME街机式显示,Python的帧率和时序稳定性可能不够。这时候可以考虑:
- C/Rust重写核心模拟器:性能高、能精确控制设备通道时序。
- JavaScript版本:方便在浏览器里直接跑,配合Canvas渲染DSKY和仪表,很方便做教学演示。
- FPGA方案:可以直接在硬件上“长出”一颗AGC,流畅度最高,但开发周期也最长。
我目前是把核心模拟器留在Python,用一种“协程+时间戳”的机制让它能容忍一定程度的非实时调度抖动。如果你要发布成在线Demo,建议核心换成Rust或C,然后走WebAssembly。
5.2 与“再现登月全流程”的联动
把Apollo-11项目标题升维到“再现登月全流程”,可以继续做这些事情:
- 导航方程模型:把AGC里调用的标准轨道数值算法(比如那种经典的迭代轨道积分器)用现代数学库对照实现,对比结果误差。
- 环境模型:结合高精度地月引力场模型、宇航员手动操作事件序列和发动机推力曲线,让仿真“环境”匹配真实任务剖面。
- 遥测可视化:把AGC寄存器里的关键状态实时绘制成曲线图,生成类似地面控制中心监测屏的画面。
我从这个方向延伸出来的经验是,不要贪多。每加入一个子系统,就要前面所有步骤的数据都能对上。先做好一个完整的指令级AGC仿真,再逐步加环境噪声,才是最稳的演进路线。
6. 实操复盘:如果重新来一遍,我会换什么做法
走过一遍从零到一实现“Apollo-11”核心仿真的路,有几个地方是我觉得可以优化的:
- 先从“指令手册”开始,而不是从“开源模拟器源码”开始。我一开始偷懒瞄了一眼别人的实现,结果被带偏了思路,后来改回手册才真正通透。指令手册自己读一遍,胜过看别人三遍代码。
- 中途没有定制专用的“调试观测器”。直接用print大法调试复杂的中断流程效率很低。后来专门写了一个事件日志模块,把每条指令、每个中断、每次DSKY按键都打上时间戳,问题定位快了一个数量级。
- DSKY交互协议值得更早动手。很多AGC逻辑是围绕按键状态切换的,如果不理解DSKY那套交互流程,你读飞行软件主循环时会一头雾水。先立起DSKY原型,再往里接计算逻辑,顺序其实更好。
如果现在有人想从零学这个项目,我的建议是:第一周只做两件事——读指令手册、跑通一个“你好世界”级别的程序(比如让一个寄存器加一后送到显示管),第二周开始接中断,第三周再碰DSKY。按这个节奏,大概一个月就能得到一个完整但没有多余装饰的可运行仿真。
“Apollo-11”这类项目最大的价值不在于是不是做出了一个完美的模拟器,而在于它逼着你思考:在极端资源约束下,前辈们是怎么把一个复杂的制导任务塞进一台15位机器里的。这份思考方式,放到今天的嵌入式系统、边缘计算、甚至前端性能优化里,都依然有其意义。我后面的计划是把这套AGC仿真接上“自动优先级调度”的对比实验——用现代RTOS的思路重新组织AGC的任务,看看能省下多少资源。那又会是另一篇能写很久的故事了。