ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言模拟操作系统:从时间片轮到LRU,一份可运行的课设全解析

C语言模拟操作系统:从时间片轮到LRU,一份可运行的课设全解析

简介:东北大学操作系统课程设计完整实现,围绕进程管理、内存管理、文件系统与设备管理四大核心模块展开,适合计算机专业学生作为课设参考,也可用于操作系统原理的实践复习。资源共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 -std=c99

-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 --mode=all --tick=200

参数说明:--mode控制要运行哪个子系统,可选process、memory、fs、all;--tick指定模拟多少个时间片,默认 100。比如只要看文件系统,就跑./ostrain --mode=fs --tick=50,输出会只保留与文件读写相关的日志。测试阶段用--mode=all最合适,让三个子系统在同一时间源下协同,观察它们是否互相干扰。

提示:如果你在 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 内的步骤是:

  1. current_tick自增,作为全局时间戳。
  2. scheduler_tick()检查当前进程时间片是否用完,用完则换下一个。
  3. memory_tick()处理缺页请求,如果物理内存不足就调用 LRU 淘汰一页。
  4. filesystem_tick()把脏掉的磁盘块延迟写回虚拟磁盘。
  5. 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 个字符加一个\0;need_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 个 tick;need_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里定义为 64,last_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_write:

int 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 伙伴系统释放时不检查 magic,double 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

现象:--tick=200跑完后程序还在运行,但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,分别跑--mode=process --tick=50和不带优先级参数的版本。观察高优先级进程的平均完成时间。一个简单验证办法是让所有进程创建后直接运行,然后统计每个进程的finish_tick - create_tick,算出平均值。两个版本的平均完成时间应该差出 1.5 倍左右,这个数字就是答辩时可以写进结论里的“性能提升”。

如果你不想动调度器,也可以改文件系统模块,把blocks[8]改成blocks[12]并增加一个间接块索引字段,模拟两级索引文件。实现量不大,但能把磁盘组织从“连续文件”往“索引文件”推进一步。无论改哪块,我的经验是:先改一个参数,跑出一个可对比的数值,再动数据结构。这个课设做完我才意识到,操作系统每做一个新特性,都要立刻把scheduler_tick、memory_tick和filesystem_tick的集成跑一遍。从那以后我每次给项目加模块,都强制自己先接上全局 tick 再写内部逻辑,而不是等所有代码写完才去想到底从哪里开始调。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进