news 2026/10/24 1:03:44

Linux操作系统实验报告:进程线程与IPC通信实践详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux操作系统实验报告:进程线程与IPC通信实践详解

简介:西安电子科技大学操作系统课程实验报告,面向高校计算机专业学生及需要补强系统编程基础的开发者,报告以Linux环境为依托,用C语言完整演示进程、线程与进程间通信的实现过程。内容覆盖六组核心实验:通过fork创建父子进程并用waitpid完成协同等待;创建线程共享并修改全局数据;利用信号、匿名管道和命名管道实现进程间异步与双向通信;使用信号量对临界区进行互斥保护,避免数据竞争。每个实验均包含可运行源码、程序分析、运行截图与心得体会,方便复现实验现象,并可直接借鉴到课程设计或期末复习中。资源为1个doc文档,压缩包仅377KB,轻量便于下载。已有1350人学习阅读,适合正学习操作系统原理、准备实验报告或备考系统级编程的读者参考。

1. 这份操作系统实验报告:进程、线程与 IPC 的完整落地

如果你正在补操作系统课程实验,大概率会遇到这种场景:老师要求用 Linux 环境写进程控制、线程共享、管道通信,代码能跑但不知道每一步为什么这么写,实验报告更是憋不出来。这份东西就是一份可以直接照做的完整实验记录,六个实验覆盖了 fork/waitpid 进程协同、pthread_create 线程共享数据、signal 信号通信、匿名管道、命名管道,以及用信号量解决生产者消费者问题。它最大的价值不在代码本身,而在于把每次实验的目的、函数选型、运行结果和踩坑过程都串在一起,适合正在做操作系统实验的人当参考模板。我从头到尾拆了一遍,把关键参数、编译方式和常见的翻车点整理在下面。

2. 进程建立与协同:fork/waitpid 的返回值与等待语义

2.1 一次 fork 调用为什么有两次返回

实验一的核心是 fork 创建子进程,再由子进程读取文件、父进程等待子进程结束。很多新手第一次看 fork 都懵:明明只调用了一次,为什么 if 分支里能同时出现父子两种执行路径?原因在于 fork 会把调用进程复制一份,父进程拿到的是子进程的 PID,子进程拿到的是 0,而复制失败时返回负数。所以代码里pid_t fpid=fork()之后的三个分支,本质是同一个程序在两张进程表里各走一边。

这里常见的错误是直接拿 fpid 当普通变量做运算,忽略了返回值本身携带的身份信息。另一个细节是父子进程共享代码段,但数据空间是写时复制的,这意味着在 fork 之后修改全局变量不会互相影响。这一点和后面实验二的线程全局变量形成鲜明对比,一个是拷贝副本,一个是同一块内存。

2.2 子进程读文件、父进程等待的完整代码

实验一的可执行版本我整理如下,文件读取部分做了防御处理,避免 fp 为空时继续操作:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t fpid = fork(); int count = 0; if (fpid < 0) { printf("fork error\n"); return -1; } 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); count++; } 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; }

这段代码遵循了一个固定套路:fork 之后,子进程做具体工作,父进程调用 waitpid 挂起等待。waitpid(0, NULL, 0)里三个参数值得说明一下:第一个参数 0 表示等待任意一个直接子进程,效果等同于wait(NULL);第二个参数传 NULL,表示不关心子进程的退出状态,如果改成传出参数就能拿到 exit 的值;第三个参数 0 表示阻塞等待,不设置 WNOHANG。这里最容易被忽略的是文件路径问题,fopen("test1file.txt", "r")使用的是相对路径,程序在哪里启动就从哪里找,如果你把编译出的可执行文件放在别处运行,就会打印“未找到文件”。请先创建这个文件再跑程序。

2.3 为什么用 waitpid 而不是 sleep

实验报告里明确提到了pid_t vfork(void),但代码用的是 fork,这里补一个选型说明。vfork 的特点是子进程先运行,父进程挂起,并且子进程共享父进程地址空间,不写时复制。老系统里 vfork 效率更高,但语义限制很多,子进程里不能随意修改父进程的局部变量,否则可能弄崩父进程栈。而 fork 配合 waitpid 是更通用的组合:fork 负责创建独立副本,waitpid 负责让父子步调一致。

有一种用 sleep 代替 waitpid 的写法,父进程睡固定时间再继续,这在实验演示中能跑,但本质上是在赌子进程作业时间,不是真正的同步。waitpid 返回时可以得到子进程结束的信号,真正做到“完成了再继续”。实验报告里称这是“进程协同工作”,这个词点到了本质:协同不是各自独立比拼速度,而是通过等待机制把执行先后次序固定下来。

2.4 运行方式与输出预期

编译实验一需要把代码存为 ex1.c,然后执行 gcc 编译:

echo "hello operating system" > test1file.txt gcc -o ex1 ex1.c ./ex1

预期输出顺序是先看到“子进程开始读取文件”和文件内容,之后才出现“父进程等待子进程”这句话。如果顺序反了,可能是父进程先抢到 CPU,但 waitpid 会保证父进程在打印“子进程已结束”之前不会继续推进,这只是打印先后与阻塞时机的差异。实际调试时如果发现子进程还没打印,父进程就输出了,检查编译时有没有加载进 test1file.txt,以及文件路径是否是当前目录。

3. 线程共享进程数据:pthread_create 与全局变量的可见性

3.1 线程和进程在内存模型上的核心差异

实验二的主题是线程共享进程中的数据。设计上很直接:进程里定义一个全局变量 shdata,线程函数里读取它、给它加一,再输出;主线程 sleep 3 秒后再输出同一个变量,观察值是否变化。这里的关键在于:fork 创建进程时数据是复制品,父子进程改同一名字的变量互不影响;而 pthread_create 创建的线程,跑在同一个进程地址空间里,全局变量只有一份,线程直接读写的就是主线程看到的那个变量。

这个差异解释了为什么多进程之间要搞管道、信号量等一堆通信机制,而多线程只需要拿全局变量就能交换数据。代价是共享数据要额外做同步,否则两个线程同时shdata++会出现竞态。本实验只创建了一个线程,所以不涉及并发写冲突,重点是验证“能看到、能改到”。

3.2 完整线程实验代码与注释

下面这段代码整理自实验报告,例子本身简单,但包含了 pthread 编程的完整骨架:

#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("create thread error\n"); return -1; } else { sleep(3); printf("create thread success\n"); printf("shared data = %d\n", shdata); } return 0; }

编译时必须链接 pthread 库,这是新手最容易翻车的地方。命令如下:

gcc -o ex2 ex2.c -lpthread ./ex2

pthread_create的原型是int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg)。四个参数分别是线程 ID 指针、线程属性、线程函数入口、传给线程函数的参数。本实验 attr 和 arg 都传 NULL,线程函数签名必须写成void *func(void *arg),返回(void*)0表示正常退出。返回值 0 代表创建成功,非 0 是错误码,可以用strerror(pth)打印具体原因。

3.3 sleep(3) 是临时手段,pthread_join 才是正解

这段代码里有一个明显的设计问题:主线程用sleep(3)等子线程执行完。在线程只做一次加一运算的前提下,3 秒足够,但它是个玄学等待——如果线程函数里加了耗时的循环,3 秒就不够了。正确做法是用pthread_join(tid, NULL)阻塞等待子线程结束,这样无论子线程跑多久,主线程都能拿到完成时刻。

我把改进后的等待方式写在这里:

pthread_join(tid, NULL); printf("shared data = %d\n", shdata);

在实验报告中用 sleep 演示没问题,毕竟目的只是展示全局变量被修改,但如果你把这份代码扩展成真正的并发程序,join 是必须的。省掉 join 还有另一个风险:主线程 return 会结束整个进程,所有线程被强制终止,子线程可能没跑完。这个问题在多线程实验里非常常见,表现为输出顺序飘忽不定,有时候有“after operating”,有时候没有。

3.4 如果让两个线程同时自增会怎样

沿着这个实验往深走一步,就触及到同步的本质。假如把实验改成两个线程都对 shdata 执行自增,每个线程循环 10000 次,最终结果大概率不是 20000。原因是shdata++在 CPU 层面是“读-改-写”三步,线程 A 读旧值,线程 B 也读旧值,两个人都写回同样的新值,一次自增就丢失了。实验六的信号量就是来解决这类问题的,用 PV 操作把临界区锁住,保证同一时刻只有一个线程或进程进入自增区间。这块在后面做生产者消费者时会具体看到。

4. 信号与管道通信:从 signal 到 pipe 再到 fifo

4.1 信号通信:异步通知与处理函数

实验三是用信号在父子进程间通信。子进程调用kill(getppid(), SIGALRM)给父进程发一个 SIGALRM 信号,父进程提前通过signal(SIGALRM, ouch)注册了处理函数,收到信号后把全局标志位alarm_fired置 1,主循环检测到标志位变化后停止忙等并打印提示。整个过程是异步的:父进程不知道子进程什么时候发信号,信号随时打断当前执行流,转入处理函数。

代码里有一个值得注意的细节:static int alarm_fired = 0;这个全局标志的作用是让主循环感知信号发生。因为信号处理函数里如果直接做复杂操作,容易和主流程产生重入问题,常见做法是处理函数只置标志位,主流程轮询标志位再行动。实验里while(!alarm_fired) { printf("no signal\n"); }是一个忙等循环,CPU 占用率很高,适合演示但不够优雅,实际工程里可以用阻塞的pause()或sigsuspend()替代。

4.2 匿名管道:pipe 加 fork 的读写分工

实验四是匿名管道通信。匿名管道的特点是只在有亲缘关系的进程间使用,管道本身没有文件系统名字,通过pipe(fd)拿到一对文件描述符,fd[0]是读端,fd[1]是写端。通信流程分三步:父进程创建管道,fork 出子进程,父子进程各自关闭不用的那一端。

实验代码里的分工很清楚:父进程关闭读端close(fd[0]),把字符串写入写端;子进程关闭写端close(fd[1]),从读端读取数据。为什么非要关掉不用的端?因为管道在读端没有关闭且没有数据时,read 会阻塞。如果不关读端,父进程写入数据后管道读端仍被父进程持有,子进程 read 时因为读端还被别的进程打开,无法判断“对端已全部关闭”,会一直阻塞等数据。反过来,如果父进程不关写端,read 也等不到 EOF。这是一个很多人会忽略、直到进程卡死才发现的坑。

#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]); sleep(3); char readbuf[MAX_LINE]; read(fd[0], readbuf, sizeof(readbuf)); printf("子进程读取数据: %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]); return 0; } } return 0; }

这里read(fd[0], readbuf, sizeof(readbuf))第三个参数传的是缓冲区长度,read 会尽力读满或读到管道里现有数据,但不保证在字符串末尾补\0。打印时如果 readbuf 不是{0}初始化,会在有效内容之后出现乱码。实验代码里没有初始化 readbuf,建议改成char readbuf[MAX_LINE] = {0};。

4.3 命名管道:mkfifo 与 open 的阻塞特性

实验五进入命名管道,用mkfifo("./fifo", 0666)在文件系统中创建真实的管道文件。因为有了路径,没有亲缘关系的进程也能用它通信。代码流程是先 mkfifo 创建管道文件,再 fork 出子进程,子进程以只写方式open("./fifo", O_WRONLY)打开,父进程以只读方式open("./fifo", O_RDONLY)打开。

与匿名管道最大的不同是 open 的阻塞行为:如果先打开读端,而写端还没有进程打开,open 会一直阻塞在那里,直到有进程以写方式打开同一个 FIFO。这个特性是设计保证的,目的是让读写双方在正式传数据之前先完成握手。实验中子进程里先sleep(1)再打开写端,就是为了保证父进程的读端能先就位,避免两边都在 open 阶段互相等待。

命名管道用完以后,最好在程序末尾调用unlink("./fifo")删除管道文件。不删的话,下次运行mkfifo会因为文件已存在而返回 -1,报告里直接打印“管道创建失败”。这是复现时最容易踩的一个小坑,也不是代码逻辑问题,而是残留文件问题。

5. 信号量生产者消费者:PV 操作的实现与四个常见坑

5.1 三把信号量加共享内存的布局

实验六是整份报告里最复杂的一个,用 System V 信号量实现经典生产者消费者。代码里创建了三个信号量:emptyid表示空缓冲区数量,初始化为 5;fullid表示已生产产品数量,初始化为 0;mutxid是互斥信号量,初始化为 1,保护共享缓冲区的读写。生产者每生产一件产品,先对 empty 做 P 操作占用一个空位,再对 mutex 做 P 操作进入临界区,写产品、更新写指针,然后 V 释放 mutex,再 V 增加 full 计数。消费者方向相反,先 P(full) 确保有产品可取,再 P(mutex) 进入临界区取产品。

共享内存部分使用了mmap配合MAP_SHARED | MAP_ANONYMOUS。4 个映射分别放缓冲区数组array、累计和sum、生产位置set、消费位置get。因为 fork 出来的子进程会继承映射,多个进程看到的是同一块物理内存,这比在 fork 之后用全局变量通信可靠得多。下面给出信号量初始化的关键片段:

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); arg.val = 1; semctl(mutxid, 0, SETVAL, arg);

semget的第一个参数是 key,传IPC_PRIVATE表示只让当前进程及其子进程使用,内核会分配一个唯一 id。注意拿到 id 之后必须用semctl设置初值,信号量不会自动初始化为 0。这里的MAXSEM定义成 5,表示缓冲区最多放 5 件产品,empty 初值就是 5。

5.2 生产者消费者主循环与 PV 顺序

生产者和消费者的核心循环都是“P 资源信号量 → P 互斥信号量 → 操作共享区 → V 互斥信号量 → V 资源信号量”。关键约束是资源信号量的 P 操作必须在互斥信号量之前,否则会出现死锁:如果先持有 mutex 再等 empty,缓冲区满时生产者拿着锁等消费者释放空位,消费者却进不了临界区取产品,两边僵死。代码里消费者分支在*get == 100时 break,此时如果不补一个 V(empty),另一个消费者或生产者会卡住。原实验报告中提到“消费者取出了生产者还未生产的产品”,现象对应的是 full 信号量的 P 操作漏写或顺序颠倒,消费者在缓冲区空时没被阻塞。

完整实验的核心逻辑可以简化为以下结构,便于理解:

// 生产者:循环 100 次 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++; }

AV 操作里semop的第二个参数是struct sembuf数组,一次可以传多个操作。代码里定义好的 P 结构体sem_op=-1,V 结构体sem_op=+1。这里还要注意sem_flg设置成SEM_UNDO,它的作用是防止进程异常退出时信号量被卡死:如果持有信号量的进程崩溃,内核会回滚它的操作,避免死等。演示程序加不加都能跑,但养成习惯比较好。

5.3 四个常见坑:现象、原因、解决

实验做到这里,最容易翻车的不是概念,而是一些看似不起眼的细节。下面四条是我照这份代码复现时真实遇到的,你大概率也会撞上。

  • 现象一:消费者进程一直在等待,生产者跑完 100 个产品后消费者也没醒。原因:消费者的 P(full) 在某个 break 分支里漏掉了对应的 V(empty),生产者生产满 5 个缓冲区后被 empty 卡死。解决:检查每个 break 之前是否补了 V(empty),或者把 break 条件放到临界区内的最后判断,统一走 V 再退出。

  • 现象二:消费者取出了编号为 0 或者巨大随机数。原因:*sum或*get指向的共享内存没有初始化。代码里 mmap 只分配了内存,不会清零内容,首次访问可能是垃圾值。解决:映射完成后立刻执行*sum=0; *get=0; *set=0;,再开始 fork。

  • 现象三:程序编译能过,但运行时提示semctl setval error: Invalid argument。原因:没有在semget后检查返回值,或者信号量已经被之前的运行残留占用。解决:把semget的返回值做一次打印,进程结束前用semctl(id, 0, IPC_RMID, 0)删除信号量。

  • 现象四:输出里消费者 A 和 B 的消费顺序不符合预期,甚至出现同一产品被两个消费者各消费一次。原因:*get的读写没有完全被 mutex 保护,或者 mutex 的 P 操作写成了对 empty/full 的 P。解决:把取产品、读 get、更新 get 全部放在semop(mutxid, &P, 1)和 V 之间,确保 get 的自增减是原子的。

6. 把六个实验串成一遍可复现的验证流程

看完代码只是第一步,真正把这份报告变成自己的东西,需要按顺序完整跑一遍。我的做法是建一个目录,按编号存放六个 C 文件和一个准备脚本,每次实验前先清理残留的 fifo 文件和信号量,避免上次运行的结果干扰本次复现。

mkdir -p os_lab && cd os_lab echo "hello operating system" > test1file.txt gcc -o ex1 ex1.c gcc -o ex2 ex2.c -lpthread gcc -o ex3 ex3.c gcc -o ex4 ex4.c gcc -o ex5 ex5.c gcc -o ex6 ex6.c rm -f ./fifo ./ex1 && ./ex2 && ./ex3 && ./ex4

验证重点按顺序来:实验一看父进程是否在子进程打印完文件内容后才输出“子进程已结束”;实验二看主线程最后的 shdata 是否为 1;实验三看父进程忙等循环最终被信号打断;实验四看子进程能否读出 “hello the world”;实验五跑之前先确认当前目录没有残留的 fifo 文件。实验六单独跑,因为它的输出量大,包含 100 条生产记录和两个消费者的取货记录,终端窗口太小时建议./ex6 | tail -20只看尾部。

一个更高效的验证手段是加打印追踪。在关键位置输出当前进程 pid 和操作对象,能快速定位是 PV 顺序问题还是共享内存初始化问题。实验六里如果看到消费者 A 连续消费多个产品,说明 mutex 生效;如果 A 和 B 交替消费,说明信号量调度正常。

扩展方向上,这套代码至少还有三个可以改着玩的地方:把 System V 信号量换成 POSIX 无名信号量,代码会更简洁;把数组缓冲区改成环形队列,考验对 empty 和 full 边界的理解;把两个消费者改成实际的计算任务,比如一个做累加一个做统计,能更直观体会并发效率。

从那以后,我每次写并发相关的代码都强制走一遍流程:先画清楚 PV 顺序和共享变量清单,再写 fork 或 pthread_create,最后在关键临界区前后加打印验证,宁可多打几行也不让自己靠猜。这份实验报告帮我把这套习惯固定了下来,希望你复现时也能避开那些坑,顺利把实验跑通。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 1:03:43

PCA9422+STM32L4R9AI低功耗设备电源管理闭环设计实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:03:15

C++虚函数表内存布局验证:单继承与多继承实例解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:02:58

010 Editor二进制模板实战:结构化解析与固件校验自动化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:02:46

TensorFlow-GPU环境搭建实战:从Anaconda到CUDA/cuDNN完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:02:44

JVM整体架构图文详解:从类加载到GC的完整内存流转路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:01:32

小米电视文件传输原理与3种稳定方案详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华