ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

南开操作系统实验:从状态机调度到xv6内核实战要点解析

南开操作系统实验:从状态机调度到xv6内核实战要点解析 简介南开大学软件学院操作系统课程课件以PDF形式整体打包适合软件工程、计算机及相关专业学生用于课堂同步、期末复习或考研基础梳理。课件围绕操作系统知识主线展开从定义、组成、类型、服务、结构、特征与设计目标入手说明批处理、分时、实时、嵌入式等系统形态并重点讲解进程创建与终止、临界区互斥访问、中断与陷入、系统调用、处理机/存储器/设备/文件管理以及操作系统从手工操作到多道程序设计、分时系统、个人电脑与移动系统的演变历史末尾附期末知识点整理便于快速回顾核心概念。资源共1个PDF文件压缩包约1.3MB体量精简便于离线阅读与打印。已有739人下载学习适合需要系统理解操作系统工作原理、复习重点章节或准备考试的学生使用。1. 南开软件学院“操作系统”在训练什么一门课顶三门课的系统能力第一次在头歌平台上交操作系统实验我把读者-写者问题写成各自持锁等待对方释放界面卡到只能强制关机。后来才知道南开软件学院这门操作系统课理论和实验是分两路并行推进的理论从进程讲到底层调度实验却直接把你丢进一个真实内核——要么给 xv6 加系统调用要么改写调度器。它解决的问题是“原理听懂了但一动手就翻车”的断层。这门课适合三类人正在修这门课的学生准备操作系统期末复习和考研复试的选手以及想通过做一遍教学内核来补齐系统能力的工程岗。别指望两周突击它更像一个把数据结构、体系结构和 Linux 内核串起来的综合训练场。2. 课程理论四块硬骨头从状态机调度到页表与 inode2.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_slice2): 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(fpid{cpu.pid} exit, stateEXIT) cpu.state BLOCKED if False else READY # 占位示意 cpu None else: print(fpid{cpu.pid} preempt, stateREADY) 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把串口输出重定向到当前终端一个CtrlA 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.cuint64 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 KERNELkernel-riscvMakefile 内部用的是$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 21 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 scale2; $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 打开看一次。调度器这一类实验最容易出问题的不是“跑不起来”而是“看起来跑起来但数值比例悄悄漂移”。一个明确的验收标准能帮你省掉大量反复翻源码的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表