☰
用C语言实现进程调度模拟器:操作系统课程设计全记录
2026/10/13 23:58:33 网站建设 项目流程

用一句话说,这门课设计就是把你对操作系统零散的认识一次性串起来——从进程怎么产生、怎么调度、怎么互斥,到内存怎么分配、文件系统怎么组织,全都要落到代码上。我这次选的题目是进程调度模拟器,用纯 C 在 Linux 环境下实现了一个多调度算法内核模块,跑了完整测试,虽然过程中被指针折磨得不行,但整体下来收获确实比空看书大太多。这篇文章就分享一下整个课程设计的完整过程:从选题、架构、编码到测试和答辩,踩过的坑和最后沉淀下来的经验都写出来,希望能给正在做或者准备做系统方向课程设计的同学一些参考。

1. 课程设计常见选题与选型逻辑

1.1 三类经典选题盘点

操作系统课程设计目前接触到的项目方向大致分三类:模拟器类、内核模块类、驱动/嵌入式类。

模拟器类最常见,典型题目是实现一个进程调度模拟器或者内存分配模拟器,不需要真实操作系统参与,自己定义数据结构、自己控制时间轴,核心考察的是对教科书算法的理解程度。这类项目工作量适中,适合刚开始接触系统编程的同学,也容易调试和演示。

内核模块类是进阶方向,在真实 Linux 内核上写一个可加载内核模块,比如拦截系统调用、实现一个简单的字符设备驱动、或者自定义一个内核线程调度实验。这类项目需要懂内核态和用户态的区别,需要会看内核日志,还要处理模块加载失败的问题,踩坑成本比模拟器高,但写进简历和答辩材料里面也更硬。

驱动/嵌入式类则跟具体硬件绑定,比如开发板上的按键驱动、LED 时序控制、屏幕刷新,这通常需要额外的开发板和交叉编译环境,门槛高一些,如果实验室没配套设备,不建议新手选这个方向。

1.2 选题怎么选才不吃亏

我的建议是:不要盲目追求难度,先看自己的时间预算和调试环境。

如果距离答辩只有四周左右,优先选模拟器类,把算法做扎实、数据可视化做好,同样能拿高分。如果选了内核模块,一定要提前确认内核头文件、编译工具链、虚拟机的环境是否可用——我见过很多同学在加载模块时卡在“Invalid module format”上,最后不得不重装内核头文件,浪费一整周。

另外有个容易被忽略的点:选题最重要是让自己能“讲清楚”。答辩老师最常问的就是“为什么这么设计”,如果只是代码抄下来跑通,一旦被追问数据结构选型的原因就答不上来。选一个自己真正理解每个细节的方向,远胜于选一个看起来很炫但说不清原理的方向。

2. 以“进程调度模拟器”为例的整体架构设计

2.1 需求拆解与模块划分

我选的题目是实现一个支持多调度算法的进程调度模拟器,要求支持先来先服务(FCFS)、短作业优先(SJF)、时间片轮转(RR)、优先级调度,以及多级反馈队列(MLFQ)。输入是一组模拟的进程,包含到达时间、服务时间、优先级三个参数,输出是调度顺序、平均周转时间、平均带权周转时间、平均等待时间。

功能上拆成四个模块:

  • 进程控制块(PCB)管理模块:负责创建、初始化、销毁 PCB 结构体;
  • 调度器模块:每种调度算法一个核心函数,接收就绪队列,返回当前应运行的进程;
  • 时间推进模块:模拟时钟 tick,驱动整个调度过程前进;
  • 统计输出模块:记录每次调度事件,计算指标并输出甘特图样式的时间轴。

这四个模块分开写,最大的好处是调试时能单独验证每个部分。比如时间推进模块出了问题,直接看日志即可锁定,不会把调度算法的逻辑也牵扯进来。

这个设计其实是参考了真实操作系统的分层思想:调度器不应该关心 PCB 内部的细节,PCB 管理模块也不应该管时间怎么推进。界面层虽然只是控制台输出,但我在结构上预留了一个回调接口,方便后面接到图形界面。

2.2 数据结构设计与核心参数设定

PCB 是最核心的数据结构,定义了以下字段:

typedef struct pcb { int pid; // 进程ID int arrive_time; // 到达时间 int service_time; // 要求服务时间 int remain_time; // 剩余服务时间 int priority; // 优先级,数字越小优先级越高 int start_time; // 首次运行时间 int finish_time; // 完成时间 int is_finished; // 是否执行完成 struct pcb *next; // 就绪队列链表指针 } PCB;

其中 remain_time 的设计是最关键的:它既承担了时间片轮转的递减计数器职责,也承担了抢占式调度中判断进程是否需要被换出的依据。第一次写完时我还统计过 start_time 和 finish_time 都默认初始化为 -1,后面计算平均周转时间时统一判断,避免未运行进程被算进分母里。

就绪队列用单链表实现,这里没有选择数组主要是因为要频繁做“取出进程”“插入新进程”操作,链表只需改指针,而数组需要移动元素。虽然都是几十个进程量级,结构上选链表更贴近内核实现习惯,对答辩证知识也有帮助。

模拟参数上,我设定了默认时间片为 2 个单位时间,进程数量默认 10 个,每个进程的到达时间范围是 0 到 8,服务时间范围是 1 到 10,优先级范围是 1 到 5。这些参数不是随便定的,要确保到达时间范围内进程能错开,同时服务时间不能太小(不然看不出调度算法之间的差异)。

2.3 为什么用纯 C 而不是其他语言

课程设计要求里明确写了“体现数据结构与指针使用”,这也提示了用 C 语言。实际上用 C 实现调度模拟器有一个比高级语言更占优势的地方:你必须手工管理所有队列、内存和指针,这逼着你把数据结构的细节彻底搞清楚。

如果用 Python 写,列表自带 pop 和 insert,队列逻辑被封装掉一大半,虽然省事,但答辩时老师问“队列里按照什么规则插入新进程”你很可能答得模棱两可。用 C 的链表,每个节点的插入删除都自己要维护前驱后继指针,答起问题来会有底气得得多。

这里必须提一个实际的工程理由:C 在模拟指针引用时能更直观对应到真实内核的实现方式,内核里的 task_struct 本质上也维护了链表关系。如果后续想扩展到内核模块,C 是唯一选项。

3. 核心调度算法的实现细节与关键代码

3.1 FCFS:最简单的队列维护

FCFS 基本上没有算法逻辑,只要维护一个 FIFO 就绪队列即可。

关键点在于到达时间的处理:模拟开始时,并非所有进程都已经就绪,需要一个“未来事件队列”来暂存尚未到达的进程。我实现时维护了 ready_queue 和 pending_list 两个结构,每一轮时钟推进时先把 pending_list 中 arrive_time <= current_time 的进程取出,按到达顺序挂到 ready_queue 尾部。

void move_arrived_processes(LinkQueue *ready, LinkQueue *pending, int current_time) { LinkQueueNode *p = pending->front; while (p != NULL && p->data.arrive_time <= current_time) { enqueue(ready, p->data); // remove from pending p = pending->front; } }

这段代码看着简单,但有个容易犯的低级错误:在链表中一边遍历一边删除当前节点,必须先把 next 指针存下来,否则删除当前节点后遍历指针就悬空了,后面访问 p->next 就是野指针。我在第一版代码里就踩过这个坑,表现为程序在进程数超过一定数量后随机崩溃。

FCFS 的统计指标通常是最差的,尤其是平均等待时间,因为短作业排在长作业后面会被无辜拖延。这个结果本身也是答辩里可以展开的点:FCFS 对交互型系统不友好,所以现代操作系统不会单独使用它,而是作为其他算法的后备策略。

3.2 SJF 与抢占式短作业优先的实现

SJF 每次选择服务时间最短的进程执行,核心是在就绪队列中用“服务时间”作为排序键。可以每次调度时遍历整个队列找出最小者,也可以维护一个优先队列。因为进程数量少,我直接采用遍历,时间复杂度 O(n),但代码更直观。

PCB *find_shortest_job(LinkQueue *queue) { LinkQueueNode *p = queue->front; PCB *best = NULL; while (p != NULL) { if (p->data.is_finished) { p = p->next; continue; } if (best == NULL || p->data.service_time < best->service_time) { best = &(p->data); } p = p->next; } return best; }

这里有个隐藏的决策点:SJF 是非抢占式,但实际模拟时如果进程在运行中,又来了一个更短的进程,是否要抢占当前进程?我在需求文档里明确设计了非抢占式版本,也就是当前进程只要开始运行,就一直运行到完成,不做切换。抢占式版本叫 SRTF(最短剩余时间优先),区别在于选择标准变成了 remain_time,而不仅仅是总服务时间。

实现 SRTF 时,我每次 tick 都判断一次“当前运行的进程剩余时间是否仍是最小的”,如果不是就触发 preempt。这个判断看起来简单,但必须保证被抢占的进程还能回到就绪队列,并且 remain_time 不能清零,否则进程就被错误销毁了。

实际测试中我对比过 SJF 和 SRTF 的指标,SRTF 的平均周转时间通常更优,但进程切换次数显著增加,这正好引出一个重要的参考结论:调度算法的“最优”是有代价的,系统要权衡切换开销。

3.3 RR 时间片轮转:时钟中断模拟

时间片轮转的核心是一个合法的“闹钟”机制:每过一个时间片,当前进程就被强制让出 CPU,回到就绪队列尾部。

实现逻辑是:全局维护一个 current_time 变量,每次时间递增执行以下流程:

  • 如果当前有运行进程,让它的 remain_time 减一;
  • 如果 remain_time == 0,标记进程完成,记录完成时间,调度下一个进程;
  • 如果累加到 time_slice 次,说明当前进程的时间片耗尽,把它放回队尾,重新调度。
void tick_schedule(LinkQueue *ready, PCB **running, int time_slice) { static int tick_counter = 0; if (*running != NULL) { (*running)->remain_time--; tick_counter++; if ((*running)->remain_time == 0) { (*running)->finish_time = current_time; (*running)->is_finished = 1; *running = NULL; tick_counter = 0; } else if (tick_counter % time_slice == 0) { enqueue(ready, **running); *running = NULL; tick_counter = 0; } } if (*running == NULL) { *running = dequeue(ready); } }

这段代码最大的隐患是 static tick_counter。如果把时间片计数逻辑简单地定义为“运行 time_slice 次后换人”,那每次调度新进程都重置计数器,遇到进程正好在时间片边界完成时会少触发一次切换,后续统计结果会偏少。实际内核做法是在进程描述符里存一个 policy 字段和一个 counter 字段,切换进程后 counter 重新装载,我最终也迁移到了 PCB 内部字段而不是全局 static。

RR 算法的时间片取值是同学最容易忽略的参数:时间片太大会退化成 FCFS,太小则切换开销暴增。我默认设 2,对比 1 和 4 的结果发现周转时间并非线性变化,这一点答辩时可以作为附加的探究结论放进去。

3.4 优先级调度与多级反馈队列

优先级调度我也实现了抢占式版本,规则是“新到达或更高优先级的进程可以抢占当前运行的低优先级进程”,打破了 FCFS 的公平性。核心是每次 tick 都扫描就绪队列找优先级最高的进程,和当前 running 进程比较一下优先级数字,如果 running 的 priority 更大,就把它换出。

优先级倒置问题是个值得在报告中强调的点:设想一个高优先级进程等待低优先级进程持有锁,而低优先级进程又被中优先级进程抢占了 CPU,结果高优先级进程被间接卡住。我在模拟器里没有实现锁机制,但我在报告里专门写了一段分析,说明真实系统引入优先级继承或优先级置顶来解决这个问题。这个点答辩时老师非常喜欢追问,可以作为加分项准备。

MLFQ 是综合实现,核心设计三个队列:

  • 第 0 队列:时间片 1,轮转调度,优先级最高;
  • 第 1 队列:时间片 2,轮转调度;
  • 第 2 队列:时间片 4,FCFS。

规则是:新进程进入第 0 队列;如果在第 i 队列时间片耗尽还没执行完,降级到 i+1 队列;高优先级队列只要存在进程,低优先级队列就不被调度。存入进程时除了 PCB 本身,还需要扩展一个 queue_level 字段记录当前所在队列,这个字段很关键。

实现 MLFQ 后算法调度次数显著增多,输出日志量暴增,因此我必须额外整理一份统计输出。后来我在这个模块上做了个增强:根据进程运行历史动态调整优先级(类似 Linux 的交互性评分),这个属于可选的扩展点,时间充裕的同学值得一试。

3.5 统计指标的计算与输出

每个进程完成后都会累计总周转时间、总等待时间。周转时间 = finish_time - arrive_time,带权周转时间 = 周转时间 / 服务时间,平均等待时间 = 平均周转时间 - 平均服务时间。

这个公式不难,真正容易出错的是“首次运行时间”的确认。SJF 和 RR 都会出现进程被多次切出切进的情况,wait_time 必须从“从未运行”和“等待后重新被调度”两个维度累计。我把等待时间累积放在调度器外层:每次把一个进程从就绪队列摘下时,先把它上次被放回队尾的时间记下来,再用当前时间减掉,这样才能准确累计。

最终输出模块用的是简单的控制台格式化:

printf("%-4s %-6s %-6s %-6s %-6s %-6s\n", "PID", "到达", "服务", "完成", "周转", "带权");

加了一个小功能是把调度顺序打印成竖排时间轴,每条事件一行,时间点对齐,答辩 PPT 里截图非常好看。

4. 测试驱动开发与调试踩坑实录

4.1 测试用例怎么设计才不算糊弄

课程设计如果只跑一组默认数据就说“测试完成”,答辩时大概率被问倒。我设计了三类用例:

  • 边界用例:进程数为 1,所有进程同时到达,所有进程服务时间为 1;
  • 特殊到达模式:进程完全错峰到达,每两个进程到达间隔大于一个进程运行时间;
  • 压力用例:进程数 200,到达时间随机,服务时间随机,比较多种算法的差异。

第一类边界用例最容易暴露空指针错误。比如进程数为 1 时,就绪队列可能为空,调度函数如果直接取队头而不判空就会崩溃。我最初就漏了这种判空,加上边界用例后立刻暴露。

第二类错峰到达用例用来验证“未来事件队列”的逻辑。如果 pending_list 里的进程没有被及时挂入就绪队列,就会出现 CPU 空闲的假象,平均周转时间计算结果离谱。

第三类压力用例则用来衡量算法稳定性。当时 200 个进程同时跑,FCFS 平均等待时间大约是 90 多,SJF 是 60 多,RR 的时间片是 2 时也能到 70 多,这些数据后来在报告里直接用折线图展示。

4.2 调试中的三个顽固 Bug

第一个是野指针问题,出现在 move_arrived_processes 的删除节点代码。前面提到过,删除链表节点后继续用旧指针遍历,属于“use-after-free”。解决方式是把 next 提前存到临时变量里,调试方式是开 AddressSanitizer 编译:用-fsanitize=address编译后,程序崩溃时能定位到具体行号。

第二个 Bug 是 RR 算法顺序颠倒。具体表现是调度顺序跟预期不符,被切换到 CPU 的进程反而滞留在运行指针上,没有放入 ready 队列。仔细排查下来,发现 enqueue(ready, temp) 和 dequeue(ready) 的调用顺序反了:应该先取队头进程放到 running,再把旧 running 进程放回队尾。如果不先保存旧 running 指针,执行 enqueue 后 running 指针被覆盖,进程就平白消失了。

第三个 Bug 是在统计平均等待时间时,我把每个进程已经运行过的时间段也算成了等待时间,导致结果偏小。解决方法是在每个进程的调度周期里维护 last_move_in 时间戳,每次进程被摘下 CPU 时把current_time - last_move_in累加进 wait_time,保证只累加“在就绪队列等待”的时间段。

调试经验总结起来一句话:先让程序跑出确定性的固定输出,再引入随机数据,不要一开始就在随机参数上排查问题。我的做法是先固定一组测试数据,打印每个时刻 CPU 状态,和手推的结果对照,等一致后再换随机组。

4.3 Makefile 与自动化测试脚本

工程里还写了一个 Makefile 管理编译流程,核心命令是:

CC = gcc CFLAGS = -Wall -g -fsanitize=address TARGET = scheduler_simulator OBJ = main.c pcb.c scheduler.c stats.c all: $(TARGET) $(TARGET): $(OBJ) $(CC) $(CFLAGS) -o $(TARGET) $(OBJ) clean: rm -f $(TARGET) *.o run: $(TARGET) ./$(TARGET) --input test1.txt --algorithm rr

自动化测试脚本用 shell 写了一堆不同参数的调用,跑完后 grep 关键字对结果做断言。这个脚本虽然简陋,但代替手敲测试目录里的十几个命令,节省了大量时间。写自动化测试还有个隐藏好处:答辩演示时,直接运行测试脚本证明代码正确性,比在终端手工跑一组数据更有说服力。

5. 答辩准备、文档整理与经验复盘

5.1 答辩高频问题与应对思路

答辩老师问的问题通常集中在几类:

  • 为什么选择这种数据结构?
  • 你的调度算法在什么条件下表现最优/最差?
  • 进程切换的代价是什么?模拟器里有没有建模这种代价?
  • 如果把算法移植到真实内核,你觉得需要改哪几个模块?

第三问是我觉得最难回答的。我的调度器里进程切换是零代价的,而真实系统里切换等于要保存上下文、加载新上下文、可能还要刷新 TLB、处理 cache 失效。因此我准备了一个表格:切换次数越多的算法(比如 RR 时间片为 1),在模拟里的周转时间可能比 FCFS 好,但真实系统中未必,因为切换开销被忽略了。这个对比讲清楚以后,老师会认为你确实考虑了模拟和现实之间的鸿沟。

第四问是我准备最多的,我的思路是把调度器的核心逻辑抽离出来,在 Linux 内核里实现一个钩子函数测量每个进程的实际运行时间,然后替换掉 CFS 的调度策略。虽然时间不够实际写完这个模块,但我把伪代码和接口设计画清楚了,印在文档附录里。

5.2 设计文档怎么组织

课程设计报告我建议按这个顺序组织:需求分析、总体设计、详细设计、测试与分析、总结展望、参考文献、附录代码。其中总体设计画一张模块图,详细设计放核心数据结构和每个算法的伪代码,测试与分析部分放数据表和折线图,附录放完整代码清单。

报告不必写得像教科书那么长,反而要把“你自己改动过的设计点”讲清楚。比如我在需求里加入了“同时支持非抢占和抢占模式”,这个点不在最原始的需求清单里,是我自己扩展出来的。这类扩展点才是展示个人思考深度的地方。

另外细节规范很重要:代码缩进、函数注释、变量命名风格统一。我见过报告正文写得很漂亮,但附录代码缩进混乱、变量名全是 a/b/c 的情况,答辩老师翻到那页基本就皱眉了。代码质量也是评分的一部分,别忽略。

5.3 做过这一轮之后,我会怎么调整

如果再做一次这门课程设计,我会做两个调整:一是加入简单的互斥锁场景,让多线程调度和锁竞争体现在模拟器里;二是用 ncurses 做一个实时进度面板,展示各就绪队列状态的变化。这些扩展点虽然工作量不小,但能让项目完整度和展示效果再上一个档次。

再有就是测试绝对要提前写。第一版代码我几乎是写完逻辑才开始补测试,导致后面改结构时回归测试全要靠手点,非常痛苦。如果下次从第一行代码开始就写单元测试,整个开发周期会缩短三分之一。

这次课程设计整体下来,最大的体会是:操作系统里的算法看起来都不难,但一旦要求你写出能跑、能统计、能比较的程序时,细节才是真正拉开差距的地方。很多坑在课本上不会提到,比如链表节点的悬空指针、调度顺序和计数器、时间片边界的处理,这些东西必须亲手写一遍才能真正记进脑子里。如果这篇文章对正在做课程设计、或者准备做系统模拟项目的你有帮助,哪怕只是少踩一个链表 bug,也值了。

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

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

立即咨询