一、printf 隐藏的缓冲区

printf 函数并非直接把内容输出到屏幕,而是默认将数据写入用户态缓冲区,只有缓冲区刷新时,内容才会真正输出。这也是很多新手遇到 “printf 不打印” 的核心原因。

1. 缓冲区的刷新规则

触发条件 说明
遇到换行符 \n 行缓冲模式下,遇到 \n 会自动刷新缓冲区,这是最常见的刷新场景。
程序正常结束 main 函数 return 时,所有缓冲区会被强制刷新。
缓冲区写满 缓冲区空间不足时,会自动刷新内容。
主动调用 fflush(stdout) 手动强制刷新标准输出缓冲区。
进程退出(异常 / 正常) 进程退出时,内核会自动刷新所有打开的流。

2. 代码示例:缓冲区不刷新的 “坑”

#include <stdio.h>
#include <unistd.h> // sleep函数

int main() {
    printf("Hello, World!"); // 没有换行符
    sleep(3); // 睡眠3秒
    printf("\n"); // 刷新缓冲区
    return 0;
}

现象:运行程序时,程序会先睡眠 3 秒,然后一次性输出 Hello, World! 并换行,而不是先打印再睡眠。原因printf 写入的内容先存在了缓冲区,直到遇到 \n 或程序结束才刷新。

3. 缓冲区与 fork 的经典问题

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Before fork: "); // 无换行符
    fork();
    printf("After fork\n");
    return 0;
}

现象:程序输出:

Before fork: After fork
Before fork: After fork

原因fork() 会复制父进程的用户态内存,包括缓冲区。父进程的 Before fork: 被留在缓冲区,子进程复制了这份缓冲区,两个进程退出时各自刷新,导致重复输出。


二、fork () 复制进程(重点)

fork() 是 Linux 创建新进程的系统调用,它会创建一个和父进程几乎完全相同的子进程,实现进程的复制。

1. fork () 的基础用法

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork error");
        return 1;
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("我是子进程,pid: %d, ppid: %d\n", getpid(), getppid());
    } else {
        // 父进程执行的代码
        printf("我是父进程,pid: %d, 子进程pid: %d\n", getpid(), pid);
    }
    return 0;
}

运行结果

我是父进程,pid: 1234, 子进程pid: 1235
我是子进程,pid: 1235, ppid: 1234

2. fork () 的核心特点

  • 一次调用,两次返回:父进程返回子进程的 PID,子进程返回 0,失败返回 -1。
  • 父子进程是独立的进程:有独立的进程 ID、地址空间,执行流互不干扰。
  • 执行顺序不确定:父子进程谁先运行由操作系统调度决定,无固定顺序。

三、fork () 补充知识点

1. 父子进程共享与复制的资源

资源类型 处理方式
代码段 只读共享,父子进程共用同一份物理内存
数据段 / 堆 / 栈 初始时共享,修改时触发写时拷贝
文件描述符 复制一份,引用计数 + 1,指向同一个文件对象
缓冲区 复制父进程的用户态缓冲区(解释了前面的重复输出问题)

2. 僵尸进程与孤儿进程

  • 僵尸进程:子进程先退出,父进程未调用 wait()/waitpid() 回收子进程资源,子进程会变成僵尸进程,占用进程号。
  • 孤儿进程:父进程先退出,子进程会被 init 进程(pid=1)收养,成为孤儿进程,退出时会被自动回收。

3. 如何避免僵尸进程

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        printf("子进程运行,即将退出\n");
        return 0;
    } else {
        wait(NULL); // 父进程阻塞,等待子进程退出并回收资源
        printf("父进程回收了子进程\n");
    }
    return 0;
}

四、写时拷贝(Copy-On-Write, COW)技术

fork() 早期的实现会直接复制父进程的整个地址空间,效率极低。现代 Linux 采用写时拷贝技术优化,只有当进程需要修改数据时,才会真正复制物理内存。

1. 写时拷贝的原理

  1. fork() 刚完成时,子进程的虚拟地址空间和父进程映射到同一块物理内存,且内存被标记为 “只读”。
  2. 当父进程或子进程尝试修改这块内存时,会触发页错误(Page Fault)
  3. 内核捕获错误后,为修改方分配新的物理内存,复制原数据,修改页表映射关系,之后再允许写入。

2. 代码示例:验证写时拷贝

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    int val = 100;
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程修改val
        printf("子进程修改前: val=%d, &val=%p\n", val, &val);
        val = 200;
        printf("子进程修改后: val=%d, &val=%p\n", val, &val);
    } else {
        sleep(1); // 让子进程先运行
        printf("父进程: val=%d, &val=%p\n", val, &val);
    }
    return 0;
}

运行结果

子进程修改前: val=100, &val=0x7ffdabcdef12
子进程修改后: val=200, &val=0x7ffdabcdef12
父进程: val=100, &val=0x7ffdabcdef12

现象说明

  • 父子进程的 val 变量虚拟地址相同,但值不同,说明它们映射到了不同的物理内存
  • 子进程修改时触发了写时拷贝,分配了新的物理内存,父进程的 val 不受影响。

五、进程的逻辑地址与物理地址

1. 概念区分

  • 逻辑地址(虚拟地址):进程代码中使用的地址,比如 &val 得到的地址,每个进程都有独立的 4GB(32 位)或 256TB(64 位)虚拟地址空间。
  • 物理地址:内存芯片上的实际地址,由操作系统内核管理,进程无法直接访问。

2. 地址映射:页表

进程的虚拟地址通过页表映射到物理地址,页表由操作系统维护,CPU 的内存管理单元(MMU)负责地址转换。


六、为什么程序中不直接使用物理地址?

  1. 安全隔离:直接使用物理地址会导致进程可以访问任意内存,破坏系统和其他进程的数据,虚拟地址让进程互相隔离。
  2. 内存管理:虚拟地址让操作系统可以实现内存交换(Swap)、分页、写时拷贝等功能,提高内存利用率。
  3. 地址无关性:程序编译时使用虚拟地址,操作系统可以将程序加载到任意物理地址运行,实现地址重定位。
  4. 简化编程:进程以为自己独占全部内存,编程时无需关心物理内存的分配情况。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐