简介:面向操作系统课程学习者的C语言进程管理实验资源包,覆盖进程创建、撤销、同步、通信与调度等核心机制,帮助读者掌握fork()、exec()、wait()等系统调用及信号量、管道等同步通信方式。压缩包共9个文件,包含ProcessControl.c与ProcessControl.h源码、两个.c及.o文件、Code::Blocks工程文件(cbp/layout/depend)以及可直接运行的exe,整体仅19KB,轻量易用。已有4169人学习下载。资源提供完整的进程控制演示程序与工程配置,读者可对照源码理解父子进程运行上下文切换、exec加载新程序、wait回收子进程等关键流程;同时结合实验内容可进一步推演信号量互斥、消息队列通信及时间片轮转等调度算法实现,适合课后实验、课程设计或考研复习场景。
1. 操作系统进程管理实验:这份 C 语言代码包把课本概念跑成了真实输出
操作系统进程管理是计算机专业里最磨练人的实验之一,很多人在 fork() 和 wait() 之间反复横跳,最后也没搞明白 PCB 和就绪队列到底怎么组织。这份 ProcessControl 代码包正是为这个场景准备的:它用 C 语言实现了进程控制块、就绪队列、FCFS/SPF/时间片轮转三种调度算法,以及信号量、管道等同步通信手段,整套工程放进 Code::Blocks 就能编译运行。你不需要从零设计系统结构,只需要读代码、调参数、看输出,就能把操作系统课本上的调度算法和进程状态转换对应到具体结果上。适合正在做操作系统实验的本科生,也适合期末复习时想用实际运行结果验证原理的考研党。
2. 先拆代码包:PCB 数据结构、文件分工与编译入口
2.1 从 .cbp 到 main.c:拿到代码先按这个顺序读
解压 ProcessControl.rar 后会看到一套标准的 Code::Blocks 工程文件。不少同学拿到代码第一反应是直接按 Build 然后 Run,结果报错一堆,问题多半出在没搞清文件结构。我的习惯是先按功能把文件分类,读代码时沿着「入口 → 头文件 → 实现」的顺序走,十分钟内就能建立整个程序的地图。
| 文件 | 作用 | 怎么处理 |
|---|---|---|
| main.c | 实验入口,初始化进程列表并调用调度/同步模块 | 第一优先级阅读 |
| ProcessControl.h | PCB 结构体、进程状态枚举、函数声明 | 第一优先级阅读 |
| ProcessControl.c | 调度算法与同步通信逻辑的具体实现 | 第二优先级阅读 |
| ProcessControl.cbp | Code::Blocks 工程配置,双击打开项目 | 直接双击,不用读 |
| ProcessControl.depend / .layout | 编译依赖与窗口布局,自动生成 | 忽略 |
| obj/Debug、bin/Debug | 编译中间产物和可执行文件 | 换环境后建议 Clean 再 Build |
先读 main.c 能看到整个实验的数据流:定义一组进程、设置到达时间和服务时间、调用调度函数、打印结果。再看 ProcessControl.h 里声明的函数接口,最后才去 ProcessControl.c 里看具体算法。这个顺序能把「哪个函数管哪件事」的分类先建立起来,而不是一头扎进实现细节里出不来。
2.2 PCB 状态机:调度实验的数据底座
进程控制块是整个实验的核心数据结构,三种调度算法都是围绕 PCB 数组展开的。下面这段结构体定义基本就是这类实验的标准写法:
#define MAX_PROCESS 10 #define MAX_NAME 16 typedef enum { PROCESS_READY, /* 就绪态 */ PROCESS_RUNNING, /* 运行态 */ PROCESS_BLOCKED, /* 阻塞态 */ PROCESS_FINISHED /* 完成态 */ } ProcessState; typedef struct { char name[MAX_NAME]; /* 进程名,如 P1 */ int pid; /* 进程编号 */ ProcessState state; /* 当前状态 */ int arrive_time; /* 到达时间 */ int service_time; /* 需要的服务时间 */ int remaining_time; /* 剩余时间,时间片轮转用 */ int priority; /* 优先级,扩展用字段 */ int finish_time; /* 完成时刻 */ } PCB;这里的 arrive_time、service_time、remaining_time 是三个支点字段。先来先服务按 arrive_time 排队,短进程优先按 service_time 选进程,时间片轮转靠 remaining_time 递减判断是否执行完毕。state 字段对应操作系统课本上的状态转换图,模拟过程中进程从就绪态进入运行态,时间片用完后如果 remaining_time 大于零,又被踢回就绪态排队。priority 在基础实验里可能闲置,但如果你想扩展成抢占式优先级调度,这个字段就是代码包预留的口子,加一个排序键就能改出来。
有同学问为什么不用 Linux 的 fork() 创建真实进程来演示调度。原因是真实进程的调度权在操作系统内核手里,你没法控制哪个进程先跑、跑多久;而用 PCB 数组模拟调度,可以精确控制每个参数,把算法的时间轴完整打印出来。这就是「模拟调度层」和「真实系统调用层」的分工,后面的同步通信会用到真实的 pipe,但调度部分始终是模拟的。
2.3 fork() 与 wait():真实系统调用层怎么和模拟层分工
进程创建这部分实验通常会引入真实的系统调用,用来对比「模拟进程」和「真实进程」的差别。核心就是 fork() 和 wait():
#include <stdio.h> #include <sys/wait.h> #include <unistd.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); return 1; } else if (pid == 0) { printf("子进程: PID=%d, 父进程 PID=%d\n", getpid(), getppid()); return 0; } else { int status; wait(&status); /* 父进程阻塞,等待子进程退出 */ printf("父进程: 子进程 %d 已退出\n", pid); } return 0; }fork() 的返回值是理解这段代码的关键:父进程拿到的是子进程的 PID,子进程拿到的是 0,失败时返回 -1,所以用 pid == 0 区分父子分支。wait() 让父进程阻塞直到子进程退出,避免产生僵尸进程。在 Linux 下编译运行后,父进程的「已退出」打印永远在子进程输出之后,因为 wait() 强制了执行顺序,这就是进程同步最原始的形态。
这里要提醒一个环境问题:fork() 和 wait() 属于 POSIX 标准接口,Windows 原生环境无法编译。所以多数老师会把 fork 演示放在 Linux 环境或者虚拟机里单独做,而调度和同步模拟部分用纯 C 跨平台实现。代码包主线程序在 Windows 的 Code::Blocks 里能直接跑,fork 演示则需要切到 Linux 环境,实验前一定先分清哪个模块在哪个环境编译。
3. 三种调度算法落地:FCFS、SPF 与时间片轮转的代码和验证
3.1 先来先服务和短进程优先:两个最基础的调度循环
调度算法的模拟核心是「每个时刻从就绪队列里选一个进程」。先来先服务最简单,按到达时间排好序后依次执行;短进程优先则是每个时刻检查就绪队列,选服务时间最短的进程。下面是把 FCFS 写进一个调度函数的常见做法:
void schedule_fcfs(PCB *procs, int n) { int time = 0; sort_by_arrive(procs, n); /* 按到达时间排序 */ for (int i = 0; i < n; i++) { if (time < procs[i].arrive_time) time = procs[i].arrive_time; /* CPU 空闲等待 */ procs[i].state = PROCESS_RUNNING; time += procs[i].service_time; procs[i].finish_time = time; procs[i].state = PROCESS_FINISHED; } }sort_by_arrive 通常是一次按 arrive_time 升序排列的冒泡或选择排序。注意 time 变量的处理:如果下一个进程还没到达,CPU 要空闲等待到它的到达时刻,这个 if 判断是新手最容易漏掉的。没有这个判断,到达时间不均匀的进程组会算出错误的完成时间,而且错得很隐蔽——结果看着像模像样,手算一核对就对不上。
短进程优先不能预排序,因为新进程可能随时到达,必须在每个调度点重新扫描就绪队列:
void schedule_spf(PCB *procs, int n) { int time = 0, finished = 0; while (finished < n) { int idx = -1; int min_service = INT_MAX; for (int i = 0; i < n; i++) { if (procs[i].state == PROCESS_FINISHED) continue; if (procs[i].arrive_time <= time && procs[i].service_time < min_service) { min_service = procs[i].service_time; idx = i; } } if (idx == -1) { time++; continue; } /* 就绪队列为空 */ time += procs[idx].service_time; procs[idx].finish_time = time; procs[idx].state = PROCESS_FINISHED; finished++; } }idx == -1 表示当前时刻没有进程到达,时间递增继续扫描。这个循环结构在时间片轮转里也会复用,区别只在选进程的标准和是否抢占。如果把 SPF 改成抢占式版本(SRTF),只需要在每个时间片结束时重新扫描一次,并允许剩余时间更短的进程打断当前进程,你可以在 schedule_spf 基础上加一个 per-time 的检查,这是实验报告里很自然的扩展点。
3.2 时间片轮转:remaining_time 与队尾插入的边界处理
时间片轮转是最容易写错的一个算法,因为它有一个「执行到一半被抢占」的动作。每个进程先运行一个时间片,没跑完就放到队尾继续排队。教科书上画的是队列动画,代码里要用数组加头尾指针模拟:
void schedule_rr(PCB *procs, int n, int quantum) { sort_by_arrive(procs, n); int time = 0, finished = 0; int front = 0, rear = n - 1; /* 用环形队列管理就绪进程的下标 */ while (finished < n) { if (front == -1) { time++; continue; } /* 队列空,CPU 空闲 */ int idx = front; if (time < procs[idx].arrive_time) time = procs[idx].arrive_time; int run_time = (procs[idx].remaining_time > quantum) ? quantum : procs[idx].remaining_time; time += run_time; procs[idx].remaining_time -= run_time; if (procs[idx].remaining_time == 0) { procs[idx].finish_time = time; procs[idx].state = PROCESS_FINISHED; finished++; } /* 未完成进程移入队尾,front 后移,继续调度 */ } }run_time 的三目运算是关键:如果剩余时间小于时间片,就只运行剩余时间,不要硬生生多扣。很多同学的翻车点就在这里——用固定 quantum 去减 remaining_time,导致剩余时间为负,完成时间全部算错。我一般会在减法之后加一个防御性检查:
if (procs[idx].remaining_time < 0) { fprintf(stderr, "时间片计算错误: 进程 %s\n", procs[idx].name); exit(1); }这个断言在实验阶段非常实用,它能把「悄悄算错」变成「立即报错」,省去你对着输出猜半天的时间。时间片的大小也值得调一调:quantum 设得很大时,RR 退化成 FCFS;设得很小时,进程切换开销在真实系统里会暴涨,模拟环境里则表现为平均周转时间变差。把 quantum 从 1 改到 10,跑同一组数据对比输出,你会直观看到这个趋势。
3.3 用一组五进程数据验证:参数怎么改、结果怎么核对
用一个五进程的例子来验证。假设到达时间分别是 0、1、2、3、4,服务时间分别是 4、3、5、2、1,时间单位统一用 ms:
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 4 |
| P2 | 1 | 3 |
| P3 | 2 | 5 |
| P4 | 3 | 2 |
| P5 | 4 | 1 |
FCFS 的完成时间依次是 4、7、12、14、15,平均周转时间 (4+6+10+11+11)/5 = 8.4。SPF 在时间 0 只有 P1 就绪,先跑 P1;时间 4 时 P2 到 P5 都已到达,选服务时间最短的 P5,再依次选 P4、P2、P3,你会发现 SPF 的平均周转时间明显低于 FCFS——这就是课本上「短进程优先降低平均周转时间」的直接证据。
时间片轮转的验证重点是时间片大小。把 quantum 设为 2,P1 先跑 2ms 剩 2ms,P2 跑 2ms 剩 1ms,以此类推。建议你先在纸上画出甘特图,再和程序输出逐段对照,只要有一处对不上,说明队列实现有 bug。这个过程枯燥,但它是理解抢占式调度不可绕过的路。改参数时注意 main.c 里数组的初始化方式,通常是一组结构体赋值,直接在初始化列表里改 arrive_time 和 service_time 就行。
4. 进程同步与通信:信号量、管道和死锁检测的实现细节
4.1 信号量的 P/V 操作:临界区保护的模拟实现
同步部分实验里最经典的是生产者消费者问题。用信号量模拟时,C 代码的核心是两个操作:
typedef struct { int value; } Semaphore; void P(Semaphore *sem) { /* wait 操作 */ sem->value--; if (sem->value < 0) { /* 真实系统中这里会阻塞挂起当前进程 */ printf("%s 阻塞等待\n", current_process_name); } } void V(Semaphore *sem) { /* signal 操作 */ sem->value++; if (sem->value <= 0) { /* 唤醒一个等待进程 */ printf("唤醒一个阻塞进程\n"); } }注意 P 操作里 sem->value 减到负数并不表示资源数量为负,而是表示等待队列里有 |value| 个进程在排队。这个符号语义是理解信号量的第一道坎,很多同学卡在这里绕不过去。模拟环境里没有真正的阻塞机制,所以用打印状态来代替,但你要在脑内把「阻塞」和「唤醒」对应到真实内核的等待队列操作。实验报告里如果能把这个对应关系写清楚,比贴大段代码更得分。
互斥量是信号量的特例,value 初始化为 1。实验里模拟两个进程竞争一个临界区时,P 操作保证同一时刻只有一个进程进入,V 操作释放资源,这个「先 P 后 V」的顺序一定不能反,反了就成了先释放再申请,临界区形同虚设。
4.2 管道通信:父子进程交换数据的完整流程
管道通信是实验里少有的使用真实系统调用的部分。pipe() 创建一对文件描述符,fork() 之后父子进程各持一端,一个写一个读:
int fd[2]; if (pipe(fd) == -1) { perror("pipe"); return 1; } pid_t pid = fork(); if (pid == 0) { close(fd[0]); /* 子进程关闭读端 */ write(fd[1], "hello", 5); /* 写入数据 */ close(fd[1]); } else { close(fd[1]); /* 父进程关闭写端 */ char buf[16]; int n = read(fd[0], buf, sizeof(buf)); buf[n] = '\0'; printf("父进程收到: %s\n", buf); wait(NULL); }管道是半双工的,数据单向流动,所以父子进程必须各自关闭不需要的一端,否则会出现「写端未关闭导致 read 阻塞」的经典问题。很多同学的管道实验卡住,不是数据没写进去,而是 fork 之后忘了 close 多余的描述符,对端永远等不到 EOF。这里的 close 顺序也有讲究:先 close 再读写,避免描述符泄漏;父进程最后 wait(NULL) 回收子进程,和前面 fork 演示是同一套逻辑。
4.3 死锁检测:简化版银行家算法的落地
死锁部分实验一般不会让你写完整的银行家算法,而是用简化版本判断当前系统是否安全。常见做法是维护资源分配矩阵,每次模拟进程请求资源时检查是否存在安全序列:
int safety_check(int available[], int max[][3], int allocation[][3], int n) { int need[MAX_PROCESS][3]; int work[3], finish[MAX_PROCESS] = {0}; for (int i = 0; i < 3; i++) work[i] = available[i]; /* 1. 计算 need = max - allocation */ /* 2. 循环查找 finish=0 且 need 全部 <= work 的进程 */ /* 3. 找到则假分配并释放资源,更新 work */ /* 4. 若存在未完成进程且找不到可分配进程,返回不安全 */ }银行家算法本身不难,但循环查找的顺序会影响结果,所以代码里必须用 finish 数组标记已完成的进程,每轮从头扫描,避免漏选。这个算法的结论是「存在安全序列则系统安全,否则可能死锁」。实验报告里建议你构造一个安全状态和一个不安全状态各跑一次,把每次的 work 数组变化打印出来,这比只贴结论有说服力得多。
4.4 消息队列与共享内存:进阶通信模型的取舍
代码包主线如果只做信号量和管道,篇幅上是够的,但课本里还提到消息队列和共享内存,这两个属于进阶选项。消息队列用 msgget、msgsnd、msgrcv 三个系统调用,数据带类型标识,支持双向通信;共享内存用 shmget 和 shmat,速度最快,但必须配合信号量做同步,否则两个进程同时写同一块内存,数据就乱了。我的建议是:时间充裕就做消息队列,代码量不多,输出也直观;共享内存留给有余力的同学,写的时候把信号量同步一起做了,实验报告的含金量会明显高一截。
5. ProcessControl 避坑记录:五个最容易翻车的现场
5.1 fork() 之后改变量:改动为什么在父进程里「消失」了
现象:子进程里修改了一个全局变量,父进程里再打印,发现值没变。 原因:fork() 采用写时复制(copy-on-write),子进程拿到的是父进程地址空间的副本。子进程修改只是改了它自己那一份,父进程完全不受影响。 解决:如果你需要父子进程共享数据,必须用管道、共享内存或者信号量这类 IPC 机制,不能靠直接改变量。在实验报告里把「子进程改变量、父进程看不到」这个现象写清楚,反而是加分项,因为它证明了写时复制机制真实存在。
5.2 wait() 挂死:父进程卡住不动了
现象:程序运行到 wait() 处迟迟不返回,控制台没有任何输出。 原因:最常见的是 fork() 之后子进程又 fork 了,或者管道场景里写端没关闭,父进程 read 一直阻塞等待数据。 解决:先检查 fork 次数,确保 wait() 只等待直接子进程;管道场景下确认父进程已经 close 掉自己那端的写描述符。在 Linux 里用 ps 命令看进程状态,如果看到标记为 defunct 的进程,说明出现了僵尸进程,父进程没有及时回收。
5.3 时间片轮转剩余时间为负
现象:RR 算法跑完,某个进程的 finish_time 比理论值大,甚至出现负数剩余时间。 原因:代码用固定 quantum 去减 remaining_time,没判断剩余时间是否小于 quantum。 解决:严格按照 min(remaining_time, quantum) 取值。这个习惯是我从翻车现场养出来的——在减法之前先打印两个操作数,一眼就能看出问题,然后加防御性断言,让错误在发生点暴露。
5.4 Code::Blocks Build 按钮灰色或报找不到编译器
现象:打开 .cbp 后 Build 按钮不可点,或者编译时提示找不到 gcc。 原因:Code::Blocks 没配置 MinGW 编译器路径。绿色版、机房预装版经常漏掉这一步。 解决:Settings → Compiler → Global compiler settings → Toolchain executables 里重新指定 MinGW 的 bin 目录。代码包里带着 obj/Debug 和 bin/Debug,说明已经在某台机器上编译过,换环境后最好先 Clean 再重新 Build,避免旧的目标文件残留导致诡异链接错误。
5.5 调度结果和手算对不上
现象:FCFS 的输出完成时间和你手算的完全不同。 原因:大概率是输入数据没按到达时间排序,或者到达时间不连续导致 CPU 空闲段没处理。 解决:先在纸上画出甘特图,再用程序跑同一组数据。如果甘特图正确而程序不对,在调度循环里加打印,每个时间点输出当前选中的进程和 time 值,逐帧对照,很快就能定位是排序问题还是空闲处理问题。
6. 把实验变成答辩加分项:三个验证技巧和我的固定习惯
6.1 时间轴打印:让调度过程可回放
调度算法的核心是时间推进,所以输出也按时间轴组织。我一般会在每个调度点打印一行:当前时刻、选中进程、剩余时间、完成时刻。这比最后只打印一张完成时间表有用得多——答辩时老师问你「P3 为什么在这个时刻被调度」,你能直接翻出那一行输出,而不是重新算一遍。
6.2 边界用例:用一句话逼出隐藏 bug
调度实验里最值得构造的边界用例是:所有进程同一时刻到达、某个进程到达时间很远、服务时间全部相等、只有一个进程。这四个用例分别检验排序稳定性、CPU 空闲等待、算法对均匀输入的处理、单进程边界。跑这四组数据不出错,调度核心基本就稳了。
6.3 把 printf 整理成统一输出模块
实验中期你会发现打印逻辑散落在各个调度函数里,改格式要改好几处。我的做法是抽一个 print_schedule_step 函数,统一接收时刻、进程名、动作三个参数。这个习惯让代码干净,答辩演示时也能随时切换详细模式和摘要模式。
从那以后,我每次拿到新的进程管理实验代码,都会强制走一遍固定流程:先画甘特图,再构造四组边界用例,最后用时间轴输出逐帧核对,确认无误才去写实验报告。这套流程走完,代码里的大多数坑都藏不住,报告里的数据也不会出现自相矛盾。希望帮到你。
本文还有配套的精品资源,点击获取