简介:这份资源是西安电子科技大学操作系统课程的实验报告文档,面向正在学习操作系统原理、需要完成Linux环境下进程与线程编程实验的高校学生。内容围绕进程建立、线程共享进程数据、信号通信、匿名管道与命名管道通信、信号量实现进程同步等六个实验展开,每个实验均包含实验目的、软硬件环境、完整C语言程序及逐段分析、运行截图与心得体会,可帮助读者对照fork()、waitpid()、pthread_create()、sem_init()等核心函数的用法,理解父子进程协同、全局变量共享、异步信号处理与临界区互斥的实现思路。压缩包内共1个doc文件,约377KB,结构完整、代码可直接参考复现。目前已有1350人学习下载,适合作为课程实验的对照范本与期末复习材料,也便于初学者在动手实践中建立对进程、线程及进程间通信机制的系统认识。
1. 从一份实验报告到六个可跑的 Linux 系统调用 Demo
翻到一份操作系统上机实验报告,很多人第一反应是“交完就忘”。但把里面的代码抽出来单独跑一遍,会发现它其实是一套很紧凑的 Linux 系统编程入门骨架:fork建进程、pthread_create建线程、kill发信号、pipe和mkfifo做管道、semget/semop配共享内存做生产者消费者同步。六个实验覆盖了进程管理、线程共享、进程间通信、进程同步四条主线,环境只要 Linux 加 gcc,没有第三方库,没有构建系统,每段代码都能单独编译成可执行文件。适合刚学操作系统课、想把手写代码跑出真实输出的人,也适合已经工作、想回头把waitpid、semop这些调用重新捋一遍的开发者。下面按“能干什么、怎么跑、怎么改”的顺序拆开讲。
2. 实验环境与目录组织:六个独立 C 文件怎么摆
2.1 环境准备与编译命令
这套实验的软硬件环境在原文里写得很明确:Linux 加 gcc。没有 Makefile,没有 CMake,每个实验就是一个.c文件。我一般会按实验编号建目录,把每个实验的源码和它依赖的数据文件放在一起,避免fopen("test1file.txt","r")这种相对路径找不到文件。
# 建工作目录,按实验分开放 mkdir -p os-lab/{exp1_process,exp2_thread,exp3_signal,exp4_pipe,exp5_fifo,exp6_sem} cd os-lab # 编译单个实验,-pthread 是线程和信号量实验必须的 gcc exp1_process/main.c -o exp1_process/run gcc exp2_thread/main.c -o exp2_thread/run -pthread gcc exp3_signal/main.c -o exp3_signal/run gcc exp4_pipe/main.c -o exp4_pipe/run gcc exp5_fifo/main.c -o exp5_fifo/run gcc exp6_sem/main.c -o exp6_sem/run -pthread编译参数里唯一容易漏的是-pthread。实验二用pthread_create,实验六虽然主体是 System V 信号量,但链接时带上-pthread不会有副作用,反而能避免某些环境下semop相关符号的隐式依赖问题。-o后面跟输出名,我习惯统一叫run,这样每个目录里./run就能直接执行,不用记一长串名字。
提示:实验一依赖一个叫
test1file.txt的文本文件,必须在运行目录下存在。如果从别的目录执行./exp1_process/run,fopen会失败,子进程打印“未找到文件”。这是新手第一个翻车点。
2.2 六个实验的模块划分与依赖关系
把六个实验当成一个项目看,它们的依赖关系其实很清晰:实验一是所有后续实验的地基,因为fork创建父子进程的模式在实验三、四、五、六里反复出现;实验二单独讲线程,和进程间通信是两条平行线;实验三到实验五是一条递进线,从信号(异步、无数据载体)到匿名管道(半双工、有血缘关系)到命名管道(半双工、可无血缘关系);实验六把前面所有东西合起来,用共享内存加信号量做同步。
| 实验 | 核心系统调用 | 关键头文件 | 是否依赖父子进程 |
|---|---|---|---|
| 实验一 进程建立 | forkwaitpid | unistd.hsys/wait.h | 是 |
| 实验二 线程共享 | pthread_create | pthread.h | 否 |
| 实验三 信号通信 | killsignal | signal.h | 是 |
| 实验四 匿名管道 | pipereadwrite | unistd.h | 是 |
| 实验五 命名管道 | mkfifoopenreadwrite | sys/stat.hfcntl.h | 是 |
| 实验六 信号量同步 | semgetsemopsemctlmmap | sys/sem.hsys/mman.h | 是(多进程) |
这张表是我自己整理时用的,原文没有列,但对着它看代码会快很多。比如实验四和实验五都叫“管道”,区别就在pipe创建的是内核里一块无名的缓冲区,只能靠fork继承文件描述符来共享;mkfifo在文件系统里创建一个真实存在的 FIFO 文件,任何进程只要open它就能通信。理解这一点,实验五里父进程open("./fifo",O_RDONLY)、子进程open("./fifo",O_WRONLY)的写法就顺了。
3. 进程与线程基础:fork 和 pthread_create 两条主线
3.1 实验一:fork 之后父子进程各走各的
实验一的核心是fork()的返回值语义:父进程里返回子进程 pid,子进程里返回 0,出错返回负数。原文代码用if(fpid<0) / else if(fpid==0) / else三分支,这是标准写法。真正值得讲的是父进程用waitpid(0,NULL,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"); } else if (fpid == 0) { // 子进程分支: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); // 子进程用 exit 退出,不回到父进程栈 } else { // 父进程分支:fpid 是子进程 pid printf("父进程:我是父进程id=%d ,我要等待子进程\n", getpid()); int ret = waitpid(0, NULL, 0); // 0 表示等待任意子进程 printf("父进程:子进程 已经结束,ret=%d\n", ret); } return 0; }waitpid的三个参数:第一个pid传 0 表示等待同一进程组里的任意子进程;第二个status传NULL表示不关心子进程退出状态;第三个options传 0 表示阻塞等待。原文代码里ret打印出来就是子进程的 pid,可以拿它和子进程里getpid()的输出对照,验证父子关系。我在原文基础上补了fclose(fp),因为原文子进程读完文件没关流,虽然exit会回收,但养成习惯更好。
注意:子进程分支用
exit(1)而不是return 0。exit会刷新 stdio 缓冲区并终止整个进程,return在main里效果类似,但在fork场景下用exit语义更明确,避免子进程意外继续执行父进程后续代码。
3.2 实验二:线程共享全局变量,sleep 是同步手段
实验二用pthread_create创建一个线程,线程函数里读全局变量shdata、自增、再打印。主线程sleep(3)后打印shdata,看到值变了,说明线程和进程共享同一块数据段。
#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四个参数:&tid接收线程 id;NULL表示用默认线程属性;creat是线程入口函数,签名必须是void *(*)(void *);最后一个NULL是传给线程函数的参数。这里sleep(3)是一种“土办法”同步,靠时间差保证线程先跑完。真实项目里不会这么写,应该用pthread_join(tid, NULL)等待线程结束。原文用sleep是为了演示简单,但这也是一个值得在二次开发时改掉的点。
4. 进程间通信三件套:信号、匿名管道、命名管道
4.1 实验三:kill 发信号,signal 注册处理函数
实验三的模型是子进程向父进程发SIGALRM,父进程用signal(SIGALRM, ouch)注册处理函数,把全局标志alarm_fired置 1,主循环检测到标志就退出。
#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; } int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed\n"); exit(1); } else if (pid == 0) { kill(getppid(), SIGALRM); // 子进程向父进程发 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(getppid(), SIGALRM)里getppid()拿父进程 pid,SIGALRM是闹钟信号。signal的第二个参数是函数指针,信号到达时内核调用它。这里有个时序问题:如果子进程在父进程执行signal之前就发了信号,信号会丢失,父进程可能永远卡在while里。原文代码能跑通是因为fork之后父进程通常先执行到signal,但这不是强保证。稳妥做法是在fork之前就注册处理函数,或者用sigaction配合阻塞信号集。这是实验三最值得在二次开发时加固的地方。
4.2 实验四:匿名管道,父写子读
匿名管道pipe(fd)创建两个文件描述符,fd[0]读端、fd[1]写端。父子进程通过fork继承这两个描述符,各自关掉不用的那一端,就形成单向数据流。
#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("子进程读取数据成功\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]); return 0; } } else { printf("管道创建失败\n"); } }关键在“各关一端”:父进程关fd[0]只留写端,子进程关fd[1]只留读端。如果两边都不关,管道两端都开着,读端read会一直阻塞等数据,因为内核认为还有写端可能写入。原文子进程里sleep(3)是为了让父进程先写,其实管道本身有缓冲(通常 64KB),父进程写完再读也没问题,sleep可以去掉。read的第三个参数是最大读取字节数,这里传sizeof(readbuf)是 80,够放"hello the world"。
4.3 实验五:命名管道,mkfifo 建一个文件系统里的 FIFO
命名管道和匿名管道的本质区别是它有一个路径名,mkfifo("./fifo", 0666)在当前目录创建一个 FIFO 文件,权限 0666。之后任何进程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); // 创建命名管道文件 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)); 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); printf("子进程写入管道\n"); write(fd, buf, strlen(buf)); close(fd); } return 0; }mkfifo的第二个参数是权限位,0666表示所有用户可读写,实际权限还会受umask影响。open一个 FIFO 时,O_RDONLY会阻塞到有写端打开,O_WRONLY会阻塞到有读端打开,所以父进程open读端后会等子进程open写端,两边都打开后才继续。原文子进程里sleep(1)其实没必要,因为open本身已经做了同步。跑完实验后当前目录会留下一个fifo文件,下次再跑mkfifo会返回 -1(文件已存在),需要先rm fifo或者用unlink清理。
提示:实验五跑完记得
rm ./fifo,否则第二次运行会打印“管道创建失败”然后直接退出。这是命名管道实验最常见的“跑一次就坏”问题。
5. 信号量同步:生产者消费者用共享内存加 PV 操作
5.1 共享内存映射与信号量初始化
实验六是六个实验里最复杂的一个,它把mmap匿名共享映射、System V 信号量、fork多进程三样东西合在一起。先看共享内存部分:array是 5 个槽的缓冲区,sum是累加和,set是生产位置,get是消费位置,四个都用mmap映射成父子进程共享。
#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; // full=已有产品数, empty=空槽数, mutex=互斥 int main() { struct sembuf P, V; union semun arg; // 四个共享区域:缓冲区、累加和、生产下标、消费下标 int *array = mmap(NULL, sizeof(int) * MAXSEM, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); int *sum = mmap(NULL, sizeof(int), 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); // 创建三个信号量,IPC_PRIVATE 表示只在本进程组内使用 fullid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); emptyid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); mutxid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); // 初始化:full=0,empty=5,mutex=1 arg.val = 0; semctl(fullid, 0, SETVAL, arg); arg.val = MAXSEM; semctl(emptyid, 0, SETVAL, arg); arg.val = 1; semctl(mutxid, 0, SETVAL, arg); // P 操作:sem_op = -1;V 操作:sem_op = +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; // ... 后续 fork 生产者与消费者 }mmap的MAP_SHARED | MAP_ANONYMOUS组合是关键:MAP_ANONYMOUS表示不关联文件,MAP_SHARED表示fork后父子进程共享同一块物理内存。如果写成MAP_PRIVATE,子进程写的是自己的副本,父进程看不到,整个实验就失效了。semget的IPC_PRIVATE让内核自动分配 key,0666是权限。semctl的SETVAL用来给信号量赋初值,union semun是 System V 信号量 API 的历史遗留,必须自己定义。
5.2 生产者与两个消费者的 PV 操作顺序
生产者循环 100 次,每次先P(empty)等空槽、P(mutex)拿锁,写缓冲区、set++,然后V(mutex)、V(full)。两个消费者各自循环,先P(full)等产品、P(mutex)拿锁,读缓冲区、累加、get++,再V(mutex)、V(empty)。
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); // 产品数 +1 i++; } sleep(1); printf("producer is over\n"); exit(0); } else { pid_t fpid2 = fork(); if (fpid2 == 0) { // 消费者 A 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); // 空槽 +1 sleep(0.5); } printf("consumer A is over\n"); exit(0); } else { // 消费者 B,逻辑与 A 相同,输出标识不同 // ... } }PV 顺序不能乱:生产者必须先P(empty)再P(mutex),如果反过来先拿锁再等空槽,缓冲区满时生产者持锁阻塞,消费者又拿不到锁释放空槽,直接死锁。消费者同理先P(full)再P(mutex)。原文实验心得里提到“消费者取出生产者还未生产的产品”,就是某个 PV 顺序写反导致的。SEM_UNDO标志让进程异常退出时内核自动撤销它做的信号量操作,避免死锁,这个细节原文没展开,但实际调试时很有用。
6. 避坑与排查:六个实验里最容易翻车的五处
6.1 现象:实验一子进程打印“未找到文件”
原因:fopen("test1file.txt","r")用的是相对路径,相对于进程的当前工作目录,不是源码所在目录。从os-lab根目录执行./exp1_process/run时,当前目录是os-lab,而文件在exp1_process/里。解决:要么cd exp1_process && ./run,要么把fopen的路径改成绝对路径或相对于执行目录的路径。我一般会在实验目录里放一个test1file.txt,然后cd进去跑。
6.2 现象:实验二编译报undefined reference to pthread_create
原因:pthread_create在libpthread里,gcc 默认不链接。解决:编译命令加-pthread,注意是-pthread不是-lpthread,前者还会设置一些编译期宏。命令写成gcc main.c -o run -pthread,参数顺序放在源文件后面。
6.3 现象:实验三父进程卡在while(!alarm_fired)死循环
原因:子进程在父进程执行signal(SIGALRM, ouch)之前就调用了kill,信号在注册处理函数前到达,被默认动作处理(SIGALRM默认终止进程),或者信号丢失。解决:把signal注册移到fork之前,保证处理函数在任何信号到达前就位。更稳的写法是用sigprocmask先阻塞SIGALRM,fork后再解除阻塞。
6.4 现象:实验五第二次运行打印“管道创建失败”
原因:mkfifo("./fifo", 0666)在文件已存在时返回 -1,errno为EEXIST。解决:运行前rm -f ./fifo,或者在代码里判断errno == EEXIST时直接复用已有 FIFO。我习惯在实验目录的清理脚本里加一行rm -f fifo,跑之前先清。
6.5 现象:实验六消费者打印的产品编号乱序或重复
原因:PV 操作顺序错误,最常见的是生产者先P(mutex)再P(empty),或者消费者在break之前没有释放mutex。原文消费者在if (*get == 100) break;处直接跳出,没有执行后面的V(mutex)和V(empty),如果这是最后一个产品,mutex会一直处于 P 状态,另一个消费者可能卡住。解决:把break改成先V再break,或者用goto统一跳到释放段。调试时可以在每次semop后打印信号量值(semctl(id,0,GETVAL))来定位。
7. 二次开发:把这六个 Demo 改成一个可复用的系统编程练习框架
这套实验代码单独看都是教学 Demo,但把它们收拢成一个练习框架,价值会大很多。我的做法是建一个common/目录放公共头文件和工具函数,每个实验一个子目录,顶层写一个Makefile统一编译。公共部分至少抽三样东西:一个check_err宏包装系统调用返回值,一个sem_p/sem_v封装semop的 P/V 操作,一个cleanup_fifo函数在atexit里清理命名管道文件。
# 顶层 Makefile,统一编译六个实验 CC = gcc CFLAGS = -Wall -Wextra -g LDFLAGS = -pthread EXPS = exp1_process exp2_thread exp3_signal exp4_pipe exp5_fifo exp6_sem all: $(EXPS) $(EXPS): $(CC) $(CFLAGS) $@/main.c -o $@/run $(LDFLAGS) clean: rm -f */run fifo-Wall -Wextra打开警告能提前发现read返回值未检查、write参数类型不匹配这类问题。-g保留调试符号,配合gdb可以单步看fork之后父子进程的执行流。clean里顺手删掉fifo,省得每次手动rm。
改造方向上,我建议优先做两件事。第一,把实验二的sleep(3)换成pthread_join,把实验四子进程的sleep(3)去掉,让同步靠机制而不是靠时间,这样代码在慢机器和快机器上行为一致。第二,给实验六加一个semctl(..., IPC_RMID, ...)在程序结束时删除信号量集,否则ipcs -s会看到一堆残留的 semid,反复跑实验会耗尽系统信号量资源。验证方法很简单:改完后连续跑十次实验六,ipcs -s | grep 0x应该看不到新增条目;实验四去掉sleep后父子进程输出顺序仍然正确。
血泪经验是,这类系统编程实验最容易被“能跑就行”糊弄过去,但fork之后的文件描述符继承、信号量的SEM_UNDO、mmap的MAP_SHARED这些点,恰恰是面试和真实项目里会追问的地方。从那以后我每次改这类多进程代码,都强制在fork前后各打一行getpid()和关键资源 id,跑一遍ipcs确认没有泄漏,再收工。希望帮到你。
本文还有配套的精品资源,点击获取