从 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 中执行外部程序,大致会经历:

  1. Bash 创建子进程,通常通过 fork() 或相关机制;
  2. 子进程调用 execve()
  3. 内核用目标程序替换这个进程原来的程序映像;
  4. 对于动态链接程序,动态链接器和启动代码完成必要的初始化;
  5. C 运行库调用 main()
  6. 程序结束后,父 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 表项、打开文件描述

学习到这里,我开始区分三层:

  1. C 变量:例如 int fd,只是用户态的一个整数;
  2. 进程的 FD 表项:内核通过这个编号找到对应引用;
  3. 打开文件描述:一次打开操作对应的内核状态,保存文件偏移量、文件状态标志等,并关联底层文件。

于是:

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

重点观察的是:

  1. dup(1) 返回一个新 FD;
  2. 通过新 FD 写入成功;
  3. 关闭新 FD;
  4. 通过原来的 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 不是子进程发送的特殊字符

对于这个普通匿名管道,当:

  1. 管道中的数据已经读完;
  2. 所有写端引用都已关闭;

继续读取时,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
  • 如何循环处理短读和短写;
  • 如何检查关闭操作和输出操作的错误;
  • 如何获取并判断子进程退出状态;
  • 管道对端关闭时,SIGPIPEEPIPE 怎样表现;
  • 超时、非阻塞和事件循环。

尤其要注意:

waitpid(pid, NULL, 0);

这里没有取出子进程的退出状态。

等待和回收成功,不等于已经判断子进程的业务执行成功。

目前这些程序用于观察正常路径和几个特定失败场景,不应该原样当作复杂服务端程序的全部错误处理方案。


十三、今天最重要的几组对照

对照 实验得到的认识
snprintf()write() 在内存中格式化,不等于输出
返回值与 errno 先根据接口契约判断结果,再解释错误
系统调用失败与程序退出 一次操作失败,不会自动决定整个程序退出状态
普通赋值与 dup() 复制整数,不等于增加内核 FD 表项
dup() 与再次 open() 前者共享打开状态,后者建立独立打开状态
普通变量与继承的 FD 私有内存独立,引用的内核对象却可能共享
子进程退出与管道 EOF EOF 取决于剩余数据和所有写端引用
日志内容与执行位置 写着“等待之后”,不代表真的在等待之后

写在最后

今天最大的收获,不是记住了多少个函数,而是开始知道应该问什么:

  • 这个变量只是用户态的值,还是用来访问内核资源的编号?
  • 谁创建了资源?
  • 谁持有引用?
  • 哪些状态共享,哪些状态独立?
  • 当前错误属于哪一次操作?
  • 哪条执行路径负责关闭资源?
  • 程序现在等待的条件,究竟由谁满足?

这些问题听起来很基础,但亲手写过之后,我再看 Samba 里的 fork()、FD 清理和进程间通信,就不再只是在记函数名。

目前我还没有学完 Linux 系统编程,也没有把这些机制的内核实现全部看懂。