资讯详情

Linux系统编程实验精讲:从fork到信号量,避坑指南

📅 2026/10/30 2:54:41 | 华诺云谱 👁 阅读
Linux系统编程实验精讲:从fork到信号量,避坑指南
简介这份资源是西安电子科技大学操作系统课程的实验报告文档面向正在学习操作系统原理、需要完成上机实验或复习进程与线程编程的高校学生。内容围绕Linux环境下gcc编程展开完整记录了进程建立、线程共享进程数据、信号通信、匿名管道与命名管道通信、信号量实现进程同步等六个实验每个实验均包含实验目的、软硬件环境、程序代码与分析、运行截图及心得体会其中fork()与waitpid()协同、pthread_create()操作全局变量、kill()发送信号等关键函数都有对应说明。资源包共1个doc文件约377KB以实验报告正文形式组织结构按实验序号依次排列便于按章节查阅与对照复现。目前已有1350人学习下载适合作为课程实验的参考范例也可用于梳理进程创建、线程数据共享与进程间通信的实现思路帮助读者理解并发编程中的同步与互斥机制。1. 从一份操作系统实验报告说起六个实验到底能跑通什么很多人第一次接触 Linux 系统编程不是从教材上的进程状态图开始而是从一份实验报告里的fork()开始。这份操作系统上机实验报告把六个实验串成了一条线进程建立、线程共享数据、信号通信、匿名管道、命名管道、信号量同步。它解决的不是“操作系统是什么”这种概念问题而是“在 Linux 下用 gcc 把进程和线程真正跑起来”的落地问题。适合刚学完进程管理、进程间通信章节需要把课堂原理变成可编译代码的读者也适合已经工作但想补系统编程基本功的从业者。下面按实验顺序拆代码、讲参数、标坑位。2. 进程建立与线程共享fork、waitpid、pthread_create 三个入口2.1 fork 之后父子进程各走哪条路实验一的核心是fork()创建子进程子进程读文件父进程用waitpid()等子进程结束。这段代码看起来简单但fork()的返回值判断是第一个分水岭返回 0 是子进程返回大于 0 是父进程拿到的子进程 pid返回负数是创建失败。很多人第一次跑的时候只写if(fpid 0)忘了父进程分支结果子进程读完文件父进程已经退出终端提示符和子进程输出混在一起。#include sys/unistd.h #include sys/wait.h #include unistd.h #include stdio.h #include stdlib.h int main() { pid_t fpid fork(); // 创建子进程返回值决定分支 int count 0; if (fpid 0) { printf(error\n); } else if (fpid 0) { // 子进程读文件并输出 printf(子进程我是子进程,id%d开始读取文件\n, getpid()); FILE *fp fopen(test1file.txt, r); if (fp NULL) { printf(未找到文件\n); } else { char text[2000]; while (fgets(text, 2000, fp) ! NULL) { puts(text); } fclose(fp); // 原报告漏了关闭补上避免句柄泄漏 } printf(子进程读取文件结束\n); exit(1); // 子进程必须显式退出否则会继续走父进程分支 } else { // 父进程等待指定子进程结束 printf(父进程我是父进程id%d ,我要等待子进程\n, getpid()); int ret waitpid(0, NULL, 0); printf(父进程子进程 已经结束ret%d\n, ret); } return 0; }waitpid(0, NULL, 0)三个参数分别是pid 传 0 表示等待同一进程组内任意子进程传具体 pid 表示等指定子进程第二个参数是子进程退出状态缓冲区传 NULL 表示不关心第三个参数 0 表示阻塞等待。这里有个容易翻车的点如果父进程在fork()之前就打开了文件子进程会继承文件描述符父子进程共享文件偏移量读出来的内容可能错位。实验一让子进程自己fopen正好避开了这个问题。编译命令用gcc -o exp1 exp1.c运行前在同目录放一个test1file.txt否则子进程只会打印“未找到文件”。想观察父子进程交替可以在父进程waitpid前加sleep(1)但别把sleep当成同步手段它只是让调度顺序看起来稳定真正的同步靠waitpid。2.2 线程共享全局变量pthread_create 的链接坑实验二用pthread_create()创建线程线程函数里修改全局变量shdata主线程sleep(3)后打印发现值已经变了。这个实验想说明线程共享同一进程地址空间全局变量不需要任何特殊机制就能被线程直接读写。但实际动手时第一个坑往往不是逻辑而是编译pthread_create在libpthread里老版本 gcc 必须加-pthread只加-lpthread有时链接顺序不对会报未定义引用。#include stdio.h #include stdlib.h #include pthread.h #include unistd.h int shdata 0; // 全局共享数据线程直接引用 void *creat(void *arg) { printf(new pthread ....\n); printf(shared data %d\n, shdata); shdata; // 线程内修改全局变量 printf(after operating :shared data %d\n, shdata); return (void *)0; } int main() { pthread_t tid; int pth pthread_create(tid, NULL, creat, NULL); if (pth ! 0) { printf(creat thread error\n); return -1; } else { sleep(3); // 给新线程执行时间不是同步原语 printf(creat thread success\n); printf(shared data %d\n, shdata); return 0; } }pthread_create四个参数第一个是输出参数拿到线程 id第二个是线程属性NULL 表示默认属性第三个是线程入口函数签名必须是void *(*)(void *)第四个是传给线程函数的参数这里不需要就传 NULL。编译用gcc -pthread -o exp2 exp2.c运行后主线程打印的shdata应该是 1。注意sleep(3)只是碰运气等线程跑完如果线程函数里有耗时操作主线程可能先打印旧值。正确做法是用pthread_join(tid, NULL)等线程结束实验报告里用sleep是为了演示共享不是示范同步。3. 信号与管道kill、pipe、mkfifo 的通信边界3.1 信号通信kill 发信号signal 注册处理函数实验三让子进程用kill(getppid(), SIGALRM)给父进程发信号父进程用signal(SIGALRM, ouch)注册处理函数把alarm_fired置 1主循环退出。信号是异步通信发送方不关心接收方什么时候处理接收方在信号到来时打断当前执行流去跑处理函数。这里最隐蔽的坑是竞态父进程如果先进入while(!alarm_fired)再注册signal子进程的信号可能在注册之前就到了默认动作会直接终止父进程。#include unistd.h #include sys/types.h #include stdlib.h #include stdio.h #include signal.h static int alarm_fired 0; void ouch(int sig) { alarm_fired 1; // 信号处理函数里只做置位别做复杂 IO } int main() { pid_t pid; pid fork(); if (pid 0) { perror(fork failed\n); exit(1); } else if (pid 0) { kill(getppid(), SIGALRM); // 子进程向父进程发信号 exit(0); } else { signal(SIGALRM, ouch); // 必须在子进程发信号前完成注册 while (!alarm_fired) { printf(no signal\n); } if (alarm_fired) printf(\nI got a signal %d\n, SIGALRM); } return 0; }kill(pid, sig)第一个参数是目标进程 idgetppid()拿父进程 id第二个参数是信号编号SIGALRM默认动作是终止进程所以必须提前用signal或sigaction接管。signal的第二个参数是处理函数指针函数签名void (*)(int)。这段代码在父进程分支里先signal再进循环顺序是对的如果反过来子进程跑得快时父进程会被默认动作杀掉现象就是终端只打印几行no signal然后进程没了没有任何报错。排查这类问题先看信号注册顺序再看有没有用sigaction替代signal来避免老接口的行为差异。3.2 匿名管道pipe 的读写端关闭顺序实验四用pipe(fd)创建匿名管道父进程关读端写数据子进程关写端读数据。匿名管道只能用于有亲缘关系的进程因为 fd 数组靠 fork 继承。这段代码里父进程写完就关写端子进程sleep(3)后读能读到数据是因为管道缓冲区还在。但很多人改代码时把close(fd[1])漏掉子进程read会一直阻塞等更多数据因为写端没关内核认为还可能有人写。#include stdio.h #include unistd.h #include stdlib.h #include string.h #include sys/types.h #define MAX_LINE 80 int main() { int fd[2]; pid_t child_pid; if (pipe(fd) 0) { printf(管道创建成功\n); child_pid fork(); if (child_pid -1) { printf(子进程创建失败\n); } else if (child_pid 0) { close(fd[1]); // 子进程关写端否则 read 不返回 EOF sleep(3); char readbuf[MAX_LINE]; read(fd[0], readbuf, sizeof(readbuf)); printf(子进程读取数据成功\n); printf(data%s\n, readbuf); close(fd[0]); exit(0); } else { close(fd[0]); // 父进程关读端 char str[] hello the world; write(fd[1], str, strlen(str)); printf(父进程写入数据成功\n); close(fd[1]); // 写完关写端子进程才能读到 EOF return 0; } } else { printf(管道创建失败\n); } }pipe(fd)的fd[0]是读端fd[1]是写端方向别记反。read返回实际读到的字节数返回 0 表示写端全关且无数据返回 -1 是出错。这段代码有个小问题readbuf没有初始化也没有补\0如果读到的数据刚好占满缓冲区printf(%s)会越界读。稳妥写法是char readbuf[MAX_LINE] {0};并且read最多读sizeof(readbuf)-1。另外sleep(3)在这里只是演示父子执行顺序真实场景应该靠管道本身的阻塞语义同步不需要睡。3.3 命名管道mkfifo 的打开顺序与阻塞实验五用mkfifo(./fifo, 0666)创建命名管道子进程以O_WRONLY打开写入父进程以O_RDONLY打开读取。命名管道在文件系统里有名字无亲缘关系的进程也能用。但open命名管道时只读打开会阻塞到有写端打开只写打开会阻塞到有读端打开这个阻塞特性是新手最容易卡住的地方如果父子进程都先open再fork或者打开顺序和读写方向不匹配程序会停在open不动看起来像死循环。#include stdio.h #include stdlib.h #include sys/types.h #include sys/stat.h #include fcntl.h #include unistd.h #include string.h int main() { int ret mkfifo(./fifo, 0666); // 已存在会返回 -1先 unlink 或忽略 EEXIST if (ret 0) { printf(管道创建失败\n); exit(0); } else { printf(命名管道创建成功\n); } pid_t fpid fork(); if (fpid 0) { printf(创建子进程失败\n); } else if (fpid 0) { int fd open(./fifo, O_RDONLY); // 阻塞到有写端打开 if (fd 0) { printf(读数据出错\n); exit(0); } char buf[BUFSIZ] {0}; int n read(fd, buf, sizeof(buf) - 1); if (n 0) { printf(父进程read buffer后 %s\r\n, buf); } close(fd); } else { int fd open(./fifo, O_WRONLY); // 阻塞到有读端打开 if (fd 0) { printf(写数据出错\n); exit(0); } char buf[100] HELLO,WORLD; sleep(1); write(fd, buf, strlen(buf)); close(fd); } return 0; }mkfifo第二个参数是权限位0666会被 umask 削掉一部分实际权限看ls -l ./fifo。重复运行前先rm -f ./fifo否则mkfifo返回 -1 直接退出。父进程read用sizeof(buf)-1留一个字节给\0比原报告的sizeof(buf)安全。命名管道和匿名管道的区别不在读写接口而在生命周期和适用范围匿名管道随进程结束消失命名管道留在文件系统里适合两个独立程序约定路径通信。4. 信号量同步生产者消费者里的 P/V 与共享内存4.1 三个信号量的初始值决定程序能不能跑对实验六是六个实验里最厚的一个一个生产者进程、两个消费者进程通过mmap匿名共享映射放缓冲区array和读写下标set、get、总和sum用三个 System V 信号量fullid、emptyid、mutxid做同步。初始值分别是fullid0已有产品数、emptyidMAXSEM5空缓冲区数、mutxid1互斥锁。这三个初始值只要错一个现象就很典型fullid初值给 1消费者会去读还没生产的槽位emptyid初值给 0生产者一开始就阻塞mutxid初值给 0所有进程卡在第一个 P 操作。#include sys/mman.h #include sys/types.h #include sys/sem.h #include fcntl.h #include unistd.h #include stdio.h #include errno.h #include time.h #include stdlib.h #define MAXSEM 5 union semun { int val; struct semid_ds *buf; unsigned short *array; }; int fullid, emptyid, mutxid; int main() { struct sembuf P, V; union semun arg; int *array, *sum, *get, *set; // 共享映射父子进程看到同一块内存 array mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); sum mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); set mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); get mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); fullid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); emptyid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); mutxid semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); arg.val 0; semctl(fullid, 0, SETVAL, arg); // 初始没有产品 arg.val MAXSEM; semctl(emptyid, 0, SETVAL, arg); // 初始 5 个空槽 arg.val 1; semctl(mutxid, 0, SETVAL, arg); // 互斥锁初值 1 V.sem_num 0; V.sem_op 1; V.sem_flg SEM_UNDO; P.sem_num 0; P.sem_op -1; P.sem_flg SEM_UNDO; pid_t fpid1 fork(); if (fpid1 0) { int i 0; while (i 100) { semop(emptyid, P, 1); // 等空槽 semop(mutxid, P, 1); // 进临界区 array[(*set) % MAXSEM] i 1; (*set); printf(produce %d\n, i 1); semop(mutxid, V, 1); // 出临界区 semop(fullid, V, 1); // 产品数加一 i; } sleep(1); printf(producer is over\n); exit(0); } else { pid_t fpid2 fork(); if (fpid2 0) { while (1) { semop(fullid, P, 1); semop(mutxid, P, 1); if (*get 100) break; *sum array[(*get) % MAXSEM]; printf(the comsumer A get number %d\n, array[(*get) % MAXSEM]); (*get); if (*get 100) { printf(the sum is %d\n, *sum); break; } semop(mutxid, V, 1); semop(emptyid, V, 1); sleep(0.5); } printf(consumer A is over\n); exit(0); } else { while (1) { semop(fullid, P, 1); semop(mutxid, P, 1); if (*get 100) break; *sum array[(*get) % MAXSEM]; printf(the comsumer B get number %d\n, array[(*get) % MAXSEM]); (*get); if (*get 100) { printf(the sum is %d\n, *sum); break; } semop(mutxid, V, 1); semop(emptyid, V, 1); sleep(0.5); } printf(consumer B is over\n); exit(0); } } return 0; }semget(IPC_PRIVATE, 1, IPC_CREAT | 0666)创建只有一个信号量的集合IPC_PRIVATE表示不共享给无关进程靠 fork 继承。semctl(id, 0, SETVAL, arg)把第 0 号信号量设成arg.val。struct sembuf里sem_num是集合内下标sem_op为 -1 是 P 操作、1 是 V 操作sem_flg用SEM_UNDO让进程异常退出时内核自动回滚避免死锁。编译用gcc -o exp6 exp6.c运行后应该看到生产者从 1 打到 100两个消费者交替取数最后打印总和 5050。4.2 共享内存映射与信号量配合的检查点这段代码里mmap的MAP_SHARED | MAP_ANONYMOUS是关键MAP_SHARED让 fork 后的父子进程映射同一物理页MAP_ANONYMOUS表示不关联文件fd传 -1。如果误写成MAP_PRIVATE每个进程各有一份副本set、get、sum互不可见现象是生产者一直生产、消费者一直读 0程序不报错但结果全错。另一个检查点是(*set) % MAXSEM和(*get) % MAXSEM必须用同一套取模逻辑缓冲区大小MAXSEM和生产者总数 100 没有整除关系靠取模循环复用槽位写错一个下标就会读到脏数据。原报告提到“消费者取出生产者还未生产的产品”最后查出来是一个 PV 操作写错。这类问题的排查顺序我一般这样走先看fullid和emptyid的 P/V 有没有配对生产者是 P(empty) 后 V(full)消费者是 P(full) 后 V(empty)方向反了就会提前消费再看mutxid的 P/V 是否成对包住临界区漏掉 V 会让另一个进程永久阻塞最后看semctl初始化有没有在 fork 之前完成如果在子进程里初始化父进程看到的还是旧值。5. 避坑与排查六个实验里最容易翻车的五处现象程序编译报undefined reference to pthread_create。原因pthread库没链接或者只写了-lpthread但放在源文件前面导致链接顺序不对。解决编译命令改成gcc -pthread -o exp2 exp2.c-pthread同时处理编译和链接标志比单独-lpthread稳。现象实验一子进程读文件输出为空或者父进程没等子进程就结束。原因test1file.txt路径不对子进程fopen返回 NULL 只打印“未找到文件”或者父进程分支漏了waitpid子进程还在跑父进程已经return。解决用绝对路径或确认运行目录父进程分支保留waitpid(0, NULL, 0)子进程分支末尾必须exit()。现象实验三父进程打印几行no signal后直接退出没有输出I got a signal。原因signal(SIGALRM, ouch)注册晚于子进程killSIGALRM走了默认动作终止进程。解决把signal注册提到fork之前或者父进程分支第一行就注册需要更可控的行为用sigaction替代signal。现象实验四子进程read一直阻塞终端停住。原因父进程写完没close(fd[1])管道写端还开着read认为后面还有数据。解决父进程写完立即close(fd[1])子进程读之前close(fd[1])读端用完也close(fd[0])。现象实验六重复运行报“管道创建失败”或信号量行为异常。原因命名管道./fifo上次没删mkfifo返回 -1System V 信号量用IPC_PRIVATE每次新建但异常退出可能留下未释放的集合ipcs -s能看到残留。解决运行前rm -f ./fifo排查时用ipcs -s看信号量、ipcrm -s id清理残留代码里给semctl返回值加perror别静默失败。6. 把六个实验串成一条验证链从单进程到多进程同步的检查习惯这份实验报告的价值不在于六个程序各自能跑而在于它们覆盖了 Linux 系统编程从进程创建到同步通信的主干。我习惯在跑完每个实验后做一次“反向验证”实验一把waitpid注释掉观察父进程先退出、子进程变孤儿被 init 收养的现象实验二把全局变量改成局部变量传进线程确认线程拿不到主线程栈上的局部变量实验三把signal挪到fork之后复现默认动作杀进程实验四不关写端看read怎么阻塞实验六把MAP_SHARED改成MAP_PRIVATE看消费者读到的全是 0。这些反向操作比正向跑通更能建立边界感。下面这张表是我自己复盘时用的检查清单按实验编号对应关键函数和第一排查点实验关键函数第一排查点典型错误现象进程建立fork/waitpid父子分支是否都有exit/return输出交错、父进程先退线程共享pthread_create编译是否带-pthread链接报未定义引用信号通信kill/signal注册是否早于发送进程被默认动作终止匿名管道pipe/read/write写端是否关闭read永久阻塞命名管道mkfifo/open文件是否残留、打开顺序卡在open不返回信号量同步semget/semop/mmap初值与 P/V 配对提前消费、总和不对还有一个容易被忽略的点实验六里sleep(0.5)传的是浮点数sleep原型是unsigned int sleep(unsigned int)实际会截断成 0消费者循环会跑得很密。想控制节奏用usleep(500000)或者nanosleep别指望sleep(0.5)精确休眠。这类细节在实验报告里不会写但真跑起来输出刷屏、CPU 占用飙高时回头查参数类型往往就是它。从那以后我每次碰多进程代码都强制先跑一遍“关写端、等子进程、查信号量初值”这三步再谈业务逻辑因为翻车基本都翻在这三处。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑