我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

C语言实现进程管理实验:从PCB设计到调度算法全解析

C语言实现进程管理实验:从PCB设计到调度算法全解析 简介操作系统进程管理实验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先来先服务用队列就能跑通的最小调度器FCFSFirst 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短作业优先按需排序的插入逻辑SJFShortest 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 RobinRR是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省下的平均等待时间在进程数量越多时越明显这个规律在你的实验结论里非常值钱。最后说一个我自己的习惯这个实验我建议你至少从头写三遍。第一遍按自己的思路写写出来能跑就算赢这遍用的是蛮力第二遍按上面讲的结构体和队列设计去重构这遍在理顺代码结构第三遍尝试自己加一个新功能比如动态优先级或者多级反馈队列这遍才算真正吃透了进程管理。我当年做这个实验踩过的坑比正文里写的还要多一倍现在回头看正是那些段错误和悬垂指针让我对操作系统底层逻辑有了切身的体感。这个实验是少数值得反复打磨的课程设计希望帮到你。本文还有配套的精品资源点击获取
返回列表