从 printf 到 pipe:我用几个 C 小程序,重新认识 Linux 系统编程
最近开始接触 Linux 服务端代码,也在尝试阅读 Samba。
刚开始看源码时,我能大概看懂函数之间的调用关系,但遇到一些问题就说不清楚了:
main()到底是怎么开始运行的?printf()和write()有什么区别?- 文件描述符为什么是一个整数?
dup()到底复制了什么?fork()之后,父子进程哪些东西独立,哪些东西共享?- 子进程怎样把数据传给父进程?
只看概念时,我经常觉得“好像懂了”。真正开始手敲代码之后,才发现自己会把标准输入当作输出、忘记给字符串结束符留空间,也会把 close() 和 waitpid() 的顺序放错。
所以这篇文章不是想一次讲完 Linux 系统编程,而是记录我做过的一组小实验:
先写程序,再看输出,最后用内核对象和系统调用解释现象。
希望对同样刚开始学习 Linux 编程的同学有帮助。
环境:Linux,我使用的是 Kali 虚拟机。
工具:C 编译器
cc、终端、strace。示例主要用于观察机制,不是完整的生产级封装。PID、FD 编号和部分输出顺序可能因环境而不同。
一、先分清:程序文件、进程和 main()
1. 程序文件不等于进程
磁盘上的可执行文件是程序。
运行起来之后,拥有 PID、虚拟地址空间、文件描述符等运行状态的,才是进程。
同一个可执行文件运行多次,可以产生多个进程。它们执行相同的程序,但并不是同一个运行实例。
2. Bash 并不是直接调用程序的 main()
在普通交互式 Bash 中执行外部程序,大致会经历:
- Bash 创建子进程,通常通过
fork()或相关机制; - 子进程调用
execve(); - 内核用目标程序替换这个进程原来的程序映像;
- 对于动态链接程序,动态链接器和启动代码完成必要的初始化;
- C 运行库调用
main(); - 程序结束后,父 Bash 获取子进程的退出状态。
这里我最开始混淆了两件事:
fork()创建新进程;execve()替换当前进程的程序映像。
execve() 本身不是“再创建一个新 PID”。
另外,main() 也不是程序最早执行的指令。在进入它之前,程序通常已经经历了装载、动态链接和运行库初始化。
这也解释了后面一个现象:明明 C 源码只有几行,strace 却能看到不少系统调用。
二、第一个实验:把字符串放进内存,不等于把它打印出来
1. 我写的进程信息程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char message[128];
int length = snprintf(message, sizeof(message),
"pid=%ld ppid=%ld\n",
(long)getpid(),
(long)getppid());
if (length < 0 || (size_t)length >= sizeof(message)) {
fprintf(stderr, "failed to format process information\n");
return EXIT_FAILURE;
}
ssize_t written = write(STDOUT_FILENO,
message,
(size_t)length);
if (written == -1) {
perror("write");
return EXIT_FAILURE;
}
if ((size_t)written != (size_t)length) {
fprintf(stderr,
"short write: expected %d bytes, wrote %zd bytes\n",
length, written);
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译、运行:
cc -std=c11 -Wall -Wextra -Werror -g -O0 review.c -o review \
&& ./review
我统一使用这些编译选项:
-std=c11:使用 C11;-Wall -Wextra:开启常用警告;-Werror:把警告视为错误;-g:保留调试信息;-O0:关闭优化,方便当前阶段观察和调试。
&& 表示前面的编译成功后才运行,避免编译失败了,却继续执行旧的二进制文件。
还有一个很基础但我确实踩过的坑:
修改代码之后要先保存。编译器读的是磁盘文件,不是编辑器里尚未保存的内容。
2. snprintf()、printf()、write() 分别做什么?
这三个函数的名字看起来有联系,但职责并不一样。
| 函数 | 当前实验中承担的工作 |
|---|---|
snprintf() |
把格式化后的字符放进指定数组 |
printf() |
把格式化后的内容交给标准输出流,可能先缓冲 |
write() |
通过系统调用向指定 FD 对应的对象写入字节 |
例如:
char message[128];
snprintf(message, sizeof(message), "hello\n");
到这里,只是数组中有了字符串。
即使里面包含换行,也不会因为调用了 snprintf() 就自动显示在终端上。
需要另外执行:
write(STDOUT_FILENO, message, 6);
才会请求内核把这些字节写向标准输出。
我刚开始以为“格式化之后应该就显示了”,后来才明确:
格式化数据与输出数据,是两个不同的步骤。
3. 为什么还要检查 snprintf() 的返回值?
snprintf() 返回的是:如果空间足够,本来需要写入多少个字符,不包括末尾的 '\0'。
因此:
if (length < 0 || (size_t)length >= sizeof(message))
分别检查:
- 格式化失败;
- 缓冲区不足,结果被截断。
为什么是 >=,而不是 >?
因为即使需要写入的字符数刚好等于数组容量,也没有空间保存字符串结束符了。
三、用 strace 看“程序向内核请求了什么”
1. strace 不是逐行调试器
我刚开始并不知道 strace 是什么。
后来把它理解成了一个观察窗口:
它主要跟踪系统调用及相关信号事件,而不是显示每个普通 C 函数调用。
运行:
strace -s 128 -e trace=write ./review
输出形式类似:
write(1, "pid=299835 ppid=299832\n", 23) = 23
可以拆开读:
write:系统调用名称;1:目标 FD;- 字符串:待写入的数据;
- 参数中的
23:请求写入的字节数; - 等号后的
23:实际写入的字节数。
这比单纯看“终端上出现了一行文字”更具体。
2. 为什么看不到 snprintf()?
因为它不是系统调用名称。
在这个实验里,它主要在用户态把数字转换成字符,并写进数组。strace 不会把普通的 snprintf() 调用单独列出来。
但运行:
strace -c ./review
又可能看到源码中没有直接写出的 openat()、mmap()、mprotect() 等调用。
这些可能来自动态链接器和运行库的初始化。
所以:
进程实际执行的内容,比 main() 函数体里写出的内容更多。
3. 为什么 trace 和程序输出会混在一起?
有时输出看起来像这样:
write(1, "...", 23这里夹着程序打印的内容
) = 23
原因是:
strace默认把跟踪信息写到标准错误;- 程序把内容写到标准输出;
- 两者都显示在当前终端,因此可能交错。
想分开观察,可以把 trace 保存到文件:
strace -o review.trace -s 128 -e trace=write ./review
四、返回值、errno 和退出状态,不是同一件事
1. 判断 write() 是否失败,先看返回值
对于:
ssize_t written = write(fd, message, length);
应该按接口约定理解:
| 返回值 | 含义 |
|---|---|
-1 |
调用报错,通过 errno 查看原因 |
| 非负值 | 本次实际写入的字节数,还要检查是否写满 |
不能简单写成:
if (errno != 0) {
/* 认为本次调用失败 */
}
因为成功的函数调用不保证清除 errno。
例如:
errno = EIO;
ssize_t written = write(fd, message, length);
即使 write() 成功,errno 中仍可能保留之前的 EIO。
我现在的记法是:
返回值说明这次操作的结果;失败契约成立后,errno 才用来解释失败原因。
2. 故意传一个无效 FD
我把写入目标改成:
write(-1, message, length);
实际看到了:
write(-1, "pid=5728 ppid=5725\n", 19) = -1 EBADF
这里两个 -1 的含义不同:
- 参数中的
-1:我传入的无效 FD; - 返回值
-1:内核报告本次调用失败; EBADF:错误原因,表示文件描述符无效。
随后:
perror("write");
打印:
write: Bad file descriptor
3. 打印错误,不会自动让程序报告失败
下面这段程序即使写入失败,最终仍然返回成功:
if (written == -1) {
perror("write");
}
return EXIT_SUCCESS;
因为 perror() 只负责打印。
想让整个程序报告失败,需要明确返回:
if (written == -1) {
perror("write");
return EXIT_FAILURE;
}
运行后立即查看:
./review
echo "exit=$?"
$? 是上一条命令的退出状态。
因此要分清:
write()返回值:一次写操作的结果;errno:失败原因;main()的返回值:程序向父进程报告的退出状态。
4. 不要让后面的操作覆盖原来的错误
我还学到一个以后阅读项目代码很有用的模式:
int ret = some_operation();
int saved_errno = errno;
record_audit_log();
errno = saved_errno;
return ret;
假设文件操作因权限不足失败,后面的审计操作又发生其他错误。
如果不保护原来的 errno,上层可能收到:
- 文件操作的失败返回值;
- 审计操作的错误原因。
两者就不匹配了。
返回给上层的操作结果与有效错误原因,应该属于同一次操作。
五、文件描述符 FD:一个整数背后是什么?
1. 先认识标准 FD
| 名称 | 数值 | 约定用途 |
|---|---|---|
STDIN_FILENO |
0 | 标准输入 |
STDOUT_FILENO |
1 | 标准输出 |
STDERR_FILENO |
2 | 标准错误 |
但要注意:这些是约定的编号和用途,实际关联的对象可以是终端、文件、管道等。
2. 我把标准输入写成了输出目标,竟然还能打印
当时误写成:
write(STDIN_FILENO, message, length);
终端居然出现了内容,我一度以为没有问题。
原因是,在当时的交互式终端环境里,FD 0 也指向终端,而且底层打开方式允许写入。
但把标准输入换成只读打开的 /dev/null:
./review < /dev/null
就暴露了问题。
“程序显示了内容”不等于“目标 FD 选择正确”。
内核检查的是 FD 引用的对象和访问方式,不会因为 C 宏名字里写着 STDIN,就自动赋予某种特殊限制。
3. FD 数字、FD 表项、打开文件描述
学习到这里,我开始区分三层:
- C 变量:例如
int fd,只是用户态的一个整数; - 进程的 FD 表项:内核通过这个编号找到对应引用;
- 打开文件描述:一次打开操作对应的内核状态,保存文件偏移量、文件状态标志等,并关联底层文件。
于是:
int another = fd;
只是复制整数。
而:
int another = dup(fd);
会请求内核增加一个新的 FD 表项。
六、dup():两个 FD,可以访问同一个底层对象
1. 复制标准输出
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int copied_fd = dup(STDOUT_FILENO);
if (copied_fd == -1) {
perror("dup");
return EXIT_FAILURE;
}
const char message[] = "hello fd\n";
printf("stdout=%d, copied_fd=%d\n",
STDOUT_FILENO, copied_fd);
ssize_t written = write(copied_fd, message,
sizeof(message) - 1);
if (written == -1) {
perror("write");
close(copied_fd);
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "short write\n");
close(copied_fd);
return EXIT_FAILURE;
}
if (close(copied_fd) == -1) {
perror("close");
return EXIT_FAILURE;
}
written = write(STDOUT_FILENO, message,
sizeof(message) - 1);
if (written == -1) {
perror("write stdout");
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "short write to stdout\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译并跟踪:
cc -std=c11 -Wall -Wextra -Werror -g -O0 dup_stdout.c -o dup_stdout \
&& strace -s 128 -e trace=dup,write,close ./dup_stdout
重点观察的是:
dup(1)返回一个新 FD;- 通过新 FD 写入成功;
- 关闭新 FD;
- 通过原来的 FD 1 仍然可以写入。
2. 关闭副本,不会自动关闭原来的 FD
假设新 FD 是 3:
close(3)后,FD 1 仍能使用;- 反过来,关闭 FD 1,只要 FD 3 还有效,也能通过 FD 3 输出。
因为 FD 3 并不是“先转发给 FD 1,再访问对象”。
它自己就持有一条指向底层打开对象的引用。
不过,如果关闭了 FD 1,printf() 不会自动改用 FD 3。标准输出流仍然关联原来的标准输出描述符。
3. close() 不会把变量自动改成 -1
close(copied_fd);
关闭的是内核中的描述符引用。
C 变量 copied_fd 里仍可能保存整数 3,但这个数字已经不能继续当作刚才那个有效 FD 使用。
以后新打开的资源还可能重新获得编号 3。
所以,FD 是可复用的编号,不是某个资源永久不变的身份证。
4. 我遇到的输出顺序问题
有一次我写了:
printf("stdout=%d,copied_fd=%d", STDOUT_FILENO, copied_fd);
write(copied_fd, "hello fd\n", 9);
终端却先显示:
hello fd
stdout=1,copied_fd=3
不是 C 语句执行顺序反了,而是:
printf()的内容暂存在 stdio 缓冲区;write()不经过那个 stdio 缓冲区,先请求内核输出;- 正常退出时,剩余 stdio 内容才被刷新。
在当前终端默认的行缓冲环境中,加上换行可以促使这一行刷新:
printf("stdout=%d, copied_fd=%d\n",
STDOUT_FILENO, copied_fd);
但重定向到文件时,换行不一定刷新。需要明确控制 stdio 输出顺序时,应考虑 fflush(stdout) 并检查结果。
5. perror() 为什么曾经写向 FD 3?
在错误实验中,我看到:
dup(2) = 3
fcntl(3, F_GETFL) = 0x402 (flags O_RDWR|O_APPEND)
write(3, "write: Bad file descriptor\n", 27) = 27
close(3) = 0
原来,我这个环境中的 libc 在该次 perror() 调用路径里,先复制了标准错误 FD,再通过临时副本输出。
这并不违背“perror() 输出到标准错误”的接口约定。
同时也提醒我:
库函数的语义,不等于底层必须只有一次固定形式的系统调用。这个内部实现也不应被当作所有 libc 的保证。
七、文件偏移量:为什么一个 FD 读 ABC,另一个读 DEF?
1. 准备数据文件
在实验目录中准备一个内容如下的文件:
ABCDEFGHIJKL
这里使用的文件名是 fd_data.txt。
注意,代码中的相对路径是相对于程序运行时的当前工作目录,不是自动相对于 C 源文件所在目录。
2. 一次 open(),再 dup()
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(void)
{
int fd = open("fd_data.txt", O_RDONLY);
if (fd == -1) {
perror("open");
return EXIT_FAILURE;
}
int copied_fd = dup(fd);
if (copied_fd == -1) {
perror("dup");
close(fd);
return EXIT_FAILURE;
}
printf("fd=%d, copied_fd=%d\n", fd, copied_fd);
char buffer[4];
ssize_t nread = read(fd, buffer, sizeof(buffer) - 1);
if (nread == -1) {
perror("first read");
close(copied_fd);
close(fd);
return EXIT_FAILURE;
}
buffer[nread] = '\0';
printf("first read: bytes=%zd, text=%s\n", nread, buffer);
nread = read(copied_fd, buffer, sizeof(buffer) - 1);
if (nread == -1) {
perror("second read");
close(copied_fd);
close(fd);
return EXIT_FAILURE;
}
buffer[nread] = '\0';
printf("second read: bytes=%zd, text=%s\n", nread, buffer);
if (close(copied_fd) == -1) {
perror("close copied_fd");
close(fd);
return EXIT_FAILURE;
}
if (close(fd) == -1) {
perror("close fd");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译、运行:
cc -std=c11 -Wall -Wextra -Werror -g -O0 dup_offset.c -o dup_offset \
&& ./dup_offset
我得到的读取结果是:
first read: bytes=3, text=ABC
second read: bytes=3, text=DEF
3. 为什么不是两次 ABC?
因为 dup() 得到的两个 FD,共享同一份打开文件描述。
文件偏移量保存在这份内核对象中,而不是分别保存在两个 FD 数字里。
| 阶段 | 共享文件偏移量 |
|---|---|
open() 后 |
0 |
第一次读取 ABC 后 |
3 |
第二次读取 DEF 后 |
6 |
这里不是第一个 FD 把读取进度“通知”给了第二个 FD。
它们本来就使用同一份读取位置记录。
4. 再 open() 一次,对照结果就变了
我保留前面的实验,另存一份,把:
int copied_fd = dup(fd);
改成:
int copied_fd = open("fd_data.txt", O_RDONLY);
对应的错误提示也改为第二次打开失败。其余读取逻辑不变。
这次得到:
first read: bytes=3, text=ABC
second read: bytes=3, text=ABC
原因是:
- 同一个磁盘文件;
- 两次独立的
open(); - 两份独立的打开文件描述;
- 各自的文件偏移量初始都是 0。
对比之后,我才真正理解:
打开同一个文件,不等于共享同一份打开状态。
八、一个差点忽略的越界:能打印出来,不代表代码合法
读文件时,我曾经写成:
char buffer[4];
ssize_t nread = read(fd, buffer, 4);
buffer[nread] = '\0';
结果正常打印了:
ABCD
但这里有问题。
当 read() 返回 4 时,数组的四个位置已经放满:
buffer[0] = 'A';
buffer[1] = 'B';
buffer[2] = 'C';
buffer[3] = 'D';
随后:
buffer[4] = '\0';
写到了第五个位置,已经越界。
正确的配套方式是:
char buffer[4];
ssize_t nread = read(fd, buffer, sizeof(buffer) - 1);
if (nread == -1) {
/* 先处理错误,不能拿 -1 当数组下标 */
}
buffer[nread] = '\0';
实际程序中,错误分支必须返回或跳转,不能继续执行下面的下标访问。
这里要区分:
read()读取的是字节,不会自动添加字符串结束符;- 使用
%s打印时,需要合法的 C 字符串; - 处理二进制数据时,不一定需要
'\0',但也不能直接套用%s。
这次给我的提醒是:
编译没有报错、程序没有崩溃,只能说明这次没有显式暴露问题,不能证明不存在未定义行为。
九、fork():一次调用,父子进程分别继续执行
1. 返回值与 PID 不是同一个概念
fork() 成功后:
| 执行位置 | 返回值 |
|---|---|
| 父进程 | 子进程 PID,大于 0 |
| 子进程 | 0 |
| 创建失败 | -1,并设置 errno |
子进程拿到 0,不代表它的真实 PID 是 0。
真实 PID 要通过 getpid() 获取。
2. 子进程修改普通变量,会影响父进程吗?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int value = 10;
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
value = 20;
printf("child: value=%d\n", value);
printf("child: pid=%ld, ppid=%ld, fork_return=%ld\n",
(long)getpid(), (long)getppid(), (long)pid);
return EXIT_SUCCESS;
}
printf("parent: pid=%ld, child_pid=%ld\n",
(long)getpid(), (long)pid);
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
return EXIT_FAILURE;
}
printf("parent after wait: value=%d\n", value);
return EXIT_SUCCESS;
}
编译、运行:
cc -std=c11 -Wall -Wextra -Werror -g -O0 fork_basic.c -o fork_basic \
&& ./fork_basic
我一次运行的结果是:
parent: pid=9894, child_pid=9895
child: value=20
child: pid=9895, ppid=9894, fork_return=0
parent after wait: value=10
3. 为什么父进程仍然是 10?
因为父子进程的普通私有内存状态相互独立。
子进程执行:
value = 20;
修改的是子进程自己的那一份。
这里父进程是在 waitpid() 成功之后才打印,因此也排除了“父进程打印得太早”的干扰。
4. 内核是不是立即复制了所有内存?
通常不是。
Linux 一般使用写时复制,也就是 Copy-on-Write,简称 COW。
可以先分成两层理解:
- 程序语义:普通私有内存彼此独立;
- 内核优化:初期可以共享物理页,需要分离写入时,再复制相应的页。
所以暂时共享物理页,不等于应用程序可以通过修改普通变量直接互相传消息。
十、fork() 继承的 FD,却可以共享文件偏移量
前面普通变量独立,并不意味着所有东西都独立。
接下来,我在 fork() 之前打开文件,让父子进程分别读取。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
int main(void)
{
int fd = open("fd_data.txt", O_RDONLY);
if (fd == -1) {
perror("open");
return EXIT_FAILURE;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
close(fd);
return EXIT_FAILURE;
}
if (pid == 0) {
printf("child: fd=%d\n", fd);
char child_buf[4];
ssize_t nread = read(fd, child_buf, sizeof(child_buf) - 1);
if (nread == -1) {
perror("child read");
close(fd);
return EXIT_FAILURE;
}
child_buf[nread] = '\0';
printf("child read: bytes=%zd, text=%s\n",
nread, child_buf);
if (close(fd) == -1) {
perror("child close");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
close(fd);
return EXIT_FAILURE;
}
printf("parent after wait: fd=%d\n", fd);
char parent_buf[4];
ssize_t nread = read(fd, parent_buf, sizeof(parent_buf) - 1);
if (nread == -1) {
perror("parent read");
close(fd);
return EXIT_FAILURE;
}
parent_buf[nread] = '\0';
printf("parent read: bytes=%zd, text=%s\n",
nread, parent_buf);
if (close(fd) == -1) {
perror("parent close");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译、运行:
cc -std=c11 -Wall -Wextra -Werror -g -O0 fork_fd.c -o fork_fd \
&& ./fork_fd
读取结果是:
child: fd=3
child read: bytes=3, text=ABC
parent after wait: fd=3
parent read: bytes=3, text=DEF
1. 同样是 FD 3,但属于两个进程
父进程有自己的 FD 表,子进程也有自己的 FD 表。
两边的 FD 3 都引用了 fork() 前那次 open() 建立的打开文件描述,因此共享偏移量。
子进程读完 ABC 后,偏移量变为 3,父进程接着读到 DEF。
2. 子进程关闭自己的 FD,不关闭父进程的 FD
子进程执行 close(3),关闭的是子进程自己的表项。
父进程的引用还在,所以底层打开状态仍然可以继续使用。
并且,子进程退出不会把共享偏移量重置为 0。
3. waitpid() 不是在同步文件偏移量
waitpid() 在这里仅用于保证顺序:
子进程先读完并结束,父进程再读取。
共享偏移量不是等到 waitpid() 才被传给父进程的,它原本就在共同引用的内核对象里。
我写这个实验时,还曾经把:
printf("parent after wait: ...");
放在 waitpid() 前面。
后来才意识到:日志里写着 “after wait”,并不能保证代码真的已经等待。
判断程序行为要看语句位置,不能只相信日志文字。
十一、pipe():终于让子进程把数据传给父进程
前面子进程改普通变量,父进程看不到变化。
这次通过管道传递数据:
- 子进程写入内核的管道缓冲区;
- 父进程从管道中读取。
1. 管道提供两个端点
int pipefd[2];
pipe(pipefd);
创建成功后:
| 表达式 | 用途 |
|---|---|
pipefd[0] |
读端 FD |
pipefd[1] |
写端 FD |
这里的 0 和 1 是数组下标,不是实际 FD 编号。
我们在 fork() 前创建管道,父子都会继承两个端点。
然后明确分工:
- 子进程发送:关闭读端;
- 父进程接收:关闭写端。
2. 我写的文字传递程序
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int pipefd[2];
if (pipe(pipefd) == -1) {
perror("pipe");
return EXIT_FAILURE;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
close(pipefd[0]);
close(pipefd[1]);
return EXIT_FAILURE;
}
if (pid == 0) {
close(pipefd[0]);
const char message[] = "hello from child\n";
ssize_t written = write(pipefd[1], message,
sizeof(message) - 1);
if (written == -1) {
perror("child write");
close(pipefd[1]);
return EXIT_FAILURE;
}
if ((size_t)written != sizeof(message) - 1) {
fprintf(stderr, "child short write\n");
close(pipefd[1]);
return EXIT_FAILURE;
}
close(pipefd[1]);
return EXIT_SUCCESS;
}
close(pipefd[1]);
char buffer[64];
ssize_t nread;
int result = EXIT_SUCCESS;
while ((nread = read(pipefd[0], buffer,
sizeof(buffer) - 1)) > 0) {
buffer[nread] = '\0';
printf("parent received: %s", buffer);
}
if (nread == -1) {
perror("parent read");
result = EXIT_FAILURE;
} else {
printf("parent: EOF\n");
}
close(pipefd[0]);
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
return EXIT_FAILURE;
}
return result;
}
编译、运行:
cc -std=c11 -Wall -Wextra -Werror -g -O0 pipe_message.c -o pipe_message \
&& ./pipe_message
这次实际看到了:
parent received: hello from child
parent: EOF
3. 这里的“共享”和普通变量不是一回事
父子进程没有共同修改一个普通数组。
子进程把自己的字符串交给内核,父进程再把管道中的数据读进自己的数组。
数据通过管道传递,不是因为父子进程的普通内存自动共享。
4. EOF 不是子进程发送的特殊字符
对于这个普通匿名管道,当:
- 管道中的数据已经读完;
- 所有写端引用都已关闭;
继续读取时,read() 才返回 0。
所以父进程打印的:
parent: EOF
是程序根据返回值作出的判断,不是管道里收到了一段叫 EOF 的文字。
5. 为什么父进程也要关闭写端?
父进程虽然不发送,但 fork() 后它也继承了写端。
如果不关闭自己的写端,即使子进程退出,内核仍然看到有人持有写端。
在管道为空、采用默认阻塞读取的情况下,父进程就可能继续等待,而不是收到 EOF。
这让我意识到:
关闭不使用的 FD,不只是防止资源浪费,有时直接决定程序能否结束等待。
6. 为什么先读取,最后才等待子进程?
管道容量有限。
如果父进程先等待子进程结束,而子进程又因为管道写满、等待父进程读取,就可能互相等待。
本次文字很短,不会填满管道,但程序仍采用:
父进程持续读取,结束后再回收子进程。
7. 进一步观察的命令
还可以使用下面的命令跟踪父子进程:
strace -f -e trace=pipe,pipe2,read,write,close,wait4 ./pipe_message
-f 表示同时跟踪创建出来的子进程。
这里列的是进一步验证的方法,不预设每台机器都显示完全相同的调用形式:
pipe()可能在 trace 中显示为pipe2();waitpid()可能显示为wait4();- 需要根据进程标识和实际 FD 编号关联读写操作。
十二、这些小程序还不是生产级代码
今天的重点是理解机制,因此我没有把每个程序都扩展成完整的 I/O 库。
后续还需要继续学习:
read()、write()、waitpid()被信号中断时如何处理EINTR;- 如何循环处理短读和短写;
- 如何检查关闭操作和输出操作的错误;
- 如何获取并判断子进程退出状态;
- 管道对端关闭时,
SIGPIPE和EPIPE怎样表现; - 超时、非阻塞和事件循环。
尤其要注意:
waitpid(pid, NULL, 0);
这里没有取出子进程的退出状态。
等待和回收成功,不等于已经判断子进程的业务执行成功。
目前这些程序用于观察正常路径和几个特定失败场景,不应该原样当作复杂服务端程序的全部错误处理方案。
十三、今天最重要的几组对照
| 对照 | 实验得到的认识 |
|---|---|
snprintf() 与 write() |
在内存中格式化,不等于输出 |
返回值与 errno |
先根据接口契约判断结果,再解释错误 |
| 系统调用失败与程序退出 | 一次操作失败,不会自动决定整个程序退出状态 |
普通赋值与 dup() |
复制整数,不等于增加内核 FD 表项 |
dup() 与再次 open() |
前者共享打开状态,后者建立独立打开状态 |
| 普通变量与继承的 FD | 私有内存独立,引用的内核对象却可能共享 |
| 子进程退出与管道 EOF | EOF 取决于剩余数据和所有写端引用 |
| 日志内容与执行位置 | 写着“等待之后”,不代表真的在等待之后 |
写在最后
今天最大的收获,不是记住了多少个函数,而是开始知道应该问什么:
- 这个变量只是用户态的值,还是用来访问内核资源的编号?
- 谁创建了资源?
- 谁持有引用?
- 哪些状态共享,哪些状态独立?
- 当前错误属于哪一次操作?
- 哪条执行路径负责关闭资源?
- 程序现在等待的条件,究竟由谁满足?
这些问题听起来很基础,但亲手写过之后,我再看 Samba 里的 fork()、FD 清理和进程间通信,就不再只是在记函数名。
目前我还没有学完 Linux 系统编程,也没有把这些机制的内核实现全部看懂。