☰
南开操作系统实验:从状态机调度到xv6内核实战要点解析
2026/10/5 2:43:32 网站建设 项目流程

简介:南开大学软件学院操作系统课程课件以PDF形式整体打包,适合软件工程、计算机及相关专业学生用于课堂同步、期末复习或考研基础梳理。课件围绕操作系统知识主线展开:从定义、组成、类型、服务、结构、特征与设计目标入手,说明批处理、分时、实时、嵌入式等系统形态,并重点讲解进程创建与终止、临界区互斥访问、中断与陷入、系统调用、处理机/存储器/设备/文件管理,以及操作系统从手工操作到多道程序设计、分时系统、个人电脑与移动系统的演变历史,末尾附期末知识点整理,便于快速回顾核心概念。资源共1个PDF文件,压缩包约1.3MB,体量精简,便于离线阅读与打印。已有739人下载学习,适合需要系统理解操作系统工作原理、复习重点章节或准备考试的学生使用。

1. 南开软件学院“操作系统”在训练什么:一门课顶三门课的系统能力

第一次在头歌平台上交操作系统实验,我把读者-写者问题写成各自持锁等待对方释放,界面卡到只能强制关机。后来才知道,南开软件学院这门操作系统课,理论和实验是分两路并行推进的:理论从进程讲到底层调度,实验却直接把你丢进一个真实内核——要么给 xv6 加系统调用,要么改写调度器。它解决的问题,是“原理听懂了但一动手就翻车”的断层。这门课适合三类人:正在修这门课的学生,准备操作系统期末复习和考研复试的选手,以及想通过做一遍教学内核来补齐系统能力的工程岗。别指望两周突击,它更像一个把数据结构、体系结构和 Linux 内核串起来的综合训练场。

2. 课程理论四块硬骨头:从状态机调度到页表与 inode

2.1 用状态机模型复习“什么是进程/线程”

南开软院的操作系统课一般会先用《计算机操作系统》或汤小丹教材打底,第一讲就是进程与线程。很多同学背得出“进程是资源分配的最小单位”,但问“进程在就绪、运行、阻塞之间到底怎么迁移”就答不利索。我的习惯是:把进程当成一台状态机,你只需要维护一个转移表——就绪态的进程被调度器选中进入运行,运行态遇到 I/O 或等锁进入阻塞,时间片用完回到就绪。拿 Python 写个极小的就绪队列模拟,比抄十遍状态图管用。

import queue READY, RUNNING, BLOCKED = 0, 1, 2 class PCB: def __init__(self, pid, need): self.pid = pid self.need = need # 剩余时间片 self.state = READY def fcfs_schedule(jobs, time_slice=2): ready = queue.Queue() cpu = None for j in jobs: ready.put(j) while not ready.empty() or cpu is not None: if cpu is None: cpu = ready.get() cpu.state = RUNNING cpu.need -= time_slice if cpu.need <= 0: print(f"pid={cpu.pid} exit, state=EXIT") cpu.state = BLOCKED if False else READY # 占位示意 cpu = None else: print(f"pid={cpu.pid} preempt, state=READY") ready.put(cpu) cpu = None

逻辑说明:这段代码把调度器抽象成“从队列取进程、扣时间片、决定继续运行还是重新入队”。time_slice是时间片,改成 1 就是完全轮转,改成很大数退化成 FCFS。学操作系统前先自己把状态迁移跑一遍,后续看 xv6 的scheduler()就不会发懵——内核调度器无非是把这段逻辑换成遍历进程表和上下文切换。留意我代码里那个占位写法,真实验证状态时建议打印state而不是用占位。

2.2 同步互斥一条线串起信号量、锁与条件变量

死锁、竞态、生产者消费者几乎是每份试卷的必考区。我见过最快翻车的写法:用if判断缓冲区满不满,结果多个生产者同时醒来,缓冲区溢出。正确写法是while条件循环,因为条件变量存在“虚假唤醒”,线程被唤醒后条件可能又变了。这个细节在 xv6 实验里同样要命,信号量的 P/V 操作一旦不对称,就等着死锁。

#include <pthread.h> pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; void producer(int *buf, int *cnt, int cap) { pthread_mutex_lock(&mtx); while (*cnt == cap) pthread_cond_wait(&not_full, &mtx); buf[*cnt] = 1; (*cnt)++; pthread_cond_signal(&not_empty); pthread_mutex_unlock(&mtx); } void consumer(int *buf, int *cnt) { pthread_mutex_lock(&mtx); while (*cnt == 0) pthread_cond_wait(&not_empty, &mtx); (*cnt)--; pthread_cond_signal(&not_full); pthread_mutex_unlock(&mtx); }

逻辑说明:pthread_cond_wait进入等待时会原子地释放互斥锁,被唤醒后重新拿锁。这里必须用while而不是if,否则两个消费者在空缓冲区上竞争,一个消费完,另一个醒来时缓冲区仍是空,直接读就越界。cap是缓冲区容量。这套“锁 + 条件变量”稍加映射就是内核里的睡眠锁:拿着锁的人可能在等待资源,所以必须能睡眠,不能自旋。与之对应的是自旋锁,适合临界区极短且不会睡眠的场景,比如 xv6 里踢出定时器中断后立刻更新ticks。

2.3 内存与文件系统的重难点对照表

内存这块的核心是虚拟地址到物理地址的翻译。多级页表要弄清VPN怎么拆成多段索引,缺页异常要分“合法但不在内存”和“非法访问”两种情况。文件系统的主线则是 inode、目录项和磁盘块的关系。下面这张表我一般考前自己默写一遍,用来对照考点和实验落点。

主题教材考点实验常见落点丢分/翻车点
多级页表页目录/页表项格式、地址拆分内核walkaddr()页表项标志位没按读写位区分
缺页异常缺页流程、交换区惰性内存分配实验进程地址空间越界当成合法缺页
调度时间片、优先级、多级队列stride / CFS 改写没保存pass增量导致饥饿
文件系统inode、软硬链接、目录结构文件描述符表、块缓存忘记释放 inode 引用计数
同步互斥信号量、管程、死锁条件信号量实现、并发 labP/V 不对称或锁释放顺序错乱

表格说明:右边那列是我每次踩坑之后补上去的“血泪经验”。复习时别只盯Belady异常这类名词,要多问一句“这个机制在代码里对应哪个函数”。比如walkaddr()就是 xv6 里把用户虚拟地址翻译成物理地址的入口,看懂它,页表那章至少拿下一半。

3. 搭出可复现的 OS 实验环境:QEMU、GCC 与一个最小内核

3.1 为什么实验一定优先选 QEMU 而不是真机

我第一反应是把课程实验放到本机 Linux 或虚拟机里跑,但很快发现两件事:一是反复编译内核镜像时,一个野指针就能让整机卡死;二是不同课程的实验对内核版本、工具链版本要求不一致,宿主机环境乱掉以后很难收回。所以操作系统实验几乎是默认用 QEMU,它把 CPU、内存、串口、磁盘都模拟成软件对象,崩了直接重置镜像,不需要重装系统。哪怕你宿主机是统信、麒麟这类国产 Linux 发行版,QEMU 一样能在里面再模拟一个实验环境,思路是“虚拟机套虚拟机”,只要内核头文件匹配,工具链就能跑。

如果你习惯 VirtualBox,也不是不行,但我不太推荐它来做内核实验:VirtualBox 偏向整机虚拟化,对串口和 gdb 调试支持不如 QEMU 顺滑。QEMU 里我常用-nographic把串口输出重定向到当前终端,一个Ctrl+A X就能退出,配合快照-snapshot做到“后悔药”效果——内核写崩了也不留残余。

3.2 环境搭建的最小命令集与 QEMU 参数含义

无论最终实验基于 xv6 还是 ucore,底层依赖都差不多:编译器、链接器、模拟器和调试器。我一般这样装:

sudo apt update sudo apt install -y build-essential gdb make qemu-system-x86 qemu-system-riscv64 # 如果做 x86 版本的最小内核,需要 32 位 libc 与链接器支持 sudo apt install -y gcc-multilib libc6-dev-i386 # 拉取课程仓库,地址以实验室给出的为准 git clone <lab-repository-url> cd <lab-repository> make qemu

参数说明:qemu-system-x86和qemu-system-riscv64分别对应 x86 与 RISC-V 两种教学内核架构。gcc-multilib是为了能在 64 位宿主机上编译出 32 位目标代码。make qemu这个目标在 xv6 里一般会调用类似qemu-system-riscv64 -machine virt -bios fw_jump.bin -kernel kernel -m 128M -smp 3 -nographic的命令,其中-smp 3是指让 QEMU 模拟 3 个 CPU 核,-m 128M是指客户机内存 128MB,-nographic把串口当作控制台。如果你要挂调试器,额外加-gdb tcp::12345 -S,表示等待 gdb 连接后再启动。

3.3 手写一个 8KB 栈的最小内核,跑赢工具链

光会make qemu还不够,我习惯让学生先手写一个最小内核,确认整条工具链真的通。这一步不会用太多代码,核心是一个启动汇编、一个 C 入口、一个链接脚本和一个 Makefile。先看汇编:

.set ALIGN, 1 << 0 .set MEMINFO, 1 << 1 .set FLAGS, ALIGN | MEMINFO .set MAGIC, 0x1BADB002 .set CHECKSUM, -(MAGIC + FLAGS) .section .multiboot .align 4 .long MAGIC .long FLAGS .long CHECKSUM .section .text .global start start: movl $stack_top, %esp call main hang: jmp hang .section .bss .align 16 stack_bottom: .skip 8192 stack_top:

这段代码开头的多引导头是给 QEMU 识别的。0x1BADB002是固定魔法数,FLAGS表示要求内存信息,-kernel kernel.bin启动时 QEMU 会检查这个头。start里做的第一件事是设置栈指针stack_top,不设栈就调 C 函数一定会崩。.bss段给内核留了 8KB 栈,实验时如果不够用会触发栈溢出,现象千奇百怪。

void main(void) { volatile char *vga = (volatile char *)0xb8000; vga[0] = 'O'; vga[1] = 0x07; vga[2] = 'K'; vga[3] = 0x07; for (;;) { __asm__ volatile("hlt"); } }

逻辑说明:这里直接往 x86 VGA 文本显存0xB8000写字符,第偶数个字节是字符、第奇数个字节是颜色。0x07是黑底白字。打印 OK 说明 C 代码能在上电后的保护模式里跑起来。hlt让 CPU 空闲,避免死循环空耗模拟器资源。链接脚本把内核加载到 1MB 处,这是多引导规范约定:

SECTIONS { . = 1M; .text : { *(.text) } .data : { *(.data) } .bss : { *(COMMON) *(.bss) } }

然后用qemu-system-i386 -kernel kernel.bin -nographic启动。如果看到终端里出现OK,说明汇编、C、链接和模拟器整条链路都是通的,再转头去改 xv6 就不会把工具链问题误判成代码问题。

4. 跑通 xv6 核心实验:新增系统调用与带权调度

4.1 新增系统调用:从用户态到内核态的完整接线

xv6 系列实验几乎是国内高校操作系统课设的通用骨架,南开软院这类课程常围绕它出题,让我先做一次最小切口的新增系统调用。假设我想加一个syscall_count(),用来统计当前进程被调度了多少次,接线一共四个文件。

先在syscall.h末尾加系统调用号:

#define SYS_count 22

在syscall.c里加跳转表项和外露函数声明:

extern uint64 sys_count(void); static uint64 (*syscalls[])(void) = { [SYS_fork] sys_fork, [SYS_exit] sys_exit, [SYS_count] sys_count, };

在内核侧写真正实现,放在sysproc.c:

uint64 sys_count(void) { return myproc()->sched_count; }

在用户侧入口usys.S加:

SYSCALL(count)

最后在user.h声明int count(void);,用户程序就能直接调用。这套接线的本质是:用户程序通过ecall或int指令陷入内核,syscall()函数根据syscall no查跳转表。头歌操作系统实验里那些“int 指令”“除零异常”的题,考察的正是同一个陷入机制。需要注意所有数组和枚举必须同步,漏改一个syscall.h就会出现“找不到已输入的环境选项”这类编译期迷惑报错。

4.2 把时间片轮转改成带权调度:一个 stride 调度器的改法

默认 xv6 是时间片轮转调度,所有就绪进程均分 CPU。课程设计里最常见的考题是“让高权重进程拿到更多 CPU”,这正是 MIT stride stepping 实验。核心思路是:每个进程有一个权重tickets和一个累计步长pass,每次调度选出pass最小的进程,选中后pass增加一个与权重成反比的量,权重高的进程pass增长慢,于是更容易再次被选中。

// proc.c 内新增字段 int tickets; int pass; // 每次调度时选出 pass 最小的进程 struct proc* stride_schedule(void) { struct proc *p, *minproc = 0; int minpass = 0x7fffffff; for (p = proc; p < &proc[NPROC]; p++) { if (p->state != RUNNABLE) continue; if (p->pass < minpass) { minpass = p->pass; minproc = p; } } if (minproc) { minproc->pass += STRIDE / minproc->tickets; } return minproc; } void scheduler(void) { for (;;) { intr_on(); struct proc *p = stride_schedule(); if (p == 0) continue; acquire(&p->lock); if (p->state == RUNNABLE) { p->state = RUNNING; swtch(&cpu->context, &p->context); } release(&p->lock); } }

逻辑说明:STRIDE是一个经验常数,我一般取20000,太小会导致pass增量过小、频繁比较,太大则切换间隔偏粗。tickets是权重,默认进程 fork 时都设tickets = 1,测试时再手动改成不同的值。minpass初始化为0x7fffffff是为了方便比较。这个改法没有处理pass溢出和部分睡眠进程的补偿问题,课设验收通常看两点:两个进程的 CPU 计数是否接近 1:3,以及低权重进程是否会饥饿——后者本身就可以作为答辩加分项来讨论。

4.3 三个实验必调的参数:时间片、进程表上限与内核栈

实验翻车往往不是逻辑错,而是参数设得离谱。我把它整理成一张参数表,改之前先对照一下:

参数默认值(常见)改在哪个文件调参后果
时间片QUANTA/定时器间隔1 tick / 1000000 cyclestimer.c、start.c太小导致切换开销大,太大让交互进程卡顿
最大进程数NPROC64param.h调大前要确认进程表数组占用是否突破内存布局
内核栈KSTACKSIZE4096 或 8192kernelvec.S/ 进程结构栈不够会静默破坏相邻页,出现诡异的 panic

时间片不是越小越好,xv6 一次上下文切换要保存 callee-saved 寄存器、切换页表、写sepc,这些开销对教学内核来说不能忽略。NPROC也不是越大越好,因为每个进程表项里都嵌了内核栈,进程表容量乘上栈大小是实打实的内存消耗。如果你发现进程一多就panic: free,先把NPROC调回 64 再看是不是kalloc内存碎片问题。

5. 操作系统实验避坑指南:五个高发翻车点与排查路径

5.1 “Exec format error”在 QEMU 里复现出“系统平台无效”报错

现象:编译好的用户程序放上文件系统后,运行时 shell 直接报Exec format error。如果你在 Windows 下看到“指定的可执行文件不是此操作系统平台的有效应用程序”,其实是同一类问题:目标平台和宿主平台不匹配。

原因:你很可能在用宿主机 gcc 直接编译,生成的是 ELF x86-64,而 xv6-riscv 需要的是 RV64 的 ELF。另一个可能是指定了错误的内核架构,比如拿 x86 版内核启动 RISC-V 镜像。

解决:先跑file 程序名看输出。若是ELF 64-bit LSB, UCB RISC-V才是对的。在 Makefile 里确认TOOLPREFIX指向riscv64-unknown-elf-或riscv64-linux-gnu-,不要裸用gcc。这个检查放第一,因为很多后续 panic 都是因为用户态程序根本没正确加载。

5.2 make 提示“找不到已输入的环境选项”,先查 Makefile 变量

现象:make到一半报错,说某个变量或环境选项不存在,紧接着是一串undefined reference。

原因:这多半不是真的缺环境变量,而是你把自己的宿主机环境变量覆盖到了 Makefile 里。比如make KERNEL=kernel-riscv,Makefile 内部用的是$K,大小写不匹配时变量为空,链接脚本、编译参数全部丢失。

解决:别急着export,先make -n看实际执行的命令,通常能看到一个异常短的空参数集。再检查Makefile里关键路径是否用了?=而不是=,?=允许命令行覆盖,=会无条件覆盖命令行。顺带提醒,从课程仓库直接 clone 后,不要把本机的CFLAGS带到 make 命令行里,xv6 自己的 CFLAGS 是经过调试与编译参数验证的。

5.3 用 gdb 连不上 QEMU 的内核端口

现象:启动 QEMU 后执行gdb,输入target remote localhost:12345报Connection refused。

原因:最常见的是忘记在 QEMU 启动参数里加-gdb tcp::12345,或者是端口被占用。另一个隐蔽原因是-S和-gdb的顺序写反,或 QEMU 版本较新后-gdb和-s同时使用产生冲突。

解决:先ss -ltnp | grep 12345查端口。启动命令写qemu-system-riscv64 ... -gdb tcp::12345 -S,其中-S是让 CPU 复位后先不执行,等 gdb 连上再继续。然后用riscv64-unknown-elf-gdb(或gdb-multiarch)连接。如果连上了但断点打不上,多半是没加调试符号,编译时看 Makefile 是否带-g。

5.4 自旋锁与睡眠锁混用的死锁

现象:实验里加了锁之后,系统在启动阶段就卡死,或者某些进程永远处于SLEEPING。

原因:在持有自旋锁的临界区里调用了可能睡眠的函数,比如sleep()或acquire()其他睡眠锁。自旋锁在等锁时是关中断的,睡眠后没人能唤醒自己。

解决:先定位backtrace栈上最近一次acquire()的调用点。自旋锁只保护几十行以内的短临界区,凡是可能阻塞的地方全部换成sleep通道。xv6 里有个约定俗成的写法:先release旧锁再acquire新锁,除非你能确认锁顺序在所有路径上一致。检查两个锁的获取顺序是否在所有进程里保持同一方向,否则就是经典的 AB-BA 死锁。

5.5 掉进惰性分配的坑:地址空间越界当成合法缺页

现象:打开lazy allocation实验后,一个简单程序在访问栈变量时莫名panic: uvmunmap或内核直接死循环。

原因:惰性分配的实现只有“缺页时分配页”还不够。当你不小心访问了完全非法的地址,比如0xffffffff,内核也会走到uvmalloc把它当成缺页分配,结果物理内存被耗空,或者后续unmap时页表项超出分配范围。

解决:缺页处理函数里先判断va是否在进程合法地址空间内。xv6 的合理边界是va < MAXVA,并且最好用walkaddr()查一次,存在映射才继续分配。惰性分配只负责“推迟”分配,不负责把非法地址变成合法地址。这个问题最容易在期末复习和答辩时被追问,把边界校验写在trap.c里是你比其他同学多拿分的地方。

6. 最后 20% 的验证技巧:把黑匣子内核变成可观测的调试对象

6.1 用 printk 分级与调度计数代替盲猜

内核崩了以后刷屏的日志里有大量信息,但新手最常见的操作是看最后三行就下结论。我一般会把日志留下,dmesg | tail -50观察触发 panic 前的调用序列。自己的实验内核则多埋调度计数,比如在每个进程结构里加一个sched_count,在swtch前自增,运行固定时间后用系统调用读出来。这比肉眼判断“好像 A 跑得比 B 多”可靠得多。

6.2 自动化课设验收脚本:用退出码说话

改完调度器后,光看 QEMU 窗口不够有说服力。我通常写一个用户态程序,让它输出A_count B_count,然后用脚本跑完整个验收并对比比例。

#!/bin/bash # 编译内核与测试程序 make qemu > /tmp/oslab.log 2>&1 & QEMU_PID=$! sleep 6 pkill -f qemu-system # 从日志中提取两个进程的计数 a=$(grep -oP 'A count: \K[0-9]+' /tmp/oslab.log) b=$(grep -oP 'B count: \K[0-9]+' /tmp/oslab.log) if [[ -z "$a" || -z "$b" ]]; then echo "FAIL: 未捕获计数输出" exit 1 fi ratio=$(echo "scale=2; $a/$b" | bc) if (( $(echo "$ratio > 2.7 && $ratio < 3.3" | bc -l) )); then echo "PASS: A/B=$ratio 符合 1:3 权重预期" else echo "FAIL: A/B=$ratio 偏移过多" exit 1 fi

逻辑说明:脚本里的sleep 6是给内核跑任务的时间,太短测不出调度比例,太长拖慢回归。真正造成误判的往往是用户态程序没把计数 flush 出来,日志里空手而归。测试程序里记得用write或printf加换行,触发行缓冲刷新,才能被 grep 到。这里退出码1和0直接接进课设验收工具,每次改完代码跑一次,没回归就安心提交。

我自己收尾有个习惯:每次实验只改一个变量,跑一遍自动化验收脚本,再把 git diff 打开看一次。调度器这一类实验,最容易出问题的不是“跑不起来”,而是“看起来跑起来,但数值比例悄悄漂移”。一个明确的验收标准能帮你省掉大量反复翻源码的时间。希望帮到你。

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

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

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

立即咨询