第一次把 Apollo-11 这个仓库从代码托管平台拉下来的时候,我盯着文件列表愣了好一会儿:满屏的.agc汇编源码、大量数字化的纸质文档,还有那些被反复提交和讨论的二进制文件。这不是一个常见的“教学项目”,它是一整个时代的软件遗产——当年指令舱和登月舱上那台导航计算机(AGC)里跑的飞行软件,被人从旧磁带和纸带上抢救出来,放进了公开仓库。
如果你对“航天软件到底怎么写出来的”充满好奇,或者你本身就是做嵌入式、做实时系统的开发者,那么这个项目值得你好好折腾一遍。它能带你回到那个没有编译器优化、没有IDE提示、没有多核调试器的年代,看看老一代工程师怎么用纯汇编在只有几十 KB 存储的机器上完成姿态控制、导航计算和登月着陆。这篇文章我会从项目结构、AGC 软件原理、实际复现步骤、踩坑经验,以及它对现代开发者的启发几个角度,把它们一次聊透。
1. 项目全貌:软件考古现场不在博物馆,而在你面前的终端里
1.1 这一堆汇编到底是什么
要理解 Apollo-11 项目,先要分清两个容易混淆的概念:一个是历史上去过月球的那次任务,另一个是这份被数字化保存下来的软件代码。我们谈的开源仓库,属于后者。
AGC 的硬件分成两套相互独立的计算机:指令舱计算机(常称为 CMC)负责地球到月球再到地球整个航线上的导航、姿态控制和推进系统管理;登月舱计算机(常称为 LGC)只在登月阶段工作,负责下降、着陆,以及从月面起飞后的上升和交会。两套计算机的软件完全独立编写,指令舱版本和登月舱版本加起来有数万行汇编代码。这些代码写在 15 位字长的计算机里,使用磁芯存储来保存临时数据,使用一种被称作“固定存储器”的特殊只读存储来保存程序。
固定存储器在工程上很有意思,它外形上像是编织的线网,制造的时候用“绕或不绕”的方式把程序固化进去,所以也被称为“绕线存储”。这种存储方式没有我们熟悉的地址线和数据线分离概念,读程序每走一步都需要经过复杂的中断和地址译码逻辑。Apollo-11 仓库里保留了所有这些指令的源码,还包括被还原的扫描版工程文档、操作说明、术语表。换句话说,它不只是几份代码文件,而是一间可以随时走进去的软件考古现场。
1.2 半个世纪之后,为什么还有人愿意啃这块硬骨头
有人会问:都这么多年过去了,再去读这些老汇编还有什么意义?我觉得意义不是怀旧,而是理解“极端约束下怎么把工程做成”。今天的嵌入式芯片动不动几百兆赫兹、几 MB 内存,但 AGC 只有大概几千字的可擦写内存和有限的固定内存,在这个容量里要塞下中断处理、实时调度、双精度数学、姿态控制、导航滤波和故障判定。这种密度在现代开发里几乎无法想象。
另一个吸引人的点是真实感。仓库里的代码不是教学示例,而是真正飞过的代码。你在模拟器里看着 DSKY 面板上的数字跳动,能体会到一件重要的事:软件可靠性不是靠事后打补丁,而是在设计早期就把资源预算、任务优先级、切换时机全部卡死。这对今天做嵌入式或者高并发服务的开发者同样很有启发。社区里那些长期活跃在 Apollo-11 项目里的贡献者,多数不是航天从业者,而是嵌入式老兵、开源爱好者和技术史研究者,大家围在一起啃老代码,本质上是想弄明白“当年那帮人到底牛在哪”。
2. 硬核拆解:那个年代是怎么让一台小内存计算机飞起来的
2.1 只有加法也能导航:AGC 的指令集与运行模型
AGC 的指令集极其精简。它大约只有十条基础指令,没有专门的乘除法指令,连多字节数据都需要软件组装。加法一次大概消耗几十微秒,放到今天慢得离谱,但在当时已经足够支撑登月任务的实时计算。
它采用的是固定字长,每个字 15 位,其中有一位用于奇偶校验,剩下的作为数据和地址。因为地址只有 12 位,所以程序员无法像今天一样随手塞进一个大地址空间,所有数据都要精心安排在可擦写存储的各个分区里。一个任务要是多占用了几十个数据字,就可能需要压缩另一个任务的临时变量区。项目源码里的注释到处能看到这种“省吃俭用”的痕迹,比如减少一次状态位翻转、复用某个暂存寄存器,这些都是那个时代的常规操作。
运行模型方面,AGC 没有现代操作系统意义上的进程和线程,它使用的是一个不断循环的主程序配合硬中断来驱动所有行为。机器内部有多个自动触发的计数器,定时产生中断,中断服务程序负责采集传感器数据、更新导航参数、响应用户键盘输入。所有工作被拆成很小的函数块,每个函数块只处理一个明确动作,然后再返回到主循环等待下一个中断。这种方式让整个系统变得异常容易证明和测试,因为你不需要考虑多线程调度带来的竞态和死锁。
2.2 解释器:在汇编层之上强行做一个高级语言
要说 AGC 软件里最让人惊叹的部分,不是基本指令集,而是它自带的“解释器”。为了让程序员避免在汇编里手写双精度乘法、向量加法、三角函数,当时的人直接在汇编指令集之上设计了一套解释器指令。解释器的每条指令本质上是一段子程序的入口,程序员只需要写类似“双精度加法”“两向量相加”这样的伪操作码,解释器会在运行时把这些伪操作码转换成真正的汇编操作。
这样做的好处非常明显:既节省了大量程序存储空间,又让代码看起来更接近结构化语言。许多现代人第一次看这类源码时都会惊讶,原来“在位机上做解释器”这件事,早在几十年前就已经被用在最高风险的载人飞行场景里了。解释器的缺点是额外消耗运行时间,但因为当时计算任务本身有严格的时间窗口,只要子程序在中断间隔内能完成,慢一点是可以接受的。
从我自己的经验来说,这个设计真正启发人的地方在于:它是在“性能、容量、可维护性”三者之间做出的漂亮权衡。今天很多嵌入式团队遇到内存爆了,第一时间想到换更大芯片,而不是像 AGC 那样重新审视实现层次并抽象出更适合问题域的解释层。当然两者场景不同,但这种思路本身值得保存。
2.3 DSKY:那个年代的人机交互出口
任何从这套软件中受益的技术爱好者,最后都会被两块面板吸引,这就是 DSKY,通常被译作“显示与键盘单元”。它并不是现代意义上的屏幕,而是一个能显示几十个数字的数码管面板和一组数字键盘。宇航员通过输入“动词/名词”组合来请求计算机执行动作。
举个例子,宇航员想查看某个时间的姿态信息,会依次按下几个数字键,告诉计算机“执行某个动词,作用在某个系统上”。每个动词代表一种操作请求,每个名词代表一种被操作的数据项。这听起来很像命令行,实际上它就是六十年代的终端界面,只不过它不需要键盘上的字母,只用十几个数字按键就能完成全部交互。
对现代开发者来说,DSKY 的交互逻辑是理解 AGC 软件结构的一把钥匙。你在源码里经常能看到针对动词和名词的分发函数,它们构成了一张功能入口表。新增功能的时候只要把新动词或名词挂进表中,就能在不大动架构的情况下接入新任务,这个思路和今天微服务网关的路由表设计有异曲同工之妙。
2.4 系统节拍与任务调度:一个极端保守的实时内核
AGC 的实时调度不是由人“按需触发”的,而是由硬件计数器自动节拍驱动的。机器内部维持一个节奏,定期触发中断,中断处理程序根据当前飞行阶段选择该执行哪一组任务。它管这个调度表叫“等待列表”。
等待列表本质上是一个按时间片组织的任务队列。比如某个任务需要在十毫秒后开始运行,它会把自己挂进等待列表里,列表按到期时间排序。中断每次触发时,系统检查列表头部有没有到期的任务,有就切过去执行,执行完继续回到主循环。这个机制天然防止了某个任务无限占用 CPU,因为时间片是固定的,任务运行时间被明文规定在注释里。
有意思的是,这种调度模型不提供动态优先级调整,也不提供抢占式多任务意义上的线程切换。它把所有事情切得极碎,在极短的固定周期里轮转一遍。正因为模型简单,工程师才能手工计算最坏情况下的执行时间,才能在发射前宣称“这套系统在计算时间上不会超限”。对比现在动不动因为嵌套事件或回调导致时序不确定的嵌入式框架,老代码里那种“要知道每一微秒花在哪里”的执着,确实值得深思。
3. 实操复现:把半个世纪前的软件在自己机器上跑起来
3.1 准备工作:拉取源码、确定工具链、别急着改代码
要把 Apollo-11 跑起来,第一步是在代码托管平台找到仓库并克隆下来。仓库体积不大,但目录结构比较特殊,里面有汇编源码、历史扫描件、构建脚本以及模拟器相关的代码。建议先把 README 完整读一遍,不要急着点开某个文件乱翻。
工具链方面,项目本身提供了跨平台的构建方案。如果你在主流操作系统上,按照文档安装好对应依赖即可。我要提醒的是,很多人第一次会在“换行符”和“编码格式”上踩坑,因为老文件保留了上世纪六十年代的字符习惯,有些脚本默认按现代标准解析文本,容易报错。最稳妥的方法是让 git 自动转换换行符,同时用 UTF-8 的宽松模式打开文档。
如果你只是想先看效果,可以直接下载社区已经编译好的模拟器二进制。如果和我一样喜欢从源码折腾,就准备一个带编译器和基础构建工具的环境,执行项目根目录下的构建命令,等待汇编器与模拟器生成。这里我给出一个通用的示意流程,具体命令以当前仓库文档为准:
# 以下为示意流程,不同平台请以项目文档为准 git clone <仓库地址> apollo-11 cd apollo-11 ./configure # 生成构建配置 make # 编译汇编器与模拟器 ./apollo -L <登月舱源码目录> # 启动模拟器并加载登月舱软件命令跑通之后,终端窗口应该会显示一个 DSKY 面板的字符画,上面闪烁着数字。走到这一步,你已经有了一台可以交互的“登月舱计算机”。
3.2 在模拟器里按键:第一次和 DSKY 打交道
模拟器跑起来后,屏幕上会有一个模拟面板和键盘区域。鼠标点击或键盘按键都可以触发 DSKY 按钮。我第一次跑的时候根本不知道按什么,后来尝试输入一组动词和名词组合,才看到数字变化。
举例来说,你想让计算机执行一次自检或显示特定数据,可以按“动词、指令结束、名词、指令结束”的顺序输入。由于不同飞行动作对应的动词号是固定的,模拟器文档或仓库里的按键说明表会给出常用编号。第一次运行建议直接查询文档中关于“初始启动”的按键组合,它会让你对整套交互逻辑有最直观的体会。
在模拟器里,你还能主动触发程序报警或者异常状态,观察计算机如何切换显示并等待宇航员进一步指令。这比单纯看代码更能理解故障处理流程:系统不是靠抛出异常来让用户处理,而是把故障状态显示在面板上,同时保留必要的备用计算路径。可以说是把“可恢复故障”设计到了硬件和软件的每一个角落里。
3.3 动手改一行代码:理解“改动是全局的代价”
只看代码不过瘾,我建议你找一个小的延时常量,把它的值改小一点,然后重新汇编、重新加载,观察模拟器行为有什么变化。比如源码里有等待某个发动机点燃时间间隔的变量,你把延时从 2 秒改成 1 秒,就会发现后续任务时间表整体前移,甚至会导致某些状态标志错乱。
这类实验最好不要在“真实飞行”里做,但模拟器里可以随便玩。修改的时候注意查找这个变量的引用位置,你会发现一个值往往被多个任务读,牵一发动全身。这侧面说明了为什么老工程师在编写代码时极其重视“单一入口、多任务引用需统一设计”。你可以试着用错误的方式把一行代码改坏,然后利用模拟器打印日志来定位异常,这个过程会让你对 AGC 的调试方式产生新的理解。
改完代码后,重新执行汇编与链接步骤即可。如果汇编器报出类似未知符号或存储溢出的错误,多半是常量值补偿没有做对。不要怕报错,它反而是你理解编译流程最好的老师。
3.4 给新手的汇编阅读入门
第一次打开.agc文件的人往往会被密密麻麻的大写标签与编号吓到。其实老汇编的规则非常朴素:一看标签、二看操作码、三看注释,基本就能还原逻辑。AGC 汇编中的标签通常是英文缩写加数字,比如某个中断处理模块、某个临时变量。注释详细出了名,连“这里为什么不开中断”“这个数为什么取 0.5”都写得明明白白。
阅读顺序上,先从程序入口附近的初始化代码开始,再到主循环和中断处理,最后再钻进解释器,会顺畅很多。如果你学过任何一门汇编语言,适应它只需要半天。如果你完全没有汇编经验,也不要紧,可以配合仓库里的框架文档,先搞懂 DSKY 输入如何映射到动词分发函数,把整体流程记下来,再细看每一个子程序。
4. 从 Apollo-11 到现代嵌入式:哪些经验还能直接复用
4.1 把内存占用当成预算来管理
现代嵌入式开发者经常抱怨栈溢出、堆碎片,但 AGC 的工程师从一开始就不允许这些问题存在。他们没有动态内存分配器,所有缓冲区、任务栈、全局变量在编写程序时就已经由人工分配并固定地址。每增加一个变量,都要检查它会不会挤占另一个任务的关键数据区。
这种“静态预算”思想放到今天的 RTOS 开发里依旧成立。许多稳定的安全关键系统仍然禁止动态分配,原因很简单:可预测性。如果你在做飞行器、医疗、汽车电子这类产品,AGC 的思路是:宁可让硬件少一点,也不让运行期行为不可预测。就算做普通嵌入式,把全局数据结构静态分配到固定区域,也能显著减少堆冲突带来的疑难 bug。
4.2 注释和文档文化:老代码比现代很多产品代码更像教科书
看 Apollo-11 源码时我很惭愧,它每条复杂指令都有说明,涉及计算流程的地方还会画表格描述状态转换。它把“人能读懂”当成了软件交付物的一部分,而不只是让编译器能读懂。许多段落甚至在解释为什么要这样写,比如“这个分支几乎没有可能到达,但在紧急情况下可以柳暗花明”。
现代软件开发强调 agile、强调快速迭代,结果往往代码合并后没人更新注释,接口文档逐渐脱离真相。我们不能把问题完全归结为节奏快,而是应当把“文档是对未来自己和同事的尊重”这句话刻在脑子里。你可以不写长篇分析,但每个关键状态机和风险点至少要留几行说明。
4.3 我们比它强,却也容易丢掉的东西
现在一个普通微控制器的性能是 AGC 的成千上万倍,我们用高级语言、用现成 SDK、用仿真调试器,开发效率确实远超从前。但另一个角度也真实存在:因为工具太强,很多人不再仔细推算最坏执行时间,不再关心内存布局,也忘了中断优先级反转和共享资源冲突这些古老问题。等到样机出现随机重启、偶发卡死,才回头去查,时间成本全搭进去了。
AGC 的开发环境简陋到连像样的编辑器都没有,所以工程师们只能把问题在设计阶段解决掉。这种“被迫的前置严谨”,今天完全可以靠规范、代码评审和静态分析工具做出来。技术变强不能牺牲对根本原则的敬畏,这是我重读老代码后最大的体会。
5. 常见问题与排查技巧实录
5.1 源码下载后无法正常编译
大多数人第一次都会遇到工具链版本或依赖缺失问题。仓库里的构建脚本往往假设你安装了比较完整的基础工具集,所以报错信息看起来五花八门。解决思路很简单:先把报错文本完整贴到搜索引擎里,再对比项目文档里的环境要求。常见的坑是缺少某个辅助库或者 make 版本太老,升级到稳定版本基本能解决。
另一个典型问题是换行符。老文件如果被某些代码托管平台自动转成 CRLF,汇编器可能认不出行结束符。处理办法是在克隆仓库时配置不自动转换换行符,或者转换后统一还原为 LF。这类问题最容易被误认为是源码损坏,实际上只是文本格式问题。
5.2 模拟器显示屏不刷新或者按键没反应
模拟器终端界面依赖终端字符渲染,如果你的终端宽度不够或者字体过于花哨,字符画经常错位。遇到这种情况,先放大终端窗口、切换等宽字体,再重启模拟器。键盘映射需要关注,有些版本默认小键盘区域才对应 DSKY 按键,笔记本没有小键盘时就需要打开相关映射开关。
如果按下按键后模拟器完全没反应,多半是焦点没有落在模拟器窗口上,或者按键事件被终端捕获了。尝试在模拟器启动后点一下画面再操作。还不行就查看模拟器日志,它会明确打印收到的按键编码,方便判断是哪一层丢掉了事件。
5.3 想继续二次开发却不知道从哪里入手
如果你读完了注释文档,想给项目加一个有趣的模拟功能,我建议先不要动核心飞行逻辑,而是从辅助功能入手。比如你自己写一个脚本,定期读取模拟器状态并打印到外部 CSV 文件,用来分析飞行轨迹。这样既能熟悉模拟器接口,又不会破坏原有代码。
加功能时保持老代码的注释风格,用同样的标签规范和缩写习惯,后续维护者会感谢你。另外,尽量在分支上改,不要直接推到主分支,保持历史完整性是这个项目的底线之一。
5.4 问题速查表
| 现象 | 常见原因 | 解决建议 |
|---|---|---|
| 汇编报错找不到文件 | 路径包含特殊字符或软链接异常 | 使用纯英文路径,重新配置工作目录 |
| 模拟器启动后黑屏 | 终端宽度不足或字体问题 | 放大终端,切到等宽字体 |
| 按键无反应 | 输入焦点不在模拟器窗口 | 点击面板区域,检查小键盘映射 |
| 加载源码后任务卡住 | 初始程序编号选择错误 | 对照文档输入正确的启动程序编号 |
| 修改常量后行为错乱 | 未考虑多任务引用 | 全局搜索该常量,逐处核对用途 |
最后再分享一点实际体会
我在把模拟器跑通、看到 DSKY 数字跳起来的那一刻,确实起了一身鸡皮疙瘩。不是因为画面有多炫酷,而是意识到自己正在运行的是那个年代人类最高难度软件工程的真实产物。它没有花哨的框架,没有几百个依赖包,只有几条简单指令、一个朴素的中断循环和一群小心翼翼写注释的工程师。读这个项目让我重新思考了很多今天被认为“理所当然”的东西,比如实时调度的确定性、内存预算的严肃性、注释作为沟通工具的价值。如果你也正被现代工具链的复杂性搞得有些疲惫,不妨拉下这套代码,亲手在模拟器里按几个键,那是另一种意义上的向前看。