简介:这份资源是西安电子科技大学操作系统课程的上机实验报告,面向正在学习操作系统、需要完成Linux环境下进程与线程编程实验的高校学生。报告围绕进程管理、线程同步与进程间通信展开,完整记录了进程的建立、线程共享进程数据、信号通信、匿名管道与命名管道通信,以及用信号量实现进程同步等六个实验,每个实验均包含实验目的、软硬件环境、程序代码与分析、运行截图和心得体会,便于读者对照复现与理解fork、waitpid、pthread_create、signal、sem_init等核心函数的用法。资源包共1个doc文档,约377KB,结构完整、内容详实,适合作为实验参考与复习材料。目前已有1350人学习下载,可帮助读者快速掌握Linux下C语言实现进程创建、线程数据共享与通信同步的编程思路,为后续并发编程与系统级开发打下基础。
1. 六份实验代码摆一起:操作系统课设真正该盯的是哪几行
操作系统实验这门课,很多人交完报告就忘,等到面试被问“fork 之后父进程和子进程谁先跑”“管道读端关了会怎样”才发现当年那份报告里全是截图,没有一行能复用的判断。我前后翻过好几份操作系统上机报告,最值得拿出来讲的是这一份:六个实验,从进程建立、线程共享数据、信号通信、匿名管道、命名管道到信号量同步,代码全在,心得也写了,但真正有价值的是那几个容易被忽略的细节——waitpid的返回值、fork之后文件描述符的继承、semop的SEM_UNDO标志、命名管道open的阻塞语义。这篇不按实验报告的顺序复述,而是按“哪些代码能直接抄、哪些坑我踩过、哪些判断依据面试和课设都用得上”来拆。如果你正在做操作系统上机、准备课设答辩,或者想把这几个系统调用真正用熟,下面每一段都对应一个能跑的场景。
2. 进程与线程:fork、waitpid、pthread_create 三行代码背后的判断依据
2.1 fork 之后到底谁先跑:别信“子进程一定后执行”
实验一的代码结构很典型:fork()之后用fpid分流,子进程读文件,父进程waitpid(0,NULL,0)等子进程结束。很多人写报告时默认“父进程先打印、子进程后打印”,但实际跑起来顺序是不确定的,调度器说了算。这份代码里父进程先打印“我要等待子进程”,再调waitpid,看起来顺序固定,但那只是因为父进程恰好先被调度。真正保证“子进程读完文件父进程才继续”的不是打印顺序,而是waitpid这个阻塞调用。
判断依据在这里:waitpid(0,NULL,0)的第一个参数0表示等待“同进程组的任意子进程”,第二个参数传NULL表示不关心退出状态,第三个参数0表示阻塞等待。如果你要精确等待某一个子进程,第一个参数应该传具体的pid;如果你想拿子进程的退出码做判断,第二个参数得传int status的地址,再用WIFEXITED(status)和WEXITSTATUS(status)去解析。这份报告里传了NULL,说明作者只关心“子进程结束了”这个事实,不关心它怎么结束的——课设够用,但生产代码里这是黑匣子,子进程崩了你都不知道。
还有一个容易被忽略的点:子进程里fopen打开文件后没有fclose,靠exit(1)退出时由内核回收。这在实验里没问题,但如果子进程不是exit而是从main返回,或者父进程在子进程退出前又fork了一次,文件描述符的引用计数就会出问题。我一般会建议在fgets循环结束后显式fclose(fp),哪怕后面马上exit,养成习惯比省一行代码重要。
2.2 线程共享数据:sleep(3) 不是同步,是赌命
实验二的代码更短:全局变量shdata,pthread_create起一个线程,线程里shdata++,主线程sleep(3)后打印。报告心得写“理解了线程共享数据”,但代码里那个sleep(3)暴露了一个典型误区——用睡眠代替同步。线程里只做了一次自增和两次打印,3 秒足够跑完,所以结果看起来“正确”。但如果线程函数里有个耗时操作,或者机器负载高,主线程可能在shdata++之前就打印了旧值,这时候报告里的“共享数据变了”就不成立了。
正确的判断依据是:线程共享进程的地址空间,shdata是全局变量,所有线程看到的是同一块内存,这一点没错;但“一个线程改了另一个线程能立刻看到”需要同步机制保证可见性和顺序,sleep只是碰巧。课设里这么写能过,但如果你在答辩时被问“如果去掉 sleep 会怎样”,答“可能打印 0”比答“没问题”更能体现你懂。我一般会在这个实验里补一句:把sleep(3)换成pthread_join(tid,NULL),主线程等线程结束再打印,结果才是确定的。pthread_join的第二个参数可以接线程返回值,这份代码里线程返回(void*)0,传NULL忽略即可。
另外注意pthread_create的返回值判断:代码里pth!=0就报错返回,这是对的,因为pthread_create成功返回 0,失败返回错误码,不是设置errno。这一点和很多系统调用不一样,写报告时如果写成“检查 errno”就是错的。
2.3 编译命令里的隐藏条件:-pthread 不是可选项
实验二用了pthread_create,但报告里没写编译命令。实际编译时如果只写gcc test.c -o test,链接阶段会报undefined reference to pthread_create。正确写法是gcc test.c -o test -pthread,注意是-pthread而不是-lpthread,前者会同时设置编译和链接阶段的宏与库,后者只加链接库,在某些平台上会影响_REENTRANT宏的定义。这个细节课设报告里经常漏,但答辩时老师一问“你怎么编译的”就露馅。我一般会把编译命令写进报告的“实验环境”里,gcc -pthread一行,比事后解释省事。
3. 进程间通信四种方式:信号、匿名管道、命名管道的代码逐段拆
3.1 信号通信:kill 发信号之前,signal 必须先注册
实验三的代码有一个顺序问题值得说:子进程kill(getppid(), SIGALRM)在父进程signal(SIGALRM, ouch)之前就可能执行。fork之后父子进程谁先跑不确定,如果子进程先跑完kill,此时父进程还没注册处理函数,SIGALRM的默认动作是终止进程,父进程直接没了,后面的while(!alarm_fired)根本不会执行。这份代码能跑通,大概率是因为父进程先被调度到了signal那一行,但这是运气,不是设计。
稳妥的写法是在fork之前就signal(SIGALRM, ouch),让处理函数在父子分流之前就位。这样无论谁先跑,信号都有处理函数接住。判断依据是:信号的注册是进程级的,fork之后子进程继承父进程的信号处理设置,所以在fork前注册,子进程也有一份,虽然子进程不发信号给自己,但至少父进程这边不会漏。
还有一个细节:ouch处理函数里只改了alarm_fired这个static int,没有做任何打印。这是对的,信号处理函数里能安全调用的函数很少,printf不在保证可重入的列表里,严格说在信号处理函数里调printf是未定义行为。课设里这么写常见,但如果你在报告里加一句“处理函数只置标志位,打印放在主循环”,老师会知道你查过资料。while(!alarm_fired)这个忙等循环也是同理,实际代码里应该用pause()或sigsuspend()替代,但课设演示够用。
3.2 匿名管道:先关哪一端,决定了会不会死锁
实验四的管道代码是六个实验里最值得逐行看的。pipe(fd)创建管道,fd[0]读端、fd[1]写端,fork之后子进程关写端、父进程关读端,然后父写子读。这个“各关一端”的动作是管道通信的核心:管道是单向的,如果父进程不关读端、子进程不关写端,读端在写端没关时会一直阻塞等数据,写端在缓冲区满时会阻塞等读端消费,两边都不关就是死锁的温床。
代码里子进程先sleep(3)再read,父进程write之后立刻close(fd[1])。这里有个隐含保证:父进程关掉写端后,子进程的read在读完数据后会返回 0(EOF),不会永久阻塞。如果父进程不关写端,子进程read完 “hello the world” 之后还会继续等,因为管道里可能还有数据。报告里没提这一点,但这是判断“管道通信写对了没有”的关键依据:写端关闭是读端收到 EOF 的前提。
另外注意read(fd[0],readbuf,sizeof(readbuf))没有检查返回值。read返回实际读到的字节数,返回 -1 是出错,返回 0 是 EOF。这份代码里父进程写入了 15 个字节(strlen("hello the world")),子进程缓冲区MAX_LINE是 80,一次read能读完,所以打印正常。但如果写入数据超过缓冲区,一次read读不完,就需要循环读。课设数据量小看不出问题,换成大文件就是翻车现场。我一般会在报告里补一句“read返回值需判断,循环读直到返回 0”,这是从实验代码到可用代码的分界线。
3.3 命名管道:mkfifo 的 0666 和 open 的阻塞语义
实验五用mkfifo("./fifo",0666)创建命名管道,然后fork,父进程open只读、子进程open只写。这里有两个容易踩的点。
第一,mkfifo的第二个参数0666是权限位,实际权限还会受umask影响。如果umask是 022,创建出来的管道权限是 0644,不是 0666。课设里单用户跑没问题,但如果换用户跑,写端可能因为权限不够打不开。判断依据:mkfifo的 mode 参数和open的 mode 一样,都是“请求的权限”,最终权限是mode & ~umask。报告里写 0666 没错,但知道 umask 这层的人不多。
第二,open命名管道的阻塞行为:以只读方式open一个命名管道会阻塞,直到有进程以只写方式打开它;反之亦然。代码里父进程先open(O_RDONLY),子进程后open(O_WRONLY),父进程会阻塞在open上等子进程,子进程打开后两边继续。这个顺序能跑通,但如果子进程先跑、父进程后跑,结果一样,因为阻塞是双向等待。真正要注意的是:如果只开一端,另一端永远不来,open就永久阻塞。实验里父子进程都在,所以没问题;实际用命名管道做两个独立程序通信时,一端没起来,另一端就卡在open上,这是排查“程序没反应”时的第一怀疑对象。
代码里子进程sleep(1)再写,父进程read之前打印了buf的初始值,这个打印在read之前,所以输出是空的,报告截图里如果看到“read buffer前 =”后面没内容,是正常的。read之后打印buf,能看到 “HELLO,WORLD”。这里read的返回值ret被判断了>0,比实验四严谨一点,但ret为 0 或 -1 的情况没处理,课设够用。
3.4 三种通信方式的选型对照
把信号、匿名管道、命名管道放一起比,判断依据其实很清楚:
| 维度 | 信号 | 匿名管道 | 命名管道 |
|---|---|---|---|
| 通信方向 | 单向,只能传信号编号 | 单向,fd[0] 读 fd[1] 写 | 单向,open 时定方向 |
| 数据载体 | 无数据,只有信号类型 | 字节流,内核缓冲区 | 字节流,文件系统节点 |
| 适用关系 | 任意进程(需权限) | 必须有亲缘关系(fork 继承 fd) | 任意进程,靠路径名 |
| 阻塞点 | 处理函数执行时机不确定 | 读空阻塞、写满阻塞 | open 等待对端、读写同管道 |
| 典型误用 | 处理函数里调 printf | 不关写端导致读端等不到 EOF | 只开一端导致 open 永久阻塞 |
这张表不是让你背,是让你在课设报告里写“为什么这个实验用管道不用信号”时有话说:信号传不了数据,管道能传字节流;匿名管道依赖 fork 继承的文件描述符,命名管道靠路径名让无亲缘进程也能连上。选型判断就这三行。
4. 信号量同步:生产者消费者代码里那几行 PV 到底该怎么读
4.1 三个信号量的分工:full、empty、mutex 各管什么
实验六的代码量最大,但结构是标准的:fullid表示已有产品数,初值 0;emptyid表示空缓冲区数,初值MAXSEM(5);mutxid是互斥锁,初值 1。生产者每生产一个产品,先P(emptyid)再P(mutxid),写入后V(mutxid)再V(fullid)。消费者反过来,先P(fullid)再P(mutxid),取出后V(mutxid)再V(emptyid)。
这个顺序不是随便定的。P(emptyid)在前,保证缓冲区有空位才继续;P(mutxid)在后,保证同一时刻只有一个进程操作共享数组。如果反过来先P(mutxid)再P(emptyid),当缓冲区满时,生产者拿着互斥锁等空位,消费者想消费却拿不到锁,死锁。报告心得里写“刚开始出现了消费者取出生产者还未生产的产品,最后查找是因为一个 PV 操作出现了错误”,说的就是这类顺序问题——大概率是P(fullid)和P(mutxid)的顺序或者某个V漏了。
判断依据:信号量的 P 操作是“申请资源,不够就阻塞”,V 操作是“释放资源,唤醒等待者”。fullid和emptyid是计数信号量,管的是“有没有产品/有没有空位”;mutxid是二元信号量,管的是“能不能进临界区”。三者各司其职,混用就会出问题。
4.2 SEM_UNDO 标志:进程异常退出时的后悔药
代码里P.sem_flg=SEM_UNDO和V.sem_flg=SEM_UNDO这个细节值得单独说。SEM_UNDO的作用是:如果进程在持有信号量期间异常退出(比如段错误、被 kill),内核会自动撤销这个进程对该信号量的操作,相当于帮你补一个 V。没有这个标志,进程崩了之后信号量计数就永久少了一个,其他进程可能永远阻塞。
课设里进程正常exit,加不加SEM_UNDO看不出区别。但如果你在报告里写“考虑了进程异常退出的场景”,这就是加分项。我一般会建议所有semop都带上SEM_UNDO,代价几乎为零,收益是避免一个很难排查的挂死问题。注意SEM_UNDO是每个semop调用独立记录的,不是信号量创建时的属性,所以 P 和 V 都要写。
4.3 共享内存映射:mmap 的 MAP_ANONYMOUS 和 fork 继承
代码用mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)创建共享内存,array、sum、get、set四个指针都映射到匿名共享内存。这里的关键是MAP_SHARED:fork之后父子进程对这块内存的修改互相可见。如果写成MAP_PRIVATE,每个进程有自己的一份拷贝,生产者写的array消费者看不到,程序逻辑就崩了。
MAP_ANONYMOUS表示不关联文件,fd传 -1,offset传 0。这个用法在 Linux 上没问题,但严格说MAP_ANONYMOUS需要_GNU_SOURCE或_DEFAULT_SOURCE宏,某些编译环境下不定义会报错。报告里没提编译宏,实际编译如果报MAP_ANONYMOUS undeclared,在文件开头加#define _GNU_SOURCE即可。这是排查编译错误时的第一反应。
还有一个细节:sum、get、set三个指针各映射了sizeof(int),array映射了sizeof(int)*MAXSEM。fork之后子进程继承这些映射,所以生产者和消费者操作的是同一块物理内存。(*set)++和(*get)++在mutxid保护下执行,不会竞争。如果去掉mutxid,两个消费者同时*sum += array[...]就会丢更新,这是并发编程里最经典的翻车点,报告心得里没提,但答辩时很可能被问。
4.4 消费者退出条件:*get == 100 的判断位置
代码里消费者循环里先P(fullid)再P(mutxid),然后判断if(*get == 100) break;。这个判断在P(mutxid)之后、V(mutxid)之前,如果 break 了,mutxid和fullid都没有 V 操作,信号量计数就漏了。课设里两个消费者都 break 后进程退出,信号量随进程销毁,看不出问题;但如果这段代码被复用到一个长期运行的服务里,漏掉的 V 会让信号量逐渐失衡。
更稳妥的写法是把退出判断放在P(fullid)之前,或者 break 之前补上V(mutxid)和V(fullid)。报告里没处理这个边界,属于“能跑但不够严谨”。我一般会在报告里加一句“消费者退出前需释放已持有的信号量”,这是从实验代码到工程代码的差距所在。
5. 避坑排查:这六个实验里最容易翻车的五个地方
5.1 现象:父进程打印“等待子进程”后直接结束,子进程输出丢失
原因:父进程没有调waitpid,或者waitpid的第一个参数传错。实验一里如果写成waitpid(-1,NULL,WNOHANG),WNOHANG表示非阻塞,子进程还没结束时直接返回 0,父进程继续往下走,子进程的输出可能还没刷出来父进程就退出了。
解决:确认waitpid第三个参数是0(阻塞),第一个参数是0或具体子进程pid。如果要用WNOHANG,得配合循环轮询,不能调一次就完事。
5.2 现象:管道实验里子进程 read 一直不返回,程序挂住
原因:父进程写完数据后没有close(fd[1]),子进程的read读完已有数据后继续等,因为写端还开着,内核认为“可能还有数据”。实验四代码里父进程write后立刻close(fd[1]),所以能正常返回;如果漏了这行,就是挂死。
解决:写端用完必须关,读端才能收到 EOF(read返回 0)。排查时用strace -f ./program看子进程卡在哪个系统调用上,如果是read且没有返回,先查写端关没关。
5.3 现象:命名管道程序运行后没有任何输出,像卡死
原因:open命名管道时只开了一端,另一端没有进程打开,open阻塞。实验五里父子进程都开了,所以没问题;如果单独跑一个只读程序,没有写端,open(O_RDONLY)就永久阻塞。
解决:确认通信双方都启动了,或者用O_NONBLOCK打开(只读非阻塞打开会立即返回,但读不到数据时read返回 -1 并置errno为EAGAIN)。排查时ls -l ./fifo看管道文件在不在,ps看对端进程起没起。
5.4 现象:信号量实验里消费者打印的产品编号重复或跳号
原因:mutxid的 P/V 没有包住整个临界区,或者array[(*set)%MAXSEM]的写入和(*set)++之间被其他进程插入。实验六代码里P(mutxid)之后写array、(*set)++、打印,然后V(mutxid),临界区是完整的;如果某个 V 漏了或者 P 的顺序错了,就会出现重复消费。
解决:检查每个P(mutxid)是否都有对应的V(mutxid),且成对出现在同一段逻辑里。用ipcs -s看信号量计数,如果某个信号量的semval异常(比如 mutex 不是 0 或 1),说明有进程没释放。
5.5 现象:编译报undefined reference to pthread_create或MAP_ANONYMOUS undeclared
原因:前者是没加-pthread编译选项,后者是没定义_GNU_SOURCE宏。这两个都是环境配置问题,不是代码逻辑问题,但课设里经常卡在这一步。
解决:编译命令统一用gcc test.c -o test -pthread,文件开头加#define _GNU_SOURCE(放在所有#include之前)。如果还报错,检查gcc --version和系统头文件路径,确认pthread.h和sys/mman.h存在。
6. 把这六个实验串成一条线:课设答辩前我会重点复述的三段逻辑
如果只让我从这份报告里挑三段能在答辩时讲清楚的内容,我会选这三段。第一段是fork加waitpid的协同:fork创建子进程后,父子进程的执行顺序由调度器决定,waitpid是父进程主动放弃 CPU 等待子进程结束的手段,它的返回值是子进程pid,失败返回 -1,errno会告诉你原因。这段逻辑能回答“为什么父进程能等到子进程”和“如果子进程先退出父进程还没 wait 会怎样”(子进程变僵尸,父进程 wait 后回收)。第二段是管道的两端关闭:匿名管道靠fork继承文件描述符,命名管道靠路径名打开,两者都是单向字节流,写端关闭是读端收到 EOF 的前提,读端关闭后写端会收到SIGPIPE信号,默认动作是终止进程。这段能解释“管道为什么能当进程同步用”和“写一个已经关闭读端的管道会怎样”。第三段是信号量的 P/V 顺序:P(empty)在P(mutex)之前,V(mutex)在V(full)之前,顺序反了就是死锁或者数据竞争,SEM_UNDO是异常退出时的兜底。这段能讲清“生产者消费者为什么需要三个信号量”和“少一个 V 会出什么问题”。
这三段逻辑对应的代码都在报告里,但报告是按实验顺序排的,答辩时按“进程创建—通信—同步”这条线串起来讲,比逐个实验念代码更有说服力。我一般会在答辩前把这三段的代码各跑一遍,改一个参数看输出变化,比如把waitpid的0改成WNOHANG看父进程提前退出,把管道的close(fd[1])注释掉看子进程挂住,把P(emptyid)和P(mutxid)换个位置看死锁。改一次跑一次,比背十遍代码管用。
回过头看,这份报告最大的价值不是六个实验的截图,而是它把 Linux 下进程、线程、通信、同步的主干系统调用都摸了一遍。fork、pthread_create、kill、pipe、mkfifo、semget/semop,这些名字在面试里出现的频率不低,但真正能说清参数含义和边界条件的人不多。从那以后我每次碰进程间通信的代码,都会先把“谁创建、谁等待、谁关闭、谁释放”这四个问题过一遍,再动手写。希望这份拆解能帮你在课设和面试里少走几个弯路。
本文还有配套的精品资源,点击获取