☰
操作系统实验工程化:QEMU调试、内核同步与调度算法避坑指南
2026/10/11 14:09:30 网站建设 项目流程

简介:山东大学操作系统实验资料包围绕进程控制、线程与管道通信、Shell(MSH)、进程同步与互斥五大实验展开,内容适合高校操作系统课程学生作为实验参考、期末复习或课程设计素材。压缩包共51个文件,以13个C源程序、3个头文件、4个Makefile及5个docx实验报告为主体,另有msh、testsh等命令脚本与若干编译中间文件,整体仅1.37MB,轻量易用。已有3757人浏览学习,是同类资源中热度较高的参考资料。内含理发店问题、抽烟者问题等经典同步互斥场景的完整实现,以及lab1、lab2等示例程序,覆盖进程创建与控制、管道通信、命令解析、信号量与互斥锁等核心知识点;读者可对照源码逐行梳理实现细节,结合docx文档理解设计思路与排错方法,对提升系统编程与并发调试能力有明显帮助。

1. 操作系统实验:从一门课变成一套“让内核跑起来”的工程练习

操作系统实验,与其说是一门课,不如说是一套“让内核跑起来”的工程练习。同样是写调度器,有同学两小时写完 Round-Robin 并在 4 核模拟器上跑通验证,也有同学卡在一个陌生的 panic 上两周,最后发现只是共享变量缺一个 volatile。这类实验通常从进程同步开始,一路走到调度算法、地址映射,再到系统调用,每一步改的不是用户态玩具,而是跑在模拟 CPU 上的内核本体。这篇笔记面向正在做某高校操作系统实验的同学,也面向想补内核基础的从业者,核心思路一句话:把实验结果变成可复现、可对比、可回放的工程产物,而不是跑通一次就撒手。

2. 用 QEMU 和交叉编译链把实验内核跑起来:最小环境与源码地图

实验包一般是源码形式下发,内核本体需要你自己构建。常见的坑是照着别人的教程启动,结果串口一片空白,问题根本不在代码,而在启动参数。我一般会把启动、编译、调试三条命令固定成脚本,每轮实验只改参数不改流程,后面所有看似玄学的内核崩溃,都有一份可复现的日志作为排查起点。

2.1 启动脚本和四个必调参数:从镜像加载到串口输出

QEMU 承担的是“模拟 CPU + 外设”的角色,实验内核通常是裸机镜像,不走 BIOS 引导,所以启动参数和跑 Linux 虚拟机完全不一样。下面这个脚本是我习惯的最小模板:

#!/bin/bash # qemu-run.sh:固定启动参数,方便重复实验 ARCH=x86_64 # 实验包是什么架构就换什么,riscv 则换成 qemu-system-riscv64 KERNEL=./build/kernel.bin # 具体镜像名以实验包 Makefile 为准 qemu-system-${ARCH} \ -kernel "$KERNEL" \ # 直接加载内核镜像,不走引导程序 -m 128M \ # 128M 内存足够跑同步与调度实验 -smp 4 \ # 4 个虚拟 CPU,专门压多核竞态 -serial mon:stdio \ # 串口接到当前终端,内核日志从这输出 -s -S \ # -s 开 gdb 远程端口 1234;-S 等待调试器连接 -no-reboot # 内核 panic 后停在现场,而不是立刻重启

四个参数各自有讲究。-kernel 告诉 QEMU 直接加载内核镜像,不用磁盘镜像和引导程序;-smp 4 决定了你后面做同步实验时竞态能不能被压出来,单核跑哲学家问题很难暴露问题,4 核跑十万次才容易复现偶发死锁;-serial mon:stdio 把内核的串口输出重定向到当前终端,printk 的所有日志都走这里;-no-reboot 是保命参数,内核 panic 后 QEMU 不会自动重启,崩溃现场留在屏幕上方便抓证据。

启动后内核通常会停在 -S 指定的暂停状态,需要 gdb 连上才继续跑。另一个终端执行:

gdb ./build/kernel.elf (gdb) target remote localhost:1234 (gdb) continue

kernel.elf 是带符号表的内核文件,kernel.bin 是纯二进制镜像。连上 gdb 后你可以在任意函数下断点,也能直接看变量。这里我的建议是:串口日志是第一证据,gdb 是第二证据,不要一上来就依赖 printf 看运行路径,先确认“能不能停住、能不能输出、能不能打断”三件事,环境就算通了。

2.2 源码地图:先读 Makefile,再找入口汇编与主函数

实验内核的源码包通常不大,几百个文件撑死,但直接从头读到尾是浪费时间。我拿到实验包后的固定动作是先读构建入口,再找程序入口:

# 先搞清构建目标,再碰代码 make help 2>/dev/null | head -30 cat Makefile | head -80 # 找主函数和启动入口,不同实验包命名不同 grep -rn "start_kernel\|kmain\|kernel_main" kernel/ --include=*.c | head -20 grep -rn "\.globl.*start" arch/ boot/ --include=*.S | head -20

Makefile 里最先看三个变量:CROSS_COMPILE 决定了用哪个交叉编译器前缀,ARCH 决定了编译哪个架构的内核,KERNEL_LOAD_ADDR 或类似变量告诉链接器内核代码要加载到哪个物理地址。这三个值任何一个不对,启动就会在进入 C 代码之前翻车,而且串口通常没有输出,看起来像黑匣子。

程序入口一般在 arch 目录下的启动汇编文件里,名字可能是 start.S、head.S 或 entry.S。它的作用是把 CPU 切到内核模式、设置栈指针、清 BSS 段,然后跳转到 C 语言主函数。你不需要读懂每一行汇编,但必须找到那个跳转目标,因为它决定了你 gdb 时应该在哪个地址下断点。

构建命令我习惯这样写:

# 改动之后增量编译,-j 按 CPU 核数并行加速 make ARCH=x86_64 CROSS_COMPILE=x86_64-linux-gnu- -j"$(nproc)"

如果实验包自带顶层脚本,以它的为准。重点不是命令本身,而是你要能回答“我改的哪个文件、它有没有被编进去”。很多实验做到一半发现行为没变化,一查是 Makefile 里的源文件列表根本没包含你新建的 .c 文件,这种问题靠读构建系统五分钟就能定位。

2.3 确认改动真的进了镜像:增量编译与字符串定位

我见过最冤枉的情况是:代码改了、make 也执行了,但跑起来还是旧行为,于是开始怀疑玄学。其实只要在镜像里搜你新加的日志字符串,就能确认改动有没有进产物:

# 构建产物里搜你新加的日志串,确认代码真的在镜像里 strings build/kernel.bin | grep "hello_scheduler"

字符串找不到时先别慌,可能原因有三:编译器把它优化掉了、你的字符串放在只读数据段但被压缩、或者源文件根本没参与构建。逐个核对,前两个用 objdump 看符号表就能确认,后一个回 Makefile 加文件。这个习惯能帮你把“玄学问题”直接降级成“工程问题”,后面所有实验都受益。

3. 三个必做实验的落地套路:同步互斥、调度算法与地址映射

把同步、调度、地址映射三个实验按顺序做下来,等于把内核从“会并发”走到“会管资源、会隔离”。这是操作系统实验最稳定的主线。每个实验我都给出核心代码骨架和验证思路,你拿到实验包后只要按接口名替换成自己的实现即可。

3.1 哲学家就餐不翻车:信号量初始值与临界区设计

同步实验最经典的题目是哲学家就餐,变体还有生产者消费者和读者写者。设计核心是“一次拿多把锁时的原子性”,以及信号量初始值是否正确,这两点决定了死锁会不会出现:

#include "sem.h" #define N 5 static sem_t forks[N]; static sem_t table; // table 初始化为 N-1,限制同时就餐人数 void philosopher(int id) { int left = id; int right = (id + 1) % N; for (;;) { sem_wait(&table); // 最多 N-1 个人同时拿起第一把叉子 sem_wait(&forks[left]); sem_wait(&forks[right]); eat(); sem_signal(&forks[right]); sem_signal(&forks[left]); sem_signal(&table); think(); } }

sem_t 的具体结构取决于实验包,但接口基本是 sem_init、sem_wait、sem_signal 三个。table 初始值为 N-1 的思路是破坏“循环等待”条件:即使五个哲学家都拿到了左边叉子,也至少留有一个空位,让某人能同时拿到两把叉子。如果你把 table 初始值改成 N,那它就是个纯摆设,死锁条件重新成立。

验证不能只看“跑起来没死锁”,要记录等待时间。常见做法是在 sem_wait 前后各读一次系统时钟,把差值累加到任务结构里,实验结束时统一打印:

uint64_t start = arch_clock(); sem_wait(&forks[left]); wait_time += arch_clock() - start;

这个数据后面写实验报告时非常有用。注意同步实验的隐蔽坑是“并发度太低看不出问题”:单核上哲学家串行执行,怎么跑都不会死锁;必须开 -smp 4 并让每个哲学家真跑在独立执行流里,死锁现场才会出现。

3.2 调度实验调四个参数:时间片、抢占阈值、优先级权重与唤醒延迟

调度实验要求你实现至少两种调度策略并对比。最常见的组合是 Round-Robin 带优先级扩展,以及 FCFS 作为基线。核心数据结构是任务表和 pick_next 函数:

struct task *pick_next(void) { struct task *best = NULL; struct task *cand; for (int i = 1; i <= NR_TASKS; i++) { cand = &task_table[(current->pid + i) % NR_TASKS]; if (cand->state != RUNNABLE) continue; // 高优先级优先;优先级相同时按循环顺序,保证公平 if (best == NULL || cand->priority > best->priority) best = cand; } return best; }

时间片的消耗和切换触发通常在时钟中断里处理,我在实验包里一般按这个框架补:

void tick_handler(void) { current->time_slice--; // 每个 tick 扣减当前任务时间片 if (current->time_slice <= 0) { current->time_slice = TIME_SLICE; // 重置时间片,标记需要调度 need_sched = 1; } }

四个参数是调度实验的核心调优对象,我用这个表格把它们理清:

参数常见取值对结果的影响
时间片长度10~20 个 tick太短则切换开销大,太长则交互任务响应迟钝
抢占阈值0 或 10 表示时间片耗尽才切换,1 表示高优任务到达立刻抢占
优先级权重1~100权重差越大,低优先级任务越容易被饿死
唤醒延迟唤醒后立即调度或下一 tick 调度影响 IO 密集型任务的平均等待时间

验证调度器不是跑通就完,要构造混合任务集:几个 CPU 密集型任务一直算,几个 IO 密集型任务频繁主动让出 CPU。记录每个任务的到达时间、首次运行时间、完成时间和累计运行时间,再算周转和等待。这里最容易混淆的是“主动让出 CPU”和“时间片耗尽”两条路径,它们都必须走调度入口,否则 IO 密集任务会卡住。

3.3 地址映射实验:把页表遍历改成日志输出,虚拟地址不再黑匣子

地址映射实验的目标是理解 MMU 怎么把虚拟地址变成物理地址。很多教学内核提供一个 walk 辅助函数遍历多级页表,你要做的往往不是从零实现,而是把它的中间结果打印出来验证:

void dump_page(pgd_t *pgd, uintptr_t vaddr) { // CREATE_NONE 表示只查页表,不自动分配新页 pte_t *pte = walk_pgd(pgd, vaddr, CREATE_NONE); if (pte && (*pte & PTE_P)) { uintptr_t paddr = *pte & PTE_ADDR_MASK; printk("va=%lx -> pa=%lx flags=%lx\n", vaddr, paddr, *pte & PTE_FLAG_MASK); } else { printk("va=%lx -> unmapped\n", vaddr); } }

然后在一条用户进程的地址空间里遍历扫描:

for (uintptr_t va = USER_START; va < USER_END; va += 4096) { dump_page(process->pgd, va); }

PTE_P 是存在位,PTE_ADDR_MASK 是物理页帧号字段掩码,PTE_FLAG_MASK 是权限位。常见做法是把可读写的用户段、内核代码段、内核堆段全部打印出来,对照实验手册里的内存布局图,逐个确认映射关系。

这里最重要的一条经验:页表项里拿到的物理地址,不要当虚拟地址直接解引用。教学内核通常只建立了部分映射,你拿物理地址当指针访问,轻则读到错误数据,重则直接触发 page fault 让整个内核 panic。验证映射关系用 printk 输出地址就够了,想读写物理内存也得先查实验手册里给你开放的映射区。

4. 把实验推进到可验收状态:串口日志、gdb 脚本与结果对比表

实验系统评判靠的是可复现验证,不是“我刚跑通一次”。所以我习惯在写实验功能之前先搭好验证设施:崩溃能自动回放、结果能按表格对比。这个阶段花一小时,后面能省三天的血泪调试时间。

4.1 用 gdb 脚本回放崩溃现场:断住 panic 自动打印调用栈

人工盯着 QEMU 窗口等 panic 出现,然后手敲 bt 是高成本操作。把 gdb 命令写成脚本,每次崩溃自动输出现场:

# crash.gdb:断在 panic 后自动输出调用栈与寄存器 set pagination off target remote localhost:1234 break panic commands silent printf "=== catch panic ===\n" bt info reg x/16gx $sp quit end continue

set pagination off 关闭分页,避免每屏停顿;silent 关闭断点命中时的默认打印,只输出我们关心的内容;bt 看调用栈,info reg 看寄存器现场,x/16gx 看栈顶 16 个字的内容。这个脚本配合 QEMU 的 -s 参数,可以在内核 panic 时自动抓住完整现场。

偶发问题要批量跑才能定位。我一般把一轮实验重复几十次,每次单独存日志,再统一对比:

# 批量回放:崩溃日志按轮次存档,最后统一检查 for i in $(seq 1 50); do timeout 90 qemu-system-x86_64 -kernel ./build/kernel.bin \ -smp 4 -serial file:/tmp/qemu-$i.log -s & QPID=$! sleep 2 gdb -batch -x crash.gdb ./build/kernel.elf wait $QPID done grep -l "panic" /tmp/qemu-*.log

没有 panic 的轮次靠 timeout 兜底结束,panic 的轮次靠 crash.gdb 里的 quit 退出。脚本输出的 /tmp/qemu-*.log 里,出现 panic 的轮次会排在 grep 结果前列,你会立刻知道这个问题是每次必现还是十次出一次。

4.2 设计实验对比表:周转时间、等待时间与 CPU 占用怎么采集

调度实验的对比数据,要在内核里主动埋点采集,不能靠人工读秒表。我通常在任务结构体里加一个记录块:

struct sched_record { int pid; uint64_t arrival; // 到达就绪队列的时间 uint64_t start; // 第一次被选中运行的时间 uint64_t finish; // 任务退出时间 uint64_t cpu_time; // 累计占用 CPU 的时间 };

采集点分别在任务创建、首次 pick_next 命中、每次时间片开始、任务退出四个位置写入对应字段。实验结束后把所有记录按 pid 排序输出到串口,再在脚本里用 awk 或 Excel 计算指标。这个数据结构不复杂,但它决定了你的实验结果可信不可信。下面这张表是我写实验报告时的标准字段:

字段含义采集点
pid任务唯一标识任务创建时分配
arrival到达就绪队列时间创建任务时读系统时钟
start第一次被调度选中时间pick_next 命中该任务时记录
finish任务完成时间任务退出清理前记录
cpu_time实际占用 CPU 的累计时间每次 tick 累加时间片

导出指标时,平均周转时间 = finish - arrival;平均等待时间 = 周转时间 - cpu_time;吞吐量 = 完成任务数 / 总运行时间。还要记录最大等待时间和最小等待时间的差,差值越大说明公平性越差,这在对比 FCFS 和 RR 时是很有说服力的论据。每组任务集至少跑五轮取平均值,防住单次运行的偶然波动。

5. 操作系统实验避坑手册:五个掉进去就爬不上来的坑

以下五个坑是这个系列实验里翻车率最高的,每个都按“现象 → 原因 → 解决”写清楚,帮你省掉大量排错时间。

5.1 坑一:共享变量没加 volatile,整个临界区被编译器“优化”没了

现象是加了锁,两个线程还是同时进入临界区,最终计数值永远不对,而且概率随优化级别升高。原因是编译器把共享标志变量的读取优化到寄存器里,循环里一直在查一个旧值,根本没重新读内存。解决方法是给共享标志加 volatile,或者用实验包提供的原子访问接口:

volatile int flag = 0; // 防止编译器缓存读取 while (!flag) cpu_relax(); // 自旋等待时让出 CPU 流水线

volatile 不是万能锁,它只保证读写不被优化掉,不保证原子性。但在这个场景里,它对治的恰恰是“编译器吞掉读操作”的问题。血泪经验是:调试同步实验时,先关优化跑一遍,再开优化跑一遍,两个结果不一致,第一嫌疑就是缺 volatile。

5.2 坑二:信号量初始值给错,一启动就全员睡死

现象是程序启动后所有线程都阻塞在 sem_wait 上,串口没有任何进展。原因多半是互斥信号量初始值给了 0,或者资源型信号量初值比实际资源数少。解决方法是把所有信号量的初始化集中到一眼能看完的地方,并用注释标出每个量代表什么资源:

sem_init(&mutex, 1); // mutex 是互斥信号量,初始为 1 表示无人持有 sem_init(&empty, 8); // 缓冲空闲槽位 sem_init(&full, 0); // 缓冲已用槽位

我一般会在实验开始前写一个“信号量清单”表格,把变量名、初值、含义列出来,逐行核对。这个动作看起来笨,但能一次性排除整个死锁类别。

5.3 坑三:多核时钟中断打死自旋锁,死锁概率随运行时长上升

现象是 4 核跑小压力测试没问题,跑长时压力测试偶发死锁,gdb 挂上去看所有 CPU 都在 spin_lock 上转圈。原因是持锁 CPU 在临界区里被时钟中断打断,中断处理需要调度或访问同一把锁,而另一个 CPU 正在自旋等待这把锁,形成循环等待。解决方法是持锁期间屏蔽本地中断:

local_irq_disable(); // 持锁期间,不让时钟中断插进来 spin_lock(&lock); /* 临界区 */ spin_unlock(&lock); local_irq_enable();

注意关中断的粒度越小越好,只在真正需要保护共享变量的几行代码里做,不要整个函数关中断。实验包里一般提供了 local_irq_disable 和 local_irq_enable 接口,找不到就查 arch 目录下的 CPU 控制代码。

5.4 坑四:地址映射实验拿物理地址当虚拟地址解引用,立刻 panic

现象是遍历页表拿到物理地址后,想看看那块内存里有什么,一用指针访问整个内核就崩。原因是教学内核通常只建立了部分地址映射,物理地址空间没有完整对应到内核虚拟地址空间,裸解引用必翻车。解决方法是先打印地址,不要动手访问:

printk("paddr=%lx\n", paddr);

如果实验手册明确给了“物理内存映射区”的范围,再在那个范围内做读写。页表遍历的目的是验证映射关系,不是让你绕过 MMU 访问内存。想在 gdb 里查那块物理内存内容,用 monitor 命令查看物理内存是更安全的方式。

5.5 坑五:用 printf 调试调度器,输出本身放大了时序偏差

现象是调度日志总是时对时不对,某次实验加了几个 printk 之后调度顺序彻底变了。原因是 printf 走串口是慢速外设,一次输出耗时可能是内存操作的几十倍,它本身就改写了调度时序。解决方法是把日志先写进内存环形缓冲,带单调递增序号,实验结束再统一输出:

struct log_entry { uint32_t seq; // 单调递增序号,事后按 seq 排序 uint32_t evt; // 事件类型 uint64_t tick; // 事件发生时的系统时钟 int pid; }; static struct log_entry ring[1024];

调度器里所有关键事件只往 ring 里写一个结构体,实验退出前沿 ring 顺序刷到串口。这个方案的额外好处是:日志与运行逻辑解耦后,调度器本身多了一个可以稳定对比的“事件流”,比盯着乱序的 printf 输出可靠得多。

6. 自己加一个系统调用,把内核到你写的用户态程序整条串起来

系统调用实验是整套实验的收口环节,它把用户态、陷入机制、内核处理逻辑串成一条完整链路。常见做法是选一个实验包预留的空系统调用号,实现一个自己的功能,再在用户态触发它。内核侧核心代码大概长这样:

// 在 syscall 表里注册你的编号,编号以实验包为准 long sys_hello(const char __user *name) { char buf[64]; // 从用户态拷字符串必须用专用接口,防止恶意指针越界 long n = strncpy_from_user(buf, name, sizeof(buf) - 1); if (n < 0) return n; buf[n] = '\0'; printk("sys_hello: %s\n", buf); return n; }

用户态侧用陷入指令发起调用:

// 用户态测试程序里触发系统调用 long ret; asm volatile("int $0x80" // x86 教学内核常用陷入方式 : "=a"(ret) : "a"(SYS_HELLO), "b"(name) : "memory"); printf("ret=%ld\n", ret);

整条链路验证分三层。第一层看内核侧 printk 日志有没有打出来;第二层看返回值是否正确;第三层故意传一个非法指针,确认 strncpy_from_user 返回错误而不是让内核崩溃。最后一个测试最有价值,它验证了用户态和内核态边界是真隔离的。

做这个实验时我养成了一个习惯:每次改内核前把当前二进制、源码 diff、串口日志按构建时间归档成一个目录。曾有某次我改了调度器代码,验证程序一直说没生效,查了半天发现是构建脚本没重新链接,QEMU 加载的还是旧镜像。从那之后我每次 make 完都顺手记下文件的 md5,跑完实验把日志和 md5 一起留档,这种“留证据”的习惯让后面几个实验的排错速度快了一倍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询