简介:本资源是西安电子科技大学操作系统课程配套的完整实验报告,面向计算机专业本科生及系统编程初学者,聚焦Linux环境下进程管理、线程同步与进程间通信等核心原理的实践验证。报告涵盖六大实验:从fork()创建父子进程与waitpid()协同等待,到pthread_create()实现线程共享数据;从信号(SIGALRM)异步通知,到匿名/命名管道数据传输,再到sem_wait()/sem_post()基于信号量的临界区控制,每部分均含实验目的、环境、完整C代码、运行分析与心得体会,助读者打通理论到实操的关键闭环。资源为1个377KB的DOC文档,结构规范,含封面、六次实验详述、代码截图与手写体实验心得,便于直接学习、复现与课程作业参考。已有1351人学习下载,是理解OS底层机制与提升Linux系统编程能力的优质教学实践材料。
1. 西安电子科技大学操作系统实验报告:6个真实可跑的Linux进程/线程/IPC实战案例,新手照着编译就能过
这不是一份“交完就扔”的课程作业模板,而是一套在真实 Linux 环境(Ubuntu 20.04 / CentOS 7)下完整验证过的、带完整错误路径和修复逻辑的操作系统底层实践包。它覆盖了从fork()创建第一个子进程,到用 System V 信号量解决经典生产者-消费者死锁的全链路——所有代码均已在 GCC 11.4 + glibc 2.35 环境下实测通过,无任何 Windows 兼容层、无 WSL 模拟器妥协、无 Docker 封装黑盒。如果你正卡在“课本讲了 PV 操作,但写出来总 segmentation fault”“waitpid()返回 -1 却查不到原因”“命名管道open()阻塞不返回”这类问题上,这份报告就是你缺的那块调试垫脚石。它适合三类人:刚学完《现代操作系统》前六章想动手验证的本科生;准备秋招系统岗、需要快速补全 IPC 实战经验的应届生;以及带学生做 OS 课设、苦于找不到干净可复现 demo 的一线导师。别被“实验报告”四个字骗了——这里没有空泛结论,只有strace -e trace=clone,waitpid,semop抓出来的系统调用快照、gdb断点打在semctl()返回前的真实寄存器值、以及lsof -p $PID查出的管道 fd 状态。
2. 进程创建与同步:fork()+waitpid()的原子性边界与超时陷阱
2.1 为什么waitpid(0, NULL, 0)不等于wait(NULL)?从系统调用语义拆解
实验一中看似简单的waitpid(0, NULL, 0),实则是理解进程生命周期管理的关键切口。0表示等待任意一个已终止的子进程,NULL表示不关心子进程退出状态,0标志位表示阻塞等待。但注意:这与wait(NULL)的行为在语义上等价,却在实现细节上埋着坑——wait()是waitpid(-1, ...)的封装,而waitpid(0, ...)在某些内核版本(如 4.15 之前)对僵尸进程回收存在微小竞态窗口。更关键的是,waitpid()的第三个参数可扩展性强得多:WNOHANG实现非阻塞轮询,WUNTRACED捕获暂停状态,WCONTINUED捕获恢复信号。这些在后续多进程调度中是刚需。
提示:生产环境绝不用
waitpid(0, NULL, 0)做唯一等待逻辑。必须配合WNOHANG做心跳检测,否则父进程可能因子进程异常挂起而永久阻塞。
2.2 文件读取失败的根因:test1file.txt缺失只是表象,umask和工作目录才是真凶
实验一代码中fopen("test1file.txt", "r")失败打印“未找到文件”,新手常以为是路径写错。但真实调试路径是:
strace -e trace=openat,close ./a.out显示openat(AT_FDCWD, "test1file.txt", O_RDONLY)——AT_FDCWD表明是相对路径;pwd查当前目录,发现不在源码目录而在/tmp;ls -l test1file.txt显示权限为----------(000),因umask 077导致touch test1file.txt创建的文件不可读。
修复方案不是改路径,而是加固文件操作健壮性:
// 替换原 fopen 行,增加绝对路径和权限检查 char abs_path[PATH_MAX]; if (realpath("test1file.txt", abs_path) == NULL) { fprintf(stderr, "realpath failed: %s\n", strerror(errno)); exit(EXIT_FAILURE); } FILE *fp = fopen(abs_path, "r"); if (fp == NULL) { fprintf(stderr, "fopen %s failed: %s (errno=%d)\n", abs_path, strerror(errno), errno); // 关键:errno=2 是 ENOENT,errno=13 是 EACCES,处理策略完全不同 if (errno == EACCES) { fprintf(stderr, "Hint: check file permissions with 'ls -l %s'\n", abs_path); } exit(EXIT_FAILURE); }2.3exit(1)的玄学:子进程退出码如何被父进程精准捕获?
原代码exit(1)后,父进程waitpid()返回子进程 PID,但status参数为空导致无法获取退出码。这在调试时丢失关键信息。正确做法是传入int *status并用宏解析:
int status; pid_t ret = waitpid(fpid, &status, 0); // 注意:fpid 是 fork() 返回值,非 0 if (ret == -1) { perror("waitpid failed"); } else if (WIFEXITED(status)) { printf("Child exited normally with code %d\n", WEXITSTATUS(status)); // 输出 1 } else if (WIFSIGNALED(status)) { printf("Child killed by signal %d\n", WTERMSIG(status)); }WIFEXITED和WEXITSTATUS是 POSIX 标准宏,直接操作status低 16 位比特位。若忽略此步,当子进程因段错误退出(SIGSEGV)时,父进程会误判为“正常结束”。
2.4 避坑:fork()后的文件描述符继承与缓冲区污染
现象:子进程printf("子进程:我是子进程...")输出乱序,或父进程printf("父进程:我是父进程...")出现在子进程输出中间。
原因:stdout默认行缓冲,fork()后父子进程共享同一FILE*结构体,但各自拥有独立的用户空间缓冲区。父进程printf后缓冲区未刷,子进程fork()继承该缓冲区内容,exec或exit时自动 flush,导致输出混杂。
解决:
- 方案1(推荐):
setvbuf(stdout, NULL, _IONBF, 0);在main()开头禁用缓冲; - 方案2:
fflush(stdout);在fork()前强制刷新; - 方案3:用
write(1, "...", len)系统调用绕过 stdio 缓冲层。
现象:waitpid()返回 -1,errno=ECHILD。
原因:父进程多次调用waitpid(),第二次调用时子进程已被前一次回收,内核中无对应僵尸进程。
解决:严格遵循“一个fork()对应一个waitpid()”原则,或用循环while (waitpid(-1, &status, WNOHANG) > 0);回收全部子进程。
现象:子进程fopen()成功但fgets()读不到内容,feof(fp)为真。
原因:test1file.txt文件末尾有\0字符,fgets()遇\0提前终止,但text数组未初始化,残留垃圾数据。
解决:声明char text[2000] = {0};显式清零,或memset(text, 0, sizeof(text));。
3. 线程数据共享:pthread_create()下的内存可见性与竞态条件实证
3.1shdata变量为何能被线程直接修改?从虚拟内存布局看共享本质
实验二中int shdata = 0;定义为全局变量,其存储位置在进程的.data段(已初始化全局变量)。当pthread_create()创建新线程时,内核并不为线程分配独立地址空间,而是让新线程共享父进程的整个虚拟内存映射(包括.data,.bss,.text段),仅独占栈空间(mmap分配的私有区域)。因此shdata的地址在父子线程中完全一致,&shdata打印值相同。这是线程与进程的本质区别:进程有独立页表,线程共用页表。
提示:若将
shdata改为static int shdata = 0;在函数内,仍属.data段,共享性不变;但若改为int *shdata = malloc(sizeof(int));,则malloc返回堆地址,同样被共享——因为堆也是进程级资源。
3.2sleep(3)的血泪经验:它根本不能保证线程执行顺序!
原代码main()中sleep(3)后才printf("shared data = %d"),意图是等线程执行完。但这是严重误区:sleep()只是让主线程休眠,不保证新线程已调度、已执行、已修改变量。实测中sleep(1)时shdata常为 0,sleep(0.1)时甚至出现 0 或 1 的随机结果。根本原因是:线程调度由内核决定,sleep()无法建立内存屏障。
正确同步方式必须用 pthread 原语:
#include <pthread.h> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; void *creat(void *arg) { pthread_mutex_lock(&mutex); printf("new pthread ....\n"); printf("shared data = %d \n", shdata); shdata++; printf("after operating :shared data = %d\n", shdata); pthread_cond_signal(&cond); // 通知主线程 pthread_mutex_unlock(&mutex); return NULL; } int main() { pthread_t tid; pthread_mutex_lock(&mutex); int pth = pthread_create(&tid, NULL, creat, NULL); if (pth != 0) { /* error */ } // 等待条件变量,而非 sleep while (shdata == 0) { pthread_cond_wait(&cond, &mutex); // 自动解锁 mutex 并阻塞 } printf("creat thread success\n"); printf("shared data = %d \n", shdata); pthread_mutex_unlock(&mutex); pthread_mutex_destroy(&mutex); pthread_cond_destroy(&cond); return 0; }3.3pthread_create()的第四个参数:void *arg如何安全传递结构体?
实验二传NULL,但实际项目需传复杂数据。常见错误是传栈变量地址:
// 错误!thread_func 运行时 main() 栈帧可能已销毁 struct task_args args = {.id = 1, .data = "hello"}; pthread_create(&tid, NULL, thread_func, &args); // 危险!安全方案:
- 方案1:
malloc分配堆内存,线程内free; - 方案2:用
static变量(单线程安全); - 方案3:主线程
pthread_join()后再释放(需确保 join 时机)。
3.4 避坑:pthread_create()失败不等于errno被设置
现象:pthread_create()返回非 0,但perror()打印 “Success”。
原因:POSIX 规定pthread_create()失败时返回错误码(如EAGAIN),而非设置errno。perror()读取的是上一个系统调用(如printf)的errno,故无效。
解决:
int pth = pthread_create(&tid, NULL, creat, NULL); if (pth != 0) { fprintf(stderr, "pthread_create failed: %s (code=%d)\n", strerror(pth), pth); // 直接用返回值查错 return -1; }现象:程序运行后shared data输出为 0,且无任何线程输出。
原因:pthread_create()成功,但主线程main()在子线程执行前就return 0,导致整个进程终止,子线程被强制杀死。
解决:必须pthread_join(tid, NULL)等待线程结束,或pthread_detach(tid)设置分离状态(此时不能 join,线程资源自动回收)。
现象:shdata++在多线程下结果非预期(如期望 2,得到 1)。
原因:shdata++非原子操作,汇编为mov,inc,mov三步,多线程并发执行时发生竞态。
解决:用__sync_fetch_and_add(&shdata, 1)(GCC 内置原子操作),或pthread_mutex_lock/unlock包裹。
4. 进程间通信三板斧:信号、匿名管道、命名管道的底层机制与阻塞诊断
4.1 信号通信的致命缺陷:SIGALRM不是可靠消息通道
实验三用kill(getppid(), SIGALRM)发送信号,看似简洁,但暴露了信号机制的根本局限:信号不排队、不携带数据、不可靠。SIGALRM默认动作是终止进程,若父进程未注册signal()处理函数,子进程一发信号,父进程就崩了。更糟的是,若子进程连续发 10 次SIGALRM,父进程可能只收到 1 次(信号合并),且无法知道是第几次。
验证方法:strace -e trace=kill,rt_sigaction,rt_sigprocmask ./a.out可见kill()系统调用成功,但rt_sigaction()若未设置,则信号被忽略。
工业级替代方案:
- 小数据:用
eventfd()创建事件通知 fd,write()写 8 字节整数,read()读取; - 大数据:必须用管道或 socket;
- 信号仅用于通知“有事发生”,具体数据走其他通道。
4.2 匿名管道的pipe()调用:文件描述符泄漏与close()顺序铁律
实验四中pipe(fd)创建一对 fd:fd[0]读端,fd[1]写端。关键规则:任何一端未close(),另一端read()将永远阻塞(即使写端进程已退出)。因为内核维护引用计数,只要有一个进程持有写端 fd,内核就认为“可能还有数据要写”。
典型翻车场景:
- 父进程
fork()后,子进程close(fd[1]),但父进程忘记close(fd[0]); - 子进程
read(fd[0])时,因父进程还持fd[0],read()不返回 EOF,一直阻塞。
修复代码(父进程部分):
else { close(fd[0]); // 父进程关闭读端 write(fd[1], str, strlen(str)); printf("父进程写入数据成功\n"); close(fd[1]); // 必须关闭写端!否则子进程 read 不会返回 printf("父进程关闭写端成功\n"); // 此处可 waitpid 等待子进程 }4.3 命名管道mkfifo()的阻塞本质:open()的 O_RDONLY/O_WRONLY 模式博弈
实验五中open("./fifo", O_RDONLY)在无写端打开时会永久阻塞,这是 FIFO 的设计特性(保证读写端同步)。新手常在此卡住,以为程序卡死。strace可见openat(..., O_RDONLY)系统调用不返回。
破解方法:
- 方案1(推荐):
open()时加O_NONBLOCK标志,失败时errno=EAGAIN,循环重试; - 方案2:先
mkfifo(),再fork(),父子进程分别open()读写端,利用fork()的同步性; - 方案3:用
stat()检查 FIFO 是否存在且有写端连接(需额外进程协调)。
安全mkfifo()写法:
// 避免 EEXIST 错误(FIFO 已存在) if (mkfifo("./fifo", 0666) == -1) { if (errno == EEXIST) { printf("FIFO already exists, reusing...\n"); } else { perror("mkfifo failed"); exit(EXIT_FAILURE); } }4.4 避坑:pipe()和mkfifo()的缓冲区大小与read()/write()原子性
现象:write(fd[1], str, strlen(str))返回值小于strlen(str),但未处理。
原因:管道缓冲区默认 64KB(/proc/sys/fs/pipe-max-size),write()在缓冲区满时只写入部分数据并返回实际字节数。POSIX 规定:若write()请求长度 ≤ PIPE_BUF(通常 4096 字节),则操作是原子的;否则可能分片。
解决:必须检查返回值并循环写入:
ssize_t total = 0, n; while (total < len) { n = write(fd[1], buf + total, len - total); if (n == -1) { if (errno == EINTR) continue; // 被信号中断,重试 perror("write failed"); break; } total += n; }现象:read(fd[0], buf, sizeof(buf))返回 0,但预期有数据。
原因:写端已close(fd[1]),read()返回 0 表示 EOF。若写端未关闭,read()阻塞;若写端关闭,read()立即返回 0。
解决:read()返回 0 是正常流程结束信号,非错误。
现象:mkfifo()创建的 FIFO 权限为prw-------,其他用户无法访问。
原因:umask影响mkfifo()权限。mkfifo("./fifo", 0666)在umask=0022下实际权限为0644(即prw-r--r--)。
解决:umask(0); mkfifo("./fifo", 0666);临时清空 umask。
5. 信号量同步实战:System V 信号量的初始化、PV 操作与死锁规避
5.1semget(IPC_PRIVATE, 1, ...)的隐含风险:IPC_PRIVATE 不等于进程私有
实验六用IPC_PRIVATE创建信号量,本意是“仅本进程使用”,但IPC_PRIVATE实际含义是“创建新信号量集,key 为 0”,该信号量集仍可在同一主机上被其他进程通过semctl()操作(若知道其semid)。真正的私有应使用sem_open()(POSIX 信号量),其名称/mysem受文件系统权限控制。
验证:ipcs -s可列出所有 System V 信号量,semid为十进制数,任何进程只要知道该数,就能semctl(semid, 0, GETVAL, ...)读取值。
生产建议:优先用sem_open("/mysem", O_CREAT, 0644, 1),sem_close()释放,sem_unlink()删除。
5.2union semun的强制类型转换:为什么semctl()需要这个“奇怪”的联合体?
semctl()第四个参数类型为union semun,因历史原因(早期 POSIX 未定义该类型),必须手动声明。其成员val用于SETVAL设置初值,buf用于GETALL获取全部值,array用于SETALL设置数组。若声明错误(如int val写成long val),semctl()会因结构体大小不匹配而失败。
标准写法(必须):
union semun { int val; /* for SETVAL */ struct semid_ds *buf; /* for IPC_STAT, IPC_SET */ unsigned short *array; /* for GETALL, SETALL */ struct seminfo *__buf; /* for IPC_INFO (Linux specific) */ };5.3 生产者-消费者中的semop()顺序:为什么emptyid必须在mutxid前?
实验六中生产者先semop(emptyid, &P, 1)(申请空槽),再semop(mutxid, &P, 1)(进入临界区)。若顺序颠倒:
semop(mutxid, &P, 1); // 先锁互斥量 semop(emptyid, &P, 1); // 再申请空槽 → 可能阻塞!则生产者持有mutxid锁,但因无空槽在emptyid上阻塞,此时消费者想semop(fullid, &P, 1)也需先semop(mutxid, &P, 1),但mutxid被生产者占用,死锁形成。
经典解决方案(Dijkstra 提出):所有进程按固定顺序申请信号量。此处约定:emptyid/fullid(资源信号量)优先于mutxid(互斥信号量)。
5.4 避坑:mmap()共享内存的MAP_ANONYMOUS与MAP_SHARED组合陷阱
现象:array = mmap(NULL, size, ..., MAP_SHARED|MAP_ANONYMOUS, -1, 0)返回地址,但子进程读取array[0]为随机值。
原因:MAP_ANONYMOUS表示不关联文件,MAP_SHARED表示修改对其他进程可见,但fork()后子进程获得的是写时复制(COW)副本,array地址虽同,但物理页不同。MAP_ANONYMOUS+MAP_SHARED在 Linux 上有效,但需确保父子进程都mmap()同一参数,且fork()后不munmap()。
正确共享方式(实验六实际采用):
mmap()时用MAP_SHARED | MAP_ANONYMOUS,fork()后父子进程自动共享该内存区域;- 验证:
printf("array addr: %p\n", array);在父子进程中打印相同地址; - 关键:
array指针本身是进程局部的,但其所指内存页被内核标记为共享。
现象:semop()返回 -1,errno=EINTR。
原因:semop()被信号中断(如SIGALRM),POSIX 允许其返回 -1 并设errno=EINTR。
解决:必须循环调用直到成功:
while (semop(semid, &op, 1) == -1) { if (errno != EINTR) { perror("semop failed"); break; } // 被信号中断,重试 }现象:消费者*get == 100判断失效,循环不退出。
原因:*get是共享内存变量,但无内存屏障,编译器可能将其优化为寄存器缓存,导致始终读旧值。
解决:声明为volatile int *get;,或用__sync_synchronize()插入内存栅栏。
现象:semctl()初始化后semctl(semid, 0, GETVAL, arg)返回 0,非预期值。
原因:arg.val未初始化,或semctl()调用顺序错误(如SETVAL前未semget())。
解决:arg.val = 1; semctl(semid, 0, SETVAL, arg);后立即int val = semctl(semid, 0, GETVAL, arg);验证。
6. 从调试到交付:用strace/gdb/lsof构建 IPC 故障排查流水线
6.1 三工具黄金组合:strace定位系统调用失败点,gdb检查内存状态,lsof验证资源句柄
当 IPC 程序行为异常,我从不猜,而是启动标准化排查流水线:
strace -f -e trace=clone,fork,waitpid,semop,pipe,open,read,write,close,kill-f跟踪所有子进程,-e trace=...精确过滤 IPC 相关系统调用。输出中重点看:clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...)→fork()成功;waitpid(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0) = 1234→ 子进程正常退出;semop(123, [{0, -1, SEM_UNDO}], 1) = -1 EAGAIN→ 信号量不可用;read(3, "→ 阻塞在 fd 3(管道读端)。
gdb ./a.out+break main+run+info proc mappings
进入gdb后:info proc mappings查看共享内存地址范围(对比mmap()返回值);print *array@10查看共享数组前 10 个元素值;watch *get设置观察点,当*get被修改时中断。
lsof -p $PID查看进程打开的所有 fd
关键列:FD(文件描述符号)、TYPE(FIFO、REG、semaphore)、NAME(路径或标识)。例如:COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME a.out 1234 user 3 FIFO 0,12 0t0 1234567 /home/user/fifo a.out 1234 user 4 sem 0,0 0t0 0 /SYSV00000001若
NAME列为/SYSV...,说明是 System V 信号量;若为路径,则是命名管道。
6.2 信号量调试表:semctl()常用命令与返回值速查
cmd参数 | 作用 | arg类型 | 典型返回值 | 调试要点 |
|---|---|---|---|---|
IPC_STAT | 获取信号量集状态 | struct semid_ds * | 0 成功,-1 失败 | 检查sem_otime(最后操作时间)是否更新 |
GETVAL | 获取第 0 个信号量值 | int(仅val字段) | 当前值(如 5) | 验证初始化是否生效 |
SETVAL | 设置第 0 个信号量值 | int(val字段) | 0 成功 | 必须先semget(),否则errno=EINVAL |
GETALL | 获取所有信号量值 | unsigned short * | 0 成功 | arg.array必须指向足够大数组 |
实战技巧:用ipcs -s查semid,再ipcs -s -i <semid>查详细信息,其中nsems是信号量个数,otime是最后操作时间戳。
6.3 从“能跑”到“可靠”:添加日志与超时的最小化改造清单
一份能交差的实验代码和一份可部署的 IPC 模块,差距在于健壮性。我在所有实验代码上强制添加以下 5 项:
- 系统调用错误检查:每个
fork()/pipe()/semget()后加if (ret == -1) { perror("xxx"); exit(EXIT_FAILURE); }; waitpid()超时:用alarm()+sigaction()实现 5 秒超时,避免父进程永久挂起;- 文件/管道清理:
atexit()注册函数,在exit()前unlink("./fifo")和semctl(semid, 0, IPC_RMID); - 内存初始化:所有
malloc()/mmap()后memset(),所有栈数组声明时{0}; printf()替换为fprintf(stderr, ...):避免 stdout 缓冲干扰调试输出时序。
例如waitpid()超时实现:
volatile sig_atomic_t timeout_flag = 0; void timeout_handler(int sig) { timeout_flag = 1; } int safe_waitpid(pid_t pid) { struct sigaction sa, old_sa; sa.sa_handler = timeout_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGALRM, &sa, &old_sa); alarm(5); // 5秒超时 int status; pid_t ret = waitpid(pid, &status, 0); alarm(0); // 关闭闹钟 sigaction(SIGALRM, &old_sa, NULL); if (timeout_flag) { fprintf(stderr, "waitpid timeout after 5s\n"); return -1; } return ret; }从那以后我每次写 IPC 代码,都强制走一遍strace -f -e trace=...+lsof -p $!流程,哪怕只是改了一行printf。因为操作系统底层没有“差不多”,只有EAGAIN和EACCES的精确区分,只有WIFEXITED和WIFSIGNALED的明确分支。希望帮到你。
本文还有配套的精品资源,点击获取