简介东北大学操作系统课程设计完整实现围绕进程管理、内存管理、文件系统与设备管理四大核心模块展开适合计算机专业学生作为课设参考也可用于操作系统原理的实践复习。资源共24个文件主体为21个C源文件覆盖文件系统读写、目录操作、内存分配与回收、进程调度算法模拟等关键功能另附头文件、可直接运行的FileSystem.exe以及课程设计报告docx压缩包仅2.11MB便于下载与本地运行。目前已有280人学习使用代码结构清晰从create、open、read等基本接口到AllocDataBlock、ReleaseDataBlock等底层实现均有覆盖配套报告包含需求分析、模块划分、关键函数说明与测试结果可帮助理解设计思路与算法选择。读者既能通过可执行文件直观观察运行效果也能对照源码深入拆解操作系统核心概念对巩固理论知识、提升编程与调试能力都很有帮助。1. 项目定位一份能跑起来、也能讲明白的操作系统课设源码包操作系统课设最怕的不是写不出代码而是写完被导师一问“你的时间片是怎么切”就卡壳。这份资源是一个某高校计科专业提交过的操作系统课程设计完整项目用 C 语言在用户态模拟了一个微型操作系统进程调度、内存管理、文件系统一个不少而且可运行、可统计、可改参数。你不需要买开发板也不需要碰 ARM 汇编只需要一台带 make 的 Linux 机器就能看到任务如何入队、页面如何被置换、文件如何写入磁盘。它既能直接交作业也能当理解操作系统原理的动手副本。下面我把它的架构、核心代码和改造思路拆开。2. 技术栈与整体架构一个靠主循环撑起来的“伪内核”这个项目没有跑在真实硬件上它通过一个 while 循环模拟时钟中断每次循环代表一个 tick。这样做的好处是可控性强课设答辩时可以单步调试肉眼看到状态变化。完全由 C99 编写依赖只有 libc 和 Makefile不需要 pthread不需要 ncurses移植成本很低。如果你想过为什么不用真实内核答案很现实真实内核的一行汇编就能卡住一整天而课程设计要的是一个能讲清楚的 demo。2.1 技术选型为什么用 C 而不是 C 或 Python操作系统课设选语言通常不是语法问题是讲原理的便利问题。同样一个时间片轮转Python 十行就写完了但老师问你怎么操作内存你只能说“列表切片”。换成 C 以后PCB 就是一个 struct队列就是一个结构体指针页面就是一个数组下标这些概念全都能对着代码指出来。这个项目坚持用 C99 的另一个原因是方便单步调试在 gdb 里可以看 ready_head 链表的每个节点甚至可以直接修改某个 PCB 的内存。编译环境也很朴素。Makefile 里只有两条关键变量CC : gcc CFLAGS : -Wall -g -O0 -stdc99-Wall打开所有常见警告-g保留调试信息-O0禁止优化否则单步调试时变量可能被优化掉。这是课设项目最常见的配置建议不要在 CFLAGS 里加-O2代价是断点里的值全部变成optimized out那种体验接近玄学。那么为什么不用 pthread 做真实并发因为课设的主题是操作系统而操作系统本身是单 CPU 上多个进程轮流跑用单线程主循环模拟并发反而更贴近底层真实行为。一旦引入 pthread调度顺序会被内核线程调度器随机化课设答辩时同一个用例跑两次结果不同是给自己挖坑。因此项目所有模块共用同一个current_tick全局变量由main的循环统一驱动彻底避免锁竞争问题。2.2 目录结构三个子系统如何由 main 串起来整个工程按“主循环 子系统”的方式组织目录不复杂。先看全貌ostrain/ ├── Makefile ├── README.md ├── src/ │ ├── main.c # 入口解析参数并驱动 tick 循环 │ ├── pcb.c # 进程控制块操作 │ ├── scheduler.c # 调度算法RR / 优先级 │ ├── memory.c # 内存分配与页面置换 │ ├── filesystem.c # 虚拟磁盘与 inode 管理 │ └── shell.c # 简单的命令口 ├── include/ │ └── ostrain.h # 公共结构体、宏、函数声明 └── test/ ├── 01_rr_test.txt # 时间片轮转测试用例 └── 02_fs_test.txt # 文件读写测试用例src/main.c是唯一入口它负责解析命令行参数并启动模拟scheduler.c、memory.c、filesystem.c是三个业务子系统相互之间通过ostrain.h里的公共结构体通信shell.c提供最基础的命令行接口用来动态创建进程或读写文件。这样拆分的好处是想把调度器换成多级反馈队列只改scheduler.c不影响内存和文件系统。编译和运行方式很直接make clean make ./ostrain --modeall --tick200参数说明--mode控制要运行哪个子系统可选process、memory、fs、all--tick指定模拟多少个时间片默认 100。比如只要看文件系统就跑./ostrain --modefs --tick50输出会只保留与文件读写相关的日志。测试阶段用--modeall最合适让三个子系统在同一时间源下协同观察它们是否互相干扰。提示如果你在 Windows 上开发建议用 WSL 或虚拟机装 Ubuntu 跑原生的 mingw 环境在usleep和路径分隔上可能有点小问题。macOS 也能跑但sleep_millis里的usleep需要包含unistd.h。工程里还留了make test目标它会在test/目录外生成result/文件夹把三个模块的输出分别写到result/process.log、result/memory.log、result/fs.log方便答辩时把日志拉出来和代码对照。2.3 一次“时钟中断”的完整数据流所有模块的联动都发生在一次 tick 中。展开看一个 tick 内的步骤是current_tick自增作为全局时间戳。scheduler_tick()检查当前进程时间片是否用完用完则换下一个。memory_tick()处理缺页请求如果物理内存不足就调用 LRU 淘汰一页。filesystem_tick()把脏掉的磁盘块延迟写回虚拟磁盘。shell_collect(t)把当前各模块的状态快照存进统计数组。这些步骤在main.c的run_simulation里是顺序调用的// main.c 核心循环每个 tick 模拟一次时钟中断 void run_simulation(int ticks) { for (int t 0; t ticks; t) { current_tick t; scheduler_tick(); // 进程调度检查时间片余量 memory_tick(); // 内存管理处理缺页或回收 filesystem_tick(); // 文件系统延迟写回脏块 shell_collect(t); // 收集当前状态做统计 sleep_millis(1); // 模拟时钟周期便于观察 } }逻辑说明每个函数都只读当前current_tick不做跨 tick 的等待。sleep_millis(1)是内部封装用usleep(1000)实现参数是毫秒数注释里把它当作“时钟周期”实际调试时可以改成 10 毫秒让输出滚动慢一点。ticks来自命令行参数--tick控制模拟总时长。需要特别留意的是函数名中的tick和current_tick全局变量是两个概念前者是动词表示驱动一步后者是全局时间戳会被统计模块用来生成时间序列。这种设计有一个容易被忽略的好处因为所有模块都挂在同一个循环里打断点时不会出现“调度器在某个线程里跑内存管理在另一个线程里跑”的错乱情况。答辩时你可以在循环体一行行执行指着 PC 寄存器概念说“这里就是中断返回后回到用户态的位置”。当然真实内核里中断是具有异步性的这个模拟项目刻意把它改成了同步循环目的是降低课设难度这一点在答辩准备时可以说清楚。另外shell_collect(t)会在统计数组里记录每个进程的 finish_tick这是后期计算平均完成时间的唯一数据来源。统计数组也是一个全局 struct在ostrain.h里定义成固定大小最多支持 32 个进程超过之后会返回 ENOMEM这块也做了简化避免动态扩容代码干扰主线理解。3. 核心模块拆解从 PCB 到 inode 的微观实现三个子系统是这份课设的主体。下面按从“任务”到“存储”的顺序拆开看每一段代码都来自项目里对应的.c文件只是裁剪掉了无关的日志分支。3.1 进程调度PCB 与时间片轮转进程管理模块的核心是pcb_t结构体它代表一个可以被调度器感知的任务// include/ostrain.h typedef struct pcb { char name[8]; // 进程名仅用于日志 int pid; // 进程号从 0 开始递增 int need_time; // 还需要的 CPU 时间 int used_time; // 已使用的 CPU 时间 int time_slice_used; // 当前时间片内已用掉的 tick 数 int priority; // 静态优先级数值越小优先级越高 struct pcb *next; // 就绪队列中的下一个节点 } pcb_t;字段说明name限制为 8 字节因为测试用例里的进程名最长就 7 个字符加一个\0need_time是创建进程时从测试文件读入的time_slice_used专门用于时间片轮转算法每次调度 tick 都会自增当它等于全局宏TIME_SLICE时触发切换。next指针把就绪进程串成一个单向链表这是最朴素、也最容易给老师画图的队列实现。调度器的核心逻辑在scheduler_tick()里// src/scheduler.c void scheduler_tick(void) { pcb_t *current g_current; if (current NULL) { current pick_next(); // 队列空尝试取新任务 } if (current NULL) { return; // 所有任务都已完成 } current-used_time; current-need_time--; current-time_slice_used; if (current-need_time 0) { // 任务完成移出就绪队列 remove_from_queue(current); printf([tick%d] %s finished\n, current_tick, current-name); g_current NULL; } else if (current-time_slice_used TIME_SLICE) { // 时间片用完放回队尾切换给下一个进程 current-time_slice_used 0; enqueue(current); // 入队尾 g_current pick_next(); if (g_current ! NULL) { printf([tick%d] switch from %s to %s\n, current_tick, current-name, g_current-name); } } }逻辑说明每次 tick当前进程的used_time和time_slice_used都加一need_time减一。先判断任务是否结束结束就从队列删除再判断时间片是否用完用完就重置时间片计数并重新入队尾然后从队头取下一个进程。这里省略了真实的寄存器上下文切换用g_current指针的重新赋值来代替在课设讲稿里可以说“我们把上下文切换抽象成了 PCB 指针切换”。参数说明TIME_SLICE在编译期定义默认值是 5也就是每个进程一次最多跑 5 个 tickneed_time来自测试用例的第一列比如P0 5 2表示进程P0需要 5 个 tick 运行优先级为 2。如果你调小TIME_SLICE进程切换频率会变高日志里switch行明显增多这很适合用来演示时间片大小对响应时间的影响。3.2 内存管理伙伴系统与 LRU 页面置换内存模块在初始化时把物理内存分成 64 个 4KB 页框并用伙伴系统分配连续页框。伙伴系统的实现很短// src/memory.c static struct block free_list[MAX_ORDER 1]; int alloc_pages(int count) { int size 1; while (size count) size 1; // 向上取整到 2 的幂 int order 0; while ((1 order) size) order; for (int curr order; curr MAX_ORDER; curr) { if (free_list[curr] NULL) continue; struct block *blk free_list[curr]; free_list[curr] blk-next; // 把取出的块不断分裂直到达到需要的阶数 while (curr order) { curr--; // 把右半个块放入下一阶空闲链表 struct block *buddy (struct block *)((char *)blk (1 curr) * PAGE_SIZE); buddy-next free_list[curr]; free_list[curr] buddy; } return blk-index; } return -1; }逻辑说明free_list[curr]存储当前阶2^curr 个连续页框的空闲块链表。分配时从最小满足请求的阶开始向上找找到后把块分裂多出来的半块挂回低一阶的链表。参数count是请求的页框数经过前两行取整后转换成order如果所有阶都为空返回 -1 表示物理内存不足触发后续的 LRU 置换。当alloc_pages返回 -1 时内存管理模块会调用find_victim_page()选一个页淘汰int find_victim_page(void) { // 第一遍看有没有访问位为 0 的合法页有就直接选 for (int i 0; i PAGE_FRAMES; i) { if (page_table[i].valid !page_table[i].accessed_flag) { return i; } } // 第二遍退而求其次选 last_access 最老的一页 int oldest -1; for (int i 0; i PAGE_FRAMES; i) { if (!page_table[i].valid) continue; if (oldest -1 || page_table[i].last_access page_table[oldest].last_access) { oldest i; } } return oldest; }逻辑说明这是简化版 LRU没有维护完整的历史链表而是用accessed_flag做一次“时钟指针”扫描只要找到访问位为 0 的页就直接淘汰如果所有合法页都被访问过则退化成按last_access选最老的。参数PAGE_FRAMES在ostrain.h里定义为 64last_access在每次页表项被访问时更新成当前current_tick。调试这个模块时最常看的输出是 replace 日志。一个理想状态是当工作集明显大于 64 页时页面置换频率会保持稳定如果还是频繁抖动说明find_victim_page的选择标准过于简单。以后想加“增强型时钟算法”把这个函数替换成查一遍脏位和访问位的组合即可。3.3 文件系统模拟磁盘与 inode文件系统模块不依赖真实文件它用一块 4MB 的全局数组模拟磁盘用 inode 记录文件和块的对应关系。结构体定义如下// src/filesystem.c #define BLOCK_SIZE 512 #define DISK_BLOCKS 8192 static uint8_t virtual_disk[DISK_BLOCKS * BLOCK_SIZE]; typedef struct inode { uint16_t ino; char name[16]; uint32_t size; int blocks[8]; // 直接块索引最多 8 块 struct inode *next; // 哈希链表冲突链 } inode_t;字段说明DISK_BLOCKS8192 乘以BLOCK_SIZE512 正好 4MB这个大小在 bss 段分配不会占用栈空间blocks[8]是直接数据块索引意味着单个文件最大 4KB。别笑这是为了把“组织原理”讲清楚故意不做多级索引改造扩展点在文末。写入流程的核心函数是fs_writeint fs_write(uint16_t ino, const void *buf, uint32_t count) { if (count 0) return 0; inode_t *inode get_inode(ino); if (inode NULL) return -ENOENT; uint32_t max_size 8 * BLOCK_SIZE; if (inode-size count max_size) { return -EFBIG; // 单文件超出直接块上限 } // 按块写入每写入一块就分配一个空闲块号 uint32_t written 0; while (written count) { uint32_t len count - written; if (len BLOCK_SIZE) len BLOCK_SIZE; int blk alloc_simulated_block(); if (blk 0) return -ENOSPC; memcpy(virtual_disk[blk * BLOCK_SIZE], (const uint8_t *)buf written, len); inode-blocks[(inode-size written) / BLOCK_SIZE] blk; written len; } inode-size count; return count; }逻辑说明先通过 inode 号找到对应的 inode 对象再检查新文件大小是否超过 4KB 上限。写入采用按块循环每次从空闲块位图中取出一个块号用memcpy把用户缓冲区的数据拷进虚拟磁盘最后更新 inode 的块数组和文件大小。参数buf是源数据指针count是字节数返回值是实际写入字节数alloc_simulated_block()内部用位图记录块是否空闲分配失败返回 -1。在文件系统模块里目录实际上是一个特殊 inode它的blocks里存的是目录项的编码。fs_create会先创建一个空 inode再把它挂到全局 inode 链表中fs_delete则反向操作释放 inode 占用的所有虚拟磁盘块。这块代码保留在最朴素的版本和真实 ext2 的目录项相比它没有记录name_len所以读取目录时是按固定 16 字节大小跳着走的。你可能会问为什么不直接用fwrite写真实文件。道理很简单这个模块想模拟的内容是“磁盘块如何被数据层管理”而不是“如何调用标准库”。用内存数组做虚拟磁盘可以配合调试器直接查看某个块的内容也可以把断电丢失数据的特性放进讲稿里。顺带一提virtual_disk在 main 里没有初始化默认全是 0所以没写过数据的块读出来是0x00这在文件系统检查软件里会被判定为“空洞”是合法的。4. 常见问题与避坑记录调试三块模块时容易翻车的地方下面的问题都不是算法难度造成的大部分是 C 语言边界、文件格式和 Makefile 展开时机带来的。我把它们单独列出来是因为每一个都实际翻过车重跑一次至少浪费半天而且这类问题在答辩前才出现的话压力会非常大。4.1 进程名带\r调度输出乱码现象从测试文件读出的进程名输出到终端时名字后面多了一个^M而且strcmp(P0, name)永远不相等导致进程明明完成却不会从队列删除。原因测试用例.txt是在 Windows 上用记事本写的换行符是 CRLF。fscanf(%s)会把空格之间的字符串读进来\r残留在字符串尾部于是name实际是P0\r。解决在read_testcase里读入名字后做一次尾部清理。常见做法是判断最后一个字符如果是\r就把它替换成\0。// 读取 name 后立即清理 if (name[strlen(name) - 1] \r) { name[strlen(name) - 1] \0; }从那以后要求所有测试用例统一用.gitattributes强制 LF 换行省得每个人 clone 下来都遇到一遍。4.2 伙伴系统释放时不检查 magicdouble free现象内存管理模块跑几十个 tick 后输出block double free然后用 gdb 打断点发现释放相邻块时把同一个块释放了两次。原因释放函数free_pages里要做“伙伴合并”但只检查了相邻地址没有检查块状态。两个块都空闲时才能合并如果其中一个已经被分配出去另一个的free_list指针会乱掉后面再释放时把已释放的块又链入链表。解决给每个块头部加一个magic字段释放前先断言当前块状态必须是分配态。合并时再检查伙伴块的状态是否为空闲两个条件都满足才执行 merge。#define MAGIC_ALLOC 0xA110C #define MAGIC_FREE 0xF3E if (blk-magic ! MAGIC_ALLOC) { fprintf(stderr, double free detected\n); return -1; }调试这种问题最有效的是在free_pages入口加printf把块号、阶、magic 都打出来两行日志就能定位是哪一次释放出的错。4.3 就绪队列为空时解引用 NULL现象--tick200跑完后程序还在运行但pick_next()返回 NULL后面的日志访问了NULL-name导致段错误。原因调度器完成所有任务后就绪队列已经空了。但主循环仍会在每个 tick 调用scheduler_tick()此时g_current为 NULL后续代码没有提前退出。解决在scheduler_tick()最开头加一个队列空判断同时在pick_next()里也返回 NULL 保护。另外让shell_collect()只在g_current ! NULL时取样。if (ready_head NULL) { g_current NULL; return; }这不算高明但很多同学复制代码时容易漏掉这个边界条件。好转之后你会发现空转的 tick 不再崩日志尾部会一直打印no process更便于解释“空闲态”。4.4 Makefile 通配符缓存旧文件列表现象往src/里新增一个vfs.c后链接时疯狂报undefined reference to fs_mount但单独gcc这个文件能过。原因Makefile 里用的是SRCS : $(wildcard src/*.c)wildcard函数在 Makefile 解析时就会展开新增文件不会自动进到SRCS里所以make根本不会编译 vfs.c。解决改成SRCS : $(shell find src -name *.c)让 Makefile 每次运行时动态搜索源文件。注意这会让 make 每次启动都执行一次find文件不多时影响可以忽略。SRCS : $(shell find src -name *.c)最稳妥的办法还是显式列出源文件虽然麻烦但不会踩“改代码没生效”的坑。后来在 Makefile 里加了一个make clean调用rm -rf result每次新增文件先 clean避免日志里掺着旧输出。5. 二次开发把时间片轮询改成优先级抢占调度这个项目最值得改的地方就是调度器。当前实现是纯时间片轮转所有进程都在同一个队列里按 FIFO 次序执行。你可以把它改成“优先级抢占式”维持多个就绪队列每个队列对应一个优先级在每个 tick 里调度器只从优先级最高的非空队列取进程一旦有高优先级进程到来立刻抢占正在运行的进程。实现时只需要动两个函数。第一个是pick_next从单队列遍历改成多队列选择// 多队列版本 pick_next返回最高优先级队列的队头 pcb_t *pick_next(void) { for (int prio 0; prio MAX_PRIO; prio) { if (queues[prio] ! NULL) { return queues[prio]; } } return NULL; }第二个是入队函数从固定enqueue(current)改成按current-priority选择队列void enqueue_prio(pcb_t *p) { int prio p-priority; if (queues[prio] NULL) { queues[prio] p; } else { pcb_t *tail queues[prio]; while (tail-next ! NULL) tail tail-next; tail-next p; } p-next NULL; }逻辑说明queues[prio]是指针数组数组下标就是优先级数值越小优先级越高。pick_next从最低下标开始找第一个非空队列并返回队头。由于每个 tick 都调用pick_next只要高优先级队列非空低优先级进程永远不会被选中这就是“抢占”的实现。改完之后建议做一次对比实验用同一个01_rr_test.txt分别跑--modeprocess --tick50和不带优先级参数的版本。观察高优先级进程的平均完成时间。一个简单验证办法是让所有进程创建后直接运行然后统计每个进程的finish_tick - create_tick算出平均值。两个版本的平均完成时间应该差出 1.5 倍左右这个数字就是答辩时可以写进结论里的“性能提升”。如果你不想动调度器也可以改文件系统模块把blocks[8]改成blocks[12]并增加一个间接块索引字段模拟两级索引文件。实现量不大但能把磁盘组织从“连续文件”往“索引文件”推进一步。无论改哪块我的经验是先改一个参数跑出一个可对比的数值再动数据结构。这个课设做完我才意识到操作系统每做一个新特性都要立刻把scheduler_tick、memory_tick和filesystem_tick的集成跑一遍。从那以后我每次给项目加模块都强制自己先接上全局 tick 再写内部逻辑而不是等所有代码写完才去想到底从哪里开始调。希望帮到你。本文还有配套的精品资源点击获取