Linux 基础文件IO

深入理解 Linux 基础 IO:从文件描述符到缓冲区的完整推演

1. 前言

C 语言课上学文件操作,fopenfwritefclose 三件套用得飞起,但几个问题一直悬着:printf 打印的字符串,是怎么跑到显示器上的?为什么 shell 里一个 > 就能把输出写进文件?为什么同样的代码,直接运行输出正常,重定向到文件后结果就"不对劲"了?

这些问题在库函数这一层永远找不到答案。实际上,文件操作从来不是 C 语言的能力,而是操作系统的能力------库函数只是把系统调用包装得好看一点。这篇文章就顺着这条链路往下挖:从文件的本质讲起,经过系统调用、文件描述符、重定向,最后落到"一切皆文件"的设计哲学和缓冲区的实现细节上。

全文有一条主线:文件操作的本质,是进程拿着 fd 这个数组下标,去内核的文件表中找到对应的 file 对象,再通过对象里的函数指针完成真正的读写。fd、重定向、一切皆文件、缓冲区,全部挂在这条链路上。

2. 文件是什么:建立认知起点

2.1 狭义与广义

先说狭义的文件:文件在磁盘里。磁盘有两个关键属性------它是永久性存储介质,文件放在上面不会因为断电消失;它是外设,既是输入设备又是输出设备。

于是可以推出一个朴素但重要的结论:对文件的所有操作,本质都是对外设的输入输出,简称 IO。读文件是把数据从磁盘搬进内存,写文件是把数据从内存搬到磁盘,仅此而已。

再说广义的文件:Linux 下一切皆文件。键盘、显示器、网卡、磁盘,这些八竿子打不着的东西,都被抽象成了文件。这句话人人会背,但它是怎么做到的?先按下不表,第 7 章用内核源码揭晓。

2.2 文件 = 属性 + 内容

一个值得注意的细节:0KB 的空文件也占磁盘空间。为什么没有内容还要占地方?因为文件不只是内容:

文件 = 属性(元数据)+ 内容

文件名、大小、权限、时间戳,这些属性本身也是数据,也要占地方存储。这个拆分直接决定了后面所有文件操作的分类------所谓的文件操作,无非两类:对内容的操作(读写),对属性的操作(查看权限、改文件名等)。

2.3 系统视角:谁在操作文件

站在系统的角度看两句话:

  • 对文件的操作,本质是进程对文件的操作。文件静静躺在磁盘上,打开它的一定是某个正在运行的进程
  • 磁盘的管理者是操作系统。用户进程没有资格直接碰硬件

两句话合起来:进程想动文件,必须经过操作系统。于是文件操作必然要跨过"进程 → 操作系统 → 硬件"这条链路,而跨过去的那扇门,就是接下来要讲的系统调用------本质上是请求操作系统代为对文件进行操作

3. 回顾 C 文件接口:熟悉的表象下藏着问题

3.1 三个默认打开的流

C 程序启动时,默认打开三个流:stdin、stdout、stderr。注意它们的类型:

c 复制代码
#include <stdio.h>
extern FILE *stdin;   // 标准输入,一般对应键盘
extern FILE *stdout;  // 标准输出,一般对应显示器
extern FILE *stderr;  // 标准错误,一般对应显示器

三个都是 FILE*,也就是 fopen 的返回值类型。这里先留一个伏笔:FILE 到底是什么结构体,里面装了什么?第 8 章会看到它的真面目。

输出到显示器有三种等价写法:分别使用 printf、fprintf、fwrite。

c 复制代码
#include <stdio.h>
#include <string.h>
int main()
{
    const char *msg = "hello fwrite\n";
    fwrite(msg, strlen(msg), 1, stdout);  // 流式写
    printf("hello printf\n");             // 格式化写,本质也是往 stdout 写
    fprintf(stdout, "hello fprintf\n");   // 指定流的格式化写
    return 0;
}

三种写法殊途同归------都往 stdout 这个流里写。所谓 stdout,说白了就是显示器在用户程序里的"代言人"。当我们向显示器打印时,本质上就是向显示器文件写入。

3.2 常用打开方式

fopen 的第二个参数决定打开方式,常用的几种整理成表:

模式 含义 文件不存在时 文件存在时
r 只读 报错 从头读
w 只写 创建 清空原内容
a 追加写 创建 在末尾追加
r+ 读写 报错 从头开始读写
w+ 读写 创建 清空原内容
a+ 读+追加 创建 读从头,写永远在末尾

w 和 a 的区别要特别记住:w 会先把文件截断成 0,a 永远在文件末尾写。用错模式的经典事故:想往日志文件追加记录,结果用了 w,历史日志一夜清空。

3.3 一个问题:文件建在了哪里

c 复制代码
FILE *fp = fopen("myfile", "w");  // 没有带路径!

fopen 没带路径,创建的 myfile 在哪?跑起来观察会发现它在"当前路径"下。那当前路径是谁说了算的?

由之前的学习我们可以知道,进程是属性和行为的载体,路径这种运行时信息当然记录在进程身上。Linux 把运行中进程的信息暴露在 /proc/[pid] 目录下,可以直接查看:

复制代码
tys@localhost:~$ ps ajx | grep myProc
  506729  533463  533463  506729 pts/249  533463 R+  1002   7:45 ./myProc
tys@localhost:~$ ls /proc/533463 -l
......
lrwxrwxrwx 1 tys tys 0 Aug 26 16:53 cwd -> /home/tys/io
lrwxrwxrwx 1 tys tys 0 Aug 26 16:53 exe -> /home/tys/io/myProc
dr-xr-xr-x 2 tys tys 0 Aug 26 16:54 fd
......

两个符号链接值得记住:

  • cwd:指向进程的当前工作目录
  • exe:指向启动该进程的可执行文件的完整路径

于是整个逻辑就通了:打开文件的本质是进程打开文件,进程自己记得 cwd,所以文件不带路径,OS 也知道该把它建在哪。顺带一提,那个 fd 目录里躺着的就是此进程打开的所有文件描述符,第 5 章会回来细看它。

3.4 顺手写个 cat

把读文件的逻辑套上命令行参数,就是一个简化版 cat:

c 复制代码
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
    if (argc != 2) {              // cat 需要且只需要一个文件参数
        printf("argv error!\n");
        return 1;
    }
    FILE *fp = fopen(argv[1], "r");
    if(!fp){
        printf("fopen error!\n");
        return 2;
    }
    char buf[1024];
    while(1){
        int s = fread(buf, 1, sizeof(buf), fp);  // 每次读 1 字节 × sizeof(buf) 个
        if(s > 0){
            buf[s] = 0;   // 手动补 \0,printf 才能正确识别结尾
            printf("%s", buf);
        }
        if(feof(fp)){    // 读到文件末尾才退出
            break;
        }
    }
    fclose(fp);
    return 0;
}

这里有个容易踩的坑:fread(buf, size, nmemb, fp) 的返回值是成功读取的项数(nmemb 的个数),不是字节数。把 size 写成 1,返回值恰好等于字节数,处理起来最省心;如果 size 和 nmemb 互换,返回值的含义就完全变了。读 man 手册时把参数和返回值对着看,比死记硬背靠谱。

4. 系统调用:文件 IO 的最底层

4.1 位图传参:一个 int 传多个开关

看系统调用之前,先补一个小技巧。open 函数的 flags 参数能同时传"只写""创建""追加"好几个选项,它是怎么用一个 int 装下多个开关的?答案是位图:

c 复制代码
#include <stdio.h>
#define ONE    0x1  // 0000 0001
#define TWO    0x2  // 0000 0010
#define THREE  0x4  // 0000 0100
void func(int flags) {
    if (flags & ONE)   printf("flags has ONE! ");
    if (flags & TWO)   printf("flags has TWO! ");
    if (flags & THREE) printf("flags has THREE! ");
    printf("\n");
}
int main() {
    func(ONE);                 // 输出:flags has ONE!
    func(ONE | TWO);           // 输出:flags has ONE! flags has TWO!
    func(ONE | THREE | TWO);   // 三个都有
    return 0;
}

每个宏只占一个二进制位,多个选项用按位或 | 拼在一起,接收方用 & 逐一检测。一个 32 位的 int 理论上能携带 32 个互不干扰的开关。这是 C 语言里最朴素的"选项集合"实现,Linux 内核里到处都是这个套路,open 的 flags 就是标准应用。

4.2 open / read / write / close

用系统接口改写"写文件",感受一下原始形态:

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
    umask(0);  // 把进程的 umask 清零,创建文件的权限不再被"打折"
    int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
    if(fd < 0){
        perror("open");   // 打印出错原因
        return 1;
    }
    int count = 5;
    const char *msg = "hello bit!\n";
    int len = strlen(msg);
    while(count--){
        // fd: 文件描述符  msg: 缓冲区首地址  len: 期望写入的字节数
        // 返回值:实际写入的字节数
        write(fd, msg, len);
    }
    close(fd);
    return 0;
}

write 原型如下图所示:

可以看到传进来的 buffer 是 void* 类型,也就是说,我们传入的不一定是字符串------结构体、整型数组都可以传。说白了,write 的职责只是把 buffer 开始的指定字节原样拷贝进文件,它既不关心、也不会转换这些字节是文本还是二进制。以字符串形式写入还是以二进制形式写入,区别只在于调用方如何准备这块内存,属于语言层面的概念;对底层 write 来说两者没有区别,统统都是一段字节流。

open 有两个原型,怎么选,取决于文件要不要新建:

c 复制代码
int open(const char *pathname, int flags);              // 打开已存在的文件
int open(const char *pathname, int flags, mode_t mode); // 可能创建文件时,用 mode 指定默认权限

flags 里必须且只能指定一个访问方式,再按需用 | 组合其他选项:

选项 含义 备注
O_RDONLY 只读打开 三选一,必选一个
O_WRONLY 只写打开 同上
O_RDWR 读写打开 同上
O_CREAT 文件不存在则创建 需要第三个参数 mode 指定权限
O_APPEND 追加写 对应 fopen 的 "a"
O_TRUNC 打开时清空文件 对应 fopen 的 "w"

第三个参数 mode 和 umask 的关系值得单独说一句:最终权限 = mode & ~umask。默认 umask 通常是 0022,所以传 0666 创建出来的文件是 0644(rw-r--r--)。代码里 umask(0) 就是为了让 0644 原样生效、不受环境影响。这其实也是为什么有些程序创建出来的文件权限"看起来不对"------先查 umask,八成是它在捣鬼。

读文件同理:

c 复制代码
int fd = open("myfile", O_RDONLY);
char buf[1024];
while(1){
    ssize_t s = read(fd, buf, sizeof(buf));  // 返回实际读到的字节数,0 表示到文件末尾
    if(s > 0){
        buf[s] = 0;   // read 不管字符串结尾,手动补 \0 再交给 printf
        printf("%s", buf);
    }else{
        break;
    }
}
close(fd);

read 的返回值是 ssize_t(有符号),读到数据返回字节数,读到文件末尾返回 0,出错返回 -1。和 fread 一样,read 不会帮你补 \0,打印前自己动手。

所以从这里可以得出结论:C 语言中的 "w" 打开方式,底层封装的其实就是下图所示的调用。

同理,"a" 方式打开文件,底层封装的调用如下图所示:

4.3 库函数和系统调用是什么关系

现在手上有两套接口:fopen/fread/fwrite/fclose 是 C 标准库的库函数 ,open/read/write/close 是系统调用。它们的关系不是并列,而是上下层:
#mermaid-svg-7ypAdpZ63nfuolnH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7ypAdpZ63nfuolnH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7ypAdpZ63nfuolnH .error-icon{fill:#552222;}#mermaid-svg-7ypAdpZ63nfuolnH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7ypAdpZ63nfuolnH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7ypAdpZ63nfuolnH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7ypAdpZ63nfuolnH .marker.cross{stroke:#333333;}#mermaid-svg-7ypAdpZ63nfuolnH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7ypAdpZ63nfuolnH p{margin:0;}#mermaid-svg-7ypAdpZ63nfuolnH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7ypAdpZ63nfuolnH .cluster-label text{fill:#333;}#mermaid-svg-7ypAdpZ63nfuolnH .cluster-label span{color:#333;}#mermaid-svg-7ypAdpZ63nfuolnH .cluster-label span p{background-color:transparent;}#mermaid-svg-7ypAdpZ63nfuolnH .label text,#mermaid-svg-7ypAdpZ63nfuolnH span{fill:#333;color:#333;}#mermaid-svg-7ypAdpZ63nfuolnH .node rect,#mermaid-svg-7ypAdpZ63nfuolnH .node circle,#mermaid-svg-7ypAdpZ63nfuolnH .node ellipse,#mermaid-svg-7ypAdpZ63nfuolnH .node polygon,#mermaid-svg-7ypAdpZ63nfuolnH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7ypAdpZ63nfuolnH .rough-node .label text,#mermaid-svg-7ypAdpZ63nfuolnH .node .label text,#mermaid-svg-7ypAdpZ63nfuolnH .image-shape .label,#mermaid-svg-7ypAdpZ63nfuolnH .icon-shape .label{text-anchor:middle;}#mermaid-svg-7ypAdpZ63nfuolnH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7ypAdpZ63nfuolnH .rough-node .label,#mermaid-svg-7ypAdpZ63nfuolnH .node .label,#mermaid-svg-7ypAdpZ63nfuolnH .image-shape .label,#mermaid-svg-7ypAdpZ63nfuolnH .icon-shape .label{text-align:center;}#mermaid-svg-7ypAdpZ63nfuolnH .node.clickable{cursor:pointer;}#mermaid-svg-7ypAdpZ63nfuolnH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7ypAdpZ63nfuolnH .arrowheadPath{fill:#333333;}#mermaid-svg-7ypAdpZ63nfuolnH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7ypAdpZ63nfuolnH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7ypAdpZ63nfuolnH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7ypAdpZ63nfuolnH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7ypAdpZ63nfuolnH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7ypAdpZ63nfuolnH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7ypAdpZ63nfuolnH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7ypAdpZ63nfuolnH .cluster text{fill:#333;}#mermaid-svg-7ypAdpZ63nfuolnH .cluster span{color:#333;}#mermaid-svg-7ypAdpZ63nfuolnH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7ypAdpZ63nfuolnH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7ypAdpZ63nfuolnH rect.text{fill:none;stroke-width:0;}#mermaid-svg-7ypAdpZ63nfuolnH .icon-shape,#mermaid-svg-7ypAdpZ63nfuolnH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7ypAdpZ63nfuolnH .icon-shape p,#mermaid-svg-7ypAdpZ63nfuolnH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7ypAdpZ63nfuolnH .icon-shape .label rect,#mermaid-svg-7ypAdpZ63nfuolnH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7ypAdpZ63nfuolnH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7ypAdpZ63nfuolnH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7ypAdpZ63nfuolnH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户程序代码
C 标准库 libc
fopen / fread / fwrite / fclose
系统调用接口
open / read / write / close
操作系统内核
磁盘 / 显示器 / 键盘等硬件

所谓的库函数,说白了就是对系统调用的封装。fopen 内部调 open,fwrite 落到 write,fclose 落到 close。为什么要多包一层?一是跨平台------不同操作系统的系统调用长得不一样,库函数替上层抹平了差异;二是好用------FILE 结构体在系统调用之上追加了缓冲、格式化等能力,第 8 章展开。

于是有一个反直觉但重要的认知:文件操作不是语言的能力,而是操作系统的能力。语言提供的库函数,只是让"调用系统调用"这件事更顺手而已。这其实也是为什么学文件 IO 必须懂系统调用------库函数的很多行为(比如缓冲、重定向后的诡异现象),只有放到系统调用这一层才能解释清楚。

5. 文件描述符 fd:小整数背后的大文章

5.1 0 / 1 / 2:三个默认打开的 fd

open 的返回值是一个小整数,这就是文件描述符(file descriptor,fd)。Linux 进程默认打开三个 fd:

fd 名称 对应硬件 C 里的流
0 标准输入 stdin 键盘 stdin
1 标准输出 stdout 显示器 stdout
2 标准错误 stderr 显示器 stderr

既然 0、1、2 已经被占了,自己 open 拿到的 fd 通常从 3 开始(要是 0、1、2 里先关出了空位,新文件就会复用小下标,分配规则见本章最后一节)。键盘和显示器这种"设备"也在 fd 表里占着位置------这就是"一切皆文件"最直观的体现,直接用 read/write 就能操作键盘和显示器:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
    char buf[1024];
    ssize_t s = read(0, buf, sizeof(buf));   // 从标准输入(键盘)读
    if(s > 0){
        buf[s] = 0;
        write(1, buf, strlen(buf));          // 写到标准输出(显示器)
        write(2, buf, strlen(buf));          // 写到标准错误(也是显示器)
    }
    return 0;
}

这段代码没有显式打开任何文件,却能完成输入输出------因为 0、1、2 在进程启动那一刻就已经替你打开了。

5.2 fd 的本质:数组的下标

问题来了:为什么 fd 是个小整数?它凭什么能代表一个文件?

由之前的学习我们可以知道,操作系统管理的哲学是"先描述,再组织"。进程打开一个文件,内核要干两件事:

描述 :为这个打开的文件创建 struct file(注意,不是磁盘上那个文件本身,而是"被打开的文件"在内核里的对象),记录读写位置、权限、引用计数等

组织 :让进程和这些文件对象关联起来------task_struct 里有一个 files_struct 指针,指向该进程自己的 files_struct,它最重要的成员是指针数组 fd_array,数组的每个元素都指向这个进程已打开的某个文件的 struct file

于是答案水落石出:fd 就是 fd_array 数组的下标。拿着下标去数组里一取,就拿到了 struct file 的地址,也就找到了文件。

在这个系统中,对文件内容做增删查改,必须先把文件的内容加载进内存(因为 CPU 只和内存打交道),然后由操作系统对内存中的数据进行操作,之后再写回文件。可以粗略地这样理解:修改文件,就是把文件的内容加载到缓冲区,操作系统对缓冲区操作之后,再从缓冲区写回原文件,这就完成了一次修改。从用户层的视角来看,对文件的增删查改,本质上就是数据在用户缓冲区和内核缓冲区之间的来回拷贝。

这套结构在内核源码里可以逐层验证(以 CentOS 7 的 3.10 内核为例,源码在 /usr/src/kernels/.../include/linux/ 下):

  • struct task_struct:linux/sched.h,其中有成员 struct files_struct *files
  • struct files_struct:linux/fdtable.h,其中有成员 struct file *fd_array[]
  • struct file:linux/fs.h,描述一个被打开的文件

类比一下:fd_array 就像商场的储物柜,柜子编号从 0 开始,每个格子里放的是文件对象的地址。fd 是柜子号,不是文件本身。柜子号可以重复使用------上一位顾客取走东西、格子空出来,下一位就能领到同一个号。这正好引出下一节。

5.3 分配规则:最小未使用的下标

用实验验证一下这个猜测:

c 复制代码
// 实验一:正常打开
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd);   // 输出 fd: 3

0、1、2 被默认流占了,新文件拿到 3,符合直觉。那如果先把 0 关掉呢?

c 复制代码
// 实验二:先关闭标准输入
close(0);
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd);   // 输出 fd: 0!

关闭 2 再打开,拿到的就是 2。fd 的分配规则:在 fd_array 里找当前没有被使用的最小下标,分配给新打开的文件。

这个规则看似平平无奇,却直接决定了下一章的主角------重定向。

6. 重定向:改变下标指向的艺术

6.1 从一个实验说起

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
    close(1);   // 关掉标准输出!
    int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
    // 关掉 1 之后,按"最小未使用"规则,新文件恰好拿到 fd = 1
    if(fd < 0){
       perror("open");
       return 1;
    }
    printf("fd: %d\n", fd);   // 这行会输出到哪?
    fflush(stdout);           // 为什么需要这行?第 8 章揭晓
    close(fd);
    exit(0);
}

运行后发现,本来应该打印在显示器上的 fd: 1,出现在了文件 myfile 里。这就是输出重定向的雏形。

原理用上一章的知识一句话就能说清:printf 只认 stdout,stdout 底层只认 fd=1,而 fd=1 这个数组元素现在指向的不再是显示器文件,而是 myfile 的 struct file。于是所有写到 1 号 fd 的数据,全部流进了文件。

6.2 重定向的本质

shell 里的 >>>< 天天用,现在可以给出本质定义了:

重定向的本质:不改变 fd 数字本身,改变 fd 数组元素里装的内容(指向哪个文件对象)。
#mermaid-svg-XVUJfrRImIuaUU11{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XVUJfrRImIuaUU11 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XVUJfrRImIuaUU11 .error-icon{fill:#552222;}#mermaid-svg-XVUJfrRImIuaUU11 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XVUJfrRImIuaUU11 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XVUJfrRImIuaUU11 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XVUJfrRImIuaUU11 .marker.cross{stroke:#333333;}#mermaid-svg-XVUJfrRImIuaUU11 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XVUJfrRImIuaUU11 p{margin:0;}#mermaid-svg-XVUJfrRImIuaUU11 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XVUJfrRImIuaUU11 .cluster-label text{fill:#333;}#mermaid-svg-XVUJfrRImIuaUU11 .cluster-label span{color:#333;}#mermaid-svg-XVUJfrRImIuaUU11 .cluster-label span p{background-color:transparent;}#mermaid-svg-XVUJfrRImIuaUU11 .label text,#mermaid-svg-XVUJfrRImIuaUU11 span{fill:#333;color:#333;}#mermaid-svg-XVUJfrRImIuaUU11 .node rect,#mermaid-svg-XVUJfrRImIuaUU11 .node circle,#mermaid-svg-XVUJfrRImIuaUU11 .node ellipse,#mermaid-svg-XVUJfrRImIuaUU11 .node polygon,#mermaid-svg-XVUJfrRImIuaUU11 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XVUJfrRImIuaUU11 .rough-node .label text,#mermaid-svg-XVUJfrRImIuaUU11 .node .label text,#mermaid-svg-XVUJfrRImIuaUU11 .image-shape .label,#mermaid-svg-XVUJfrRImIuaUU11 .icon-shape .label{text-anchor:middle;}#mermaid-svg-XVUJfrRImIuaUU11 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XVUJfrRImIuaUU11 .rough-node .label,#mermaid-svg-XVUJfrRImIuaUU11 .node .label,#mermaid-svg-XVUJfrRImIuaUU11 .image-shape .label,#mermaid-svg-XVUJfrRImIuaUU11 .icon-shape .label{text-align:center;}#mermaid-svg-XVUJfrRImIuaUU11 .node.clickable{cursor:pointer;}#mermaid-svg-XVUJfrRImIuaUU11 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XVUJfrRImIuaUU11 .arrowheadPath{fill:#333333;}#mermaid-svg-XVUJfrRImIuaUU11 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XVUJfrRImIuaUU11 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XVUJfrRImIuaUU11 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XVUJfrRImIuaUU11 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XVUJfrRImIuaUU11 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XVUJfrRImIuaUU11 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XVUJfrRImIuaUU11 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XVUJfrRImIuaUU11 .cluster text{fill:#333;}#mermaid-svg-XVUJfrRImIuaUU11 .cluster span{color:#333;}#mermaid-svg-XVUJfrRImIuaUU11 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XVUJfrRImIuaUU11 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XVUJfrRImIuaUU11 rect.text{fill:none;stroke-width:0;}#mermaid-svg-XVUJfrRImIuaUU11 .icon-shape,#mermaid-svg-XVUJfrRImIuaUU11 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XVUJfrRImIuaUU11 .icon-shape p,#mermaid-svg-XVUJfrRImIuaUU11 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XVUJfrRImIuaUU11 .icon-shape .label rect,#mermaid-svg-XVUJfrRImIuaUU11 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XVUJfrRImIuaUU11 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XVUJfrRImIuaUU11 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XVUJfrRImIuaUU11 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重定向后
fd = 1
struct file:磁盘文件 log.txt
重定向前
fd = 1
struct file:显示器

所谓 cat file > log.txt,就是 shell 先安排子进程把 1 号下标的内容换成 log.txt 的地址,再执行 cat。cat 照样往 1 号写,数据却进了文件------cat 对此毫不知情,也无需知情。这也是重定向设计优雅的地方:应用程序完全不用感知重定向的存在

6.3 dup2:正规的重定向姿势

手动 close(1) 再 open 去赌最小下标,太脆弱了。内核提供了专门的系统调用:

c 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);
// 作用:让 newfd 指向 oldfd 所指向的文件(原 newfd 若已打开会被关闭)

dup2 干的事,就是把 oldfd 数组元素的内容拷贝到 newfd 数组元素上。一个把标准输出改到 log 文件的完整例子:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
    int fd = open("./log", O_CREAT | O_RDWR, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    dup2(fd, 1);          // 1 号下标的内容被替换成 log 文件的地址
    for (;;) {
        char buf[1024] = {0};
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);  // 从键盘读
        if (read_size < 0) {
            perror("read");
            break;
        }
        printf("%s", buf);   // 虽然还是 printf,数据却写进了 log 文件
        fflush(stdout);
    }
    return 0;
}

追加重定向(>>)只需要把 open 的 flags 换成 O_APPEND;输入重定向(<)则是 dup2(fd, 0),把标准输入换成文件。三种重定向,一套操作。

6.4 实战:给 minishell 加上重定向

学以致用------把重定向功能加进之前写的 minishell。整体思路三步:

  1. 解析命令行时从后向前 扫描 <>>>,把命令和重定向部分切开,记录重定向类型和目标文件名
  2. fork 出子进程后、程序替换,执行重定向(open + dup2)
  3. execvpe 正常执行命令

这里有两个问题值得想清楚:

  • 重定向为什么必须在子进程里做? 如果在父进程(shell 自己)里改 fd 表,shell 后续所有命令的输出全都进文件了,shell 就废了。子进程是父进程数据的拷贝,改子进程的,父进程毫发无损
  • 程序替换会不会把重定向"冲掉"? 不会。由之前的学习我们知道,程序替换只换代码和数据,进程的 files_struct 原封不动。于是先 dup2 再 exec,替换后的新程序一睁眼,0/1/2 已经指向了文件

核心解析代码,从字符串末尾向前找重定向符号:

c 复制代码
#define NoneRedir   0    // 无重定向
#define InputRedir  1    // 输入重定向 <
#define OutputRedir 2    // 输出重定向 >
#define AppRedir    3    // 追加重定向 >>
int redir = NoneRedir;
char *filename = nullptr;

// 跳过字符串开头的空格("    file.txt" → "file.txt")
#define TrimSpace(pos) do{\
   while(isspace(*pos)){\
       pos++;\
   }\
}while(0)

void ParseRedir(char command_buffer[], int len)
{
    int end = len - 1;
    while(end >= 0)   // 从后往前扫,重定向符号总在命令参数的后面
    {
        if(command_buffer[end] == '<')
        {
            redir = InputRedir;
            command_buffer[end] = 0;              // 在 '<' 处截断,左边是命令
            filename = &command_buffer[end] + 1;  // 右边是文件名
            TrimSpace(filename);
            break;
        }
        else if(command_buffer[end] == '>')
        {
            if(command_buffer[end-1] == '>')      // ">>":追加重定向
            {
                redir = AppRedir;
                command_buffer[end] = 0;
                command_buffer[end-1] = 0;
                filename = &command_buffer[end]+1;
                TrimSpace(filename);
                break;
            }
            else                                  // 单个 '>':覆盖重定向
            {
                redir = OutputRedir;
                command_buffer[end] = 0;
                filename = &command_buffer[end]+1;
                TrimSpace(filename);
                break;
            }
        }
        else
        {
            end--;
        }
    }
}

子进程里执行重定向:

c 复制代码
void DoRedir()
{
    // 在子进程里做,且在 execvpe 之前做
    if(redir == InputRedir)
    {
        int fd = open(filename, O_RDONLY);
        if(fd < 0) exit(2);
        dup2(fd, 0);   // 标准输入换成文件
    }
    else if(redir == OutputRedir)
    {
        int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
        if(fd < 0) exit(4);
        dup2(fd, 1);   // 标准输出换成文件
    }
    else if(redir == AppRedir)
    {
        int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
        if(fd < 0) exit(6);
        dup2(fd, 1);   // 同样是 1 号,flags 换成 O_APPEND
    }
    else
    {
        // 没有重定向,什么都不做
    }
}

bool ExecuteCommand()
{
    pid_t id = fork();
    if(id < 0) return false;
    if(id == 0)
    {
        DoRedir();                       // 先重定向
        execvpe(gargv[0], gargv, genv);  // 再程序替换
        exit(7);                         // 只有替换失败才会走到这
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    // ...回收子进程,取出退出码
    return true;
}

跑起来输入 ls -a -l > hello.txt,打开文件一看,和真 shell 一个效果。到这里,shell 的黑魔法就剩不了几样了。

7. 一切皆文件:VFS 与函数指针表

7.1 struct file 长什么样

第 6 章反复说"fd 指向 struct file",现在把 struct file 摊开看(定义在内核 include/linux/fs.h,节选关心的字段):

c 复制代码
struct file {
    struct inode        *f_inode;   // 指向文件的 inode(磁盘上那个文件的元数据)
    const struct file_operations *f_op;   // 指向操作方法表!本篇最关键的字段

    atomic_long_t       f_count;    // 引用计数:多个 fd 指向同一个文件对象时增加
    unsigned int        f_flags;    // 打开时传入的 flags(O_RDONLY 等)
    fmode_t             f_mode;     // 访问模式:只读 / 只写等
    loff_t              f_pos;      // 当前读写位置
    ...
};

注意 f_pos(当前偏移量)放在 struct file 里,而不是磁盘文件里------所以同一个文件被 open 两次,会得到两个独立的 struct file,各有各的读写位置,互不干扰。这也解释了一个现象:两个进程同时写同一个文件而不加 O_APPEND,数据可能互相覆盖,因为它们各写各的偏移。

从上面可以看到 f_count 的存在,这说明文件对象不是被"一个进程独享"的------它可能同时被多个 fd 引用。这个参数就是引用计数 :fork、dup、dup2 都会让新的 fd 指向同一个 struct file,每多一个引用,f_count 就加 1;每关闭一个 fd,f_count 就减 1,减到 0 时内核才真正释放这个文件对象。要注意区分两种情况:多个进程各自 open 同一个磁盘文件,会各自得到独立的 struct file(各自的引用计数从 1 开始);而 fork、dup 出来的 fd 指向的是同一个 struct file,才会共享引用计数。

事实上,从用户层的角度去看,每个打开的流(FILE 对象)都有自己对应的缓冲区。这里先了解个概念,不展开详谈,第 8 章会详细讨论。

7.2 标准输出和标准错误

先运行如下代码:

cpp 复制代码
#include <iostream>
#include <cstdio>
int main()
{
    std::cout << "hello cout" << std::endl;
    printf("hello printf\n");
    std::cerr << "hello cerr" << std::endl;
    fprintf(stderr, "hello stderr\n");
    return 0;
}

运行结果如下图所示:

从这里可以看出,std::cout 和 std::cerr 底层虽然对应不同的 fd(1 和 2),但默认都指向显示器文件,所以打印的内容都出现在显示器上。

如上图中的操作,是把 stdout 中的内容重定向进 log.txt,而 std::cerr 中的内容仍然打印到显示器文件上。

如果想让 stdout 和 stderr 中的内容都打印到同一个文件中,可以做如下操作:

但是一般情况下,我们不建议这么做,而是采用以下做法:

有了以上技术,从此以后我们就可以把正常输出和错误信息分开打印,方便日后查看错误日志。

7.3 file_operations:藏在结构体里的多态

真正的主角是 f_op 指向的 file_operations。这个结构体除了第一个成员,剩下的全是函数指针(节选):

c 复制代码
struct file_operations {
    struct module *owner;
    // 改变文件的当前读写位置(lseek 系统调用的落点)
    loff_t (*llseek) (struct file *, loff_t, int);
    // 从设备/文件读数据,返回成功读取的字节数
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    // 向设备/文件写数据,返回成功写入的字节数
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    // 初始化一个异步读
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    // 初始化一个异步写
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    // 将设备内存映射到进程地址空间(mmap 系统调用的落点)
    int (*mmap) (struct file *, struct vm_area_struct *);
    // 打开文件
    int (*open) (struct inode *, struct file *);
    // 进程关闭它的设备文件描述符拷贝时调用
    int (*flush) (struct file *, fl_owner_t id);
    // 文件引用计数归零、结构体将被释放时调用
    int (*release) (struct inode *, struct file *);
    // 刷新任何挂着的数据(fsync 系统调用的落点)
    int (*fsync) (struct file *, struct dentry *, int datasync);
    ...
};

盯着看就会发现一个精妙的事实:这个结构体里的每一个函数指针,几乎都对应一个系统调用。不同设备在底层读写方法的具体实现上各不相同,而 read 系统调用的内核实现,最终就是取出 f_op->read 这个函数指针并调用它。file_operations 就是把系统调用和驱动程序关联起来的关键数据结构。

7.4 "一切皆文件"是怎么落地的

现在可以正面回答第 2 章埋下的问题了。键盘、显示器、磁盘、管道、网卡,每种设备的行为天差地别,Linux 是怎么把它们统一成"文件"的?

答案是:内核在系统调用和设备驱动之间加了一层抽象------VFS(虚拟文件系统)。每个设备被打开时,内核里都会有与之对应的 struct file,它的 f_op 指向该设备类型的 file_operations:
#mermaid-svg-lA7quAJRGCeIVMO1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lA7quAJRGCeIVMO1 .error-icon{fill:#552222;}#mermaid-svg-lA7quAJRGCeIVMO1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lA7quAJRGCeIVMO1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lA7quAJRGCeIVMO1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lA7quAJRGCeIVMO1 .marker.cross{stroke:#333333;}#mermaid-svg-lA7quAJRGCeIVMO1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lA7quAJRGCeIVMO1 p{margin:0;}#mermaid-svg-lA7quAJRGCeIVMO1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster-label text{fill:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster-label span{color:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster-label span p{background-color:transparent;}#mermaid-svg-lA7quAJRGCeIVMO1 .label text,#mermaid-svg-lA7quAJRGCeIVMO1 span{fill:#333;color:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 .node rect,#mermaid-svg-lA7quAJRGCeIVMO1 .node circle,#mermaid-svg-lA7quAJRGCeIVMO1 .node ellipse,#mermaid-svg-lA7quAJRGCeIVMO1 .node polygon,#mermaid-svg-lA7quAJRGCeIVMO1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lA7quAJRGCeIVMO1 .rough-node .label text,#mermaid-svg-lA7quAJRGCeIVMO1 .node .label text,#mermaid-svg-lA7quAJRGCeIVMO1 .image-shape .label,#mermaid-svg-lA7quAJRGCeIVMO1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lA7quAJRGCeIVMO1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lA7quAJRGCeIVMO1 .rough-node .label,#mermaid-svg-lA7quAJRGCeIVMO1 .node .label,#mermaid-svg-lA7quAJRGCeIVMO1 .image-shape .label,#mermaid-svg-lA7quAJRGCeIVMO1 .icon-shape .label{text-align:center;}#mermaid-svg-lA7quAJRGCeIVMO1 .node.clickable{cursor:pointer;}#mermaid-svg-lA7quAJRGCeIVMO1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lA7quAJRGCeIVMO1 .arrowheadPath{fill:#333333;}#mermaid-svg-lA7quAJRGCeIVMO1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lA7quAJRGCeIVMO1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lA7quAJRGCeIVMO1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lA7quAJRGCeIVMO1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lA7quAJRGCeIVMO1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lA7quAJRGCeIVMO1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster text{fill:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 .cluster span{color:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lA7quAJRGCeIVMO1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lA7quAJRGCeIVMO1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lA7quAJRGCeIVMO1 .icon-shape,#mermaid-svg-lA7quAJRGCeIVMO1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lA7quAJRGCeIVMO1 .icon-shape p,#mermaid-svg-lA7quAJRGCeIVMO1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lA7quAJRGCeIVMO1 .icon-shape .label rect,#mermaid-svg-lA7quAJRGCeIVMO1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lA7quAJRGCeIVMO1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lA7quAJRGCeIVMO1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lA7quAJRGCeIVMO1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 键盘的方法表
磁盘的方法表
管道的方法表
socket 的方法表
read(fd, buf, size)

系统调用
VFS 虚拟文件系统

拿着 fd 找到 struct file,取 f_op->read
键盘驱动 read

从键盘硬件取数据
磁盘驱动 read

从磁盘块读数据
管道驱动 read

从内核缓冲区取数据
网卡驱动 read

从网络收数据

  • 键盘的 file_operations 里,read 指向"从键盘硬件取数据"的驱动函数
  • 磁盘的 file_operations 里,read 指向"从磁盘块读数据"的驱动函数
  • 管道、socket 也各有各的实现

于是上层用户永远只管调用同一个 read 系统调用,内核拿着 fd 找到 struct file,再通过 f_op->read 跳到对应设备的驱动函数。设备差异被封装在函数指针的指向里------这就是 C 语言用函数指针实现的"多态",也是"一切皆文件"的全部秘密。

说白了:不同硬件的读写方法实现各不相同,但通过 file 结构体里的函数指针,我们只要知道文件打开后的 fd,就能调用到对应硬件的读写方法------相当于用函数指针实现了多态,屏蔽了底层硬件读写方法的差异,一个 read 接口就能操作所有设备。所谓"一切皆文件",不是说所有设备真的变成了文件,而是所有设备都被描述成了带统一方法表的 file 对象------先描述(struct file + file_operations),再组织(fd 数组挂进进程)。这仍然是操作系统管理万物的那套老手艺,只是这次管理的对象是外设。

这套设计带来的工程收益是实打实的:一套 read/write API 就能调取 Linux 里绝大部分资源------读文件、读管道、读 socket,甚至读进程信息(/proc 伪文件系统)。将来学网络编程,socket 收发数据用的还是这套接口,到时候不用重新学。

8. 缓冲区:性能与语义的博弈

8.1 为什么需要缓冲区

先想一个问题:printf 一行 20 字节的数据,如果每打印一个字符就发起一次 write 系统调用,会发生什么?

每次系统调用都要从用户态切到内核态,伴随 CPU 上下文切换,开销不小。更糟的是,外设的速度和 CPU 差了几个数量级------CPU 全速运转,打印机慢悠悠吐纸,CPU 大部分时间都在干等。

解决思路:在内存里开一块空间,先把数据攒在这里,攒够一批再一次交给内核。这块空间就是缓冲区。它一箭双雕:

  • 减少系统调用次数:一千次"写 1 字节"的 write,变成一次"写 1000 字节"的 write
  • 隔离速度差:CPU 把数据丢给缓冲区就走,慢设备自己慢慢消费(打印机的工作方式就是这样:先把文档输出到打印缓冲区,打印机逐步打,CPU 干别的去)

8.2 什么是缓冲区

我们平常说的缓冲区其实有两个:一个是 C 标准库封装的用户级缓冲区,另一个是操作系统自带的内核缓冲区。我们平时写代码用的 printf、fprintf、fputs,在打印的时候其实都是先把数据拷贝到 C 语言(用户层)提供的缓冲区里------因为频繁调用系统接口会让程序效率十分低下,所以 C 语言层就封装了这样一层缓冲区。只有当我们手动刷新(fflush)、满足刷新条件,或者进程正常结束的时候,用户级缓冲区里的数据才会被拷贝到系统缓冲区(内核层),再由系统缓冲区写入对应的硬件。

8.3 三种缓冲策略

标准 IO 提供了三种缓冲方式:

类型 刷新时机 典型场景
全缓冲 缓冲区填满才刷新 往磁盘文件写入(普通文件使用)
行缓冲 遇到换行符 \n 就刷新 往显示器输出(stdout)
无缓冲 不缓冲,立即刷新 标准错误(stderr)

行缓冲是为了符合人的阅读直觉------一行一行蹦出来,体验最好;glibc 里行缓冲区默认大小是 1024 字节,满了也会强制刷新。stderr 无缓冲则是为了错误信息能第一时间蹦出来,哪怕程序下一秒就崩溃,错误也已经在屏幕上了。

除了默认规则,三种情况会强制刷新:缓冲区满、显式调用 fflush、进程正常退出 。注意"正常退出"这个词------return 和 exit 会刷新缓冲区,_exit 不会,它是直接退场,缓冲区里的数据当场作废。

还有一个关键设定容易被忽略:缓冲方式跟着"流"走,而不是跟着函数走。同一个 printf,往显示器输出是行缓冲,重定向到文件就变成全缓冲。下面这个实验就栽在这上面。

8.4 实验:重定向后 printf "消失"了

c 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    // 关掉 1 之后,新文件拿到 fd = 1,stdout 实际指向了它
    if (fd < 0) {
        perror("open");
        return 0;
    }
    printf("hello world: %d\n", fd);   // 这行输出去哪了?
    close(fd);
    return 0;
}

运行后 cat log.txt,文件是空的!printf 明明执行了,数据去哪了?

原因拆开看:本来 stdout 对应显示器,行缓冲,printf 里的 \n 一出现就该刷新。但 close(1) + open 之后,1 号 fd 指向了磁盘文件,标准库发现流对应的是普通文件,就切换成全缓冲策略。一行 "hello world" 远远填不满缓冲区,\n 也不触发刷新,数据一直躺在用户级缓冲区里。而 close(fd) 直接把 fd 关了,进程退出时想刷新也没了门路,数据彻底丢了。

两种修法。第一种:close 之前主动 fflush:

c 复制代码
printf("hello world: %d\n", fd);
fflush(stdout);   // 强制把用户级缓冲区刷出去
close(fd);

这也顺手解释了第 6 章那个实验里为什么要 fflush(stdout)------当时卖了个关子,现在补上了。

第二种更有意思,用 stderr 做实验,反向验证"stderr 无缓冲":

c 复制代码
int main() {
    close(2);   // 关标准错误
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    // 新文件拿到 fd = 2,stderr 实际指向了它
    perror("hello world");   // perror 往 stderr 输出
    close(fd);
    return 0;
}

这次不加 fflush,cat log.txt 也能看到内容:

复制代码
hello world: Success

因为 stderr 无缓冲,perror 直接调用 write 输出,不经过用户级缓冲区(至于尾巴上的 "Success",那是 perror 拼上去的 errno 描述,此刻 errno 是 0,对应 "Success")。两个实验一对照,三种缓冲策略的差别看得明明白白。

8.5 FILE 里封装了 fd

第 3 章留的伏笔可以兑现了:FILE* 到底是什么?

实际上,FILE = fd + 缓冲区。

c 复制代码
typedef struct _IO_FILE FILE;   // /usr/include/stdio.h

struct _IO_FILE 定义在 libio.h,节选关键字段:

c 复制代码
struct _IO_FILE {
    int _flags;                 /* 标志位 */

    /* 下面一组指针描述了 FILE 自带的缓冲区 */
    char* _IO_read_ptr;         /* 当前读位置 */
    char* _IO_read_end;         /* 读区末尾 */
    char* _IO_read_base;        /* 读区起始 */
    char* _IO_write_base;       /* 写区起始 */
    char* _IO_write_ptr;        /* 当前写位置 */
    char* _IO_write_end;        /* 写区末尾 */
    char* _IO_buf_base;         /* 缓冲区起始 */
    char* _IO_buf_end;          /* 缓冲区末尾 */
    ...
    struct _IO_FILE *_chain;    /* 所有打开的 FILE 串成链表 */
    int _fileno;                /* 封装的文件描述符! */
    ...
};

两个发现:

  1. FILE 结构体里有一个 _fileno 字段,封装的就是 fd。库函数的一切底层操作都绕回 fd------所谓 FILE,就是"fd + 缓冲区(用户层) + 管理信息"的打包。stdin/stdout/stderr 三个流内部的 _fileno 分别就是 0/1/2
  2. 那一堆 _IO_xxx 指针,把一块缓冲区划出了读区、写区,正是缓冲区的实现本体

前面章节反复说的"缓冲区",到这里可以给出准确定位了:printf/fwrite 的缓冲区是 C 标准库提供的用户级缓冲区,就住在 FILE 结构体里。write 系统调用没有这层缓冲(OS 内核自己另有内核级缓冲区,负责攒数据批量落盘,属于另一层的事,这里不展开,以后讲文件系统再说)。

那这个缓冲区是谁加的?printf、fwrite 是库函数,write 是系统调用,库函数在系统调用的"上层"。write 没有缓冲而 printf 有,足以说明缓冲区是封装时二次加上去的------又因为是 C 程序,所以由 C 标准库提供。

8.6 经典实验:fork 之后的诡异输出

最后一个实验,把前几篇的知识全部串起来:

c 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
    const char *msg0="hello printf\n";
    const char *msg1="hello fwrite\n";
    const char *msg2="hello write\n";
    printf("%s", msg0);                    // 库函数,带缓冲
    fwrite(msg1, strlen(msg1), 1, stdout); // 库函数,带缓冲
    write(1, msg2, strlen(msg2));          // 系统调用,没有用户级缓冲
    fork();
    return 0;
}

直接运行,输出三行,符合预期:

复制代码
hello printf
hello fwrite
hello write

但用 ./hello > file 重定向后运行,文件里变成了五行:

复制代码
hello write
hello printf
hello fwrite
hello printf
hello fwrite

printf 和 fwrite 各输出了两次,write 只有一次,而且 write 跑到了最前面。逐条解释:

  • **重定向后,stdout 从显示器变成文件,缓冲策略从行缓冲变成全缓冲。**于是 fork 时刻,"hello printf"和"hello fwrite"还躺在父进程的用户级缓冲区里,没刷新(如果还是往显示器打,\n 早就触发刷新了,fork 时缓冲区是空的,自然只有一份)
  • write 是系统调用,不经过用户级缓冲区,fork 前数据就已经进文件了------所以它在最前面,且只有一份
  • fork 之后,子进程几乎完整拷贝父进程,包括 FILE 结构体和它肚子里没刷新的缓冲区------缓冲区本质上也是进程的数据,同样遵守写时拷贝
  • 父子进程先后退出,各自退出时都会刷新自己那份缓冲区------同一份数据被刷了两次

这个实验把几个知识点焊在了一起:缓冲策略随流的类型变化(本节)、fork 的写时拷贝(进程控制篇)、库函数与系统调用的分层(第 4 章)。以后在多进程程序里看到"日志重复输出",第一反应就该是------谁在 fork 之后还没刷新缓冲区。

8.7 亲手写一个 mini stdio

理解缓冲区最狠的办法是自己实现一个。目标:mfopen/mfwrite/mfflush/mfclose 四个接口,支持行缓冲。先设计结构体:

c 复制代码
#pragma once
#define SIZE 1024
#define FLUSH_NONE 0   // 无缓冲
#define FLUSH_LINE 1   // 行缓冲
#define FLUSH_FULL 2   // 全缓冲

struct IO_FILE
{
    int flag;                  // 刷新方式
    int fileno;                // 文件描述符 ------ 对标 _IO_FILE 的 _fileno
    char outbuffer[SIZE];      // 输出缓冲区本体
    int cap;                   // 缓冲区容量
    int size;                  // 当前已缓存的数据量
};
typedef struct IO_FILE mFILE;
mFILE *mfopen(const char *filename, const char *mode);
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);

实现:

c 复制代码
#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

mFILE *mfopen(const char *filename, const char *mode)
{
    int fd = -1;
    if(strcmp(mode, "r") == 0)
        fd = open(filename, O_RDONLY);
    else if(strcmp(mode, "w") == 0)
        fd = open(filename, O_CREAT|O_WRONLY|O_TRUNC, 0666);
    else if(strcmp(mode, "a") == 0)
        fd = open(filename, O_CREAT|O_WRONLY|O_APPEND, 0666);
    if(fd < 0) return NULL;

    mFILE *mf = (mFILE*)malloc(sizeof(mFILE));
    if(!mf) {
        close(fd);
        return NULL;
    }
    mf->fileno = fd;
    mf->flag = FLUSH_LINE;   // 简化处理:默认行缓冲
    mf->size = 0;
    mf->cap = SIZE;
    return mf;
}

void mfflush(mFILE *stream)
{
    if(stream->size > 0)
    {
        // 把用户级缓冲区的数据交给内核(write 系统调用)
        write(stream->fileno, stream->outbuffer, stream->size);
        // 强制内核立刻把数据同步到外设,不在内核缓冲区里攒批
        fsync(stream->fileno);
        stream->size = 0;
    }
}

int mfwrite(const void *ptr, int num, mFILE *stream)
{
    // 0. 缓冲区放不下就先刷新(单次写入超过缓冲区大小的情况从简处理)
    if(stream->size + num > stream->cap)
        mfflush(stream);
    // 1. 数据先拷进用户级缓冲区,而不是直接 write
    memcpy(stream->outbuffer + stream->size, ptr, num);
    stream->size += num;
    // 2. 行缓冲策略:末尾是 \n 就刷新
    if(stream->flag == FLUSH_LINE && stream->size > 0
       && stream->outbuffer[stream->size - 1] == '\n')
    {
        mfflush(stream);
    }
    return num;
}

void mfclose(mFILE *stream)
{
    if(stream->size > 0)
        mfflush(stream);   // 关闭前兜底刷新,对应"进程退出刷新缓冲区"
    close(stream->fileno);
    free(stream);          // 别忘了释放结构体本身
}

测试代码,每秒写一条,观察文件实时增长:

c 复制代码
#include "my_stdio.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
    mFILE *fp = mfopen("./log.txt", "a");
    if(fp == NULL) return 1;
    int cnt = 10;
    while(cnt)
    {
        printf("write %d\n", cnt);
        char buffer[64];
        snprintf(buffer, sizeof(buffer),"hello message, number is : %d\n", cnt);
        cnt--;
        mfwrite(buffer, strlen(buffer), fp);
        mfflush(fp);
        sleep(1);
    }
    mfclose(fp);
}

几十行代码,就把标准库干的事复刻了一遍:数据先 memcpy 进缓冲区,满足刷新条件才调一次 write,fsync 再把内核缓冲区也压出去。回头看 _IO_FILE 里那一堆指针和字段,突然就不神秘了------只是比这一版做得更细、考虑得更全而已。

9. 总结

一根线把全文串起来------一次 printf 的完整旅程:
#mermaid-svg-DEFh6v2rtswZIwaB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DEFh6v2rtswZIwaB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DEFh6v2rtswZIwaB .error-icon{fill:#552222;}#mermaid-svg-DEFh6v2rtswZIwaB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DEFh6v2rtswZIwaB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DEFh6v2rtswZIwaB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DEFh6v2rtswZIwaB .marker.cross{stroke:#333333;}#mermaid-svg-DEFh6v2rtswZIwaB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DEFh6v2rtswZIwaB p{margin:0;}#mermaid-svg-DEFh6v2rtswZIwaB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DEFh6v2rtswZIwaB .cluster-label text{fill:#333;}#mermaid-svg-DEFh6v2rtswZIwaB .cluster-label span{color:#333;}#mermaid-svg-DEFh6v2rtswZIwaB .cluster-label span p{background-color:transparent;}#mermaid-svg-DEFh6v2rtswZIwaB .label text,#mermaid-svg-DEFh6v2rtswZIwaB span{fill:#333;color:#333;}#mermaid-svg-DEFh6v2rtswZIwaB .node rect,#mermaid-svg-DEFh6v2rtswZIwaB .node circle,#mermaid-svg-DEFh6v2rtswZIwaB .node ellipse,#mermaid-svg-DEFh6v2rtswZIwaB .node polygon,#mermaid-svg-DEFh6v2rtswZIwaB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DEFh6v2rtswZIwaB .rough-node .label text,#mermaid-svg-DEFh6v2rtswZIwaB .node .label text,#mermaid-svg-DEFh6v2rtswZIwaB .image-shape .label,#mermaid-svg-DEFh6v2rtswZIwaB .icon-shape .label{text-anchor:middle;}#mermaid-svg-DEFh6v2rtswZIwaB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DEFh6v2rtswZIwaB .rough-node .label,#mermaid-svg-DEFh6v2rtswZIwaB .node .label,#mermaid-svg-DEFh6v2rtswZIwaB .image-shape .label,#mermaid-svg-DEFh6v2rtswZIwaB .icon-shape .label{text-align:center;}#mermaid-svg-DEFh6v2rtswZIwaB .node.clickable{cursor:pointer;}#mermaid-svg-DEFh6v2rtswZIwaB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DEFh6v2rtswZIwaB .arrowheadPath{fill:#333333;}#mermaid-svg-DEFh6v2rtswZIwaB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DEFh6v2rtswZIwaB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DEFh6v2rtswZIwaB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DEFh6v2rtswZIwaB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DEFh6v2rtswZIwaB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DEFh6v2rtswZIwaB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DEFh6v2rtswZIwaB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DEFh6v2rtswZIwaB .cluster text{fill:#333;}#mermaid-svg-DEFh6v2rtswZIwaB .cluster span{color:#333;}#mermaid-svg-DEFh6v2rtswZIwaB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DEFh6v2rtswZIwaB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DEFh6v2rtswZIwaB rect.text{fill:none;stroke-width:0;}#mermaid-svg-DEFh6v2rtswZIwaB .icon-shape,#mermaid-svg-DEFh6v2rtswZIwaB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DEFh6v2rtswZIwaB .icon-shape p,#mermaid-svg-DEFh6v2rtswZIwaB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DEFh6v2rtswZIwaB .icon-shape .label rect,#mermaid-svg-DEFh6v2rtswZIwaB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DEFh6v2rtswZIwaB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DEFh6v2rtswZIwaB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DEFh6v2rtswZIwaB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 满足刷新条件
f_op->write
printf(数据)
用户级缓冲区

FILE 结构体内部
write(fd=1, ...)
files_struct 的

fd_array1
struct file

拿到文件对象
设备驱动函数
内核缓冲区
显示器 / 磁盘

第 2 章到第 8 章对应这条链路上的七块拼图:

  1. 文件 = 属性 + 内容,对文件的操作就是对外设的 IO,操作文件的是进程,管硬件的是操作系统
  2. 库函数是对系统调用的封装,FILE* 是 C 库搞出来的抽象,fopen 内部就是 open
  3. open/read/write/close 是文件 IO 的最底层,flags 用位图方式传参,mode 要和 ~umask 做与运算
  4. fd 是 fd_array 的下标,分配规则是"最小未使用",task_struct → files_struct → struct file 三级指针找下去
  5. 重定向不换 fd 数字,只换数组元素指向,dup2 是正规姿势;minishell 里让子进程在程序替换前完成重定向,父进程毫发无损
  6. 一切皆文件 = struct file + file_operations 函数指针表,设备差异藏在函数指针的指向里,这是 C 语言的多态
  7. 缓冲区是 C 库加在系统调用之上的用户级缓冲,策略分全缓冲/行缓冲/无缓冲;fork 后输出翻倍,是缓冲区遇上写时拷贝的合谋

接下来讲文件系统------磁盘上的文件怎么组织、inode 是什么、软硬链接的区别,再往后是动静态库。到时候会发现,这一篇里的 struct file 管的是"打开的文件",而 inode 管的是"磁盘上的文件",两者在内核里通过 f_inode 字段握手------故事还在继续。

相关推荐
tx112087635821 分钟前
K8S-pod管理与控制器管理
linux·运维·kubernetes·pod
老当益壮梁奶奶32 分钟前
Linux软件编程学习笔记(七):线程分离与线程间通信详解
linux·c语言·c++·笔记·学习
Awh-33 分钟前
Linux软件应用编程 第一章中:Linux文件操作
linux·运维·服务器
adinnet20261 小时前
财务报销自动化:用智能体辅助处理繁琐报销任务
运维·自动化
zhendianluli2 小时前
VS Code Remote-SSH 连接故障排查手册(精详版)
运维·ssh
Tim风声(网络工程师)2 小时前
Kail下载
linux·运维·服务器
JXJD20043 小时前
光模块自动化组装设备 工艺体系与选型指南
运维·自动化
AOwhisky3 小时前
Linux 网络服务架设学习笔记(第五期)——Web 服务(上篇):Apache HTTP Server——从静态到动态
linux·运维·服务器·笔记·学习·云计算·apache
zbyyd3 小时前
Linux 多线程:互斥锁 &amp; 信号量
linux·c语言·开发语言·网络