南开大学软件学院操作系统实验:从WSL2环境搭建到内核调试的完整路径 简介这份资料是南开大学软件学院操作系统课程的配套课件面向计算机相关专业学生及备考操作系统的学习者帮助梳理课程重点、建立知识框架。压缩包内共1个PDF文件约1.3MB内容以讲义形式呈现便于打印或平板阅读。课件系统覆盖操作系统概述、进程管理、内存管理、文件系统与设备管理等核心章节具体包括操作系统的定义、组成、类型与服务单体、层次式、微内核、C/S模型、虚拟机等结构以及并发性、虚拟性、异步性、共享性等特征同时整理了进程创建与终止、临界区互斥访问的四个条件、中断与陷入的区别、系统调用类型、假脱机技术、地址空间等高频考点并附有操作系统期末知识点整理如实时系统与分时系统的异同、进程与管程的区别等。目前已有739人学习适合用于期末复习、考研初试梳理与课堂同步巩固。1. 南开大学软件学院操作系统课从课堂实验到工程落地的完整路径如果你正在搜“南开大学软件学院操作系统”大概率不是想听一遍进程调度和虚拟内存的定义而是想知道这门课到底练什么、用什么环境、实验怎么跑通、学完能不能直接用在 Linux 内核调优或者嵌入式开发上。我当年第一次接触操作系统实验时最大的困惑不是概念而是“明明课本看懂了一到写代码就不知道从哪下手”。南开大学软件学院的操作系统课程核心价值在于把抽象机制拆成可运行的模块——从系统调用封装、进程创建与通信到内存分配模拟、文件系统实现再到并发控制与死锁避免。它适合两类人一是软件工程、计算机相关专业的学生需要把理论课变成能写进简历的项目二是已经工作的后端或嵌入式工程师想补上“为什么我的程序会卡死、内存为什么会泄漏”这类底层直觉。这门课不是教你背概念而是逼你亲手实现一个迷你内核或系统工具从而真正理解 Linux 操作系统、Windows 操作系统在调度和资源管理上的差异。接下来我会按“环境怎么搭、实验怎么做、参数怎么调、坑在哪”的顺序把这条路径完整拆开。2. 实验环境搭建用 WSL2 和 QEMU 跑通第一个系统调用2.1 为什么选 WSL2 QEMU 而不是纯虚拟机很多同学一上来就装 VMware 或 VirtualBox结果遇到“客户机操作系统已禁用 CPU”或者“VMware 操作系统未找到”这类报错折腾半天进不去系统。南开软件学院的操作系统实验通常要求 Linux 环境但没必要直接双系统。我一般会推荐 WSL2 加 QEMU 的组合WSL2 提供完整的 Linux 内核接口QEMU 用来模拟裸机或运行自制内核镜像。这样既能在 Windows 下用 VS Code 写代码又能避免虚拟机嵌套带来的性能损耗。如果你用的是二手联想笔记本先确认 BIOS 里 Intel VT-x 或 AMD-V 是开启状态否则 WSL2 装不上。至于“刚买的二手联想笔记本需要跟卖家要 Windows 操作系统的密钥吗”这个和实验无关但提醒一句WSL2 不依赖 Windows 激活状态放心用。安装命令如下注意以管理员身份打开 PowerShell# 启用 WSL 和虚拟机平台功能 dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart # 重启后设置 WSL2 为默认版本 wsl --set-default-version 2 # 安装 Ubuntu 22.04南开实验常用版本 wsl --install -d Ubuntu-22.04逻辑说明第一条命令开启 WSL 子系统支持第二条开启虚拟机平台这是 WSL2 的底层依赖。设置默认版本为 2 后安装的发行版会自动使用轻量级虚拟机架构内核版本更接近真实 Linux。参数上-d指定发行版名称如果你需要麒麟操作系统或统信操作系统做兼容性测试也可以后续通过wsl --import导入镜像但课程实验用 Ubuntu 足够。2.2 安装编译工具链和 QEMU 模拟器进入 WSL2 的 Ubuntu 终端后先换国内源再装包否则下载速度会让你怀疑人生。这里以清华源为例# 备份原源列表 sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak # 替换为清华源Ubuntu 22.04 sudo sed -i s|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g /etc/apt/sources.list # 更新并安装必要工具 sudo apt update sudo apt install -y build-essential gcc-multilib nasm qemu-system-x86 gdb make git逻辑说明build-essential提供 gcc、make 等基础编译工具gcc-multilib支持 32 位编译因为很多操作系统实验从 x86 实模式开始nasm是汇编器用于写引导扇区qemu-system-x86是模拟器用来加载你的内核镜像gdb用于调试。参数上-y表示自动确认安装。装完后用qemu-system-i386 --version验证如果提示找不到命令检查 PATH 或重新登录终端。2.3 验证环境跑一个最小的引导程序不要急着写完整内核先确认工具链能产出可启动的镜像。新建boot.asm; boot.asm - 最简引导扇区在屏幕上打印字符 org 0x7c00 ; BIOS 将引导扇区加载到 0x7c00 mov ah, 0x0e ; BIOS teletype 输出模式 mov al, O ; 要打印的字符 int 0x10 ; 调用 BIOS 视频中断 jmp $ ; 死循环防止执行到未知区域 times 510-($-$$) db 0 ; 填充到 510 字节 dw 0xaa55 ; 引导扇区魔数编译并运行nasm -f bin boot.asm -o boot.bin qemu-system-i386 -fda boot.bin逻辑说明org 0x7c00告诉汇编器代码会被加载到内存地址 0x7c00这是 BIOS 规定的引导扇区加载地址。int 0x10是 BIOS 提供的视频服务ah0x0e表示以电传模式输出字符。最后两行是引导扇区的固定格式填充到 510 字节最后两字节必须是0xaa55否则 BIOS 不认。参数上-fda表示把 boot.bin 当作软盘镜像启动。如果 QEMU 窗口弹出并显示一个字母 O说明环境通了。这一步是后面所有实验的地基别跳过。3. 进程与线程实验从 fork 到调度器模拟3.1 理解 fork、exec 和 wait 的真实行为操作系统课最经典的实验就是进程创建与通信。很多同学背了“fork 一次调用两次返回”但真写代码时搞不清父子进程谁先跑、文件描述符怎么继承。南开软件学院的实验通常会要求你写一个多进程程序用 pipe 或共享内存完成数据交换再模拟一个简单的轮转调度。先看下面这段代码#include stdio.h #include unistd.h #include sys/wait.h #include string.h int main() { int pipefd[2]; pid_t pid; char buf[64]; if (pipe(pipefd) -1) { // 创建管道pipefd[0] 读端pipefd[1] 写端 perror(pipe); return 1; } pid fork(); // 创建子进程 if (pid 0) { perror(fork); return 1; } if (pid 0) { // 子进程关闭读端向写端写入数据 close(pipefd[0]); const char *msg hello from child; write(pipefd[1], msg, strlen(msg)); close(pipefd[1]); _exit(0); // 子进程用 _exit 避免刷新父进程缓冲区 } else { // 父进程关闭写端从读端读取 close(pipefd[1]); ssize_t n read(pipefd[0], buf, sizeof(buf) - 1); if (n 0) { buf[n] \0; printf(parent received: %s\n, buf); } close(pipefd[0]); wait(NULL); // 等待子进程结束避免僵尸 } return 0; }逻辑说明pipe创建匿名管道返回两个文件描述符。fork后子进程继承这两个描述符但必须关闭不用的那一端否则读端永远等不到 EOF。子进程用_exit而不是exit因为exit会刷新 stdio 缓冲区可能导致输出重复。父进程用wait回收子进程资源。参数上read的第三个参数留一个字节给字符串结束符。这个实验的常见变体是让多个子进程通过管道向父进程发送消息父进程按到达顺序打印模拟进程调度中的就绪队列。3.2 用时间片轮转模拟调度器参数怎么设调度器实验的核心是维护一个进程控制块PCB队列每个 PCB 记录进程 ID、剩余执行时间、状态。时间片大小直接影响吞吐量和响应时间。我一般会让学生先实现固定时间片轮转再改成动态优先级。下面是一个简化版调度器框架#include stdio.h #include stdlib.h #define TIME_SLICE 4 // 时间片大小单位时钟 tick #define MAX_PROC 10 typedef struct { int pid; int burst; // 总需执行时间 int remaining; // 剩余执行时间 int state; // 0就绪1运行2完成 } PCB; void schedule(PCB procs[], int n) { int time 0, completed 0; while (completed n) { for (int i 0; i n; i) { if (procs[i].state 2) continue; // 已完成跳过 procs[i].state 1; // 置为运行态 int run (procs[i].remaining TIME_SLICE) ? procs[i].remaining : TIME_SLICE; time run; procs[i].remaining - run; printf(time %d: pid %d runs %d, remaining %d\n, time, procs[i].pid, run, procs[i].remaining); if (procs[i].remaining 0) { procs[i].state 2; completed; } else { procs[i].state 0; // 回到就绪态 } } } } int main() { PCB procs[] { {1, 10, 10, 0}, {2, 6, 6, 0}, {3, 8, 8, 0} }; schedule(procs, 3); return 0; }逻辑说明每个进程按顺序获得一个时间片如果剩余时间小于时间片则直接跑完。TIME_SLICE设为 4 意味着每个进程一次最多跑 4 个 tick。参数调整建议时间片太小会导致上下文切换开销占比升高太大则退化成先来先服务响应时间变差。一般取平均 burst 的 1/5 到 1/10。你可以把TIME_SLICE改成 2 或 8观察总完成时间和平均等待时间的变化。这个实验的进阶要求是加入优先级队列高优先级进程可以抢占当前进程这时需要额外维护一个就绪队列数组并在每次时间片中断时检查是否有更高优先级进程到达。3.3 线程同步用信号量解决生产者-消费者问题进程实验之后通常接线程同步。南开软件学院的实验会要求用 POSIX 信号量或互斥锁实现有界缓冲区的生产者-消费者模型。下面用信号量写一个最小版本#include stdio.h #include pthread.h #include semaphore.h #include unistd.h #define BUFFER_SIZE 5 #define PRODUCE_COUNT 10 int buffer[BUFFER_SIZE]; int in 0, out 0; sem_t empty; // 空槽位数量 sem_t full; // 已占槽位数量 pthread_mutex_t mutex; void* producer(void* arg) { for (int i 0; i PRODUCE_COUNT; i) { sem_wait(empty); // 等待空槽位 pthread_mutex_lock(mutex); // 互斥访问缓冲区 buffer[in] i; printf(producer put %d at %d\n, i, in); in (in 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(full); // 增加已占槽位 usleep(100000); } return NULL; } void* consumer(void* arg) { for (int i 0; i PRODUCE_COUNT; i) { sem_wait(full); // 等待有数据 pthread_mutex_lock(mutex); int item buffer[out]; printf(consumer got %d from %d\n, item, out); out (out 1) % BUFFER_SIZE; pthread_mutex_unlock(mutex); sem_post(empty); // 释放空槽位 usleep(150000); } return NULL; } int main() { pthread_t prod, cons; sem_init(empty, 0, BUFFER_SIZE); // 初始有 BUFFER_SIZE 个空槽 sem_init(full, 0, 0); // 初始没有数据 pthread_mutex_init(mutex, NULL); pthread_create(prod, NULL, producer, NULL); pthread_create(cons, NULL, consumer, NULL); pthread_join(prod, NULL); pthread_join(cons, NULL); sem_destroy(empty); sem_destroy(full); pthread_mutex_destroy(mutex); return 0; }逻辑说明empty信号量表示空槽位数初始为缓冲区大小full表示已占槽位数初始为 0。生产者先等empty再拿互斥锁操作缓冲区最后释放full。消费者顺序相反。互斥锁保证同一时刻只有一个线程修改in或out。参数上sem_init的第二个参数为 0 表示线程间共享非零表示进程间共享。编译时需要加-pthread链接线程库。这个实验最容易翻车的地方是死锁如果生产者先等full再等empty或者忘记释放互斥锁程序会卡死。调试时用gdb附加到进程查看线程栈。4. 内存管理与文件系统实验从分页到简易 FAT4.1 分页地址转换手算一遍再写代码内存管理实验通常要求实现一个分页系统把逻辑地址转成物理地址。很多同学直接抄公式但遇到多级页表就晕。我建议先手算一个例子假设页大小 4KB逻辑地址 32 位一级页表 10 位二级页表 10 位页内偏移 12 位。给定逻辑地址 0x00403ABC拆成二进制后一级索引是 0x00403ABC 22 0x1二级索引是 (0x00403ABC 12) 0x3FF 0x3偏移是 0xABC。然后查一级页表找到二级页表基址再查二级页表找到物理页框号最后拼上偏移。写代码时用位运算#define PAGE_SHIFT 12 #define PAGE_SIZE (1 PAGE_SHIFT) #define PTE_INDEX(addr) (((addr) PAGE_SHIFT) 0x3FF) #define PDE_INDEX(addr) ((addr) 22) #define OFFSET(addr) ((addr) (PAGE_SIZE - 1)) // 假设 page_dir 是一级页表数组page_table 是二级页表数组 unsigned int translate(unsigned int logical, unsigned int *page_dir, unsigned int *page_table) { unsigned int pde PDE_INDEX(logical); if (!(page_dir[pde] 0x1)) return 0xFFFFFFFF; // 页表不存在 unsigned int pte PTE_INDEX(logical); if (!(page_table[pte] 0x1)) return 0xFFFFFFFF; // 页不存在 unsigned int frame page_table[pte] 0xFFFFF000; return frame | OFFSET(logical); }逻辑说明PDE_INDEX取逻辑地址高 10 位作为一级页表索引PTE_INDEX取中间 10 位作为二级页表索引OFFSET取低 12 位。页表项最低位是存在位用来判断页是否在内存中。参数上0xFFFFF000掩码取出物理页框号的高 20 位。这个实验的常见扩展是加入缺页中断处理当存在位为 0 时从磁盘交换区调入页面更新页表然后重新执行指令。调试时打印每一步的索引和偏移和手算结果对比。4.2 简易文件系统实现 create、write、read、delete文件系统实验一般要求在一个模拟磁盘上实现超级块、inode 位图、数据块位图、inode 表和根目录。下面是一个极简的 inode 结构定义#define BLOCK_SIZE 512 #define MAX_INODES 64 #define MAX_BLOCKS 1024 #define MAX_FILENAME 28 typedef struct { int inode_bitmap[MAX_INODES / 32]; // inode 位图 int block_bitmap[MAX_BLOCKS / 32]; // 数据块位图 int inode_count; int block_count; } SuperBlock; typedef struct { int mode; // 文件类型和权限 int size; // 文件大小 int blocks[12]; // 直接块指针 int indirect; // 一级间接块 } Inode; typedef struct { int inode_num; char name[MAX_FILENAME]; } DirEntry;逻辑说明超级块记录位图和计数inode 记录文件元数据和数据块指针目录项把文件名映射到 inode 号。创建文件时先扫描 inode 位图找空闲 inode再扫描数据块位图找空闲块写入目录项。写入时根据偏移计算块索引如果超过 12 个直接块则使用间接块。删除时清空位图对应位和 inode。参数上MAX_FILENAME取 28 是为了让目录项对齐到 32 字节方便计算。这个实验的坑在于位图操作置位和清位要用|和~不要直接赋值否则会覆盖其他文件的状态。测试时先创建几个小文件再创建一个大文件触发间接块最后删除中间文件检查位图是否正确回收。4.3 页面置换算法对比LRU 和 Clock 的实测差异内存管理还有一个必做实验是页面置换。给定一个页面访问序列分别用 FIFO、LRU、Clock 算法模拟统计缺页次数。下面用数组实现 LRUdef lru(pages, frame_count): frames [] page_faults 0 for page in pages: if page in frames: frames.remove(page) # 命中则移到最近使用位置 frames.append(page) else: page_faults 1 if len(frames) frame_count: frames.pop(0) # 淘汰最久未使用 frames.append(page) return page_faults # 测试序列 pages [7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1] print(LRU faults:, lru(pages, 3))逻辑说明frames列表按最近使用顺序排列命中时把页面移到末尾缺页时如果满了就弹出第一个元素。参数上frame_count是物理页框数一般取 3 到 5 做对比。你可以把 LRU 换成 FIFO用队列命中不移动和 Clock用环形指针和引用位跑同一组序列观察缺页次数差异。通常 LRU 缺页最少但开销大Clock 是折中方案。这个实验的进阶要求是统计不同序列下的表现并画出缺页率曲线。5. 避坑与排查操作系统实验里最容易翻车的 5 个点5.1 现象QEMU 启动后黑屏没有任何输出原因引导扇区最后两字节不是0xaa55或者镜像大小超过 512 字节被截断。解决用xxd boot.bin | tail -n 1检查最后两字节是否为55 aa如果不是检查汇编里的times 510-($-$$) db 0和dw 0xaa55是否写对。另外确认nasm -f bin生成的是纯二进制不是 ELF。5.2 现象fork 后子进程输出重复或者 printf 打印两次原因父进程的 stdio 缓冲区在 fork 时被复制子进程调用exit会刷新缓冲区导致重复输出。解决子进程用_exit而不是exit或者在 fork 前用fflush(stdout)清空缓冲区。这个坑在重定向到文件时尤其明显。5.3 现象信号量程序卡死gdb 显示线程都在等待原因sem_wait和sem_post配对错误或者互斥锁在异常路径上没有释放。解决用strace -f跟踪系统调用看哪个线程阻塞在sem_wait或futex上。检查代码中每个sem_wait是否都有对应的sem_post每个pthread_mutex_lock是否都有unlock。建议用 RAII 风格封装锁或者用pthread_cleanup_push注册清理函数。5.4 现象分页地址转换结果和手算不一致原因位运算优先级搞错或者页表项掩码写错。解决把逻辑地址拆成二进制打印出来逐位对照。注意的优先级低于所以addr 12 0x3FF实际是addr (12 0x3FF)必须加括号写成(addr 12) 0x3FF。另外确认页大小和索引位数匹配。5.5 现象文件系统写入后读取乱码或者删除后空间没回收原因inode 和数据块位图没有同步更新或者目录项没有正确写入磁盘。解决每次修改位图后立即写回模拟磁盘不要只改内存。读取时先根据目录项找到 inode 号再根据 inode 的块指针读数据。删除时先清空目录项再释放 inode 和数据块顺序不能反否则可能留下悬空指针。测试时用hexdump查看磁盘镜像的超级块和位图区域确认状态一致。6. 进阶技巧用 GDB 和 QEMU 调试自制内核当你已经能跑通引导扇区和简单内核后下一步是学会用 GDB 远程调试 QEMU。这个技能在排查三重故障、页表切换、中断处理时非常关键。启动 QEMU 时加-s -S参数qemu-system-i386 -fda kernel.bin -s -S-s表示在 1234 端口开启 GDB 服务器-S表示启动时暂停 CPU等待 GDB 连接。然后另开一个终端gdb (gdb) target remote localhost:1234 (gdb) set architecture i8086 (gdb) break *0x7c00 (gdb) continue (gdb) info registers (gdb) x/10i $pc逻辑说明target remote连接 QEMU 的调试端口set architecture i8086告诉 GDB 当前是 16 位实模式。break *0x7c00在引导扇区入口下断点continue让 CPU 运行到断点。info registers查看寄存器状态x/10i $pc反汇编当前指令开始的 10 条指令。参数上如果内核已经进入 32 位保护模式需要改成set architecture i386。这个技巧能让你在死机时看到最后执行的指令和栈内容比盲目加打印高效得多。另一个实用技巧是给 QEMU 加-d int,cpu_reset参数把中断和 CPU 复位信息输出到日志qemu-system-i386 -fda kernel.bin -d int,cpu_reset -D qemu.log然后查看qemu.log里面会记录每次中断的向量号和 CPU 状态。如果发生三重故障日志会显示Triple fault这时检查 IDT 是否加载正确、中断处理程序是否越界。我一般会先用-d int确认时钟中断和键盘中断能正常触发再逐步加入系统调用。最后说一个习惯每次修改内核代码后先用make clean make全量编译避免增量编译残留旧目标文件导致行为诡异。调试时把关键变量和页表基址打印到串口或屏幕不要依赖单步跟踪一切。操作系统实验的很多问题不是逻辑错而是环境配置和工具链版本不一致。希望帮到你。本文还有配套的精品资源点击获取