我第一次接触 Apollo-11,是在一个讲复古计算项目的社区帖子下面。这名字按常理马上会让人联想到登月,但让我这个搞嵌入式开发的人蹲了整整两个周末的,是它背后那台制导计算机里的那套汇编软件。2048 个字的 RAM、36864 个字的只读存储、15 位的字长,这套配置连现代单片机都算不上豪华,却要跑导航、姿态控制、发动机管理、地面指令处理一整套路实时逻辑。这篇文章不聊历史,也不做科普演讲,我想纯粹以嵌入式开发者的视角,把 Apollo-11 这套代码从“听说过”带你到“能在模拟器上跑起来、还能自己改两行试试”的程度。
如果你对实时操作系统、汇编语言、资源极度受限环境下的软件设计感兴趣,或者单纯好奇几十年前的代码怎么写出光今依然合理的框架,这篇文章都适合你。我会把最难啃的存储切换、解释器、DSKY 输入原理拆开讲,也会把我在复现过程中踩过的最土的坑一一列出来。
1. 整体设计与思路拆解:2K 内存里怎么塞下一整套飞行大脑
1.1 为什么这件事值得拆开看
很多人看到 Apollo-11 的配置第一反应是“这也太简陋了”。但恰恰是这种简陋,把设计者的水平逼到了极致。今天写一个日志库可能就要 2K 内存,而当时整台航天器上负责制导、导航和控制的核心计算机,所有可改写的存储空间只有 2048 个字。每个字还不是常见的 8 位或 16 位,而是 15 位。没有位操作指令,没有浮点运算单元,没有硬件乘法器。
那它需要同时处理哪些事?简单列一下:
- 姿态控制:每秒多次读取惯性平台的角速度数据,滤噪、转换、输出喷气或力矩控制信号;
- 导航解算:结合燃油消耗、观星数据、惯性测量值,实时估算飞行器当前轨道参数;
- 发动机管理:负责点火、关机、推力中止等离散时序,必须精确到毫秒级;
- 人机交互:宇航员通过 DSKY 面板输入动词、名词命令,系统要立刻响应;
- 与地面通信:接收上行指令,打包遥测数据发回地面。
这些任务在现代实时系统里,也就是开几个线程、给好优先级就完事。但在 Apollo-11 那个年代,开发团队想的是怎么把一个 15 位的“大数字”拆成操作码和地址,怎么在有限的存储块之间切换还不弄丢返回地址,怎么把调度器做得足够简单可靠到可以载人上天。
我读这套源码的时候,刻意没有把它当“历史文物”膜拜,而是当成一个正经的嵌入式实时系统来读:任务队列怎么维护、中断优先级怎么组织、公共缓冲区怎么分配。越读越发现,今天嵌入式开发里很多习以为常的做法,六十年前已经很成体系了。
1.2 15 位字长带来的“一切从简”
先说说这个 15 位字长有多特殊。15 位有符号数范围是 -16384 到 16383,这连现代 int 的零头都不到。如果你直接按普通小数的思路去编码,那导航计算很快就会被精度问题吞掉。
当年的工程师怎么做?答案是用“分段缩放”的思想。同一个变量在程序的不同阶段,用不同的比例尺表示。比如角速度,在惯性平台数据里可能是“某个固定增量”;到了姿态控制输出端,又变成“一套脉宽指令”。这些换算过程全部要靠手写比例因子,不像现在,一个 float 就全部搞定。
同时,因为只有 15 位,程序里频繁用到定点数和整数,而不是浮点数。需要更高精度的地方,开发者就手动拼出“双精度”运算——用两个相连的内存字来表示一个长数,再靠一段段低层代码实现进位、借位、循环运算。这种做法在 Apollo-11 源码里很常见,读的时候一眼扫过去全是来回搬运累加器内容的指令,其实就是在模拟一个 30 位长整数。
再说一个细节:每个内存字除了 15 位数据,还有一个奇偶校验位。只读存储那边更夸张,直接用磁芯编织工艺烧录,断电也不丢。这套存储架构让我想到现在的 ECC 内存,但在当时,这些属于最基本的“可靠性保底”。对航天器来说,内存里一个 bit 翻转可能比算错一次姿态更致命。
1.3 存储银行切换:代码比寻址空间大,怎么办
这是读 Apollo-11 源码最容易卡住的地方。因为直接寻址空间只有 2048 个字,代码总量却有三万多字,程序根本不可能一次都装进可寻址空间。解决办法是“存储银行切换”,用今天的话说就是类似单片机的 Flash 分页。
整个只读存储被切成了若干个 bank,每个 bank 大小固定。处理器内部有一个或几个寄存器专门记录“当前正在访问哪个 bank”。当你需要执行某个跨 bank 的函数时,先设置 bank 切换寄存器,然后再跳转。这样才能读到目标代码。
听起来不难,难的是函数返回。普通处理器里调子程序,返回地址存在栈上;而 Apollo-11 几乎没有通用意义上的软件栈,返回地址只能放在固定寄存器或固定内存单元里。一旦发生 bank 切换,返回地址的保存、恢复、再切换,全部靠程序员约定好的一套规则:
- 调用前,把返回地址存到一个固定位置;
- 切换到目标 bank;
- 执行完要返回时,先恢复原来的 bank 设定;
- 再从返回地址继续跑。
这套手动“函数调用”机制,对现在写惯了高级语言的人来说非常反直觉。我第一次看到一堆 TC 指令(相当于无条件跳转)来回跳,以为程序是在循环里绕圈,后来才意识到那其实是函数调用链。真正吃透这一点之后,整个代码结构就会突然变得非常清晰。
2. 核心细节解析与实操要点:从指令集到 DSKY 人机交互
2.1 别被汇编吓到:AGC 指令集其实只有三四十条
很多人看到 Apollo-11 源码第一反应是“这也太陡峭了”。但真正常用的指令其实很少,官方指令集总共也就三四十条,分类还很规整:
- 内存访问类:把内存内容加载到累加器、把累加器存回内存、做加减法;
- 控制流类:无条件跳转、条件跳转、子程序调用;
- 索引类:通过索引寄存器实现类似间接寻址的效果;
- 输入输出类:从 DSKY 或传感器端口读数据、把结果写到输出通道;
- 特殊扩展指令:需要先执行一条 EXTEND 前缀指令才能使用的那批,包括移位、乘法、除法、自增自减等。
理解这套指令集最简单的方法,是先把它当成一台“增强版计算器”。举一个最小例子,求两个地址里的数之和,大概长这样:
CA 5 ; 地址 5 的内容装入累加器 A AD 10 ; 地址 10 的内容与 A 相加,结果留在 A TS 20 ; 把累加器的值写到地址 20注意这里没有通用寄存器堆的说法,累加器 A 就是核心工作寄存器,几乎所有数据运算都经过它。内存地址就是字地址,没有字节寻址一说,读代码的时候需要把脑子里的字节模型清空,换成“一字一地址”的模型。
这几十条指令虽然简单,但组合起来非常灵活。我强烈建议新手拿到源码后,先把指令表打印出来放在手边,遇到不认识的查一眼,而不是反复猜。省下的时间绝对值得。
2.2 解释器与双精度运算:代码换内存的艺术
如果说指令集是 Apolllo-11 的地基,那“解释器”就是它最亮眼的魔法层。当年的硬件没有 FPU,导航计算又需要大量向量、矩阵、双精度加减乘除。如果全部用那三四十条机器指令硬写,代码量会直接爆炸,2K RAM 和 36K 只读存储肯定塞不下。
开发团队的选择很机灵:他们在底层机器指令之上,又做了一层“解释器”。说白了,飞行软件里的很多语句并不是最终机器指令,而是一种解释器操作码。解释器程序常驻在只读存储里,运行时会逐条读取这些操作码,再翻译成一段段底层指令去执行。
这个设计很像今天的虚拟机或字节码解释器:
- 好处一:代码体积大幅减小。一个向量加操作码,在底层可能需要几十条指令,但从飞行程序的角度看,只是一个字节级操作数;
- 好处二:抽象层级提升了。编写导航程序的工程师不用整天惦记累加器和累加进位,只要按解释器的语义写操作码就行;
- 好处三:内存复用更容易。解释器的运行现场可以固定在几个存储单元里,不像传统函数调用那样需要大量栈空间。
代价也显而易见:解释器每条指令都比原生机器指令跑得慢。但在当时的航天场景下,导航计算频率并不算高,能稳定可靠地完成计算比追求峰值速度重要得多。
我读源代码时的一个判断技巧是:如果一段代码看起来不像普通汇编,里面也没有明显的 TC 跳转,而是密密麻麻的数字序列,那它大概率就是解释器操作码。这时不要拿机器指令表去套,而应该去找解释器指令表。这个方向一旦对了,代码马上就能读顺。
2.3 DSKY:宇航员手上的命令行
Apollo-11 的面板叫人机交互单元,习惯上叫 DSKY。它看起来像一台老式计算器:有数字键盘,有几个状态灯,有几个显示区,还有专门显示程序编号的窗口。宇航员通过它向飞行计算机下达指令、查看计算结果。
交互模型非常有启发性,采用的是“动词 + 名词 + 数值”的格式。比如操作员先按 VERB 键输入一个动词代码,代表“我想做什么”;再按 NOUN 键输入一个名词代码,代表“我想处理哪个对象”;接着输入具体的数字,最后按执行键确认。
这套模型放到今天看,就是最朴素的命令行界面。它比盲目按一堆功能键更统一,也更省内存:按键的种类和组合少,程序内部只需要维护一张“动词-名词”解释表,就能覆盖上百种操作。
在源码层面,DSKY 的输入会被当作一系列离散事件。每次按键,系统产生一个中断或者置一个标志位。调度器发现有人动过键盘,就会把“处理键盘输入”这个任务挂进执行队列。很多现代实时系统里所谓的高优先级“按键任务”,其实就是这套思路一脉相承下来的。
我的实操建议是:不要一上来就去读 DSKY 完整驱动,那通常会涉及底层寄存器映射和字符译码。先把“按键事件如何进入任务队列”的链路画出来,后面所有跟人机交互相关的代码都会豁然开朗。
2.4 实时任务分层:协作式调度和共享内存的真相
Apollo-11 严格来说不是一个抢占式实时操作系统,更接近“协作式多任务 + 时钟中断”的组合。主程序里有一个调度循环,由实时时钟定期触发。每个周期,调度器检查各个任务模块的入口标志,如果某个任务的启动条件满足,就把它排进待运行队列,然后顺序执行。
这跟现代 RTOS 最大的区别是:任务不会被时间片强制打断。一个任务只要开始跑,就要自己保证在合理的时长内结束;如果需要等待事件,它会显式地把自己的状态挂起,把 CPU 让出来。这种设计省掉了大量上下文切换开销,在只有 2K RAM 的环境下几乎是唯一可行的路。
代价是共享内存的“危险”被放大了。因为没有任务栈,很多任务用的都是固定的公共工作区。A 任务刚算完一半的中间结果,如果不及时保存,B 任务一进来就可能把它冲掉。所以代码里有大量看似繁琐的“保存现场”和“恢复现场”片段,都是协作式调度下必须的自我保护。
读代码时的关键抓手是任务注册表和标志位。源码里会有很多地址专门用于记录“任务是否启动”“事件是否发生”“上一次处理到哪一步”。模拟器调试时,我最常做的就是把这些标志位内存位置打印出来,看它们在一个调度周期里的变化。这比盯着 PC 指针跳来跳去高效得多。
3. 实操过程与核心环节实现:亲手跑起一套 Apollo-11
3.1 准备材料与构建环境
要把 Apollo-11 这套代码真正跑起来,需要三样东西:源码原文、一个模拟器、以及足够耐心。源码目前有很多社区维护的转录版本,在公开代码托管平台上都能找到。模拟器也有开源实现,专门模拟当年的处理器、存储和 DSKY 面板。
准备工作并不复杂:
- 第一步,确定你要看哪一个版本。Apollo-11 其实有两套主要软件,分别对应指令舱和登月舱,代码并不完全相同。我建议新手先从指令舱版本开始,因为整体任务更线性,调度关系更容易抓;
- 第二步,找一个编译好或者能快速从源码构建的模拟器。大部分模拟器在普通开发机上编译很轻,几分钟就能起来;
- 第三步,准备参考资料。至少要有指令集手册和解释器操作码表,有符号表更好。别看网上资料很多,没有这两份文档,读代码就像看天书。
我实际构建环境时踩过的最大坑是“工具链版本不一致”。部分转录源码依赖老式汇编器,用现代交叉汇编器编译时会报很多假错。后来我干脆不再自己编译,而是直接加载社区提供的 ROM 镜像文件,把精力全放在分析运行行为上。这是最省力的方式。
3.2 5 分钟跑通一次最小“登月计算”
我不管你用哪个模拟器,下面这套流程应该都能对应上:
- 加载 AGC 的 ROM 镜像和初始内存镜像,相当于给这台“古代计算机”通电;
- 启动模拟器,让它按正常时钟节奏运行;
- 通过模拟器自带的控制台观察寄存器状态,特别关注累加器 A、程序计数器和当前 bank 号;
- 在模拟器中找到 DSKY 面板的交互入口,模拟一次键盘输入。
我第一次跑起来的时候,最兴奋的时刻就是看到 DSKY 窗口里出现程序编号,然后数字随着指令执行开始跳动。那一刻你会意识到,这不是一个普通的“软件启动”,而是一个几万个字的老程序在完整地、按照真实时序地运转。如果你打开的是带实时时钟对齐的模拟器,它甚至还会按当年的 CPU 速率去执行,慢得出奇又真实得可怕。
跑通后的第一个练习,我建议你试着读当前正在执行的程序段。把 program counter 和 bank 寄存器对应起来,到源码表里查一眼当前位置的注释,基本就能知道这台“飞行器”现在在干哪一步。这个动作虽然简单,但能帮你建立代码和运行时行为之间的映射。
3.3 通过 DSKY 干预运行:亲手发一条“请求”
跑通基础启动后,第二件事就是模拟人机交互。别先急着改代码,先用最原始的方式发一条 DSKY 命令,看看系统怎么响应。
操作流程大致是这样的:
- 暂停模拟器,让系统停在某个稳定状态;
- 在模拟器的 DSKY 面板模拟区,按下动词键、名词键,然后输入一串数字;
- 继续运行,观察程序编号切换、显示区数据变化;
- 在仿真控制台里输出“键盘事件已进入任务队列”的标志;
- 跟着这个标志一路查,看系统是从哪个模块开始处理这条指令的。
我建议第一次选一个最简单的命令,比如“显示当前内存单元数量”之类不带实际物理意义的数据请求。这类命令不会触发发动机时序,安全,而且代码路径很短,特别适合新手做断点分析。
做这个实验的时候,最关键的是体会“事件驱动”的感觉:键盘按下去,并不会立刻改变某个变量,而是把一个事件挂进队列,等待调度器处理。很多第一次接触的人会在这一步以为模拟器坏了,其实只是调度时序还没走到那条任务链上。
3.4 监控和验证:把“遥测参数”拉出来分析
真正让我对 Apollo-11 代码产生强烈好感的,是模拟器里“变量监控”这个功能。你可以在运行过程中,定期打印指定内存地址的值,然后把数据导出来画成曲线。这基本就是在地面上做遥测分析。
我自己试过最简单的一个实验:监控推进剂相关的某个累加器值,跑一小段导航计算,然后导出 CSV,在本地用绘图工具画一条下降曲线。整个过程不超过十分钟,但我直观地看到代码在“算数”,而不是一个个孤立指令在蹦跶。
操作上,你只需要在模拟器里设置监视区间和导出格式:
monitor addr 0x1234 count 500 interval 20 dump csv /tmp/agc_trace.csv不同模拟器的命令词可能不一样,但思路完全一致:挑一个连着变化的地址,设置采样周期,跑一段,导出,绘图。如果你选择的地址对应的是一个有意义的状态量,比如速度残差或姿态误差,你会看到非常漂亮的收敛曲线。这是证券观感上最爽的一步,而且能帮你验证对代码逻辑的猜测是否正确。
4. 常见问题与排查技巧实录:代码考古过程中踩过的坑
4.1 任务不启动?先别急着查代码,查调度节拍
我最早遇到的一个问题是:在模拟器上通过 DSKY 输入了程序编号,结果屏幕上什么都没有,程序编号窗口也不变。我一度以为指令格式读错了,反复查源码确认按键解析没问题,但还是没反应。
后来才发现,问题出在实时时钟中断上。模拟器默认不给我开精确的时钟节拍,导致主调度循环根本没有被驱动。整个系统等于停在一个等时钟中断的死等里,自然不会有任务启动。
排查方法很简单:先往输出通道打一句状态,或者直接看模拟器是否周期性打印“时钟中断”。我当时打开模拟器的时钟日志后,立刻就看到中断从未触发。把时钟频率设成当年标称值,系统马上活跃起来。
这个坑对我最大的教训是:Apollo-11 的实时行为严重依赖时钟中断。一切任务启动、队列推进,都以时钟节拍为心跳。不要用普通单片机的“延时轮询”思维去理解它。
4.2 符号地址对不上?多半是版本或 bank 号问题
读源码的时候,你一定会在文档里看到类似“某个寄存器位于某地址、这个函数入口在某地址”的信息。当你兴冲冲跑到源码里一找,会发现完全对不上。
这种情况大概率是两个原因之一:
- 一是你拿的是另一个舱段的代码。指令舱和登月舱虽然同属 Apollo-11,但它们的程序编号、存储布局、任务模块都不完全一样。文档里写的是登月舱的地址,你却拿指令舱的源码在查,那是必然找不到;
- 二是 bank 号不对。寄存器地址还好,函数入口地址往往分布在不同的只读存储 bank 里。查符号表时不能只看地址后半段,还要看它属于哪个 bank。很多源码里的符号都会带 Bank 后缀或者单独标注 bank 号。
我的处理方式很朴素:先把符号表导出来,过滤出我关心的函数名,再对照 bank 字段找到正确的代码位置。一开始我以为自己在互相矛盾,后来才明白是 bank 切换把我的视野局限住了。
4.3 汇编器版本坑:别硬刚,用现成镜像最省力
转录源码里经常混着老汇编器的产物,和现在的交叉汇编器有兼容性问题。有些扩展指令需要特殊伪指令前缀,有些操作数写法现在完全不认。我一开始天真地想,反正只是汇编,语法应该差不多,结果编译报了几百个错,心态差点崩掉。
后来我换了个思路:不再追求“从源码编译出镜像”,而是直接找社区验证过的现成 ROM 镜像文件,加载进模拟器分析。这样既保证了行为与历史版本一致,又省掉了和汇编器的搏斗时间。
如果你也需要修改代码做实验,我建议不要改原版转录文件,而是把需要验证的那一小段单独摘出来,放到一个最小汇编工程里,用现代交叉汇编器编译后手动拼回镜像。这个流程有点绕,但能保住分析基准不被工具链污染。
4.4 排错速查表
| 现象 | 可能原因 | 快速检查方式 |
|---|---|---|
| 程序编号没切换 | 时钟中断未开启或频率不匹配 | 打开模拟器时钟日志,看有没有周期中断产生 |
| 键盘输入无响应 | 事件队列未挂起或任务被更高优先级任务占住 | 打印任务标志位地址,观察事件是否进入队列 |
| 符号地址在源码中找不到 | 版本选错或 bank 号看漏 | 检查符号表 bank 字段,确认链接的舱段版本 |
| 编译疯狂报错 | 源码使用老汇编器指令写法 | 直接用现成 ROM 镜像,不要执着于重新编译 |
| 计算结果对不上预期 | 定点数或双精度比例尺理解错 | 找到变量注释里的比例因子,换算成真实物理量 |
| 运行到某处突然跳飞 | 返回地址在 bank 切换时没保存好 | 检查函数调用前的返回地址寄存器保存逻辑 |
这张表基本覆盖了我复现过程中八成以上的问题。如果你遇到其他怪问题,我的建议是先降低节奏,把时钟对齐、银行号、事件标志位这三样确认一遍。它们出错的表现千奇百怪,但根源往往就是这么几处。
我在实际操作中最有感触的一刻,是终于把 DSKY 上的一次按键和内存里一个标志位的翻转对应起来的时候。那种感觉就像在考古现场找到了一块完整的瓷器碎片,能顺着纹理想象出当年整台系统运转的样子。
对于想继续深挖的朋友,我还有一个建议:试着四面时钟停掉,亲手往标志位地址里写值,手动激活一个任务。绕过键盘、绕过事件队列,直达调度器底层,你才能真正体会到什么叫“协作式多任务”。这套代码虽然老,但它把“在极有限资源下把实时行为做正确”这件事讲得很透彻。
如果你手头有开发板,也可以考虑把这套模拟器移植到一块带彩色屏的板子上去,做一个复古飞行控制面板摆件。既能练手,又能让更多人直接感受到那个时代的软件魅力。