南大ICS的PA1,章节名叫“开天辟地的篇章”,副题是“最简单的计算机”。很多第一次做这门课实验的同学,打开实验手册时会愣一下:我还没学会写多少代码,就要做一台“计算机”了?没错,PA1要你做的,正是用C语言在软件里捏出一台最小但五脏俱全的x86计算机。它没有屏幕、没有键盘,只有一块内存、一组寄存器和一个不断取指执行的CPU核心,你可以在里面跑几行汇编,看着eip一点点往前走。这篇文章把自己在建这台“最简单的计算机”时踩过的坑、查过的文档、总结出的思路全部写下来,给正在做南大ICS PA1的同学一个可复现的参考,也让还没到这个阶段的人提前感受一下计算机系统基础这门课到底在玩什么。
1. PA1到底是什么:从“开天辟地”四个字说起
1.1 这不是一个普通的编程大作业
先纠正一个常见误解:PA1不是那种写完交上去就完事的编程大作业,它是南大ICS课程里一整套“造计算机”实验的起点。整套PA从PA1到PA4,最终目标是让你亲手实现一台能跑简单操作系统程序的模拟计算机。这个模拟器有一个名字,叫NEMU,全称是Nanjing University Emulator。你写的不是某个业务功能,而是计算机体系结构里最核心的那一层:CPU如何取指、如何解码、如何执行、如何读写内存。
很多同学把它理解成“数据结构和算法之外的一个C语言练习”,这个定位偏了。PA1真正想教你的,是把《计算机组成原理》和《计算机系统结构》课本上的抽象概念,变成你能用gdb逐行调试的真实代码。教科书会告诉你CPU的五个阶段是取指、译码、执行、访存、写回,但只有当你亲眼看到cpu.eip取出一条指令、操作数被解析出来、然后寄存器值发生变化的时候,你才会从“背概念”变成“有体感”。
PA1的“最简单的计算机”,强调的是最小可运行系统。它不需要支持完整的x86指令集,也不需要处理中断、异常、虚拟内存,甚至连输入输出都只是通过一个调试命令行来做。它只需要做到一件事:能够把一小段程序加载进内存,然后让CPU一条一条执行完,最后通过特殊指令停下来。能做到这一点,就说明你对计算机的底层运行机制已经建立起了最基本的直觉。
1.2 PA1要交付什么
PA1的核心交付物,拆开来看其实是这么几块:NEMU工程框架、寄存器模型、物理内存模型、CPU执行循环、一批基础指令的实现、单步执行和监视点调试功能,以及通过AM抽象机器运行一段独立于操作系统的程序。这些名词堆在一起有点吓人,但你仔细看就会发现,每一步都踩在一条清晰的逻辑链上。
寄存器模型和内存模型是“硬件容器”。一个CPU如果没有寄存器,就没有地方保存中间结果;没有内存,就没有地方放指令和数据。PA1要求你用C语言的结构体和数组去模拟这两样东西,而不是真的去写Verilog,原因很简单:C语言表达状态机足够直接,而且你可以随时打印、随时打断点,比真正的硬件调试环境友好太多。
执行循环是“灵魂”。CPU之所以是CPU,不是因为它有一堆寄存器,而是因为它会不断地做“从eip指向的地址取指令,分析这条指令要干什么,然后执行它”。PA1让你实现的exec_once函数,就是把这个过程翻译成几十行C代码。当你看到这个循环转起来,一台“活”的计算机就已经诞生了。
单步执行和监视点则是“调试工具”。课程组为什么把调试器也放进PA里让你自己实现?因为在你后续实现PA2、PA3的时候,会频繁遇到“程序跑飞了但不知道在哪一步跑飞”的问题,如果这时候没有一个趁手的调试环境,你会寸步难行。PA1要求你做一个叫SDB的调试监视器,支持si单步、info r查看寄存器、x查看内存、w设置监视点,本质上是在给自己造一个趁手的兵器。
2. 动手前的准备:环境和工程结构
2.1 先把环境踩平:WSL2、编译工具链、Makefile
PA0的文档一般会要求你配置好Linux环境。这一关本身不复杂,但每年都有不少人在环境上耗掉两三天,尤其是Windows用户。如果你用的是WSL2,最常见的报错就是“WSL2无法启动”或者“此计算机上未启用虚拟化”。遇到这种情况,先别急着重装,按顺序排查。
第一步,检查Windows功能里“适用于Linux的Windows子系统”和“虚拟机平台”两个开关是否都打开了。只开前者不开后者,WSL2就没法跑。第二步,进入BIOS确认Intel VT-x或者AMD SVM是否开启。很多品牌机默认关闭虚拟化,进入BIOS的方式一般是开机时按F2、F10或Del,具体键要看机器型号。第三步,在管理员PowerShell里执行wsl --update,把内核组件更新到最新版本。第四步,如果还不行,检查Windows版本是否过旧,WSL2要求Windows 10 2004以上。
环境跑通之后,建议装齐编译和调试工具。在Ubuntu下就是一条命令的事,apt install build-essential git gdb make。PA1阶段你不需要装任何额外的图形库,NEMU的运行靠的是终端交互。这一点对同学们很友好,不用担心在WSL2里没法弹窗口。
这里说一个我自己的体会:一定要把git用起来。NEMU的代码不是一锤子写完的,你会在调试中反复修改,很可能改到一半发现自己把原来的正确版本弄坏了。每次完成一个小功能就commit一次,对你找回bug、对照修改记录会非常有帮助。
2.2 NEMU目录结构与你该关心哪些文件
我第一次打开NEMU目录时是有点懵的,文件太多了,不知道从哪里看起。但静下心来理一层就会发现,它的结构比想象中清晰。核心目录无非就几个:include/放公共头文件,src/放实现代码,src/isa/下面按指令集架构分子目录,PA1主要是x86;src/cpu/管CPU执行循环,src/memory/管内存读写,src/monitor/管命令行调试器,src/device/在后面的PA阶段才会派上大用场。
你真正要改的文件并不多,很多文件只是框架,甚至不需要打开。PA1刚上手时,我建议按这个顺序去读代码:先找到入口函数init_monitor,看它做了什么初始化;再找到cpu_exec,看CPU主循环是怎么写的;然后进入exec_once,找到它调用了什么函数来取指令、译码、执行;最后再看memory目录里vaddr_read和vaddr_write是怎么访问物理内存的。这个顺序是从上往下的调用链,跟着调用链走,比你胡乱翻文件高效得多。
还有一个绕不开的东西是Makefile和Kconfig。NEMU用了一套类似Linux内核的配置方式,运行make menuconfig可以选择ISA、功能模块等。PA1阶段你大概率只需要选择x86,然后存盘退出。需要注意,修改了Kconfig之后,最好执行一次make clean再重新make,否则可能出现配置改了但编译产物没更新的诡异问题。类似这种“明明改了代码却不生效”的坑,后面专门拿出一节讲。
3. 核心实现:一台“能跑指令”的计算机
3.1 寄存器与内存:先有容器,再有灵魂
PA1给你一个非常明确的编码任务:在src/cpu/reg.c里定义一个CPU_state结构体,用来存放所有寄存器。x86架构下通用寄存器是8个,分别是eax、ecx、edx、ebx、esp、ebp、esi、edi,再加上指令指针eip和标志寄存器eflags。这里有一点很关键:寄存器类型要用无符号32位整型,也就是uint32_t,而不是普通的int。原因是x86的寄存器在语义上就是一个32位无符号数,如果你用int,在做位运算和比较时很容易被符号扩展坑到。
内存模型更直接,通常就是一个动态分配的uint8_t数组。为什么用uint8_t而非uint32_t?因为内存是按字节寻址的,你希望读写任意地址的时候都能精细控制。NEMU一般会提供一个宏,比如CONFIG_MBASE表示内存起始的“客户机物理地址”,然后通过一个指针pmem指向真实分配的宿主内存。CPU访问内存时拿到的是一个客户机地址,需要把它换算成宿主地址,也就是做一次减法偏移。
读写内存的函数里有一个容易漏掉的细节:地址边界。如果你要读一个4字节的数据,但地址已经接近内存末尾,朴素的做法会越界。PA1阶段不一定强制你做边界检查,但养成习惯是有好处的。我自己的做法是先实现一个简单版本,保证小测试能过,然后再把边界处理和合法性检查补上。
这里说句实在话:千万不要觉得“内存数组”很简单就跳过不写。很多后面PA阶段出现的神秘崩溃,追根溯源都来自PA1的内存读写函数没有处理好地址映射。你现在把这几十行代码写扎实,后面的麻烦能少一半。
3.2 取指-译码-执行:CPU真正在循环里做的事
CPU最核心的逻辑,用伪代码写出来其实很短:
void exec_once() { uint32_t inst = inst_fetch(cpu.eip, 4); decode_and_execute(inst); cpu.eip += insn_len; }inst_fetch从cpu.eip指向的地址取出一条指令机器码,然后交给译码和执行。PA1阶段的指令大多不长,有1字节的、2字节的、3字节的,也有带立即数的更长形式。你必须在完成一次执行后正确计算这条指令占了多少字节,并且把eip推进到下一个地址,否则CPU就会在一个死循环里反复执行同一条指令,程序永远走不下去。
这种“死循环”是PA1最容易出现的bug之一。表现是:执行si单步时,eip的值始终不变,或者程序日志里不断打印同一条指令。排查时不要急着猜,把eip的旧值、取出的指令字节、执行后的eip新值都打印出来,对比一下就知道是哪一步出了问题。原则就一个:取指、执行、计算长度、更新eip,这四件事的逻辑必须串起来,漏掉任何一环,CPU就不是CPU。
NEMU执行到特殊指令时,需要让模拟器停下。这一点很重要:如果你把程序里所有指令都执行完了,CPU还傻乎乎地往下取指,它读到的就是内存里的随机数据,然后立刻跑飞。PA1的做法是在这个“最终指令”里调用类似nemu_trap的函数,打印一条信息后设置一个NEMU_RUNNING的标志,让它退出主循环。你会在调试时无数次看到这个停机函数,它意味着程序正常结束了。
另一个容易忽略的点是即时量的符号扩展。x86指令里经常出现imm8、imm16、imm32,当你把8位或16位的立即数赋给32位寄存器时,必须想清楚是零扩展还是符号扩展。PA1的测试程序里常会用到负数,如果你一律按零扩展处理,减法运算就会得到完全错误的结果。判断依据是你要去查指令手册里那个操作数的类型,不能拍脑袋。
3.3 把指令逐条跑通:就这么几条指令,够干什么
PA1通常让你实现的指令集规模不大,一般集中在mov、add、sub、cmp、jmp、push、pop等几十条以内。为什么选这些?因为它们已经足够表达简单的控制流和数据运算,不需要碰乘除法,也不需要处理字符串操作等复杂指令。课程组的意图很清楚:先让你体会完整的指令生命周期,而不是让你背整本指令手册。
测试方式是NEMU里预置了一些简单的testcase,通常是几个汇编源文件,或者带有机器码的数据文件。程序跑完后会通过nemu_trap报告结果,比如寄存器值是否符合预期。最开始你会遇到各种“结果不对”的情况,这时候一定不要急着改指令表,先确认你理解这条指令的语义。
我印象最深的是mov指令的寻址方式。x86的寻址方式很丰富,寄存器寻址、立即数寻址、间接寻址、基址加变址寻址,同一个mov会有好几种二进制格式。PA1阶段你通常只需要实现其中一部分,但不能因此掉以轻心,因为测试用例很可能专挑你没实现的那几条寻址方式来出题。补全指令时最靠谱的方式是查阅x86指令手册中关于ModR/M字节的说明,把整个“如何解析操作数”的逻辑理清楚,而不是对着单个指令硬编码。
做指令扩展时有个实用技巧:每实现一条新指令,只跑一个最小的用例验证它。比如你实现了push,就写一个只含push和停机指令的小程序,确保栈指针esp和栈内存都正确了,再做下一步。千万不要攒十好几条指令一起提交,然后面对一堆报错无从下手,那种感觉非常痛苦。
4. 调试能力,才是PA1真正想教你的
4.1 进入调试世界:NEMU内部的监视器
PA1会要求你在NEMU里实现一个简单的调试监视器SDB,支持如下几个最基本的命令。
| 命令 | 作用 | 实现思路 |
|---|---|---|
si [N] | 单步执行N条指令,默认1条 | 循环调用exec_once并打印指令 |
info r | 打印所有寄存器值 | 遍历CPU_state,格式化输出 |
x N ADDR | 从ADDR开始查看N个内存单元 | 调用vaddr_read读取并打印 |
p EXPR | 计算表达式值 | 实现一个表达式求值器 |
w EXPR | 设置监视点,表达式变化时暂停 | 每次循环前重新计算表达式并比较 |
c | 连续执行直到停机或命中监视点 | 让CPU循环执行,每步检查监视点 |
q | 退出NEMU | 直接结束进程 |
这些命令看起来是给使用者用的,但你写它们的过程,实际上是在构建一个“观察计算机内部状态”的窗口。以后调试PA2、PA3里的程序时,你八成都要回到这个调试器里,看看某个寄存器的值、某块内存的变化。所以宁可多花点时间,也要把命令实现得顺手一点。
表达式求值器是这里最有意思、也最容易写埋雷的部分。它要支持十进制整数、十六进制数(以0x开头)、寄存器名(以$开头,比如$eax),以及加减乘除和括号。很多人上来就想写递归下降解析,其实也可以用一个更简单的思路:先把表达式拆成token,再按优先级组合成表达式树。如果你在这里感觉吃力,说明编译原理的基础还不太牢,可以回头复习一下“逆波兰表达式”和“中缀转后缀”的算法,思路会开阔很多。
监视点的实现也值得一提。它的本质是在每次指令执行前,把被监视表达式重新算一遍,和上次的结果比较,变了就暂停。这比较朴素,但很符合教学需求:问题定位阶段,你通常想知道“某个位置什么时候被改写了”,监视点正是在帮你回答这个问题。
4.2 用GDB调NEMU本身:你不会后悔的一课
SDB是你NEMU内部的调试器,但NEMU本身作为一个C程序,也会出bug、会崩溃,这时候你需要的是通用调试工具GDB。PA1的隐藏任务,就是逼你学会在GDB里断点到某个函数、打印结构体成员、查看内存内容。
我自己最常用的几个操作:在启动时先break到cpu_exec函数,看主循环是否进入;然后break到exec_once,每次调用都查看cpu.eip的值;如果发现某个指令执行结果不对,就break到对应的指令处理函数里,打印操作数和目标寄存器的值。这套组合拳能解决绝大多数“执行结果错误”的问题。
很多同学觉得GDB难,不敢用。其实GDB的命令比SDB多得多,但你只需要掌握八个就够用了:b设断点、r运行、n单步跳过、s单步进入、p打印变量、x查看内存、info registers看寄存器、bt看调用栈。剩下的命令都是遇到具体问题再去查资料。掌握这些之后,你会发现自己从“靠猜靠加printf”进化成了“靠证据定位问题”的人,排查效率可能提升一个量级。
5. 问题排查与避坑实录
5.1 编译和运行阶段的坑
NEMU的代码量虽然不算太大,但当你开始修改它,就会遇到各种编译或运行层面的问题。这里挑几个高频的共享一下。
第一个是“改了代码但运行结果没变”。十有八九是Makefile的依赖没有正确生成,或者你改了Kconfig之后没有重新生成配置头文件。最直接的办法是执行一次make clean再make。如果你用的是WSL2这种跨文件系统的环境,偶尔还会遇到时间戳不同步导致make认为文件没变化的情况,这时候执行make clean同样有效。
第二个是头文件的宏定义找不到。NEMU中很多配置是通过#define从配置头文件里引入的,如果你添加了一个新的代码文件,但忘记包含某个头文件,就会报几千行错误。应对方法是先看错误信息里第一个出现的文件是哪个,别被后面的海量错误吓到。通常第一个错误会指引你找到缺失的头文件或未定义的宏。
第三个是链接错误,常见的是“undefined reference to xxx”。这多半是因为你声明了一个函数但没实现,或者实现文件没有添加到Makefile的编译列表中。检查Makefile里SRCS或者类似变量的通配规则有没有覆盖到你的新文件。如果是在sdb相关函数里报错,还要检查menu命令表是否注册了对应的回调函数。
5.2 逻辑与调试阶段的坑
过了编译关,真正的考验才刚刚开始。PA1阶段最常见的逻辑错误,我总结成这么几类。
第一类是eip更新错误。你单步执行时发现程序没有按预期顺序走,或者一直卡在某条指令上,多半是这条指令执行后没有正确增加eip。尤其是jmp这类会改变控制流的指令,它要求eip跳转到目标地址,而不是简单地加上指令长度。写条件跳转时,一定要清楚“条件成立”和“条件不成立”两条路径上的eip行为。
第二类是无符号数导致的比较错误。寄存器和内存地址都是无符号数,如果你用int类型去接收,再拿来和某个值比较,很容易出现负数。我遇到过这样的场景:打印出来的eip是很大的正数,但在C代码里实际比较时却是负数,因为中间经过了int转换。排查方法是把涉及地址和寄存器的变量统一声明成uint32_t或者vaddr_t类型。
第三类是操作数解析错误。同样的mov指令,因为ModR/M字节不同,操作数可能是寄存器、内存地址或立即数,如果你只处理了其中一种情况,测试用例就会在不经意间踩到雷。解决方法是老老实实把ModR/M的解析流程写完,不要走捷径。你可以先用一个最简单的用例跑通寄存器寻址,再逐步加内存寻址和立即数寻址,层层递进。
第四类是监视点或表达式求值器的边界条件。比如表达式里出现非法字符、括号不匹配、除零、寄存器名不存在等。虽然PA1的测试用例可能不会故意刁难你,但你自己调试时一定会输入这些非法内容。给表达式求值器加一个返回错误码的机制,并在SDB命令入口处检查它,能省掉很多调试时的心烦意乱。
5.3 常见问题速查表
把上面这些坑整理成一个速查表,遇到问题按图索骥比较方便。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
make后运行结果没变 | Makefile依赖或Kconfig未重新生成 | 执行make clean后重新make |
undefined reference to xxx | 函数声明未实现,或新文件未加入编译列表 | 检查函数定义与Makefile的SRCS配置 |
| 指令执行顺序乱跳 | eip更新错误,控制流指令处理不当 | 打印每条指令执行前后的eip,检查跳转路径 |
| 寄存器显示为负数 | 用%d打印uint32_t类型 | 使用PRIx32或强制转换后按无符号打印 |
| 程序无法正常停机 | 未实现停机指令或停机标志未修改 | 检查nemu_trap调用是否触发退出循环 |
| 监视点不触发 | 表达式求值出错,或比较逻辑写反 | 单独测试表达式求值器,再测试监视点比较逻辑 |
| WSL2 启动失败 | 虚拟化功能或BIOS虚拟化未开启 | 检查Windows功能、BIOS设置并更新WSL |
这个表格没办法覆盖所有问题,但能覆盖大部分“初学必踩”的坑。做PA1遇到问题时,试着把症状归类:是编译期问题、运行期问题还是结果错误问题。这样你就知道该去向Makefile、调试器、还是指令语义去寻找答案。
6. PA1做完之后,你的能力边界在哪
6.1 从NEMU到AM:一次“软件硬件化”思维的升级
PA1后半段会引入AM,也就是抽象机器。为什么要引入它?因为NEMU是一个模拟硬件,但它本身不提供任何编程接口,你怎么在上面跑程序?AM做的事情,就是在NEMU的寄存器和内存之上,给你提供一组统一的库函数,让你可以写一个栈、写一个简单的打印逻辑,甚至可以跑一个简单的“hello world”。
很多同学第一次接触AM时会觉得抽象,因为它不是一个真正的操作系统,也不是库函数,而是一层“把硬件封装成可编程环境”的接口。换句话说,NEMU是硬件,AM是硬件之上的固件或很小的运行时。PA1里你往往只用到了AM最基础的一小部分,但这个分层思想会一直延续到PA2、PA3。你在这里建立的“硬件资源如何通过软件接口暴露给上层”的直觉,是千元万金不换的理解。
6.2 你从PA1带走的四项真功夫
做完PA1,不要只盯着成绩,看一下自己是不是练出了这四项能力。第一,看寄存器、看内存、看指令流的过程中,你对计算机组成原理里“存储程序”这个概念,从一个背诵的结论变成了亲眼的观察。第二,你掌握了用调试器定位问题的方法,而不是靠猜和盲目修改代码。第三,你对C语言的指针、类型、无符号语义有了更深的理解,这些东西在普通课上很难被真正逼到极致。第四,你养成了“先跑通最小用例,再逐步扩展”的工程习惯,这个习惯放在以后任何系统软件项目里都受用。
最后说点我自己的体会。PA1的周期通常一到两周,看起来只是整门课的一个起点,但它其实是很多同学第一次在“没有标准答案”的代码里自主探索。课程手册会给你方向,但不会给你完整代码,每一步都需要你自己查文档、做实验、看输出。这个过程很折磨人,可一旦你亲手让一台最简单的计算机跑起来,看到终端里打出的那个“HIT GOOD TRAP”,那种成就感,真的会让人喜欢上系统软件这个方向。如果你现在正卡在某个指令上,别急,睡一觉,明天继续。做计算的本质就是在反复出错和修正中找到那条正确的路,PA1只是这条路上第一段风景。