资讯动态

六个C程序搞定操作系统实验:进程、线程与IPC实战

发布时间:2026/10/24 3:15:03 来源:尧图企业网站定制
简介西安电子科技大学《操作系统》课程实验报告内含六组完整上机实验重点围绕进程管理、线程同步与进程间通信三大主题展开。实验一通过fork()与waitpid()实现父子进程协同由子进程读取文件、父进程等待其结束实验二演示线程如何直接修改全局共享变量实验三至五逐一演示信号通信、匿名管道与命名管道的数据传输方式实验六则利用信号量解决共享资源互斥访问问题。每个实验均配有可直接编译运行的C语言源码、关键函数说明、运行截图与独立心得方便读者在Linux/gcc环境中边读边练也能作为课程报告格式与撰写思路的参照。资源为单个doc文档共377KB适合操作系统课程学习者、考研复习者以及想通过动手实验理解并发编程的开发者。目前已有1350人浏览学习代码注释清晰可对照运行验证。1. 操作系统实验不是玄学六个C程序把进程、线程、IPC串起来了操作系统是不是“玄学”把这几段实验代码编译跑一遍就有答案。这套报告是某高校操作系统课程的上机记录六个C语言程序把Linux里最常用的进程、线程和进程间通信手段挨个过了一遍用fork()创建父子进程用waitpid()等子进程收工用pthread_create()证明线程活在同一份内存里接着kill()信号、pipe()匿名管道、mkfifo()命名管道依次登场最后用信号量把生产者-消费者问题完整落地。适合正在补操作系统实验的在校生也适合面试前想快速把IPC细节捡起来的开发者。读完之后你能把这六段代码全部编译运行知道每段代码里的分支为什么这么写哪些地方容易踩坑还能顺手改造成一个小工具。2. 先跑通进程与线程实验一、二的代码、编译和运行实验一和实验二是后面所有通信实验的地基。先把“创建一个进程”“创建一个线程”这两个动作看清楚再去看管道和信号量才不会乱。这里用一个表格先交代六个实验的考点后面每个实验逐步展开。实验核心函数考察点实验一 进程建立fork()、waitpid()父子进程协同与等待实验二 线程共享数据pthread_create()线程间全局变量共享实验三 信号通信kill()、signal()异步通知与信号处理函数实验四 匿名管道pipe()、read()、write()父子进程单向数据流实验五 命名管道mkfifo()、open()通过FIFO文件跨进程通信实验六 信号量同步semget()、semop()、mmap()PV操作解决生产者-消费者问题表格里能看到整份报告的编排逻辑先进程、再线程然后从最轻量的信号一路做到最完整的信号量同步。下面先从实验一的fork()看起。2.1 fork() 一次返回两次父子分支是怎么分的fork()是Linux里最容易让人懵的函数因为它“调用一次、返回两次”。调用完成后父进程收到的是子进程的PID子进程收到的是0失败时返回-1。这套代码把这个分支写得非常直白#include unistd.h #include stdio.h #include stdlib.h int main() { pid_t fpid fork(); // 调用一次 if (fpid 0) { printf(error\n); // fork 失败 exit(1); } else if (fpid 0) { printf(子进程我是子进程, id%d\n, getpid()); } else { printf(父进程我是父进程, id%d, 创建了子进程 %d\n, getpid(), fpid); } return 0; }这段代码在fork()之后的if/else if/else本质上是靠返回值区分两条执行流子进程从fpid 0分支继续走父进程从else分支继续走。注意fork()之后父子进程各有一份独立的count变量拷贝改其中一个不会影响另一个——这是“进程地址空间隔离”的直接体现很多人在写多进程程序时会惯性思维以为变量是共享的这里是最容易翻车的第一课。编译命令很简单gcc exp1.c -o exp1运行后会先打印父进程信息先打印哪个不固定取决于内核调度然后看到子进程的PID比父进程大几位。2.2 waitpid 收尸子进程没退出父进程就要等单独创建一个进程没什么价值实验一的核心是“父进程等子进程干完活再继续”。这里用waitpid()实现协同#include sys/wait.h #include unistd.h #include stdio.h #include stdlib.h int main() { pid_t fpid fork(); if (fpid 0) { printf(error\n); } else if (fpid 0) { printf(子进程开始读取文件\n); 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); // 退出码waitpid 不关心它的值 } else { printf(父进程我要等待子进程\n); int ret waitpid(0, NULL, 0); printf(父进程子进程已经结束, ret%d\n, ret); } return 0; }waitpid(0, NULL, 0)三个参数依次是等待哪个子进程、状态收集指针、选项标志。这里pid0表示等待与父进程同组的任意一个子进程NULL表示不关心退出状态0表示阻塞等待。如果你想精确等某个子进程就把第一个参数改成它的PID如果改成-1含义变成“等待任意子进程”等价于老的wait()。运行前一定要先在工作目录里创建一个文本文件test1file.txt比如echo hello os test1file.txt。子进程的fopen(test1file.txt, r)用的是相对路径找不到就直接打印“未找到文件”然后结束此时waitpid()一样会返回但看不到文件内容。2.3 pthread_create线程如何共享进程里的全局变量实验二把视角从进程切到线程。进程之间内存隔离线程却共享同一份地址空间这就是它能直接读写全局变量的原因#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); // 主线程睡3秒等子线程跑完 printf(creat thread success\n); printf(shared data %d\n, shdata); return 0; } }pthread_create()四个参数第一个tid用来接收线程ID第二个NULL表示默认属性第三个creat是线程入口函数签名必须是void *(*)(void *)第四个NULL是传给线程函数的参数。线程函数里对shdata主线程sleep(3)之后再打印看到的已经是1——同一个变量两条执行流都能改。“线程共享进程数据”在这里被演示得很清楚不是靠传参不是靠IPC就是直接操作同一块内存。但注意sleep(3)属于“睡等”不是同步原语。后面避坑章节会展开讲为什么这个写法在真实并发场景下不可靠以及正确的替代方案是什么。2.4 让实验一、二跑起来的编译命令实验环境以Linux gcc为基准Ubuntu或CentOS都行。先装基础工具再逐个编译sudo apt-get install gcc make echo hello os test1file.txt gcc exp1.c -o exp1 gcc exp2.c -o exp2 -lpthread ./exp1 ./exp2注意实验二必须加-lpthread链接线程库否则会报undefined reference to pthread_create。实验一的文件读取依赖test1file.txt存在运行后你能看到“父进程等待”和“子进程读取结束”的先后顺序是固定的——子进程打印完读取结果父进程才从waitpid()返回。3. 信号、匿名管道与命名管道三种IPC的代码链路进程之间怎么说话实验三到实验五给出了三种答案信号是“喊一嗓子”匿名管道是“父子之间的水管”命名管道是“文件系统里的一根水管”。三种方式使用场景不同代码结构也各有各的坑。3.1 信号通信kill(getppid(), SIGALRM) 的父子通知链路信号是最轻量的异步通知机制不需要建立连接一个进程“喊一嗓子”另一个进程收到后自动跳进处理函数。实验三是子进程给父进程发一个SIGALRM#include unistd.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); exit(1); } else if (pid 0) { // 子进程向父进程发送 SIGALRM kill(getppid(), SIGALRM); exit(0); } else { // 父进程注册处理函数然后忙等 signal(SIGALRM, ouch); while (!alarm_fired) { printf(no signal\n); } printf(\nI got a signal %d\n, SIGALRM); } return 0; }signal(SIGALRM, ouch)把SIGALRM的处理函数注册成ouch子进程kill(getppid(), SIGALRM)把信号发给父进程。getppid()拿到的是“创建我的那个进程”在fork场景里就是父进程的PID。信号到达后父进程正在执行的while(!alarm_fired)循环被中断跳进ouch()把标志位改成1循环条件失效继续打印“I got a signal”。这段代码最值得注意的地方是信号处理函数里只做“置标志”这种简单操作没有去调用printf这类非异步安全函数。虽然这里打印也没出事但如果信号来得密集在信号处理函数里做重活很容易搞坏主流程的数据。更规范的做法是sigaction()注册处理函数它能精确控制信号掩码和标志位signal()在glibc下已经是兼容老接口的包装了。3.2 匿名管道close(fd[1]) 与 close(fd[0]) 的读写端控制管道是“一端写、一端读”的字节流。pipe()创建一个长度为2的数组fd[2]fd[0]是读端fd[1]是写端。父子进程都持有这两个描述符所以每一端必须主动关掉自己不用的一方否则数据流会乱#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]; if (pipe(fd) 0) { pid_t child_pid fork(); 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)); close(fd[1]); printf(父进程写入并关闭写端\n); return 0; } } return 0; }创建管道后先fork()管道描述符随之被父子进程各拷贝一份。子进程执行close(fd[1])关闭写端父进程执行close(fd[0])关闭读端管道变成“父写子读”的单向通道。子进程sleep(3)是刻意让父进程先完成写入再读用时间换顺序确定性。参数上read(fd[0], readbuf, sizeof(readbuf))最多读80字节管道里实际只写了13字节所以read()返回13打印出来的字符串不带换行。如果父进程不close(fd[1])情况会变得很微妙写端仍处于打开状态read()会一直阻塞等数据程序变成“卡死”。管道的核心约定是只有当所有写端都关闭后读端的read()才会收到EOF返回0否则它会无限期等下去。这是匿名管道最容易踩的坑。3.3 命名管道mkfifo创建的FIFO文件如何跨进程传数据匿名管道只能用于有亲缘关系的进程命名管道通过文件系统里的一个FIFO文件破除了这个限制。任何进程只要能打开这个文件就能读写数据#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() { // 创建命名管道权限 0666 int ret mkfifo(./fifo, 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}; int ret read(fd, buf, sizeof(buf)); if (ret 0) { printf(父进程读到: %s\n, buf); } close(fd); } else { // 子进程只写打开 int fd open(./fifo, O_WRONLY); char buf[100] HELLO,WORLD; sleep(1); write(fd, buf, strlen(buf)); close(fd); } return 0; }mkfifo(./fifo, 0666)的第一个参数是路径第二个参数是权限位0666表示所有人可读可写。fork()之后父进程走fpid 0分支用O_RDONLY打开子进程走else分支用O_WRONLY打开父子进程通过这个FIFO文件交换了一段字符串。注意FIFO的打开行为和普通文件不一样以只读方式open()一个FIFO时如果没有写端打开open()会阻塞在那里等待。这个实验里父进程先执行到open(O_RDONLY)子进程sleep(1)后执行open(O_WRONLY)一读一写配对成功程序才会继续往下走。这也是为什么第二次运行这个程序会直接输“管道创建失败”——mkfifo()发现./fifo文件已经存在返回-1且errno为EEXIST。解决办法是运行前先rm -f ./fifo或者代码里先remove(./fifo)。4. 信号量同步解析生产者-消费者的PV操作实现实验六是全部实验里代码量最大、也最接近真实并发场景的一个一个生产者进程生产100个产品两个消费者进程竞争消费。中间用到三个信号量和一大块共享内存。它的核心不是“读写数据”而是“控制多个进程对同一块资源的访问顺序”。4.1 三个信号量fullid、emptyid、mutxid 各自的职责信号量的本质是一个带阻塞能力的计数器P操作尝试把计数减1不够减就挂起V操作把计数加1并唤醒等待者。实验里创建了三个信号量#include sys/sem.h #include sys/mman.h #include sys/types.h #define MAXSEM 5 union semun { int val; struct semid_ds *buf; unsigned short *array; }; int fullid, emptyid, mutxid; struct sembuf P {0, -1, SEM_UNDO}; // 减1不够就阻塞 struct sembuf V {0, 1, SEM_UNDO}; // 加1唤醒等待者 int main() { 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; // 空位初始5 semctl(emptyid, 0, SETVAL, arg); arg.val 1; // 互斥信号量初始1 semctl(mutxid, 0, SETVAL, arg); }semget(IPC_PRIVATE, 1, IPC_CREAT|0666)创建一组信号量第一参数IPC_PRIVATE表示不关联文件、只在本程序内使用第二参数是数量1第三参数是权限。semctl(id, 0, SETVAL, arg)把第0个信号量的值设为arg.val。三个信号量分工明确fullid记录“已有产品”初始0emptyid记录“空缓冲区”初始5mutxid是互斥锁初始1。这里的SEM_UNDO值得解释一下它让信号量的加减在进程退出时自动回滚。如果某进程P操作后没来得及V就崩溃了内核会把它的欠账还回去避免其他进程永远卡死。对于课程实验它可有可无在生产代码里它是一道安全网。4.2 mmap 共享内存array、sum、set、get 都放在哪信号量负责“同步”但真正传数据要靠共享内存。实验用mmap()一次性映射了四块匿名内存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);mmap四个关键参数第一参数NULL表示让内核选地址第二参数是映射长度array映射5个int其他三个各映射1个第三参数PROT_READ | PROT_WRITE表示可读可写第四参数MAP_SHARED | MAP_ANONYMOUS最关键——MAP_SHARED保证父子进程写这块内存相互可见MAP_ANONYMOUS表示不依赖任何文件。最后一个-1和0分别是文件描述符和偏移匿名映射下都用不到。这四个指针的角色分别是array是环形缓冲区大小5set是生产者下一次写的位置get是消费者下一次读的位置sum用来累加被消费的产品编号。fork()之后子进程自动继承这四块映射所有进程看到的是同一片物理内存。4.3 生产100个、两个消费者抢完整流程走一遍生产者逻辑是标准的“先P(empty)再P(mutex)”// 生产者进程 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; } printf(producer is over\n); exit(0);消费者逻辑是反过来“先P(full)确认有产品再P(mutex)拿数据”// 消费者进程A while (1) { semop(fullid, P, 1); // 确认有产品可拿 semop(mutxid, P, 1); // 进入临界区 if (*get 100) break; *sum array[(*get) % MAXSEM]; printf(consumer 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);消费者B的代码与A完全相同只是打印文案不同。两个消费者通过get指针竞争取产品fullid保证只有“有货”时才能进临界区mutxid保证同时只有一个进程在操作array和getemptyid保证生产者不会在缓冲区满时继续生产。array[(*get) % MAXSEM]这行用取模实现环形缓冲缓冲区只有5格产品有100个下标满了就绕回0。“生产一个、消费一个”的完整时序是这样的生产者先P(empty)把空位从5减到4再P(mutex)锁门写入产品后V(mutex)开门V(full)把产品数从0加到1。消费者这边P(full)发现产品数为1减到0P(mutex)进门拿货V(mutex)出门V(empty)把空位还回去。如此交替full和empty加起来恒等于5多一个产品都存不下。拿产品累进sum的设计是验证正确性的关键100个消费者如果每个数字都被恰好消费一次sum应该等于12...100 5050。如果同步写错了sum会偏小或者出现重复消费。5. 避坑与排查这六个实验里最容易翻车的五个细节六段代码核心机制都不复杂但每一段都藏着能把人卡半小时的细节。这里把最常翻车的五个现象按“现象 → 原因 → 解决”整理出来跑代码前先看一遍能省不少事。5.1 命名管道第二次运行就“创建失败”现象实验五第一次运行正常第二次运行直接打印“管道创建失败”并退出./fifo文件明明还在。原因mkfifo(./fifo, 0666)在文件已存在时返回-1并设置errno为EEXIST。原代码没有做“已存在就跳过”的处理直接当失败退出了。解决运行前先rm -f ./fifo或者在代码里调用remove(./fifo)清理旧文件。还有一个容易误判的点父进程open(./fifo, O_RDONLY)看起来像“卡住不动”其实那是FIFO在等写端打开是正常行为不是死锁。5.2 实验二里 sleep(3) 掩盖了竞态条件现象去掉实验二主线程里的sleep(3)后经常打印shared data 0结果不稳定。原因pthread_create()只负责“创建线程”创建完就返回不代表子线程已经执行到shdata。主线程和子线程是并发跑的主线程先打印了旧值。解决用pthread_join()代替sleep()让主线程明确等待子线程结束pthread_join(tid, NULL); printf(shared data %d\n, shdata);sleep(3)靠时间换顺序pthread_join()靠同步机制保证“子线程已退出”。后者才是并发编程里正确的等待姿势前者只是碰运气。5.3 匿名管道 read() 卡死程序不退出现象实验四的子进程read(fd[0], readbuf, sizeof(readbuf))一直阻塞整个程序挂住。原因管道读端在“写端全部关闭”之前不会返回EOF。如果父进程忘了close(fd[1])或者关闭动作晚于子进程的read()读端会一直等数据。原代码里父进程在两个printf之后执行了close(fd[1])所以没问题改代码时稍一疏忽就会踩中。解决养成习惯——pipe()之后立刻决定哪端不用fork()完成后马上close()自己不需要的那个端口。检查思路也简单看read()之前是不是所有写端都关了。5.4 消费者取完第100个产品后 break信号量没释放现象实验六运行到后期一个消费者打印完the sum is 5050后退出另一个消费者卡在semop(fullid, P, 1)上整个程序不结束。原因消费者在*get 100时直接break跳过了后面的semop(mutxid, V, 1)和semop(emptyid, V, 1)。虽然SEM_UNDO会在进程退出时还账但消费者只是break出while进程还没退出互斥锁就被它带走了。这时候另一个消费者即使能等到fullid也进不了临界区。解决把信号量释放放到break之前或者干脆在循环尾部统一释放if (*get 100) { printf(the sum is %d\n, *sum); semop(mutxid, V, 1); semop(emptyid, V, 1); break; }但这样改完还有一个隐患另一个消费者会永远阻塞在semop(fullid)上因为总产品数就100个已经全被拿走。课程实验里可以接受因为父进程退出后整个信号量集合会失效如果要写成长时间运行的服务必须设计“结束标志”或者用消费者计数来决定退出时机。5.5 实验一头文件写错、文件没预先创建现象gcc exp1.c报错找不到sys/unistd.h跑起来后子进程打印“未找到文件”。原因原代码里写的是#includesys/unistd.h这个头文件在Linux下不存在正确路径是#include unistd.h。另外一个原因是test1file.txt没有创建fopen()拿相对路径找不到就返回NULL。解决头文件改成unistd.h运行前先echo hello os test1file.txt。顺带说一句waitpid(0, NULL, 0)里的0偶尔会被误读成“等0号进程”实际含义是“等待同进程组的任意子进程”换-1语义更直观。6. 二次开发把实验六改造成命令行参数版的生产者-消费者工具实验六的代码把产品和缓冲区大小都写死了MAXSEM5、生产100个。想拿它做实验或者验证不同参数下的行为就得改源码重新编译太麻烦。我一般会先做三件事把总量做成命令行参数、把信号量创建封装成函数、加一个简单的参数校验。先封装信号量创建原来的semget semctl四行代码在三个信号量上重复了三次收敛成一个函数union semun { int val; struct semid_ds *buf; unsigned short *array; }; int create_sem(int init_val) { int id semget(IPC_PRIVATE, 1, IPC_CREAT | 0666); union semun arg; arg.val init_val; if (semctl(id, 0, SETVAL, arg) -1) { perror(semctl setval error); exit(1); } return id; }然后改main()的人口接收total和bufsize两个参数int main(int argc, char *argv[]) { int total 100; // 默认生产100个 int bufsize 5; // 默认缓冲区5格 if (argc 1) total atoi(argv[1]); if (argc 2) bufsize atoi(argv[2]); if (total 0 || total 10000 || bufsize 0 || bufsize 100) { fprintf(stderr, 参数不合法total 取 1~10000bufsize 取 1~100\n); return 1; } fullid create_sem(0); emptyid create_sem(bufsize); mutxid create_sem(1); array mmap(NULL, sizeof(int) * bufsize, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); // sum / set / get 的映射与原来一致略 // fork 生产者、消费者流程保持不变 }改造后./exp6 200 10就能让程序生产200个产品、用10格缓冲区运行。整个生产消费逻辑不用动只把MAXSEM和100换成变量。验证改造是否正确我习惯用两条路径一是看终端输出的sum是否等于12...total的等差数列求和即total * (total 1) / 2二是用strace跟踪信号量操作序列确认没有进程在semop上异常阻塞gcc exp6.c -o exp6 ./exp6 200 10 strace -f -e tracesemop,mmap,fork ./exp6 50 5从那以后我每次跑这套实验代码都会先看一眼“有没有进程还挂在semop上”确认没有僵尸进程再进行下一组实验。命名管道跑第二遍也习惯了先删fifo文件线程实验一律用pthread_join而不是sleep硬等。这套代码虽然短但把它改造成自己的小工具再跑通你对Linux进程间通信的掌握会比看十遍书都扎实。希望这些经验能帮你少走两步弯路编译、运行、改数一次过。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑