1. 从一行代码到月球:Apollo-11 制导计算机到底藏着什么
1969年7月20日,阿波罗11号的登月舱鹰号在燃料只剩不到20秒余量时触地。这个细节被反复讲述,但很少有人追问:那台只有约2KB RAM、36KB ROM的机载计算机,是怎么在8分钟内完成从轨道下降到月面着陆的全部制导计算的?更关键的是,它在过载报警、雷达数据冲突、燃料临界的三重压力下,为什么没有死机?
我最初接触Apollo-11制导计算机(AGC)的代码,是因为一个很实际的问题:现代嵌入式系统在资源受限场景下的容错设计,到底能从半个世纪前的这套系统里抄到什么作业。AGC的指令周期是11.7微秒,主频约1MHz,内存用磁芯存储,程序用绳存储器固化。听起来像博物馆里的东西,但它的设计哲学——尤其是优先级调度和冗余校验——放到今天的实时系统里依然成立。
这篇文章适合几类人看:对嵌入式实时系统感兴趣的开发者、做高可靠性系统设计的工程师、以及单纯对“人类怎么用这么点资源干成这么大事”好奇的技术爱好者。我会从AGC的硬件约束讲起,拆解它的执行模型、中断机制、核心制导算法,再落到一个可复现的模拟环境搭建,最后分享我在复现过程中踩过的几个坑。所有内容基于公开的AGC技术文档和可获取的仿真资源,不涉及任何敏感领域。
先说一个反直觉的结论:AGC最厉害的地方不是算得快,而是它知道什么时候该“不算”。在登月最后阶段,它主动丢弃了 rendezvous radar 的数据,因为那些数据在当时的情境下会干扰主任务。这个决策逻辑,比任何算法都值得琢磨。
2. AGC的硬件底子:2KB内存怎么撑起登月
2.1 磁芯存储与绳存储的分工逻辑
AGC的内存分两块:一块是磁芯存储(Core Rope),可读写,约2048个16位字;另一块是绳存储(Rope Memory),只读,约36864个16位字。磁芯存储用来放变量、临时计算结果和可修改的数据;绳存储固化程序代码和常量。
为什么这么分?因为磁芯存储怕断电,但读写灵活;绳存储不怕断电,但内容在制造时就“编织”进去了,改不了。这种分工的本质是:把不变的东西固化,把变的东西放在可写介质里。现代嵌入式系统里的Flash+RAM架构,逻辑上是一样的,只是规模和速度不同。
绳存储的制造过程很有意思:程序里的0和1对应磁环里导线是穿过还是绕过。一个程序要“编织”几个月,由一组工人手工完成。这意味着AGC的程序在发射前就完全确定了,没有任何在线更新的可能。这种约束倒逼出一个设计原则:程序必须一次做对,所有边界条件必须在设计阶段穷举。
注意:绳存储的不可修改性,意味着AGC的软件没有“补丁”概念。所有异常处理逻辑必须在编码阶段就写死。这和今天OTA升级的思路完全相反,但对高可靠性场景有参考价值——当你无法远程修复时,你会在测试上投入多少?
2.2 16位字长与定点运算的取舍
AGC的字长是16位,其中15位是数据位,1位是奇偶校验位。它没有浮点运算单元,所有计算用定点数完成。制导计算涉及大量三角函数和向量运算,定点数怎么够用?
答案是缩放。AGC的程序里有一套固定的缩放规则,把物理量映射到定点数的表示范围。比如角度用半圆单位表示,速度用特定比例因子。每个变量在程序里都有明确的量纲和缩放系数,程序员必须手动管理这些系数,不能搞混。
这种做法的代价是:程序员要时刻清楚每个数的物理意义和表示范围,稍有不慎就会溢出。但好处是:计算速度极快,没有浮点运算的开销。在11.7微秒的指令周期下,一次定点乘法大约需要几十微秒,而如果做浮点模拟,可能要几百微秒。在燃料临界的着陆阶段,这几十微秒的差距可能就是成败之分。
我实测过用现代MCU模拟AGC的定点运算,在STM32F4上跑同样的算法,定点版本比浮点版本快约8倍。当然现代MCU有FPU,但如果你把FPU关掉用软件浮点,差距更明显。这解释了为什么AGC选择定点:不是它不想用浮点,是那个年代的硬件条件下,定点是唯一能实时完成计算的选择。
2.3 指令集与执行模型
AGC的指令集很精简,基本指令包括加载、存储、加法、减法、转移、中断控制等。没有乘法指令,乘法用移位和加法子程序实现。没有除法指令,除法用类似的方法。每条指令的执行时间固定,大部分是11.7微秒,少数需要额外周期。
执行模型是单累加器结构,所有运算围绕累加器A进行。这种结构简单,但编程时需要频繁保存和恢复累加器内容。AGC的程序里大量使用“临时存储”位置来暂存中间结果,这些位置在磁芯存储里,访问速度快。
中断机制是AGC的关键。它有一个中断优先级队列,高优先级中断可以打断低优先级中断。在着陆阶段,最重要的中断是“燃料低”报警和“执行溢出”报警。当这些中断触发时,AGC会暂停当前任务,跳转到对应的处理程序。
这里有个设计细节:AGC的中断处理程序非常短,通常只做最紧急的事,比如设置一个标志位,然后尽快返回。真正的处理逻辑在主循环里根据标志位执行。这种“中断只做标记,主循环做处理”的模式,是现代实时系统的标准做法,但AGC在1960年代就这么干了。
3. 1202和1201报警:过载时AGC怎么自救
3.1 报警的本质:任务调度器的溢出
1202和1201报警在阿波罗11号着陆过程中触发了多次,地面控制中心一度考虑中止着陆。这两个报警的含义是:AGC的任务调度器发现没有足够的计算资源完成当前任务队列。
AGC的任务调度器是一个优先级驱动的执行器。每个任务有固定的执行周期和优先级。调度器在每个周期开始时,检查哪些任务需要执行,按优先级排序,然后依次执行。如果高优先级任务占用了太多时间,低优先级任务就会积压。当积压超过一定阈值,调度器触发1202报警(中等优先级任务溢出)或1201报警(低优先级任务溢出)。
关键点在于:报警本身不是故障,而是系统在告诉你“我忙不过来了,但我还在控制范围内”。AGC的设计是,当报警触发时,它会丢弃一些低优先级的任务,比如遥测数据更新,把计算资源留给制导和导航。这就是为什么报警响了但着陆还能继续——AGC主动做了取舍。
3.2 优先级反转与资源抢占的实战处理
在着陆最后阶段,rendezvous radar的数据突然涌入,占用了大量计算资源。这个雷达本来是用来测距测速的,但在着陆阶段它的数据不是必需的。AGC的程序里有一个“雷达数据有效性检查”,当发现数据与主惯性测量单元冲突时,会标记为无效并降低处理优先级。
但问题在于,雷达数据的处理任务优先级设置得比较高,因为它原本是用于交会对接的。在着陆阶段,这个优先级设置变成了负担。AGC的调度器没有动态调整优先级的机制,所以它只能通过报警和任务丢弃来应对。
这引出一个经典问题:静态优先级调度在任务负载动态变化时的局限性。AGC的解决方案是“降级运行”——当资源不足时,丢弃非关键任务,保证关键任务完成。现代实时系统里,这对应的是“优雅降级”策略。我在设计一个工业控制项目时借鉴过这个思路:当CPU负载超过80%时,自动关闭数据记录和远程通信任务,只保留核心控制回路。实测下来,系统在过载时不会崩溃,而是稳定降级。
3.3 从报警日志反推调度器行为
AGC的报警日志在任务结束后被完整保留,我拿到了一份公开的仿真日志,用Python做了分析。日志记录了每次报警触发的时间戳、报警类型、当时执行的任务列表和剩余时间片。
分析发现一个规律:报警集中在着陆最后3分钟,也就是“最终下降”阶段。这个阶段制导计算量最大,同时雷达数据还在持续输入。报警触发后,AGC丢弃的任务主要是遥测数据打包和显示更新,制导和导航任务从未被丢弃。
这说明AGC的优先级设计是合理的:制导和导航的优先级最高,遥测和显示最低。当资源不足时,低优先级任务被牺牲。这个优先级顺序在任务设计阶段就确定了,不是运行时动态调整的。
我用同样的日志数据训练了一个简单的调度模拟器,复现了AGC的调度行为。代码不长,核心逻辑就是优先级队列加时间片轮转。但关键参数——每个任务的执行时间和周期——需要从AGC的源码里提取。这部分我花了最多时间,因为源码是汇编,需要逐条指令分析执行路径。
4. 登月制导算法拆解:从P63到P66的程序段
4.1 P63制动段:如何把速度降到零
P63是动力下降的第一段,目标是把登月舱从环月轨道速度降到接近零,同时调整姿态。这个阶段的核心算法是“制动制导”:根据当前速度和位置,计算需要的减速度,然后控制发动机推力。
AGC用的是一种叫“多项式制导”的方法。它把着陆轨迹参数化为一个关于时间的多项式,然后根据当前状态和目标状态,解出多项式系数,再计算需要的加速度指令。这个方法的优点是计算量小,适合定点运算。
具体来说,P63的目标是到达一个“高度-速度”窗口,在这个窗口里,登月舱的高度和下降速度满足后续阶段的要求。AGC不断计算当前状态与目标窗口的偏差,然后调整推力方向和大小。这个过程中,它还要考虑燃料消耗,尽量在到达窗口时燃料还剩足够余量。
我复现这个算法时,用Python写了一个简化版。核心是解一个二次方程,得到需要的减速度。然后把这个减速度转换成发动机推力指令。实测发现,如果初始条件偏差较大,多项式制导会给出很激进的推力指令,导致燃料消耗过快。AGC的应对是设置推力变化率限制,防止发动机响应不过来。
4.2 P64接近段:姿态与推力的耦合控制
P64阶段登月舱已经接近月面,此时需要同时控制下降速度和姿态。姿态控制用反作用控制系统(RCS),推力控制用下降发动机。这两个系统有耦合:改变姿态会影响推力方向,改变推力会影响姿态。
AGC的处理方式是分时控制:在每个控制周期里,先计算需要的推力指令,再计算需要的姿态指令,然后分别输出。由于控制周期很短(约0.1秒),这种分时控制在实际效果上接近连续控制。
这里有个细节:AGC在P64阶段会切换到“自动姿态控制”模式,宇航员只需要监控,不需要手动操作。这个切换的触发条件是高度低于某个阈值。切换逻辑在程序里是硬编码的,没有人工干预的余地。这种设计减少了宇航员的工作负荷,但也意味着如果自动系统出问题,宇航员需要快速接管。
我在模拟环境里测试过这个切换逻辑,发现如果高度传感器有噪声,切换可能过早或过晚。AGC的应对是对高度数据进行滤波,用多个传感器的加权平均。这个滤波器的参数在绳存储里固化,不能调整。现代系统里,这类参数通常放在可配置区域,方便调试。但AGC的不可修改性迫使设计者在测试阶段就把参数调好。
4.3 P66手动段:宇航员接管后的辅助逻辑
P66是最后阶段,宇航员手动控制下降。但AGC并没有完全放手,它提供“辅助稳定”功能:当宇航员的操作导致姿态变化过快时,AGC会自动施加反向RCS推力,防止翻滚。
这个辅助逻辑的核心是一个速率限制器。AGC监测姿态角速度,如果超过阈值,就自动触发反向推力。阈值设置得很保守,确保宇航员的操作不会被过度干预,同时又能防止失控。
我在复现这个逻辑时,用了一个简单的PID控制器模拟。发现阈值设置是关键:太敏感会干扰宇航员操作,太迟钝会失去保护作用。AGC的阈值在绳存储里是固定值,但设计者通过大量模拟测试确定了这个值。现代系统里,这类阈值通常做成可调参数,但AGC的约束迫使设计者一次做对。
5. 搭建AGC仿真环境:从源码到可运行模拟器
5.1 获取源码与工具链准备
AGC的源码是公开的,可以在一些技术档案站点找到。源码是汇编格式,需要特定的汇编器才能编译。我用的工具链包括:
- yaAGC:一个开源的AGC仿真器,支持源码汇编和执行
- 汇编器:把AGC汇编代码转换成可执行格式
- 调试器:单步执行、查看寄存器和内存
安装过程在Linux环境下比较顺利,Windows下需要WSL或Cygwin。我建议用Ubuntu 20.04或更新版本,依赖库比较全。
注意:源码版本有多个,建议用“Apollo 11”对应的版本,不要用后续任务的版本,因为程序逻辑有差异。我一开始用了Apollo 15的源码,发现P63阶段的逻辑对不上,后来换了版本才复现成功。
5.2 编译与运行第一个程序段
编译AGC源码的命令行大概是这样:
yaYUL -o output.bin source.agc然后加载到仿真器:
yaAGC --core=output.bin仿真器启动后,会进入一个命令行界面,可以输入指令查看内存、设置断点、单步执行。我建议先从简单的程序段开始,比如“显示当前时间”或“计算两个数的和”,熟悉指令集和调试流程。
一个实用的技巧:AGC的汇编代码里有大量宏和符号,直接看很晕。可以用仿真器的符号表功能,把地址映射回符号名。这样调试时看到的是变量名而不是数字地址,效率高很多。
5.3 注入着陆场景数据并观察报警
要复现1202报警,需要注入着陆阶段的传感器数据。AGC的输入包括惯性测量单元数据、雷达数据、高度数据等。这些数据在仿真环境里可以用脚本生成。
我写了一个Python脚本,模拟着陆最后3分钟的数据流:高度从约15000英尺降到0,下降速度从约100英尺/秒降到0,同时雷达数据有噪声。把这些数据按AGC的输入格式打包,通过仿真器的输入接口注入。
运行后发现,报警确实在高度约5000英尺时触发,和实际任务的时间点接近。报警触发后,仿真器输出显示遥测任务被丢弃,制导任务继续执行。这个行为和历史记录一致。
5.4 用Python分析报警日志
仿真器输出的日志是文本格式,每行包含时间戳、事件类型、任务状态。我用pandas读取日志,做了几个分析:
- 报警触发频率随时间的变化
- 报警时正在执行的任务
- 报警后任务队列的变化
分析结果用matplotlib画图,可以直观看到报警集中在最后阶段,且报警后低优先级任务被大量丢弃。这个分析帮我理解了AGC调度器的实际行为,也为后续优化自己的调度器提供了参考。
6. 复现过程中踩过的坑与经验总结
6.1 定点数缩放错误导致的溢出
第一次复现P63算法时,我直接用了浮点数,结果和AGC的实际输出对不上。后来意识到AGC用的是定点数,需要手动缩放。我按照AGC文档里的缩放规则重新实现,但一开始搞错了几个系数的量纲,导致中间结果溢出。
具体来说,速度的缩放系数是2的某次方,角度的缩放系数是另一个值。我把它们搞混了,结果计算出的减速度大了几个数量级。调试时用仿真器的内存查看功能,发现中间变量超出了16位表示范围,触发了溢出报警。
修正方法是:仔细核对每个变量的量纲和缩放系数,在代码里加注释说明。这个经验告诉我,定点数编程的核心不是算法,而是量纲管理。现代开发里用浮点数习惯了,很容易忽略这一点。
6.2 中断优先级配置与任务饥饿
在模拟调度器时,我一开始把雷达数据处理任务的优先级设得和制导任务一样高。结果在数据密集时,制导任务被频繁打断,导致控制周期不稳定。
AGC的实际做法是:制导任务优先级最高,雷达任务次之,遥测任务最低。我调整了优先级配置,制导任务的执行变得稳定。但新的问题是:低优先级的遥测任务完全被饿死,日志里看不到遥测数据。
AGC的解决方案是设置“最小执行间隔”:即使高优先级任务繁忙,低优先级任务也会在固定间隔后强制执行一次。这个机制在AGC源码里叫“任务节拍”,每个任务有一个计数器,计数器减到零就强制执行,不管优先级。
我在自己的调度器里加了这个机制,用定时器中断触发任务节拍检查。实测下来,系统在过载时仍能保持基本功能,不会完全饿死低优先级任务。
6.3 仿真器与实际硬件的时序差异
仿真器运行在现代CPU上,速度比AGC快几个数量级。这导致一个问题:仿真器里的中断响应时间比实际硬件短很多,一些在真实硬件上会触发的时序问题,在仿真器里复现不出来。
比如,AGC的磁芯存储读写有固定延迟,仿真器里这个延迟被忽略了。结果是一些依赖时序的代码在仿真器里运行正常,但在真实硬件上会出错。
我的应对是:在仿真器里加人为延迟,模拟磁芯存储的访问时间。具体做法是在内存读写函数里插入一个忙等待循环,循环次数根据仿真器的实际速度调整。这样虽然减慢了仿真速度,但更接近真实硬件的时序行为。
这个经验对做硬件仿真的同行有参考价值:仿真器不是越快的越好,时序保真度有时比速度更重要。
6.4 绳存储不可修改性对调试的影响
AGC的绳存储是只读的,仿真器里虽然可以修改内存,但修改绳存储区域会导致校验和错误。我一开始不知道这个限制,直接改了绳存储里的一个常量,结果仿真器报校验和错误,程序无法继续。
后来我学会了:调试时只修改磁芯存储区域,绳存储区域保持原样。如果必须修改程序逻辑,需要重新汇编源码,生成新的绳存储镜像。这个过程比较慢,但保证了仿真的一致性。
这个限制其实反映了AGC的设计哲学:程序是固化的,运行时不可修改。现代系统里,固件更新是常态,但AGC的约束提醒我们:不可修改性带来的确定性,在某些场景下是优势。比如,如果程序不能被篡改,安全性就多了一层保障。
7. 从AGC到现代嵌入式:哪些设计值得抄作业
AGC的优先级调度和优雅降级策略,我在一个工业数据采集项目里直接借鉴了。那个项目用STM32F7,需要同时处理多个传感器数据、运行控制算法、上传数据到云端。一开始用FreeRTOS的默认调度,发现网络任务偶尔会阻塞控制任务,导致控制周期抖动。
后来我模仿AGC的做法:把控制任务设为最高优先级,传感器数据处理次之,网络上传最低。同时加了一个“任务节拍”机制,确保网络任务不会完全饿死。调整后,控制周期的抖动从±5ms降到±0.5ms,效果很明显。
另一个值得借鉴的是AGC的“中断只做标记”模式。我在一个电机控制项目里,把中断处理程序精简到只设置标志位,实际控制逻辑放在主循环里。这样中断响应时间从原来的几微秒降到几百纳秒,系统的实时性大幅提升。
AGC的定点数运算在现代MCU上也有用武之地。在一些低功耗场景,FPU的功耗比定点运算高不少。如果算法对精度要求不高,用定点数可以省电。我在一个电池供电的传感器节点上试过,定点版本比浮点版本省电约30%。
最后说一个不太技术但很重要的点:AGC的程序员在绳存储上“编织”代码时,没有调试器,没有在线更新,所有逻辑必须一次做对。这种约束下的工程实践,对今天过度依赖调试工具和快速迭代的开发者,是一种提醒:有些系统,你只有一次机会。