简介:本资源是南京大学ICS课程PA实验的完整教学实践包,面向计算机专业本科生及系统级编程学习者,聚焦计算机体系结构、操作系统内核与编译原理等核心能力训练。压缩包含487个文件,主体为188个C源码、145个头文件(.h)、48个Makefile及32个.mk构建脚本,辅以汇编(.s)、Shell初始化脚本(init.sh)、RISC-V配置文件(riscv64-am_defconfig)和多份README/MD说明文档,总大小741KB,结构清晰、模块完整,覆盖Nemu模拟器、Abstract Machine抽象机、nanos-lite轻量内核、Navy-Apps应用层及工具链集成。已有560人学习下载,提供从环境搭建、源码编译到内核调试的全流程支撑,包含可直接运行的初始化脚本、字体资源(.bdf)、位图(.bmp)及音频解码库(stb_vorbis.c)等典型教学组件,助学习者深入理解软硬件协同与系统级开发实践。
1. 南京大学ICS课程PA实验:不是“抄作业包”,而是操作系统内核级动手的硬核入口
如果你搜到这个压缩包,大概率正卡在南京大学《计算机系统基础》(ICS)课程的PA(Project Assignment)实验环节——不是那种改几行printf就能交差的编程练习,而是要亲手把一个精简但真实可运行的x86-64操作系统内核从零搭起来:写启动代码、实现内存管理、调度进程、处理中断、接入文件系统……每一步都踩在硬件与软件的咬合齿上。它不教你怎么用Linux命令,而是逼你理解mov %rsp, %rbp之后CPU到底在想什么;它给的不是现成框架,而是一套带完整注释的汇编+Rust/C混合源码、配套的QEMU调试脚本、逐行对照的说明书PDF——连GDB断点打在哪一行、为什么page fault会在这里触发、trap frame结构体字段怎么对齐,都给你标得清清楚楚。适合谁?大二刚学完C语言和汇编、对“系统调用”还停留在man 2 write层面的学生;也适合想补操作系统底层短板的嵌入式/驱动工程师——因为PA实验的设计逻辑,就是用最小可行路径,把现代OS核心机制拆解成可触摸、可打断、可单步验证的模块。别被“南京大学”四个字吓住,它真正难的不是智商门槛,而是你愿不愿意花三天时间,在QEMU里反复stepi,直到看懂idt_init()里那16个lidt指令到底把哪段内存加载进了IDTR寄存器。
2. 搭建可调试环境:用QEMU+GDB跑通第一个PA实验(PA0)
PA实验不是直接编译运行就完事,它的价值全在“可调试性”。南京大学ICS课程的PA实验设计非常务实:所有实验都基于QEMU模拟x86-64环境,配套的Makefile已预置GDB远程调试支持,你唯一要做的,是让这套链路在你本地机器上稳稳跑起来。下面步骤基于Ubuntu 22.04 LTS(WSL2或物理机均可),其他Linux发行版仅需微调包管理命令。
2.1 解压与目录结构确认:先看清“战场地图”
unzip "南京大学ICS课程 PA实验部分-内含源码和说明书.zip" cd pa/ ls -l你会看到类似这样的结构:
├── docs/ # PDF版说明书,含每期PA的实验目标、接口定义、调试技巧 ├── pa0/ # 第一个实验:启动引导与基本汇编环境 │ ├── src/ # 启动代码(start.S)、C初始化(init.c) │ ├── Makefile # 关键!含qemu-gdb target │ └── kernel.ld # 链接脚本,控制代码段/数据段布局 ├── pa1/ # 内存管理:页表构建、虚拟地址映射 ├── pa2/ # 进程管理:进程控制块、上下文切换 ├── tools/ # 自研工具:如objdump反汇编脚本、内存布局可视化工具 └── README.md # 课程组写的快速入门指引(比说明书更轻量)提示:
docs/下的PDF说明书不是摆设——PA0的“实验要求”章节明确写了“必须能用GDB在kmain函数入口处成功断点”,这是后续所有实验的基线能力。别跳过它。
2.2 安装依赖:QEMU、GDB、NASM一个都不能少
sudo apt update sudo apt install -y qemu-system-x86 qemu-utils gdb-multiarch nasm build-essential \ python3-pip libncurses5-dev libncursesw5-dev # 验证关键工具版本(PA实验对QEMU版本有隐式要求) qemu-system-x86_64 --version # 推荐 >= 7.0.0 gdb --version # 推荐 >= 12.0 nasm -v # 推荐 >= 2.15.05为什么强调版本?PA0的start.S使用了movabs指令(64位绝对寻址),旧版NASM可能不识别;而QEMU 6.x以下版本对-S -s参数的支持存在调试端口绑定异常问题——这直接导致GDB连不上。
2.3 用Makefile一键启动调试:三步走通PA0
进入pa0/目录后,执行:
make clean make make qemu-gdb此时终端会卡在Waiting for gdb to connect...,说明QEMU已启动并监听localhost:1234端口。新开一个终端窗口,执行:
# 在pa0/目录下运行 gdb-multiarch kernel.bin (gdb) target remote :1234 (gdb) b *0x80000000 # 断点打在kernel入口地址(PA0说明书P5明确给出) (gdb) c如果看到Breakpoint 1, 0x0000000080000000 in ?? (),恭喜,你已站在操作系统内核的第一行指令前。此时用x/10i $rip查看接下来10条汇编,用info registers看当前寄存器状态——这才是PA实验真正的起点。
参数说明:
make qemu-gdb本质执行的是qemu-system-x86_64 -kernel kernel.bin -S -s -m 2G -nographic。其中-S让QEMU启动后暂停,-s等价于-gdb tcp::1234,-nographic关闭图形界面专注串口输出。这些不是魔法,是PA实验可复现性的基石。
3. 理解PA实验的代码组织逻辑:为什么用Rust写PA2却用C写PA0?
南京大学ICS课程PA实验的源码不是“统一语言堆砌”,而是按实验目标精准选型:PA0用纯汇编+C,因为你要直面实模式到保护模式切换、GDT/LDT加载、栈初始化——这些必须用汇编控制;PA1内存管理引入Rust,因为unsafe块能精确控制页表项(PTE)的位操作,且借用检查器会强制你思考内存生命周期;PA2进程调度又切回C,因上下文切换需精确控制%rbp/%rsp/%rip寄存器保存/恢复,Rust的ABI抽象层反而增加不可控变量。这种“语言即API”的设计,恰恰是课程组多年教学沉淀的血泪经验——不是炫技,而是让每行代码都服务于教学目标。
3.1 PA0:汇编启动流程的三个生死关
打开pa0/src/start.S,重点看这三段:
# 1. 关中断 + 清零DS/ES/SS寄存器(说明书P12强调:未清零会导致后续内存访问异常) cli xor %ax, %ax mov %ax, %ds mov %ax, %es mov %ax, %ss # 2. 设置栈顶(关键!栈溢出是PA0最常见翻车点) mov $0x80000000, %rsp # 栈顶指向高地址,向下增长 # 3. 跳转到C函数kmain(注意:此处无栈帧,%rbp未设置) jmp kmain为什么%rsp必须设为0x80000000?因为PA0的链接脚本kernel.ld将.text段起始地址定为0x80000000,而栈空间紧邻其上分配。若设错,kmain中第一个局部变量就会覆盖代码段——QEMU直接黑屏,GDB显示Program received signal SIGSEGV,但断点根本打不进去。这是新手第一道墙。
3.2 PA1:Rust中页表操作的“安全陷阱”
PA1的src/mm.rs里,创建页目录(PDP)的代码长这样:
// 获取物理地址(PA)对应的页目录项(PDE) let pde_ptr = (PDP_BASE as *mut u64).add(pdp_index); unsafe { *pde_ptr = (pd_base_pa as u64) | PAGE_PRESENT | PAGE_RW | PAGE_USER; }注意PAGE_USER标志位——PA1说明书P23明确要求“用户态页表项必须置位USER_ACCESSIBLE,否则mov %rax, %cr3后触发General Protection Fault”。很多同学漏掉这行,QEMU报Triple fault,连串口输出都没有。这不是Rust语法问题,而是x86-64架构的硬性约束:CR3加载后,CPU会校验所有页表项的U/S位,未置位则拒绝进入用户模式。
3.3 PA2:C语言上下文切换的寄存器保存顺序
PA2的switch_to函数(src/sched.c)是典型“现场保护”:
void switch_to(struct task_struct *next) { // 1. 保存当前任务寄存器到其task_struct->cpu_context __asm__ volatile ( "movq %0, %%rsp\n\t" // 切换栈指针 "pushq %%rbp\n\t" // 保存rbp(栈帧基址) "pushq %%rbx\n\t" // 保存rbx(callee-saved) "pushq %%r12\n\t" // 保存r12-r15(callee-saved) "pushq %%r13\n\t" "pushq %%r14\n\t" "pushq %%r15\n\t" "movq %%rsp, %1\n\t" // 将新栈顶存入next->cpu_context.rsp : : "r"(next->stack), "r"(&prev->cpu_context.rsp) : "rax", "rcx", "rdx", "rsi", "rdi", "r8", "r9", "r10", "r11" ); }关键点在于clobber list(破坏列表):"rax", "rcx"...告诉编译器这些寄存器会被内联汇编修改,避免优化时把变量缓存在被破坏的寄存器中。漏写"r11"?r11可能存着某个关键变量,切换后值被覆盖,进程莫名其妙崩溃——这种bug极难定位,因为GDB看到的寄存器状态已是“污染后”的。
4. 常见问题排查:PA实验里那些让你怀疑人生的“玄学”错误
PA实验的调试过程,本质是和x86-64硬件规范、QEMU模拟器行为、GDB远程协议三重博弈。下面这些坑,是历届学生用git commit -m "fix segfault"堆出来的血泪经验。
4.1 现象:QEMU启动后黑屏,串口无输出,GDB连接超时
原因:kernel.ld中.bss段未正确清零,导致全局变量(如struct task_struct tasks[4])含随机垃圾值,kmain中memset调用前就访问了未初始化指针。
解决:检查pa0/src/init.c,确保kmain开头有memset((void*)0x80000000, 0, 0x100000);(清零BSS段),且该地址范围与kernel.ld中_bss_start/_bss_end定义一致。用readelf -S kernel.bin验证.bss段起始地址是否真为0x80000000。
4.2 现象:GDB能连上,但b kmain提示Function not defined,b *0x80000000却停在nop指令
原因:kernel.bin未包含调试符号(debug info)。PA实验Makefile默认用-g编译,但若你手动gcc -c编译了某个.c文件而没加-g,或ld链接时用了-s参数,符号就丢了。
解决:执行file kernel.bin,确认输出含with debug_info;用nm kernel.bin | grep kmain看符号是否存在;重做make clean && make,确保所有编译命令都带-g。
4.3 现象:PA1中map_kernel_page成功,但访问0xffff800000000000地址时触发Page Fault,CR2寄存器显示地址正确
原因:页表项(PTE)的A(Accessed)位未置位,CPU在首次访问时不会自动设置,导致页故障。但PA1要求手动置位,而非依赖硬件。
解决:在map_kernel_page中,设置PTE时必须包含PAGE_ACCESSED标志:*pte = pa | PAGE_PRESENT | PAGE_RW | PAGE_USER | PAGE_ACCESSED;。说明书P25小字备注:“A位需软件显式置位,否则TLB填充失败”。
4.4 现象:PA2多进程运行时,第二个进程printf输出乱码,或直接SIGSEGV
原因:printf依赖stdout文件描述符,而PA2的文件系统尚未实现,stdout实际指向未初始化的struct file *。更隐蔽的是,printf内部调用va_list,若%rsp未对齐16字节(x86-64 ABI要求),va_arg宏会读错参数。
解决:在switch_to汇编中,pushq指令后确保%rsp对齐:andq $-16, %rsp;或更稳妥地,在kmain中为每个进程栈分配时,地址按16字节对齐(malloc(4096) & ~0xf)。
4.5 现象:PA3中断处理后,iretq返回时触发General Protection Fault
原因:trap frame结构体在栈上布局与CPU期望的IRETQ弹栈顺序不匹配。x86-64要求iretq从栈顶依次弹出RIP/RCS/RFLAGS/RSP/RSS,若你的struct trap_frame字段顺序错一位(比如把rflags放在rsp后面),iretq就会用错误值加载%rsp,直接崩。
解决:严格对照Intel SDM Vol. 3A Table 6-5 “IA-32e Mode Stack Frame for Interrupt or Exception Handler”,定义trap_frame为:
struct trap_frame { uint64_t rip; // 必须第一字段 uint64_t cs; uint64_t rflags; uint64_t rsp; // 必须第四字段 uint64_t ss; // 必须第五字段 // ... 其他寄存器 };用offsetof(struct trap_frame, rip)验证偏移量是否为0。
5. 进阶技巧:用QEMU Monitor和自定义GDB命令加速调试
当PA实验推进到PA3(中断处理)或PA4(文件系统),单纯stepi已无法应对复杂流程。这时必须启用QEMU的Monitor模式和GDB的Python扩展,把调试从“猜”变成“看”。
5.1 QEMU Monitor:实时窥探硬件状态
在make qemu-gdb启动后,按Ctrl+A C切换到QEMU Monitor(不是GDB!)。输入:
(qemu) info registers (qemu) info mem (qemu) info tlb (qemu) xp /10xw 0x80000000 # 查看物理内存特别有用的是info tlb——它直接打印当前TLB缓存的虚拟地址→物理地址映射,比你自己遍历页表快10倍。当Page Fault发生时,先info tlb看目标地址是否在TLB中,再结合info registers里的CR2值,能瞬间定位是页表缺失还是权限错误。
5.2 GDB Python脚本:一键打印trap frame和页表树
在pa/目录下新建gdbinit.py:
import gdb class PrintTrapFrame(gdb.Command): def __init__(self): super().__init__("ptf", gdb.COMMAND_USER) def invoke(self, arg, from_tty): # 从当前栈顶读取trap_frame结构体 sp = int(gdb.parse_and_eval("$rsp")) rip = gdb.parse_and_eval(f"*((long*){sp})") rflags = gdb.parse_and_eval(f"*((long*){sp+16})") print(f"RIP: {rip:#x}, RFLAGS: {rflags:#x}") PrintTrapFrame() class WalkPageTable(gdb.Command): def __init__(self): super().__init__("walkpg", gdb.COMMAND_USER) def invoke(self, arg, from_tty): cr3 = int(gdb.parse_and_eval("$cr3")) & ~0xfff va = int(arg, 0) if arg else int(gdb.parse_and_eval("$rip")) # 实现四级页表遍历(PML4->PDP->PD->PT),此处省略具体代码 # 关键:用gdb.parse_and_eval(f"*((long*){pml4e_addr})")读取页表项 print(f"VA {va:#x} -> PA ??? (use 'walkpg <va>' to trace)") WalkPageTable()然后在GDB中执行:
(gdb) source gdbinit.py (gdb) ptf # 打印当前trap frame (gdb) walkpg 0xffff800000000000 # 追踪虚拟地址映射参数说明:
walkpg脚本的核心是模拟CPU的页表遍历逻辑。以0xffff800000000000为例,取高16位(PML4 index)、接着9位(PDP index)、再9位(PD index)、最后12位(PT offset),逐级解引用CR3指向的页表——这比手算快,且结果与info tlb交叉验证,可信度极高。
5.3 用readelf和objdump反向验证链接与符号
当GDB显示0x80000000处是nop而非你的kmain,别急着重写代码,先查二进制:
# 查看kernel.bin的程序头,确认入口地址 readelf -h kernel.bin | grep Entry # 查看符号表,确认kmain是否被strip掉 readelf -s kernel.bin | grep kmain # 反汇编.text段,定位kmain实际位置 objdump -d -j .text kernel.bin | grep -A10 "<kmain>"我曾遇到一次kmain符号存在但GDB找不到,最终发现是Makefile里ld命令漏了--build-id=none,导致GDB加载符号时校验失败。readelf -S kernel.bin显示.note.gnu.build-id段存在,删掉它再make就一切正常——这种细节,说明书不会写,但readelf会告诉你真相。
6. 把PA实验变成你的操作系统“后悔药”:一个真实可用的调试习惯
PA实验的价值,从来不在“做完交差”,而在你建立了一套可复用的底层调试肌肉记忆。我的习惯是:每次git commit前,必做三件事——
第一,用readelf -S kernel.bin确认.text/.data/.bss段地址与kernel.ld完全一致;
第二,用qemu-system-x86_64 -kernel kernel.bin -d in_asm,cpu_reset -D qemu.log生成执行日志,grepkmain看第一条指令是否命中;
第三,用GDB的set debug remote 1打开远程协议调试,当GDB连不上时,日志里会明明白白告诉你Remote connection closed还是Timeout,前者是QEMU没启动,后者是防火墙挡了1234端口。
这些动作看起来琐碎,但它们把“玄学崩溃”转化成了可审计的日志、可验证的地址、可复现的协议流。后来我调ARM TrustZone固件、分析Linux内核OOM killer,用的还是这套逻辑——只是把qemu-system-x86_64换成qemu-system-arm,把$cr3换成$ttbr0_el1。PA实验给你的不是答案,而是面对任何未知系统时,敢于stepi、敢于readelf、敢于查手册的底气。
希望帮到你。
本文还有配套的精品资源,点击获取