简介:这份资源是西安电子科技大学操作系统课程的实验报告文档,面向正在学习操作系统原理、需要完成Linux环境下进程与线程编程实验的高校学生。内容围绕进程管理、线程同步与进程间通信展开,涵盖进程的建立、线程共享进程数据、信号通信、匿名管道与命名管道通信,以及用信号量实现进程同步等实验模块,每部分均配有实验目的、软硬件环境说明、完整C语言程序及逐段分析、运行截图与心得体会,可帮助读者对照理解fork()、waitpid()、pthread_create()、sem_wait()等核心函数的用法与协同逻辑。资源包共1个文件,为doc格式实验报告,大小约377KB,结构完整便于直接查阅与参考。目前已有1350人学习下载,适合需要梳理实验流程、复盘代码实现与撰写报告的学习者使用。
1. 六份实验代码摆一起,我才看懂进程与通信的骨架
操作系统这门课,光看教材上的进程状态图,考试能过,但一上机就露馅。我前后把六份实验代码反复跑过、改过、也故意改坏过,最后发现一个反直觉的结论:真正卡住人的从来不是fork()怎么写,而是“谁等谁、谁关哪一端、信号量初值设多少”这三件小事。这份实验报告资源把进程建立、线程共享、信号通信、匿名管道、命名管道、信号量同步六个场景串成一条线,每个场景都配了可编译的 C 代码和函数说明。对刚接触 Linux 系统编程的新手,它是一份能照着敲、照着调错的作业底稿;对已经写过并发程序的熟手,它更像一面镜子,能照出你对父子进程协同、管道半双工、PV 操作这些底层机制到底理解到哪一层。下面我按“怎么跑通、怎么判断对错、哪里最容易翻车”来讲,把这份资源里真正值钱的部分拆开。
2. 进程与线程:从 fork 到 pthread_create 的协同逻辑
2.1 fork 之后父子进程到底谁先跑
实验一的核心不是“创建两个进程”这句话,而是创建之后父子进程的执行顺序完全不确定。代码里父进程调用waitpid(0,NULL,0)阻塞等待,子进程去读文件,这个设计把“协同”落到了实处:父进程不抢跑,子进程干完活父进程才继续。我一般会这么判断一段 fork 代码写得对不对——先看fpid的三个分支有没有漏,<0报错、==0子进程、>0父进程,漏一个就是隐患;再看子进程里exit()有没有调,不调的话子进程可能继续执行父进程的代码路径,输出会乱成一锅粥。
pid_t fpid = fork(); if (fpid < 0) { printf("error"); // 创建失败,必须处理 } else if (fpid == 0) { // 子进程分支:读文件 FILE *fp = fopen("test1file.txt", "r"); if (fp == NULL) { printf("未找到文件\n"); // 文件不存在要给出明确提示 } else { char text[2000]; while (fgets(text, 2000, fp) != NULL) { puts(text); } } exit(1); // 子进程必须显式退出 } else { // 父进程分支:等待子进程结束 int ret = waitpid(0, NULL, 0); printf("父进程:子进程已经结束,ret=%d\n", ret); }这段代码里waitpid的第一个参数是 0,含义是等待“同一个进程组里的任意子进程”,不是等某个特定 pid。实验场景只有一个子进程,所以写 0 没问题;一旦有多个子进程,就得传具体 pid 或者用-1等任意子进程。status传NULL表示不关心退出状态,想看子进程是正常退出还是被信号杀掉,就得传一个int变量的地址再用WIFEXITED判断。这些细节教材上一笔带过,但调试时全是它们说了算。
2.2 线程共享全局变量,共享的边界在哪
实验二用pthread_create起一个线程,线程里把全局变量shdata加一,主线程sleep(3)之后再读,发现值变了。这个实验想说明的是同一进程内所有线程共享数据段,但很多人跑完只记住了“会变”,没记住“为什么能变、什么时候变”。全局变量在进程的数据段,线程没有独立地址空间,所以线程改的就是主线程看的同一块内存。sleep(3)在这里不是随便写的,它是用来“等线程跑完”的粗糙手段,正规做法应该是pthread_join。
int shdata = 0; // 全局共享数据,位于进程数据段 void *creat(void *arg) { 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; } sleep(3); // 粗糙等待,正规应使用 pthread_join printf("shared data = %d\n", shdata); return 0; }编译时记得加-lpthread,否则链接阶段会报undefined reference to pthread_create,这是新手第一个翻车点。pthread_create的四个参数分别是线程标识符指针、线程属性(NULL表示默认)、线程函数、传给线程函数的参数。实验里参数传NULL,线程函数签名写成void *creat(void *arg)就行。判断这段代码是否合格,我会看两点:线程函数返回值有没有写return (void *)0,主线程有没有用join或至少sleep保证线程跑完再读结果。少了后者,输出顺序就是玄学。
2.3 进程与线程的选型判断依据
把实验一和实验二放一起对比,选型逻辑就清楚了。需要独立地址空间、一个进程崩了不影响另一个,用进程,代价是通信要走管道、信号、共享内存这些机制;需要频繁共享数据、创建销毁开销小,用线程,代价是要自己处理竞态。这份资源里实验一用fork加waitpid解决协同,实验二用全局变量加sleep演示共享,两个场景刚好把“隔离性”和“共享性”的取舍摆出来了。我一般会这么比:先问数据要不要跨进程共享,要就用线程或共享内存;再问容错要求高不高,高就拆进程。实验代码虽然简单,但这个判断框架是能直接搬到实际项目里的。
3. 进程间通信:信号、匿名管道、命名管道怎么选
3.1 信号通信的异步本质与处理函数
实验三让子进程用kill(getppid(), SIGALRM)给父进程发信号,父进程用signal(SIGALRM, ouch)注册处理函数,把alarm_fired置一,主循环靠这个标志位退出。信号是异步的,父进程在while(!alarm_fired)里空转打印no signal,信号一到处理函数打断当前执行流,回来之后循环条件就变了。这里有个容易忽略的点:alarm_fired应该声明成volatile sig_atomic_t,普通int在编译器优化下可能被缓存到寄存器,导致主循环永远看不到变化。
static volatile sig_atomic_t alarm_fired = 0; // 信号处理函数里改的变量要 volatile void ouch(int sig) { alarm_fired = 1; // 只做最简单的赋值,别在信号处理函数里做复杂操作 } int main() { pid_t pid = fork(); if (pid == 0) { kill(getppid(), SIGALRM); // 子进程向父进程发信号 exit(0); } else { signal(SIGALRM, ouch); // 注册处理函数 while (!alarm_fired) { printf("no signal\n"); } printf("I got a signal %d\n", SIGALRM); } return 0; }信号处理函数里能做的事非常有限,printf严格来说都不该放进去,因为它是非异步信号安全的。实验里把打印放在主循环外面是对的。判断信号代码是否可靠,我会看处理函数是不是只改了标志位、主逻辑是不是在主流程里跑。另外signal在不同系统上语义有差异,更稳的是sigaction,但实验环境用signal足够跑通。
3.2 匿名管道的半双工与端口关闭顺序
实验四用pipe(fd)创建匿名管道,父进程关读端写数据,子进程关写端读数据。管道是半双工的,fd[0]读、fd[1]写,这个方向不能反。最容易翻车的地方是端口没关对:父进程如果不关fd[0],子进程的read可能一直阻塞等不到 EOF;子进程如果不关fd[1],父进程写完之后管道写端还有引用,读端也等不到结束。实验代码里父进程写完关fd[1]、子进程读完关fd[0],这个顺序是对的。
int fd[2]; pipe(fd); // fd[0] 读端,fd[1] 写端 pid_t child_pid = fork(); if (child_pid == 0) { close(fd[1]); // 子进程不用写端,关掉 char readbuf[MAX_LINE]; read(fd[0], readbuf, sizeof(readbuf)); 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)); close(fd[1]); // 写完关闭,读端才能收到 EOF }read的返回值要判断,返回 0 表示对端关闭,返回 -1 表示出错。实验里直接printf读到的缓冲区,没处理读失败的情况,实际用的时候得补上。匿名管道只能用于有亲缘关系的进程,这是它和命名管道最本质的区别,也是选型时的第一道分界线。
3.3 命名管道的文件系统入口与阻塞行为
实验五用mkfifo("./fifo", 0666)在文件系统里创建一个管道文件,然后父子进程分别以只读、只写方式open它。命名管道有名字,所以无亲缘关系的进程也能通过路径打开同一个管道通信。这里的关键判断点是open的阻塞语义:以只读方式打开一个命名管道会阻塞,直到有进程以写方式打开它;反过来也一样。实验里父进程先open读端会卡住,直到子进程open写端,所以代码里子进程sleep(1)再写,顺序上能跑通,但更稳的做法是让写端先开或者用O_NONBLOCK。
int ret = mkfifo("./fifo", 0666); // 创建命名管道,权限 0666 if (ret < 0) { printf("管道创建失败\n"); // 已存在同名文件也会失败,调试时要先删 exit(0); } pid_t fpid = fork(); if (fpid > 0) { int fd = open("./fifo", O_RDONLY); // 只读打开,会阻塞到写端出现 char buf[BUFSIZ] = {0}; read(fd, buf, sizeof(buf)); printf("父进程读到 = %s\n", buf); close(fd); } else { int fd = open("./fifo", O_WRONLY); // 只写打开 char buf[100] = "HELLO,WORLD"; write(fd, buf, strlen(buf)); close(fd); }mkfifo的第二个参数是权限位,受umask影响,实际权限可能不是 0666。重复运行实验前要先unlink("./fifo")或者手动删掉,否则mkfifo返回 -1,程序直接退出,很多人第一次跑就卡在这。命名管道适合跨独立进程的简单数据传递,但它是半双工、基于字节流的,没有消息边界,传结构体要自己处理粘包。
3.4 三种通信方式的对比结论
| 维度 | 信号 | 匿名管道 | 命名管道 |
|---|---|---|---|
| 通信方向 | 单向通知 | 半双工 | 半双工 |
| 适用进程关系 | 任意(需 pid) | 有亲缘关系 | 无亲缘关系 |
| 数据量 | 仅信号编号 | 字节流 | 字节流 |
| 同步方式 | 异步打断 | 读写阻塞 | 打开与读写均可能阻塞 |
| 典型翻车点 | 处理函数做复杂操作 | 端口未关导致阻塞 | 文件已存在导致创建失败 |
选型时先看进程有没有亲缘关系,没有就排除匿名管道;再看传的是“通知”还是“数据”,只传事件用信号最轻;传数据且进程独立用命名管道。这份资源把三种方式各给了一个可跑的最小例子,价值在于你能在同一台机器上把三种都跑一遍,对比阻塞行为和端口处理,比看十页教材管用。
4. 信号量同步:生产者消费者里的 PV 顺序与初值
4.1 三个信号量的职责划分
实验六是整份资源里最厚的一个,生产者进程生产 100 个产品,两个消费者进程取走并求和,用共享内存放缓冲区,用三个信号量协调:fullid表示已有产品数,初值 0;emptyid表示空缓冲区数,初值MAXSEM;mutxid是互斥锁,初值 1。这三个初值不是随便定的,fullid为 0 意味着消费者一开始就得等,emptyid为 5 意味着生产者最多先放 5 个,mutxid为 1 保证同一时刻只有一个进程动缓冲区。
// 共享内存映射,父子进程看到同一块 int *array = mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *set = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *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); union semun arg; arg.val = 0; semctl(fullid, 0, SETVAL, arg); // 初始无产品 arg.val = MAXSEM; semctl(emptyid, 0, SETVAL, arg); // 初始全空 arg.val = 1; semctl(mutxid, 0, SETVAL, arg); // 互斥锁mmap用MAP_SHARED | MAP_ANONYMOUS是为了让 fork 出来的子进程共享同一块匿名内存,set和get分别是生产指针和消费指针,也要共享,所以单独映射。semget的IPC_PRIVATE表示创建私有信号量集,0666是权限。semctl的SETVAL用来设初值,union semun这个联合体在有些系统上需要自己定义,glibc 头文件里不一定给全,编译报错时补上就行。
4.2 PV 操作的顺序为什么不能换
生产者每生产一个产品,先对emptyid做 P、再对mutxid做 P,放数据,然后对mutxid做 V、对fullid做 V。消费者反过来,先 Pfullid再 Pmutxid,取数据,Vmutxid再 Vemptyid。这个顺序是死规矩:如果生产者先 Pmutxid再 Pemptyid,缓冲区满的时候生产者拿着锁等空位,消费者想取数据却拿不到锁,直接死锁。实验正文里提到“消费者取出生产者还未生产的产品”,根子就是某个 P/V 顺序写反或者漏了。
struct sembuf P = {0, -1, SEM_UNDO}; // P 操作:信号量减一 struct sembuf V = {0, 1, SEM_UNDO}; // V 操作:信号量加一 // 生产者循环体 semop(emptyid, &P, 1); // 等空位 semop(mutxid, &P, 1); // 进临界区 array[(*set) % MAXSEM] = i + 1; // 放产品 (*set)++; semop(mutxid, &V, 1); // 出临界区 semop(fullid, &V, 1); // 产品数加一 // 消费者循环体 semop(fullid, &P, 1); // 等产品 semop(mutxid, &P, 1); // 进临界区 *sum += array[(*get) % MAXSEM]; // 取产品 (*get)++; semop(mutxid, &V, 1); // 出临界区 semop(emptyid, &V, 1); // 空位加一SEM_UNDO的作用是进程异常退出时自动撤销它做过的 P 操作,避免信号量永久卡死,调试阶段建议保留。semop的第三个参数是操作数组的长度,这里每次只操作一个信号量所以传 1。判断这段同步逻辑对不对,我会盯三处:P 的顺序是不是先资源信号量后互斥信号量、V 的顺序是不是先互斥后资源、set和get的取模是不是都用了MAXSEM。三处都对,生产者消费者就不会出现取空或覆盖。
4.3 共享内存与信号量的配合边界
共享内存负责“数据放哪”,信号量负责“什么时候能放能取”,两者职责不能混。实验里array、sum、get、set全在共享内存,信号量只做计数和互斥。一个常见误用是把互斥锁省掉,觉得fullid和emptyid已经能保证安全,但两个消费者可能同时通过fullid的 P 操作进入临界区,同时改get和sum,结果就是数据错乱。mutxid不能省。另一个边界是MAXSEM设成 5,缓冲区小,生产消费交替频繁,更容易暴露顺序错误,调试时故意把MAXSEM改成 1 或 2,死锁和竞态会更快现形。
5. 避坑与排查:六个实验里最容易翻车的五处
5.1 编译链接报 pthread_create 未定义
现象:实验二代码gcc test.c -o test之后报undefined reference to 'pthread_create'。原因:pthread 库不是默认链接的,需要显式加-lpthread。解决:编译命令改成gcc test.c -o test -lpthread,放在源文件后面,顺序反了在某些链接器上也会失效。
5.2 命名管道重复创建直接退出
现象:实验五第二次运行打印“管道创建失败”然后退出。原因:mkfifo对已存在的同名文件返回 -1,./fifo上次跑完没删。解决:运行前unlink("./fifo"),或者在mkfifo失败时判断errno == EEXIST并继续使用已有管道。调试脚本里加一句rm -f ./fifo最省事。
5.3 匿名管道读端一直阻塞
现象:实验四子进程read不返回,程序挂住。原因:父进程写完没关fd[1],管道写端还有打开的文件描述符,读端等不到 EOF。解决:检查父子进程是否各自关闭了不用的那一端,父进程写完立刻close(fd[1]),子进程读完close(fd[0])。
5.4 信号处理函数改了变量主循环却看不到
现象:实验三父进程一直打印no signal,信号明明发了。原因:alarm_fired是普通int,被编译器优化进寄存器,主循环读的是寄存器旧值。解决:声明成volatile sig_atomic_t,强制每次从内存读。这个坑在-O2优化下必现,-O0下可能侥幸跑通,别被没优化的结果骗了。
5.5 生产者消费者死锁或取到空数据
现象:实验六跑着跑着不动了,或者消费者打印出 0 和未生产的值。原因:P/V 顺序写反,或者mutxid漏做 P,或者fullid、emptyid初值设错。解决:按“先资源后互斥做 P、先互斥后资源做 V”逐行核对,把MAXSEM调小到 1 加速复现,用ipcs -s查看残留信号量,必要时ipcrm清掉再跑。
6. 把六份代码跑成自己的判断力
这份实验报告资源真正的用法,不是把六段代码抄进作业交掉,而是拿它当对照物,逼自己回答几个问题:fork之后父进程为什么必须等、管道两端为什么必须各关一个、信号量初值为什么是 0/5/1 而不是别的数。我的习惯是每跑通一个实验,就故意改坏一处——把waitpid删掉看输出乱序、把close(fd[1])注释掉看读端挂起、把mutxid的 P 操作挪到emptyid前面看死锁——改坏一次再修回来,比顺顺利利跑十遍记得牢。六个实验覆盖了进程创建、线程共享、信号、匿名管道、命名管道、信号量同步,这条线走完,Linux 系统编程里“隔离与共享、同步与通信”的基本盘就有了。资源里的代码可以直接编译,函数说明和心得体会也标出了每个实验的意图,照着跑、照着改,比对着教材空想强得多。需要这份实验报告底稿的,按文中的代码结构和函数清单去取用,先跑通再改坏,最后留下的是你自己的排查手感。从那以后我每次写多进程程序,都会先把端口关闭和 P/V 顺序在纸上过一遍再敲键盘,希望帮到你。
本文还有配套的精品资源,点击获取