简介:操作系统进程管理实验的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()返回当前进程PID,getppid()返回父进程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的第二个参数是pshared,0表示信号量在线程间共享,非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[j+1].arrival_time) { Process tmp = procs[j]; procs[j] = procs[j+1]; procs[j+1] = 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环境下,程序运行后卡死,Ctrl+C都杀不掉
原因:这里十有八九是死锁。最常见的场景是父子进程都在等待对方释放某个资源——比如父进程等待子进程退出,而子进程等待父进程发消息。另一种情况是信号量的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操作、确认管道断干净”这三个检查步骤,这三板斧帮我少熬了不少夜。希望这份源码和踩坑记录能帮到你,让你把更多精力放在理解调度和同步的本质上,而不是和编译器与运行时死磕。
本文还有配套的精品资源,点击获取