☰
操作系统实验避坑指南:fork、管道与信号量常见故障排查
2026/10/23 3:07:05 网站建设 项目流程

简介:西安电子科技大学操作系统课程实验报告,面向正在学习进程管理、线程同步与进程间通信的学生。报告完整记录实验一至实验六:进程建立、线程共享数据、信号通信、匿名管道与命名管道通信,以及信号量同步,均基于Linux环境和C语言实现。其中包含fork、waitpid、pthread_create、kill、管道及sem_init等关键函数的实际调用代码与分析,可帮助读者理解父子进程协同、多线程数据共享及临界区互斥等核心知识点。资源为1个doc文档(约377KB),正文含实验程序、结果截图、程序分析和心得体会,结构清晰,便于对照实验要求参考或直接复用代码环境。目前已有1350人学习/下载,适合西安电子科技大学在校生、操作系统课程学习者及需要系统级编程案例的开发者借鉴。

1. fork() 之后父子进程谁也不等谁:操作系统进程实验最典型的“没同步”故障

操作系统实验里,fork() 建立进程这一关几乎每届都有同学卡在同一类现象上:子进程还没把文件读完,父进程已经不管不顾往下跑了;或者反过来,终端像死了一样卡住,Ctrl+C 都无动于衷。这类故障在进程、线程、管道、信号量实验里会反复出现,根子都在「进程之间没有同步好」。

这份实验报告覆盖了进程建立、线程共享数据、信号通信、匿名管道、命名管道、信号量六个实验,我在整理时把每个实验里最常见的报错单独拎了出来。写这篇文章的目的很直接:把每个报错的触发条件和根因讲清楚,再落到能直接抄的代码和参数说明,而不是让你拿着报错去翻课本猜。

正在做操作系统上机实验、被报错卡住的同学,按章节对号入座;已经跑通但说不清原理的同学,重点看避坑章节的排查记录。每一节的代码都按「正确写法 + 参数解释」组织,照着改就能过。

2. 进程建立与回收:fork() 返回值、文件读取与 waitpid() 的排查顺序

2.1 fork() 返回值判断错:子进程把父进程的活也干了一遍

先说最容易踩的第一个坑。fork() 不是「创建一个新进程」这么简单,它调用一次、返回两次:父进程里返回的是子进程的 PID(大于 0),子进程里返回 0,创建失败返回 -1。很多同学只记得「fork 成功返回大于 0」,于是写成 fpid >= 0 就直接走读文件分支,结果父子进程都进了同一个分支,读文件这段代码被执行了两遍。

正确的写法必须把三种情况分开,先排除 < 0,再区分 == 0 和 > 0:

#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { pid_t fpid = fork(); // 调用一次,返回两次 if (fpid < 0) { printf("fork error\n"); // 创建失败 exit(1); } else if (fpid == 0) { printf("子进程: pid=%d\n", getpid()); // 子进程分支 } else { printf("父进程: 子进程 pid=%d\n", fpid); // 父进程分支 } return 0; }

逻辑说明:fork() 之后,父进程和子进程从同一行代码继续往下执行,唯一的区别就是 fpid 的值。fpid == 0 是子进程的身份证,fpid > 0 是父进程拿到子进程 PID 的唯一途径。漏掉任何一个分支,都会出现两个进程抢着干同一件事的灵异现象。我一般让同学在每个分支里打印 getpid(),先确认自己到底进了哪个分支,如果终端打出两遍相同的「父进程」内容,基本就是分支条件写错了,不是玄学。

这里补充一点:fork 失败最常见的两个原因,一是系统进程数达到上限,二是内存不足。实验环境里几乎不会遇到,但判断 fpid < 0 这个分支必须保留,否则出错时程序会带着一个未初始化的 fpid 继续跑,行为完全不可预测。

2.2 子进程报“未找到文件”:工作目录与 fopen 判空

实验一里子进程要用 fopen("test1file.txt", "r") 读文件,常见的报错是终端输出「未找到文件」。这个坑很隐蔽,因为代码里明明有 test1file.txt,路径也看着没错。原因是:fork() 创建的子进程会继承父进程的工作目录,而 fopen 的第二个参数 "r" 表示以只读方式打开,相对路径是相对于当前进程的工作目录解析的。如果你在 /home/lab1 下编译运行,但数据文件放在了 /home 下,子进程自然找不到。

解决的办法有两个:一是把文件路径写成绝对路径,二是在程序里先用 getcwd(NULL, 0) 打印工作目录,再把数据文件放到同一个目录。另外,fopen 的返回值一定要判空,下面这段是实验一子进程读文件的完整骨架:

FILE *fp = fopen("test1file.txt", "r"); if (fp == NULL) { printf("未找到文件\n"); return 1; } char text[2000]; while (fgets(text, 2000, fp) != NULL) { puts(text); // 逐行输出文件内容 } fclose(fp); // 用完关闭,否则文件句柄泄漏

说明:fgets 的第二个参数 2000 是缓冲区大小,加上字符串结尾符,实际最多读 1999 个字符;第三个参数是 FILE* 流。如果只调一次 fscanf 或者 getc,容易漏读多行文件,所以要用 while 循环逐行读。我在排查时发现不少同学忘记 fclose,短程序看不出来,但后面做命名管道实验时文件描述符越积越多,open 就会报错。养成「打开必判空、用完必关闭」的习惯,能少踩很多坑。

提示:fopen 返回 NULL 时,再用 perror("fopen") 打印一次,能直接区分「文件不存在」和「权限不足」,比只输出「未找到文件」更有排查价值。

2.3 waitpid() 参数写错:子进程变僵尸进程

父进程要等子进程读完文件再继续,实验里用的是 waitpid(0, NULL, 0)。很多同学卡在「父进程不等子进程自己先结束」,另一个更常见的现象是程序能跑,但 ps -ef 里出现一堆<defunct>进程——这就是僵尸进程。僵尸进程是子进程已经退出、但父进程没有调用 wait/waitpid 回收它的退出状态,进程控制块还留在内核里。父进程不回收,僵尸就一直挂着。

waitpid 的第一个参数有讲究:pid > 0 时等待指定 PID 的子进程;pid == 0 时等待与父进程同进程组的任意子进程;pid == -1 时等待任意子进程。第三个参数 options 传 0 表示阻塞等待,传 WNOHANG 表示不阻塞、没有子进程退出就立刻返回 0。下面这段是实验一的标准回收写法:

int status; pid_t ret = waitpid(0, &status, 0); // 阻塞等待同组子进程结束 if (ret > 0) { printf("子进程 %d 已结束\n", ret); }

参数说明:第二个参数 status 用来接收子进程退出状态,如果只关心「等没等完」,传 NULL 也行;options 传 0 是阻塞模式,父进程会一直挂起直到有子进程退出。如果父进程还要干别的事,可以传 WNOHANG 配合循环轮询。注意 waitpid 的返回值是「退出的子进程 PID」或者 -1(出错,比如没有子进程),不是 0。

这一节最核心的同步逻辑是:子进程读文件的耗时不可控,父进程必须用 waitpid 阻塞自己,等子进程 exit 之后再往下走。如果发现父进程的 printf 永远比子进程早,优先检查是不是没调用 wait/waitpid,或者 waitpid 的 pid 参数写成了某个不存在的 PID。

3. 线程共享数据与信号通信:pthread_create 不输出、信号丢失的根因排查

3.1 主线程先退出:新线程的 printf 还没来得及打印

实验二用 pthread_create() 创建线程,在线程里操作全局变量 shdata。常见翻车现场是:终端上只看到「creat thread success」,线程里的三行 printf 全不见了。这不是线程没执行,而是 main 函数直接 return 0 退出了,整个进程结束,新线程根本没机会跑。原始实验代码里用 sleep(3) 硬等三秒,能跑通,但属于「运气好」——线程调度时机并不保证 3 秒内一定执行完。

正确做法是用 pthread_join 等线程结束,而不是靠 sleep 赌调度:

#include <stdio.h> #include <pthread.h> #include <unistd.h> int shdata = 0; // 全局共享数据 void *creat(void *arg) { printf("线程: shared data = %d\n", shdata); shdata++; // 线程里修改全局变量 printf("线程: 修改后 = %d\n", shdata); return (void *)0; } int main() { pthread_t tid; int ret = pthread_create(&tid, NULL, creat, NULL); if (ret != 0) { printf("creat thread error\n"); return -1; } pthread_join(tid, NULL); // 等线程结束,替代 sleep printf("主线程: shared data = %d\n", shdata); return 0; }

逻辑说明:pthread_join 会阻塞主线程,直到 tid 对应的线程返回,效果和进程里的 waitpid 类似。第一个参数是线程 ID,第二个参数接收线程返回值,传 NULL 表示不关心。用 pthread_join 之后,线程里的三行 printf 一定在主线程继续之前打完,顺序确定。这里要顺便理解线程与进程的区别:线程共享进程的地址空间,所以全局变量 shdata 是同一个变量;而 fork() 出来的子进程有独立地址空间,子进程改全局变量父进程看不到。实验二的意义就是验证这一点——线程里 shdata++ 之后,主线程打印出来确实变了。

3.2 编译报错 undefined reference topthread_create:忘了 -lpthread

另一个高频报错出现在编译阶段,报错信息长这样:

gcc thread_demo.c -o thread_demo /tmp/ccXXXX.o: undefined reference to `pthread_create'

原因:在较老版本的 glibc 里,pthread 相关函数在单独的 libpthread 库中,编译时必须显式链接。解决办法是在编译命令末尾加 -lpthread,或者直接用 -pthread 编译选项:

gcc thread_demo.c -o thread_demo -lpthread # 或 gcc -pthread thread_demo.c -o thread_demo

参数说明:-l 是链接库选项,pthread 是库名,链接器会去找 libpthread.so;-pthread 是更完整的编译选项,它会同时设置宏和链接参数,新版 gcc 下更推荐。如果加了 -lpthread 仍然报 undefined reference,先检查是不是拼写成了 -lpthreads(多了 s),再看是不是库路径有问题。这类编译报错和运行时故障不一样,它属于「环境没配好」,但出现频率极高,实验报告里值得单独记一笔。

3.3 信号丢失:子进程的 kill 发得太早,父进程还没注册 signal()

实验三的信号通信有一个非常典型的时序坑。代码逻辑是父进程先 fork,子进程里立刻 kill(getppid(), SIGALRM),父进程里再 signal(SIGALRM, ouch) 注册处理函数。问题在于:fork 之后父子进程并发执行,子进程可能抢在父进程执行 signal() 之前就把信号发出去了。这时父进程还没注册处理函数,SIGALRM 的默认动作是终止进程,或者信号直接被丢弃,表现为父进程收不到任何通知。

正确做法是先信号(signal) 注册,再 fork。这样无论子进程多快,信号到达时处理函数一定已经就位:

#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <signal.h> static volatile sig_atomic_t alarm_fired = 0; // 标志位要用 volatile void ouch(int sig) { alarm_fired = 1; // 处理函数里只改标志 } int main() { signal(SIGALRM, ouch); // 先注册,再 fork pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } else if (pid == 0) { kill(getppid(), SIGALRM); // 子进程给父进程发信号 exit(0); } else { while (!alarm_fired) { // 忙等;实际工程可用 pause() 挂起等待 } printf("收到信号 %d\n", SIGALRM); } return 0; }

说明:signal(SIGALRM, ouch) 把 SIGALRM 的处理函数绑定到 ouch;kill 的第一个参数是目标进程 PID,getppid() 拿到父进程 PID,第二个参数是信号编号。这里有两个容易忽略的细节:一是通知变量 alarm_fired 必须声明成 volatile sig_atomic_t,否则编译器可能把它优化到寄存器里,主循环一直读不到更新;二是 while(!alarm_fired) 是忙等,会空转占满 CPU,真实场景应该用 pause() 挂起等待信号,或者用 sigwait。实验报告里写心得体会时,把这两点补进去是加分项。

4. 管道通信故障:匿名管道 read 卡死与命名管道 open 阻塞的解决

4.1 匿名管道:父进程写入成功,子进程 read() 却卡死

实验四用 pipe() 建匿名管道,父进程写、子进程读。最常见的故障是终端卡住不动,或者子进程的 printf 永远不出现。根因是管道读写的阻塞特性:read() 在没有数据且管道仍存在写端时会阻塞等待;只有当管道已经没有写端时,read() 才返回 0。而 pipe() 创建的两个文件描述符在 fork 之后会被父子进程同时继承——父进程手里有读端 fd[0] 和写端 fd[1],子进程手里也有。如果父进程不关闭读端,管道就一直存在读端;子进程不关闭写端,写端也一直存在,两边互相等,谁也等不到。

解决的核心是「谁用哪端就留哪端,另一端必须 close」:

#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #define MAX_LINE 80 int main() { int fd[2]; if (pipe(fd) != 0) { // 创建管道 printf("管道创建失败\n"); return 1; } pid_t child_pid = fork(); if (child_pid == 0) { // 子进程:读端 close(fd[1]); // 关闭写端,否则 read 不会返回 0 char buf[MAX_LINE] = {0}; read(fd[0], buf, sizeof(buf)); // 阻塞直到有数据 printf("子进程读到: %s\n", buf); close(fd[0]); exit(0); } else { // 父进程:写端 close(fd[0]); // 关闭读端 char str[] = "hello the world"; write(fd[1], str, strlen(str)); // 写入数据 close(fd[1]); // 写完关闭,子进程 read 才能返回 printf("父进程写入成功\n"); } return 0; }

逻辑说明:pipe(fd) 成功后 fd[0] 是读端、fd[1] 是写端,数据只能从写端流向读端。子进程要 read,就必须 close(fd[1]),让整个系统里只剩下一个读端;父进程要 write,就必须 close(fd[0])。最关键的一步是父进程 write 完之后 close(fd[1])——如果写端不关,子进程 read 读完数据后不会返回,因为管道里可能还有「未来的数据」,只有所有写端关闭,read 才返回 0。

提示:如果数据打印出来了但程序不退出,十有八九是写端没关干净。排查时用ls -l /proc/<pid>/fd/看进程持有的文件描述符,一眼就能确认。

这里要提醒管道的容量限制:Linux 匿名管道缓冲区默认 64KB 左右,如果父进程写入超过缓冲区大小,write() 会先阻塞,等子进程读走一部分再继续。实验四的数据只有十几个字节,不容易触发;但做大数据量传输实验时,父进程 write 卡住是正常现象,不是 bug,需要配合子进程边读边处理。

4.2 命名管道:mkfifo 返回 -1,第二次运行就失败

实验五用 mkfifo("./fifo", 0666) 创建命名管道。很多同学的代码第一次运行没问题,第二次运行报「管道创建失败」。原因很简单:mkfifo 创建的是磁盘上的一个 FIFO 特殊文件,第一次运行创建成功后文件就留在当前目录,第二次运行 mkfifo 发现同名文件已存在,返回 -1,errno 是 EEXIST。这不是权限问题,也不是管道坏了,而是「文件已存在」被当成错误处理了。

处理方式是兼容已存在的场景:只有 errno 不是 EEXIST 时才真正报错:

#include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #include <string.h> int main() { // 创建命名管道;已存在不算失败 if (mkfifo("./fifo", 0666) < 0 && errno != EEXIST) { printf("管道创建失败\n"); exit(1); } printf("命名管道就绪\n"); // 后续再 fork,父子进程分别 open 读写 return 0; }

参数说明:mkfifo 的第一个参数是路径名,第二个参数 mode 是权限位,0666 表示所有者、同组、其他用户都可读写。注意它创建的是类型为 p 的特殊文件,用 ls -l 查看时行首是 p 而不是 -。errno 的判断必须在 mkfifo 返回 -1 之后立刻检查,中间如果调用了其他库函数,errno 可能被覆盖。我一般会让同学在判断后面加一句 perror("mkfifo"),把错误原因打出来,比盲猜快得多。

4.3 命名管道 open() 阻塞:只开一端,另一端迟迟不来

比 mkfifo 创建失败更隐蔽的是 open() 卡住。FIFO 的 open 有一个特殊行为:以 O_RDONLY 打开时,会阻塞直到有另一个进程以 O_WRONLY 打开同一管道;同理,以 O_WRONLY 打开也会阻塞直到有读端就位。实验五里父进程 open("./fifo", O_RDONLY) 如果写在 fork 之前,而子进程还没创建出来,父进程的 open 就会一直等下去——终端看起来就像死机。

正确顺序是先 fork,父子进程各自 open,让两端同时就位:

pid_t fpid = fork(); if (fpid > 0) { // 父进程:只读打开 int fd = open("./fifo", O_RDONLY); if (fd < 0) { perror("读数据出错"); exit(1); } char buf[BUFSIZ] = {0}; int n = read(fd, buf, sizeof(buf)); if (n > 0) printf("父进程读到: %s\n", buf); close(fd); } else if (fpid == 0) { // 子进程:只写打开 int fd = open("./fifo", O_WRONLY); if (fd < 0) { perror("写数据出错"); exit(1); } char buf[] = "HELLO,WORLD"; write(fd, buf, strlen(buf)); close(fd); exit(0); }

逻辑说明:fork 之后父子进程几乎同时去 open,父进程的 O_RDONLY 会等待子进程的 O_WRONLY 就位,两端握手完成后 open 同时返回,数据流通。如果发现程序卡在 open 这一行,先确认是不是把 open 放在了 fork 之前;再确认子进程是否真的走到了 open 的代码——子进程提前 exit 了,父进程的 open 也会一直阻塞。可以加一行 fprintf(stderr, "child before open\n") 来确认执行流。

另一个替代方案是 open 时加 O_NONBLOCK 标志,读端在没有写端时立即返回而不是阻塞。但这个标志会连带影响后续 read 的行为,实验里不建议一上来就用,先把阻塞版本的时序跑通再说。

5. 信号量实验常见问题避坑:四类踩坑记录与 PV 顺序修正

这一章专门整理信号量生产者消费者实验里最常见的四类翻车,每一条都按「现象 → 原因 → 解决」的结构写。信号量这里涉及 semget、semctl、semop 三个函数,参数多、概念绕,也是最容易把实验报告写成「调了一晚上没调出来」的部分。

5.1 踩坑记录一:消费者取到了还没生产的产品

  • 现象:终端先打印 "the consumer A get number 1",之后才打印 "produce 1";或者消费者一次性把 100 个产品全取走了,生产者还在慢慢生产。
  • 原因:实验报告里明确提过这个问题——「消费者取出生产者还未生产的产品,最后查找是因为一个 PV 操作出现了错误」。典型错误是把生产者里的 P(empty) 和 V(full) 写反,导致生产者没有真正占用空缓冲区,消费者却从 full 信号量上拿到了「有产品」的许可。
  • 解决:把 PV 顺序固化成模板,先资源信号量、后互斥信号量:
struct sembuf P = {0, -1, SEM_UNDO}; // P 操作:信号量减 1 struct sembuf V = {0, 1, SEM_UNDO}; // V 操作:信号量加 1 // 生产者 semop(emptyid, &P, 1); // 1. 申请一个空缓冲区 semop(mutxid, &P, 1); // 2. 进入临界区 array[(*set) % MAXSEM] = i + 1; (*set)++; semop(mutxid, &V, 1); // 3. 离开临界区 semop(fullid, &V, 1); // 4. 通知消费者有一个新产品 // 消费者 semop(fullid, &P, 1); // 1. 申请一个产品 semop(mutxid, &P, 1); // 2. 进入临界区 *sum += array[(*get) % MAXSEM]; (*get)++; semop(mutxid, &V, 1); // 3. 离开临界区 semop(emptyid, &V, 1); // 4. 释放一个空缓冲区

逻辑说明:semop 的第一个参数是信号量 ID,第二个参数是 struct sembuf 数组,第三个参数是操作个数。P 操作的 sem_op = -1,V 操作 sem_op = +1;SEM_UNDO 表示进程退出时自动还原信号量,防止进程异常退出导致信号量被永久占用。这里的关键是:mutex 只保护临界区,不能替代 empty/full 的资源计数。如果先 P(mutex) 再 P(empty),当缓冲区满时,生产者会拿着 mutex 在 P(empty) 上睡着,消费者想进临界区又拿不到 mutex,两边直接卡死——这是教科书级的死锁写法,实验里表现为「两边都停住不动」。

5.2 踩坑记录二:semctl 报 semctl setval error

  • 现象:程序运行后终端输出 "semctl setval error",信号量初始化失败,后续 semop 全部报错。
  • 原因:semctl 的第四个参数是 union semun,这个联合体在 Linux 的 sys/sem.h 里并不自动定义,需要程序自己声明。很多同学直接照抄书上的 semctl(fullid, 0, SETVAL, arg),但没定义 union semun,编译能过(隐式声明或者 glibc 版本差异),运行时拿不到正确的 arg。
  • 解决:在文件顶部手工声明联合体,并检查每个 semctl 的返回值:
union semun { int val; // SETVAL 时用 struct semid_ds *buf; // IPC_STAT 时用 unsigned short *array; // GETALL/SETALL 时用 }; union semun arg; arg.val = 0; // full 初始为 0:还没有产品 if (semctl(fullid, 0, SETVAL, arg) == -1) perror("semctl fullid error"); arg.val = MAXSEM; // empty 初始为 5:5 个空位 if (semctl(emptyid, 0, SETVAL, arg) == -1) perror("semctl emptyid error"); arg.val = 1; // mutex 初始为 1:互斥 if (semctl(mutxid, 0, SETVAL, arg) == -1) perror("semctl mutxid error");

参数说明:semctl 第一个参数是 semget 返回的信号量 ID,第二个参数是信号量编号(从 0 开始),第三个参数是命令,SETVAL 表示设置信号量值。三个信号量的初始值都有讲究:full 从 0 开始,因为一开始缓冲区是空的;empty 从 MAXSEM(实验中是 5)开始;mutex 从 1 开始,保证同一时刻只有一个进程进临界区。如果把 full 和 empty 的初始值设反,生产者消费者立刻乱套。

5.3 踩坑记录三:semop 返回 -1 且 errno 是 EINTR

  • 现象:程序跑着跑着,某个 semop 突然返回 -1,perror 输出 "Interrupted system call",然后整个实验挂掉。实验三里刚学了信号,实验六里信号就来捣乱。
  • 原因:semop 是阻塞调用,进程在等待信号量时如果收到信号,内核会让 semop 返回 -1,errno 设为 EINTR。默认信号处理会终止进程,但如果你在同一个程序里用 signal() 注册了处理函数,这个信号就会打断正在阻塞的 semop。
  • 解决:在 semop 外面包一层循环,被 EINTR 打断就重试:
int semop_retry(int semid, struct sembuf *sops, size_t nsops) { int ret; do { ret = semop(semid, sops, nsops); // 被信号打断就重试 } while (ret == -1 && errno == EINTR); return ret; }

说明:errno == EINTR 时,信号量操作并没有完成,重试是安全的,不会造成重复扣减。这个封装在实验六里可能用不上,但如果你把实验三和实验六合并调试,几乎必然碰到。另外要注意 semop 失败时 errno 也可能是 EAGAIN(设置了 IPC_NOWAIT 而信号量不足),两种错误要分别处理。我在带实验时发现,很多同学把 semop 返回值丢到 if 里却不打印 errno,这是最浪费时间的排查盲区。

5.4 踩坑记录四:两个消费者重复取同一个产品

  • 现象:消费者 A 打印 "get number 3" 后,消费者 B 也打印 "get number 3",同一个产品被消费了两次,最后的 sum 不对。
  • 原因:mmap 映射共享内存时少传了 MAP_SHARED,或者误用了 MAP_PRIVATE。MAP_PRIVATE 的写时复制机制会让每个进程各自持有一份副本,进程 A 改 array[0] 之后,进程 B 完全看不到。get、sum、set 这些指针也一样,各改各的副本,消费者 B 的 (*get) 永远停在 0。
  • 解决:mmap 必须用 MAP_SHARED | MAP_ANONYMOUS:
int *array = mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, // 关键:共享 + 匿名 -1, 0);

参数说明:mmap 的前两个参数是映射起始地址(NULL 表示让内核选)和映射长度;PROT_READ | PROT_WRITE 表示可读可写;MAP_SHARED 表示对映射内存的修改对所有映射同一区域的进程可见,MAP_ANONYMOUS 表示不基于文件、直接分配匿名内存;后面的 -1 和 0 是文件描述符和偏移量,匿名映射时 fd 填 -1。附带一个风险点:两个消费者都在 while(1) 里判断 *get == 100,如果这个判断放在临界区外,可能出现 A 和 B 同时读到 *get == 99 然后各自消费的情况。建议把「是否消费完」的判断也放进 mutex 保护区内,和 *get++ 放在一起。

6. 快速定位:进程通信这类故障,我按这三步查

六组实验跑下来,报错千奇百怪,但定位手段就那么几招。我按从外到内的顺序讲。

第一步,先看进程活着没有。终端卡住时另开一个终端跑ps -ef | grep 程序名,或pstree -p。看到<defunct>是父进程没回收,去查 waitpid;看到进程还在但状态是 S,多半是阻塞在某个 read、open 或 semop 上。

第二步,用 strace 看系统调用。strace -f -o trace.log ./程序,-f 会跟踪 fork 出来的所有子进程,跑完打开 trace.log 看最后一个系统调用:阻塞在 read(fd[0]) 是管道读端没数据且写端没关,阻塞在 open("./fifo", O_RDONLY) 是命名管道另一端没打开,阻塞在 semop 是信号量等不到。这一步能把「猜」变成「确认」,比到处加 printf 高效得多。

第三步,加打印确认分支和返回值。fork 分支、open 返回值、semop 返回值各打印一行,perror 会自动带出 errno 对应的错误文字,「File exists」「Interrupted system call」每出现一次,就能直接对应到上面某一节的坑。信号量的故障还能用ipcs -s查看当前信号量的值,确认没有初始化错。

常用现象的排查对照:

现象优先检查
父进程先跑完,子进程才输出waitpid 缺失或 pid 参数错
ps 看到 defunct 僵尸进程父进程没调用 wait/waitpid
线程 printf 完全消失主线程先退出,缺 pthread_join
编译 undefined reference pthread缺 -lpthread
read 卡死不动管道写端没全关闭
mkfifo 第二次运行失败文件已存在 EEXIST
open 卡住FIFO 另一端没打开
消费者取到未生产产品PV 顺序错误
消费者重复取同一产品mmap 缺 MAP_SHARED

从那以后,我每跑一个进程通信程序,都强制自己先做两件事:一是把关键句柄的返回值全部打印出来,二是准备一个 strace 命令随时待命。这两步能挡掉八成「调了一晚上发现是分支写错」的悲剧。这份实验报告里的六个代码,我都按上面的方法重新跑过,把所有坑的成因和改法整理成了带注释的排错版,需要对照调试的同学直接下载即可。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询