ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

南京大学ICS PA实验:x86-64操作系统内核动手实践指南

南京大学ICS PA实验:x86-64操作系统内核动手实践指南 简介本资源是南京大学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课程的PAProject 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. 搭建可调试环境用QEMUGDB跑通第一个PA实验PA0PA实验不是直接编译运行就完事它的价值全在“可调试性”。南京大学ICS课程的PA实验设计非常务实所有实验都基于QEMU模拟x86-64环境配套的Makefile已预置GDB远程调试支持你唯一要做的是让这套链路在你本地机器上稳稳跑起来。下面步骤基于Ubuntu 22.04 LTSWSL2或物理机均可其他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 PA1Rust中页表操作的“安全陷阱”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 PA2C语言上下文切换的寄存器保存顺序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 // 保存rbxcallee-saved pushq %%r12\n\t // 保存r12-r15callee-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...告诉编译器这些寄存器会被内联汇编修改避免优化时把变量缓存在被破坏的寄存器中。漏写r11r11可能存着某个关键变量切换后值被覆盖进程莫名其妙崩溃——这种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 definedb *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 FaultCR2寄存器显示地址正确原因页表项PTE的AAccessed位未置位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启动后按CtrlA 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.pyimport 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*){sp16})) print(fRIP: {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(fVA {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-idnone导致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、敢于查手册的底气。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进