拓冰建站拓冰建站
首页 / 资讯中心 / 正文

操作系统实验进阶:从环境搭建到内核模块与调试技巧

简介北京交通大学操作系统课程实验资料包围绕进程管理、内存管理、文件系统、死锁防范与安全机制五个模块展开适合正在学习操作系统、需要完成同类实验或想通过实战巩固理论的学生参考。资源共51个文件其中包含26个C语言源文件、8个备份文件、5个C源文件、4个Markdown文档、2个头文件、1份PDF实验报告和汇编源码等整体以源代码和实验文档为主压缩包约875KB。已有47人学习。实验源码与报告覆盖进程调度、页面置换、简易文件系统、银行家算法与访问控制等典型实现读者可对照代码解读设计思路也能从报告中查看实现步骤、结果分析和问题处理方案既可用于课程作业借鉴也能帮助理解操作系统底层运行机制提升编程与逻辑思维能力。目录结构按实验序号分组便于按模块查找与对比学习。1. 这组操作系统实验到底在训练什么操作系统实验常被误读成“照着指导书敲代码”。真正做过一轮就会意识到课设题目之间并非孤立的小练习而是一条从进程生命周期、CPU 调度、同步互斥到虚拟内存与文件系统的完整抽象链路。北京交通大学操作系统实验的内容设计正是围绕这条链路展开先让你在裸机或模拟器里把一个最小的内核模块跑起来再用调试器观察它如何被加载、调度、抢占最后通过修改调度策略或替换一个页面置换算法切身理解“资源管理”四个字的分量。这套实验的收益点不在“做完了”而在“做对过一次”亲手编译一次 Linux 内核模块、写一个带阻塞队列的信号量实现、在 QEMU 里单步跟踪一次系统调用这些经验无论面试还是日常开发都直接可用。准备工作不需要多高的天赋一台 8G 内存的 x86 机器加上 Ubuntu LTS 就足够下面按一条清晰的路径把环境、实验类型、测试手段和调试技巧一次讲透。2. 实验环境与工具链搭建从裸机到模拟器2.1 为什么推荐“Ubuntu QEMU GDB”三件套操作系统实验的硬件依赖很特殊直接在物理机上反复编译内核并不现实重启等待和误操作崩溃都消耗精力。常见做法是用虚拟机或模拟器作为实验载体把内核跑在一个可控的沙箱里。这里最稳妥的组合是Ubuntu 22.04 / 24.04 LTS 作为宿主机QEMU 作为模拟器GDB 配合源码级调试涉及驱动或中断的实验再叠加 VMware 或 VirtualBox 来验证真实硬件行为。选型理由是各实验类型的约束不同。进程管理和调度算法实验以逻辑验证为主QEMU 的确定性更强快照和单步指令能力比 VMware 更顺手而涉及外设驱动、DMA、真实中断的实验VMware 的虚拟硬件模型更贴近主流 PC适合观察硬件与内核的交互。把两个平台各留一份按实验类型切换比吊死在一个环境里效率高。下表是常见任务与推荐平台的对应关系实验类型推荐平台理由xv6 系统调用、调度算法QEMU xv6源码精简适合单步调试Linux 内核模块LKMVMware Ubuntu真实内核环境模块加载直观同步互斥、死锁模拟任意 Linux 发行版纯用户态可复现不涉及内核态虚拟内存、页面置换QEMU Linux 内核需要可控的内存压力场景RT-Thread 或嵌入式 RTOSQEMUarm或开发板嵌入式场景交叉编译2.2 宿主机最小环境配置拿到一台干净的 Ubuntu 机器先补齐编译链和调试工具。实验不需要图形界面最小安装即可但以下工具包必须装齐sudo apt update sudo apt install -y build-essential gcc-multilib gdb make git qemu-system-x86 \ qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils \ linux-headers-$(uname -r) net-tools vim装完确认内核模块编译依赖是否就位最直接的办法是尝试编译一个空模块mkdir -p ~/oslab/hello cd ~/oslab/hello cat hello.c EOF #include linux/init.h #include linux/module.h MODULE_LICENSE(Dual BSD/GPL); static int __init hello_init(void) { printk(KERN_INFO hello oslab: module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello oslab: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); EOF cat Makefile EOF obj-m hello.o all: make -C /lib/modules/$(shell uname -r)/build M$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M$(PWD) clean EOF make这段代码的逻辑不复杂hello.c定义了模块加载和卸载时的钩子函数printk是内核态的“printf”日志输出到内核缓冲区而非终端Makefile 里的-C参数指定内核源码树目录M$(PWD)告诉 kbuild 去当前目录编译外部模块。如果make后能看到hello.ko文件工具链就是好的。用sudo insmod hello.ko加载dmesg | tail看到hello oslab: module loaded即通过验证。2.3 GDB 调试内核态代码的两种方式用户态程序用 gdb 直接 attach 即可内核态要麻烦得多。常见做法是 QEMU 的-s -S参数配合 GDB 远程调试-s在 1234 端口开放 gdbserver-S让 CPU 在启动时冻结等待调试器连入。启动 QEMU 后在另一个终端用 gdb 连接gdb (gdb) target remote :1234 (gdb) hbreak start_kernel (gdb) continue注意这里用的是hbreak硬件断点而非普通break因为内核启动早期分页尚未建立软件断点会修改指令段导致页错误。另一种更符合课程实验节奏的方式是直接调试 xv6 这样的小型教学内核。xv6 源码量只有一万行左右支持用 gdb 单步跟踪调度器切换、系统调用入口等关键路径。调试时把 xv6 的kernel/kernel符号文件加载进 gdb再用target remote :1234连接 QEMU即可像调试普通 C 程序一样浏览内核变量与调用栈。3. 经典实验拆解系统调用、同步互斥与调度3.1 xv6 增加一个系统调用的完整路径教学操作系统实验中最常见的题目是“新增系统调用”。以 xv6 为例完整路径要修改四处用户态声明、系统调用编号、内核分发表、具体实现函数。缺一处就会在链接或运行时暴露问题。下面是新增sys_hello系统调用的核心改动// user/user.h 中添加用户态函数声明 int hello(void); // kernel/syscall.h 中分配系统调用编号 #define SYS_hello 23 // kernel/syscall.c 中增加分发映射 extern uint64 sys_hello(void); static uint64 (*syscalls[])(void) { [SYS_hello] sys_hello, }; // kernel/sysproc.c 中实现内核态逻辑 uint64 sys_hello(void) { printf(hello from kernel, pid%d\n, myproc()-pid); return 0; }代码后的逻辑要讲清楚用户态调用hello()时编译出的汇编指令把系统调用号放入a7寄存器再执行ecall陷入内核后由syscall()查表分发syscalls数组用了指定初始化器即使编号调整也不会错位。实验常犯的错是改了syscall.h和sysproc.c却漏掉syscall.c的分发表导致运行时提示“未知系统调用号”。另外要注意 xv6 的用户态库函数不是 libc而是 xv6 自带的user.h中的声明新函数必须同时在此声明并实现用户态封装通常在user/user.pl或直接写一个 C 文件才能被init或测试程序调用。3.2 同步互斥实验从信号量到条件变量的实现同步实验的考察点通常集中在三个方面临界区保护、阻塞与唤醒、避免死锁。以“用信号量实现生产者消费者”为例完整可运行的核心代码如下#include pthread.h #include semaphore.h #include stdio.h #define BUFFER_SIZE 8 sem_t empty; // 空槽位数量 sem_t full; // 已占用槽位数量 pthread_mutex_t mutex; int buffer[BUFFER_SIZE]; int in 0, out 0; void *producer(void *arg) { for (int i 0; i 100; i) { sem_wait(empty); // 申请一个空槽位没有则阻塞 pthread_mutex_lock(mutex); // 保护环形队列的写入 buffer[in] i; in (in 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(full); // 增加一个满槽位 printf(produce %d\n, i); } return NULL; } void *consumer(void *arg) { for (int i 0; i 100; i) { sem_wait(full); // 申请一个满槽位 pthread_mutex_lock(mutex); int item buffer[out]; out (out 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(empty); printf(consume %d\n, item); } return NULL; }这段代码的精妙之处在于信号量的计数语义本身就完成了“缓冲区满则生产者等待、缓冲区空则消费者等待”的逻辑互斥锁只负责保护环形队列的索引更新。sem_wait若计数为 0 时会主动让出 CPU这是与自旋锁最本质的区别自旋锁忙等待浪费 CPU信号量走睡眠队列不会。实验中要观察的一个关键指标是打印顺序是否严格交替由于调度时机不可控打印顺序不交替是正常的只要消费数量与生产数量一致各 100 个就说明同步逻辑正确。3.3 调度算法实验多级反馈队列的实现骨架调度算法是另一个高频实验主题。比起写一个只输出“平均等待时间”的模拟程序更有区分度的是实现一个可运行的多级反馈队列调度器。核心数据结构是多个 FIFO 队列每个队列有独立的时间片长度新进程进入最高优先级队列用完时间片未执行完则降级。精简实现如下#define MAX_QUEUE 3 #define TIME_SLICE 10 // 队列 0 时间片长度单位时钟节拍 typedef struct task { int pid; int remaining_time; // 还需运行时间 int queue_level; // 当前所在队列 struct task *next; } task_t; task_t *mlfq_queues[MAX_QUEUE]; // 队列 0 优先级最高 task_t *pick_next_task(void) { for (int level 0; level MAX_QUEUE; level) { if (mlfq_queues[level] ! NULL) { task_t *t mlfq_queues[level]; mlfq_queues[level] t-next; return t; } } return NULL; } void scheduler_tick(task_t *current) { if (current NULL) return; current-remaining_time--; if (current-remaining_time 0) { // 运行完毕直接回收 return; } if (current-queue_level 0 /* 已用完本层时间片的判定条件 */) { current-queue_level 1; // 放入队列尾部重新排队 enqueue(mlfq_queues[1], current); } }这个实现体现的要点是“优先级提升”和“时间片递增”两个机制。只降级不提升会导致长任务饿死所以常规做法是每隔固定周期把队列 1、2 进程全部提升到队列 0。实验报告中需要比较不同时间片长度下的平均周转时间建议把时间片设为 1、5、10、20 四档做对照实验观察短作业优先效应如何被多级反馈结构自动实现。4. 虚拟内存与文件系统用 Linux 内核模块验证原理4.1 从“页表长什么样”到“进程地址空间的读取”虚拟内存实验如果只停留在理论很难留下深刻印象。推荐路径是写一个小型 Linux 内核模块遍历当前进程或指定 PID 进程的页表打印虚拟地址到物理地址的映射关系。这个实验能直观回答“页表到底存在哪里”“PTE 里有什么标志位”这两个基础问题。核心逻辑#include linux/mm.h #include linux/kprobes.h static void walk_page_table(struct mm_struct *mm, unsigned long addr) { pgd_t *pgd pgd_offset(mm, addr); p4d_t *p4d p4d_offset(pgd, addr); pud_t *pud pud_offset(p4d, addr); pmd_t *pmd pmd_offset(pud, addr); pte_t *pte pte_offset_map(pmd, addr); if (pte_present(*pte)) { unsigned long phys pte_pfn(*pte) PAGE_SHIFT; printk(va%lx - pa%lx flags%lx\n, addr, phys, pte_flags(*pte)); } pte_unmap(pte); }这段代码遍历了四级页表pgd、p4d、pud、pmd、pte最后解析出物理页帧号和权限位。实验可进一步扩展用/proc/pid/maps选择一个数据段的地址传入模块观察它的 PTE 是否被置为可写、用户态可访问再出发一次缺页后重新查看看pte_present如何从 0 变为 1。这比单纯跑一个 LRU 模拟程序要深刻得多因为直接观察到的是真实硬件行为。运行时需要以 root 权限加载模块并确保内核开启了CONFIG_PROC_FS与相关调试配置。4.2 页面置换算法的可复现对比LRU 与时钟算法若实验要求实现页面置换算法不要只给“概念加伪代码”需要能读入引用串并输出缺页率的程序。时钟算法Clock也称二次机会算法是 LRU 的低开销近似在真实内核中使用广泛。一个能在用户态直接编译运行的简化版#include stdio.h #include stdlib.h #define FRAME_NUM 4 int frames[FRAME_NUM]; int refbits[FRAME_NUM]; int pointer 0, page_faults 0; int find_victim(void) { while (1) { if (refbits[pointer] 1) { refbits[pointer] 0; // 给第二次机会 } else { int victim pointer; pointer (pointer 1) % FRAME_NUM; return victim; } pointer (pointer 1) % FRAME_NUM; } } void clock_access(int page) { int i; for (i 0; i FRAME_NUM; i) { if (frames[i] page) { // 命中 refbits[i] 1; return; } } page_faults; int victim find_victim(); // 未命中选驱逐对象 frames[victim] page; refbits[victim] 1; }算法逻辑的关键是淘汰指针只单向移动遇到引用位为 1 时不清除而是重置为 0等到第二次扫过时再驱逐。这样做的目的是避免刚访问过的页被立即换出。实验时构造访问序列对比 OPT、FIFO、LRU 与 Clock 四种算法的缺页次数表格是实验报告中常用的整理方式访问序列FIFOLRUClock1,2,3,4,1,2,5,1,2,3,4,59891,2,3,4,5,1,2,3,4,5101010随机 100 位序列约 60约 55约 574.3 文件系统实验用 debugfs 洞察 inode 与磁盘布局文件系统实验的常见误区是直接写一个“模拟文件系统”的纯逻辑程序和真实磁盘交互完全无关。更有价值的实验方式是用debugfs工具直接检查真实 ext4 文件系统的结构inode 号、块位图、间接块索引。一条命令即可验证磁盘布局sudo debugfs -R stat /etc/hostname /dev/sda2命令输出中包含Inode: 2345这类信息Blocks行列出数据块号。把/etc/hostname的 inode 号记下来再用sudo debugfs -R blocks /etc/hostname /dev/sda2查看该文件占用的具体块号然后dd if/dev/sda2 of/tmp/block.raw bs4096 skip块号 count1提取原始块内容strings /tmp/block.raw能看到文件内容确实落在这里。这一系列命令把“文件系统是保存在磁盘上的数据结构”这个抽象概念变成了肉眼可见的字节序列。5. 自动化测试与评分验证让实验“可验收”5.1 用脚本驱动测试而不是人工点点点操作系统的正确性验证比普通应用复杂因为很多结果具有不确定性如线程调度顺序。所以测试脚本要同时关注“功能性断言”和“统计性断言”。功能性断言检查系统调用返回值、文件内容是否符合预期统计性断言检查多次运行后某个指标落在合理区间。一个简单的 BASH 测试框架#!/bin/bash # 测试 xv6 的 hello 系统调用 make qemu-nox /tmp/xv6_run.log 21 QEMU_PID$! sleep 3 # 从 QEMU 串口日志中查找预期输出 if grep -q hello from kernel /tmp/xv6_run.log; then echo [PASS] sys_hello output is present else echo [FAIL] sys_hello output missing fi # 检查系统调用编号是否冲突 grep unknown syscall /tmp/xv6_run.log echo [FAIL] syscall number conflict kill $QEMU_PID脚本中make qemu-nox以无图形模式启动 xv6stdout 重定向到日志文件后续测试通过日志关键字判断结果。这里的关键在于-nox模式必须配合sleep等待内核启动完成否则 grep 永远匹配不到内容。更稳妥的方式是检测串口输出中出现init: starting sh这一标志性字符串后再开始测试。5.2 并发实验的测试设计重复、压力、边界并发程序的 bug 具有偶发性一次运行通过不代表逻辑正确。测试要覆盖三组场景高并发压力启动 64 个生产者和 64 个消费者各执行一万次操作、极端边界缓冲区大小为 1 时验证不会死锁缓冲区大小为 1 时验证不会出现两个线程同时写、长时间运行持续十分钟以上观察是否有偶发阻塞。对内核并发实验还可以加上CONFIG_DEBUG_ATOMIC_SLEEP等内核调试选项来捕获原子上下文中的睡眠行为。5.3 评分维度与常见错误定位表课程实验的评分通常从功能、设计、代码规范、报告质量四个维度展开。功能测试占比最大常见错误集中在这样几类错误表现定位方向排查手段模块加载报Invalid module format内核版本或编译器不匹配uname -r对比模块信息死锁或程序卡住信号量/PV 操作不成对gdb attach 后用thread apply all bt查看栈打印乱码或地址无意义指针错误或页表未正确映射检查指针类型与virt_to_phys使用系统调用返回 -1编号或分发表映射错误在syscall()中打印调用号偶现段错误并发状态下共享变量未加锁ThreadSanitizer 或内核的KCSAN6. 把实验做到可交付的工程化技巧6.1 printk 分级规范养成内核日志的好习惯写内核代码时printk的日志级别不是随便填的。KERN_ERR表示影响功能的错误KERN_INFO用于正常运行信息KERN_DEBUG用于调试信息。开发期间把大量调试信息打成KERN_INFO上线前再统一降级会造成大量无效日志刷屏。正确做法是从第一行代码起就明确分级调试期用KERN_DEBUG并通过/proc/sys/kernel/printk控制输出级别提交时不需要额外清理。6.2 三个立竿见影的排错技巧第一个技巧是“少打多判”在关键路径上只打印“入口和出口”中间用状态变量辅助判断这比在循环里打印一大堆数据高效得多。比如排查调度器 bug 时观察pick_next_task的入口参数和返回值就足以定位大部分问题无需查看每一步内部状态。第二个技巧是“先复现再修”操作系统的偶发 bug 在未稳定复现前不要动手改代码。用 QEMU 快照保存崩溃前的现场配合-d int,cpu_reset参数记录中断和 CPU 复位事件能极大缩小排查范围。复现后先记录现场再分析避免“越改越乱”。第三个技巧是“保留回归脚本”每次修改后不仅跑当前实验的测试还要把过往实验的测试全部跑一遍。xv6 这类小型内核常在修复一个 bug 时引入另一个相关回归。准备一个顶层run_all_tests.sh一条命令跑完所有实验的验证逻辑这是把课程设计做成工程项目的关键一步。结合 git 做每次修改的提交——不要等到所有代码写完再一次提交那样真出问题时无法二分定位。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门