☰
Apollo-11 AGC仿真复原:从指令集到制导任务的可观测执行内核
2026/10/16 4:12:13 网站建设 项目流程

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

第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那段著名的“个人一小步、人类一大步”。但在我这里,它指的是一套我反复打磨过的历史指令与制导计算机(AGC)仿真与代码复原项目。说白了,就是把当年那台算力还不如现在一块电子表、却把人送上月球的机载计算机,用现代的方式重新跑起来、读明白、改得动。这个项目能做什么?它能让你在本地把原始汇编指令一条条执行、观察寄存器变化、复现关键飞行阶段的制导逻辑;它解决的是“历史代码看不懂、跑不起来、改不动”的问题;适合对嵌入式、汇编、实时系统、航天软件工程感兴趣的从业者和硬核爱好者。

我之所以选这个方向,是因为现代软件工程里太多“抽象层”把底层遮得严严实实,很多人写了五年代码都没见过寄存器长什么样。而AGC这套东西,没有操作系统、没有内存管理单元、没有浮点单元,一切靠裸机汇编和巧妙的数学技巧硬扛。把它啃下来,你对“计算机到底怎么工作”的理解会上一个台阶。整个项目的设计思路可以概括成三句话:先复原指令集,再复现执行环境,最后对齐历史任务阶段。这三步缺一不可,顺序也不能乱,因为指令集是地基,执行环境是骨架,任务阶段是血肉。

在方案选型上,我做过几轮取舍。第一轮我试过直接用现成的开源仿真器,结果发现它们大多只关心“能不能跑”,对内部状态的可见性做得很差,调试时像隔着一层毛玻璃。第二轮我想自己从零写一个解释器,但很快意识到指令集的边界情况太多,纯手写容易漏。最终我采用的是**“参考公开的指令集文档 + 自建可观测执行内核 + 历史任务脚本驱动”**的组合方案。这个方案的优势在于:执行内核完全可控,每一条指令执行前后的寄存器、累加器、程序计数器都能打印出来;同时任务脚本可以按历史时间线逐步推进,方便对照公开的飞行阶段记录做验证。要避免的问题也很明确——不要一上来就追求“全指令集覆盖”,那样会陷入细节泥潭,正确的做法是先覆盖核心的算术、跳转、I/O指令,把主流程跑通,再逐步补齐边角指令。

这里有个关键判断我想单独说:为什么不用高级语言模拟而坚持用接近硬件的解释方式?因为AGC的很多“坑”恰恰藏在硬件行为里。比如它的累加器是16位但实际有效位更少,溢出处理有特定规则;比如它的指令周期和内存访问时序强相关。如果你用Python的整数直接模拟,这些细节全被抹平了,跑出来的结果“看起来对”,但一旦对照真实遥测数据就会发现偏差。所以我的执行内核里,所有寄存器都严格按位宽和溢出规则实现,宁可慢一点,也要保证行为一致。实测下来,这种“较真”在后期对齐任务阶段时省了无数排查时间。

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

2.1 指令集复原:从文档到可执行代码

指令集复原是整个项目最枯燥但也最不能偷懒的环节。AGC的指令字长是16位,但并不是所有位都用来表示操作码,有些位是给寄存器地址、有些是给扩展操作码用的。我踩过的第一个坑就是想当然地按“操作码+操作数”的经典格式去解析,结果发现它的编码方式更接近“位域拼接”,不同指令的操作码长度还不一样。解决办法是老老实实把指令集手册里的编码表逐条抄下来,做成一张查找表,用位掩码去匹配。

具体操作上,我建议你这样做:先建立一个指令描述结构,包含助记符、操作码掩码、操作码值、操作数位数、执行函数指针。然后用一个循环去匹配当前指令字。这里有个细节技巧——把高频指令放在查找表前面,因为AGC程序里跳转和算术指令占比极高,提前匹配能省不少时间。虽然现代机器不在乎这点开销,但养成这个习惯对理解“为什么当年要这么设计”有帮助。

注意:指令集文档里有些指令在不同模式下行为不同,比如某些算术指令在“溢出模式”下会触发中断。复原时一定要把这些模式标志位也实现进去,否则跑复杂任务时会莫名其妙卡死。

2.2 执行内核:让每条指令都“看得见”

执行内核是我花时间最多的地方,也是这个项目最有价值的部分。核心要求就一个:任何时刻都能回答“现在寄存器里是什么、下一条要执行什么、上一条干了什么”。为了实现这个,我在内核里维护了一个完整的状态快照结构,包括累加器、辅助寄存器、程序计数器、溢出标志、中断使能等。每执行一条指令,先把快照存进环形缓冲区,再执行,执行完再存一次。这样调试时可以把最近几百条指令的状态变化全部回放出来。

这里有个实操要点:环形缓冲区的大小要可配置,默认我设的是4096条。太小了排查长流程不够用,太大了内存占用高且回放慢。4096这个值是我反复试出来的平衡点,覆盖大多数子程序的完整执行轨迹绰绰有余。另外,快照结构里我特意加了一个“指令来源地址”字段,这样当程序跑飞时,能立刻定位到是哪条跳转指令把控制流带偏的。

还有一个容易被忽略的点是中断处理。AGC的中断不是现代意义上的“异常”,而是硬件级别的信号跳转。我在内核里用了一个待处理中断队列,每执行完一条指令就检查队列,有中断就插入执行。这个设计让整个执行流程保持单线程、可预测,避免了多线程带来的状态竞争问题。实测下来,这种“伪并发”方式完全够用,而且调试友好得多。

2.3 任务脚本驱动:把历史飞行阶段变成可执行序列

光有指令集和执行内核还不够,你得有东西去“驱动”它。我的做法是把公开的飞行阶段拆解成一系列任务脚本,每个脚本是一串带时间戳的输入事件和预期状态检查点。比如“起飞后第30秒,检查高度寄存器是否在预期范围内”。这样做的好处是,整个仿真过程变成可重复、可验证的,而不是“跑一遍看看”。

写任务脚本时,我建议你从最简单的阶段开始,比如地面测试阶段,先验证基本的输入输出和算术逻辑,再逐步推进到制导、导航这些复杂阶段。每个脚本都要有明确的“通过/失败”判定条件,不能靠肉眼观察。我早期偷懒没写判定条件,结果后面阶段一多,根本分不清是仿真错了还是我观察错了。后来补上自动判定,效率直接翻倍。

提示:任务脚本里的时间戳不要用真实时间,而要用“指令周期数”。因为仿真速度可快可慢,用真实时间会导致结果不可复现。指令周期数是确定性的,跑一万遍结果都一样。

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

3.1 环境搭建与依赖准备

先说环境。我用的是一台普通的开发机,操作系统不限,因为整个项目是跨平台的。核心依赖只有两个:一个支持位操作的编程语言运行时,一个用于可视化状态变化的轻量级界面库。语言我选的是C++,原因是位操作直接、性能可控、和底层打交道的直觉最接近。如果你更习惯Rust或Go,也完全没问题,只要能把位域和指针玩明白就行。

搭建步骤我列一下,你可以直接抄作业:

  1. 创建工作目录,初始化版本管理。这一步别省,后面改指令集实现时你会感谢自己。
  2. 建立三个核心模块目录:isa(指令集)、core(执行内核)、scripts(任务脚本)。
  3. 在isa里先写指令描述结构体和查找表初始化函数。
  4. 在core里写状态快照结构和执行循环。
  5. 在scripts里写第一个最小任务脚本,比如“加载一个数、加一、存回去”。

这里有个参数选择我想展开说:状态快照的存储方式。我试过两种,一种是每执行一条指令就完整拷贝一份状态结构,另一种是只记录变化字段。前者简单但内存占用大,后者省内存但回放逻辑复杂。最终我选了前者,因为4096条快照的内存占用在现代机器上完全可以接受,而简单带来的调试便利性远超那点内存。算一下:一个状态结构大概几十字节,4096条也就几百KB,忽略不计。

3.2 指令执行循环的完整实现

执行循环是整个项目的心脏,我把它拆成四个阶段:取指、译码、执行、写回。取指阶段从程序计数器指向的内存地址读出指令字;译码阶段用查找表匹配指令并解析操作数;执行阶段调用对应的执行函数;写回阶段更新寄存器和标志位。这四个阶段之间我加了可选的钩子函数,方便在任意阶段插入日志或断点。

关键代码逻辑我用伪代码说明一下:

while (running) { snapshot_before = capture_state(); instruction = memory[pc]; decoded = decode(instruction); if (decoded == INVALID) { handle_invalid_instruction(); continue; } execute(decoded); snapshot_after = capture_state(); ring_buffer_push(snapshot_before, snapshot_after); check_interrupts(); check_breakpoints(); }

这段逻辑看起来简单,但有几个细节决定成败。第一,capture_state必须是无副作用的,不能改变任何寄存器或标志位,否则会污染执行结果。第二,decode失败时不能直接崩溃,要有明确的错误处理路径,因为历史程序里可能存在未文档化的指令或数据被误当作指令执行的情况。第三,check_interrupts要在写回之后、下一条取指之前执行,这个时序不能乱,否则中断响应会延迟一个周期,导致时间相关的逻辑对不上。

实测下来,这个循环在普通机器上每秒能执行几百万条指令,跑完一个完整任务阶段也就几秒钟。如果你觉得慢,可以把日志钩子关掉,性能还能再翻几倍。

3.3 第一个可运行任务:从“加一”到“制导”

我建议你的第一个任务脚本就做最傻的事:把一个数加载到累加器,加一,存回内存,然后检查结果。这个脚本虽然简单,但能验证取指、译码、执行、写回、内存访问全链路是否通畅。我当初就是靠这个脚本发现了内存地址映射的一个错误——我把指令内存和数据内存的地址空间搞混了,导致存回去的数覆盖了后面的指令,程序直接跑飞。

跑通“加一”之后,下一步是实现一个简单的循环,比如从1累加到100。这个阶段你会遇到跳转指令和条件判断,是验证控制流的关键。再往后,就可以尝试复现历史任务里的制导计算了。制导计算涉及大量的三角函数和向量运算,AGC用了一种叫“CORDIC”的迭代算法来算三角函数,没有查表也没有浮点。我在实现这部分时,专门写了一个CORDIC的独立测试脚本,先验证算法本身正确,再嵌入到任务流程里。

这里分享一个实操心得:每实现一个新指令或新算法,都要写一个独立的单元测试脚本,不要直接塞进大任务里跑。因为大任务一旦出错,你很难判断是哪个环节的问题。独立测试脚本跑通了,再集成,出问题的概率会低很多。我早期图省事跳过了这步,结果在一个大任务里排查了整整两天,最后发现只是一个标志位没清零。

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

4.1 程序跑飞:控制流异常排查

程序跑飞是这类项目最常见的现象,表现就是程序计数器跳到一个不该去的地方,然后执行一堆无意义的指令。排查思路我总结成三步:先看最后一条正常执行的指令,再看跳转目标地址,最后检查跳转条件。大多数跑飞都是因为跳转条件判断错了,而条件判断错往往是因为标志位没正确更新。

我遇到过一个典型案例:一个条件跳转指令在累加器为零时应该跳转,但实际没跳。查了半天发现,前一条算术指令执行后,零标志位确实置位了,但我在写回阶段不小心把它清零了。原因是我在写回逻辑里加了一个“默认清零”的操作,本意是防止标志位残留,结果把该保留的也清了。解决办法是只清零那些明确需要复位的标志位,其他标志位保持原样。这个坑很隐蔽,因为大部分时候程序逻辑是对的,只有特定条件组合才会暴露。

注意:如果你发现程序在某个特定输入下才跑飞,大概率是标志位或边界条件问题。这时候把状态快照打开,逐条对比正常和异常输入下的执行轨迹,差异点就是问题所在。

4.2 结果偏差:数值计算精度问题

AGC的数值计算有很多“近似”和“截断”,如果你用现代浮点数去模拟,结果会对不上。我早期就犯过这个错,用双精度浮点算三角函数,结果和预期值差了十万八千里。后来才明白,AGC用的是定点数加迭代算法,精度和舍入方式都和浮点完全不同。

解决办法是完全按定点数规则实现,包括乘法后的移位、加法的溢出处理、除法的迭代次数。这里有个参数需要计算:定点数的小数位数。AGC里不同变量的小数位数不一样,有的用单精度(比如14位小数),有的用双精度(比如28位小数)。我在实现时给每个变量都标注了精度,运算时按最高精度对齐。这个工作量不小,但一旦对齐,结果就非常稳定。

排查精度问题时,我建议你从最简单的运算开始验证,比如两个定点数相加,再逐步到乘法、除法、三角函数。每步都和公开的预期值对比,偏差超过阈值就停下来查。不要等到整个任务跑完才发现结果不对,那时候排查范围太大了。

4.3 常见问题速查表

问题现象可能原因排查方法解决技巧
程序计数器跳飞跳转条件错误或标志位异常查看最后正常指令和跳转目标检查标志位写回逻辑,避免误清零
数值结果偏差大浮点模拟代替定点运算对比单步运算结果按定点规则重实现,标注精度
执行速度突然变慢日志钩子或断点未关闭检查钩子开关状态生产运行时关闭所有调试钩子
中断未响应中断队列检查时机错误查看中断检查在循环中的位置确保在写回后、取指前检查
内存数据被覆盖指令和数据地址空间混淆打印内存访问地址范围严格分离指令内存和数据内存
任务脚本不可复现使用真实时间而非周期数检查脚本时间戳类型统一改用指令周期数

4.4 独家避坑技巧

除了上面这些,我再分享几个只有实际动手才会遇到的坑。第一个是指令字的字节序问题。AGC的指令字是16位,但存储时高字节和低字节的顺序在不同文档里描述不一致。我一开始按一种顺序实现,结果所有指令都译码错了。后来用一条已知指令的二进制表示去反推,才确定正确的字节序。这个坑的教训是:不要信文档的描述,要用已知数据去验证。

第二个是内存映射的边界。AGC的可用内存很小,而且不同区域有不同用途,有的只读、有的可写、有的映射到I/O。我早期没做边界检查,结果程序写到了一个只读区域,仿真器直接崩溃。后来加了访问权限检查,非法访问时打印警告并跳过,而不是崩溃。这样即使程序有问题,仿真器也能继续跑,方便定位。

第三个是任务脚本的检查点设计。检查点不能太密也不能太疏。太密了脚本臃肿,太疏了出问题定位不到。我的经验是在每个逻辑阶段结束时设一个检查点,比如“制导计算完成”“姿态调整完成”。检查点里对比关键寄存器和内存值,偏差超过容差就报错。容差怎么定?我一般设成预期值的千分之一,这个精度对大多数阶段够用,也不会因为正常的舍入误差误报。

5. 项目延展与个人体会

这个项目跑通之后,我做了几个延展,觉得挺有意思。一个是把执行轨迹可视化,用简单的折线图展示关键寄存器随时间的变化,一眼就能看出程序在哪个阶段“抖”了一下。另一个是做指令集覆盖率统计,看看哪些指令在历史任务里从没被用到,哪些是高频指令。统计结果挺反直觉的——有些文档里重点介绍的指令,实际任务里一次都没出现;而一些看起来不起眼的跳转指令,占了执行总量的三成以上。

还有一个延展方向是对比不同版本的指令集实现。AGC的指令集在项目过程中有过修订,有些指令的行为变了。我把不同版本的实现做成可切换的,跑同一个任务脚本,观察结果差异。这个对比让我对“硬件迭代如何影响软件行为”有了非常具体的感受。

我个人在实际操作中的体会是,这类复原项目最大的价值不在于“跑通”,而在于逼你去理解每一个设计决策背后的约束。为什么用定点不用浮点?因为当年没有浮点硬件,而且定点在特定范围内精度更可控。为什么中断处理那么简单?因为实时性要求高,复杂的中断嵌套会引入不可预测的延迟。这些约束在现代开发里往往被抽象掉了,但理解它们能让你在面临类似约束时做出更好的判断。

最后再分享一个小技巧:如果你也想做类似的项目,从最小的可运行单元开始,每一步都写测试,每一步都做版本管理。不要想着一次性把整个系统搭好再跑,那样大概率会卡在某个细节上出不来。我见过太多人兴致勃勃地开始,然后在指令译码阶段就放弃了。慢慢来,反而比较快。

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

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

立即咨询