☰
北交大操作系统实验答案与报告:进程、文件系统与页面置换实战
2026/10/9 2:59:24 网站建设 项目流程

简介:这份资源是北京交通大学操作系统课程的实验答案与报告合集,面向正在修读操作系统实验课、需要参考实现思路与报告写法的本科生,也可供自学者对照练习进程管理、内存管理、文件系统、死锁与权限控制等核心模块。压缩包共41个文件,以26个C源码和5个C++源码为主体,另含4份Markdown实验报告、2个头文件、2个目标文件及汇编、文本等辅助文件,整体约73KB,体量轻便,便于逐实验查阅。内容覆盖进程调度模拟、内存分配与页面置换、文件系统模拟、银行家算法及访问控制模型等典型实验,每份报告包含设计思路、实现过程与结果分析,源码可直接编译运行验证。目前已有202人学习下载,适合需要快速理清实验脉络、补齐代码实现或撰写实验报告的同学参考借鉴。

1. 从一份北交大操作系统实验包说起:进程、文件系统与页面置换到底怎么落地

很多人学操作系统,课本上 FCFS、LRU、inode 背得滚瓜烂熟,一到动手就卡壳——调度算法写出来跑不对,管道通信读着读着就阻塞,页面置换模拟的命中率跟理论值对不上。这份北京交通大学操作系统实验答案和报告包,覆盖了 lab_1 到 lab_5 五个实验,包含汇编入门、进程控制、进程通信、页面置换、文件系统实现,源码和报告都在里面。它适合正在上操作系统课、被实验报告和代码双重折磨的本科生,也适合想拿 C/C++ 把 OS 核心机制跑一遍的自学者。下面按实验模块拆开讲,重点说清楚每个实验在做什么、代码怎么跑、参数怎么调、哪里容易翻车。

2. lab_1 与 lab_2:从汇编入口到进程控制的代码拆解

2.1 lab_1 的汇编与 C 混合编程:hello_linux 到底在干什么

lab_1 目录下有hello_linux.asm、hello_linux.c、huibian.c、mem.c、thread.c、cpu.c、getpid.c这几个文件。从文件名判断,这个实验的目标是让学生理解程序从汇编入口到 C 运行时的过渡,以及通过系统调用获取进程信息。

先看hello_linux.asm,这是一个 32 位汇编程序,典型写法是用int 0x80触发系统调用。在 Linux 下,write系统调用号是 4,exit是 1。汇编代码大致长这样:

; hello_linux.asm - 32位 Linux 汇编 hello world section .data msg db 'Hello, Linux!', 0xA ; 字符串加换行 len equ $ - msg ; 计算字符串长度 section .text global _start _start: ; write(1, msg, len) mov eax, 4 ; 系统调用号 4 = write mov ebx, 1 ; 文件描述符 1 = stdout mov ecx, msg ; 字符串地址 mov edx, len ; 字符串长度 int 0x80 ; 触发系统调用 ; exit(0) mov eax, 1 ; 系统调用号 1 = exit xor ebx, ebx ; 返回码 0 int 0x80

编译和运行需要指定 32 位格式,因为int 0x80在 64 位模式下行为不同:

nasm -f elf32 hello_linux.asm -o hello_linux.o ld -m elf_i386 hello_linux.o -o hello_linux ./hello_linux

这里的关键参数是-f elf32和-m elf_i386,少了任何一个都会链接失败或运行时段错误。hello_linux.c应该是用 C 语言实现同样功能的版本,通过syscall()或直接内联汇编调用write。getpid.c则是调用getpid()获取当前进程 PID,用来验证进程创建后的标识。

thread.c涉及 pthread 库,常见做法是用pthread_create创建线程,然后观察线程 ID 和进程 ID 的关系。cpu.c和mem.c可能是获取 CPU 信息(通过/proc/cpuinfo)和内存信息(通过/proc/meminfo)的辅助程序。

注意:如果是在 64 位 Ubuntu 上跑 32 位汇编,需要先安装gcc-multilib和libc6-dev-i386,否则ld会报找不到 32 位库。

2.2 lab_2 的进程控制:fork、exec 与信号处理

lab_2 目录下有2-2.c、2-3.c、2-4.c、2-3_m.c和README.md。从编号看,这是四个递进的进程控制实验。

2-2.c大概率是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) { // 子进程:fork 返回 0 printf("Child process: PID=%d, Parent PID=%d\n", getpid(), getppid()); } else { // 父进程:fork 返回子进程 PID printf("Parent process: PID=%d, Child PID=%d\n", getpid(), pid); wait(NULL); // 等待子进程结束,避免僵尸进程 } return 0; }

编译命令很简单:gcc 2-2.c -o 2-2。运行后你会看到两行输出,顺序不确定——这正好说明进程调度的不确定性。wait(NULL)是必须加的,否则子进程结束后父进程没回收,用ps aux | grep defunct能看到僵尸进程。

2-3.c和2-3_m.c的区别从后缀_m推测,可能是多进程版本或修改版。常见做法是2-3.c用fork()+exec()族函数加载新程序,2-3_m.c则用system()或popen()实现类似效果。exec族函数有六个变体(execl、execle、execlp、execv、execvp、execvpe),区别在于参数传递方式和是否搜索 PATH。

2-4.c可能涉及信号处理,比如用signal()或sigaction()注册 SIGINT 处理函数,然后观察 Ctrl+C 的行为变化。这里有个血泪经验:signal()在不同 Unix 版本上语义不一致,Linux 下建议用sigaction(),虽然写法麻烦但行为可预测。

#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("Caught signal %d, not exiting.\n", sig); } int main() { struct sigaction sa; sa.sa_handler = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGINT, &sa, NULL); // 注册 SIGINT 处理函数 while (1) { printf("Running... PID=%d\n", getpid()); sleep(2); } return 0; }

编译后运行,按 Ctrl+C 不会退出,而是打印捕获信息。要终止程序得用kill -9 <PID>,因为 SIGKILL 不能被捕获。这个实验的坑在于:如果sa_flags没设SA_RESTART,被信号中断的系统调用(如sleep)会返回EINTR,需要手动处理。

3. lab_3 进程通信:管道、FIFO 与 Socket 的三套实现

3.1 匿名管道 pipe.c 与 FIFO 命名管道

lab_3 是进程间通信(IPC)的重头戏,文件列表里有pipe.c、fifo_send.c、fifo_rcv.c,以及Sender_1.c、Receiver_1.c、Sender_2.c、Receiver_2.c、Sender_3.c、Receiver_3.c三组发送/接收对,还有Client.c、Server.c和3-1.c、3-2_1.c、3-2_2.c、3-3_1.c、3-3_2.c。

pipe.c是匿名管道的基础实现。匿名管道只能用于有亲缘关系的进程(父子、兄弟),因为管道文件描述符需要通过fork()继承。典型代码:

#include <stdio.h> #include <unistd.h> #include <string.h> int main() { int fd[2]; pipe(fd); // fd[0] 读端,fd[1] 写端 pid_t pid = fork(); if (pid == 0) { // 子进程:关闭读端,写入数据 close(fd[0]); const char *msg = "Hello from child"; write(fd[1], msg, strlen(msg)); close(fd[1]); } else { // 父进程:关闭写端,读取数据 close(fd[1]); char buf[256] = {0}; read(fd[0], buf, sizeof(buf) - 1); printf("Parent received: %s\n", buf); close(fd[0]); } return 0; }

这里的关键是:父子进程必须各自关闭不用的那一端。如果父进程不关写端,子进程的read会一直阻塞,因为管道写端还没完全关闭。这是新手最容易翻车的地方——程序卡住不动,用strace跟一下才发现卡在read系统调用上。

fifo_send.c和fifo_rcv.c用的是命名管道(FIFO),通过mkfifo()创建,可以在无亲缘关系的进程间通信。发送端和接收端分别编译成两个可执行文件,先运行接收端(会阻塞在open上),再运行发送端。

// fifo_send.c #include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main() { const char *fifo_path = "/tmp/my_fifo"; mkfifo(fifo_path, 0666); // 创建 FIFO,权限 0666 int fd = open(fifo_path, O_WRONLY); // 阻塞直到有读端打开 const char *msg = "FIFO message"; write(fd, msg, strlen(msg)); close(fd); unlink(fifo_path); // 删除 FIFO 文件 return 0; }

mkfifo的权限参数受umask影响,实际权限是0666 & ~umask。如果umask是 022,实际权限是 644。open的O_WRONLY会阻塞到有读端打开,这是 FIFO 的同步机制。如果不想阻塞,可以用O_NONBLOCK,但写端在无读端时会返回ENXIO错误。

3.2 Sender/Receiver 三组实现与 Client/Server 架构

Sender_1.c/Receiver_1.c、Sender_2.c/Receiver_2.c、Sender_3.c/Receiver_3.c这三组应该是用不同 IPC 机制实现同样的发送/接收功能。常见做法是:第一组用管道,第二组用消息队列,第三组用共享内存。3-1.c、3-2_1.c、3-2_2.c、3-3_1.c、3-3_2.c则是实验报告里对应的测试代码。

Client.c和Server.c是 Socket 编程的经典配对。TCP 版本的服务端流程是socket()→bind()→listen()→accept()→read()/write(),客户端是socket()→connect()→write()/read()。

// Server.c - TCP 服务端简化版 #include <stdio.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> int main() { int server_fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8080); bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(server_fd, 5); // backlog 设为 5 struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len); char buf[256] = {0}; read(client_fd, buf, sizeof(buf) - 1); printf("Server received: %s\n", buf); write(client_fd, "ACK", 3); close(client_fd); close(server_fd); return 0; }

编译命令:gcc Server.c -o server,gcc Client.c -o client。先运行./server,再开一个终端运行./client。htons(8080)把主机字节序转成网络字节序,端口号必须大于 1024(非 root 用户不能绑定特权端口)。listen的 backlog 参数在 Linux 上实际生效值受/proc/sys/net/core/somaxconn限制,默认 128。

注意:如果bind报Address already in use,说明端口被占用或上次的 socket 还在 TIME_WAIT 状态。可以用setsockopt设置SO_REUSEADDR解决,或者等 60 秒再试。

4. lab_4 与 lab_5:页面置换算法与文件系统实现

4.1 lab_4 页面置换算法:FIFO、LRU 与 OPT 的命中率对比

lab_4 只有一个文件页面置换算法.cpp和报告操作系统实验四_页面置换算法.md。这个实验要求实现三种页面置换算法并对比命中率。

FIFO 最简单,用一个队列维护页面进入顺序,缺页时淘汰队首页面。LRU 需要记录每个页面的最近访问时间,可以用链表或数组加时间戳实现。OPT(最优置换)是理论最优,淘汰未来最长时间不被访问的页面,实际系统中无法实现,只用于对比。

#include <iostream> #include <vector> #include <queue> #include <unordered_map> #include <algorithm> // FIFO 页面置换 int fifo(const std::vector<int>& pages, int frame_count) { std::queue<int> frames; std::unordered_map<int, bool> in_frame; int faults = 0; for (int page : pages) { if (in_frame.find(page) == in_frame.end()) { faults++; if ((int)frames.size() >= frame_count) { int victim = frames.front(); frames.pop(); in_frame.erase(victim); } frames.push(page); in_frame[page] = true; } } return faults; } // LRU 页面置换 int lru(const std::vector<int>& pages, int frame_count) { std::vector<int> frames; int faults = 0; for (int page : pages) { auto it = std::find(frames.begin(), frames.end(), page); if (it == frames.end()) { faults++; if ((int)frames.size() >= frame_count) { frames.erase(frames.begin()); // 淘汰最久未使用 } } else { frames.erase(it); // 命中则移到末尾 } frames.push_back(page); } return faults; }

参数说明:pages是页面访问序列,frame_count是物理块数量。fifo用queue保证先进先出,lru用vector模拟链表,命中时把页面移到末尾。测试时用同一组页面序列跑两个函数,对比faults数量。

常见坑:LRU 的std::find是线性查找,页面数多时效率低。实际工程中会用哈希表加双向链表做到 O(1),但实验里页面数少,线性查找够用。另一个坑是页面序列的生成——如果随机生成,每次结果不同,报告里没法对比。建议固定序列,比如{7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1},这是经典教材例子。

4.2 lab_5 文件系统:从 init_disk 到 file 操作的完整链路

lab_5 目录下有io.cpp、io.h、file.cpp、file.h、main.cpp、init_disk.cpp、init.o、main.o、ldisk.txt和报告操作系统实验5_文件系统.md。这是一个模拟文件系统的实现,包含磁盘初始化、文件操作和 I/O 接口。

从文件结构看,init_disk.cpp负责初始化虚拟磁盘,ldisk.txt可能是磁盘镜像文件或初始化数据。file.h/file.cpp定义文件操作接口(创建、删除、读写),io.h/io.cpp提供底层块读写。main.cpp是测试入口。

编译命令需要把所有 .cpp 一起编译:

g++ -o fs main.cpp file.cpp io.cpp init_disk.cpp

如果init.o和main.o是预编译的目标文件,也可以直接链接:

g++ -o fs main.o init.o file.cpp io.cpp

ldisk.txt是磁盘镜像文件,通常用固定大小的块(如 512 字节或 1024 字节)模拟磁盘扇区。init_disk.cpp会读取这个文件或创建它。文件系统的核心数据结构是超级块、inode 位图、数据块位图、inode 表和根目录。

常见坑:文件路径问题。如果ldisk.txt用相对路径打开,而程序运行目录不对,会报open failed。建议在代码里用绝对路径,或者运行时先cd到文件所在目录。另一个坑是init.o和main.o可能是 32 位编译的,在 64 位系统上链接会报undefined reference,需要重新编译所有源文件。

5. 避坑与排查:编译、运行、报告三阶段的常见问题

5.1 编译阶段:32 位与 64 位混编的链接错误

现象:ld: i386 architecture of input file 'xxx.o' is incompatible with i386:x86-64 output。

原因:目标文件是 32 位编译的,但链接器默认生成 64 位可执行文件。

解决:要么全部用 32 位编译(gcc -m32),要么全部用 64 位重新编译。对于 lab_1 的汇编,必须用nasm -f elf32加ld -m elf_i386。如果系统没装 32 位库,apt install gcc-multilib解决。

5.2 运行阶段:管道阻塞与僵尸进程

现象:程序运行后卡住不动,Ctrl+C才能退出。

原因:管道读端在写端未关闭时阻塞,或者fork()后父进程没wait()导致僵尸进程堆积。

解决:检查管道代码是否在父子进程里正确关闭了不用的文件描述符。父进程必须wait()或waitpid()回收子进程。用ps aux | grep Z查看僵尸进程。

5.3 运行阶段:Socket 绑定端口失败

现象:bind: Address already in use。

原因:端口被其他进程占用,或上次运行的 socket 处于 TIME_WAIT 状态。

解决:用netstat -tlnp | grep 8080查看占用进程,kill掉或换端口。代码里加setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))允许重用地址。

5.4 报告阶段:页面置换命中率与理论值不符

现象:LRU 命中率比 FIFO 低,或者 OPT 不是最优。

原因:页面序列生成有问题,或者算法实现有 bug。常见的是 LRU 命中时没更新访问顺序,导致淘汰了刚访问过的页面。

解决:用固定序列手工验算一遍。比如序列7,0,1,2,0,3,0,4,3 个物理块,FIFO 缺页 7 次,LRU 缺页 6 次,OPT 缺页 5 次。如果结果对不上,逐步打印每次访问后的物理块状态。

5.5 文件系统实验:磁盘镜像文件读写越界

现象:Segmentation fault或数据写入后读出来是乱码。

原因:块号计算错误,或者read/write的偏移量超过了文件大小。

解决:在io.cpp里加边界检查,打印每次读写的块号和偏移。用ls -l ldisk.txt确认文件大小,确保块号乘以块大小不超过文件长度。

6. 进阶技巧:用 GDB 和 strace 定位 OS 实验中的玄学问题

OS 实验里最头疼的不是写代码,而是程序行为跟预期不一致——明明逻辑对的,跑出来就是不对。这时候光看代码没用,得上工具。

GDB 是首选。编译时加-g保留调试信息,然后gdb ./program。对于多进程程序,GDB 默认只跟踪父进程,要在fork()后跟踪子进程,得用set follow-fork-mode child。对于多线程,info threads查看所有线程,thread <n>切换。断点用break file.c:line,查看变量用print var,查看调用栈用backtrace。

strace 是另一个神器,专门跟踪系统调用。strace ./program会打印每个系统调用的名称、参数和返回值。管道阻塞的问题,用strace -f ./program跟一下,能看到卡在哪个read上。Socket 问题,strace -e trace=network ./server只看网络相关调用。

我一般会先用strace -f -o trace.log ./program把系统调用写到文件里,然后grep关键调用。比如查open失败:grep open trace.log,看返回的-1和ENOENT。查read阻塞:grep read trace.log,看哪个 fd 没返回。

对于页面置换实验,如果命中率不对,可以在代码里加打印,但更高效的是用 GDB 的条件断点。比如在缺页处理函数里设break page_fault if page == 7,只在你关心的页面上停下来,然后print frame_count、print frames看状态。

还有一个技巧:用valgrind检查内存问题。valgrind --leak-check=full ./program能发现内存泄漏和越界访问。文件系统实验里如果new了没delete,或者数组越界写,valgrind 会直接报出来。

从那以后我每次跑 OS 实验,都强制走一遍strace加gdb的组合——先看系统调用层面有没有异常,再进 GDB 看变量状态。这套流程帮我省了无数个通宵。希望帮到你。

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

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

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

立即咨询