☰
Apollo-11 AGC 制导计算机:从仿真环境搭建到汇编编程实战
2026/10/16 4:55:32 网站建设 项目流程

1. 从“Apollo-11”这个名字说起:它到底指什么

第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的第一反应是阿波罗登月计划。这个联想没错,但如果只停在这一层,就完全错过了它在技术圈里真正的分量。在软件工程和计算机历史的语境下,Apollo-11 指的是阿波罗11号制导计算机(AGC,Apollo Guidance Computer)上运行的那套飞行软件,以及围绕它衍生出来的一整套工程实践、代码仓库和仿真工具链。

这套东西为什么值得单独拿出来讲?因为它是一个极端约束条件下的工程奇迹。AGC 的内存小到什么程度?它的核心存储(Core Rope Memory)只有 72KB 左右,其中存储程序的部分大约 36KB,RAM 更是只有 2KB 出头。你今天手机里随便一张照片都比它整个内存大几千倍。但就是在这种条件下,它完成了登月舱下降、上升、交会对接、返回地球这一整套复杂制导任务。更关键的是,这套代码是手工编织进核心存储的——物理意义上的“编织”,一根根线穿过磁环,穿错了就得重来。

所以“Apollo-11”这个标题背后,至少有三层价值值得深挖。第一层是历史与工程价值:它是人类第一次把“软件可靠性”推到生死攸关级别的项目,很多现代软件工程的概念(比如优先级调度、冗余设计、错误恢复)都能在这里找到源头。第二层是代码与仿真价值:AGC 的源码后来被公开,社区做了完整的仿真器和汇编器,你可以在自己电脑上跑起来,甚至用 Python 或 JavaScript 写代码去控制它。第三层是学习与复现价值:对于想理解“底层系统如何在资源极度受限时做取舍”的开发者来说,这是一个绝佳的案例库。

这篇文章适合谁看?如果你是嵌入式开发者、对计算机历史感兴趣的工程师、想找硬核项目练手的学生,或者单纯被“人类怎么用这么点资源干成这么大的事”这个问题吸引,那接下来的内容会对你有用。我会从 AGC 的架构讲起,拆解它的核心设计取舍,然后带你走一遍本地搭建仿真环境、跑通代码、甚至自己写一段控制逻辑的完整流程,最后分享几个我在折腾过程中踩过的坑和总结出来的经验。全程不堆术语,尽量用你能听懂的话把原理讲透。

2. AGC 的硬件底子与软件架构:为什么它只能用“巧劲”

2.1 核心存储与内存的物理限制决定了编程范式

要理解 AGC 的软件,必须先理解它的硬件。AGC 的存储分两类:核心存储(Core Rope Memory)和核心内存(Core Memory)。前者是只读的,存程序;后者是可读写的,存变量和临时数据。核心存储的容量按“字”算,每个字 16 位(其中 15 位数据加 1 位奇偶校验),总共有 36,864 个字,换算成字节大约是 72KB。核心内存更小,只有 2,048 个字,约 4KB。

这里有个关键点:核心存储是出厂即固化的。程序在制造阶段就被编进存储体,一旦编好就不能改。这意味着软件在“烧录”之前必须经过极其严格的验证,因为改一个 bit 的成本高到离谱。这种约束直接催生了一套非常克制的编程风格:没有动态内存分配,没有复杂的运行时,所有逻辑都必须是确定性的、可静态分析的。

我打个比方,这就像你要在一个只能写一次的石板上刻一篇文章,而且石板面积只有一张明信片那么大。你不可能写草稿再改,必须一次成型。所以 AGC 的代码里几乎没有“通用”的抽象,每个函数都为了特定任务高度定制。这种“反抽象”的做法在现代看来很笨,但在当时是唯一可行的路径。

2.2 指令集与执行模型:16 位字长下的精打细算

AGC 的指令集是围绕 16 位字长设计的。它的指令格式分两种:基本指令和扩展指令。基本指令占一个机器字,包含操作码和地址字段;扩展指令需要两个机器字,能表达更复杂的操作。指令类型涵盖算术运算、逻辑运算、跳转、I/O 操作等,但数量非常有限,总共只有几十条。

执行模型上,AGC 有一个累加器(A)和一个低位寄存器(L),两者配合完成乘除法。乘法的结果会拆到 A 和 L 两个寄存器里,因为 16 位乘 16 位的结果是 32 位,单个寄存器装不下。这种设计在当时很常见,但用起来需要程序员非常清楚数据在哪个寄存器里,稍不留神就会算错。

更麻烦的是中断处理。AGC 有一个向量中断系统,但中断优先级是硬编码的。高优先级任务(比如制导计算)可以打断低优先级任务(比如显示更新),但反过来不行。这种设计保证了关键任务不会被琐事耽误,但也意味着低优先级任务可能被“饿死”。实际飞行中,正是这套优先级机制在关键时刻救了命——登月舱下降时计算机过载报警,软件自动丢弃了低优先级任务,保住了制导核心逻辑。

2.3 任务调度:优先级驱动的实时系统雏形

AGC 的调度器叫Executive,可以理解为一个极简的实时操作系统。它管理着一组“作业”(Job),每个作业有固定的优先级。调度器按优先级从高到低扫描,找到就绪的最高优先级作业并执行。如果高优先级作业被中断,它会保存现场,等中断处理完再恢复。

这套机制的核心是确定性。每个作业的执行时间都是可预测的,因为代码路径是固定的,没有循环等待,没有动态分配。这种确定性在航天场景下是刚需——你不可能让计算机在关键时刻“卡一下”。现代实时操作系统(RTOS)的很多设计思路,比如优先级抢占、任务就绪队列,都能在 Executive 里找到影子。

但这里有个坑:优先级反转。如果低优先级任务占用了高优先级任务需要的资源,高优先级任务就会被阻塞。AGC 通过严格的设计避免了这种情况——所有共享资源都是只读的,或者访问顺序被精心安排过。这种“设计阶段就消除并发问题”的思路,比后来靠锁和信号量去补救要高明得多。

3. 把 AGC 跑起来:本地仿真环境的搭建与验证

3.1 仿真器选型:为什么我最终选了 yaAGC

社区里能跑 AGC 代码的仿真器不止一个,比较知名的有yaAGC、Virtual AGC和几个基于 JavaScript 的网页版。我前后试了三种,最后长期用的是 yaAGC。原因有三个:第一,它的源码结构清晰,C 语言写的,编译依赖少,在 Linux 和 macOS 上都能顺利跑起来;第二,它支持完整的 AGC 指令集,包括那些冷门的扩展指令;第三,它自带调试接口,可以单步执行、查看寄存器和内存,这对理解代码行为非常关键。

Virtual AGC 功能更全,但依赖也更多,配置起来容易卡在某个库的版本问题上。网页版适合快速体验,但没法深入调试。所以如果你是想“认真折腾”而不是“看一眼”,yaAGC 是更务实的选择。

安装过程不复杂,但有几个细节容易忽略。首先,你需要一个 C 编译器(gcc 或 clang 都行)和 make 工具。其次,yaAGC 依赖一个叫readline的库来做命令行交互,如果系统里没有,编译会报错。在 Ubuntu 上直接apt install libreadline-dev就行,macOS 上用 Homebrew 装readline然后设置好编译路径。

# 以 Ubuntu 为例,安装依赖 sudo apt update sudo apt install build-essential libreadline-dev git # 克隆 yaAGC 源码 git clone https://github.com/virtualagc/virtualagc.git cd virtualagc/yaAGC # 编译 make

编译完成后,你会得到一个yaAGC可执行文件。但光有仿真器还不够,你还需要ROM 镜像——也就是 AGC 的程序数据。这些数据在社区仓库里以二进制或十六进制格式提供,通常叫agc.bin或类似名字。把 ROM 文件和仿真器放在同一个目录下,运行./yaAGC --rom=agc.bin,如果一切正常,你会看到仿真器启动并等待输入。

3.2 ROM 加载与启动自检:第一次跑通的关键检查点

第一次跑的时候,我卡了大概两个小时,问题出在 ROM 文件的格式上。社区提供的 ROM 有几种格式:纯二进制、十六进制文本、带校验的二进制。yaAGC 默认读的是纯二进制,如果你喂给它十六进制文本,它会报“ROM 大小不对”或者直接崩溃。

怎么判断你手里的 ROM 是什么格式?用file命令看一眼,或者用hexdump -C agc.bin | head看开头几个字节。如果是可打印的 ASCII 字符(比如0到9、A到F),那多半是十六进制文本,需要先转成二进制。转换工具社区也有,叫hex2bin或者自己写个几行的 Python 脚本就行。

# 把十六进制文本 ROM 转成二进制 with open('agc.hex', 'r') as f: hex_str = f.read().replace('\n', '').replace(' ', '') with open('agc.bin', 'wb') as f: f.write(bytes.fromhex(hex_str))

ROM 加载成功后,仿真器会开始执行启动序列。AGC 的启动代码会做一系列自检:检查内存、检查指令、初始化寄存器。如果自检通过,你会看到类似AGC started的输出,然后仿真器进入待命状态。这时候你可以通过调试接口输入命令,比如查看某个内存地址的值,或者让仿真器单步执行。

这里有个经验:不要一上来就跑完整任务。AGC 的完整飞行软件包含登月、上升、交会等多个阶段,直接跑很容易因为某个输入条件不满足而卡住。更好的做法是先跑一个简单的测试程序,比如让 AGC 做几次加法,确认仿真器和 ROM 的配合没问题,再逐步增加复杂度。

3.3 用调试接口观察指令执行:理解 AGC 行为的最快方式

yaAGC 的调试接口是我最喜欢的功能。你可以用break命令在某个地址下断点,用step单步执行,用reg查看寄存器,用mem查看内存。这些命令看起来简单,但组合起来能让你像看慢动作一样观察 AGC 的每一步操作。

举个例子,AGC 的加法指令是AD,它把内存里的值加到累加器 A 上。你可以先找到一段做加法的代码,在AD指令的地址下断点,然后单步执行,观察 A 寄存器的变化。如果结果不对,就往前查,看数据是从哪里加载的,中间有没有被其他指令修改。

这种“逐指令追踪”的方法看起来很笨,但对于理解 AGC 这种没有高级抽象的代码来说,是最有效的。现代开发里你很少需要关心一条指令怎么执行,但在 AGC 上,每条指令都值得看。我建议你在仿真器里跑一个“从 1 加到 10”的小程序,亲手跟一遍执行流程,比看十篇文章都管用。

4. 写一段 AGC 汇编:从“Hello World”到控制逻辑

4.1 AGC 汇编的基本语法与工具链

AGC 的汇编语言叫AGC Assembly,语法和现代汇编差别很大。它的指令助记符很短,比如TC(Transfer Control,跳转)、CCS(Count, Compare, and Skip)、ADS(Add to Storage)。操作数通常是一个地址,或者一个“标志位”组合。

写 AGC 汇编需要两个工具:汇编器和链接器。社区里常用的是yaYUL,它是 Virtual AGC 项目的一部分。yaYUL 把.agc源文件汇编成二进制,然后你可以用仿真器加载运行。

# 用 yaYUL 汇编一个源文件 ./yaYUL my_program.agc # 输出通常是 my_program.bin 或类似名字

AGC 汇编的源文件结构比较固定:开头是注释和常量定义,然后是代码段。常量定义用=赋值,比如COUNT = 10。代码段里每条指令占一行,格式是标签 指令 操作数。标签是可选的,用来标记跳转目标。

这里有个容易踩的坑:AGC 的地址空间是分页的。它的内存被分成多个“bank”,每个 bank 有固定大小。跳转指令只能在同一 bank 内跳,跨 bank 跳转需要用特殊的BANK指令。如果你写的代码跨了 bank 但没处理,汇编器会报错,或者运行时跳到错误的地方。我一开始没注意这个,写了个稍微长点的程序就崩了,查了半天才发现是 bank 切换的问题。

4.2 一个可运行的示例:用 AGC 汇编实现累加计数

下面这段代码是我自己写的,功能很简单:从 1 累加到 10,把结果存到一个内存地址里。虽然简单,但它涵盖了 AGC 汇编的几个核心概念:常量定义、内存分配、循环、条件跳转。

# 累加计数示例 COUNT = 10 # 循环次数 RESULT DS 1 # 结果存储,分配 1 个字 CAF ONE # 把常数 1 加载到累加器 TS RESULT # 把累加器存到 RESULT CAF COUNT # 把 COUNT 加载到累加器 TS COUNTER # 存到计数器 LOOP CAF RESULT # 加载当前结果 AD ONE # 加 1 TS RESULT # 存回结果 CAF COUNTER # 加载计数器 AD NEGONE # 减 1(加 -1) TS COUNTER # 存回计数器 CCS COUNTER # 检查计数器 TC LOOP # 如果计数器 > 0,跳回 LOOP TC DONE # 否则跳到 DONE DONE TC DONE # 死循环,程序结束 ONE = 1 NEGONE = -1 COUNTER DS 1

这段代码里,CAF是“Clear and Add Fixed”,把常数加载到累加器;TS是“Transfer to Storage”,把累加器存到内存;AD是加法;CCS是“Count, Compare, and Skip”,它会根据操作数的符号决定下一条指令是否跳过。CCS的行为比较绕,简单说就是:如果操作数为正,下一条指令执行,再下一条跳过;如果为负,下一条跳过,再下一条执行;如果为零,下两条都跳过。这个设计是为了方便实现循环和条件分支。

把这段代码用 yaYUL 汇编,然后用 yaAGC 加载运行,你可以在调试接口里查看RESULT地址的值,应该是 10。如果不对,就单步跟一下,看是哪一步算错了。

4.3 从示例到实际控制:AGC 代码的调试思路

跑通累加示例之后,你可以尝试更复杂的东西,比如模拟一个简单的“制导”逻辑:根据输入的角度和速度,计算一个修正量。AGC 的原始代码里有大量这类计算,核心是定点数运算。AGC 没有浮点数,所有小数都用定点表示,通常是 1/2 的幂次。比如 0.5 表示为 0x4000(因为 0x4000 是 16384,而 16384/32768 = 0.5)。

定点运算的坑在于溢出和精度损失。两个接近 1 的数相乘,结果可能超过 1,导致溢出;两个很小的数相乘,结果可能被截断成 0。AGC 的代码里有很多“缩放”操作,就是在计算前后调整数值范围,避免溢出。你自己写的时候也要注意这一点,尤其是做乘除法的时候。

调试 AGC 代码有个技巧:把中间结果存到内存里,用调试接口看。AGC 的内存很小,但存几个中间变量还是够的。每算完一步就存一下,然后单步执行,看每一步的结果是否符合预期。如果某一步不对,就往前查输入数据。这种“分步验证”的方法比一次性跑完再查错要高效得多。

5. 折腾 AGC 过程中踩过的坑与总结的经验

5.1 ROM 版本不匹配导致的“灵异现象”

我最开始用的是从某个非官方仓库下载的 ROM,跑起来之后发现有些指令的行为和文档描述不一致。比如CCS指令在操作数为零时的跳过行为,和我查到的资料对不上。一开始我以为是自己理解错了,后来换了一个官方仓库的 ROM,问题就消失了。原来那个非官方 ROM 是早期版本,有些指令的微码实现和后期版本不同。

这件事给我的教训是:AGC 的 ROM 有多个版本,不同版本之间可能有细微差异。如果你在仿真器里看到的行为和文档不符,先检查 ROM 版本。社区里通常会有版本说明,比如“这是 Apollo 11 的最终飞行版本”或者“这是 Apollo 15 的版本”。选一个和你目标一致的版本,能省掉很多莫名其妙的调试时间。

5.2 汇编器的 bank 切换报错与解决

前面提到过 bank 切换的问题,这里展开说一下。AGC 的地址空间被分成 36 个 bank,每个 bank 有 1024 个字(有些 bank 是 2048 个字)。跳转指令TC只能跳转到当前 bank 内的地址。如果你要跳到另一个 bank,需要先用BANK指令切换,然后再跳。

yaYUL 汇编器在遇到跨 bank 跳转时会报错,提示“address out of range”或者“bank mismatch”。解决办法有两种:一是把代码重新组织,让跳转目标落在同一个 bank 内;二是显式地插入BANK指令。第二种方法更灵活,但需要你清楚每个 bank 的边界在哪里。

我个人的习惯是:在写代码之前先规划好 bank 布局。把相关的函数放在同一个 bank 里,把常量和数据放在固定的 bank 里。这样能最大程度避免跨 bank 跳转,代码也更清晰。如果实在避不开,就在跳转前加BANK指令,并在注释里写清楚为什么要切。

5.3 定点数运算的精度陷阱

AGC 的定点数运算是我踩坑最多的地方。举个例子,我想计算sin(30°),理论上结果是 0.5。但在 AGC 里,角度是用“半圈”为单位表示的,30° 对应的是 1/6 圈,也就是 0.1667 左右。这个数在定点表示下会有精度损失,算出来的sin值可能不是精确的 0.5,而是 0.4998 或者 0.5002。

这种误差在单次计算里很小,但如果你的逻辑依赖精确比较(比如“如果结果等于 0.5 就执行某操作”),就会出问题。AGC 的原始代码里很少做精确比较,通常是用“大于”或“小于”来判断,留出一定的容差。你自己写代码时也要注意这一点,永远不要假设定点运算的结果是精确的。

另一个坑是乘法的溢出。AGC 的乘法指令MP会把结果的高位放在 A 寄存器,低位放在 L 寄存器。如果你只取 A 寄存器的值,相当于把结果除以了 32768。这个行为在计算“比例”时很有用,但如果你忘了这一点,就会得到完全错误的结果。我建议你在做乘法之前,先想清楚结果的量级,确定要不要取 L 寄存器的值。

5.4 仿真器性能与实时性的取舍

yaAGC 默认是“尽可能快”地执行指令,而不是模拟真实 AGC 的时钟频率。这意味着仿真器跑得比真实硬件快得多,对于调试来说这是好事,但对于理解“实时性”来说就不太直观。如果你想观察 AGC 在真实时间尺度下的行为,需要给仿真器加一个“节流”参数,让它按真实频率执行。

# 按真实频率执行(假设 AGC 主频约 2MHz,具体参数看仿真器文档) ./yaAGC --rom=agc.bin --throttle=1.0

加了节流之后,仿真器会慢下来,你能更清楚地看到任务调度的时序。但调试的时候还是建议关掉节流,不然单步执行会等到天荒地老。我的做法是:理解时序时开节流,调试逻辑时关节流,根据目的切换。

6. 这套东西对现代开发者的实际价值

折腾 AGC 仿真和汇编,表面上看是在“玩古董”,但实际上它逼着你面对一些现代开发中很少需要考虑的问题:资源极度受限时怎么做设计取舍、没有抽象层时怎么组织代码、确定性为什么比性能更重要。这些问题在嵌入式、航天、工业控制等领域依然是核心挑战。

我自己的收获是,写完 AGC 汇编之后再回头看现代 C 代码,会对“编译器帮你做了多少事”有更具体的感知。比如 AGC 里每个函数调用都要手动保存返回地址,而现代编译器自动处理栈帧;AGC 里每个变量都要手动分配地址,而现代语言有自动内存管理。这种对比让你更清楚哪些是“本质复杂度”,哪些是“工具帮你消除的偶然复杂度”。

如果你也想试试,我的建议是从仿真器跑通开始,然后写几个简单的汇编程序,最后尝试理解一段原始 AGC 代码的逻辑。不需要全部搞懂,能跟下来一个完整的计算流程就够了。这个过程会让你对“计算机到底怎么工作”有一个更底层的认识,这种认识在调试复杂系统时非常有用。

最后分享一个小技巧:AGC 的原始代码里有很多注释,这些注释是当年程序员写的,读起来很有意思。比如有一段注释写着“这里假设输入不会超过 1,如果超过了,我们就完了”。这种带着人类情绪的注释在现代代码里很少见,但它提醒你,写代码的是人,而人会在极端压力下做出各种取舍。理解这些取舍,比记住指令集更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询