简介:南京大学计算机系统课程ICS2017编程作业项目,是一套覆盖模拟器、抽象层、操作系统到应用层的完整教学实验框架,面向计算机专业学生或自学系统底层知识的学习者。框架包含NEMU微处理器模拟器、NexusAM硬件抽象层、NanosLite轻量级操作系统及NavyApps示例应用,可支撑计算机组成原理、操作系统与程序开发等环节的动手实践。资源为zip压缩包,共877个文件,以c/h源文件为主,配合makefile构建脚本、md说明文档、s汇编代码及少量python辅助脚本,整体约18.6MB;其中c/h文件对应硬件模拟与系统实现,makefile负责工程构建,md文档便于按模块阅读实验说明,目录结构清晰。目前已有95人学习使用。借助附带的说明文档和PA-main主入口,学习者可逐步完成从指令模拟、系统启动到应用运行的全链路实验,通过NEMU观察CPU状态,在NanosLite中理解进程与内存管理,再借助NavyApps实践上层应用开发,对建立系统观和排查底层问题很有帮助。 如果你在学校里认真刷过《深入理解计算机系统》,大概会有一种错觉:书上的字节、指令、栈帧都理解了,但一到让程序真正从二进制跑到输出的层面,还是会卡住。南京大学计算机系统课程ICS2017的编程作业PA,就是来解决这个问题的。它不是一个普通的大作业,而是一整套完整的计算机系统教学实验框架,里面包含NEMU模拟器、NexusAM抽象层、NanosLite操作系统和NavyApps应用程序,目标是让你亲手从零“造”出一台能跑起仙剑奇侠传的电脑。这个项目既能把组成原理、汇编、操作系统、编译原理串成一条线,也能当独立项目来学习。这篇文章不打算复述官方文档,而是把我对四层框架的理解、每个阶段的推进节奏和实操时踩过的坑整理出来,给正在做PA的同学和想补系统底层知识的开发者一点参照。
1. 项目概述:一门课怎么让你从零“造”出一台电脑
1.1 这个项目到底做了什么
南京大学ICS课程的核心是PA,ICS2017版本用的是x86指令集,后续课程版本也有切换到RISC-V的。整个项目的组织方式非常像搭积木:最底层是NEMU,一个用C语言写的教学模拟器,负责模拟CPU、内存和基本设备;往上一层是NexusAM,一个抽象机器层,把不同ISA的差异封装成统一API;再往上是NanosLite,一个极简但五脏俱全的操作系统;最顶层是NavyApps,一堆跑在NanosLite上的应用程序,其中最有名的就是《仙剑奇侠传》游戏。这四个层次加起来,不是各自独立练手的demo,而是一条完整链路:NEMU启动后加载NexusAM,NexusAM为NanosLite提供运行环境,NanosLite再加载并调度NavyApps里的应用。
这件事解决的痛点很明确:学计算机组成原理时,我们通常在纸上算指令周期;学操作系统时,又直接面对Linux这种庞然大物。两门课之间的断层,导致很多人直到毕业也没能想清楚“一条指令到底是怎么变成屏幕上那幅画面的”。PA用一套可调试、可扩展的迷你实现,把这条链路从头到尾打通了。
1.2 什么人适合照着这个框架学
如果你是正在修ICS或同类课程的学生,PA就是主线任务,建议按部就班做。如果你已经工作,想回炉补系统底层知识,也可以拿这个项目当“私人训练场”,因为它的代码量在课程框架里算是克制的,每个阶段只要求你实现必要的功能,理解成本比直接去啃QEMU或者x86手册低得多。需要的基础是C语言能写链表、结构体和指针,Linux命令行会用,git会基本的add、commit、branch。不会也没关系,PA0就是带你把这些工具配好。
有不少人会在网上搜“计算机系统概论答案”之类的东西,我的看法是:答案对分数可能有用,但对能力提升几乎为零。这个项目最值钱的部分是调试过程里那些“灵光一现”的时刻,比如你发现一条指令译码错误导致整个系统乱跳,花两小时定位后,那种对指令语义的敏感度是看十遍答案都换不来的。
2. 分层设计:为什么是NEMU而不是直接上QEMU
2.1 NEMU模拟器的核心功能
NEMU的全称是NJU EMUlator,它的定位很明确:一个为了教学而生的模拟器。它不需要模拟现代CPU的乱序执行、cache分级,也不需要模拟复杂的外设总线。它只需要做到三件事:取指、译码、执行,外加少量设备交互。这并不代表QEMU没有价值,而是教学场景更看重可控性和可调试性。QEMU体量太大,物理内存、设备模型、动态翻译全都堆在一起,初学者很难在里面找到一条清晰的“指令执行路径”。
在ICS2017的x86版本里,你首先要实现的是最基础的指令执行循环:从eip指向的内存位置读出一个指令字节,解析操作码和ModR/M字段,决定操作数宽度和寻址方式,然后执行。等你把mov、add、sub、cmp、jcc、call、ret这一类基础指令跑通,就可以通过简易调试器来单步观察程序状态。这个阶段很多人会低估难度,觉得“指令不就那几条吗”,但实际动手时,光是一个立即数符号扩展就够你喝一壶:unsigned char和signed char混用,结果算出来的地址完全不对。
NEMU的另一个设计特点是设备方面的“够用就好”。它通过I/O端口映射来访问串口、时钟、键盘和VGA显存。比如NanosLite要向屏幕输出字符,实际就是往某个端口写数据,NEMU拿到这个端口写操作后再更新图形窗口或者标准输出。这种设计虽然简单,却把“CPU和外部设备如何通信”这个核心概念展示得很干净:没有总线仲裁,也没有DMA,就是in/out指令加上中断。
2.2 NexusAM抽象层解决了什么问题
第一次看到NexusAM这个名字,很多人会疑惑:明明是要做操作系统,为什么中间非要夹一层抽象?直接让NanosLite跑在NEMU上不行吗?答案是不行,至少不优雅。因为NanosLite需要访问时钟、键盘、显存,需要处理中断,而这些东西在不同ISA上的实现方式完全不一样。如果OS代码里到处是平台相关的寄存器操作,那整个项目就退化成“为某台特定模拟器写驱动”,失去了通用性。
NexusAM的核心思路是定义一个“抽象机器”接口,把硬件能力分成几类:TRM(图灵机,提供计算和内存)、IOE(输入输出,提供timer/keyboard/video)、CTE(中断和异常)、VME(虚拟内存)、MPE(多处理器)。NEMU只需要针对具体ISA实现这套接口,NanosLite的代码完全不关心底层是x86还是RISC-V。这就像家里换了一个牌子的路由器,墙上的网口标准不变,所有设备照样能上网。ICS2017版本里,AM更像是一个“运行时”,它把寄存器上下文切换、中断入口这些脏活累活都包住了,让你在写OS时能集中精力考虑逻辑。
2.3 NanosLite和NavyApps如何组成完整系统
NanosLite是一个极简但完整的操作系统内核。它做的事情包括:加载ELF格式的可执行文件、维护进程列表、处理系统调用、管理虚拟内存,以及通过AM的IOE接口操作设备。应用层发起read/write时,实际通过系统调用陷入内核,NanosLite再调用AM接口跟NEMU硬件交互。这条链路长度并不长,但每一环都真实存在:用户态程序 -> 系统调用 -> 内核 -> AM -> 设备模拟。
NavyApps则是应用集合,里面除了hello、计算器这类小工具,还有仙剑奇侠传这样带图形界面的游戏。为什么课程要放一个游戏上来?因为它几乎覆盖了所有设备:键盘输入、时钟计时、显存绘制、甚至音频。如果你能让仙剑跑起来,说明整条链路里的中断、系统调用、显示驱动都工作正常。我第一次看到NEMU窗口里出现仙剑标题画面时,确实挺激动的,那种“从一行C代码到一台能玩游戏电脑”的成就感,很难用分数衡量。
3. 从PA0到PA4:主线任务怎么一步步推进
3.1 PA0和PA1:环境、模拟器内核与调试器
PA0主要是准备工作。你要在Linux环境里装好gcc、make、git、gdb,并学会用makefile构建项目。这个阶段可能看起来没什么技术含量,但很多人后面卡住就是因为git用不溜。PA的官方文档会要求你在关键节点提交代码,因为后面有些实验需要基于前面成果继续开发,如果没有版本管理意识,改崩了只能原地重来。
进入PA1后,你要完成NEMU的核心执行循环,同时实现一个简易调试器。调试器功能包括:si(单步执行)、info r(查看寄存器)、x(查看内存)、p(表达式求值)、watchpoint(监视点)。表达式求值这个任务特别适合练递归下降解析:你要处理优先级、括号、变量、十六进制数,还要支持解引用。很多同学在这里第一次体会到“写一个能解析自己调试命令的程序”和“用scanf硬拆字符串”的区别。监视点则是硬件调试器概念的简化版:CPU每执行完一条指令,模拟器就检查监视点条件是否满足,满足就暂停。这个机制以后在做OS调试时非常有用。
3.2 PA2和PA3:中断、AM运行时与操作系统
PA2的任务是把指令集补全到能处理中断和异常,然后实现AM的CTE部分。你需要实现int指令触发软中断、CPU响应中断后保存上下文、跳转到中断入口,再通过iret恢复。这里是第一道大坎:上下文切换到底要保存哪些寄存器,顺序是什么,栈指针怎么调整,稍不留神就会让后面的OS跑不起来。我在做的时候吃过一次亏,少保存了一个标志寄存器,结果中断返回后条件跳转全部错乱,排查了很久。
PA3就是NanosLite了。你要实现装载ELF、系统调用、虚拟内存,可能还有分页机制。分页这块特别容易出bug:你需要在启动分页前准备好页表,并且把内核所在的虚拟地址和物理地址映射好。很多人一开启cr0的PG位就立刻triple fault,原因往往是线性地址低端没有映射,或者页目录项权限位设置不对。我的建议是先在纸上画出虚拟地址到物理地址的映射关系,再动手填页表,不要凭感觉写。跑起来之后,再用测试程序验证进程切换,基本就稳了。
3.3 PA4:把NavyApps跑起来
PA4的内容相对轻松但也有意思:把你实现好的NanosLite当底座,把NavyApps里的应用程序编译进去,让它们在模拟器上真正运行。这里涉及交叉编译和文件系统:比如你要用交叉工具链编译出能在NanosLite上跑的ELF,还要把多个应用程序打包成一个简单的文件系统镜像,让OS能按文件名加载。
仙剑奇侠传是PA4的“毕业项目”。它用到键盘、时钟、VGA甚至音频,任何一个设备接口出错,游戏画面都会异常。比如按键没有反应,多半是NEMU的键盘设备中断没有正确触发;画面花屏,则可能是VGA的绘制地址或像素格式算错了。当你把这些问题一个一个修掉,看到游戏开场动画正常播放,整个PA才算真正完整收官。
4. 实操中真正会踩的坑与排查技巧
4.1 指令执行阶段的“翻车点”
PA1和PA2最常见的bug集中在译码上。指令长度是可变的,比如mov指令可能是mov r/m8, r8,也可能是mov r/m32, r32,操作码后面跟的ModR/M字节决定了使用寄存器还是内存寻址,还决定了后面有没有disp或imm。很多人在解析一个带SIB字节的寻址时直接漏掉SIB,导致访问地址错位。我的排查经验是:写一个最小的汇编测试程序,比如movl $0x12345678, (%eax),然后把NEMU的trace输出和objdump反汇编结果一行行对比,哪里对不上就在哪修。
另一个常见问题是立即数符号扩展。比如add $0x80, %ebx,在32位操作数下0x80应该是正数128,但如果你按8位有符号解析当成-128,结果就会差一大截。这类问题不会立刻让程序崩溃,而是让变量值莫名其妙地不对。我建议在做指令循环时,把“操作数宽度”和“是否符号扩展”这两个维度单独写成辅助函数,并加单元测试。
4.2 上下文切换和虚拟内存的连环坑
到了PA2/PA3,最大的坑是上下文切换。中断发生时,你要在栈上保存完整的寄存器快照;恢复时要严格按照保存顺序弹栈。所有寄存器都保存了,但eflags没有保存,或者保存了eip却用错弹出顺序,都会导致用户进程恢复后疯跑。我的做法是画一张“中断栈布局图”,把每个寄存器的保存位置和恢复指令写在纸上,再对着代码检查。
虚拟内存的坑集中在页表。分页开启后,CPU访问的每一个地址都会先走MMU转换,如果你的页表覆盖不全或者权限位不对,NEMU会直接报地址访问异常。这里要特别小心内核线程和用户进程的地址空间切换:每次进程切换都需要切换页目录,同时还要维护TLB的一致性。在NEMU里通常不需要真的模拟TLB,但你必须重新加载CR3。我遇到过一个问题:切换页目录后,内核代码跑飞,后来发现是因为我把内核只映射到了高地址,但是在切换CR3之前的代码还在低地址执行,跳转失败。
4.3 提升调试效率的几个实用手段
首先,git提交要勤,不只在大节点提交,每完成一个小功能就应该commit一次。这样改崩了可以随时回退,也能看清楚是哪一次改动引入了问题。其次,NEMU里的log和trace是调试神器,建议在指令执行循环里加一个宏开关:
#define LOG_ENABLE 1 if (LOG_ENABLE) { printf("eip = 0x%x, opcode = 0x%02x\n", cpu.eip, opcode); }出问题时打开trace,跑一小段程序就能看出执行流在哪里变歪。
还有一个技巧是差分测试:把你实现的NEMU和QEMU同时跑同一个程序,对比执行后的寄存器状态,不一致的地方就是bug所在。这个手段对指令集的正确性验证特别有效,缺点是QEMU环境本身也要折腾一下,但对于后面PA3、PA4的稳定性非常有帮助。
5. 这个教学框架的价值远不止一门课
5.1 学完之后再看系统底层,视角完全不同
做完PA之后,我最大的变化是看任何程序都会在脑子里多出“它最终变成什么指令”“它落在哪个地址空间”“它调用系统调用时内核做了什么”这几层视角。以前写C语言,指针越界只是UB,做完PA之后你会意识到,越界可能正好写到某个设备映射地址,引发全系统崩溃。这种敏锐度对调试线上服务、排查性能瓶颈非常有帮助。
对后续学习的影响也很明显:再去读OSTEP或者xv6,你不会再被抽象概念绕晕,因为上下文切换、页表、系统调用这些名词你都是一个一个亲手实现过的。再看QEMU这类开源模拟器的源码,也能理解它为什么那样组织代码。甚至去了解RISC-V或ARM的底层时,你已经有足够的基础去迁移知识。
5.2 这个框架还能怎么扩展
项目本身的扩展空间很大。你可以给NEMU增加一个虚拟磁盘设备,让NanosLite支持真正的文件持久化;也可以把架构从x86移植到RISC-V,课程后续版本就是这么做的;还可以给NavyApps添加自己的小游戏或工具,比如写一个像素画板,用AM_GPU_FBDRAW把画面刷到显存,立刻就能看到效果。如果你想研究模拟器性能,还可以尝试把NEMU的指令循环优化一下,加上缓存或动态翻译的雏形。
另外,如果你有机会当助教,也可以基于这个框架设计新的实验题,比如让学生实现一个简单的malloc,或者给NanosLite加一个优先级调度器。它就像一套“系统积木”,你想往哪个方向深挖,都能找到对应的接口。
最后说一点个人体会。我到现在还记得第一次在NEMU窗口里看到NanosLite启动、仙剑标题画面出现时的兴奋感。这个项目让我意识到,所谓“懂计算机系统”,不是能背出多少概念,而是当一条指令、一次中断、一次内存访问真正按你的设计跑通时,你心里有完整的图景。如果时间有限,我建议至少把PA1和PA2完整做下来,后面的部分会越走越顺。网上那些可以“抄近道”的资料,能不看就不看,真正的收获都在调试的每一个通宵里。
本文还有配套的精品资源,点击获取