资讯动态

Apollo-11制导计算机源码复现:高可靠性嵌入式系统的确定性设计与冗余容错实践

发布时间:2026/10/15 1:38:27 来源:尧图企业网站定制
1. 项目缘起与整体设计思路第一次看到“Apollo-11”这个标题很多人脑子里蹦出来的可能是那个著名的登月计划。但在技术圈子里尤其是做嵌入式、航天软件、高可靠性系统的人眼里Apollo-11代表的是另一层含义——一套被反复验证、代码量不大但极其精炼的制导计算机源码。这个项目标题背后核心领域其实是高可靠性嵌入式系统的代码复现与架构分析潜在需求是搞清楚“在算力极其有限的条件下如何写出稳定到能载人上天的代码”。我最初接触这个方向是因为手头一个工业控制项目要求代码在极端环境下零容错运行。当时翻了不少资料最后发现绕不开Apollo-11制导计算机的那套设计哲学。它的核心不是用了多牛的技术恰恰相反它用的是最笨、最扎实的办法极简的指令集、确定性的执行流程、冗余到令人发指的自检机制。这套东西放到今天对于做物联网设备、车载控制器、医疗电子的人来说依然有极强的参考价值。这个项目适合谁来参考如果你是刚入行的嵌入式工程师它能帮你建立“资源受限下如何做架构取舍”的直觉如果你是有经验的系统架构师它能让你重新审视自己对“可靠性”的理解是不是太依赖硬件堆料了。哪怕你只是对计算机体系结构感兴趣这套代码的注释和设计文档也足够你啃上一阵子。整体设计思路可以拆成三条线第一条是指令集与执行模型Apollo-11的制导计算机用的是一种自定义的16位指令集所有操作都是围绕“固定周期、确定性延迟”来设计的没有缓存、没有流水线每条指令的执行时间完全可预测。第二条是内存与存储管理它的内存小到以KB为单位所以每一字节的分配都要精打细算核心代码和数据的存放位置是硬编码的不允许动态分配。第三条是冗余与故障恢复这套系统里几乎每一个关键变量都有备份每一个关键操作都有校验甚至程序计数器都有两套一旦主计数器跑飞备用计数器能立刻接管。为什么选择这种“笨”方案因为在天上飞的时候你没法按重启键。任何不确定性都是致命的。今天我们在做高可靠性系统时经常陷入一个误区堆硬件、堆算力、堆框架。但Apollo-11的思路恰恰相反——用最简单的结构配合最严格的验证把不确定性扼杀在编码阶段。这个思路放到现在的工业物联网场景里依然成立。比如你做的是一个部署在偏远地区的环境监测节点换电池的成本比设备本身还高那你就需要这种“一次写对、长期稳定”的设计哲学。2. 核心细节解析与实操要点2.1 指令集与执行周期的确定性设计Apollo-11制导计算机的指令集非常精简总共只有几十条指令但每一条的执行周期都是固定的。比如加法指令不管操作数是什么执行时间都是固定的几个机器周期。这种设计的好处是你可以精确计算出任何一段代码的最坏执行时间这对于实时控制系统来说是刚需。我在复现这套指令集的时候最大的感受是没有意外。现代CPU有分支预测、有乱序执行、有缓存命中与否的差异导致同样一段代码在不同输入下的执行时间可能差出几十倍。但在Apollo-11的模型里你写一个循环循环次数是N那执行时间就是N乘以单次循环的固定周期数没有任何浮动。这种确定性是它能够通过严格时序验证的基础。实操中要注意的是这种确定性是以牺牲性能为代价的。你不能指望它跑复杂的算法它的算力大概只相当于现在一颗几块钱的单片机。但反过来想如果你的业务逻辑本身就不复杂只是要求绝对稳定那这种架构反而比跑Linux的系统更靠谱。我在一个温控项目里就借鉴了这个思路把控制逻辑写成固定周期的状态机每个周期做固定的事不搞动态内存分配不搞多线程结果连续运行了两年多一次死机都没出过。注意确定性执行的前提是中断处理也要纳入周期计算。Apollo-11的中断响应时间也是固定的中断服务程序里不允许有不确定的等待。如果你在自己的项目里用中断一定要确保中断服务程序足够短且执行时间可预测。2.2 内存布局与存储分配策略Apollo-11的内存小得可怜核心内存大概只有几KB加上辅助存储也不过几十KB。在这种条件下内存布局不是“怎么方便怎么来”而是“每一字节都要有明确用途”。它的做法是把内存分成几个固定区域核心代码区、常量区、变量区、备份区。变量区里的每一个变量都有固定的地址不允许动态申请。这种做法的好处是你随时可以通过地址反查这个变量是干什么用的调试的时候非常直观。而且因为没有动态分配也就不存在内存碎片和泄漏的问题。我在做一个小型数据采集设备时就照搬了这个思路把所有变量都定义成全局静态变量编译期就确定好地址运行期只读写不申请。结果代码的RAM占用比用动态分配少了将近三分之一而且跑了一年多内存使用曲线是一条直线没有任何波动。具体操作上你可以这样做先用链接脚本把内存划分成几个段代码段、只读数据段、读写数据段、备份数据段。然后在代码里用固定的宏来访问这些段里的变量。备份数据段专门用来存放关键状态每次更新主变量时同步更新备份变量。读取的时候做一次比较如果不一致就触发告警或恢复流程。提示备份区的更新频率要控制好。如果每个周期都写备份会浪费不少CPU时间。Apollo-11的做法是只在关键状态变更时才写备份平时只读主变量。你可以根据自己业务的实时性要求来调整这个策略。2.3 冗余机制与故障恢复流程Apollo-11最让人佩服的地方是它的冗余设计。程序计数器有两套一套主用一套备用每个指令周期结束时比较两者是否一致不一致就触发恢复。关键变量也有备份读取时做多数表决。甚至整个制导程序在内存里存了两份一份运行一份待命一旦检测到运行副本出错立刻切换到待命副本。这种冗余不是简单的“复制一份”而是有明确的切换逻辑和验证机制。我在一个安全相关的项目里尝试过类似的方案主控芯片和备用芯片跑同样的逻辑通过一个心跳信号互相监测。如果主芯片在指定时间内没有发出心跳备用芯片就接管输出。这个方案听起来简单但实际调试时踩了不少坑。最大的坑是切换瞬间的输出抖动因为两个芯片的状态不可能完全同步切换时如果处理不好会导致执行器收到一个错误的中间状态。后来我借鉴了Apollo-11的做法在切换前先做一次状态同步把主芯片的关键变量通过共享内存传给备用芯片备用芯片确认收到并校验无误后再接管输出。这样就避免了切换抖动。这个经验告诉我冗余机制的核心不是“有备份”而是“备份能无缝接管”。注意冗余设计会增加系统的复杂度和成本。不是所有项目都需要这么干。我的建议是先评估故障后果如果故障会导致人身安全或重大财产损失那冗余是必须的如果只是重启一下就能解决那优先考虑简化设计把单套系统的可靠性做扎实。3. 实操过程与核心环节实现3.1 环境搭建与源码获取复现Apollo-11的第一步是拿到源码。这套代码最早是以纸质形式存档的后来有人扫描并转录成了文本格式。你可以在一些开源代码托管平台上找到转录版本通常包含汇编源码和对应的注释文档。我建议直接找那种带完整注释的版本因为原始代码的变量命名非常简短没有注释根本看不懂。拿到源码后你需要一个汇编器来把它转成可执行文件。原版的汇编器是跑在大型机上的现在你可以用开源的交叉汇编器来替代。我试过几种方案最后选了一个用Python写的轻量级汇编器好处是你可以直接读汇编器的源码搞清楚每条指令是怎么编码的。这对于理解指令集本身非常有帮助。环境搭建的具体步骤准备一台Linux或macOS的开发机Windows的话建议用WSL。安装Python 3.8以上版本以及必要的构建工具gcc、make等。从开源仓库克隆源码和汇编器注意选择star数较高、最近有更新的版本。按照仓库的README编译汇编器然后用它汇编源码。如果一切顺利你会得到一个二进制文件和一个符号表文件。符号表里记录了每个变量和函数的地址调试时非常有用。提示不同来源的源码版本可能有差异有的版本包含注释有的没有。建议先找一个带完整注释的版本把代码结构搞清楚之后再尝试自己从头汇编。另外汇编过程中如果报错先检查汇编器的版本是否匹配不同版本的汇编器对伪指令的支持可能不一样。3.2 指令集模拟器的编写与调试光有汇编源码还不够你还需要一个模拟器来运行它。Apollo-11的制导计算机是一种特定的架构现代CPU不能直接执行它的指令。所以你需要写一个模拟器把它的指令翻译成现代CPU能执行的操作。我写模拟器的时候核心是两部分指令译码和状态更新。指令译码就是根据操作码判断这条指令要做什么比如是加法、跳转还是内存读写。状态更新就是根据译码结果修改寄存器、内存和程序计数器的值。听起来简单但细节很多。比如Apollo-11的累加器是16位的但有些指令只操作低15位最高位是奇偶校验位。如果你忽略了这个细节模拟出来的结果就会和真实硬件不一致。调试模拟器的时候我建议从最简单的指令开始写一个测试用例手动计算预期结果然后和模拟器的输出对比。比如先测加法指令输入两个数看输出对不对。再加跳转指令看程序计数器有没有正确更新。一步一步来不要一上来就跑完整的制导程序那样出了问题你根本不知道是哪条指令错了。我在调试过程中遇到过一个典型问题溢出标志的处理。Apollo-11的加法指令在溢出时会设置一个标志位但不同版本的模拟器对这个标志位的处理方式不一样。有的版本在溢出时把结果截断有的版本则保留溢出后的值。我查了原始文档才发现正确的做法是截断并设置标志位。这个细节如果不注意跑复杂程序时就会出现莫名其妙的结果偏差。3.3 关键模块的移植与验证模拟器跑通之后你可以尝试把一些关键模块移植到现代平台上。比如姿态控制算法、导航计算模块这些逻辑本身和硬件关系不大移植过来可以直接用。移植的时候要注意数据类型的对应关系。Apollo-11用的是定点数现代平台默认是浮点数直接混用会导致精度问题。我的做法是先把定点数的运算规则搞清楚然后在现代平台上用整数模拟定点运算。比如两个16位定点数相乘结果要右移多少位才能保持小数点的位置不变这个移位量是固定的不能随便改。移植完一个模块后用原始数据跑一遍和Apollo-11的预期输出对比确认一致后再移植下一个模块。验证环节我建议用单元测试的方式。每个模块写几个测试用例覆盖正常情况和边界情况。比如导航模块输入一个已知的位置和速度看输出的加速度对不对。边界情况包括零值、最大值、最小值等。这些测试用例跑通之后你对移植代码的信心会强很多。注意移植过程中不要随意“优化”代码。Apollo-11的代码看起来有很多“冗余”操作比如重复计算同一个值但这些冗余往往是为了保证确定性或容错性。你如果自作主张把它优化掉可能会引入难以排查的时序问题。我的原则是先原样移植跑通之后再考虑优化而且每次优化都要有充分的测试支撑。4. 常见问题与排查技巧实录4.1 汇编报错与指令编码问题复现过程中最常见的问题就是汇编报错。报错信息通常很简短比如“invalid opcode”或“operand out of range”。这时候不要慌先检查你的汇编器版本和源码版本是否匹配。Apollo-11的指令集在不同时期有过微调早期版本和后期版本的指令编码可能不一样。如果版本没问题那就检查操作数的范围。比如某个指令要求操作数在0到4095之间你给了一个5000就会报“operand out of range”。这时候你需要查指令手册确认这个指令的操作数范围到底是多少。我遇到过一种情况源码里用了一个宏来生成操作数但宏展开后的值超出了范围。这种问题比较隐蔽需要你逐行检查宏展开的结果。另一个常见问题是符号未定义。汇编器在遇到一个未定义的符号时会报错但这个符号可能在另一个源文件里定义。你需要检查链接脚本确认所有源文件都被正确包含进来了。如果符号确实不存在那可能是源码版本不完整你需要找更完整的版本。4.2 模拟器运行结果与预期不符模拟器跑起来之后结果和预期不符是家常便饭。排查这种问题我总结了一个“从简到繁”的流程单步执行让模拟器一次只执行一条指令每执行完一条就打印寄存器和内存的状态。然后你手动对照指令手册看这条指令的执行结果对不对。这是最笨但最有效的方法。断点调试在关键指令处设置断点比如循环的入口和出口观察程序计数器的变化是否符合预期。如果程序计数器跑飞了那多半是跳转指令的地址算错了。对比测试如果你有原始硬件的执行记录比如历史文档里的测试数据拿同样的输入跑你的模拟器逐条对比输出。差异出现的位置就是问题所在。我印象最深的一次排查是模拟器跑一个循环时陷入了死循环。单步执行后发现循环计数器的递减指令没有正确更新标志位导致循环条件永远为真。查了指令手册才发现递减指令在结果为零时应该设置零标志位但我的模拟器漏掉了这一步。补上之后循环就正常退出了。4.3 移植后的精度与性能问题移植到现代平台后精度问题是最让人头疼的。Apollo-11用的是定点数现代平台用浮点数两者在舍入规则上不一样。比如定点数除法结果是截断的而浮点数除法是四舍五入的。如果你直接混用累积误差会越来越大。我的解决办法是在移植代码里显式地模拟定点运算。具体来说定义一个定点数类型内部用整数存储所有运算都按照定点数的规则来实现。这样虽然代码量大一些但精度和原始系统完全一致。性能方面定点运算比浮点运算快所以移植后的代码反而可能跑得更快。另一个性能问题是内存访问模式。Apollo-11的内存访问是直接寻址没有缓存。现代平台有缓存如果你的数据布局不合理会导致频繁的缓存未命中性能反而下降。我的经验是把频繁访问的变量放在一起让它们落在同一个缓存行里。这个优化不需要改算法只需要调整变量的声明顺序。4.4 常见问题速查表问题现象可能原因排查方法解决措施汇编报错“invalid opcode”汇编器版本与源码不匹配检查汇编器版本号对比指令手册换用匹配版本的汇编器或修改源码中的指令模拟器结果与预期不符指令译码错误或标志位处理不当单步执行逐条对比指令手册修正译码逻辑补全标志位更新程序计数器跑飞跳转指令地址计算错误在跳转指令处设断点检查目标地址修正地址计算逻辑检查符号表移植后精度偏差大定点数与浮点数混用对比原始数据和移植后的输出用整数模拟定点运算统一舍入规则移植后性能下降缓存未命中率高用性能分析工具查看缓存命中率调整变量布局提高局部性冗余切换时输出抖动主备状态不同步在切换点抓取主备状态对比增加状态同步和校验环节提示这张表里的问题我都实际遇到过解决方案也是验证过的。但你的环境可能和我的不一样所以排查时还是要结合具体情况。我的建议是每解决一个问题就把现象、原因和解决方法记录下来形成自己的知识库。下次再遇到类似问题直接查表就行。5. 从Apollo-11中学到的工程思维5.1 资源受限下的架构取舍Apollo-11给我的最大启发不是某个具体的技术点而是一种思维方式在资源受限的条件下如何做架构取舍。它的每一个设计决策都是在“功能”和“可靠性”之间找平衡。比如它不支持动态内存分配因为动态分配会引入不确定性它不支持复杂的浮点运算因为浮点运算的周期数不固定。这些限制在今天看来很“落后”但正是这些限制成就了它的可靠性。我在做自己的项目时经常问自己一个问题这个功能真的必要吗如果去掉它系统会不会更简单、更可靠很多时候答案都是“去掉更好”。比如我曾经在一个数据采集项目里加了OTA升级功能结果因为升级过程中的电源波动导致设备变砖。后来我把OTA去掉改成通过串口本地升级虽然麻烦一点但再也没有出过问题。这种“做减法”的思维在现在的开发环境里反而更难坚持。因为工具太方便了加一个库、加一个框架几行代码就能实现一个功能。但每加一个东西就多一个潜在的故障点。Apollo-11的代码量不大但每一行都是必要的。这种克制值得我们学习。5.2 确定性比性能更重要在实时控制领域确定性比性能更重要。Apollo-11的算力很低但它能保证在指定的时间内完成指定的任务。现代系统性能很高但经常出现“大部分时间很快偶尔卡一下”的情况。对于控制类应用来说这种“偶尔卡一下”是致命的。我在一个电机控制项目里就吃过这个亏。当时用了一个带操作系统的方案大部分时间控制周期是1毫秒但偶尔会跳到5毫秒。结果电机在高速运转时会出现明显的抖动。后来我换成了裸机方案把控制逻辑写成固定周期的中断服务程序抖动就消失了。虽然裸机方案的代码量更大开发效率更低但控制效果好了很多。这个经验让我明白选择技术方案时不能只看平均性能要看最坏情况下的表现。Apollo-11的设计目标就是“最坏情况下也能正常工作”这个目标应该成为所有高可靠性系统的设计准则。5.3 验证与测试的不可替代性Apollo-11的代码之所以可靠很大程度上是因为它经过了极其严格的验证和测试。据说每一行代码都被人工审查过多次每一个分支都被测试用例覆盖过。这种验证力度在现在的敏捷开发模式下是很难想象的。我并不是说我们要回到那种“一行代码审三天”的模式但至少要在关键模块上加大验证力度。我的做法是把系统分成“关键”和“非关键”两部分。关键部分比如安全逻辑、状态机必须写单元测试必须做代码审查必须做边界测试。非关键部分比如日志、显示可以适当放宽要求。这样既保证了核心可靠性又不至于拖慢整体进度。另外Apollo-11的测试用例是随着代码一起维护的。每次修改代码都要跑一遍回归测试。这个习惯我也坚持了下来。我的项目里有一个tests目录里面存放了所有测试用例。每次提交代码前我都会跑一遍make test确保没有破坏已有功能。这个习惯帮我避免了很多“改一个bug引入两个新bug”的情况。5.4 文档与注释的价值Apollo-11的源码里注释的密度非常高。几乎每一行代码都有注释解释这行代码在做什么、为什么这么做。这种文档习惯在当时的开发环境下是非常难得的。也正是因为这些注释后人才能理解这套代码的设计意图。我在自己的项目里也强制要求写注释。但不是那种“给变量赋值”的废话注释而是解释“为什么”的注释。比如“这里用查表法而不是计算法是因为查表法的执行周期固定满足实时性要求”。这种注释在半年后自己回头看时能快速回忆起当时的设计决策。除了代码注释我还建议写一份设计文档记录系统的整体架构、关键决策和已知限制。这份文档不需要很正式用Markdown写就行放在项目根目录下。新成员加入时先读这份文档能少走很多弯路。6. 后续扩展与个人体会这个项目后续还可以往几个方向扩展。一个是指令集的可视化写一个工具把汇编代码转成流程图直观地展示程序的执行路径。另一个是自动化测试框架把Apollo-11的测试用例整理成标准格式每次修改模拟器后自动跑一遍。还有一个方向是与现代RTOS的对比分析把Apollo-11的设计思路和FreeRTOS、Zephyr等现代实时操作系统做对比看看哪些思路可以借鉴哪些已经过时。我个人在实际操作中的体会是复现Apollo-11最大的收获不是学会了怎么写汇编而是建立了一种“面向可靠性”的思维方式。这种思维方式让我在面对任何项目时都会先问几个问题这个设计在最坏情况下会怎样如果某个环节出错了系统能不能自己恢复有没有更简单但更可靠的替代方案最后再分享一个小技巧如果你觉得直接啃Apollo-11的源码太吃力可以先从它的测试用例入手。测试用例通常比源码好读而且能帮你快速理解系统的输入输出行为。把测试用例跑通之后再回头看源码你会发现很多之前看不懂的地方突然就明白了。这个“从测试到实现”的学习路径比“从实现到测试”要高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑