简介:东南大学《操作系统概念》课程作业代码与实验报告合集,面向操作系统课程学习者、课程设计或需要参考同类实验的学生。四个实验依次涉及:使用系统调用实现文件读写并完善错误处理,在Linux内核中增加系统调用,利用Mutex和信号量机制实现生产者消费者问题,以及编写LRU算法及其近似算法并分析页错误率,基本覆盖操作系统核心知识点。
资源共27个文件,以C/C++源码、头文件、Word实验报告、CSV数据文件和说明文档为主,压缩包整体14.63MB。代码与报告按实验分目录存放,CSV文件记录随机页面访问序列及统计结果,便于验证和对比不同算法的缺页情况。已有229人学习,所有代码均测试运行成功,Windows和Linux平台版本完整,既可直接编译运行,也可作为撰写实验报告的参考,还是一份可套用的代码模板与实验报告范文,能帮助理解文件I/O、内核调用、同步互斥和内存管理等关键机制。
1. 从系统调用到LRU:一份OS课程作业里的四个实验单元
一份《操作系统概念》课程的源码包能翻出多少东西?这套作业没有停留在“调通API”层面:系统调用、内核源码级分析、进程同步、页面置换,四块硬骨头各占一个实验。SystemCall.cpp、test.c、psta.c、LRU.h 加上多份统计csv,每个文件对应一次能复现的验证。对课程设计卡住的人,这些是可直接抄的基线;对工作几年的工程师,值得看的是另一层:错误处理怎么做才不糊弄、生产者消费者为什么在Windows和Linux上写法不同、随机序列下LRU到底比FIFO强多少。这些参数判断,正是OS实验报告最该写的内容。
2. 系统调用文件拷贝:read/write与双平台错误处理的正确姿势
2.1 交互式输入比命令行参数更容易卡住
这个实验第一项硬性要求,是“使用者可输入源文件和目的文件的路径”。很多人习惯用scanf("%s")一次性读两个字符串,代码在测试目录里跑得好好的,直到有人给了带空格的路径——Windows 的Program Files、Linux 桌面环境里常见的带空格目录名,都会让路径在第一个空格处被截断,打开文件必然失败。我一般用fgets读整行,再手工去掉换行符,把路径当作一整行来处理,而不是一个无空格的 token。
// 交互式读入路径并去掉换行,避免 scanf 被空格截断 char src[PATH_MAX], dst[PATH_MAX]; printf("Input source file path: "); if (fgets(src, sizeof(src), stdin) == NULL) { fprintf(stderr, "failed to read input\n"); return -1; } src[strcspn(src, "\n")] = '\0'; // 去掉 fgets 留下的换行符 if (src[0] == '\0') { fprintf(stderr, "source path is empty\n"); return -1; }fgets最多读入sizeof(src) - 1个字符,并把换行符也放进缓冲区;strcspn(src, "\n")返回第一个换行符的下标,把它替换成\0就能得到干净的路径串。PATH_MAX在 Linux 下通常是 4096,足够覆盖绝大多数绝对路径。这里还做了一个空串校验,否则后续open会返回一个不太直观的ENOENT。
2.2 用 open/read/write 而不是 fread/fwrite
为什么要求用系统调用而不是fread/fwrite?因为fopen系列会在用户态维护一块缓冲区,底层的read/write调用细节被隐藏了;而本实验考察的恰恰是“系统调用返回值怎么判断、部分写怎么处理”。read返回正整数表示实际读到的字节数,返回 0 表示到达文件尾,返回 -1 才是出错;write同样不保证一次把缓冲区全部写出。真正容易翻车的,是忽略了write的局部写。
// Linux 侧:打开输入文件,逐块拷贝,目标文件用追加/创建方式打开 int in_fd = open(src, O_RDONLY, 0644); int out_fd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (in_fd < 0 || out_fd < 0) { perror("open failed"); close(in_fd); close(out_fd); return -1; } char buf[4096]; ssize_t n; while ((n = read(in_fd, buf, sizeof(buf))) > 0) { char *p = buf; while (n > 0) { // 处理部分写 ssize_t w = write(out_fd, p, n); if (w < 0) { perror("write failed"); goto fail; } p += w; n -= w; } }buf选 4096 字节,正好对应一个内存页大小,既不会因为缓冲区太小而频繁陷入内核态,也不会因为太大挤占栈空间。内层while是关键:磁盘满、管道阻塞、信号中断都可能导致返回值小于传入长度,只有循环到n == 0才算写完的一段数据真正落地。perror会根据当前errno打印具体错误文本,比只输出 “error” 有信息量得多。
2.3 错误处理不能只写一个 return -1
交互式程序最忌讳“一错就退出”。源文件不存在、权限不够、目标路径是个目录、磁盘写满,这四类错误应该分别提示用户怎么处理,而不是抛出一个笼统的失败码。可以把错误场景整理成一张矩阵,报告里直接照抄:
| 错误场景 | Linux 下的 errno | 处理惯例 |
|---|---|---|
| 源文件不存在 | ENOENT | 提示重新输入源路径 |
| 无读取权限 | EACCES | 提示检查文件权限或运行身份 |
| 目标是目录 | EISDIR | 提示目标必须指向文件路径 |
| 输出位置空间不足 | ENOSPC | 提示清理磁盘,保留已写部分 |
| 路径过长 | ENAMETOOLONG | 提示缩短路径或更换目录 |
2.3.1 Windows 侧的错误码差异
Windows 上这套判断完全不一样。CreateFileA失败返回INVALID_HANDLE_VALUE,即(HANDLE)-1,而不是 0 或负数;ReadFile/WriteFile返回 BOOL,失败时调用GetLastError()才能拿到错误码,而且错误码取值范围和 errno 完全不重叠。
// Windows 侧:CreateFileA 打开文件,失败统一走 GetLastError 分支 HANDLE hIn = CreateFileA(src, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hIn == INVALID_HANDLE_VALUE) { fprintf(stderr, "Open failed, code=%lu\n", GetLastError()); return -1; }第三个参数FILE_SHARE_READ表示允许其他进程同时读这个文件,不传的话,文件被占用时CreateFileA会直接失败;OPEN_EXISTING强制“文件必须已存在”,正好对应 Linux 的O_RDONLY语义。写报告时可以强调一句:两套 API 的返回值判据不同,是双平台移植里最容易忽略的差异。
提示:Windows 侧路径分隔符是反斜杠,但绝大多数底层 API 同时接受正斜杠;进入
CreateFileA之前先统一替换路径分隔符,能少很多诡异错误。
3. 从Linux内核源码到新系统调用:Syscall表、注册与验证
3.1 读内核代码不要从头到尾,按调用链往下钻
作业一的 part2 要求做 Linux 内核代码分析,还要新增系统调用。很多同学的第一个问题是“内核源码那么大,从哪读起”。我一般建议先写一个用户态的小工具,确定要观测的系统调用,再沿着“应用 →syscall()→ 平台入口 → 系统调用表 → 具体实现”这条链往下追。项目里的test.c、psta.c、psta.h就是这个思路:psta负责封装调用和统计,test.c负责触发,这样分析内核时不用反复改用户态代码。
分析内核代码不需要先找网盘里过时的 PDF 二手资料,直接拉一份主线内核源码,从最常被调用的open、read、write入手就足够了。主线源码的注释和宏定义都更新及时,读它相当于站在最新实现上看问题,比看旧版剖析书更接近真实代码。
3.2 系统调用表:syscall_64.tbl 是第一个入口
x86_64 架构下,新增系统调用最先要动的是arch/x86/entry/syscalls/syscall_64.tbl。这张表每一行把一个系统调用号映射到内核函数名,编译脚本会把它生成 C 头文件;entry_SYSCALL_64拿到用户态传入的调用号后,直接在表里查函数指针并跳转。所以“增加系统调用”本质是四步:
| 步骤 | 要改的文件 | 内容 |
|---|---|---|
| 分配编号 | syscall_64.tbl | 在表尾选一个空闲编号,如 548 |
| 声明原型 | include/linux/syscalls.h | 加入 asmlinkage 函数声明 |
| 实现主体 | kernel/ 下新增文件 | 用 SYSCALL_DEFINEn 宏包裹函数体 |
| 编译注册 | 重新编译内核并重启 | 用户态测试前先确认 dmesg |
不同内核版本的系统调用号差异很大,选号前先看表尾最大值,别去猜一个固定号码。自己实验用的内核,选一个空闲号即可;如果提交到真实环境,还要同步更新syscalls_64.h等由脚本生成的文件,这些生成文件不要手工改。
3.3 用 SYSCALL_DEFINEn 而不是直接写函数
直接写一个long sys_hello_os(...)也能通过编译,但少了参数类型校验和追踪点支持。SYSCALL_DEFINE1这组宏会把参数展开成合适的寄存器存取逻辑,保证 64 位平台下指针参数能正确到达。内核态读用户态字符串还必须用strncpy_from_user,不能直接解引用用户指针,否则可能触发异常或读到不可信数据。
// kernel/hello_os.c 自定义系统调用,打印用户态传入的字符串 #include <linux/kernel.h> #include <linux/syscalls.h> #include <linux/uaccess.h> SYSCALL_DEFINE1(hello_os, const char __user *, name) { char buf[64]; if (strncpy_from_user(buf, name, sizeof(buf) - 1) < 0) return -EFAULT; // 用户地址非法或越界 buf[sizeof(buf) - 1] = '\0'; pr_info("hello_os: %s\n", buf); // 内核日志,用 dmesg 查看 return 0; }函数名前的__user标记是给 sparse 静态检查工具看的,提示这个指针来自用户态;strncpy_from_user返回值小于 0 表示复制失败,返回 -EFAULT 后,用户态拿到的就是errno等于 14。pr_info打到内核日志,用dmesg | tail能看到输出。调试时如果dmesg里什么都没有,先确认调用号有没有真正注册进系统调用表。
3.4 用户态验证:syscall() 触发与 psta 统计
3.4.1 用 syscall() 直接触发新调用
glibc 不会为自定义系统调用生成包装函数,所以测试代码里用syscall()最省事:
// test.c 用户态触发新系统调用,返回值小于 0 表示 -errno #include <unistd.h> #include <sys/syscall.h> #include <stdio.h> #ifndef SYS_hello_os #define SYS_hello_os 548 // 与 syscall_64.tbl 保持一致 #endif int main(void) { long ret = syscall(SYS_hello_os, "OS Course"); fprintf(stderr, "ret=%ld\n", ret); // 负数即 -errno return 0; }syscall()第一个参数是调用号,后面跟着可变参数,正好绕过 libc 包装层。<unistd.h>里没有这个宏时手动#define,测试完记得删掉,避免污染环境。
3.4.2 psta.c/psta.h 把统计逻辑收敛起来
psta这类辅助模块通常做三件事:循环触发 N 次系统调用、记录每次返回值、最后输出平均耗时。这样换调用号时只改一处,统计逻辑不用动。我在实验报告里把psta的输出整理成表格,既展示了内核行为,又说明了用户态观测手段,比只贴一段dmesg更有说服力。
4. 生产者/消费者:Pthreads与Win32信号量的对称与差异
4.1 为什么同一个题目要求写两遍
生产者/消费者是《操作系统概念》第七版第六章后的经典 Project。它在 Linux 下的标准解法是pthread_mutex_t加sem_t,在 Windows 下则是一组内核对象句柄:CreateMutex、CreateSemaphore、WaitForSingleObject。两套 API 的抽象层级不一样:Pthreads 把同步原语当作类型化的对象,Windows 则统一用HANDLE操作。同一个算法写两遍的价值在于,你能清楚看到“锁 + 信号量”是系统的资源管理手段,而不是某个库特有的语法。
4.2 Linux 侧:互斥锁只保护缓冲数组,不能包住信号量等待
4.2.1 信号量初始值决定缓冲语义
有界缓冲的核心是两个信号量:empty_slots表示空闲槽位数量,full_slots表示有数据的槽位数量。初始值一个等于缓冲区容量,一个等于 0,生产者和消费者对称操作,才不会出现计数错乱。
// 代码_Linux:有界缓冲,容量 8,使用互斥锁 + 两个信号量 #define BUFFER_SIZE 8 sem_t empty_slots, full_slots; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; sem_init(&empty_slots, 0, BUFFER_SIZE); // 初始空闲槽位 = 容量 sem_init(&full_slots, 0, 0); // 初始有数据槽位 = 0sem_init第二个参数传 0 表示线程间共享,传非 0 才是进程间共享;第三个参数是信号量初值。如果full_slots初值误写成容量,消费者会立刻以为缓冲区里全是数据,读到的却是未初始化的内存。
4.2.2 持锁等信号量是一种典型的自锁死锁
// 错误写法:先拿互斥锁,再等空闲槽位 pthread_mutex_lock(&mutex); sem_wait(&empty_slots); // 缓冲区满时,生产者阻塞,消费者拿不到锁 pthread_mutex_unlock(&mutex);// 正确顺序:先等信号量,再进入临界区 sem_wait(&empty_slots); pthread_mutex_lock(&mutex); buffer[in] = item; in = (in + 1) % BUFFER_SIZE; pthread_mutex_unlock(&mutex); sem_post(&full_slots);错误版本在缓冲区满时,生产者阻塞在sem_wait上,消费者想进临界区取数据却被互斥锁挡住,双方互相等待。正确顺序的核心是:锁只保护共享数组本身,信号量负责“有没有空间/有没有数据”,两者各司其职,不要嵌套。
4.3 Windows 侧:句柄与 WaitForSingleObject 的对称写法
Windows 版本的逻辑模型一样,但 API 观感完全不同。WaitForSingleObject既是 P 操作,也是锁获取,一个函数通吃;ReleaseSemaphore则对应 V 操作。生产者代码写成这样:
// 代码_Windows:CreateSemaphore + CreateMutex + WaitForSingleObject HANDLE g_hEmpty = CreateSemaphore(NULL, BUFFER_SIZE, BUFFER_SIZE, NULL); HANDLE g_hFull = CreateSemaphore(NULL, 0, BUFFER_SIZE, NULL); HANDLE g_hMutex = CreateMutex(NULL, FALSE, NULL); DWORD WINAPI Producer(LPVOID param) { for (int i = 0; i < 100; i++) { WaitForSingleObject(g_hEmpty, INFINITE); // 等待空闲槽位 WaitForSingleObject(g_hMutex, INFINITE); // 获取互斥锁 buffer[in] = i; in = (in + 1) % BUFFER_SIZE; ReleaseMutex(g_hMutex); ReleaseSemaphore(g_hFull, 1, NULL); // 释放一个数据槽 Sleep(20); } return 0; }CreateSemaphore第二、三个参数分别是初始计数和最大计数,这里empty和full的最大值都设为缓冲区容量;ReleaseSemaphore第三个参数用于接收上一次计数,不需要就传NULL。WaitForSingleObject第二个参数传INFINITE表示无限等待,生产环境通常换成超时时间。Windows 版最常见的坑是线程函数写完后忘了CloseHandle,句柄泄漏在长时间运行时非常明显。
4.4 常见故障:假死、消费者不醒、VS调试拦不住断点
| 症状 | 原因 | 修法 |
|---|---|---|
| 运行几秒后卡住 | 持锁状态下等待 empty/full 信号量 | 把信号量等待移出临界区 |
| 消费者永远不醒 | full 信号量初值错给成容量 | 检查CreateSemaphore初始计数 |
| 两个生产者写同一个槽 | in 下标没按容量取模 | 写入后立即(in + 1) % BUFFER_SIZE |
| 主函数退出后子线程消失 | 没回收线程句柄就 return | 先WaitForSingleObject(hThread)再退出 |
| VS 提示“当前不会命中断点” | 编译优化或 PDB 与 exe 版本不一致 | 用 Debug 配置 F5 启动,确认符号文件最新 |
“当前不会命中断点”不是代码逻辑问题,而是调试符号没对上。重新生成 exe 后,PDB 时间戳必须一致,断点才拦得住;这条经验在双平台项目里同样适用,Linux 侧对应的是-g编译选项和gdb的源码路径设置。
注意:如果生产者和消费者各自只跑固定次数就退出,主线程一定要先
join所有子线程再返回,否则main结束会直接终止整个进程,统计结果永远是残缺的。
5. LRU实现与复杂度对比:双向链表+哈希不是唯一答案
5.1 数据结构选型:为什么是链表加哈希
实验四要求实现 LRU 及其近似算法,并分析时间复杂度、空间复杂度和实现难度。LRU 要解决三件事:访问时快速定位页面、满时快速找出最久未用的页面、同时维护访问时间顺序。双向链表加哈希表正好覆盖这三个需求:unordered_map<页号, 链表迭代器>提供 O(1) 定位,链表头表示最近访问,链表尾表示最久未用,淘汰时删尾节点也是 O(1)。
| 算法 | 访问代价 | 淘汰代价 | 额外空间 | 实现难度 |
|---|---|---|---|---|
| LRU(链表+哈希) | O(1) | O(1) | O(页框数) | 高,维护双向链表和 map 同步 |
| CLOCK/二次机会 | O(1) | 最坏 O(n) 扫描 | O(页框数) | 中低,只需环形数组 |
| FIFO | O(1) | O(1) | O(页框数) | 最低,但存在 Belady 异常 |
CLOCK 算法用环形数组加引用位近似 LRU:缺页时从指针位置扫描,引用位为 1 就清零并继续,遇到 0 就替换。最好情况 O(1),最坏情况要扫一整圈。实验报告里讲“实现难度”,重点就写 CLOCK 对引用位的维护逻辑,以及为什么它能近似 LRU。
5.2 随机页面访问序列:均匀分布与局部性
main.cpp生成测试序列的方式,会直接影响页错误率的结论。如果所有页面等概率出现,工作集接近全部页面,任何算法的表现都会被拉平;真实程序有明显的局部性,访问往往集中在少数页面附近。所以测试要生成两组序列:一组均匀随机做 baseline,一组带局部性模拟真实负载。
// main.cpp 生成带局部性的访问序列,ratio 控制局部性强弱 std::vector<int> gen_trace(int length, int max_page, double locality) { std::vector<int> seq(length); int cur = rand() % max_page; for (int i = 0; i < length; i++) { if (rand() % 100 < (int)(locality * 100)) { // 90% 概率在当前位置 ±1 邻域内移动 cur = (cur + (rand() % 3) - 1 + max_page) % max_page; seq[i] = cur; } else { seq[i] = rand() % max_page; // 瞬间跳出,模拟切换 } } return seq; }locality取 0.9 时,大约 90% 的访问落在当前页邻域,10% 随机跳跃;cur跳到随机页后,下一轮依然有 90% 概率留在新邻域,这模拟了进程切换后的重新聚集。访问长度建议不低于 10 万次,太短的话随机波动会把算法差异淹没掉。
5.3 页错误率计算口径要写死在第一行
5.3.1 冷启动与预热
页错误率的算法很简单:缓存未命中次数除以总访问次数。容易产生歧义的是初始状态:缓存为空时,前几个页面必定缺页,这部分“冷启动缺失”是否计入统计,不同报告可能给出不同数字。
// 缺页统计:缓存从空开始,缺页数累加 faults for (int page : seq) { if (cache_map.find(page) != cache_map.end()) { touch(page); // LRU 中把该页移到链表头 continue; } faults++; if (cache_map.size() == capacity) evict(); // 淘汰链表尾部或 CLOCK 指针处 insert(page); // 新页加入链表头 } double miss_ratio = (double)faults / seq.size();touch(page)在 LRU 里是 O(1) 的链表摘除和头插,在 CLOCK 里只是把引用位置 1;很多近似算法实现漏掉了这个操作,导致“访问已有页面”没有更新热度信息,结果错误率比真正的 LRU 差一大截。报告中要注明“统计口径为冷启动,包含前capacity次缺页”,再把不含预热的数字也列出来,两条曲线都给读者看。
注意:
evict()和insert()必须作用于同一个数据结构。如果 LRU.h 里链表和哈希表赋值不一致,缺页率会出现“算出来的淘汰页号根本不在缓存里”这类诡异现象,先用小容量单步调试。
6. 用trace与统计csv交叉验证LRU:时序数据怎么榨出结论
实验四配套的OSC-Experiment4-Traces.zip和五份_statistics.csv,是用来验证算法实现最直接的素材。trace 是原始页面访问序列,csv 是跑完算法后的统计数据。拿到压缩包先别急着写报告,第一步是解压并确认 trace 格式:
unzip -o OSC-Experiment4-Traces.zip -d traces head -n 5 traces/emacs.trace如果每行一个数字,那就是页号,总行数就是访问次数;如果带逗号,第二列通常是读/写类型或时间戳,统计脚本要按列取值。wc -l traces/emacs.trace能快速确认访问规模,测试时间也以这个数据量为准。
五份_statistics.csv的表头不一定完全相同,先用head -n 1看列名,再决定用哪一列做缺页率计算。手头有多个 csv 时,用 awk 压缩成一张横向对比表:
for f in *_statistics.csv; do faults=$(tail -1 "$f" | cut -d, -f1) refs=$(tail -1 "$f" | cut -d, -f2) awk -v name="$f" -v f="$faults" -v r="$refs" \ 'BEGIN { printf "%s %.4f%%\n", name, f * 100 / r }' done这段脚本假设 csv 最后一行的第一列是缺页次数、第二列是访问次数。实际文件列顺序可能不同,跑之前先看表头,按实际列号改-f参数,不要照抄。
验证近似算法时,最好把 LRU.h 的淘汰逻辑抽象成touch()和evict()两个回调,CLOCK、NFU 只改这两处,统计代码一行不动。这样跑同一份 trace,得到缺失率的差异才是算法本身带来的差异。把多个 csv 合并成比率曲线时,横坐标建议用“可用页框数”而不是访问次数,再和教材里经典曲线对比,报告的说服力会强很多。
本文还有配套的精品资源,点击获取