从零手写操作系统:内核态用户态切换与系统调用实现实验指南

发布时间:2026/10/20 15:05:17
从零手写操作系统:内核态用户态切换与系统调用实现实验指南
简介HNU_ostep-操作系统实验是一套面向高校计算机专业学生的操作系统课程实践资源围绕进程管理、内存管理、文件系统与I/O系统等核心模块展开适合正在学习操作系统原理、需要动手实现内核组件的学习者。压缩包共收录2000个文件以910个C源文件和760个头文件为主体辅以Python脚本、Shell脚本、CMake与Makefile构建文件以及Markdown、TXT说明文档和少量PDF、DOCX参考资料整体约42.51MB。其中ostep_code目录提供实验相关源码可作为实现调度算法、页面置换等功能的蓝本readme.txt则给出实验总览、环境搭建与任务要求帮助读者快速把握整体框架。已有165人学习该资源内容兼顾理论理解与代码实践便于读者通过阅读和修改源码直观观察不同算法对系统行为的影响为后续系统软件开发积累经验。1. 从零手写内核到用户态这套操作系统实验资源到底能跑通什么如果你正在搜“操作系统实验怎么做”“内核态用户态怎么切换”“系统调用怎么实现”大概率已经翻过不少 PPT 和教材但真正能编译、能跑、能调试的完整工程没几个。这套 HNU_ostep 操作系统实验资源核心价值就一句话它把从裸机启动、中断处理、内存管理到用户态进程与文件系统的整条链路拆成了可独立编译、可逐步验证的实验模块。适合两类人一是正在上操作系统课、需要交实验但不想只抄报告的学生二是想补底层功底、但被 Linux 内核源码规模劝退的开发者。它不要求你先精通汇编但要求你愿意动手改代码、看串口输出、用调试器单步跟。下面我按“先跑起来、再改参数、最后排错”的顺序把这份资源里最值得复现的部分拆开讲。2. 环境搭建与首次编译把镜像跑进模拟器2.1 工具链选型为什么用交叉编译而不是本机 gcc操作系统实验的第一道坎不是写代码而是让编译出来的二进制能在目标架构上跑。这套资源默认面向 RISC-V 或 x86 的裸机环境常见做法是用交叉编译工具链比如riscv64-unknown-elf-gcc或i686-elf-gcc。为什么不用本机 gcc因为本机 gcc 默认链接的是宿主系统的 libc 和启动文件而内核需要自己定义入口_start、自己控制链接地址不能依赖任何用户态库。我一般会先确认工具链是否在 PATH 里# 检查交叉编译器是否存在版本号能打印出来说明可用 riscv64-unknown-elf-gcc --version # 如果提示 command not found需要先安装或手动加入 PATH export PATH$PATH:/opt/riscv/bin参数说明--version不只是看版本还能确认当前 shell 找到的是哪个路径下的编译器。很多人编译报“无法识别指令集”最后发现是用了本机 gcc 而不是交叉编译器。链接脚本linker.ld里会指定代码段起始地址比如0x80000000这个地址必须和模拟器加载地址一致否则第一条指令就取指异常。2.2 编译与运行三条命令背后的完整流程资源里通常有Makefile但直接make之前建议先看懂它做了什么。常见步骤是编译汇编启动文件、编译 C 内核、链接成 ELF、再转成二进制镜像。下面是一个典型流程# 清理旧产物避免残留 .o 文件导致链接错误 make clean # 编译并链接生成 kernel.elf make # 用 QEMU 加载并运行-nographic 表示串口输出到终端 qemu-system-riscv64 -nographic -machine virt -kernel kernel.elf逻辑说明make clean不是可有可无我遇到过因为旧.o没删改了头文件但行为没变排查半小时才发现是增量编译没触发。-nographic把图形界面关掉所有输出走串口适合看printk或printf的调试信息。如果 QEMU 启动后没有任何输出先检查kernel.elf的入口地址是否和-kernel加载地址匹配再看串口初始化有没有写对 UART 寄存器。提示第一次跑通不要急着改代码先确认串口能打印出启动日志这是后续所有调试的基础。2.3 目录结构速览每个文件夹对应哪个实验这份资源的目录一般按实验阶段划分常见结构如下表。你拿到手先对照自己的实验要求确认缺哪个模块。目录/文件作用对应实验boot/启动汇编与入口裸机启动、栈初始化kernel/内核主逻辑中断、时钟、内存管理user/用户态程序与库系统调用、进程fs/文件系统实现读写、目录、inodeMakefile编译规则全流程构建linker.ld链接脚本内存布局如果你只做前三个实验可以先把fs/排除在编译目标外减少链接错误干扰。常见做法是改Makefile里的OBJS变量只保留当前实验需要的目标文件。3. 中断与系统调用从时钟中断到用户态切换3.1 中断处理流程为什么保存现场顺序不能乱操作系统实验里最容易被“玄学”卡住的就是中断。现象是时钟中断来了程序跑飞或死循环。原因多半是保存现场和恢复现场的顺序不对称。RISC-V 里常见做法是在汇编里先分配栈空间再按固定顺序保存ra、sp、gp、tp以及t0-t6、a0-a7等寄存器。恢复时反序弹出最后sret返回。# 中断入口先保存现场再调用 C 处理函数 csrrw t6, mscratch, t6 # 交换 t6 和 mscratch拿到栈指针 sd ra, 0(t6) # 按固定偏移保存 ra sd sp, 8(t6) # ... 其余寄存器依次保存 call trap_handler # 进入 C 语言处理逻辑 # 恢复时按相反顺序 ld 回来逻辑说明mscratch里存的是当前 hart 的栈指针交换后t6指向内核栈。保存顺序必须和恢复顺序严格相反否则ra被覆盖函数返回就跳到错误地址。参数说明偏移量0、8对应结构体里各寄存器的位置改结构体时偏移也要同步改否则读出来的值全是乱的。3.2 系统调用实现用户态如何“主动”陷入内核系统调用是用户态和内核态之间的受控入口。常见做法是用户态把系统调用号放进a7参数放进a0-a5然后执行ecall。内核的 trap 处理函数根据a7分发到不同处理逻辑。// 用户态发起系统调用写文件描述符 long sys_write(int fd, const void *buf, size_t count) { register long a7 asm(a7) 64; // 64 是 write 的调用号 register long a0 asm(a0) fd; register long a1 asm(a1) (long)buf; register long a2 asm(a2) count; asm volatile(ecall : r(a0) : r(a1), r(a2), r(a7) : memory); return a0; // 返回值放在 a0 }逻辑说明register ... asm(a7)是 GCC 的显式寄存器变量语法强制把值放到指定寄存器。ecall触发异常后CPU 跳到mtvec或stvec指向的入口。内核里根据a7查表调用sys_write实现返回值再写回a0用户态就能拿到结果。参数说明memory告诉编译器这段汇编可能改内存不要缓存变量否则优化后buf内容可能没写进去。3.3 用户态切换页表和栈指针一起换才安全从内核态返回用户态时不能只改sret的返回地址还要切换页表和栈指针。常见做法是在进程控制块里保存用户页表基址和用户栈指针切换时先写satp再恢复sp。// 切换到用户态前设置页表、恢复用户栈、跳转到用户入口 void switch_to_user(struct task_struct *task) { write_csr(satp, task-pgdir); // 切换地址空间 asm volatile(mv sp, %0 : : r(task-user_sp)); // 换用户栈 asm volatile(sret); // 返回用户态 }逻辑说明satp是 RISC-V 的页表基址寄存器写错会导致取指时页表映射不对直接触发缺页异常。user_sp必须是用户页表里映射过的地址否则换栈后第一次压栈就崩。参数说明task-pgdir通常是物理地址右移 12 位后的值具体格式看手册不同版本可能不同。注意切换页表和换栈之间不要插入任何可能触发内存访问的代码否则会用到旧页表或旧栈行为不可预测。4. 内存管理与文件系统页分配、缺页和 inode 读写4.1 物理页分配空闲链表和位图怎么选内存管理实验里物理页分配器是基础。常见两种做法空闲链表和位图。空闲链表分配快但容易产生外部碎片位图实现简单适合页数量固定的场景。这份资源里我一般先用位图跑通因为调试时可以直接打印位图看哪些页被占用。// 位图分配找到第一个为 0 的位置 1 并返回页号 int alloc_page(void) { for (int i 0; i TOTAL_PAGES; i) { if (!(bitmap[i / 8] (1 (i % 8)))) { bitmap[i / 8] | (1 (i % 8)); // 标记占用 return i; } } return -1; // 无空闲页 }逻辑说明i / 8定位字节i % 8定位位。置 1 表示占用释放时置 0。参数说明TOTAL_PAGES由内存大小和页大小决定比如 128MB 内存、4KB 页就是 32768 页。如果分配失败返回 -1调用方要处理不能直接解引用。4.2 缺页异常读位和写位要分开判断缺页异常处理是内存管理里最容易翻车的部分。现象是用户程序写一个只读页内核没报错但数据没写进去。原因通常是缺页处理时没区分读缺页和写缺页或者没检查页表项的权限位。// 缺页处理根据 scause 判断是读还是写再决定是否分配新页 void do_page_fault(uint64_t addr, uint64_t cause) { pte_t *pte walk_page_table(addr); // 查页表 if (!(*pte PTE_V)) { // 页不存在分配新页 void *page alloc_page(); *pte PA2PTE(page) | PTE_V | PTE_R | PTE_W | PTE_U; } else if (cause 0x2) { // 写缺页但页只读复制或报错 // 常见做法COW 时复制新页否则杀进程 } }逻辑说明PTE_V表示有效PTE_R/PTE_W/PTE_U分别表示读、写、用户态可访问。cause的最低位区分读和写。参数说明PA2PTE把物理地址转成页表项格式通常是右移 12 位再左移 10 位具体看手册。如果写缺页但页表项没有PTE_W不能直接置位否则会破坏只读语义。4.3 文件系统inode 和目录项怎么配合文件系统实验通常要求实现open、read、write、close。核心结构是 inode 和目录项。inode 存元数据和数据块指针目录项存文件名到 inode 号的映射。// 查找目录项遍历目录文件的数据块比较文件名 int dir_lookup(struct inode *dir, const char *name) { struct dirent de; for (int off 0; off dir-size; off sizeof(de)) { read_inode_data(dir, de, off, sizeof(de)); // 读一个目录项 if (de.inode 0) continue; // 空项跳过 if (strcmp(de.name, name) 0) return de.inode; // 命中 } return -1; // 未找到 }逻辑说明目录本身也是文件数据块里存的是dirent数组。de.inode 0表示该项已删除或未使用。参数说明off按sizeof(de)递增不能按字节递增否则会读到半个目录项。read_inode_data内部要根据文件偏移算出逻辑块号再查 inode 的块指针数组。提示调试文件系统时先实现一个ls系统调用把目录项打印出来比直接跑用户程序更容易定位问题。5. 避坑与排查五个让我熬夜的典型问题5.1 现象QEMU 启动后卡住无任何串口输出原因最常见的是入口地址不对或者栈指针没初始化就调用了 C 函数。另一个可能是串口波特率或寄存器地址写错。解决先用-d in_asm看 QEMU 是否在取指如果取指正常但没输出检查 UART 初始化代码。我一般会在_start里直接写一个字符到串口数据寄存器确认硬件通路没问题。5.2 现象中断处理里调用printf后死机原因printf可能触发内存分配或锁操作而中断上下文里不允许睡眠或重入。解决中断里只用printk这种不依赖锁的简单输出或者先把日志存到缓冲区退出中断后再打印。参数说明如果printk内部用了自旋锁要确保中断关闭否则可能死锁。5.3 现象系统调用返回 -1但用户态参数明明是对的原因内核里从用户态指针读数据时没做地址检查或者页表没切换导致读的是内核地址。解决在sys_write等函数里先用copy_from_user或手动walk_page_table确认用户地址有效。常见做法是检查地址是否小于用户空间上限再逐页确认页表项有PTE_U。5.4 现象文件写入后读出来是乱码原因inode 的数据块指针没更新或者写偏移计算错误。解决在write里打印逻辑块号和块内偏移确认和read一致。参数说明如果文件大小不是块大小整数倍最后一块要特殊处理不能直接写整块。5.5 现象进程切换后第一次系统调用就崩原因切换时没保存或恢复tp寄存器而内核用tp指向当前进程控制块。解决在上下文切换汇编里显式保存tp或者用mscratch存当前任务指针。我一般会在切换后打印tp的值确认指向正确的任务结构。6. 进阶验证用用户态程序压测内核边界跑通基本实验后真正能暴露问题的是写一个稍微复杂的用户态程序比如递归计算斐波那契、频繁读写文件、或者创建多个进程。下面这个测试程序会反复触发系统调用和缺页// 用户态压测递归 文件写 内存分配 int fib(int n) { return n 2 ? n : fib(n-1) fib(n-2); } int main() { int fd open(test.txt, O_CREAT | O_WRONLY); for (int i 0; i 100; i) { char buf[64]; int len sprintf(buf, %d\n, fib(20)); // 触发栈增长 write(fd, buf, len); // 触发文件系统 } close(fd); return 0; }逻辑说明fib(20)递归深度约 20 层会触发多次栈页缺页。write反复调用会测试文件系统缓存和 inode 更新。参数说明如果栈只有一页递归到第 10 层左右就会缺页正好验证缺页处理是否正确。如果文件系统没实现O_CREATopen会返回 -1先确认标志位处理。验证方法跑完后用ls看文件大小是否等于预期再用read读回来对比内容。如果大小对但内容错多半是写偏移没更新。如果大小不对检查 inode 的size字段有没有在每次写后累加。从那以后我每次改完中断或页表相关代码都强制走一遍“编译、启动、跑压测、看串口日志”四步绝不跳过任何一步。希望帮到你。本文还有配套的精品资源点击获取