news 2026/10/15 19:50:33

Apollo-11制导计算机源码复现:从环境搭建到雷达中断调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Apollo-11制导计算机源码复现:从环境搭建到雷达中断调试

1. 项目缘起与整体设计思路

第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那个著名的登月计划。但在我们这群折腾代码的人眼里,它其实是另一个东西——一个在GitHub上被反复fork、star数高得吓人的开源项目,全称叫“Apollo-11”,也就是阿波罗11号制导计算机(AGC)的源代码汇编仓库。这个项目把当年那台只有72KB内存、主频不过2MHz的机器上跑的所有代码,原封不动地搬到了现代开发者的屏幕上。我第一次接触它是在一个深夜,当时正在翻一些老硬件的资料,结果被这个仓库的commit记录吸引住了——里面居然有“修正了登月舱下降阶段的雷达采样逻辑”这种提交信息,瞬间就有了一种穿越感。

这个项目能做什么?简单说,它让你能在自己的电脑上,用现代工具链去编译、运行、甚至单步调试那段改变人类历史的代码。它解决了一个很实际的问题:很多对计算机历史、嵌入式系统、实时控制感兴趣的人,苦于找不到一个足够复杂、足够真实、又足够有故事性的代码库来练手。Apollo-11恰好填上了这个空位。它适合谁?我觉得三类人最该看看:一是嵌入式开发的老手,想看看在没有现代RTOS、没有高级语言、甚至没有像样IDE的年代,前辈们是怎么写出能上天的代码的;二是计算机专业的学生,课本上讲的“实时系统”“中断优先级”“内存对齐”这些概念,在这个项目里全是活生生的例子;三是任何对“工程极限”这个词有执念的人,因为这套代码的每一个字节都经过了最严苛的审查。

我之所以想写这篇东西,是因为我在复现这个项目的过程中踩了不少坑,也积累了一些文档里不会写的经验。网上关于Apollo-11的讨论大多停留在“哇,好厉害”的层面,真正动手去编译、去跑模拟器、去读汇编的人并不多。我想把从环境搭建到代码解读的完整路径梳理出来,顺便聊聊我从这套代码里学到的、至今仍然适用的工程原则。这不是一篇学术论文,也不是官方文档的翻译,就是一个普通开发者在折腾完这个项目后,觉得有些东西值得记下来。

2. 核心细节解析与实操要点

2.1 代码结构:为什么是汇编,为什么是这种汇编

Apollo-11的代码全部是用AGC汇编写的,这种汇编和你在大学里学的x86汇编或者ARM汇编完全不是一回事。AGC(Apollo Guidance Computer)是一台为特定任务定制的机器,它的指令集非常精简,但设计得极其巧妙。整个代码库分为两个主要部分:Comanche055和Luminary099。前者是命令舱(CM)的代码,后者是登月舱(LM)的代码。我一开始以为这两个是独立的,后来才发现它们之间有一套复杂的交互协议,尤其是在交会对接阶段,两边要通过雷达和无线电交换大量数据。

AGC汇编的一个显著特点是“一址指令”为主,也就是说大多数指令只操作一个内存地址,累加器(A)是隐式的。这跟现代处理器的三址指令、二址指令差别很大。举个例子,你想把两个数相加,得先Load一个数到A,然后Add另一个数,结果还在A里。这种设计的好处是指令长度短,坏处是写起来啰嗦,而且对程序员的心智负担很重。我在读代码的时候经常要停下来画图,跟踪A寄存器里到底装了什么。

另一个让我印象深刻的是“bank switching”机制。AGC的内存只有36K words(注意是word,不是byte,每个word是15位),但程序空间需要更大,所以用了存储体切换。代码里到处是BANK伪指令,告诉汇编器这段代码该放在哪个体里。如果你在调试的时候发现PC跳到了一个莫名其妙的地址,十有八九是bank切换出了问题。这个坑我在模拟器里踩过好几次,后面会细说。

注意:AGC的word是15位,不是16位。这意味着你在处理位操作的时候,最高位(第15位)是符号位还是数据位,取决于具体的指令。很多文档里写“16位”是错的,会误导你对溢出和进位标志的理解。

2.2 工具链选型:为什么我最终选了yaAGC

要跑Apollo-11的代码,你有几个选择:一是用原始的AGC硬件(显然不现实),二是用FPGA复刻(成本高,门槛也高),三是用软件模拟器。软件模拟器里比较有名的有yaAGC、Virtual AGC、还有几个个人维护的轻量级模拟器。我试过其中三个,最后稳定在yaAGC上。

yaAGC是Virtual AGC项目的一部分,用C语言写的,跨平台支持很好。它的优点是仿真度高,连AGC的“周期 stealing”行为都模拟了——就是那个年代内存慢,CPU取指的时候内存没法同时响应,所以每个指令周期会被拉长。这个细节对时序敏感的代码(比如中断处理)影响很大。我试过另一个模拟器,跑一般代码没问题,但一跑到雷达采样中断就崩,后来换到yaAGC就正常了。

安装yaAGC的过程不算复杂,但有几个依赖项容易漏。在Ubuntu上,你需要先装build-essential、automake、libtool,还有libncurses-dev(因为yaAGC的调试器是文本界面的)。如果你从源码编译,记得先跑./configure的时候加上--enable-debug,不然调试符号不全,后面单步跟踪会很痛苦。我一开始没加这个选项,结果在调试器里只能看到地址,看不到符号名,等于盲人摸象。

编译Apollo-11源码本身用的是yaYUL,这是Virtual AGC项目里的汇编器。用法很简单:yaYUL --block1 Luminary099.agc(注意Luminary是Block 2的,Comanche是Block 1的,别搞混了)。汇编输出是一个.bin文件,里面是纯二进制,可以直接喂给yaAGC。我第一次汇编的时候报了一堆“undefined symbol”,后来发现是有些符号定义在另一个文件里,需要把所有.agc文件一起传进去,或者用--include指定搜索路径。

2.3 内存布局:72KB是怎么塞下整个登月程序的

AGC的内存是36K words,每个word 15位,换算成字节大概是67.5KB。但实际可用的更少,因为有一部分被I/O寄存器和计数器占用了。代码里对内存的使用极其精细,几乎每一个word都有明确的用途。我统计了一下Luminary099的符号表,光是变量就有上千个,从“当前姿态角”到“雷达天线指向”,每一个都精打细算。

这种内存压力下,程序员们发明了很多技巧。比如“共享内存区”,不同的任务在不同的阶段复用同一块内存,前提是它们不会同时活跃。这在现代开发里是不可想象的——你敢让两个线程共用一个全局变量试试?但在AGC上,这是常态,因为任务调度是确定性的,什么时候谁跑,都是设计好的。我在读代码的时候经常看到注释里写“此变量在上升阶段用作X,在下降阶段用作Y”,一开始觉得混乱,后来才理解这是被逼出来的智慧。

另一个技巧是“位打包”。一个15位的word里,可能塞了三四个不同的标志位或者小整数。比如某个状态字,第0-2位是模式,第3位是使能,第4-7位是计数器。这种操作在现代C语言里用位域也能做,但AGC汇编里你得手动用MASK和AND来提取。我试过在模拟器里改一个位,结果忘了清旁边的位,导致整个导航模式跑飞了。这个教训告诉我:在资源受限的系统里,每一个位都是宝贵的,但也是危险的。

3. 实操过程与核心环节实现

3.1 环境搭建:从零到跑通第一个指令

我是在一台Ubuntu 22.04的机器上做的,但步骤在macOS和Windows(WSL)上大同小异。首先,你得把Virtual AGC的仓库克隆下来。这个仓库有点大,因为里面包含了所有Apollo任务的源码、模拟器、汇编器、还有一堆文档。我建议用--depth 1只拉最新版,不然历史记录能下到你怀疑人生。

git clone --depth 1 https://github.com/virtualagc/virtualagc.git cd virtualagc

接下来是编译。官方推荐用make,但直接跑make会编译所有东西,包括一些你根本用不到的测试工具。我一般只编译需要的部分:

make yaYUL yaAGC yaAGCb1

yaAGCb1是Block 1的模拟器,如果你只玩Luminary(Block 2),可以跳过。编译过程大概需要几分钟,取决于你的机器。如果报错说找不到readline,装一下libreadline-dev就行。

编译完之后,你需要把Apollo-11的源码汇编成二进制。源码在virtualagc/Apollo11目录下(不同版本可能路径略有不同)。我用的命令是:

./yaYUL --block2 ../Apollo11/Luminary099/Luminary099.agc > Luminary099.bin

注意--block2这个参数,如果你汇编的是Comanche(Block 1),要改成--block1。我一开始没加这个参数,汇编器默认按Block 1处理,结果生成的二进制在Block 2模拟器里跑不起来,报了一堆“invalid instruction”。这个坑花了我半个小时才反应过来。

汇编成功后,你会得到一个.bin文件,大小应该在几十KB左右。然后启动模拟器:

./yaAGC --core=Luminary099.bin --port=19697

--port是模拟器和外部调试器通信的端口,你可以随便指定一个没被占用的。启动后,模拟器会进入一个文本界面,显示AGC的当前状态:寄存器、内存、还有那个著名的“DSKY”(Display and Keyboard)模拟。

3.2 第一次运行:从“待机”到“执行”

模拟器启动后,AGC并不是立刻开始跑程序的。它处于“待机”状态,等待你通过DSKY输入指令。DSKY是宇航员和计算机交互的界面,上面有两行数字显示(每行5位八进制数)和一堆按钮。在模拟器里,你可以用键盘模拟这些按钮。

我第一次跑的时候,按照文档输入了“V37E00E”——这是“切换到程序00”的指令。V37是“改变程序”,E是“执行”,00是程序号。输入之后,DSKY的显示变了,模拟器开始输出一些日志。但程序并没有像我想象的那样“跑起来”,而是停在了一个等待状态。后来我才知道,程序00是“空闲”程序,它什么都不做,只是等待。要真正跑导航程序,得输入“V37E01E”或者更复杂的指令序列。

这里有个小技巧:模拟器的调试器(yaAGC自带的那个)可以让你直接查看内存和寄存器。按Ctrl+C进入调试模式,然后输入R显示寄存器,M 1000显示地址1000开始的内存。我经常用这个来验证代码有没有按预期执行。比如,我想看某个变量有没有被更新,就找到它的符号地址(在汇编生成的.sym文件里),然后M一下。

3.3 调试一个真实的中断:雷达采样

为了真正理解这套代码,我决定调试一个具体的功能:登月舱下降阶段的雷达采样。这个功能在Luminary099里对应的代码段叫“RADAR_READ”,大概在Luminary099/TRIM文件里(具体路径可能因版本而异)。它的作用是每隔一定时间读取着陆雷达的高度和速度数据,然后传给导航系统。

首先,我在汇编生成的符号表里找到RADAR_READ的地址。符号表是一个文本文件,每行是“地址 符号名”。用grep搜一下:

grep RADAR_READ Luminary099.sym

假设输出是1234 RADAR_READ,那地址就是八进制的1234。然后在模拟器里设一个断点:

B 1234

继续运行(C),程序跑到这个地址就会停下来。这时候你可以单步执行(S),看每一条指令对寄存器和内存的影响。我印象最深的是,雷达数据是通过一个叫“CDU”的接口读进来的,代码里用了大量的IN和OUT指令。这些指令在模拟器里会触发一个“I/O事件”,你可以用--iomask参数来观察哪些I/O端口被访问了。

调试过程中我发现一个有趣的现象:雷达采样的中断优先级并不是最高的。在AGC里,中断分为好几个级别,最高的是“紧急中断”(比如发动机故障),然后是“关键中断”(比如姿态控制),雷达采样属于“常规中断”。这意味着如果系统正在处理一个关键中断,雷达数据可能会被延迟读取。代码里对此有补偿逻辑:如果发现采样间隔超过了预期,会用外推法估算当前值。这个设计思路在现代实时系统里也很常见,但能在1960年代的代码里看到,还是让我吃了一惊。

提示:在模拟器里调试时,记得把--trace打开,它会输出每条执行的指令和寄存器变化。虽然输出量很大,但配合grep过滤,能快速定位问题。我一般用--trace | grep RADAR来看雷达相关的执行流。

4. 常见问题与排查技巧实录

4.1 汇编报错:符号未定义或重复定义

这是最常见的问题,尤其是在你只汇编了部分文件的时候。Apollo-11的代码是分模块的,很多符号定义在“头文件”里(AGC汇编里叫“equates”)。如果你只传了主文件,汇编器找不到那些符号,就会报“undefined symbol”。解决办法是把所有相关的.agc文件都传给yaYUL,或者用--include指定目录。

重复定义则通常是因为你同时包含了Block 1和Block 2的文件。这两个版本的代码有一些同名符号,但地址不同。我建议你明确自己要跑哪个版本,然后只包含那个版本的目录。如果你不确定,看看文件名:Comanche开头的是Block 1,Luminary开头的是Block 2。

4.2 模拟器跑飞:PC跳到非法地址

这个问题我遇到过好几次,原因五花八门。最常见的是bank切换错误。AGC的代码在跨bank调用子程序时,需要先切换bank,然后跳转。如果切换的bank号错了,PC就会跑到一个空的或者错误的内存区域。在模拟器里,你会看到PC突然变成一个很大的数,然后模拟器报“invalid instruction”。

排查方法是打开--trace,看PC跳转前的最后几条指令。通常你会看到一条BANK指令,后面跟着TC(transfer control)。检查那个BANK的参数是不是你期望的值。有时候是因为符号表里的bank号被覆盖了,这通常意味着你的汇编命令有问题。

另一个可能的原因是内存越界。AGC的内存是分段的,有些段是只读的(比如程序区),有些是可读写的(比如数据区)。如果你试图写一个只读地址,模拟器会报错。这个在调试时很容易发生,比如你误把数据写到了程序区。解决办法是仔细检查你的M命令或者代码里的存储指令,确保地址在正确的段里。

4.3 DSKY输入无响应

DSKY的输入是通过模拟器的键盘映射来处理的。默认情况下,数字键对应DSKY的数字键,但有些功能键(比如+、-、E)需要按特定的组合。我一开始不知道,按了半天数字,DSKY上没反应。后来查了文档,发现E键是回车,+和-是符号键,V键是“Verb”,N键是“Noun”。

如果你按了键但DSKY没反应,先检查模拟器是不是在前台运行(有时候后台运行会丢失键盘事件)。另外,模拟器的--port参数如果和另一个程序冲突,也会导致输入无响应。我一般用netstat看一下端口占用情况,换个端口就好。

4.4 性能问题:模拟器跑得太慢

yaAGC是纯软件模拟,而且模拟了周期级的时序,所以速度不可能和现代CPU比。如果你觉得太慢,可以试试关掉--trace,或者用--fast模式(如果有的话)。但要注意,--fast模式可能会跳过一些时序相关的行为,导致某些依赖精确计时的代码跑不对。我一般只在调试非时序敏感的部分时用--fast,调试中断和I/O时还是用正常模式。

另一个影响性能的因素是模拟器的编译选项。如果你自己编译,记得用-O2优化。默认的-O0会慢很多。我在一台老笔记本上试过,-O0的时候模拟器跑得比实际AGC还慢,换了-O2之后流畅多了。

4.5 常见问题速查表

问题现象可能原因排查方法解决技巧
汇编报undefined symbol缺少equates文件检查是否包含了所有相关.agc文件用--include指定目录,或一次性传所有文件
模拟器PC跳到非法地址bank切换错误打开--trace看跳转前指令检查BANK指令参数,确认符号表bank号
DSKY输入无响应键盘映射不对或端口冲突检查模拟器是否前台运行,netstat看端口换端口,确认按键映射(E=回车,V=Verb)
模拟器运行太慢编译优化不足或trace开启检查编译选项,关闭trace用-O2重新编译,非调试时关trace
内存写入报错试图写只读段检查存储指令的地址确认地址在数据区,用符号表验证

5. 从Apollo-11代码里学到的工程原则

5.1 确定性优于灵活性

AGC的代码里几乎没有“动态分配”这个概念。所有内存都是静态分配的,所有任务都是预先定义好的。这看起来很不灵活,但换来了极高的确定性。在登月这种任务里,你不可能接受“内存分配失败”或者“任务调度延迟”这种问题。现代嵌入式系统里,很多安全关键领域(比如汽车刹车、医疗设备)也在回归这种设计思路:能用静态的就不用动态的,能编译时确定的就不留到运行时。

我在读代码的时候注意到,AGC的程序里有很多“断言”式的检查。比如,如果某个变量超出了预期范围,程序会跳到一个“恐慌”例程,点亮报警灯。这种防御性编程的思路,在现代代码里反而被忽视了。我们总是假设输入是合法的,内存是充足的,但在资源受限的环境里,这种假设是致命的。

5.2 注释即文档

Apollo-11的代码注释密度极高,有些地方甚至每行代码都配了注释。而且注释不是那种“i++ // 增加i”的废话,而是解释了“为什么”这么做。比如,有一段代码是处理雷达数据的,注释里写了“如果高度低于1000英尺,切换到低量程模式,因为高量程的精度不够”。这种注释对于后来维护的人(或者几十年后像我这样的考古者)来说,价值连城。

我现在的习惯是,写代码的时候至少把“为什么”写清楚。比如,你选了一个特定的算法,不是因为“大家都这么用”,而是因为“在这个场景下,它的时间复杂度是O(n),而另一个是O(n^2),我们的数据量是10万,所以选这个”。这种注释在代码审查的时候能省很多口水。

5.3 测试到极致

Apollo-11的代码在正式使用前,经过了无数次的模拟和测试。我看了他们的测试记录,光是“登月下降”这个阶段,就模拟了上千次,包括各种异常情况:雷达失效、发动机推力不足、甚至宇航员误操作。这种测试的覆盖度,在现代软件项目里也是罕见的。

我在自己的项目里借鉴了这一点:对于关键路径,不要只测“正常流程”,要专门写测试用例去覆盖“异常流程”。比如,如果你的代码要处理网络请求,除了测“请求成功”,还要测“超时”“返回错误码”“返回格式不对”这些情况。这些测试用例写起来麻烦,但真出问题的时候,能帮你快速定位。

5.4 资源受限下的创造力

AGC只有72KB内存,2MHz主频,但它完成了登月这么复杂的任务。这让我反思:我们现在的开发环境是不是太“奢侈”了?动不动就上框架,一个简单的功能引入几百KB的依赖。当然,我不是说要用汇编去写Web应用,但在设计系统的时候,多想想“这个功能真的需要这么多资源吗”,往往能发现优化空间。

我在做一个物联网项目的时候,一开始用了一个现成的MQTT库,编译出来固件有500KB。后来我读了一下那个库的源码,发现大部分功能我根本用不到,就自己写了一个精简版的MQTT客户端,固件一下子降到了80KB。这个思路就是从Apollo-11的代码里学来的:只做需要做的事,不做“可能以后会用到”的事。

6. 后续可以怎么玩

如果你已经跑通了基本的模拟,可以试试这几个方向。一是修改代码,比如把登月舱的下降速度改大一点,看看导航系统会不会报警。这能帮你理解代码里的保护逻辑。二是用模拟器的调试接口写一个自动化脚本,比如自动输入一系列DSKY指令,然后检查内存里的结果。这相当于给AGC写单元测试。三是把AGC的代码移植到现代平台上,比如用C语言重写某个模块,然后对比行为。这个难度比较大,但能让你深入理解每一行汇编的意图。

我个人的体会是,Apollo-11这个项目最大的价值不在于它“古老”,而在于它“纯粹”。没有框架,没有依赖,没有魔法,每一行代码你都能看懂,每一个字节你都能追踪。在这个抽象层越来越厚的时代,偶尔回到这种“裸机”环境,反而能让你对计算机的本质有更清晰的认识。我每次调试完AGC的代码,再回去写现代应用,都会觉得那些所谓的“复杂问题”其实没那么复杂。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 19:49:32

HDU 2059龟兔赛跑:动态规划多阶段决策优化实战

1. 项目概述:一道经典动态规划题的实战拆解“HDU 2059 龟兔赛跑”不是某个新上线的App,也不是某款网红硬件,而是杭州电子科技大学(HDU)在线判题系统(Online Judge,简称OJ)中编号为20…

作者头像 李华
网站建设 2026/10/15 19:49:07

Seasar框架实战解析:从S2Container依赖注入到S2Dao应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:47:59

卓越嵌入式工程师培养计划:从零散知识到系统化能力

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:47:52

基于MP2642GR-Z与STM32L031C6的两串电池主动均衡设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:47:51

layui深度实战:模块化机制、渲染原理与后台开发避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华