用C语言构建最小x86计算机:南大ICS PA1硬件模拟器实战指南
发布时间:2026/9/16 6:59:58 锦皓数字建站

南大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、编译工具链、MakefilePA0的文档一般会要求你配置好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主要是x86src/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后重新makeundefined 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只是这条路上第一段风景。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。