简介《操作系统进程管理实验C语言实现》是一份面向操作系统课程学习者与自学者的C语言实践资料围绕进程创建、撤销、同步、通信与调度等核心主题演示Unix/Linux下系统调用的实际用法以弥补纯理论学习的不足。压缩包共9个文件其中C源码与头文件是实验主体目标文件与可执行程序可直接运行验证工程配置文件.cbp便于在Code::Blocks中打开调试整体仅19KB下载和部署都很轻量。目前该资源已有4169人学习适合课程设计、实验报告或考研复习场景。实验以ProcessControl工程为主线代码中包含fork()、exec()、wait()等关键调用并涉及信号量、管道、消息队列及调度模拟等扩展思路通过阅读源码、编译运行和修改参数读者既能观察父子进程行为与退出状态回收也能在此基础上练习FCFS、时间片轮转等调度算法较完整地形成操作系统进程管理的实践闭环。1. 操作系统进程管理实验C语言实现先看你到底在考核什么很多人做操作系统进程管理实验C语言实现时会发现照着教程把代码抄进去、编译运行、打印几个 PID交完实验报告就算过关。可到了答辩环节老师随口问一句“fork 之后父子进程谁先执行”或者“僵尸进程怎么产生”当场就卡住了。这门实验真正要考核的不是你敲代码的手速而是对进程生命周期、父子关系、执行流替换和进程间通信这几块心智模型的掌握程度。我把这套实验拆成四个在国内高校里最常出现的题目用 fork() 画进程树、用信号量加共享内存实现生产者-消费者、时间片轮转调度模拟以及一个会后置的 IPC 练习。适合三类人正在做课内实验的本科生、转行进基础软件方向的自学者、准备考研或复试时想补动手能力的人。下面按我平时带人的顺序讲每步都给出了能直接编译的最小代码和参数说明。2. 用 fork() 画进程树API 记忆到进程生命周期2.1 fork 返回值是面试官常问的先画最小二进程先写一个最朴素的程序让一个进程分裂成父子两个各自打印自己的 PID 和父进程 PID。这个例子虽然简单但它能一次性把 fork、getpid、getppid 和 wait 四个 API 全串起来。#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); // 从这行开始进程一分为二 if (pid 0) { perror(fork); exit(1); } if (pid 0) { // 子进程走这里fork 返回 0 printf([子] pid%d, 父进程 pid%d\n, getpid(), getppid()); exit(0); } else { // 父进程走这里fork 返回子进程 PID printf([父] pid%d, 子进程 pid%d\n, getpid(), pid); wait(NULL); // 回收子进程避免僵尸 } return 0; }跑起来会看到两行输出但顺序不一定。这不是代码 bug而是 fork 之后的执行顺序由调度器决定这正是实验报告里值得写的一点。pid_t本质是 int 的别名但建议别改成 int 直接比较不同平台的位宽差异会埋雷。参数层面的注意点fork()一次调用返回两次。父进程拿到子进程 PID子进程拿到 0失败返回 -1。很多人写判断时只写if (pid 0)处理子进程漏掉else分支结果父子进程都往下跑同一段逻辑实验输出乱成一团这属于没有建立“fork 后两条执行流”的心智模型。wait(NULL)这里的作用不只是收尸。如果父进程先退出子进程会被 init 进程收养行为不容易复现反过来父进程不 wait子进程退出后就会变成僵尸进程。很多同学的实验报告只打印 PID 不回收交完程序跑完看起来没问题但用ps aux | grep defunct一看一屏都是 Z 状态。2.2 让它长成树循环创建子进程的计数与剪枝实验题经常要求创建 N 个子进程形成一棵进程树。最容易写错的是在 for 循环里不加判断导致子进程继续 fork最终子进程数量呈爆炸式增长。下面这段是正确的最小实现#include stdio.h #include unistd.h #include sys/wait.h int main() { for (int i 0; i 3; i) { pid_t pid fork(); if (pid 0) { // 子进程打印后立即退出不再参与后续循环 printf(子进程 %d: pid%d, 父进程%d\n, i, getpid(), getppid()); _exit(0); } } // 只有父进程会走到这里 for (int i 0; i 3; i) { wait(NULL); } printf(父进程 %d 全部回收完毕\n, getpid()); return 0; }关键在子进程分支里的_exit(0)。子进程一旦创建成功就立刻退出不会回到 for 循环继续 fork所以最终得到的是 3 个直接子进程而不是 2 的 3 次方减 1 个进程。_exit和exit在单线程程序里差别不大但多线程环境里exit会触发清理钩子可能产生不可预期的行为实验里统一用_exit更干净。打印的 i 值不代表子进程的执行顺序。三个子进程可能按 0、2、1 的顺序打印需要给输出加 pid 才能看出谁先谁后。我在带实验时会让学员把这一行的输出追加到文件里而不是终端因为终端输出本身有缓冲多个进程同时写会互相穿插干扰判断。2.3 用 exec 族换皮从父子进程到执行新程序只靠 fork父子进程运行的是同一份代码。实验里经常要你创建一个子进程去执行另一个程序这就必须用到 exec 族函数。理解 exec 的常见误区是认为它“新开了一个进程”实际上是当前进程的代码段被整体替换PID 不变只有地址空间、堆栈和打开的文件描述符受到影响。#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { // 子进程变身执行 /bin/ls execl(/bin/ls, ls, -l, NULL); perror(execl); // 只有失败才会走到这行 _exit(1); } wait(NULL); printf(父进程回收完成\n); return 0; }execl的第一个参数是可执行文件完整路径第二个参数是传给新程序的 argv[0]按惯例写成程序名后面是可变参数列表必须以 NULL 结尾。如果 exec 成功perror那一行永远执行不到如果你看到它打印了错误多半是路径写错或文件没有可执行权限。实验里也有人图省事用system(ls -l)它能工作但调用的机制完全不同。system 相当于 fork 一个子进程执行sh -c ls -l中间套了个 shell排错时黑匣子效应很明显。老师问“exec 和 system 有什么区别”答不上来的话不如老老实实用 execl代码多两行原理清楚很多。这一节的进阶点是 exec 族函数的几个变体之间的差异execv 用数组传参、execle 可以带环境变量、execvp 会在 PATH 里找命令。实验要求不高时用 execl 就够但要把“fork 复制、exec 替换”这个心智模型写进报告这是评分时最抓眼球的点。3. 用信号量与共享内存搭出生产者-消费者IPC 的三种坑3.1 为什么不选管道POSIX 信号量与共享内存的选型理由进程间通信的实验题里生产者-消费者是经典中的经典。实现手段很多管道、消息队列、共享内存加信号量。管道代码短但匿名管道只能用于父子进程命名管道要处理 mkfifo 和打开阻塞的问题消息队列封装程度高逻辑好写但不少实验题目明确要求“使用共享内存”。我不建议在实验里用管道应付共享内存的题目。共享内存是同一台机器上吞吐量最高的 IPC 方式数据从用户态直接映射到同一块物理页面不需要内核在收发两端各拷贝一次。代价是它不带任何同步机制必须搭配信号量这类原语保证互斥与顺序。#include stdio.h #include stdlib.h #include unistd.h #include sys/shm.h #include semaphore.h #include fcntl.h #include sys/stat.h #define SHM_SIZE 8 // 缓冲区能放的数据个数 typedef struct { int buf[SHM_SIZE]; int in; int out; } shared_data;选共享内存加 POSIX 信号量组合还有一层考虑是它和你后面的实验课衔接最顺虚拟存储器管理里会讲页面映射、缺页中断共享内存正好是这些概念在用户态最直观的载体。你用 shmat 得到地址后可以把它当成普通数组使用这种“一块物理内存映射到多个进程地址空间”的体验管道给不了。3.2 生产者-消费者核心代码框架下面这版代码我把信号量初始化和共享内存创建放在同一个文件里用 fork 创建生产者和消费者两个进程省去写两个程序联调的麻烦适合在虚拟机上直接跑通。#include stdio.h #include stdlib.h #include unistd.h #include sys/shm.h #include semaphore.h #include fcntl.h #include sys/stat.h #define SHM_SIZE 8 typedef struct { int buf[SHM_SIZE]; int in; int out; } shared_data; int main() { sem_t *empty, *full; shared_data *shm; // 两个命名信号量empty 记录空位full 记录数据 empty sem_open(/exp_empty, O_CREAT, 0666, SHM_SIZE); full sem_open(/exp_full, O_CREAT, 0666, 0); if (empty SEM_FAILED || full SEM_FAILED) { perror(sem_open); exit(1); } int shmid shmget(IPC_PRIVATE, sizeof(shared_data), 0666 | IPC_CREAT); if (shmid -1) { perror(shmget); exit(1); } shm (shared_data *)shmat(shmid, NULL, 0); shm-in 0; shm-out 0; pid_t pid fork(); if (pid 0) { // 消费者 for (int i 0; i 10; i) { sem_wait(full); int item shm-buf[shm-out]; shm-out (shm-out 1) % SHM_SIZE; printf(消费: %d\n, item); sem_post(empty); } shmdt(shm); exit(0); } else { // 生产者 for (int i 0; i 10; i) { sem_wait(empty); shm-buf[shm-in] i; shm-in (shm-in 1) % SHM_SIZE; printf(生产: %d\n, i); sem_post(full); } wait(NULL); shmdt(shm); shmctl(shmid, IPC_RMID, NULL); sem_unlink(/exp_empty); sem_unlink(/exp_full); } return 0; }编译命令是gcc -pthread producer_consumer.c -o pc。-pthread这个参数容易漏如果用的是 CentOS 7 自带的 gcc 4.8不加它可能链接失败Ubuntu 20.04 以上一般没问题但为了保险始终加上。sem_open的第一个参数是信号量名字必须以斜杠开头且路径名最长到 NAME_MAX 减 4。同一进程里可以打开多个同名信号量它们共享计数如果程序崩溃没来得及 sem_unlink下一次运行会拿到旧信号量计数可能是脏值实验跑几次后行为开始诡异这就是没清理干净。shmget(IPC_PRIVATE, sizeof(shared_data), 0666 | IPC_CREAT)用 IPC_PRIVATE 的好处是避免 key 冲突坏处是只能在父子进程间用。如果实验要求兄弟进程或无关进程通信改用ftok(某个存在的文件路径, 0x01)生成 key。进程 attach 共享内存后shm 指针在父子进程里指向同一块物理页所以生产者写 buf 消费者立刻能看到。代码里缓冲区满时生产者会阻塞在sem_wait(empty)缓冲区空时消费者阻塞在sem_wait(full)这就完成了同步。如果去掉其中一个 sem_wait程序的表现可能是消费者读到脏数据也可能是生产者覆盖未消费的数据用实验报告记录这两种现象比直接跑通更值钱。3.3 三个必调参数与两眼一抹黑时的排错顺序写这版代码最常翻车的三个位置按我的经验排个优先级第一sem_open的权限位。0666 少写一个 6消费者可能因为权限不足打开失败报错信息是 Permission denied进程还没开始跑就断。创建信号量的进程和打开信号量的进程必须是同一个 uid虚拟机和宿主机共享目录时尤其容易踩。第二共享内存的大小。sizeof(shared_data)里嵌套了数组而不是直接写SHM_SIZE * sizeof(int)这两个值在结构体紧凑时一样但一旦加了别的字段就会对不上。用shmget时内核按 PAGE_SIZE 向上取整申请小了不会立即报错写越界时可能直接段错误。第三打印语句的位置。缓冲区数组的 in/out 指针只在临界区内移动但 printf 属于耗时操作放在 sem_post 外面不会破坏逻辑但会让消费者的实际消费节奏和生产者的生产节奏产生肉眼可见的错位。实验报告里如果要截“生产者快、消费者慢”的对比效果把 delay 放在 sem_post 之后而不是临界区内部。如果跑起来一片混乱我的排查顺序是先确认两个进程是否确实在跑用ps -ef | grep pc查父子进程再看信号量路径是否残留ls /dev/shm/里能不能看到exp_empty最后用ipcs -m查共享内存段有没有被正确回收。多数诡异现象都出在第二次运行复用旧资源这一点上。4. 调度模拟与进程管理避坑5 个常见问题与排查4.1 时间片轮转模拟代码不长坑都在输出另一类高频实验题是模拟短作业优先或时间片轮转调度。我用时间片轮转举例因为它的状态转换最直观代码实现也不长适合在实验报告里画进程状态图对比。#include stdio.h #define N 4 typedef struct { int pid; // 进程编号 int arrival; // 到达时间 int remain; // 剩余服务时间 int time_slice; // 当前已用的时间片记数 } proc; int main() { proc p[N] { {1, 0, 6, 0}, {2, 1, 4, 0}, {3, 3, 2, 0}, {4, 5, 8, 0} }; const int QUANTUM 2; int done 0; int clock 0; int queue[N]; // 简单就绪队列存数组下标 int head 0, tail 0; for (int i 0; i N; i) queue[tail] i; while (done N) { int sel queue[head]; head (head 1) % N; // 到达时间晚于当前时钟则重新入队等下一个调度点 if (p[sel].arrival clock) { queue[tail] sel; tail (tail 1) % N; clock; continue; } if (p[sel].remain QUANTUM) { clock p[sel].remain; printf(进程 %d 完成完成时间%d\n, p[sel].pid, clock); p[sel].remain 0; done; } else { clock QUANTUM; p[sel].remain - QUANTUM; printf(进程 %d 时间片用完剩余%d, 当前时间%d\n, p[sel].pid, p[sel].remain, clock); queue[tail] sel; tail (tail 1) % N; } } return 0; }这个实现有两个必须说清的细节。一是到达时间晚的进程即使时间片到了也要等它真正到达才能运行示例里的if (p[sel].arrival clock)分支就是干这个的如果实验题假设所有进程同时到达这段可以简化成纯轮转。二是环形队列的长度这里直接用了 N 个长度但实际上一个时间片内最多只有一个进程被重新入队所以 N 够用如果改成抢占式优先级调度队列要做成动态结构。实验报告里需要用表格把每个时间点的进程状态记录下来代码里 printf 已经输出了“完成时间”和“剩余时间”跑完直接把输出贴进报告再补一列“等待时间”和“平均周转时间”就行。如果老师要求画甘特图这个输出格式也够用了。4.2 五条高频踩坑记录现象、原因、解决第一条进程变成僵尸。现象是程序运行完后用ps aux能看到没有父进程的进程残留状态列是 Z偶尔还能看到 defunct 字样。原因是子进程退出时父进程没有调用 wait 或 waitpid子进程的退出状态没人收。解决方法是父进程在循环末尾调用 wait或者为 SIGCHLD 信号注册处理函数在回调里重新调 waitpid。实验里的临时救急办法是kill -9 僵尸进程PID但这样只是从进程表里清掉根因不解决重跑照样出现。第二条循环里没写剪枝条件进程数量爆炸。现象是程序一运行虚拟机直接卡死终端失去响应。原因是 for 循环里调用 fork 后子进程没退出还继续执行下一次循环进程数按指数增长。解决方法是子进程分支立即_exit(0)或者用getpid()判断当前进程是否是第一次 fork 后产生的子进程。这属于实验里最危险的一条建议在运行前先ulimit -u 200限制当前 shell 的最大用户进程数给自己留个后悔药。第三条共享内存段残留导致第二次运行异常。现象是第一次运行正常第二次运行输出混乱或者 shmget 报 EEXIST。原因是进程异常退出时 IPC_RMID 没有执行。解决方法是代码里用信号处理函数统一清理或者在每次实验前手动执行ipcrm -a。这条特别容易发生在 gdb 调试中途按了 CtrlC 的情况下。第四条信号量互斥失效。现象是生产者生产的数据被消费者重复消费多次或者两个进程同时往缓冲区里写导致数据错乱。原因是 sem_init 时 pshared 参数写成了 0这个值在 Linux 上只对线程有效多进程场景必须写 1。我用的是 sem_open不会遇到这个问题但如果你在旧课件上看到了sem_init(sem, 0, 1)要判断它是否真的用于多进程否则要改成 sem_open 或把 pshared 改成 1。第五条Windows 上硬跑 Linux 代码。现象是编译报错找不到sys/shm.h和unistd.h。原因是 Windows 原生环境不提供 POSIX 头文件。解决方法是改用 WSL 或 VMware 装一个 Ubuntu 20.04 虚拟机在 Linux 环境里编译运行。这一步躲不开操作系统实验本来就该在 Unix 类系统下面做别把时间浪费在给 Cygwin 配环境上。4.3 增加调度实验得分静态数组与排序因素时间片轮转模拟的关键是“按到达时间排序”这一步。代码示例里直接用数组初始化的顺序作为就绪队列初始顺序但如果实验题的进程列表不是按到达时间排好的就要先排序。建议用插入排序或冒泡排序做稳定排序不要用快排这种不稳定排序因为两个到达时间相同的进程顺序变化会导致打印的完成时间发生细微变化老师验收时对照标准答案会产生疑问。还有一个容易画蛇添足的坑实验题明明让你做时间片轮转你却把进程按短作业优先排了队。体现在输出上就是每个进程的执行顺序恰好是服务时间从小到大看起来“效率太高”反而让老师怀疑你没看懂题目。要先在报告的进程状态表格里写明是 FCFS 还是 SJF再根据题目类型选调度策略不要凭感觉抄网上代码。5. 从实验到加分把进程管理做成可调试的工程实验交完能跑只是起点真正拉开分差的是可验证性。我给你一个每天在 Linux 下做 C 开发都在用的习惯给关键路径加一条带 PID 的日志输出用时间戳观察进程间的先后关系。#include stdio.h #include unistd.h #include time.h static void log_stage(const char *tag) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); printf([%ld.%09ld] pid%d %s\n, ts.tv_sec, ts.tv_nsec, getpid(), tag); fflush(stdout); }把这个函数嵌到 fork 前后、exec 前后、信号量等待前后跑一次就能看清所有细节哪个进程先进入临界区、哪个进程先打印、父子进程谁先退出。CLOCK_MONOTONIC不能用time()替代因为为了观察执行顺序需要纳秒级精度而墙上时钟会受 NTP 调整的影响出现时间回跳。第二个加分项是跑完实验后自动验证结果把程序跑 100 次做批处理检查每次的进程 PID 数量是否一致数量不稳定说明有进程没被回收或者 fork 次数写错。第三个技巧是用strace跟踪系统调用这个命令在验收答辩时效果极好。strace -f -e traceclone,execve,wait4 ./pc能列出每个进程真正触发的系统调用的顺序比你在代码里到处加 printf 准确得多。出示这条输出记录老师就知道你理解 fork、exec、wait 在内核层面做了什么。我自己第一次做调度模拟时为了少敲两行代码把打印放在了错误的循环位置结果输出的进程顺序和预期完全对不上。重新读了状态图才发现是“时间片轮换时打印剩余时间”的时机搞错了。后来养成了先画状态转换图再写代码的习惯实验多花 20 分钟规划调试阶段省下两个晚上。你也试试把上面这三步嵌入你的实验流程希望帮到你。本文还有配套的精品资源点击获取
