简介:操作系统进程管理实验(C语言实现)是一份面向高校计算机专业学生的课程实验资料,以C语言和Unix/Linux系统调用为核心,覆盖进程创建、同步、通信与调度等关键机制,帮助读者将“进程是程序的一次执行实例”等理论与fork、exec、wait等实际用法打通。压缩包内共9个文件,以.c/.h源码、Code::Blocks工程文件(.cbp/.layout/.depend)为主,同时附带.o目标文件和.exe可执行程序,便于直接打开工程查看、编译和运行;整体仅19KB,小巧但内容完整。实验涉及信号量、管道、消息队列、共享内存等同步通信方式,以及FCFS、短进程优先、时间片轮转等调度算法,并覆盖死锁预防与检测等典型问题,代码结构清晰,可作为操作系统实验、课程设计或考前复习的实用参考。目前已有4169人学习下载,对于正在准备进程管理模块实验的同学具有直接借鉴价值。
1. 操作系统进程管理实验为什么值得认真重写一遍
如果你正在学操作系统这门课,那“进程管理实验(C语言实现)”大概率是逃不掉的一次作业——不管用的是Linux环境还是Windows上的模拟框架,核心都是同一件事:用C语言把进程控制块(PCB)、就绪队列和调度算法在用户态“演”出来。很多同学交完实验就忘了,觉得这只是应付学分;但真实情况是,这道题把整个操作系统最抽象的一层——进程生命周期——压成了一个几千行的C程序,你把它写明白了,后面学虚拟存储、文件系统都会顺很多。
这个实验要解决的痛点非常具体:进程不是“正在运行的程序”,它是被PCB记录下来、被队列组织起来、被调度算法选中才获得CPU的一个实体。你在C语言里没法真的去创建内核进程,但你可以模拟这一整套机制——用结构体当PCB,用链表当就绪队列,用你写的调度函数当操作系统的进程调度器。适合谁做?正在修《计算机操作系统》或Linux课程的本科生,以及想补课的在职开发者。这篇文章按我的习惯,从PCB设计一路讲到调度算法的参数调优,中间穿插我实际踩过的坑。动手之前提醒一句:别急着写代码,先把PCB里要放哪些字段想清楚,否则后面改结构体等于重构。
2. 把PCB和就绪队列搭出来:C语言里进程管理的骨架
2.1 PCB里到底该放什么字段:从教科书到可运行代码
教科书上写PCB包含进程标识符、状态、优先级、CPU现场保护区等,听起来很抽象。落到C语言里,你要面对的第一个决策就是:PCB结构体设计成什么样。我见过很多新手实验把PCB写得特别大,什么进程组、会话ID都塞进去,结果调度算法还没写,先被一堆无关字段拖累。
我一般会遵循“最少够用”原则。能支撑起进程创建、就绪、运行、阻塞、终止这五个状态转换的字段,才是这个实验的必需品。我的最小结构体是这样:
typedef struct pcb { int pid; // 进程ID,从1开始递增 char name[16]; // 进程名,方便调试输出 int state; // 0-就绪 1-运行 2-阻塞 3-终止 int priority; // 优先级,数字越大优先级越高 int need_time; // 还需要运行的CPU时间片数 int used_time; // 已经运行的时间片数 struct pcb *next; // 链表指针,指向队列中下一个进程 } PCB;这段代码的逻辑很直观:pid是进程的唯一标识,state记录状态,priority给优先级调度用,need_time和used_time是调度器做判断的核心数据——一个进程什么时候能结束,就看used_time是否追上了need_time。next指针让PCB变成链表节点,就绪队列就是用这种单向链表实现的。
参数说明:need_time的单位我建议用“时间片”而不是“秒”。因为模拟调度器里一般没有真实时钟,常见做法是用循环次数或者用户输入的整数来代表时间片。你如果把它定义成秒,和后面的时间片轮转算法配合起来会非常别扭。
2.2 就绪队列的三种实现:数组、链表和“你其实只需要链表”
就绪队列的实现方式,决定了你后面调度算法的写法。数组实现最简单,但删除中间节点要移动数据,时间复杂度高;链表实现最自然,插删都在O(1);还有一种是用固定大小的数组配合位图标记,那是给真实内核用的,实验里没必要。
我推荐直接用单向链表,原因有两个。第一,FCFS(先来先服务)是在队尾插入、队头取出,链表天然适配;第二,SJF(短作业优先)需要按need_time排序插入,链表只要找到合适的位置插进去就行,不用移动其他节点。
void enqueue(PCB **head, PCB *proc) { if (*head == NULL) { *head = proc; proc->next = NULL; return; } PCB *cur = *head; while (cur->next != NULL) { cur = cur->next; } cur->next = proc; proc->next = NULL; } PCB *dequeue(PCB **head) { if (*head == NULL) return NULL; PCB *proc = *head; *head = proc->next; proc->next = NULL; return proc; }enqueue是把进程节点追加到队尾,dequeue是从队头取走进程。这里的head用的是二级指针,因为你要修改头指针本身。很多新手在这里写成一级指针,函数返回后发现链表没变,就是因为指针传递是按值传递的,你改了形参的指向,实参并不知道。
如果你做的是SJF或优先级调度,enqueue要改成按need_time或priority排序插入。不要单独为每种算法写一套队列函数,更好的做法是让enqueue接收一个比较函数指针,这样一套队列代码服务所有调度算法。
2.3 进程创建与终止:init进程和exit流程怎么处理
操作系统的第一个进程是init进程(PID为1),你的模拟程序里也要有一个“起始进程”。常见做法是在main函数最开始手动创建一个PCB作为初始就绪队列的第一个节点,后面通过fork操作来创建新进程——注意,这里的“fork”不是Linux系统调用,而是你自己的create_process函数,它做的事情是:分配PCB内存、填充字段、把新PCB放进就绪队列。
PCB *create_process(int pid, const char *name, int priority, int need_time) { PCB *proc = (PCB *)malloc(sizeof(PCB)); if (proc == NULL) { perror("malloc failed"); exit(EXIT_FAILURE); } proc->pid = pid; strncpy(proc->name, name, sizeof(proc->name) - 1); proc->name[sizeof(proc->name) - 1] = '\0'; proc->state = 0; // 新进程创建后进入就绪态 proc->priority = priority; proc->need_time = need_time; proc->used_time = 0; proc->next = NULL; return proc; }这里有个细节值得注意:strncpy之后手动给字符串数组最后一个位置写\0。很多入门教材只用strcpy,但如果name字段长度不够,strcpy会越界写内存,这个bug在实验数据量小的时候不发作,一旦你测试十几二十个进程,就等着看“段错误”吧。
进程终止的逻辑正好相反:把PCB的状态改成终止态,输出一条记录,然后free这块内存。千万注意不要让调度器再访问已经free的节点。我在后面避坑章节会再讲这个,先记在心里:终止进程要做的不是简单修改状态,而是“移出所有队列 + 释放内存 + 防止二次访问”。
3. 让调度算法跑起来:FCFS与SJF的实现差异
3.1 FCFS先来先服务:用队列就能跑通的最小调度器
FCFS(First Come First Serve)是最直观的调度算法:谁先到就谁先运行,运行到结束为止。它的核心逻辑用一个循环就能描述:从就绪队列取出队头进程,让它运行need_time个时间片(模拟一次性运行完),然后把它置为终止态,继续取下一个。
void fcfs_schedule(PCB **ready_queue) { int current_time = 0; while (*ready_queue != NULL) { PCB *proc = dequeue(ready_queue); printf("时间 %d: 进程 %s (PID=%d) 开始运行 ", current_time, proc->name, proc->pid); current_time += proc->need_time; proc->used_time = proc->need_time; proc->state = 3; // 终止 printf("时间 %d: 进程 %s 运行结束,耗时 %d 个时间片 ", current_time, proc->name, proc->need_time); print_ready_queue(*ready_queue); free(proc); } }这段代码的逻辑主线是:只要就绪队列非空,就取队头进程,一次性运行完它的全部需要时间,然后打印时间线、释放PCB。print_ready_queue是一个辅助函数,打印当前就绪队列里还有哪些进程,方便你观察调度顺序。
有个参数值得你注意:current_time是模拟出的系统时间,它不是真实时间的流逝,而是进程占用CPU的时间累加。这种设计有一个好处——调试时你可以精确算出每个进程的完成时间和平均等待时间,然后和手算结果对比,验证调度器逻辑是否正确。我是建议你在这个循环里,每调度一个进程就打印一次当前时间,别偷懒,这个输出是你后面写实验报告的重要素材。
3.2 SJF短作业优先:按需排序的插入逻辑
SJF(Shortest Job First)和FCFS唯一的区别,就在于就绪队列的插入策略。FCFS是追加到队尾,SJF是按need_time从小到大排序插入。所以你的enqueue函数要改成“有序插入”。
void enqueue_sjf(PCB **head, PCB *proc) { if (*head == NULL || proc->need_time < (*head)->need_time) { proc->next = *head; *head = proc; return; } PCB *cur = *head; while (cur->next != NULL && cur->next->need_time <= proc->need_time) { cur = cur->next; } proc->next = cur->next; cur->next = proc; }这段代码的逻辑是:如果新进程比队头进程的need_time还短,新进程直接成为队头;否则从头遍历队列,找到第一个比新进程运行时间长的节点,把新进程插在它前面。这样队列始终保持按need_time升序排列,调度时依然是取队头,就实现了“短作业优先”。
这里有个排序稳定性问题值得说:如果两个进程的need_time相同,上面的插入逻辑会把新进程插入到相同时间节点的后面(因为条件是<=才继续遍历),这保持了FCFS的公平性。你如果改成<,后插入的同时间进程反而排前面,这在实验报告里解释起来会很麻烦。我建议保留<=,让SJF在时间相同的情况下退化为FCFS。
3.3 平均等待时间对比:用同一组输入验证两种算法
写实验报告时,讲师一般会要求你对比不同调度算法的性能。这时候你需要一组固定的测试数据,跑不同算法,记录每个进程的完成时间和等待时间。我常用的测试数据集设计是:5个进程,need_time分别是5、3、8、1、4,到达时间都设为0。
下面这段代码是模拟运行结束后计算平均等待时间的片段。等待时间 = 完成时间 - 到达时间 - 运行时间(所有进程到达时间为0时,简化为完成时间减去运行时间)。
typedef struct result { int pid; int finish_time; int wait_time; } Result; void calc_wait_time(Result results[], int n) { float total_wait = 0; for (int i = 0; i < n; i++) { results[i].wait_time = results[i].finish_time - need_times[i]; total_wait += results[i].wait_time; printf("PID=%d 完成时间=%d 等待时间=%d ", results[i].pid, results[i].finish_time, results[i].wait_time); } printf("平均等待时间: %.2f ", total_wait / n); }注意这里的need_times数组需要和调度时的need_time保持一致,我建议用一个全局数组存储,避免函数间传递参数出错。FCFS的平均等待时间你可以手工算出来验证:调度顺序是5→3→8→1→4(按PID顺序),完成时间分别是5、8、16、17、21,等待时间分别是0、3、6、1、4,平均2.8。SJF的顺序是1→3→4→5→8(按运行时间排序),平均等待时间会更短。这个“更短”不是一个模糊的感觉,而是可以手算验证的确切数值——建议你跑完代码后拿着计算器核对一遍,能对上就说明逻辑没错。
4. 抢占与时间片:把轮转和优先级调度做对
4.1 时间片轮转RR:时钟中断的模拟方式
时间片轮转(Round Robin,RR)是FCFS的改进,每个进程只能连续运行一个时间片的时长,然后被强制切换。这里的核心是“时钟中断”的模拟——你要在调度循环中维护一个计数器,当进程运行时间累计到一个时间片时,把它重新放回就绪队列队尾,换下一个进程运行。
#define TIME_SLICE 2 void rr_schedule(PCB **ready_queue) { int current_time = 0; while (*ready_queue != NULL) { PCB *proc = dequeue(ready_queue); int run_time = (proc->need_time - proc->used_time) < TIME_SLICE ? (proc->need_time - proc->used_time) : TIME_SLICE; proc->used_time += run_time; current_time += run_time; printf("时间 %d: 进程 %s 运行 %d 个时间片,剩余 %d ", current_time, proc->name, run_time, proc->need_time - proc->used_time); if (proc->used_time >= proc->need_time) { proc->state = 3; // 终止 printf("进程 %s 完成 ", proc->name); free(proc); } else { proc->state = 0; // 重新变为就绪态 enqueue(ready_queue, proc); // 放回队尾 } print_ready_queue(*ready_queue); } }这个实现里TIME_SLICE是你要调的核心参数。我见过有人把TIME_SLICE设成和最大进程运行时间一样大,那RR就退化成FCFS了;设成1,上下文切换开销会特别大(虽然是模拟的),但能明显看到进程轮转的效果。我的建议是设成2或3,既能看出轮转效果,又不会让输出刷屏。
特别注意run_time的计算:当进程剩余时间不足一个时间片时,只让它运行剩余时间,而不是强制运行满一个时间片。这个细节很多网上代码都漏了,结果就是进程“超额运行”,虚拟时间对不上。
4.2 优先级调度:抢占式与非抢占式的实现差异
优先级调度比RR多一点决策逻辑:每次调度时,不一定是队头进程运行,而是从就绪队列里挑优先级最高的那个。非抢占式是当前进程运行到结束才重新选;抢占式是只要有更高优先级的进程进入就绪队列,当前进程立刻被换下。
PCB *select_highest_priority(PCB *queue) { PCB *best = queue; PCB *cur = queue; while (cur != NULL) { if (cur->priority > best->priority) { best = cur; } cur = cur->next; } return best; }这个函数是优先级调度的核心,它遍历整个就绪队列找出priority最大的节点。注意它返回的是节点指针,但你还需要知道它的前驱节点才能把它从链表里摘除。我常见的做法是把这个选择和删除合并成一个函数:遍历时同时记录前驱,找到最优节点后通过前驱把它摘下来。
抢占式实现的关键在于:创建新进程之后,要立刻比较新进程的优先级和当前运行进程的优先级。这在模拟环境里意味着你的主循环结构要比FCFS复杂——不再是“取队头运行到结束”,而是“取最高优先级进程,运行一个时间片,每运行完一个时间片就重新检查是否有更高优先级的进程来了”。你可以用一个running指针记录当前运行的进程,就绪队列里放其他进程,每次调度时比较running和新队头的优先级。
4.3 一个进程的完整生命周期:创建→就绪→运行→阻塞→终止
讲到这里,可以把前面几节串起来了:一次完整的调度模拟,应该能打印出每个进程从创建到终止的全部状态变化。我一般会在代码里维护一个全局current_time,每一步操作都打印时间和事件,比如:
void log_event(int time, const char *event, int pid) { printf("[t=%d] %s (PID=%d) ", time, event, pid); }log_event函数很简单,但它强制你用统一的格式输出日志,这对于调试多进程并发逻辑非常有用。你可以在进程创建时调用它输出“CREATED”,进程开始运行时输出“RUNNING”,让出CPU时输出“READY”,结束时输出“TERMINATED”。
实验里阻塞状态一般通过一个“手动阻塞”的测试接口来模拟——常见做法是让用户输入某个PID来阻塞它,过一会再输入PID来唤醒它。这样做的意义是让你理解:进程不是永远在就绪队列里的,它可能因为等待IO或资源而暂时离开CPU调度,这个状态切换在实验报告里会占很大的篇幅。能画出每个进程的状态流转图,基本就能拿满分。
5. 进程管理实验避坑指南:五个让我翻车的细节
5.1 free之后还在用指针:段错误与悬垂指针
现象:调度器运行到某个进程时,打印输出正常,但再次访问该进程的next指针时直接段错误。
原因:进程已经终止并被free释放了内存,但调度循环里某处还持有指向该内存的指针。单向链表如果不把前驱的next置空,就会出现“访问已释放内存”的情况。这在C语言里属于未定义行为,有时候能跑,有时候崩溃,纯看运气。
解决:在free(proc)之前,确定没有任何指针指向proc。我在代码里会强制做两件事:一是从队列摘除后立即将前驱的next指向proc->next;二是free之后立刻将指针置为NULL,并在调度循环里检查“指针不为NULL才使用”。另一个更稳妥的方法是,不要真正free,而是维护一个“终止进程链表”,把所有终止的进程挂在那里,统一在程序退出时释放。牺牲一点点内存,换来的是一整晚的安睡。
5.2 进程名中的字符串缓冲溢出
现象:给进程起了一个15个字符的名字,程序运行正常;换成20个字符的名字,程序在创建进程时就崩了。
原因:name字段是char[16],用strcpy拷入20个字符的字符串,直接越过数组边界写入相邻内存。这是典型的缓冲区溢出,新手的编码习惯里最喜欢犯的一个毛病。
解决:一律用strncpy限制拷贝长度,并在末尾手动补'\0'。调试时可以在create_process里加一个断言,检查输入名字的长度不能超过15个字符,超出就报错退出。这个习惯延伸到实验以外的工程代码里,能帮你少写一堆漏洞。
5.3 队列排序时破坏了队头指针
现象:有序插入的函数在插入新节点后,队头变成了别的节点,或者链表出现循环导致死循环打印。
原因:enqueue_sjf或enqueue_priority的实现中,没有更新头指针。你想想,如果新节点比原来的队头优先级还高,它应该变成新队头,但函数只修改了局部变量head,调用方持有的队头指针还是原来的节点。
解决:队列操作函数统一使用二级指针PCB **head,在函数内部允许修改实参的指向。如果你已经写了一级指针的版本,可以在调用后检查返回值再更新头指针——但这是权宜之计,不如直接改成二级指针来得干净。
5.4 阻塞队列与就绪队列是两套东西
现象:阻塞一个进程后,它就再也不会被调度了;或者唤醒一个进程后,它会同时出现在就绪队列和阻塞队列里。
原因:很多实验实现只建了就绪队列,没有专门的阻塞队列。阻塞的时候把进程从就绪队列移除,但只是让它“飘在内存里”,没有放入一个新的队列结构中;唤醒时也不知道从哪里把它找回来。
解决:我的做法是维护两个队列指针:ready_queue和blocked_queue。阻塞操作是“从就绪队列摘除节点,加入阻塞队列”;唤醒操作是“从阻塞队列摘除节点,加入就绪队列(或直接参与优先级比较)”。这两个队列互不重叠,一个进程在同一时刻只能属于其中一个——这个约束你要在代码注释里写清楚,并在每个操作后加断言检查,防止逻辑错误积累。
5.5 时间片用完但没有重新插入队列
现象:时间片轮转调度中,某个进程只运行了一个时间片,但之后它从输出日志里“消失”了,再也没有被调度。
原因:时间片用完时,代码只修改了used_time,却没有调用enqueue把进程放回就绪队列队尾。进程变成了“游离状态”,既不在运行中,也不在任何队列里。
解决:把时间片轮转的代码逻辑按照“用完就插队尾”这个原则来写。每次运行完一个时间片,立刻判断:进程是否完成?完成就释放,没完成就插入队尾。这个判断和后续操作应该在同一个代码块里完成,不要分散到不同分支,减少遗漏的可能。
6. 让实验看出门道:可视化输出与调度验证
实验能跑通是一回事,能“讲清楚”是另一回事。我给你三个进阶方向,既能让你的代码看起来更专业,也能真正加深对进程管理的理解。
第一个方向是给调度器加一个时间轴可视化输出。我平时做的方法是,维护一个二维字符数组作为时间轴画布,横轴是时间片,纵轴是进程。每运行一个时间片就在对应位置画一个块,运行结束后用printf直接打印这个二维数组。FCFS和RR的差别,一眼就能看出来——FCFS是一条条长长的彩色块,RR是细密的交替条纹。代码实现不超过50行,但实验报告里的展示效果会非常惊艳。
第二个方向是验证调度顺序的正确性。你可以手算一组进程在FCFS和SJF下的调度顺序,然后在代码中输出实际的调度顺序,一个个对比。注意SJF在到达时间都相同的情况下有确定性结果,一旦引入不同到达时间,情况就复杂了,建议从一个简单的测试集开始。
第三个方向是数据规模不要太保守。试试生成30个进程,运行时间随机分布在1到10之间,让调度器跑一轮,观察平均等待时间的变化规律。你会发现SJF比FCFS省下的平均等待时间,在进程数量越多时越明显,这个规律在你的实验结论里非常值钱。
最后说一个我自己的习惯:这个实验我建议你至少从头写三遍。第一遍按自己的思路写,写出来能跑就算赢,这遍用的是蛮力;第二遍按上面讲的结构体和队列设计去重构,这遍在理顺代码结构;第三遍尝试自己加一个新功能,比如动态优先级或者多级反馈队列,这遍才算真正吃透了进程管理。我当年做这个实验踩过的坑,比正文里写的还要多一倍,现在回头看,正是那些段错误和悬垂指针,让我对操作系统底层逻辑有了切身的体感。这个实验是少数值得反复打磨的课程设计,希望帮到你。
本文还有配套的精品资源,点击获取