ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

西南科技计算机操作系统实验:从fork到页面置换的避坑指南

2026/10/8 16:43:38 拓冰建站 浏览量
西南科技计算机操作系统实验:从fork到页面置换的避坑指南 简介这份资源是西南科技大学计算机操作系统课程的实验配套材料面向正在学习操作系统原理、需要完成课程实验的本科生及自学者。内容围绕进程管理、内存管理、文件管理三大核心模块展开覆盖进程调度、内存分配回收、文件系统实现等典型实验主题适合作为课程实验的参考实现与对照样例。压缩包共10个文件以cpp与c源码、exe可执行程序为主另含1个文件系统相关文件整体约481KB体积轻便便于本地编译运行与调试。目前已有89人学习下载。读者可从中获取各实验模块的完整源码与可运行程序对照理解进程管理、内存管理及文件管理的实现思路借助源码与可执行文件快速验证实验结果排查编译与运行中的常见问题为课程实验报告撰写与原理巩固提供直接参考。1. 西南科技计算机操作系统实验一个压缩包背后到底藏着什么如果你手上正好有一个叫「西南科技计算机操作系统实验.7z」的压缩包或者你正在西南科技大学修操作系统这门课那你大概率已经意识到一件事操作系统实验不是那种抄抄实验报告就能过的水课。它逼着你从用户态掉进内核态从printf掉进fork、wait、sem_wait这些系统调用里。这个压缩包通常包含课程要求的实验源码、Makefile、实验指导书和测试脚本覆盖进程管理、内存管理、文件系统、并发同步这几大块。它解决的核心问题是让你在真实的 Linux 环境下用 C 语言把课本上的 PV 操作、页面置换、磁盘调度真正跑起来。适合谁适合刚学完理论、一写代码就翻车、不知道fork之后到底发生了什么的大二大三学生也适合想重新捡起系统编程的从业者。接下来我不讲空话直接按这个压缩包里最常见的几个实验把环境、代码、参数和踩坑点拆开讲。2. 把压缩包跑起来环境、解压与第一个进程实验2.1 为什么操作系统实验必须用 Linux 而不是 Windows操作系统实验的底层依赖是 POSIX 系统调用fork、exec、pipe、mmap、semaphore这些接口在 Windows 上没有对等实现。你在 Windows 上装个 MinGW 写fork编译器直接告诉你找不到这个函数。所以第一件事就是准备一个真实的 Linux 环境。常见做法有三种装双系统、用虚拟机、或者用 WSL2。我一般推荐 WSL2因为文件互通方便code .直接能在 Windows 的 VS Code 里编辑 Linux 下的文件。但要注意WSL2 的内核是微软自己编译的某些涉及内核模块加载的实验会受限如果实验要求改内核源码或者写字符设备驱动老老实实开虚拟机装 Ubuntu 20.04 或 22.04。解压这个 7z 包在 Linux 下需要先装p7zipsudo apt update sudo apt install p7zip-full build-essential gcc make gdb 7z x 西南科技计算机操作系统实验.7z -o./oslab cd oslab ls -la7z x保留目录结构-o指定输出目录。解压后你通常会看到几个子目录比如process/、memory/、filesystem/、sync/每个目录下有一个Makefile和若干.c文件。先别急着改代码跑一遍make看能不能编译通过。如果报undefined reference to pthread_create说明链接时缺了-lpthread去 Makefile 里找LDFLAGS加上就行。2.2 进程创建实验fork 的返回值为什么让那么多人翻车进程实验一般要求你写一个程序用fork创建子进程观察父子进程的 PID 和返回值。代码大概长这样#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); // 关键调用一次调用两次返回 if (pid 0) { perror(fork failed); return 1; } else if (pid 0) { // 子进程分支pid 为 0 printf(child: my pid%d, parent pid%d\n, getpid(), getppid()); } else { // 父进程分支pid 为子进程的 PID printf(parent: my pid%d, child pid%d\n, getpid(), pid); wait(NULL); // 回收子进程避免僵尸 } return 0; }逻辑说明fork调用一次但在父进程和子进程中各返回一次。父进程中返回子进程的 PID大于 0子进程中返回 0出错返回 -1。参数说明fork没有参数但它的行为受系统负载和RLIMIT_NPROC限制。wait(NULL)的参数是子进程退出状态指针传NULL表示不关心退出码。编译命令gcc -o fork_demo fork_demo.c ./fork_demo你会看到两行输出顺序不确定因为父子进程谁先被调度是玄学。这里最常见的翻车是子进程打印的ppid不是父进程的 PID而是 1 或者某个奇怪的值。原因是你没加wait父进程先退出了子进程被 init 进程收养。解决办法就是父进程必须wait或waitpid。2.3 Makefile 里必须检查的三个编译参数这个压缩包里的 Makefile 通常已经写好了但你要知道关键参数在哪。-Wall打开所有警告-g生成调试信息方便 gdb-pthread链接线程库。如果实验涉及信号量还要确认-lrt有没有加因为sem_open在旧版 glibc 里属于实时扩展库。检查方法grep -E CFLAGS|LDFLAGS Makefile如果输出里没有-pthread而你的代码用了pthread_create编译能过但链接会报错。这时候别去改系统库直接在 Makefile 的LDFLAGS后面追加-pthread就行。3. 内存管理与页面置换从 FIFO 到 LRU 的代码落地3.1 页面置换实验到底在模拟什么内存管理实验通常要求你模拟页面置换算法给定一个页面引用串和物理块数计算缺页次数和缺页率。这个实验的价值在于让你直观看到为什么 LRU 比 FIFO 好为什么 Belady 异常只在 FIFO 出现。压缩包里一般会提供一个pagereplace.c的骨架你需要补全fifo、lru、opt三个函数。核心数据结构是一个物理块数组和一个访问时间戳数组。3.2 FIFO 与 LRU 的最小实现与参数含义先看 FIFO 的实现#define MAX_FRAMES 10 #define MAX_PAGES 100 int frames[MAX_FRAMES]; int front 0; // 指向最早进入的页 int fifo(int pages[], int n, int frame_count) { int faults 0; for (int i 0; i frame_count; i) frames[i] -1; // -1 表示空 for (int i 0; i n; i) { int found 0; for (int j 0; j frame_count; j) { if (frames[j] pages[i]) { found 1; break; } } if (!found) { frames[front] pages[i]; // 替换最早进入的页 front (front 1) % frame_count; // 环形队列 faults; } } return faults; }逻辑说明frames数组模拟物理块front指向下一个要被替换的位置。每次缺页就把新页写到front位置然后front后移一位。参数说明pages是页面引用串n是串长度frame_count是物理块数。注意frame_count不能超过MAX_FRAMES否则数组越界。LRU 的区别在于替换时不是看谁先进而是看谁最久没被访问。实现上可以加一个last_used数组记录每个块最后一次被访问的时间步int lru(int pages[], int n, int frame_count) { int frames[MAX_FRAMES], last_used[MAX_FRAMES]; int faults 0, time 0; for (int i 0; i frame_count; i) { frames[i] -1; last_used[i] -1; } for (int i 0; i n; i) { int found -1; for (int j 0; j frame_count; j) { if (frames[j] pages[i]) { found j; break; } } if (found ! -1) { last_used[found] time; // 命中更新时间戳 } else { int victim 0; for (int j 1; j frame_count; j) { if (last_used[j] last_used[victim]) victim j; // 找最久未用 } frames[victim] pages[i]; last_used[victim] time; faults; } } return faults; }参数说明time是一个单调递增的计数器每次访问加一。last_used越小表示越久没被访问。这里有个坑如果物理块初始为空last_used初始化为 -1那么第一次替换时所有块的last_used都是 -1victim会选到下标 0这没问题但如果你把last_used初始化成 0就会和第一次访问的时间戳混淆。血泪经验初始化成 -1 最安全。3.3 用同一组引用串对比三种算法的缺页率写一个main函数把同一个引用串分别喂给三个算法int main() { int pages[] {7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1}; int n sizeof(pages) / sizeof(pages[0]); int frames 3; printf(FIFO faults: %d\n, fifo(pages, n, frames)); printf(LRU faults: %d\n, lru(pages, n, frames)); return 0; }编译运行后FIFO 缺页 15 次LRU 缺页 12 次。这个差距就是 LRU 利用局部性原理带来的收益。你可以改frames为 4再跑一次观察 FIFO 是否出现 Belady 异常缺页次数反而增加。这个实验的验收点通常就是让你解释为什么 LRU 不会出现 Belady 异常答案在于 LRU 属于栈算法物理块增加时命中集合是嵌套的。4. 并发与同步信号量、互斥锁和经典 PV 操作4.1 生产者-消费者实验为什么总有人死锁并发同步实验一般要求用信号量实现生产者-消费者问题。压缩包里会给你一个pc.c骨架里面已经开了pthread线程但信号量初始化和 PV 操作要你自己填。经典写法#include pthread.h #include semaphore.h #include stdio.h #define BUFFER_SIZE 5 #define PRODUCE_COUNT 10 int buffer[BUFFER_SIZE]; int in 0, out 0; sem_t empty, full, mutex; void* producer(void* arg) { for (int i 0; i PRODUCE_COUNT; i) { sem_wait(empty); // P 操作等待空槽 sem_wait(mutex); // 互斥进入临界区 buffer[in] i; in (in 1) % BUFFER_SIZE; printf(produced: %d\n, i); sem_post(mutex); // V 操作退出临界区 sem_post(full); // V 操作增加满槽 } return NULL; } void* consumer(void* arg) { for (int i 0; i PRODUCE_COUNT; i) { sem_wait(full); // P 操作等待满槽 sem_wait(mutex); int item buffer[out]; out (out 1) % BUFFER_SIZE; printf(consumed: %d\n, item); sem_post(mutex); sem_post(empty); } return NULL; } int main() { sem_init(empty, 0, BUFFER_SIZE); // 初始空槽数 缓冲区大小 sem_init(full, 0, 0); // 初始满槽数 0 sem_init(mutex, 0, 1); // 互斥锁初始为 1 pthread_t p, c; pthread_create(p, NULL, producer, NULL); pthread_create(c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); sem_destroy(empty); sem_destroy(full); sem_destroy(mutex); return 0; }逻辑说明empty表示空槽数量full表示满槽数量mutex保证同一时刻只有一个线程操作缓冲区。参数说明sem_init第二个参数为 0 表示信号量在线程间共享第三个参数是初始值。编译要加-pthreadgcc -o pc pc.c -pthread ./pc死锁的典型原因把sem_wait(mutex)写在sem_wait(empty)前面。这样生产者先拿到互斥锁然后等空槽但空槽要等消费者释放消费者又拿不到互斥锁循环等待直接卡死。记住顺序资源信号量在前互斥信号量在后。4.2 用 strace 和 gdb 排查同步 bug 的实操如果程序卡住不输出别急着改代码先用strace看系统调用停在哪strace -f -e tracefutex ./pc-f跟踪所有线程-e tracefutex只看 futex 调用。如果看到某个线程一直停在futex(FUTEX_WAIT)说明它在等一个永远不会被 post 的信号量。再用gdbattach 上去gdb -p $(pgrep pc) (gdb) thread apply all bt看每个线程的调用栈哪个线程卡在sem_wait哪个线程已经退出。常见情况是生产者线程提前退出消费者还在等full。解决办法是确保生产者和消费者循环次数匹配或者用sem_post在生产者退出前多发一次。4.3 信号量初值设错会怎样一个真实翻车记录我见过最隐蔽的 bug 是把sem_init(empty, 0, BUFFER_SIZE)写成了sem_init(empty, 0, 0)。编译没问题运行直接死锁因为生产者一开始就等空槽但空槽数为 0永远等不到。这种错误用strace看就是所有线程都停在futex没有任何输出。排查方法在sem_init后面加一行int val; sem_getvalue(empty, val); printf(empty%d\n, val);确认初值是不是你想要的。这个习惯能省下大量调试时间。5. 文件系统实验从 open 到 mmap 的读写路径5.1 文件读写实验的三种实现方式对比文件系统实验通常要求你实现一个简单的文件复制程序或者模拟 inode 分配。压缩包里可能提供filecopy.c的框架让你用read/write、fread/fwrite、mmap三种方式各写一遍然后对比性能。常见做法是先用open打开源文件和目标文件然后用一个固定大小的缓冲区循环读写#include fcntl.h #include unistd.h #include stdio.h #define BUF_SIZE 4096 int main(int argc, char* argv[]) { if (argc ! 3) { fprintf(stderr, usage: %s src dst\n, argv[0]); return 1; } int src open(argv[1], O_RDONLY); int dst open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); if (src 0 || dst 0) { perror(open); return 1; } char buf[BUF_SIZE]; ssize_t n; while ((n read(src, buf, BUF_SIZE)) 0) { ssize_t written 0; while (written n) { ssize_t w write(dst, buf written, n - written); if (w 0) { perror(write); return 1; } written w; } } close(src); close(dst); return 0; }逻辑说明read返回实际读到的字节数可能小于BUF_SIZE所以write要循环写直到写完。参数说明open的第三个参数0644是文件权限表示所有者读写、组和其他只读。O_TRUNC表示如果目标文件存在就截断为 0。编译运行gcc -o filecopy filecopy.c ./filecopy source.txt dest.txt diff source.txt dest.txtdiff没有输出就说明复制正确。这里有个坑read返回 0 表示读到文件末尾返回 -1 表示出错但read可能被信号中断返回 -1 且errno为EINTR这时候应该重试而不是直接报错。健壮的写法要判断errno。5.2 mmap 方式为什么有时候反而更慢mmap把文件映射到内存省去了read/write的数据拷贝理论上更快。但实际测试中小文件用mmap可能比read/write还慢因为mmap有建立映射和缺页中断的开销。代码大概这样#include sys/mman.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main(int argc, char* argv[]) { int src open(argv[1], O_RDONLY); struct stat st; fstat(src, st); char* p mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, src, 0); int dst open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); write(dst, p, st.st_size); munmap(p, st.st_size); close(src); close(dst); return 0; }参数说明mmap的PROT_READ表示只读映射MAP_PRIVATE表示写时复制。st.st_size是文件大小。注意mmap对空文件会失败因为长度不能为 0。这个实验的验收点通常是让你用time命令对比三种方式在大文件比如 100MB上的耗时然后解释为什么mmap在顺序读场景下不一定占优。6. 避坑与排查操作系统实验里最容易翻车的五件事6.1 僵尸进程堆积导致 fork 失败现象程序循环fork几百次后fork返回 -1perror输出Resource temporarily unavailable。原因父进程没有wait子进程子进程退出后变成僵尸占用进程表项。解决在父进程里加waitpid(-1, NULL, WNOHANG)或者用signal(SIGCHLD, SIG_IGN)让内核自动回收。注意SIG_IGN在某些系统上会影响wait的返回值实验要求严格的话还是老老实实wait。6.2 信号量忘记 destroy 导致下次运行异常现象第一次运行正常第二次运行直接死锁或者输出错乱。原因sem_init的信号量没有sem_destroy如果信号量是命名信号量sem_open还会在/dev/shm留下文件。解决程序退出前确保每个sem_init都有对应的sem_destroy命名信号量要sem_unlink。用ls /dev/shm检查有没有残留的sem.*文件。6.3 页面置换实验数组越界不报错但结果错误现象缺页次数算出来是负数或者大得离谱。原因frame_count大于MAX_FRAMES或者页面引用串长度超过MAX_PAGES写越界破坏了其他变量。解决在函数入口加断言assert(frame_count MAX_FRAMES n MAX_PAGES)编译时加-fsanitizeaddress让越界直接报错。这个编译选项在调试内存问题时非常有用。6.4 gcc 版本差异导致的内核头文件找不到现象在 Ubuntu 22.04 上编译报fatal error: linux/sched.h: No such file or directory。原因新版内核把一些头文件移到了linux/sched/目录下或者需要安装linux-headers-$(uname -r)。解决sudo apt install linux-headers-$(uname -r)如果还找不到检查代码里是不是直接引用了内核内部头文件用户态程序不应该包含linux/sched.h应该用sched.h。6.5 用 root 跑实验导致文件权限混乱现象用sudo ./experiment跑完之后生成的文件属主是 root普通用户改不了下次make报权限拒绝。原因sudo让程序以 root 身份创建文件。解决不要用sudo跑实验程序除非实验明确要求加载内核模块。如果已经搞乱了用sudo chown -R $USER:$USER .把当前目录权限改回来。这个坑我踩过不止一次血泪经验就是能不用 root 就不用 root。7. 进阶技巧用 gdb 和 perf 把实验代码看透7.1 用 gdb 跟踪 fork 之后的执行流fork之后父子进程分叉普通gdb默认只跟父进程。要跟踪子进程先设置gdb ./fork_demo (gdb) set follow-fork-mode child (gdb) set detach-on-fork off (gdb) break main (gdb) runset follow-fork-mode child让 gdb 在fork后进入子进程set detach-on-fork off让父进程也保留在 gdb 控制下。然后可以用info inferiors查看所有进程inferior 2切换过去。这个技巧在调试进程间通信时特别有用你能同时看到父子进程的变量状态。7.2 用 perf 看页面置换实验的缺页开销perf stat可以统计程序运行时的硬件事件perf stat -e page-faults,cache-misses ./pagereplace输出会告诉你缺页次数和缓存未命中数。如果你把frame_count从 3 改成 4缺页次数下降但cache-misses可能上升因为更大的工作集超出了 L1 缓存。这个观察能帮你理解为什么操作系统要在缺页率和缓存命中率之间做权衡。参数说明-e指定事件page-faults是缺页事件cache-misses是缓存未命中。如果perf报权限错误用sudo sysctl kernel.perf_event_paranoid1降低限制。7.3 一个我常用的验证习惯每次改完代码我不会直接跑完整测试而是先写一个最小输入验证核心逻辑。比如页面置换实验我会先用{1,2,3,1,2,3}这种简单序列跑一遍手工算一遍缺页次数对上了再跑随机长序列。这个习惯帮我省下了大量「结果不对但不知道哪错」的时间。操作系统实验的代码往往涉及指针和数组一个下标写错就全盘皆输最小验证是唯一的后悔药。希望帮到你。本文还有配套的精品资源点击获取