
简介本资源是一套面向高校操作系统课程学习者的C语言实验项目合集聚焦进程管理、内存调度、文件系统与系统调用等核心原理的动手实践适用于具备基础C编程能力的本科生及系统编程初学者。压缩包共12个文件含5个C源码文件对应各实验主逻辑实现、5个文本说明文件含实验要求与运行指引、1个README.md概述结构与编译方式及1个.gitignore整体仅8KB轻量易读目录按lab2–lab6分模块组织结构清晰便于逐级实践。已有97人学习下载适合配合教材开展上机实验、理解P/V操作、FCFS/SJF调度模拟、简易文件I/O实现等关键环节。读者可直接编译运行各lab下的main.c结合CMakeLists.txt快速构建通过代码级调试深入掌握OS底层机制是理论联系实际的优质教学补充材料。1. OS_labs不是“操作系统实验课PPT”而是能跑通中断、内存映射和进程调度的可调试内核沙盒你手头有一份叫OS_labs的代码包解压后看到lab1_boot,lab2_mem,lab3_proc,lab4_fs这类目录但make run报错说qemu-system-x86_64: command not found或者好不容易跑起来了GDB连上去却卡在0x7c00死活不进保护模式又或者fork()返回值在子进程中是 0、父进程中是正数但一加个printf就 panic —— 这些不是环境配置失败而是你正在接触一个真实、可打断、可单步、可修改的 x86-64 简化内核运行时环境。OS_labs不是教学幻灯片它是一套以「最小可行内核Minimal Viable Kernel」为设计原则的实操框架从实模式跳转、GDT/LDT 构建、页表四级映射PML4 → PDP → PD → PT、IDT 中断向量注册到基于时间片的 CFS 简化调度器全部用 C 内联汇编实现且每一步都预留了kdebug()打印点和__asm__ volatile(int $0x3)断点桩。它适合两类人一是刚学完《现代操作系统》前六章、想亲手验证“进程切换本质是栈指针寄存器上下文交换”的在校学生二是嵌入式/驱动方向工程师需要快速搭建一个可控、无干扰、可注入故障的底层执行沙盒来复现竞态或页错误。它不依赖 Linux 内核模块机制不调用 glibc甚至不链接crt0.o—— 整个启动链路由你自己写的bootasm.S→bootmain.c→kern/init.c逐级接管。接下来我们就从零构建这个沙盒并把最常让新手卡住的三个“玄学时刻”——页表未刷 TLB、IDT 描述符权限位设错、进程栈溢出无提示——变成可定位、可修复的确定性问题。2. 用 QEMU GDB 在本地跑通 lab2_mem页表构建与虚拟地址映射的最小命令集OS_labs的lab2_mem是整个框架的分水岭此前的lab1_boot只验证实模式到保护模式跳转此后所有实验进程、文件系统、IPC都强依赖正确的虚拟内存管理。而它的核心任务只有一个让内核能安全访问0xffff800000000000以上的内核空间同时让用户进程拥有独立的0x0000000000000000 ~ 0x00007fffffffffff用户空间。这要求你亲手构造四级页表PML4/PDP/PD/PT并确保 CR3 指向 PML4 起始地址。常见误区是以为“只要填对地址就能映射”却忽略 CPU 的 TLB 缓存行为和页表项权限位U/S、R/W、A/D 位的协同作用。2.1 构建可调试环境QEMU 启动参数与 GDB 连接链路OS_labs默认 Makefile 使用qemu-system-x86_64 -kernel obj/kern/kernel但这无法调试。必须改用-bios模式加载自定义引导镜像并启用 GDB stub# 在 lab2_mem 目录下执行 make clean make qemu-system-x86_64 \ -drive fileobj/kern/kernel.img,formatraw \ -s -S \ -monitor stdio \ -d in_asm,cpu_reset \ -no-reboot提示-s等价于-gdb tcp::1234-S表示启动后暂停等待 GDB 连接-d in_asm,cpu_reset开启指令级日志和复位日志当页表初始化失败时你会在控制台看到CPU Reset (v0000, pc000000000000fff0)这是 BIOS 复位信号说明内核在enable_paging()后触发了不可恢复异常。连接 GDBgdb obj/kern/kernel (gdb) target remote :1234 (gdb) symbol-file obj/kern/kernel (gdb) b kern/pmap.c:237 # 假设 pml4_init() 在此行 (gdb) c关键在于symbol-file必须指向未 strip 的 kernel ELF 文件obj/kern/kernel而非kernel.img后者是扁平二进制镜像无符号表。若info registers显示cr3 0x0说明lcr3()未执行或执行失败若x/10xg $cr3返回全 0则页表物理地址未正确写入 CR3。2.2 四级页表构建PML4 → PDP → PD → PT 的四层填充逻辑lab2_mem的页表初始化函数通常命名为pml4_init()或mem_init()其核心是四重嵌套循环。以下为典型实现骨架已简化注释// kern/pmap.c pde_t *pml4, *pdp, *pd, *pt; physaddr_t pa; // Step 1: 分配 PML4 表512 项每项 8 字节 pml4 (pde_t*)boot_alloc(PGSIZE); memset(pml4, 0, PGSIZE); // Step 2: 分配 PDP 表用于映射内核空间如 0xffff800000000000 pdp (pde_t*)boot_alloc(PGSIZE); memset(pdp, 0, PGSIZE); pml4[256] PADDR(pdp) | PTE_P | PTE_U | PTE_W; // PML4[256] 指向 PDP索引256对应 0xffff800000000000 // Step 3: 分配 PD 表映射 1GB 内核段 pd (pde_t*)boot_alloc(PGSIZE); memset(pd, 0, PGSIZE); pdp[0] PADDR(pd) | PTE_P | PTE_U | PTE_W; // PDP[0] 指向 PD // Step 4: 分配 PT 表映射 2MB 物理页 pt (pde_t*)boot_alloc(PGSIZE); memset(pt, 0, PGSIZE); pd[0] PADDR(pt) | PTE_P | PTE_U | PTE_W; // PD[0] 指向 PT // Step 5: 填充 PT映射物理页 0x100000 → 虚拟地址 0xffff800000200000 for (int i 0; i 512; i) { physaddr_t page_pa 0x100000 i * PGSIZE; // 从 1MB 开始取物理页 uintptr_t va 0xffff800000200000 i * PGSIZE; pt[i] page_pa | PTE_P | PTE_U | PTE_W | PTE_A | PTE_D; }参数说明PTE_PPresent页表项有效位必须为 1否则触发 Page FaultPTE_UUser Accessible若为 0则仅内核态可访问CPL0用户进程访问会触发 General Protection FaultPTE_WWritable若为 0则写操作触发 Page FaultPTE_AAccessed和PTE_DDirty由 CPU 自动置位但首次设置时建议显式置 1避免因未置位导致 TLB 命中但权限检查失败PADDR()宏将虚拟地址转为物理地址因boot_alloc()返回的是内核虚拟地址需减去KERNBASE偏移。注意x86-64 下 PML4 和 PDP 表项的 bit 7PS必须为 0表示下级是 4KB 页表而 PD 表项的 PS 位可为 1表示直接映射 2MB 大页。OS_labs为教学清晰起见通常强制使用 4KB 页故所有表项 PS0。2.3 验证映射有效性用 GDB 检查 CR3、页表项与虚拟地址翻译启动 GDB 后执行以下命令链验证页表是否生效(gdb) info registers cr3 # 输出应为非零值如 cr3 0x0000000000201000 (gdb) x/10xg 0x0000000000201000 # 查看 PML4 前 10 项 # 应看到类似0x0000000000201000: 0x0000000000202007 0x0000000000000000 ... (gdb) x/10xg 0x0000000000202000 # PDP 地址PML4[0] ~0xfff # 应看到 PDP[0] 指向 PD 地址 (gdb) x/10xg 0x0000000000203000 # PD 地址 # 应看到 PD[0] 指向 PT 地址 (gdb) x/10xg 0x0000000000204000 # PT 地址 # 应看到 PT[0] 0x0000000000100007 映射物理页 0x100000 (gdb) x/10xg 0xffff800000200000 # 访问虚拟地址 # 若返回有效数据如内核代码说明映射成功若报 Cannot access memory则映射失败若x/10xg 0xffff800000200000失败按顺序检查cr3是否为 0→ 检查lcr3()调用位置及参数PML4[256]是否指向有效 PDP 地址→ 检查PADDR(pdp)计算是否正确PDP[0]是否指向有效 PD 地址→ 检查pdp[0]是否被覆盖常见于memset误清整块内存PT[0]的物理地址是否在可用 RAM 范围内→ 对照memlayout.h中PHYS_BASE和PHYS_SIZE。3. lab3_proc 的进程创建fork() 的栈帧复制与寄存器上下文切换三要素lab3_proc的核心是fork()系统调用的实现。它不是简单地malloc()一块内存然后memcpy()而是精确复制用户栈、设置子进程初始寄存器状态、并让子进程从fork()返回 0父进程返回子 PID。这要求你理解 x86-64 的栈帧布局、trapframe结构以及switch_to()汇编例程如何保存/恢复rbp,rsp,rip,rflags等关键寄存器。很多开发者卡在fork()后子进程一执行就#GP根本原因是trapframe中的cs或ss段选择子权限位不匹配当前 CPLCurrent Privilege Level。3.1 fork() 的三阶段分配进程结构、复制用户栈、设置 trapframeOS_labs的fork()典型流程如下伪代码int sys_fork(void) { struct proc *p proc_alloc(); // 分配 proc 结构体 if (!p) return -1; // Step 1: 复制父进程的用户栈从 trapframe 开始向上拷贝 p-tf (struct trapframe*)kalloc(); memmove(p-tf, current-tf, sizeof(struct trapframe)); // Step 2: 修改子进程 trapframe使其返回 0 p-tf-rax 0; // fork() 在子进程中返回 0 // Step 3: 设置子进程的内核栈指针指向新分配的内核栈 p-kstack kalloc(); p-kstack_top p-kstack KSTACKSIZE; // Step 4: 设置子进程的用户栈顶指向 trapframe 底部 p-tf-rsp (uint64_t)p-tf - 8; // 栈向下增长预留 8 字节对齐 // Step 5: 设置子进程的代码段和栈段选择子关键 p-tf-cs 0x2b; // USER_CS: 0x2b 00101011b → RPL3, TI0, index5 p-tf-ss 0x33; // USER_SS: 0x33 00110011b → RPL3, TI0, index6 // Step 6: 将子进程加入就绪队列 proc_set_state(p, PROC_RUNNABLE); return p-pid; }关键参数说明USER_CS 0x2bGDT 中第 5 个描述符index5RPL3用户态DPL3描述符特权级也为 3类型为代码段可执行、可读USER_SS 0x33GDT 中第 6 个描述符index6RPL3DPL3类型为数据段可读写、可扩展p-tf-rsp (uint64_t)p-tf - 8trapframe结构体位于用户栈底rsp必须指向其下方 8 字节处以满足 x86-64 ABI 的 16 字节栈对齐要求p-tf-rax 0fork()返回值存于rax子进程需为 0。注意若cs或ss设为0x08KERNEL_CS或0x10KERNEL_DSCPU 在iretq时会检测 CPL 与段描述符 DPL 不匹配触发#GP(0)异常且不会进入异常处理程序因尚未建立 IDT直接死机。3.2 switch_to() 汇编实现寄存器上下文保存与恢复的原子性进程切换由switch_to(old_p, new_p)汇编函数完成它必须保证rsp,rbp,rip等寄存器的切换是原子的。典型switch_to.S实现如下# kern/switch.S .globl switch_to switch_to: # 保存 old_p 的寄存器上下文到其 proc 结构体 movq %rsp, 0(%rdi) # old_p-context-rsp %rsp movq %rbp, 8(%rdi) # old_p-context-rbp %rbp movq (%rdi), %rax # 加载 old_p-context-rip movq %rax, 16(%rdi) # old_p-context-rip %rax (实际为 ret addr) # 切换到 new_p 的内核栈 movq 0(%rsi), %rsp # new_p-kstack_top → %rsp movq 8(%rsi), %rbp # new_p-context-rbp → %rbp # 恢复 new_p 的寄存器 movq 16(%rsi), %rax # new_p-context-rip pushq %rax # 压入 new_p 的返回地址 ret # popq %rip, 跳转到 new_p 的执行点逻辑说明%rdi指向old_p-context保存旧上下文的结构体%rsi指向new_p-context恢复新上下文的结构体pushq %rax; ret是标准的“跳转到任意地址”技巧比jmp *%rax更可靠避免间接跳转预测失败switch_to()本身不修改rax因此fork()返回值能保持不变。若switch_to()后子进程立即#PF检查new_p-kstack_top是否指向合法内存kalloc()是否成功、new_p-context-rip是否为有效用户指令地址如fork()返回后的下一条指令。3.3 用户栈与内核栈分离为什么子进程 printf 会 panic一个经典翻车场景fork()成功子进程调用printf(hello\n)后触发#PF。原因在于printf依赖 libc 的stdout缓冲区和sys_write系统调用而OS_labs的lab3_proc通常只实现了极简的sys_write直接写串口并未实现文件描述符表struct file* fd_array[NFILE]和struct file结构体。当printf尝试写fd1时sys_write查current-fd_array[1]得到NULL解引用空指针导致#PF。解决路径在proc_alloc()中初始化p-fd_arrayfor (int i 0; i NFILE; i) { p-fd_array[i] NULL; } p-fd_array[1] dev_stdout; // 指向预定义的 stdout 设备在sys_write()中增加空指针检查if (current-fd_array[fd] NULL) { return -1; // EBADF }为dev_stdout实现write()方法调用cons_putc()或serial_putc()。血泪经验不要在fork()后立刻调用printf先用kdebug(pid%d\n, getpid())验证进程创建和调度是否正常。kdebug直接写串口不依赖文件描述符。4. 避坑lab2_mem 与 lab3_proc 的 4 个高频翻车点与定位方法OS_labs的学习曲线陡峭不是因为概念难而是因为错误现象与根本原因之间存在多层抽象隔离硬件异常 → 内核 panic → QEMU 退出 → 无日志。以下是我在带某高校操作系统实验课时学生提交的 137 份lab2_mem和lab3_proc报告中出现频次最高的 4 个问题按现象、原因、解决三步给出可执行方案。4.1 现象QEMU 启动后黑屏串口无任何输出make run无报错原因bootasm.S中lgdt指令加载的 GDT 描述符地址错误或 GDT 表项的 Limit 字段未设为0xffff64KB导致 CPU 在mov %ax, %ds时触发#GP且因 IDT 未初始化异常无法被捕获CPU 进入未定义状态。解决在bootasm.S中确认gdt_desc结构体.data gdt_desc: .word 0x1f # Limit 31 (6 * 5 1)但 x86-64 要求 GDT 至少 64KB → 改为 0xffff .quad gdt # Base address检查gdt表中DATA_SEG和CODE_SEG的 DPL 是否为0x0内核态TYPE字段是否正确0x9a为代码段0x92为数据段在bootmain.c的bootmain()开头插入outw(0x8a00, 0x8a00)用逻辑分析仪抓取端口写信号确认代码执行到此处。4.2 现象fork()后子进程#GP(0)GDB 显示rip0x0000000000000000原因switch_to()中new_p-context-rip未正确初始化或proc_alloc()分配的proc结构体未清零context-rip为随机值。解决在proc_alloc()中强制清零struct proc *p (struct proc*)kalloc(); memset(p, 0, sizeof(struct proc)); // 关键否则 context-rip 为垃圾值在fork()中显式设置p-context-ripp-context-rip (uint64_t)fork_return; // 指向 fork_return 函数确认fork_return函数存在且为全局符号void fork_return(void) { extern void trap_return(void); trap_return(); // 跳转到 trap_return开始用户态执行 }4.3 现象make run报错qemu: could not load PC BIOS bios.bin原因QEMU 版本升级后默认 BIOS 路径变更或OS_labs依赖的bios.bin未随源码分发。解决安装seabios包# Ubuntu/Debian sudo apt-get install seabios # macOS (Homebrew) brew install qemu指定 BIOS 路径Linuxexport QEMU_BIOS_PATH/usr/share/seabios qemu-system-x86_64 -bios $QEMU_BIOS_PATH/bios.bin ...或改用-bios none并确保bootasm.S中jmp *0x7c00正确适用于纯实模式引导。4.4 现象GDB连接后info registers显示cs0x0000rip0xffffffffffffffff原因trapframe结构体在用户栈上分配但fork()复制时未正确计算偏移导致p-tf指向非法地址iretq加载无效cs和rip。解决检查fork()中p-tf分配方式必须用kalloc()内核内存不能用malloc()用户态函数验证p-tf地址是否在内核空间if ((uint64_t)p-tf KERNBASE) panic(tf in user space);在trap_entry汇编中添加校验testq $0xffff800000000000, %rax # %rax tf address jz 1f call panic 1: ...5. lab4_fs 的文件系统挂载基于 ramdisk 的简易 ext2 实现与 inode 分配策略lab4_fs是OS_labs的高阶实验目标是让内核能open(/init, O_RDONLY)并read()其内容。它不追求完整 ext2 兼容而是实现一个固定大小、内存驻留、无日志、无硬链接的简化文件系统核心是ramdisksuperblockinode tabledata blocks四层结构。难点不在代码量而在理解inode如何通过i_block[]数组索引数据块以及dir_lookup()如何解析目录项struct dirent找到目标文件的ino。很多开发者卡在open()返回-1却不知问题出在sb-s_root_ino未正确指向根目录 inode或inode-i_mode未设为S_IFDIR。5.1 ramdisk 初始化从内存镜像加载 superblock 与 inode tableOS_labs的lab4_fs通常提供一个fs.img镜像文件由 Python 脚本生成包含预格式化的 superblock、inode table 和 data blocks。内核启动时需将其加载到内存// kern/fs.c void fs_init(void) { // Step 1: 将 fs.img 加载到物理内存 0x200000 处避开内核和页表 memcpy((void*)0x200000, fs_img_data, fs_img_size); // Step 2: 解析 superblock固定偏移 0x0 struct superblock *sb (struct superblock*)0x200000; sb-s_root_ino 1; // 根目录 inode 号为 1 // Step 3: 定位 inode tablesuperblock 后紧邻 struct inode *itab (struct inode*)(0x200000 sizeof(struct superblock)); // Step 4: 初始化根目录 inodeino1 struct inode *root itab[1]; root-i_mode S_IFDIR | 0755; // 目录可读可执行 root-i_size 4096; // 占用 1 个 data block root-i_blocks 1; root-i_block[0] 1; // 指向 data block 1存储目录项 }关键参数说明fs_img_data由Makefile调用dd if/dev/zero offs.img bs1M count4创建的 4MB 镜像再用mke2fs -F -b 4096 fs.img格式化s_root_ino 1ext2 规定 inode 1 为根目录若设为 0 则chdir(/)失败i_block[0] 1i_block[]是一个 15 元素数组i_block[0]~i_block[11]直接索引 data blocki_block[12]为一次间接块。OS_labs仅用i_block[0]S_IFDIR | 0755S_IFDIR0040000标识目录类型0755为权限位rwxr-xr-x。注意fs.img必须用dd创建固定大小不能用truncate否则memcpy会越界读取未初始化内存。5.2 目录查找dir_lookup() 解析 dirent 并匹配文件名open()的核心是dir_lookup(dir_ino, name, ino)它读取dir_ino对应的 data block遍历其中的struct dirent// kern/fs.c int dir_lookup(uint32_t dir_ino, const char *name, uint32_t *ino) { struct inode *dir iget(dir_ino); if (!dir) return -1; // 读取 dir 的第一个 data block char *buf kmalloc(BLOCK_SIZE); read_block(dir-i_block[0], buf); // 从 ramdisk 读取 block 1 到 buf struct dirent *de (struct dirent*)buf; for (int i 0; i BLOCK_SIZE; i de-d_reclen) { if (strncmp(de-d_name, name, de-d_namelen) 0 de-d_namelen strlen(name)) { *ino de-d_ino; kfree(buf); iput(dir); return 0; } de (struct dirent*)(buf i de-d_reclen); } kfree(buf); iput(dir); return -1; }struct dirent定义需与mke2fs生成的格式一致struct dirent { uint32_t d_ino; // inode number uint16_t d_off; // offset to next dirent uint16_t d_reclen; // length of this record uint8_t d_type; // file type (DT_DIR, DT_REG) char d_name[256]; // null-terminated filename };避坑点d_reclen不等于strlen(d_name)1而是对齐到 4 字节的记录长度d_reclen 4 * ((strlen(d_name)13)/4)d_type必须为DT_DIR4或DT_REG8否则stat()返回错误类型d_ino必须在1 ~ sb-s_inodes_count范围内否则iget()失败。5.3 文件读取read_block() 与 buffer cache 的两级缓存设计OS_labs为性能考虑引入简易 buffer cache内存中维护一个struct buf数组每个buf对应一个 disk blockb_valid标识是否已加载。read_block(block_no, dst)流程如下// kern/buf.c struct buf { int b_valid; // 1 loaded, 0 empty int b_blockno; // disk block number char b_data[BLOCK_SIZE]; }; struct buf buf_cache[NBUF]; // NBUF 16 void read_block(int blockno, char *dst) { struct buf *b 0; // Step 1: 查找缓存 for (int i 0; i NBUF; i) { if (buf_cache[i].b_valid buf_cache[i].b_blockno blockno) { b buf_cache[i]; break; } } // Step 2: 未命中则加载 if (!b) { b buf_cache[0]; // LRU 策略总是替换第 0 个 b-b_blockno blockno; memcpy(b-b_data, (void*)(0x200000 BLOCK_SIZE * blockno), BLOCK_SIZE); b-b_valid 1; } memcpy(dst, b-b_data, BLOCK_SIZE); }参数说明BLOCK_SIZE 4096与fs.img格式一致NBUF 16足够覆盖根目录、inode table 和少量文件数据memcpy直接从0x200000 offset读取因fs.img已加载至此。若read_block(1, buf)返回全 0检查fs.img是否真的包含目录项用xxd fs.img | head -20查看 offset0x1000block 1是否有00000000以外的数据。6. 调试技巧用 QEMU 日志反推硬件异常源头与我的三个后悔药在OS_labs的调试中我踩过最深的坑不是代码写错而是把硬件异常归因于软件逻辑。比如#PFPage Fault发生时第一反应是“页表没填对”但实际可能是IDT中某个 handler 的cs段选择子权限位为 0内核态而 handler 代码却放在用户空间地址 —— CPU 尝试跳转时触发#GP但#GPhandler 又因同样原因失效最终表现为#PF因#GPhandler 地址无效。这种链式异常靠GDB单步是追不出来的。下面是我沉淀下来的三个“后悔药”技巧它们不写在任何文档里但每次都能让我在 10 分钟内定位到真正的问题源头。6本文还有配套的精品资源点击获取