1. 项目缘起与核心定位
Apollo-11 这个标题,第一次看到的人多半会愣一下——它既像一段历史代号,又像某个技术项目的内部命名。实际上,在开源软件和计算机历史圈子里,Apollo-11 指向的是一个非常具体的东西:阿波罗11号制导计算机(AGC)指令舱与登月舱软件的完整源代码托管项目。这个项目把当年让人类第一次踏上月球的飞行软件,以公开、可读、可编译的形式重新整理出来,放到现代开发者面前。
它解决的核心问题很直接:那段代码原本是写在磁芯存储器里的,用的是自定义汇编语言,运行在算力和内存都极其有限的机器上。几十年过去,原始文档散落、硬件早已停产,普通人根本无从下手去理解它到底怎么工作。Apollo-11 项目做的事情,就是把这些代码从历史档案里“捞”出来,做数字化整理、注释、语法高亮,甚至提供模拟器让你能在现代电脑上把它跑起来。
适合谁来参考?三类人最值得花时间:一是对嵌入式系统和实时控制感兴趣的工程师,想看看在没有操作系统、没有高级语言、内存以 KB 计算的年代,软件是怎么做到万无一失的;二是计算机历史爱好者,想亲手翻一翻那段改变人类进程的代码长什么样;三是教学场景下的老师或学生,拿它当“极限约束下的软件工程”案例,比任何教科书都生动。
我最初接触这个项目,是因为在做一个小型实时控制器的固件优化,总觉得现代工具链把很多底层细节藏得太深。翻完 AGC 的代码结构之后,很多关于“资源怎么省、优先级怎么排、错误怎么兜底”的思路,突然就通了。这也是我写这篇博文的出发点:不把它当历史文物供着,而是当一份可以拆解、可以借鉴的工程样本。
2. 代码仓库的整体结构与设计思路
2.1 为什么用汇编而不是高级语言
Apollo-11 项目里的代码,全部是 AGC 汇编语言。你打开任何一个.agc文件,看到的都是类似TC、CCS、XCH这样的助记符,没有函数、没有类、没有标准库。这不是当年的人不想用高级语言,而是硬件条件根本不允许。
AGC 的内存分两部分:核心存储(Core Rope)和 erasable memory。核心存储是只读的,容量大约 36K 字,每个字 15 位(其中 1 位奇偶校验,14 位有效数据)。erasable memory 更小,只有 2K 字左右。你拿现代的眼光看,这点空间连一个像样的 JSON 解析器都放不下。所以每一行代码都必须精打细算,每一个跳转都要考虑周期数。
项目在整理时保留了这种原始形态,没有试图把它翻译成 C 或 Python。这个选择非常关键:一旦翻译,时序关系、内存布局、中断响应全部会失真。保留汇编,等于保留了那段代码的“物理真实性”。你在阅读时能直接感受到,为什么某个变量要放在固定地址、为什么某个子程序要用 fall-through 而不是跳转——这些都是被内存和周期逼出来的设计。
2.2 仓库目录的划分逻辑
整个仓库的目录结构并不复杂,但每一层都有明确意图。根目录下通常能看到几个核心文件夹:Comanche系列对应指令舱代码,Luminary系列对应登月舱代码,另外还有Validation、Test之类的辅助目录。
Comanche 和 Luminary 的命名来自当年的项目代号,不是随便起的。指令舱和登月舱的软件虽然共享很多底层例程,但任务剖面完全不同:指令舱要负责导航、姿态控制、返回地球;登月舱要负责下降、着陆、上升。两者对实时性的要求、对故障的容忍度都不一样。所以代码被拆成两个独立构建目标,各自有独立的入口和中断向量表。
这种“同一套底层,不同任务剖面”的组织方式,在现代嵌入式项目里也很常见。比如同一个 MCU 平台,做电机控制和做传感器采集,底层驱动可以复用,但应用层调度策略必须分开。Apollo-11 项目把这个思路用最原始的方式呈现出来,反而更容易看清本质。
2.3 构建系统与模拟器的配合
项目里包含了一套构建脚本,用来把汇编源码编译成 AGC 可执行格式。这套脚本通常依赖一个叫yaYUL的汇编器,它是后来社区根据当年文档重新实现的。yaYUL 不是官方工具,但它的行为经过大量比对,能正确生成核心存储的二进制镜像。
编译出来的镜像不能直接在现代 CPU 上跑,所以项目还配套了模拟器。模拟器负责模拟 AGC 的指令周期、寄存器行为、内存读写和中断响应。你在本地跑起来之后,可以看到代码在“虚拟 AGC”上一步步执行,甚至能接入一个简单的 DSKY(显示键盘)模拟界面,输入指令、查看寄存器状态。
这个构建加模拟的组合,是整个项目最实用的部分。没有它,代码就只是一堆文本;有了它,你才能做实验:改一个参数、加一条指令、观察时序变化。我建议任何想深入的人,都先把模拟器跑通,再回头读代码,效率会高很多。
3. 核心细节解析与实操要点
3.1 内存布局与变量命名规则
AGC 汇编里没有“变量声明”这个概念。所有 erasable memory 的地址都是硬编码的,通常用ERASE或EQUALS这样的伪指令来分配。你在代码里会看到大量类似TEMPFLG、RADMOD、PIPAX这样的符号,它们背后都是固定地址。
这种做法的好处是:任何时刻,你都知道某个数据在内存的哪个位置,调试时可以直接看地址。坏处也很明显:地址冲突、覆盖、越界,全靠人工检查。项目在整理时保留了一套命名约定,比如以T开头的多半是临时变量,以R开头的多半是寄存器映射,以C开头的多半是计数器。这套约定不是强制的,但读代码时能帮你快速判断一个符号的用途。
注意:不要试图去“优化”这些地址分配。AGC 的 erasable memory 只有 2K 字,很多地址被多个子程序分时复用。你一旦改动,可能破坏某个中断服务程序的隐式约定,导致模拟器里跑飞。
3.2 中断处理与优先级设计
AGC 的中断机制非常原始,没有现代意义上的中断控制器。它靠一套“中断向量”加“优先级排队”的逻辑来工作。高优先级的中断可以打断低优先级的处理,但低优先级不能嵌套高优先级。项目代码里,每个中断服务程序都很短,通常只做几件事:保存现场、置标志、跳回主循环。
这种设计的原因是:AGC 的周期数极其宝贵,中断服务程序如果太长,会挤占导航和姿态控制的计算时间。所以你会看到,很多实际处理逻辑并不在中断里完成,而是被“标记”下来,等主循环轮到的时候再处理。这就是典型的“前后台”架构,前台是中断,后台是主循环。
我在做现代嵌入式项目时,也经常用这个思路:中断里只做最紧急的事,比如清标志、存数据,复杂计算全部丢给主循环。Apollo-11 的代码把这个原则执行到了极致,值得反复看。
3.3 数值表示与溢出处理
AGC 用的是 15 位字长,其中 14 位有效数据。它支持定点数和一种特殊的“双精度”表示。很多计算涉及角度、速度、加速度,单位不是现代的标准单位,而是 AGC 自己的“脉冲”或“计数”。项目里保留了一套转换例程,用来在内部表示和物理量之间换算。
溢出处理是另一个重点。AGC 没有硬件浮点,所有运算都是定点或整数。一旦溢出,结果会出错,而且不会自动报错。所以代码里到处是溢出检查:先判断符号位,再判断高位,最后才做运算。这种“先检查后运算”的模式,在现代安全关键系统里依然是标准做法。
提示:读代码时,遇到
OVF或CHECKOVF之类的标签,不要跳过。它们往往是整个子程序最核心的安全逻辑。
4. 实操过程与核心环节实现
4.1 本地环境搭建与模拟器编译
要在本地跑起来,第一步是准备工具链。项目通常提供两种方式:一种是直接用预编译的模拟器二进制,另一种是从源码编译。我建议从源码编译,因为你能顺便看到模拟器是怎么实现指令周期的。
以常见的 Linux 环境为例,大致流程如下:
# 克隆仓库(这里用通用描述,实际地址以项目为准) git clone <apollo-11-repo-url> cd apollo-11 # 安装依赖:汇编器、模拟器构建工具 # 通常需要 C 编译器、make、以及 yaYUL 的源码 make yaYUL make simulator编译完成后,你会得到yaYUL和agc_sim两个可执行文件。接下来是编译源码:
# 进入 Comanche 或 Luminary 目录 cd Comanche # 用 yaYUL 汇编主程序 ../yaYUL MAIN.agc > MAIN.bin生成的MAIN.bin就是核心存储镜像。然后启动模拟器:
../agc_sim -f MAIN.bin模拟器启动后,通常会显示一个简单的命令行界面,或者一个 DSKY 模拟窗口。你可以输入V35E之类的指令,查看当前状态。
4.2 关键参数计算:周期与内存的权衡
AGC 的指令周期大约是 11.7 微秒(具体取决于指令类型)。这意味着每秒最多执行约 85,000 条指令。听起来不少,但导航计算涉及大量三角函数和矩阵运算,实际可用余量很小。
项目里有一个经典的例子:姿态控制子程序PITCH和YAW的计算,需要在 2 毫秒内完成一次迭代。2 毫秒等于约 170 个指令周期。在这 170 个周期里,要完成读取传感器、计算误差、输出控制量、检查溢出。代码里用了大量查表和近似,就是为了把周期数压下来。
我在模拟器里做过一个实验:把某个查表改成实时计算,结果迭代时间从 1.8 毫秒涨到 3.2 毫秒,直接导致控制周期失稳。这个实验让我彻底理解了“为什么当年要用查表”——不是偷懒,是物理约束。
4.3 从源码到运行:一次完整的实验记录
我选了一个最简单的子程序做实验:DISPLAY例程,负责把数据送到 DSKY 显示。这个例程不涉及复杂计算,但涉及 I/O 操作和中断配合。
步骤一:在模拟器里加载 Luminary 镜像,启动后进入待机状态。 步骤二:用调试命令查看DISPLAY的入口地址,设置断点。 步骤三:手动触发一次显示更新,观察寄存器变化。 步骤四:修改显示缓冲区的一个值,重新运行,看 DSKY 模拟界面是否变化。
整个过程大概花了半小时,但收获很大:我看到了 I/O 指令如何与中断标志配合,看到了显示刷新如何被主循环调度。这些细节在纯读代码时很容易忽略,只有跑起来才会注意到。
注意:模拟器的时序和真实 AGC 有细微差别,不要用它来验证极端实时性。它的价值在于理解逻辑,不在于精确复现硬件。
5. 常见问题与排查技巧实录
5.1 汇编器报错:符号未定义或地址冲突
这是新手最常遇到的问题。yaYUL 在汇编时,如果遇到未定义的符号,会直接报错并停止。常见原因有三个:一是符号拼写错误,AGC 汇编对大小写敏感;二是符号定义在另一个文件里,但没有正确包含;三是地址冲突,两个变量被分配到同一个 erasable 地址。
排查方法:先看报错行号,确认符号名。然后在仓库里全局搜索这个符号,看它在哪里定义。如果是地址冲突,检查ERASE伪指令的分配范围,确保没有重叠。
5.2 模拟器跑飞:PC 跳到非法地址
模拟器跑飞通常表现为:程序计数器突然跳到一个没有代码的地址,或者陷入死循环。原因可能是中断向量配置错误、堆栈溢出、或者某个跳转指令的目标地址算错了。
我的排查习惯是:先在模拟器里打开指令跟踪,看最后几条执行的指令。然后检查跳转前的条件判断,看是不是某个标志位被意外置位。AGC 的跳转指令很多是条件跳转,条件判断依赖累加器的值,而累加器的值又依赖前一条指令。所以往往要往前追好几步才能找到根因。
5.3 显示不更新:I/O 通道与中断标志
DSKY 显示不更新,通常不是显示例程本身的问题,而是 I/O 通道没被正确选中,或者中断标志没被清除。AGC 的 I/O 是通道映射的,每个外设对应一个通道号。显示更新前,必须先选中对应通道,然后写数据,最后清标志。
我遇到过一次:显示例程逻辑完全正确,但 DSKY 就是不动。后来发现是中断标志在别的地方被意外清除了,导致显示更新请求被吞掉。这种问题在单步调试时很难发现,只有连续运行才会暴露。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 汇编报错:符号未定义 | 拼写错误、未包含定义文件 | 全局搜索符号,检查包含关系 |
| 汇编报错:地址冲突 | ERASE 分配重叠 | 检查 erasable 地址分配表 |
| 模拟器跑飞 | 中断向量错误、堆栈溢出 | 打开指令跟踪,回溯跳转条件 |
| 显示不更新 | I/O 通道未选中、中断标志被清 | 检查通道选择指令和标志清除位置 |
| 计算结果偏差大 | 溢出未检查、单位换算错误 | 检查溢出逻辑和转换例程 |
| 模拟器启动失败 | 镜像格式不对、依赖缺失 | 确认 yaYUL 版本和模拟器参数 |
提示:遇到问题时,先把模拟器的日志级别调到最高,很多错误在日志里都有线索。不要一上来就改代码,先确认是环境问题还是逻辑问题。
6. 从 Apollo-11 项目里能带走什么
这个项目最打动我的地方,不是它有多“古老”,而是它把工程约束下的决策过程完整保留了下来。现代开发里,我们很少需要为 2K 内存写代码,但“资源有限时怎么排优先级”“怎么在中断和主循环之间分配任务”“怎么用查表换时间”这些思路,在任何嵌入式场景里都适用。
我后来在自己的项目里借鉴了 AGC 的“前后台”架构:中断只做标志置位,主循环做状态机调度。代码量没增加多少,但实时性和可维护性明显提升。另外,AGC 的溢出检查习惯也让我改掉了“先算再说”的毛病,现在写任何数值计算,都会先想边界条件。
如果你只想快速体验一下,建议直接跑模拟器,输入几条 DSKY 指令,感受一下当年的交互方式。如果你想深入,就从Luminary的MAIN.agc开始读,顺着中断向量表往下追,大概两三天能摸清主循环的骨架。再往后,就是无穷无尽的细节了——但那些细节,才是这段代码真正值钱的地方。