把 Apollo-11 当项目名来写,懂行的人第一反应多半不是火箭本身,而是那台跟飞船绑在一起的机载制导计算机:2K 字的可擦写内存、36K 字的磁芯绳只读存储、15 位字长,主频连 1MHz 都不到。就是这台连今天一块智能手表都不如的计算设备,在半个多世纪前完成了人类第一次载人登月着陆的实时计算。这篇文章是我业余时间做的致敬项目总结:用模拟器重新实现了这套制导计算机的核心——指令集、一补数运算、实时调度器、DSKY 人机交互面板,并且完整跑通了着陆制导流程。没有玄学,全是能复现的代码和踩坑记录。适合对嵌入式系统、实时调度、复古计算感兴趣的人,也适合想知道"几十年前没有调试器的代码为什么能那么硬核"的普通开发者。
1. 先把项目标题拆开:Apollo-11 到底想表达什么
1.1 四个字符背后是一整套工程哲学
"Apollo-11"作为标题,指向的不只是一次任务,而是那个年代整个计算体系的缩影。当我要动手复刻时,先给自己定了三个问题:这套系统当年的存储和计算结构为什么长成这样?它在实时调度上做了哪些我们今天觉得理所当然的设计?如果把它搬到自己桌面上,要用什么现代手段才能还原它的行为?
这三个问题决定了项目的形态。不能只写一篇科普稿,也不能直接抄一个现成的开源模拟器,而是在理解原理之后从头实现一套可运行的最小系统。我最终的选择是:核心模拟器用 C 语言编写,把 CPU、存储、定时器和 DSKY 设备抽象成独立模块,再通过串口连接一块自己焊接的面板。这样既有底层细节,又能直接上手操作,而不是停留在"看图说话"的层面。
1.2 为什么选"模拟器 + 实体面板"而不是纯研究资料
光读资料和看图纸,很多东西是隔着一层纱的。比如一补数运算里的"负零"问题,你看着公式觉得懂了,真正写代码跑到边界值才发现坑在哪。又比如调度器的优先级关系,你读文档知道理论,但只有把中断和任务队列模拟出来,看到高优先级任务被低优先级任务顶掉的那一瞬间,那种"原来如此"的体会才真正落地。
所以我从一开始就定了一条原则:所有关键路径都要落实到能运行的代码和能触达的硬件。模拟器负责让老系统的行为可观测,实体面板负责让操作手感可感知。两者缺一个,整个项目就只是PPT。
2. 那台计算机到底有多"寒酸":核心硬件参数拆解
2.1 2K 内存 + 36K 磁芯绳 ROM:程序是"绣"出来的
先上一张我整理的核心参数表,后面所有设计决策都围绕它展开:
| 项目 | 当年系统的典型参数 |
|---|---|
| 可擦写内存(相当于今天的 RAM) | 2048 字 × 15 位,约合 3.75KB |
| 固定内存(相当于今天的 ROM) | 36864 字 × 15 位,约合 67.5KB |
| 物理字长 | 15 位数据 + 1 位奇偶校验位 |
| 主频 | 约 1MHz 级别 |
| 基本时间节拍 | 约 10ms 一次硬件中断 |
| 人机交互设备 | DSKY(键盘 + 数值显示 + 状态灯) |
你没看错,全部可用存储加起来不到 72KB,而且其中大部分还是只读的。更夸张的是,这部分只读存储不是半导体,而是用铁氧体磁芯加导线"编织"出来的。每条程序指令以"导线是否穿过磁芯"来区分 0 和 1:穿过去代表 1,绕过去代表 0。整段软件代码被物理地"绣"在存储器里,想改一个二进制位,就得把整块存储板拆下来重新绕线。
这就是为什么当年的软件开发流程和今天完全不同。今天你改一行代码、跑一次自动化测试就能部署;当时改一行代码意味着重新编织一块存储板,还要对每一根线有没有穿错做逐位校验。软件团队为此不得不把"一次做对"当成最高准则,把代码审查和验证做到近乎苛刻的地步。我在复刻时用代码里一个 36864 大小的常量数组模拟这块"绳 ROM",每次编译加载,都比读普通数组多一分敬畏。
2.2 15 位字长和一补数算术:你可能遇到的第一个坑
现代开发者习惯了 int32、float64 这些开箱即用的数据类型,但当年的制导计算机用的是 15 位数据位加 1 位奇偶校验位的物理字。数值运算采用一补数表示:负数不是"符号位加绝对值",而是正数按位取反。
这里有个非常经典的陷阱:一补数里存在"负零"。15 位全 1(0x7FFF)就是负零,它在数学上等于 0,但在机器里可能不等于正零。我在模拟器里第一次写比较逻辑时,因为没处理负零,导致某些导航中间量的判断结果出错,整整定位了一晚上。这个坑我在后面调试实录里还会详细展开。
另一个重要细节是溢出处理。一补数加法需要用"端回进位":如果最高位相加产生进位,要把这个进位再加回到最低位。我第一次实现时图省事,直接转成补数来算,结果边界值测试全部翻车。后面会给出正确的参考代码。
2.3 实时不是"快",而是"确定"
提到"实时系统",很多人第一反应是速度快,其实恰恰相反。当年这套系统的设计目标不是"一秒跑多少亿次",而是"在严格的时间窗口内,保证某个计算一定完成"。
它的做法是:以约 10ms 的硬件中断作为基本节拍,中断到来时扫描任务表,按优先级挑选一个任务执行。所有任务按紧急程度排队,高优先级任务可以插队,低优先级任务如果没有足够时间就往后压。这种设计后来被总结为"优先级驱动的协作式调度",今天你在各种 RTOS 里看到的任务队列,思路一脉相承。
这里面最著名的例子就是任务过载时的"程序警报"。当单位时间内涌入的任务超过计算机处理能力时,它不会像今天的电脑那样直接蓝屏重启,而是主动降级:丢弃低优先级任务,并在 DSKY 上亮出报警码。半个多世纪前那次着陆过程中确实出现过这类报警,地面指挥团队根据演练经验判断为可继续,才让任务得以顺利完成。可靠系统不保证永不犯错,但保证每一次犯错都有预案。
3. DSKY:半个世纪前的人机界面教科书
3.1 Verb/Noun 交互:把"填表"变成"对话"
DSKY 全称是 Display and Keyboard,中文语境里常叫"显示键盘单元"。它的正面结构很简单:一块五位数值显示区,三个两位数字的小窗口(分别显示当前程序号、动词、名词),下面是一排按键和几盏状态灯。宇航员通过选定动词(Verb)和名词(Noun),告诉计算机"做什么"以及"对哪个数据做"。
放在今天看,这有点像命令行里的"命令加参数",但在那个年代,面对笨重的宇航服手套和窄小的座舱,这套交互已经是最有远见的设计之一。它把复杂系统状态收敛成几个数字和灯光,机器会主动告诉操作员"我正在等什么输入""哪个状态不对",而不是让操作员面对一块黑屏盲猜。
我复刻面板时特意保留了这套交互逻辑,而不是简单做一个"点按钮出数字"的玩具。键盘矩阵扫描、数码管动态刷新、状态灯映射,这些细节叠加起来,才让整个模拟器系统有了"活"的感觉。
3.2 复刻面板的硬件取舍
实体面板我用的是一块很常见的单片机开发板,加上两组数码管和矩阵按键。显示部分采用动态扫描:单片机周期性刷新每一位,利用人眼视觉暂留让所有数字"同时"亮起。按键部分用矩阵扫描检测,按下后通过串口协议发给模拟器,模拟器处理完再把结果回传刷新显示。
这里有个经验之谈:千万别把面板逻辑和模拟器逻辑搅在一起。两块板各自独立,面板只当作一个"远程终端",模拟器才是真正的计算核心。这样调试崩溃时,你永远知道该去看哪一边,不用在两个工程之间反复横跳。
4. 从零开始把整套系统跑起来:实操全记录
4.1 方案选型:为什么核心模拟器用 C
选型的时候其实纠结过一段时间。FPGA 可以最贴近原始电路,但开发周期长,对个人业余项目不友好;Python 写起来快,但做位级模拟和时序控制不够直接;最后我选了 C——它既能精确表达位运算,又方便以后移植到单片机上。整个模拟器的目标就一个:在 PC 上按真实时序执行制导计算机的指令流,同时通过串口与面板通信。
整体架构分四层:存储层(RAM 数组加 ROM 常量数组)、CPU 层(取指、译码、执行)、设备层(定时器、DSKY 设备)、调度层(任务表加中断)。每层之间用接口隔离,任何一层都能单独替换。这个分层方式跟现代软件设计大同小异,但放到那个年代的系统上,你会格外体会到"模块化"不是口号,而是为了让系统能活下去。
4.2 存储与核心指令实现:一补数加法的正确姿势
存储层很简单,两个数组而已:
// 可擦写内存:2048 字,物理上 15 位数据 + 1 位校验 uint16_t erasable_memory[2048]; // 固定内存:36864 字,来自"绳 ROM"的位流 const uint16_t rope_rom[36864];麻烦的是运算。一补数加法要把最高位的进位循环回最低位,也就是经典的端回进位。参考实现如下,我建议直接收藏:
uint16_t ones_complement_add(uint16_t a, uint16_t b) { uint32_t sum = (uint32_t)a + b; // 先做普通二进制加法 sum = (sum & 0xFFFF) + (sum >> 16); // 端回进位 return (uint16_t)sum; }注意:一补数运算和补数运算不能混着写。混用会让所有边界值测试全部翻车,这是我在这个项目里踩得最疼的一个坑。
实际系统是 15 位数据位,端回进位的基数从 16 换成 15 即可。减法就是加负数的补数,乘法、除法要拆成移位和累加,同时还要处理溢出标志。我把这些运算逐条对照当年资料里的行为写测试用例,尤其是零、负零、最大值、最小值这些边界值,每一条都值得单独跑一遍。
4.3 实时调度器与"程序警报"复现
调度器是整套软件的灵魂。我实现了一个简化但行为一致的任务表:每个任务有优先级编号和"是否到期"标志,每次定时器中断到来,从最高优先级往下扫描,找到第一个到期任务执行。
void timer_tick(void) { for (int prio = 0; prio < MAX_PRIO; prio++) { if (tasks[prio].due) { tasks[prio].handler(); tasks[prio].due = 0; break; // 一拍先做最重要的一件事 } } }真实系统的"报警"条件就是任务表溢出:当连续多个节拍都有任务到期、队列积压超过阈值时,系统触发程序警报。我在模拟器里专门做了一个故障注入开关,可以人为让某个高频任务持续插入。几分钟内,面板上就会复现出当年那个经典的报警码(1201/1202 那一类)。第一次看到它亮起来的时候,我脑子里突然闪过一个念头:真正可靠的系统不是永不犯错,而是把每一种已知错误都变成了有预案的"状态"。
4.4 DSKY 通信协议:让桌面和面板对话
面板和模拟器之间走串口,协议是我自己定义的,简单但可靠:
| 方向 | 帧格式 |
|---|---|
| 模拟器 → 面板 | 帧头 0xAA + 目标显示区编号 + 5 位 BCD 数据 + 异或校验 |
| 面板 → 模拟器 | 帧头 0x55 + 按键编号 + 0x00 + 异或校验 |
每个按键按下后,面板把键值打包发出;模拟器收到后执行对应动作(比如切换程序、读取导航数据),再把新的显示内容回传。为了避免串口数据乱序,两边都维护了一个简单状态机,校验失败就丢弃当前帧重新同步。这套协议虽然原始,但非常稳定,我连续跑了十几个小时没有丢过一帧。
5. 调试实录:那些让我抓狂又过瘾的故障
5.1 一补数的"负零"差点让我怀疑人生
前面说过负零的问题,实际表现是:系统里某个导航中间变量在特定路径下被算成了 0x7FFF,我拿它跟 0x0000 做相等判断,结果不相等,整个判断逻辑就走岔了。排查过程很原始:在模拟器里打印所有关键寄存器的值,跑最小的复现用例,一步步缩小范围。找到根因后修复只花了几行代码,但定位的时间差不多一个晚上。
这类问题的教训是:做位级模拟器,第一件事就是列一张"所有非法编码和边界编码"的清单。它们不是 bug,是系统的固有语义。你越早把它们当成一等公民对待,后面就越省事。
5.2 任务优先级标反了:面板一直在报警
我最初实现调度器时,把优先级数值从小到大当成"从低到高",结果一运行就疯狂触发程序警报——因为系统总在优先处理最不重要的任务,重要任务永远积压。修起来不难,但它让我更深刻地理解了"优先级"背后的现实含义。在实时系统里,优先级就是生死状:它决定了在计算机最忙的时候,谁的计算被保证完成,谁的计算被牺牲。反了,整个系统的可靠性就反了。
5.3 时序对不齐:跑得"对"但显示"错"
有一段时间,模拟器计算结果是对的,但 DSKY 面板上的数字偶尔会跳动、闪烁。问题出在模拟器的"时间节拍"和面板的"刷新节拍"没有对齐:寄存器在中断边界前后被多次改写,面板恰好在这个窗口里采样,就读到了中间值。
解决办法是模拟真实的采样语义:规定面板只能在定时器中断的某个固定时刻读取寄存器,其他时间数据对面板不可见。改完之后,那种随机闪烁彻底消失了。接口的确定性,很多时候比接口的功能更重要。
5.4 常见问题速查表
把这段时间踩过的坑整理成一张表,方便后来人直接对照:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 面板一直在报警 | 优先级扫描方向反了 | 从高优先级往下扫 |
| 数字不停跳动 | 采样时序未对齐 | 固定中断边沿采样 |
| 减法结果总不对 | 一补数端回进位缺失 | 按端回进位实现 |
| 串口偶尔乱码 | 帧校验失败未重同步 | 状态机复位并重新同步 |
| 负零判断失误 | 未处理一补数负零 | 把负零归一为正零 |
6. 复刻之外:普通开发者能从老代码里学到的三件事
6.1 资源受限是最狠的架构师
2K 内存能做完的事,今天一个"hello world"的工程脚手架都不止这个体积。当每一个字都有成本时,你不会写"先留着以后用"的代码,不会为想象中的需求预留接口。这种约束逼出来的简洁,跟现代工程里漫无目的的重构和过度设计,形成了鲜明对比。我在写模拟器的时候,也刻意给自己定了约束:能不用动态内存就不用,能用位运算就不用浮点库。结果代码反而比一开始放开写要清晰得多。
6.2 可靠性不是加功能,是砍功能
当年团队对每一个要上机的功能都极度挑剔。能不加就不加,能简化就简化。反观今天很多项目,功能越堆越多,发布越来越急,但可靠性并没有成正比的提升。"砍功能"的勇气,比"加功能"的能力更难获得。同时,他们为每一种可能出错的情况预设了降级路径——哪怕出错,也要以已知的、操作员能理解的方式出错。这种思路值得直接搬进今天的系统设计里。
6.3 测试文化比测试工具重要
在芯片还没有成熟仿真器的年代,代码在编织进"绳 ROM"之前,只能靠软件模拟和人工审阅来验证。他们为此建立了极其严格的审查流程,甚至为了一套导航数据专门搭建模拟环境。今天的开发者有 CI、有单元测试框架、有静态分析工具,但核心仍然不是工具本身,而是"对每一次改动都怀有敬畏"的文化。工具会过时,这种文化不会。
项目做完那天深夜,我把面板摆在桌上,让模拟器从头跑一次完整的着陆制导流程——我在工程里给它起的场景代号就是 12-02。五位数码管上的数字一点点跳动,状态灯按预期亮起,那种感觉很奇妙:不是兴奋,而是一种"原来如此"的踏实。如果你也想做类似的事,我的建议很简单:先不要企图复刻整套系统,而是从最小闭环开始——模拟一条加法指令,接一盏 LED,让按下按键的那一瞬间灯亮起来。后面所有复杂的东西,都是这个最小闭环的放大。做技术致敬最好的方式,不是买一件纪念衫,而是亲手把它重新造一遍,然后感受它当年为什么要被造出来。