聊聊【Linux】文件描述符背后的底层逻辑:重定向、VFS 与用户态缓冲区(整理分享)

前段时间遇到一个小问题,后来发现这是个挺常见的坑,顺手整理一篇笔记。

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录

二、补充:内建命令的重定向为什么要恢复现场? 三、从源码角度看 fd 表和 struct file 四、一切皆文件:VFS 到底做了什么? 五、缓冲区:为什么 printf 和 write 表现不一样? 六、fork 后为什么 printf 可能重复输出? 七、glibc 中 FILE 的真身:_IO_FILE 八、模拟理解:为什么缓冲区能减少系统调用?九、全文总结

前言

前面大家已经知道,Linux 文件 I/O 里有一条很重要的主线:

进程通过文件描述符 fd,在自己的文件描述符表中找到对应的 struct file,然后由操作系统完成真正的读写操作。

理解了这句话以后,再看 fopenopenreadwriteclose 这些接口,其实就不会觉得它们是分散的知识点了。

这篇文章继续往下看几个更容易混的内容:

  • 标准输出和标准错误为什么可以分开重定向?
  • 2>&1 到底是把谁复制给谁?
  • struct file 里面的 f_posf_countf_op 分别有什么用?
  • Linux 为什么说“一切皆文件”?
  • printfwrite 都能输出,为什么重定向后表现不一样?
  • glibc 里的 FILE* 到底封装了什么?
这一篇不再重复讲基础接口,而是顺着实验现象继续往底层看:先看现象,再看 fd 表,再看内核结构体,最终看 glibc 的用户态缓冲区。

一、重谈重定向:stdout 和 stderr 为什么可以分离?

大家平时写输出重定向时,最常见的是:

./a.out > log.txt

其实完整写法应该是:

./a.out 1> log.txt

这里的 1 指的是标准输出,也就是 stdout 对应的文件描述符。

正常情况下可以省略 1,因为 > 默认就是重定向标准输出。

先看一段代码:

#include
#include

int main()
{
    std::cout  stdout 最终对应的是文件描述符 1,只要把 1 的指向改掉,标准输出的位置就会跟着改变。

---

### 1.1 标准输出和标准错误不是一回事

再看下面这段代码:


include


include

int main()
{
// 标准输出:stdout / cout -> fd 1
std::cout 显示器


这就是为什么标准输出进了文件,而标准错误还在屏幕上。

---

### 1.2 为什么要区分 stdout 和 stderr?

一开始我也觉得奇怪:反正都是往显示器上打印,为什么还要分成 `stdout` 和 `stderr`?

其实这样设计是为了把**普通结果**和**错误信息**分开。

比如程序正常运行时输出结果,用 `stdout`;程序出现错误、调试信息、异常提示,用 `stderr`。

这样我们就可以在命令行中分别保存:


./a.out 1> log.normal 2> log.error

含义是:

- `1> log.normal`:普通输出写入 `log.normal`
- `2> log.error`:错误输出写入 `log.error`

![](https://i-blog.)

这样做的好处是很明显的:

> 正常结果和错误日志不会混在一起,排查问题时更方便。

---

### 1.3 2>&1 到底是什么意思?

如果想让标准输出和标准错误都进入同一个文件,有两种写法。

第一种:


./a.out 1> log.txt 2>> log.txt

这里标准错误使用 `>>` 是为了追加,否则两次 `>` 可能会出现覆盖问题。

第二种更常见:


./a.out 1> log.txt 2>&1

![](https://i-blog.)

这条命令不要死记,要拆开看。

第一步:


1> log.txt

底层类似:


int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
close(fd);

执行完后:


fd 1 -> log.txt
fd 2 -> 显示器

第二步:


2>&1

这里的 `&1` 表示“文件描述符 1”,不是普通文件名。

所以 `2>&1` 的含义是:

> 让 fd = 2 复制当前 fd = 1 的指向。

底层可以理解成:


dup2(1, 2);

注意,复制的是 **当前 1 号文件描述符背后指向的文件对象**。由于第一步已经让 `1` 指向了 `log.txt`,所以第二步执行完以后,`2` 也指向 `log.txt`。

最终结果:


fd 1 -> log.txt
fd 2 -> log.txt

这就是 `2>&1` 的真正含义。

---

### 1.4 2>1 和 2>&1 不是一回事

这个地方很容易写错。


./a.out 2>1

它的意思是:

> 把标准错误重定向到一个名字叫 1 的普通文件里。

而:


./a.out 2>&1

才是:

> 把标准错误重定向到标准输出当前指向的位置。

所以 `&` 不能省略。

---

---

## 二、补充:内建命令的重定向为什么要恢复现场?

上面讲的重定向,大多数情况都是针对普通外部命令来说的。

比如:


ls -l > log.txt

这种命令通常可以 `fork` 出子进程,然后在子进程中完成:


dup2(fd, 1);
execvp(...);

因为子进程的文件描述符表被改了,并不会污染父 Shell。

但是 Shell 里还有一种特殊命令,叫做**内建命令**,比如:


cd
export
echo

这些命令有些不能简单交给子进程执行。比如 `cd` 如果在子进程里执行,只会改变子进程的工作目录,父 Shell 的目录并不会变,所以 `cd` 这类命令必须由父 Shell 自己执行。

这时候问题就来了:


echo "hello" > log.txt

如果 `echo` 被当成内建命令,由父 Shell 自己执行,那么父进程就必须临时完成重定向。

但父进程不能永久改掉自己的 `fd = 1`,否则命令执行完之后,Shell 后续的提示符和输出都会继续写进 `log.txt`,终端就被污染了。

所以内建命令的重定向需要一个额外步骤:

> 先备份现场,执行完命令后再恢复现场。

---

### 2.1 dup:先把标准输出备份起来

可以先调用:


int backup = dup(1);

`dup(1)` 的作用是:在当前进程的文件描述符表里找一个最小的空闲位置,把 `fd = 1` 当前指向的文件对象复制过去。

比如此时 `fd = 1` 指向显示器,`dup(1)` 可能返回 `3`:


fd 1 -> 显示器
fd 3 -> 显示器

这一步相当于先把“显示器的指向”保存到 `backup` 里。

---

### 2.2 dup2:临时把标准输出切到文件

接着打开目标文件:


int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

然后执行:


dup2(fd, 1);
close(fd);

此时:


fd 1 -> log.txt
backup 3 -> 显示器

接下来父 Shell 执行内建命令时,输出就会进入 `log.txt`。

---

### 2.3 命令执行完后恢复标准输出

内建命令执行完成后,必须马上恢复:


dup2(backup, 1);
close(backup);

恢复后:


fd 1 -> 显示器

这样父 Shell 的标准输出就回到了终端,后续命令提示符也不会被写进文件。

完整流程可以整理成:


int backup = dup(1); // 1. 备份标准输出

int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1); // 2. 临时重定向到文件
close(fd);

// 3. 执行内建命令,比如 echo/export/cd 等
RunBuiltinCommand();

dup2(backup, 1); // 4. 恢复标准输出
close(backup); // 5. 关闭备份 fd


这就是内建命令重定向和普通外部命令重定向最大的区别:

场景重定向位置是否需要恢复外部命令子进程中重定向,然后 `exec`一般不需要,子进程退出即可内建命令父 Shell 自己重定向必须恢复,否则污染 Shell

所以 Shell 实现重定向时,不能只会 `dup2(fd, 1)`,还要看命令到底是在子进程执行,还是父进程自己执行。

## 三、从源码角度看 fd 表和 struct file

前面一直在说:重定向的本质是修改文件描述符表中某个下标背后的指向。

那文件描述符表到底长什么样?

在内核中,进程由 `task_struct` 描述。每个进程都有自己的文件相关信息,其中有一个很重要的指针:


struct task_struct {
// ...
struct files_struct files;
// ...
};

也就是说:

> 进程通过 files 指针找到自己的文件描述符表。

---

### 2.1 files_struct:文件描述符表

`files_struct` 中有一个非常重要的数组:


struct files_struct {
atomic_t count; // 引用计数
struct fdtable
fdt;
struct fdtable fdtab;

spinlock_t file_lock;
int next_fd;

struct embedded_fd_set close_on_exec_init;
struct embedded_fd_set open_fds_init;

struct file fd_array[NR_OPEN_DEFAULT]; // 文件描述符表数组
};


这里最关键的是:


struct file
fd_array[NR_OPEN_DEFAULT];

它是一个指针数组,每个元素存放一个 `struct file*`。

用户层拿到的 `fd`,本质上就是这个数组的下标。

比如:


fd_array[0] -> 标准输入对应的 struct file
fd_array[1] -> 标准输出对应的 struct file
fd_array[2] -> 标准错误对应的 struct file
fd_array[3] -> 新打开文件对应的 struct file

所以:

> fd 不是文件本身,而是当前进程文件描述符表中的数组下标。

---

### 2.2 struct file:内核中的打开文件对象

当一个文件被进程打开时,内核会创建一个 `struct file` 对象,用来描述“本次打开”。

简化后可以看成这样:


struct file {
atomic_t f_count; // 引用计数
unsigned int f_flags; // 打开方式,如 O_APPEND、O_TRUNC
mode_t f_mode; // 访问模式,如读、写
loff_t f_pos; // 当前读写位置
const struct file_operations f_op; // 操作函数集合

void private_data; // 私有数据,常给设备驱动使用
};


几个成员要重点理解:

成员含义`f_count`引用计数,表示有多少地方正在使用这个打开文件对象`f_flags`打开文件时的标志位,比如 `O_APPEND``f_mode`当前文件的读写模式`f_pos`当前读写位置`f_op`文件操作函数表,里面是一组函数指针

---

![](https://i-blog.)

### 2.3 为什么刚写完不能马上读?

`struct file` 中只有一个 `f_pos`,它表示当前读写位置。

不管是读还是写,都会影响这个位置。

比如用读写方式打开文件后,先写入内容:


write(fd, "hello", 5);

写完后,`f_pos` 已经移动到了文件末尾。

如果这时候马上:


read(fd, buf, sizeof(buf));

就会从文件末尾开始读,自然读不到刚才写入的内容。

所以如果想重新从头读,需要使用:


lseek(fd, 0, SEEK_SET);

或者在 C 库层使用:


rewind(fp);

这就是为什么之前学习 `w+` 时,写完想读必须调整读写位置。

---

### 2.4 进程和文件是解耦的

进程有自己的 `task_struct`,打开文件有自己的 `struct file`。

它们不是直接绑死在一起,而是通过文件描述符表建立联系:


task_struct
|
v
files_struct
|
v
fd_array[fd]
|
v
struct file

也就是说:

> 进程通过 fd 找到文件对象,文件对象再记录本次打开的状态。

这个设计让进程管理和文件管理解耦了。

---

## 四、一切皆文件:VFS 到底做了什么?

Linux 下常说一句话:

> 一切皆文件。

这句话不能只背下来,要结合 `struct file` 和 `file_operations` 理解。

在 Windows 中,普通文件是文件;在 Linux 中,不只是普通文件,键盘、显示器、磁盘、管道、socket 等很多资源也会被抽象成文件来管理。

这样做的好处是:

> 用户层可以使用统一的 read/write/open/close 接口访问不同资源。

比如:

- 读普通文件:`read(fd, buf, size)`
- 从键盘读:`read(0, buf, size)`
- 向屏幕写:`write(1, buf, size)`
- 向管道写:`write(pipefd[1], buf, size)`

上层接口看起来一样,但底层设备完全不同。

---

![](https://i-blog.)

### 3.1 不同设备的读写方式肯定不一样

磁盘、键盘、显示器、网卡,这些设备硬件结构完全不同。

它们都可以“读写”,但是具体实现肯定不一样。

比如:

- 键盘主要负责输入
- 显示器主要负责输出
- 磁盘既可以读也可以写
- 网卡有自己的网络收发逻辑

那么 Linux 怎么能用统一的 `read/write` 管理这些不同设备?

答案就在 `struct file` 里的 `f_op`。

---

### 3.2 file_operations:函数指针表

`struct file` 中有一个成员:


const struct file_operations f_op;

`file_operations` 结构体里面存放的是一组函数指针:


struct file_operations {
ssize_t (
read) (struct file , char __user , size_t, loff_t );
ssize_t (
write) (struct file , const char __user , size_t, loff_t );
int (
open) (struct inode , struct file );
int (release) (struct inode , struct file );
// ...
};

可以这样理解:

> file_operations 就是一张操作函数表,里面规定了这个文件对象应该怎么读、怎么写、怎么打开、怎么关闭。

如果打开的是普通磁盘文件,`f_op->read/write` 指向磁盘文件系统对应的读写方法。

如果打开的是显示器设备,`f_op->write` 就会指向显示器驱动提供的写方法。

如果打开的是键盘设备,`f_op->read` 就会指向键盘驱动提供的读方法。

所以当用户层调用:


write(fd, buf, size);

内核大致会走这样的路线:


当前进程
-> fd_array[fd]
-> struct file
-> file->f_op->write(...)
-> 具体设备驱动

这就是“一切皆文件”的关键。

---

![](https://i-blog.)

### 3.3 VFS:屏蔽底层差异的一层

我们可以把 `struct file` 和 `file_operations` 这一层理解成 VFS 的一部分。

VFS 全称是:

> Virtual File System,虚拟文件系统。

它的作用不是直接操作某个具体硬件,而是做一层统一抽象。

上层只需要调用统一接口:


read(fd, ...);
write(fd, ...);

至于底层到底是磁盘、显示器、键盘、管道还是 socket,就交给 `f_op` 中的函数指针去分发。

这有点像 C 语言版本的“多态”:

层次作用用户层统一调用 `read/write`VFS 层通过 `struct file` 和 `f_op` 做抽象驱动层不同设备实现自己的读写方法硬件层真正的物理设备

所以,“一切皆文件”不是说所有东西真的都变成了普通磁盘文件,而是说:

> Linux 在访问这些资源时,尽量把它们抽象成统一的文件接口。

---

## 五、缓冲区:为什么 printf 和 write 表现不一样?

接下来进入另一个容易混的点:缓冲区。

我们先看一个实验。


include


include


include


include


include


include


include

int main()
{
close(1);

int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
if (fd < 0)
{
perror("open");
return 1;
}

// C 库函数
printf("fd: %d\n", fd);
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");

// 系统调用
const char
msg = "hello write\n";
write(fd, msg, strlen(msg));

close(fd);
return 0;
}


现象是:

- `write` 的内容可以写入文件。
- `printf` 的内容可能没有写进去。

为什么?

因为:

> printf 是 C 标准库函数,数据通常先进入用户态缓冲区;write 是系统调用,直接进入内核。

---

### 4.1 用户态缓冲区和内核缓冲区

这里要区分两种缓冲区:

缓冲区所在位置由谁管理常见接口用户态缓冲区用户空间C 标准库 glibc`printf/fprintf/fwrite`内核缓冲区内核空间操作系统`write/read`

`printf` 的数据不会一定马上进入内核,而是先写到 C 库维护的用户态缓冲区中。

满足某些条件后,C 库再调用底层的 `write`,把用户态缓冲区中的数据刷新到内核。

刷新条件常见有:

1. 调用 `fflush`
2. 缓冲区满了
3. 遇到换行并且是行缓冲
4. 进程正常退出时刷新

而 `write` 本身已经是系统调用,它不经过 C 库的 `FILE*` 缓冲区,所以表现和 `printf` 不一样。

---

### 4.2 close(fd) 为什么可能导致 printf 内容丢失?

回到上面的代码。

`printf` 的内容先进入 `stdout` 的用户态缓冲区。

`stdout` 内部记录的底层 fd 是 `1`。

因为我们先执行了:


close(1);

又打开了 `log.txt`,使得新文件占用了 `fd = 1`。

所以此时 `stdout` 的底层 fd 仍然是 1,只不过 1 背后已经指向了 `log.txt`。

但是问题在于,后面代码里又执行了:


close(fd);

而这个 `fd` 很可能就是 1。

也就是说,文件描述符 1 被关闭了。

等程序退出时,C 库想把 `stdout` 用户态缓冲区中的内容刷新出去,就需要通过 `stdout` 内部记录的 fd,也就是 1 去调用 `write`。

但这时候 fd 1 已经被关闭了,刷新失败,所以 `printf` 的内容就可能看不到。

如果想让 `printf` 的内容写进去,可以在关闭 fd 前强制刷新:


fflush(stdout);
close(fd);

这就是 `fflush` 的作用:

> 把 C 库用户态缓冲区中的数据,尽快刷新到内核。

---

### 4.3 三种缓冲策略

C 标准库常见有三种缓冲策略:

缓冲类型触发刷新条件常见场景无缓冲立即刷新`stderr` 常见行缓冲遇到 `\n` 刷新终端显示器常见全缓冲缓冲区满或主动刷新普通磁盘文件常见

这也解释了一个现象:

同样是 `printf("hello\n")`,输出到显示器时看起来马上出现;但重定向到普通文件时,可能要等程序结束或者缓冲区满才真正写入文件。

---

![](https://i-blog.)

### 4.4 fflush、fsync、sync 的区别

这几个接口也容易混。

接口作用范围简单理解`fflush(FILE*)`C 库用户态缓冲区 -> 内核把 `FILE*` 缓冲区刷给操作系统`fsync(fd)`内核缓冲区 -> 磁盘设备要求内核把 fd 对应文件的数据同步到磁盘`sync` 命令系统级缓存 -> 外设要求系统把缓存数据统一刷回外设

可以这样记:


printf/fwrite
-> 用户态缓冲区
-> fflush
-> 内核缓冲区
-> fsync/sync
-> 磁盘/外设

所以:

> fflush 解决的是 C 库层面的刷新问题;fsync 解决的是内核到磁盘的同步问题。

---

## 六、fork 后为什么 printf 可能重复输出?

再看一个经典实验:


include


include


include


include

int main()
{
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");

const char s = "hello fwrite\n";
fwrite(s, strlen(s), 1, stdout);

const char ss = "hello write\n";
write(1, ss, strlen(ss));

fork();
return 0;
}


如果直接输出到显示器,通常看到 4 条消息。

如果重定向到文件:


./a.out > log.txt

可能看到 7 条消息:

- `write` 输出 1 次
- `printf/fprintf/fwrite` 各输出 2 次

![](https://i-blog.)

![](https://i-blog.)

---

### 5.1 为什么输出到显示器时没有重复?

当输出目标是显示器时,C 库通常采用行缓冲。

也就是说:


printf("hello printf\n");

遇到 `\n` 后,数据就被刷新出去了。

等执行到 `fork()` 时,用户态缓冲区里基本已经没有这些内容,所以父子进程退出时不会再次重复刷新。

---

### 5.2 为什么重定向到文件后会重复?

当输出目标是普通文件时,C 库通常采用全缓冲。

这时候:


printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
fwrite(...);

这些内容可能还留在用户态缓冲区里,没有马上写入文件。

执行 `fork()` 后,子进程会继承父进程的用户态内存状态,其中就包括这份尚未刷新的缓冲区内容。

之后父进程和子进程都会退出。进程正常退出时,C 库会刷新自己的标准 I/O 缓冲区。

这里可以再细一点理解:父进程或子进程在退出时刷新缓冲区,本质上要把用户态缓冲区中的数据拷贝到内核,同时还会调整或清理这块用户态缓冲区的状态。这个过程会涉及对用户态缓冲区内存的修改,因此可能触发写时拷贝。

触发写时拷贝后,父子进程各自拥有一份缓冲区数据。于是:

- 父进程退出时刷新一次
- 子进程退出时也刷新一次

所以同一份 `printf/fprintf/fwrite` 内容就可能被写入两次。

---

### 5.3 为什么 write 只输出一次?

因为 `write` 是系统调用。

它在 `fork()` 之前就已经把数据交给了内核,不经过 C 库的用户态缓冲区。

所以子进程不会再继承一份 `write` 的用户态缓冲数据,自然也不会重复输出。

这说明:

> C 库函数和系统调用虽然都能完成输出,但它们中间隔着一层用户态缓冲区,所以在 fork 和重定向场景下表现会不一样。

---

## 七、glibc 中 FILE 的真身:_IO_FILE

我们平时用的 `FILE*` 到底是什么?

在 C 标准库里,`FILE` 不是内核的 `struct file`,而是用户态 glibc 提供的一个结构体。

内核中的 `struct file` 和 glibc 中的 `FILE` 不是一回事:

结构所在位置作用`struct file`内核态描述内核中一个被打开的文件对象`FILE / _IO_FILE`用户态 glibc描述 C 标准库中的文件流,包含用户态缓冲区和 fd

---

### 6.1 为什么有了 struct file,还需要 FILE?

因为系统调用有成本。

如果每输出几个字节都调用一次 `write`,用户态和内核态之间会频繁切换,效率很低。

所以 C 标准库在用户态又封装了一层 `FILE*`:


fprintf
-> 写入 FILE 用户态缓冲区
-> 缓冲区满足刷新条件
-> 通过 _fileno 拿到 fd
-> 调用 write(fd, ...)
-> 内核 struct file
-> 磁盘/设备

这样就能减少系统调用次数,提高效率。

---

### 6.2 FILE 中封装了什么?

简化理解,`FILE` 里面至少要封装:

- 对应的文件描述符 fd
- 用户态缓冲区
- 当前缓冲区读写位置
- 刷新策略
- 和其他 FILE 对象组织起来的指针

比如 glibc 中的 `_IO_FILE` 会包含类似字段:


struct _IO_FILE {
int _flags;

char _IO_read_ptr;
char
_IO_read_end;
char _IO_read_base;

char _IO_write_base;
char _IO_write_ptr;
char
_IO_write_end;

int _fileno; // 底层文件描述符 fd

struct _IO_FILE _chain; // 用来把多个 FILE 对象串起来
};


其中最重要的是:

字段含义`_fileno`C 库文件流绑定的系统层 fd`_IO_write_base / _IO_write_ptr / _IO_write_end`写缓冲区相关指针`_chain`将多个 `FILE` 对象连接起来,方便统一管理

---

![](https://i-blog.)

### 6.3 进程退出时为什么文件会自动刷新?

我们经常会发现,即使没有手动 `fflush`,程序正常结束时,文件内容也可能被写进去。

这是因为 C 库会维护自己打开的 `FILE` 对象。

当进程正常退出时,C 库会尝试刷新并关闭这些已经打开的标准 I/O 流。

所以:


FILE
fp = fopen("log.txt", "w");
fprintf(fp, "hello world\n");
return 0;

即使没有手动 `fclose(fp)`,正常情况下也可能看到内容写入文件。

但是这并不代表可以不写 `fclose`。

更好的习惯还是:


fclose(fp);

因为 `fclose` 不只是关闭文件,也会触发刷新,并释放相关 C 库资源。

---

## 八、模拟理解:为什么缓冲区能减少系统调用?

可以写一个简单实验来观察。

假设我们每秒查看一次文件内容:


while :; do
cat log.txt
sleep 1
echo "#########"
done

![](https://i-blog.)

如果程序使用的是 C 库全缓冲,可能会发现:程序运行过程中,文件内容迟迟不出现;等程序结束或调用 `fclose/fflush` 后,内容才突然出现。

如果想在程序没有结束时也看到内容,可以手动刷新:


fflush(fp);

![](https://i-blog.)

如果想进一步要求内核把数据同步到磁盘,可以使用:


fsync(fd);
```

这里要要注意层次:

fflush 是 C 库层的刷新,fsync 是系统调用层的同步。

它们不是一回事。


九、全文总结

本文主要把 Linux 文件 I/O 中几个容易分散的知识点串在了一起:重定向、2>&1、VFS、一切皆文件、缓冲区和 glibc 的 FILE*

重定向的本质不是 printf 变了,而是进程文件描述符表中对应 fd 背后的指向变了。普通的 > 默认只改 fd = 1,所以标准错误 fd = 2 仍然可以留在屏幕上;而 2>&1 的核心就是让 2 复制当前 1 的指向。

一切皆文件的关键在于 VFS。上层统一调用 read/write,内核通过 struct file 中的 file_operations 函数指针,分发到不同设备自己的读写方法,从而屏蔽底层硬件差异。

缓冲区部分要分清用户态和内核态。printf/fprintf/fwrite 会先经过 glibc 的用户态缓冲区,而 write 是系统调用。正因为这层区别,重定向到文件后再 fork,C 库函数的输出可能重复刷新,而 write 不会。

最终,FILE* 并不是内核的 struct file,而是 glibc 在用户态封装出来的文件流结构。它内部保存了 fd、缓冲区和管理指针,最终还是要通过系统调用进入内核。

所以这篇最核心的一句话就是:

系统层用 fd 找到内核 struct file,C 库层用 FILE* 封装 fd 和用户态缓冲区,VFS 再通过 file_operations 屏蔽不同设备的差异。

抓住这条线,重定向、缓冲区、glibc 和“一切皆文件”就能连起来理解了。

资源分享
【Linux基础IO】printf 为什么会写进文件?一文搞懂重定向和 dup2 底层原理
【Linux】基础 I/O 详解:从 fopen 到 open,终于搞懂 fd 为什么从 3 开始
【Linux进程控制】从exec程序替换到手写简易Shell:fork、execvp、环境变量与内建命令


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论