ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

操作系统进程管理实验:C语言实现fork、信号量与调度算法全解析

操作系统进程管理实验:C语言实现fork、信号量与调度算法全解析 简介操作系统进程管理实验的C语言实现源码包适合高校操作系统课程学生与需要理解进程控制机制的开发者。全部文件共9个包含ProcessControl.c源文件、ProcessControl.h头文件、编译生成的.o目标文件、Code::Blocks工程配置.cbp/.layout/.depend以及可直接运行的exe程序压缩包仅19KB结构紧凑便于快速查看与运行。已有4169人学习参考。代码围绕进程的创建、同步、通信与调度展开涉及fork()、exec()、wait()等系统调用并包含信号量、管道等进程间通信机制的模拟实现同时兼顾先来先服务、时间片轮转等调度思想的演示。这份资源既可作为操作系统实验报告的配套代码也可作为课程设计的起步模板帮助读者将进程生命周期、状态转换与调度策略等抽象概念落实到具体C语言实现中提升理论与实践结合能力。1. 操作系统进程管理实验卡壳这份C语言源码包帮你把抽象概念跑起来进程管理是操作系统课程里最容易“听懂了但不会写”的一块fork()、wait()这些系统调用在PPT上画得明明白白但真到Code::Blocks里敲代码时不是编译报错就是输出和预期对不上。这份用C语言实现的进程管理实验资源把进程创建、同步、通信和调度算法都落到了源码层。压缩包里是完整的Code::Blocks工程包含main.c、ProcessControl.h、ProcessControl.c几个核心文件直接打开就能编译运行。有C语言基础、正在被操作系统实验折磨的在校生或者想快速回顾进程API用法的从业者都能从这套源码里找到可以照着改的骨架。2. 先理解fork()与exec()进程创建不是“复制粘贴”那么简单2.1 fork()的返回值决定进程身份后续代码是分叉点整个实验的地基是进程创建而创建进程的核心就是fork()。在ProcessControl.c里创建逻辑走的正是Unix/Linux系统调用这套路。fork()调用一次却返回两次父进程拿到的子进程PID子进程拿到的是0失败时父进程拿到-1。这个返回值不是用来“看看”的它直接决定当前代码跑在哪个进程里。pid_t pid fork(); if (pid 0) { perror(fork failed); exit(1); } else if (pid 0) { printf([子进程] PID%d, 父进程PID%d\n, getpid(), getppid()); // 子进程继续执行这里 } else { printf([父进程] PID%d, 子进程PID%d\n, getpid(), pid); // 父进程走这里 }代码逻辑并不复杂先判断fork()是否失败然后按返回值的区分进入各自的执行分支。值得注意的地方是子进程拿到的父进程PID是getppid()而父进程拿到的子进程PID就是fork()的返回值。很多实验报告里的错误是把getpid()和fork()返回值搞混导致父子进程关系写的颠倒。参数说明getpid()返回当前进程PIDgetppid()返回父进程PID这两个函数搭配fork()返回值能把进程关系完整串起来。在实验代码中这一段的输出通常会打印两次分别标注父进程和子进程这个过程本身就能帮你验证fork()确实返回了两次。2.2 fork()复制的是“那一刻”的状态文件描述符和缓冲区分清楚教材里会说fork()复制了进程的所有资源但这个“所有”是有边界的。它复制的是内存映像、文件描述符表、环境变量和栈等运行时上下文而文件描述符复制后是共享同一个文件偏移量的。很多同学在实验里会发现fork()之后父子进程各自对文件指针做读写结果却互相影响根因就在这里。FILE *fp fopen(test.txt, w); if (fp NULL) { perror(fopen); exit(1); } pid_t pid fork(); if (pid 0) { fprintf(fp, 子进程写入的内容\n); fclose(fp); } else { wait(NULL); fprintf(fp, 父进程写入的内容\n); fclose(fp); }逻辑说明父进程先打开文件然后fork()出子进程。子进程用fprintf写入并关闭了自己的文件指针。父进程wait()等子进程结束后再写入。因为父子进程共享同一个文件偏移量子进程写入后偏移量已经移动父进程写入的内容会紧跟其后。如果你希望父子进程各自独立写入应该在使用fork()之后再各自打开文件而不是共享一个FILE指针。运行完后用cat查看test.txt会发现两行内容是接续出现的这个结果能直观展示文件偏移量的共享机制。参数说明wait(NULL)用于父进程等待子进程结束NULL表示不关心退出状态只做同步。如果把这个等待去掉父进程可能抢先写入输出顺序会乱这也是实验里反复强调wait()的原因。2.3 exec()替换进程映像fork()与exec()搭配才是完整形态fork()只是复制要让子进程真正去执行别的程序需要exec()系列函数。这一族函数包括execl()、execv()、execle()、execve()等区别只在于参数传递方式l版本用可变参数列表v版本用字符串数组。它们共同的特点是调用成功后不返回因为当前进程的内存映像已经被新程序替换了。pid_t pid fork(); if (pid 0) { execl(/bin/ls, ls, -l, NULL); perror(execl failed); exit(1); } else { wait(NULL); printf(子进程执行完毕\n); }逻辑说明子进程把自己替换成/bin/ls -l命令。如果execl()执行成功子进程不会再执行后面的perror和exit因为内存映像已经整个换掉了。只有execl()失败时比如路径错误才会走perror打印错误信息。所以exec()调用后面一定要跟一个错误处理这是唯一能在exec失败时捕获问题的机会。参数说明execl的第一个参数是可执行文件路径第二个参数是argv[0]必须写程序名本身之后是命令行参数最后以NULL结尾。很多人只传了路径忘写argv[0]或者漏了结尾的NULL程序直接崩溃。3. 进程同步与通信信号量、管道和消息队列的取舍3.1 信号量的P/V操作临界区不是加个锁就万事大吉多个进程同时访问共享资源必须先想好保护策略。信号量是POSIX线程与进程同步的标准方案之一实验里用到的通常是sem_wait()和sem_post()这一对函数对应的教材说法就是P操作和V操作。关键点是这两个函数必须成对出现漏掉任何一个另一个进程就可能永久阻塞。#include semaphore.h sem_t sem; sem_init(sem, 0, 1); // 第二个参数0表示线程间共享1表示初始值 // 进程内P操作 sem_wait(sem); // 临界区访问共享资源 // 需要保护的代码段 sem_post(sem);逻辑说明sem_init的第二个参数是pshared0表示信号量在线程间共享非0表示进程间共享——如果要在多进程环境里用记得设为1。第三个参数是初始值设为1就是互斥锁设为N就是允许N个进程同时进入。实验里常见的翻车点是在fork()之后才sem_init或者直接在父进程里初始化后fork()出来的子进程确实能共享这个信号量因为fork()会复制父进程的内存信号量也被复制了一份。但如果换成mmap或shm_open创建的共享内存这个信号量就得放进共享内存区否则子进程拿到的是独立副本。3.2 管道父子进程间最简单的一对一通信管道是最普及的IPC方式实现成本低适合父子进程或兄弟进程之间传数据。它分为匿名管道和命名管道实验里常见的是匿名管道用pipe()创建。通信方向是单向的想双向通信就得建两个管道。int pipefd[2]; if (pipe(pipefd) -1) { perror(pipe); exit(1); } pid_t pid fork(); if (pid 0) { close(pipefd[1]); // 子进程关闭写端 char buf[128]; read(pipefd[0], buf, sizeof(buf)); // 从管道读数据 printf(子进程收到: %s\n, buf); close(pipefd[0]); } else { close(pipefd[0]); // 父进程关闭读端 write(pipefd[1], hello from parent, 18); close(pipefd[1]); }代码逻辑很清晰pipefd数组里pipefd[0]是读端pipefd[1]是写端。fork()之后父子进程都把不需要的那端关掉这是防止数据写乱的关键。很多实验报告里不关没用的端结果就是read()返回后卡在那里——因为管道里还有写端没关闭系统认为后续还会有数据不会给EOF。参数说明write的最后一个参数18是字符串hello from parent的长度如果写错会带上多余的字符。更稳妥的做法是用strlen计算长度避免手数字符个数。3.3 消息队列和共享内存什么时候用它们更合适管道是字节流没有天然的消息边界而消息队列和共享内存能提供更结构化的通信。消息队列用msgsnd和msgrcv收发每条消息有类型字段接收端能按类型筛选适合请求/响应模式的场景。共享内存则是把所有进程的数据放在同一块物理内存区域速度最快但必须配合信号量或其他机制做互斥访问。共享内存的使用流程是固定的先用shmget获取或创建共享内存段再用shmat附加到进程地址空间用完之后shmdt分离。这套流程只要顺序乱了——比如先shmat再shmget——肯定报错。实验源码里通常会把这三步写成固定的初始化函数错误处理也放在里面这样后续调用时不用重复处理返回值。消息队列的收发函数参数更多每个参数的取值空间大规律是先写类型再写数据优先级体现在类型值上类型值小的优先被读出。4. 调度算法模拟把FCFS、SPF和时间片轮转写进同一个框架4.1 先来先服务最简单的等待队列调度算法实验的核心不是背诵定义而是实现并对比。先来先服务FCFS的实现门槛最低用一个就绪队列按到达时间排队当前进程结束时弹出队首进程运行。关键点在于如何判断一个进程“已经到达”——如果当前时间还没到进程的到达时间调度器得空转等待或者采用“按到达时间排序后只遍历到达的进程”的方式避免死循环。typedef struct { int pid; int arrival_time; int burst_time; } Process; void fcfs_schedule(Process procs[], int n) { // 按到达时间排序 for (int i 0; i n - 1; i) { for (int j 0; j n - i - 1; j) { if (procs[j].arrival_time procs[j1].arrival_time) { Process tmp procs[j]; procs[j] procs[j1]; procs[j1] tmp; } } } int current_time 0; for (int i 0; i n; i) { if (current_time procs[i].arrival_time) { current_time procs[i].arrival_time; } printf(进程%d 开始时间%d 结束时间%d\n, procs[i].pid, current_time, current_time procs[i].burst_time); current_time procs[i].burst_time; } }这个排序是关键步骤缺了它整个调度就没了意义。时间推进的处理也同样重要当CPU空闲时直接把当前时间跳到下一个进程的到达时间节省不必要的循环。用双层冒泡排序虽然效率一般但胜在代码直观适合实验展示逻辑。参数说明current_time是调度器的系统时间轴arrival_time是每个进程的到达时间burst_time是需要的CPU时间。当前时间不足时直接跳变能保证调度器永远不落后于到达时间。4.2 时间片轮转队列维护与时间片的选择是一门“玄学”时间片轮转Round Robin比FCFS复杂的地方在于每个进程只能跑一个时间片时间片用完还没结束就要排到队尾。这天然需要循环队列支持用数组加头尾指针模拟。时间片大小选多少直接决定最终的平均等待时间——时间片太大就退化成FCFS太小则频繁切换上下文切换开销暴增。#define TIME_SLICE 2 void round_robin(Process procs[], int n) { int remaining[n]; for (int i 0; i n; i) remaining[i] procs[i].burst_time; int current_time 0, done 0; int queue[n], front 0, rear 0; int in_queue[n]; for (int i 0; i n; i) in_queue[i] 0; // 初始把所有已到达进程入队 for (int i 0; i n; i) { if (procs[i].arrival_time current_time) { queue[rear] i; in_queue[i] 1; } } while (done n) { if (front rear) { current_time; // 队列空时间前进 continue; } int idx queue[front]; int run_time remaining[idx] TIME_SLICE ? remaining[idx] : TIME_SLICE; current_time run_time; remaining[idx] - run_time; printf(进程%d 运行%d个时间片当前时刻%d\n, procs[idx].pid, run_time, current_time); // 新到达的进程入队排在当前进程之后 for (int i 0; i n; i) { if (!in_queue[i] procs[i].arrival_time current_time) { queue[rear] i; in_queue[i] 1; } } if (remaining[idx] 0) { queue[rear] idx; // 没跑完就重新入队 } else { done; } } }核心逻辑拆开看分为入队、运行、调度三步。程序先让所有已到达进程入队而正在运行的进程如果耗尽了时间片还没结束会被重新放回队尾。新到达的进程会在当前进程的本次运行结束后插入队尾这就保证了先到先等。参数上TIME_SLICE取2实际的可调范围很大你可以试试改成1或5观察完成时间和平均等待时间的变化。5. 避坑指南进程管理实验最常见的四个翻车现场5.1 现象fork()之后printf输出了两遍而且顺序乱原因printf带缓冲fork()复制了进程的内存映像同时也复制了缓冲区。主进程在fork()之前调用printf但没有刷新缓冲区fork()之后缓冲区内容被复制到子进程于是同样的输出打印两次。缓冲区的清理发生程序退出或缓冲区满时顺序自然不可控。解决fork()之前强制刷新缓冲区调用fflush(stdout)或者用write()直接写文件描述符write是不带缓冲的。5.2 现象wait(NULL)之后父进程拿不到子进程的退出状态原因wait(NULL)只负责等待参数为NULL表示不接收退出状态。想拿到状态码得传一个int指针再用WIFEXITED和WEXITSTATUS解析。很多初学者用wait(NULL)后一脸问号以为程序状态码丢了。解决换成int status; wait(status);然后打印WEXITSTATUS(status)就能拿到子进程在exit()里传入的退出码。5.3 现象Code::Blocks环境下程序运行后卡死CtrlC都杀不掉原因这里十有八九是死锁。最常见的场景是父子进程都在等待对方释放某个资源——比如父进程等待子进程退出而子进程等待父进程发消息。另一种情况是信号量的P操作执行了两次V操作只执行了一次。解决先在代码里查找信号量的成对操作确保每个sem_wait都有对应sem_post。再检查管道两端有没有冗余的未关闭写端。如果场景涉及多个进程检查是否出现了循环等待链。5.4 现象编译时报“semaphore.h: No such file or directory”原因信号量库不是默认链接的需要在Code::Blocks的Linker设置里加上-lpthread或-lrt库。有的发行版还需要装posix线程开发包。刚接触Linux编程时常常在这里卡很久。解决在Code::Blocks的Project → Build options → Linker settings里添加pthread把编译链接命令变成gcc main.c -lpthread -o main。6. 验证代码正确性三组测试用例吃透进程管理实验6.1 验证fork()内存隔离写一个全局变量在子进程里修改它的值然后父进程打印它。如果父进程打印的结果没变说明父子进程的变量空间是独立的——这验证了“写时复制”的概念。代码结构大概是全局int val 0; fork()后子进程里val 100; 退出父进程wait()后打印val如果还是0就对了。这一步别忘加fflush或使用换行来清缓冲区否则输出顺序会错乱。代码在codeBlocks里直接跑这个用例是最快的进程语义体检。6.2 验证wait()的收尸效果连续fork()N个子进程每个子进程exit(一个不同数值)父进程循环调用wait(status)每次打印WEXITSTATUS(status)。你会看到结果是乱序的——子进程谁先结束wait就先返回谁。如果想按PID顺序收尸就得挨个waitpid(pid)。这个差异会在实验问答环节被反复问到。6.3 验证共享内存的读写一致性用shmget创建一段共享内存父子进程分别往里面写和读再配合信号量做互斥。这个用例跑通说明你已经掌握了进程间通信的完整链路创建共享内存、附加、互斥访问、分离、删除。写这个用例时注意顺序先创建共享内存再fork()否则子进程拿不到共享内存的标识符。这套流程走完后可以从单一进程的实验扩展到多进程并发场景对比FCFS和Round Robin在平均等待时间上的数据差异。从那以后我每次写进程管理相关的代码都会强制自己走一遍“标记输出加fflush、成对检查P/V操作、确认管道断干净”这三个检查步骤这三板斧帮我少熬了不少夜。希望这份源码和踩坑记录能帮到你让你把更多精力放在理解调度和同步的本质上而不是和编译器与运行时死磕。本文还有配套的精品资源点击获取
返回列表