从学 C 语言的第一天起,你大概就写过这样两行代码:fopen("myfile", "w") 打开一个文件,然后 fwrite 往里面写数据。你可能会想:这不就是"读写文件"吗?有什么好讲的?
但当你深入 Linux 之后,一个问题会逐渐变得扎眼——**到底是谁在真正地"打开文件"?**是 C 语言的库函数吗?还是某个藏在操作系统内核里你从没见过的家伙?
答案会让你重新审视前面学的所有 IO 代码:fopen、fwrite 这些库函数,只是"跑腿的中介",真正打开文件、负责搬运数据的是 Linux 内核提供的一批系统调用。而连接你的程序与这些系统调用之间的桥梁,是一个神秘的"小整数"——文件描述符(fd)。
这篇文章我们就把 Linux 基础 IO 拆开揉碎:从"文件到底是什么"讲起,回顾 C 语言文件接口,再一层层揭开系统调用、文件描述符、重定向的底牌,最后落到无数新手栽跟头的缓冲区。这一篇下来,你会彻底看懂 printf 和 write 到底差在哪、为什么 > file 重定向后输出会"迟到甚至消失"、以及你那个打印"hello world"永远只出现一次的困惑是怎么来的。
你应该有的知识准备
在读之前,我默认你至少具备下面这些基础。它们大多散落在 C 语言课里,这里我只做一句话回顾:
- C 语言基础:会写
main函数、认识指针、会用printf / fopen / fwrite / fread这些库函数。 - 进程的概念:程序跑起来之后就是一个进程,进程有自己独立的内存、自己独立的"身份"(进程 PID)。理解
/proc/<pid>这个虚拟文件系统会很有用。 - 文件就是"字节序列":无论是文本还是二进制,对操作系统来说,文件就是一连串字节,读写就是移动一个"读写位置(偏移)"在这串字节上取放数据。
- 稍微知道一点位运算:
&(按位与)、|(按位或)会在"传递标志位"那节用到,也贯穿open的第二个参数。
如果你这些都知道,那这堂课会非常顺畅;如果哪条模糊,看到对应代码时回头补一眼就行。
理解"文件":从磁盘到一切皆文件
狭义理解:磁盘上的文件
最朴素的认识是:文件在磁盘里。磁盘是一种永久性存储介质——断电之后数据不丢,所以我们说文件在磁盘上的存储是"永久性的"。这一点和内存正好相反,内存一断电就全没了。
还要注意一个容易被忽略的身份:磁盘本身是外设。它既是"输出设备"(我们要把数据写到它里面去),也是"输入设备"(我们要把数据从它里面读出来)。所以你看:既然对文件的所有操作,本质上就是对"输入设备"和"输出设备"的输入输出,那么"文件操作"和"IO"这件事是天生画等号的——**IO(Input/Output,输入/输出)**这个词,就是为文件操作准备的。
如果你把一个空文件(0 字节)放进磁盘,你会发现它依然"占地方"。这背后是一个重要结论:
文件 = 元数据 + 内容
一个文件,是由"文件属性(元数据)"和"文件内容"两部分组成的。0 字节的空文件,只是"内容"是空的,它的"属性"可一点都不少:文件名、大小、修改时间、权限、所有者……这些属性占用的那点磁盘空间,就是它"0KB 内容却占用磁盘空间"的原因。
也正因为文件由这两部分组成,所有的文件操作,本质上只有两类:操作内容,或者操作属性。read/write 操作内容,stat/chmod 操作属性。记住这个分类,后面学别的会很有帮助。
广义理解:一切皆文件
如果你把视野放大,会发现 Linux 里"文件"这个概念被大大地升华了。Linux 下"一切皆文件"——不只是磁盘上的普通文件,连键盘、显示器、网卡、磁盘这些硬件设备,也全都被抽象成了文件。
这意味着:你可以像读写普通文件一样去读写一个设备、去读取系统状态、去和一个管道通信。好处是巨大的——开发者只需要学一套 API、一套思维模型,就能操作 Linux 里绝大部分资源。Linux 中几乎所有"读"的操作都能用 read 完成,几乎所有"改"的操作都能用 write 完成。这正是我们后面第四节要展开的"一切皆文件"的精髓,这里先建立一个印象。
系统视角:进程才是操作的主体
最后,换一个"上帝视角"看文件:对文件的操作,本质上是进程对文件的操作;而磁盘的管理者是操作系统,不是某个进程。
这句话拆开来看有两层意思:
第一,文件不是凭空存在的,它一定是被"某个进程"打开的。同一时刻,可能有多个进程都在打开同一个文件。
第二,进程本身没有一个键直接插在磁盘上。进程要想读写文件,必须"请求"操作系统替它干活——因为它没有权限直接触碰硬件。这个"请求操作系统帮忙"的动作,就是我们下节要反复提到的主角:系统调用。
思考题 1.1:为什么说"C 语言 / C++ 的库函数并不是读取文件的最终执行者"?那文件到底是谁读的?
详解:C / C++ 的库函数(
fread、fwrite、ifstream等)只是运行在用户态的程序代码,它们本身没有读写磁盘硬件的权限。最终真正去操作外设、把数据搬进搬出的是 Linux 内核。库函数做的,是把你的请求翻译成一次对内核的"系统调用"(比如read、write),由内核代表进程去完成真正的 IO,再把结果返回给库函数。所以答案是:文件最终由操作系统内核,通过系统调用替进程读取。
回顾 C 语言的文件接口
在学习系统接口之前,我们先把 C 语言标准库(libc)这套你熟悉的接口完整过一遍,因为下一节讲系统调用时,你会体会到"它们俩几乎一一对应"。
用 fopen 打开文件
先写一个最朴素的三行小例子:
// hello.c —— 打开文件并卡住,观察进程状态
#include <stdio.h>
int main()
{
// fopen("myfile", "w"):以"写"模式打开(若不存在则创建)名为 myfile 的文件
// 参数1 文件名是相对路径;参数2 是打开方式
FILE *fp = fopen("myfile", "w");
if (fp == NULL) // fopen 失败返回 NULL
{
printf("fopen error!\n");
return 1;
}
// 打开成功后用一个死循环卡住进程,方便我们去 /proc 里观察它
while (1);
fclose(fp); // 正常情况用 fclose 关闭(这里永远执行不到)
return 0;
}先别管 while(1) 有点"坏坏的",这是故意的——我们想让程序一直跑着,好去观察这个进程。编译运行:gcc hello.c -o hello && ./hello。
myfile 到底被创建在哪个目录下?我们问一个更本质的问题:进程的"当前路径"是怎么被系统知道的?
答案是:每个进程在操作系统里都有一份"档案",记录它自己从哪来、现在在哪个目录。你可以在 /proc(一个虚拟文件系统,每个运行中的进程都有一个以 PID 命名的目录)里亲眼看到它:
# 先查到 hello 这个进程的 PID
$ ps ajx | grep hello | grep -v grep
506729 533463 533463 506729 pts/2 533463 R+ 1002 7:45 ./hello
# 用 PID 去 /proc 里查看这个进程的档案
$ ls /proc/533463 -l
total 0
...
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 cwd -> /home/hyb/io # 当前工作目录
lrwxrwxrwx 1 hyb hyb 0 Aug 26 16:53 exe -> /home/hyb/io/hello # 可执行文件
dr-x------ 2 hyb hyb 0 Aug 26 16:54 fd # 文件描述符表
...注意其中两个符号链接:
cwd:指向这个进程当前的工作目录(current working directory)。哪怕你只写了个相对路径"myfile",系统只要看一眼cwd就知道要在哪个目录里创建文件。exe:指向启动这个进程的可执行文件的完整路径。
所以"打开文件,本质是进程打开文件"。进程知道自己此刻在哪、自己是谁,于是不带路径的 myfile 也能被准确定位。这个 fd 目录,我们待会儿讲文件描述符时会回头看它,它里面就装着这个进程当前打开的所有文件。
fopen 的打开方式(mode)
fopen 的第二个参数是一段字符串,叫 mode,它决定以什么方式打开。这是你需要刻进脑子的表:
| mode | 含义 | 文件不存在时 | 位置 |
|---|---|---|---|
r | 只读 | 打开失败(返回 NULL) | 从开头 |
r+ | 读写 | 打开失败 | 从开头 |
w | 只写,且把原文件清空(截断) | 创建 | 从开头 |
w+ | 读写,且清空 | 创建 | 从开头 |
a | 只追加(写到文件末尾) | 创建 | 末尾(读的位置不在) |
a+ | 读写追加(写总是在末尾) | 创建 | 读从开头,写总是在末尾 |
几个容易踩的细节:
w会把文件截断成 0 字节再开始写,也就是说只要用w打开,旧内容就被清了。想保留旧内容就绝不能乱用w。a(append)永远不会覆盖已有内容,写到一半你怎么挪位置它都坚持"追加到末尾"。- Linux 世界里其实没有"文本文件 / 二进制文件"之分(
r里的那个"text"只是 C 标准的历史说法),读写都以字节为单位。后面你会看到,系统接口根本不区分这两者。
顺带一提,C 里还有一组配合定位用的函数:fseek(移动到指定位置)、ftell(取当前位置)、rewind(回到开头)。它们在 C 部分已学过,这里不过多展开,它们是"直接操作内容偏移"的一族,和后面要讲的 lseek 对应。
用 fwrite 写文件
我们写一个向文件里写 5 遍 "hello bit!\n" 的小程序:
// write_demo.c —— 用 fwrite 向文件写入内容
#include <stdio.h>
#include <string.h>
int main()
{
// "w" 模式:不存在则创建,存在则清空
FILE *fp = fopen("myfile", "w");
if (fp == NULL)
{
printf("fopen error!\n");
return 1;
}
const char *msg = "hello bit!\n"; // 要写的内容
int count = 5;
while (count--) // 循环 5 次
{
// fwrite(数据, 单个元素大小, 元素个数, 流)
// fwrite(msg, strlen(msg), 1, fp):写 1 个"长度为 strlen(msg)"的元素
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp); // 关闭并刷新缓冲区
return 0;
}请你特别注意 fwrite 的返回值语义:size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream),它返回的是"完整写入的元素个数"。这里 size=strlen(msg)、nmemb=1,也就是说把 "hello bit!\n"(12 字节)当作"一个元素",所以返回值要么是 1(成功),要么是 0(失败)——它返回的不是字节数。这一点和后面系统调用 write 的"返回字节数"形成了鲜明的对比,是新手最容易搞混的地方,先记住这个差异。
用 fread 读文件
读写对称,读文件用 fread。下面这段代码把 myfile 的内容读出来:
// read_demo.c —— 用 fread 读文件内容
#include <stdio.h>
#include <string.h>
int main()
{
// "r" 模式:只读,文件必须存在
FILE *fp = fopen("myfile", "r");
if (fp == NULL)
{
printf("fopen error!\n");
return 1;
}
char buf[1024]; // 存放读到的内容
// key:这里用 fread(buf, 1, strlen("hello bit!\n"), fp)
// 即每次都尝试读 strlen(...) 个"1 字节的元素"——这时返回值就等于字节数
size_t n = strlen("hello bit!\n");
while (1)
{
ssize_t s = fread(buf, 1, n, fp); // 返回实际读到的字节数
if (s > 0)
{
buf[s] = 0; // 手动补上字符串结束符
printf("%s", buf);
}
if (feof(fp)) // 判断是否读到文件末尾
{
break;
}
}
fclose(fp);
return 0;
}这一版代码有两个坑,需要你格外小心:
fread的返回值和参数匹配。fread(buf, 1, n, fp)中size=1、nmemb=n,所以返回的是"成功读到的元素个数",此时每个元素 1 字节,于是返回值恰好等于"读到的字节数"。若用fread(buf, n, 1, fp),语义就变成了"读一个 n 字节的元素",只要一次没凑够 n 个字节,返回就是 0,容易丢数据。简单项目里,读文本惯用fread(buf, 1, sizeof buf, fp),返回的 s 就是字节数。- 缓冲区越界 / 未补结束符。
buf[1024]是固定大小,读到的内容要自己buf[s] = 0补上\0再用printf("%s", ...),否则printf会一直往后读到内存里某个碰巧为 0 的字节为止,产生"越界读"。
把读文件这段稍微改改,就能写一个简易 cat 命令:
// mycati.c —— 一个简易 cat:./mycati 文件名,把内容打印出来
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if (argc != 2) // 只接受一个命令行参数
{
printf("argv error!\n");
return 1;
}
FILE *fp = fopen(argv[1], "r"); // 打开命令行里指定的文件
if (fp == NULL)
{
printf("fopen error!\n");
return 2;
}
char buf[1024];
while (1)
{
// 一次尽量多读:读满 buf(1024 字节),size=1 保证返回的是字节数
int s = fread(buf, 1, sizeof(buf), fp);
if (s > 0)
{
buf[s] = 0; // 补结束符
printf("%s", buf); // 打印
}
if (feof(fp)) // EOF 则结束
{
break;
}
}
fclose(fp);
return 0;
}编译并测试:gcc mycati.c -o mycati && ./mycati myfile,它能像 cat myfile 一样把文件内容打出来。
输出到显示器的多种方法
写文件用一个 FILE*,其实往"显示器"输出也是写文件——显示器被抽象成了一个流。C 库里往显示器输出有好几种写法:
// print_all.c —— 往 stdout 输出的几种方式
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg = "hello fwrite\n";
// 1) fwrite 显式指定 streams=stdout:写出 strlen(msg) 个字节
fwrite(msg, strlen(msg), 1, stdout);
// 2) printf 默认就往 stdout 输出,还支持格式化
printf("hello printf\n");
// 3) fprintf 显式指定流,等价于带格式化的 fwrite
fprintf(stdout, "hello fprintf\n");
return 0;
}这三种方式在你眼里"长得不一样",但本质是同一件事——都往 stdout 这个流上写。而 printf 不过是 fprintf(stdout, ...) 的便捷写法。
stdin / stdout / stderr
这就引出一个关键点:C 程序一启动,就默认打开了三个文件流,分别是 stdin(标准输入)、stdout(标准输出)、stderr(标准错误)。它们的类型都是 FILE*,和 fopen 的返回值类型一模一样:
#include <stdio.h>
// 这三个是 C 库声明的全局"文件流",类型都是 FILE*
extern FILE *stdin; // 标准输入:默认连着键盘
extern FILE *stdout; // 标准输出:默认连着显示器
extern FILE *stderr; // 标准错误:默认连着显示器(用于报错信息)为什么要分"标准输出"和"标准错误"两个都连着显示器的流?因为输出"正常结果"和"报错信息"在很多场景下是想分开的(比如你想让正常结果进文件、错误直接打到屏幕)。这个区分在我们的重定向和缓冲区课程里会反复用到。
思考题 2.1:
fwrite(msg, strlen(msg), 1, fp)的返回值可能是 1,也可能是 0,那么"1 和 0"分别代表什么?它和write(fd, msg, len)的返回值有什么本质区别?详解:前者
size=strlen(msg)、nmemb=1,返回值是"写成功的完整元素个数",所以 1 表示那strlen(msg)个字节作为一个元素完整写入了,0 表示没有完整写入。它不是字节数。后者(系统调用write)返回的是"实际成功写入的字节数",可能小于你要写的len(叫"部分写")。一个数"元素个数",一个数"字节数",这就是两者最容易弄混的地方。
系统文件 I/O:真正负责"打开文件"的是系统
前面我们反复强调"打开文件其实靠系统",现在来真的。除了 fopen 这套 C 接口(C++ 有 ifstream,别的语言也有自己的封装,但它们脚下踩的都是同一套东西),Linux 为我们提供了一组以 open / close / read / write / lseek 为代表的系统调用。先别急,做它之前,我们要先学会一个给函数传"标志位"的技巧,因为它是 open 的第二个参数的核心。
传递标志位的方法
需求是:我想给一个函数传一个"组合开关",它可以同时表示打开了好几个选项。最优雅的做法,是用一个整数,每一位代表一个选项——用按位或(|)组合,用按位与(&)判断:
// flags_demo.c —— 用"位"来传递多个标志
#include <stdio.h>
// 三个标志,各自占用一个二进制位,互不重叠
// 0001(即十进制 1)、0010(2)、0100(4)
#define ONE 0001 // 二进制 0001
#define TWO 0002 // 二进制 0010
#define THREE 0004 // 二进制 0100
// 注意:C 里以 0 开头的是八进制,0001=1、0002=2、0004=4
// func 根据 flags 里"置位"了哪些位来决定行为
void func(int flags)
{
if (flags & ONE) printf("flags has ONE! "); // 按位与:看 ONE 那一位是否为 1
if (flags & TWO) printf("flags has TWO! ");
if (flags & THREE) printf("flags has THREE! ");
printf("\n");
}
int main()
{
func(ONE); // 只带 ONE -> flags has ONE!
func(THREE); // 只带 THREE -> flags has THREE!
func(ONE | TWO); // 同时带 ONE 和 TWO
func(ONE | THREE | TWO); // 三者都带上
return 0;
}编译运行,你会看到:ONE | TWO 那两个选项同时生效了。这就是"用位传标志"的核心思想——每个选项占用唯一一个 bit,| 用来叠加,& 用来检测。open 的第二个参数 flags 正是这么设计的:O_RDONLY、O_WRONLY、O_CREAT、O_APPEND……每个都是独立的 bit,你用 | 把它们拼起来一起传给 open。理解了这一个技巧,后面 open 的代码就是顺水推舟。
用 open/write 写文件——第一份"系统口味"的代码
现在我们用系统接口,写前一节一模一样的"写 5 遍 hello":
// sys_write.c —— 用系统调用 open + write 写文件
#include <stdio.h>
#include <string.h>
#include <unistd.h> // 声明系统调用 write / close / read
#include <sys/types.h>
#include <sys/stat.h> // 声明 open 所需的一些类型
#include <fcntl.h> // 声明 open 以及 O_* 一系列标志
int main()
{
umask(0); // 不屏蔽任何权限位,让 open 的 mode 原样生效(稍后解释)
// open(文件名, 标志, 权限)
// O_WRONLY:只写打开;O_CREAT:文件不存在则创建
// 0644:创建新文件时的权限 = rw-r--r--(8 进制)
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0) // 系统调用失败返回 -1
{
perror("open"); // perror 根据 errno 打印出错原因
return 1;
}
int count = 5;
const char *msg = "hello bit!\n";
int len = strlen(msg);
while (count--)
{
// write(fd, 缓冲区地址, 想写的字节数)
// 返回值:实际成功写入的字节数
write(fd, msg, len);
}
close(fd); // 关闭文件描述符
return 0;
}注意,这里的 open 比 fopen"原始"很多:它返回的不是 FILE*,而是一个 int——这就是文件描述符,是后面整个章节的核心,先记住"open 成功返回一个小写整数,失败返回 -1"。
用 open/read 读文件
读文件的系统版本:
// sys_read.c —— 用系统调用 open + read 读文件
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
// 只读打开,O_RDONLY 就是"只读"
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
char buf[1024];
while (1)
{
// read(fd, 缓冲区, 想读的字节数)
// 返回值:实际读到的字节数;0 表示读到文件末尾(EOF);-1 表示出错
ssize_t s = read(fd, buf, sizeof(buf) - 1);
if (s > 0)
{
buf[s] = 0; // 补结束符
printf("%s", buf);
}
else if (s == 0) // EOF
{
break;
}
else // 出错
{
perror("read");
break;
}
}
close(fd);
return 0;
}接口总览:open / close / read / write / lseek
把这几个系统调用放到一起看,它们和 C 接口几乎一一对应:
// 头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// 打开:成功返回一个文件描述符,失败返回 -1
// 两个参数版本:open(pathname, flags)
// 三个参数版本:open(pathname, flags, mode) —— 当用到 O_CREAT 时必须用这个
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 关闭:成功返回 0,失败返回 -1
int close(int fd);
// 读:返回实际读到的字节数;0=EOF;-1=出错
ssize_t read(int fd, void *buf, size_t count);
// 写:返回实际写出的字节数;可能小于 count(部分写)
ssize_t write(int fd, const void *buf, size_t count);
// 移动读写位置:成功返回新偏移,失败返回 -1
off_t lseek(int fd, off_t offset, int whence); // whence: SEEK_SET/SEEK_CUR/SEEK_ENDopen 的 flags,你最要紧的是这几个:
O_RDONLY只读、O_WRONLY只写、O_RDWR读写——这三个必须指定一个,且只能指定一个。O_CREAT:文件不存在则创建。一旦用了它,就必须提供第三个参数mode来指定新文件的权限。O_APPEND:追加写——每次写都从文件末尾开始。- (还有一个后面讲
CLOSE_ON_EXEC时会见到的O_CLOEXEC,先埋个伏笔。)
mode 是权限位,最常见的几个:0644 = rw-r--r--(所有者读写,其它只读),0666 = rw-rw-rw-。这里有个隐藏的坑叫 umask:进程有一个"权限掩码",open 创建的文件,实际权限 = mode & ~umask。比如系统默认 umask 是 022,那么 open(..., 0666) 实际得到的是 0666 & ~022 = 0644。上面代码里的 umask(0) 就是把掩码清零,让权限原样生效,方便你精确观察。现实中服务器往往不让你改 umask,所以别以为 open 传了 0666 就一定是 0666。
返回值与错误处理:一切系统调用的共同语言
这是新手最容易忽略、却又最能区分"懂不懂系统编程"的地方。系统调用的返回值和 C 库函数有一个巨大的不同:它们统一用"负一 + 全局错误码 errno"报告错误。
- 成功:返回一个非负值(如打开的文件描述符、读到的字节数)。
- 失败:返回
-1,同时把errno(一个全局整数)设置成具体的错误码(如ENOENT文件不存在、EACCES权限不足),并在函数内部用perror/strerror把它翻译成人类可读的字符串。
所以几乎每个系统调用的返回值,都应该先判一下 < 0,像上面每段代码里的 if (fd < 0) { perror(...); return ...; } 那样。不检查返回值,是系统编程里最危险的坏习惯之一——open 失败了你还拿着 -1 去 write,只会得到另一个莫名其妙的错误。
思考题 3.1:
fopen失败返回NULL,open失败返回-1,这个"返回值的哲学"有什么共同点?为什么要统一用负一而不是随便一个数表示失败?详解:共同点是"用一个不可能出现在合法结果里的值来表示失败"。
open成功的结果是"文件描述符",它必然是一个非负整数(0、1、2、3……),而 -1 永远不可能是合法的文件描述符,所以用 -1 表示失败就是数学上无歧义的选择。同理fopen返回到是FILE*指针,NULL地址永远不会是合法的文件指针,所以用 NULL 表示失败。read返回字节数也是非负,所以 -1 表示失败、0 表示 EOF 而不会混淆。用"不在值域内的特殊值"表示错误,是 C 风格的通用约定。
文件描述符与重定向的本质
现在我们终于要正面认识本节的主角——文件描述符(file descriptor,简称 fd)。
一句话:文件描述符就是一个"小整数",它是进程打开的文件在"文件描述符表"里的下标。有了它,进程——以及内核——就能在茫茫文件里精确指出"我说的是哪一个"。这也是为什么 open 成功返回一个 int 而不是什么复杂的结构。
文件描述符 0、1、2
就像 C 程序启动默认打开三个流(stdin/stdout/stderr)一样,每个 Linux 进程一启动,就默认打开了三个文件描述符:
| 文件描述符 | 名字 | 默认设备 | 对应的 C 流 |
|---|---|---|---|
| 0 | 标准输入 stdin | 键盘 | stdin |
| 1 | 标准输出 stdout | 显示器 | stdout |
| 2 | 标准错误 stderr | 显示器 | stderr |
这三个数字 0、1、2 是铁打约定,头文件 <unistd.h> 里用 STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO 三个宏替你把它们记好了。既然 0/1/2 已经是标准输入输出,那么你之后用 open 打开的第一个新文件,拿到的文件描述符就从 3 开始。
知道了 0、1、2,我们甚至不需要 fopen,直接用系统调用往键盘读、往显示器写:
// std_fd.c —— 直接用 fd 0/1/2 完成"从键盘读、往屏幕写"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
char buf[1024];
// fd 0 是标准输入(键盘)。read(0, ...) 读键盘输入
ssize_t s = read(0, buf, sizeof(buf));
if (s > 0)
{
buf[s] = 0; // 补结束符
// fd 1 是标准输出(显示器)
write(1, buf, strlen(buf));
// fd 2 是标准错误(也是显示器)
write(2, buf, strlen(buf));
}
return 0;
}运行它,敲几个字回车,屏幕上会把同样的内容打两遍(一遍走 fd 1,一遍走 fd 2)。这直观证明:printf 的底层就是往 fd 1 上 write,fprintf(stderr,...) 的底层是往 fd 2 上 write。
文件描述符到底是什么:内核视角
我们口口声声说 fd 是"小整数",它凭什么是小整数?这要从内核为管理打开的文件所维护的结构说起,推荐你用 uname -a 看内核版本,然后在 /usr/src/kernels/ 里找到对应版本的内核源码来对照(下面给出的是 3.10 内核中的路径):
- 每个进程都有这样一个大结构体
struct task_struct(进程控制块,PCB):/usr/src/kernels/<版本>/include/linux/sched.h - 它里面有一个指针
*files,指向一张表struct files_struct:/usr/src/kernels/<版本>/include/linux/fdtable.h files_struct中最重要的部分是一个指针数组,数组的每个元素都指向一个"已打开文件对象"struct file:/usr/src/kernels/<版本>/include/linux/fs.h
于是整个逻辑串起来了:
进程 (task_struct)
│ *files
▼
files_struct ── 指针数组 ──► [0] -> file{...} (标准输入)
[1] -> file{...} (标准输出)
[2] -> file{...} (标准错误)
[3] -> file{...} (你 open 的 myfile)
...
所以"文件描述符本质就是那个指针数组的下标"。系统一拍脑袋就知道:拿着 fd=3,就是在说"数组里下标 3 那个元素指向的 file",而那个 file 结构体里记录了这个文件的全部状态(当前读写位置 f_pos、权限、引用计数 f_count 等)。你返回给用户层的 fd,其实是一个"门牌号",方便用户层和内核用一个整数指路。
(现代内核用一个更精确的层次:fd 表条目指向"打开文件描述 open file description",再由它指向 inode;多个 fd 可以共享同一个 open file description,这也是后面 dup2 重定向、以及"两次 open 同一个文件偏移是否独立"的关键。入门掌握到"fd 是数组下标、指向 file"这一层就足够。)
fd 的分配规则:从 3 开始
为什么你 open 一个文件,拿到的就是 3?因为规则是:在 fd 表里找到"当前没有被使用的、最小的那个下标",把它作为新 fd。
验证一下:
// fd_rules.c —— 观察 fd 分配规则
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
int main()
{
// 此时 0/1/2 都已被占,所以第一个 open 拿到最小空闲下标 3
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 fd: 3
close(fd);
return 0;
}如果我先 close(0) 或 close(2),把 0 或 2 空出来,那 open 就会捡到那个更小的空位:
// fd_rules2.c —— 关闭 0 或 2 后再 open,观察 fd 会变成 0 或 2
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
int main()
{
close(0); // 先把 0 号让出来
// close(2); // 换成关 2 也一样,测试时两个二选一
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 由于 0 被关闭,open 捡到最小的空位 0 -> 输出 0
close(fd);
return 0;
}你会看到输出变成了 fd: 0(或 fd: 2,取决于你关的是哪个)。"最小可用下标"这条分配规则,正是下面所有重定向技巧的基础——因为它意味着"只要你腾出一个位置(比如关掉 1),下一次 open 就一定占用这个位置"。
"系统调用 vs 库函数"那张图
在讲重定向前,我们把关系图定格一下:
用户程序(fopen / fwrite / printf ...) ← 库函数(libc)
│
▼
系统调用接口(open / read / write / close) ← 内核提供的接口
│
▼
内核完成真正的 IO(驱动 + 外设)
fopen、fclose、fread、fwrite 这些以 f 开头的 C 库函数,本质上都是对系统调用的一层封装。它们为什么存在?为了方便——带了缓冲区、带了格式化、降低了出错概率。你可以把 C 库函数理解成"给系统调用套了一层人话壳",系统调用则是内核真正的门面。fd 是这条链里用户层最终拿到的"凭证":C 库函数再怎么封装,最终向内核伸手要的还是 fd。
重定向的本质:替换 fd 1 指向的 file
现在,奇迹时刻。如果我把 fd 1 关掉,再 open 另一个文件,会发生什么?
// redirect_first.c —— 关掉 stdout,观察输出"变形"
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
close(1); // 关上标准输出 fd 1
// 因为 1 空了,open 最小可用下标规则生效:fd 一定拿到 1!
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd); // 注意:这条 printf 会打进 myfile!
fflush(stdout); // 强制刷新,确保内容真的落到文件
close(fd);
exit(0);
}运行后你会发现:屏幕上什么都没打,但 cat myfile 一看,fd: 1 和 printf 的其它内容都跑到文件里去了!
这就是著名的输出重定向。原理一句话:printf 底层往 fd 1 上写,而调用 open 时,因为 fd 1 恰好是"最小可用下标",所以 myfile 就顶替了原来显示器的位置,占据了 fd 1。于是"往标准输出写"就变成了"往 myfile 里写"。常见 shell 重定向 >(覆盖)、>>(追加)、<(输入)全是这个机制。
但注意上面对 fflush(stdout) 的处理——printf 是有用户级缓冲的,后面讲缓冲时你会彻底明白为什么这里不加 fflush 内容可能"消失"。现在先记住这个"钩子",我们把它留到缓冲区章节引爆。
思考题 4.1:为什么上面这段代码里
open一定拿到 fd 1,而不是 3 或别的数?详解:因为文件描述符采用"最小可用下标"分配规则。程序启动时 0、1、2 都被占用;
close(1)之后,下标 1 变成了整个表里最小的空位,于是open必然在这个空位新建条目,即返回 1。这正是重定向能成立的根本原因——只要腾出某个固定下标(1 或 2 或 0),下一次open就会"恰好"占用它。
用 dup2 系统调用,把重定向写得更专业
你可能会说:为了重定向还得先 close(1),万一期间别人又 open 了一个文件把最小空位抢走,不就乱了?没错,所以工程上并不裸用这套"关掉再 open",而是用专门的系统调用——
#include <unistd.h>
// 把 oldfd 这个描述符"复制"到 newfd 这个描述符上
// 效果:newfd 从此和 oldfd 指向同一个"打开文件对象"
int dup2(int oldfd, int newfd);dup2 的语义非常干净,三个要点:
- 让
newfd成为oldfd的"复制",两者指向同一个file(共享一个读写位置)。 - 如果
newfd本来是打开的,会自动先把它关掉(静默地)。 - 如果
oldfd == newfd,什么都不做,直接返回newfd。
用 dup2 重定向标准写法是:先 open 拿到一个高一点的正数 fd(比如 3),再用 dup2(3, 1) 把文件搬到 fd 1 上,再 close(3) 释放临时 fd。因为 dup2 会先关掉旧的 fd 1,所以不需要手动 close(1),且这个过程是原子的,安全许多。而且用 dup2 重定向后,缓冲相关的情形也比"直接 close(1) 再 open"更贴近真实 shell(下面缓冲区章节你还会再遇到它)。
演示一个"把 stdin 重定向到文件、把 stdout 重定向到 log"的小循环:
// dup2_demo.c —— 用 dup2 把 stdin 重定向到 stdin.txt,把 stdout 重定向到 log
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
// 打开输入文件;此时 fd 至少是 3(0/1/2 已占用)
int in = open("stdin.txt", O_RDONLY);
int out = open("log", O_CREAT | O_RDWR | O_TRUNC, 0666);
if (in < 0 || out < 0)
{
perror("open");
return 1;
}
dup2(in, 0); // 让 fd 0 指向 in —— 从此"从标准输入读"= 从 stdin.txt 读
close(in); // 释放临时 fd 3
dup2(out, 1); // 让 fd 1 指向 out —— 从此"printf"= 写进 log
close(out);
// 循环读文件,printf 往 stdout 打,但 stdout 已经是 log 了
char buf[1024];
for (;;)
{
ssize_t n = read(0, buf, sizeof(buf) - 1); // 读的是 stdin.txt
if (n <= 0) break;
buf[n] = 0;
printf("%s", buf); // 写的是 log
fflush(stdout); // 刷掉 printf 的用户缓冲
}
return 0;
}准备好一个 stdin.txt(里面写几行文字),运行后会发现屏幕没有输出,但 log 里出现了 stdin.txt 的内容。输入重定向和输出重定向,本质都是同一件事——通过 dup2 更改某个固定 fd(0/1/2)指向的"打开文件对象"。
亲手实现一个带重定向的 minishell
把上面的知识串起来,我们写一个真正能用的迷你 shell,支持 cd、> file、>> file、< file:
// myshell.c —— 带输入/输出/追加重定向的迷你 shell
// gcc -o myshell myshell.c && ./myshell
#include <ctype.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
// 四种重定向状态:无、输入(<)、输出(>)、追加(>>)
enum { NONE, INPUT, OUTPUT, APPEND };
int main()
{
char line[1024];
for (;;)
{
printf("[my-shell]# "); // 命令提示符
fflush(stdout); // 提示符要立刻显示,手动刷新(重点!后面讲缓冲)
// 从标准输入读一行命令,读到 EOF(Ctrl+D)则退出
if (fgets(line, sizeof(line), stdin) == NULL)
break;
line[strcspn(line, "\n")] = 0; // 去掉行尾换行符
if (line[0] == 0)
continue; // 空行跳过
// ---------- 第一步:解析重定向符号 ----------
int redir = NONE; // 默认无重定向
char *filename = NULL; // 重定向目标文件名
char *p = line + strlen(line) - 1; // 从行尾往前找重定向符
while (p >= line)
{
if (*p == '<') // 输入重定向
{
redir = INPUT;
*p = 0; // 断开命令行与文件名的连接
filename = p + 1;
break;
}
else if (*p == '>')
{
if (p > line && *(p - 1) == '>') // 连续的 >> 是追加
{
redir = APPEND;
*(p - 1) = 0; // 把两个 '>' 都断开
*p = 0;
}
else
{
redir = OUTPUT;
*p = 0;
}
filename = p + 1;
break;
}
p--;
}
if (filename)
while (isspace((unsigned char)*filename)) filename++; // 去掉文件名前空格
// ---------- 第二步:解析命令与参数 ----------
char *argv[64];
int argc = 0;
char *tok = strtok(line, " "); // 逐空格切分命令和参数
while (tok)
{
argv[argc++] = tok;
tok = strtok(NULL, " ");
}
argv[argc] = NULL; // argv 必须以 NULL 结尾
if (argc == 0)
continue;
// ---------- 内建命令:必须由 shell 自己执行(因为要影响 shell 自身)----------
if (strcmp(argv[0], "cd") == 0)
{
if (argc == 2 && chdir(argv[1]) == -1) perror("cd");
continue;
}
// ---------- 第三步:fork 子进程执行外部命令 ----------
pid_t pid = fork();
if (pid == 0) // 子进程
{
// 子进程里做重定向:只会影响子进程,不污染父 shell(重定向要在子进程做!)
int fd;
if (redir == INPUT)
{
fd = open(filename, O_RDONLY);
if (fd < 0) { perror("open"); exit(2); }
dup2(fd, 0); // 把该文件搬到标准输入
close(fd);
}
else if (redir == OUTPUT)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if (fd < 0) { perror("open"); exit(3); }
dup2(fd, 1); // 搬到标准输出:之后的 printf 全进文件
close(fd);
}
else if (redir == APPEND)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
if (fd < 0) { perror("open"); exit(4); }
dup2(fd, 1);
close(fd);
}
// 换掉当前进程镜像,执行真正的命令
execvp(argv[0], argv);
perror("execvp"); // 只有 exec 失败才走到这
exit(1);
}
// 父进程 shell 等待子进程运行结束
waitpid(pid, NULL, 0);
}
return 0;
}这个 shell 只有两三百行,却已经能干不少实事了:
$ gcc -o myshell myshell.c && ./myshell
[my-shell]# ls -l # 普通命令,正常执行
[my-shell]# echo hello > out.txt # 输出重定向:当前没有内建 echo,交给系统 /usr/bin/echo
[my-shell]# cat out.txt # 读回来:hello
[my-shell]# ls >> out.txt # 追加重定向
[my-shell]# wc < out.txt # 输入重定向
[my-shell]# cd /tmp && pwd # 内建 cd(注意 cd 必须 shell 自己执行)
[my-shell]# exit (Ctrl+D)两个关键设计请你务必体会:
- 重定向必须在子进程里做,不能由父 shell 做。因为如果 shell 自己把 fd 1 改了,那它自己接下来的提示符输出也会跑进文件,整个 shell 就废了。fork 出的子进程"复制"了父进程的 fd 表,子进程里改 fd 1 只影响自己,exec 之后这个重定向就由被替换的程序继承,父 shell 完全不受影响——所以
DoRedir()那块逻辑必须放在pid == 0的子进程分支里。 - 程序替换(exec)不影响重定向。前面讲过 exec 会把当前进程的代码和数据整个换掉,但打开的 fd 不会被关(默认),所以 exec 之后的
ls一往 fd 1 写,就会写进你 redirect 好的文件里。
思考题 4.2:为什么
cd必须由 shell 自己(内建)执行,而不能 fork 一个子进程去chdir?详解:
chdir改变的是"进程自己的工作目录",而 fork 出的子进程有自己独立的一份内存和状态(chdir 的结果是进程级状态,不随 exec 传递也不写回父进程)。如果cd开一个子进程去执行,改变的是子进程的 cwd,父 shell 的工作目录纹丝不动,用户的cd就"没反应"。所以cd、export这类改变"shell 自身状态"的命令,必须由 shell 进程自己调用对应的系统调用直接执行,这就是"内建命令(builtin)"存在的原因。
CLOSE_ON_EXEC:exec 之后 fd 的归宿
上面说"exec 之后 fd 默认不被关闭",这句话有个重要的坑,就是文件描述符的 close-on-exec(关闭时执行,FD_CLOEXEC)标志。
默认情况下,一个进程 open 出来的所有 fd,在 fork + exec 后都会被原样继承给新程序。如果这个新程序是可信任的、我们需要它继承 fd(这正是重定向的机理),那就没问题;但如果我们在一个"服务型"进程里打开了一些敏感文件/网络套接字,再 fork+exec 一个不可控的程序,那些 fd 就会泄露给它——这是典型的安全漏洞(资源泄漏、权限提升、chroot 逃逸都与此相关)。此外,哪个 fd 会被继承继承到什么时候,大家心里没数,也容易出资源耗尽(EMFILE,打开文件数超限)的问题。
解决办法是给 fd 打上 FD_CLOEXEC 标志:任何带这个标志的 fd,在成功调用 exec 时会被内核自动关闭;如果 exec 失败,则保持打开。
设置它有两种方式:
// cloexec.c —— 设置/清除"close-on-exec"标志
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
// 方式一(推荐,原子):在 open 时顺手带上 O_CLOEXEC
// Linux 2.6.23 起支持。exec 后这个 fd 自动关闭
int fd = open("log", O_CREAT | O_RDWR | O_CLOEXEC, 0644);
// 方式二:打开后用 fcntl 单独设置 FD_CLOEXEC
// F_GETFD 取当前"文件描述符标志",F_SETFD 设置它
int flags = fcntl(fd, F_GETFD); // 读当前的 fd 标志
flags |= FD_CLOEXEC; // 加上 close-on-exec 这一位
fcntl(fd, F_SETFD, flags); // 写回去
// 清除同理:flags &= ~FD_CLOEXEC; 再 fcntl(F_SETFD, flags) 即可
close(fd);
return 0;
}两个细节必须澄清:
- 在早期版本/多线程下,方式二的
open+fcntl两步之间有一个竞态窗口,别的线程若恰好在这两步之间 fork+exec,fd 就泄露了。所以现代 Linux 更推荐用O_CLOEXEC一步原子搞定。 dup/dup2/fcntl(F_DUPFD)复制出来的新 fd,会"清零"FD_CLOEXEC(这是 POSIX 硬性规定)。这恰好是重定向需要的:我们dup2(fd, 1)出来的新 fd 1 不应带 close-on-exec,否则 exec 时重定向就没了。反过来,那些你不想传给子程序的高位 fd,请记得设O_CLOEXEC,这才是一个健壮服务该做的。
回到我们的 minishell:那里我们把 fd 打开后做了 dup2 再 close(fd),等于没留下多余的高位 fd,所以即使不说 O_CLOEXEC 也是干净的。而一个真正的守护进程(长期收请求、fork 处理)如果不加 O_CLOEXEC,是会埋下 fd 泄漏隐患的。
思考题 4.3:在我的 minishell 里,子进程
exec外部命令,需要给重定向出的 fd 1 设置FD_CLOEXEC吗?为什么?详解:不需要,而且绝对不能设。我们想让外部命令(如
ls)的printf/write写进重定向后的目标文件,就是靠"fd 1 在 exec 后依然打开并指向那个文件"来实现的。若给 fd 1 加了FD_CLOEXEC,exec 成功那一刻 fd 1 就被自动关掉,重定向立刻失效——ls > out.txt的 out.txt 里什么都看不见。这正是"dup 出的新 fd 会清除 FD_CLOEXEC"这一设计存在的意义:重定向的 fd 要被保留,而临时的高位 fd 我们手动 close 掉了,两不冲突。
理解"一切皆文件"的内涵
前面第一节我们埋了个概念,现在回来取它:为什么 Linux 能"一切皆文件"?做到这一点的关键,是内核里两个结构——struct file 和 struct file_operations。
file 结构体:一个"已打开文件对象"
当进程 open 一个文件时,内核会为这个文件创建一个 struct file 结构体(也常叫 open file description)。它里面记录了这个文件被打开后的各种运行状态,我们挑重点看(新版内核字段相似):
// 这是 3.10 内核 include/linux/fs.h 中 struct file 的一部分(字段示意)
struct file {
...
struct inode *f_inode; // 指向 inode(文件的"元数据档案",含大小、权限等)
const struct file_operations *f_op; // 关键!指向这个类型文件的操作函数表
...
atomic_long_t f_count; // 引用计数:有几个 fd 在引用这个打开对象
unsigned int f_flags; // 打开时用的标志(O_RDONLY/O_APPEND...)
fmode_t f_mode; // 访问模式(只读/只写/读写),定义在 <fcntl.h>
loff_t f_pos; // 当前读写位置(偏移)
...
};对它理解最自然的方式是:f_pos 是"读写位置",f_mode 是"读写权限",f_count 是"几个身子在共用这一份文件状态"。当多个 fd(比如 dup2 出来的)指向同一个 file 时,它们的 f_pos 是共享的;而两次独立的 open 同一个文件,则产生两个独立的 file,各有一个独立的 f_pos——这就是为什么两次打开同一个文件、读位置不共享。
file_operations:把系统调用和具体设备"焊"在一起的函数指针表
struct file 里最值得关注的成员是 f_op。f_op 是指向 struct file_operations 的指针,而 struct file_operations 里几乎全是函数指针:
struct file_operations {
struct module *owner; // 所属内核模块
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);
int (*readdir)(struct file*, void*, filldir_t); // 读目录(仅文件系统用)
...
int (*open) (struct inode*, struct file*); // 打开
int (*release)(struct inode*, struct file*); // 关闭时的回收
int (*fsync) (struct file*, struct dentry*, int); // 强制刷盘
...
};这一张函数指针表,就是**"一切皆文件"的核心机制**:每种类型的"文件"(普通磁盘文件、目录、管道、设备、socket、proc 虚拟文件)都有自己的一套 read/write/open/... 实现,通过 f_op 里的函数指针挂载到统一的 struct file 上。
当用户调用 read(fd, ...) 时,内核顺着 fd → file → f_op->read 找到这个具体文件自己实现的读函数,把控制权转交给它。于是对普通文件的 read 走设备驱动,对管道的 read 走管道实现,对 proc 文件中某个虚拟项的 read 走对应的内核函数——对用户来说,统统都是 read(fd, buf, n) 这一个接口。
这就是"Linux 下一切皆文件"的一半真相:开发者只需一套 open/read/write/close 的 API,就能操作系统中绝大部分资源。多样在底下被 file_operations 这一张"总分发表"隐藏了,统一暴露给用户。将来学套接字(socket)编程时你会看到,它的接口和文件接口也是同构的。
思考题 5.1:既然键盘、显示器、普通文件、socket 的
read/write内部实现各不相同,为什么用户代码永远只写read(fd,...)/write(fd,...)?详解:因为统一由
struct file_operations这张函数指针表来"分发"。每个 fd 对应的struct file里有f_op指针,指向该类型资源自己的操作函数表。系统调用进入内核后,不需要知道你是磁盘、网卡还是管道,它只需要顺着fd → file → f_op->read/write找到对应实现并调用即可。把"差异"封装在每种资源的驱动实现里,把"统一"暴露给开发者,这就是"一切皆文件"优雅的地方。
缓冲区:库函数与系统调用的分水岭
终于到了全网吐槽最多的部分。很多人学到这里依然一脸懵:为什么我明明 printf 了,屏幕上(或文件里)却什么都没有?为什么重定向到文件后内容会"迟到"甚至"横空冒出两份"?这一切的谜底,都是缓冲区。
什么是缓冲区
一句话:缓冲区就是内存里预留出来的一小块存储空间,用来暂存输入输出的数据。对输入设备服务的那块叫输入缓冲区,对输出设备服务的那块叫输出缓冲区。
printf("abc") 之后,这些字符其实不一定立刻跑到显示器上——它可能先被丢进一个用户空间的小数组里攒着,等到合适的时机才一次性交给显示。
为什么要引入缓冲区
核心就一个词:效率。
每一次 write() 都是一次系统调用,一次系统调用要经历"用户态 → 内核态"的状态切换、进程上下文切换,这非常贵。如果你要往磁盘写 1 万个"一个字节地写",就触发 1 万次系统调用,任务管理器都能看到 CPU 在空转。
而有了缓冲区,C 库可以把好多次小写入攒成一大块,用极少几次(甚至一次)write 批量交给内核。同理从磁盘读也可以一次扛一大块进内存,后续访问直接命中内存,速度远超磁盘。
再者,缓冲区还充当了高速 CPU 与慢速外设之间的"缓冲垫":比如打印机能慢慢打印,CPU 把文档一股脑丢进打印机缓冲区就回去干别的了,不必傻等到打印完。总而言之:
缓冲区 = 内存中的一块蓄水池。它牺牲一点点实时性,换取大幅降低系统调用次数、让 CPU 和外设各自舒服地干活。
三种缓冲类型
标准 IO(stdio)提供三种缓冲模式(对应头文件里的宏 _IONBF / _IOLBF / _IOFBF):
| 模式 | 宏 | 什么时候真正执行系统调用 | 默认用在谁身上 |
|---|---|---|---|
| 全缓冲 | _IOFBF | 缓冲区填满才写 | 普通的磁盘文件 |
| 行缓冲 | _IOLBF | 遇到换行符 \n(或缓冲区满)才写 | 关联到终端的标准输入/输出(如 stdout 连显示器时) |
| 无缓冲 | _IONBF | 每写一个字节立刻调系统调用 | 标准错误 stderr |
决定"用哪种缓冲"的,不是函数,而是这个流背后接的是什么设备。Glibc 的典型行为(也是 POSIX 的约定)是:
stderr永远无缓冲——正因为如此,错误信息能第一时间打出来,哪怕程序下一秒崩了。stdout如果连着终端(显示器),就是行缓冲——所以你在终端里printf("hello\n"),那行立刻出现。stdout一旦被重定向到文件(或管道),就变成全缓冲——这就是"重定向后内容迟到/消失"的根源,也是本节最大的那个坑。stdin总是有缓冲。
关于缓冲区的"大小",严格来说 C 标准并没有规定一个固定值,它由实现决定。Glibc 里 BUFSIZ 这个宏默认是 8192;实际运行时,行缓冲接到终端时常用 1024 字节,接到普通文件时常见 4096 左右(大体和内核页大小相关)。所以不用死记"1024"或"8192"这种数字——它随实现而变,重要的是"三种模式的逻辑"。想手动改,用 setvbuf/setbuf,且必须在第一次对该流 IO 之前调用。
除了默认的三条规则,还有三种特殊情形会强制刷新缓冲区:
- 缓冲区满了;
- 显式调用
fflush(流); - 进程正常结束(退出,会自然冲刷所有流的缓冲)。
重定向后"缓冲区未刷"的大坑
现在引爆第一节留的那个钩子。看这段代码——本来想靠"关 1 再 open"把 printf 的内容写进文件:
// buff_unflush.c —— 演示重定向到文件后,printf 的内容"消失"了
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
close(1); // 腾出 fd 1
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
printf("hello world: %d\n", fd); // 想让它写进 log.txt
close(fd);
return 0;
}运行后你却发现:
$ gcc buff_unflush.c -o myfile && ./myfile
$ cat log.txt
$ # 空的!内容不见了!为什么?因为我们把 fd 1 从"显示器"换成了"磁盘文件",于是 stdout 的缓冲模式从行缓冲变成了全缓冲。printf 的内容进了用户缓冲区,而这条 hello world: %d\n 远没填满缓冲区,既不满足"满",进程里那点数据又因为……等等,你说进程不是正常退出会刷吗?
这里的关键是:进程"正常 return"确实会刷,但我们在 return 0 之前先 close(fd) 了,把那个指向文件的 fd 1 提前关掉,并且没有刷新——此时用户缓冲区里攒着的数据因为流还没刷出来、而所在的底层 fd 已被关闭,内容就丢掉了。换句话说:关闭底层 fd 之前,一定要先让该流把缓冲冲刷出去。
那正确的写法是什么?两个:
方案一:显式 fflush(stdout),在 close 之前强制冲刷:
// buff_flush.c —— fflush 强制把缓冲写出去
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
printf("hello world: %d\n", fd);
fflush(stdout); // 强制把 stdout 缓冲里的内容刷进 log.txt
close(fd);
return 0;
}现在 cat log.txt 能看到内容了。
方案二:利用"stderr 无缓冲"来当"及时输出"的验证——把目标换成 fd 2(标准错误),用 perror(它往 stderr 写)来输出。因为 stderr 无缓冲,所以不需要 fflush 就能立刻写入文件:
// buff_nobuf.c —— stderr 无缓冲,perror 立即写出
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
close(2); // 腾出 fd 2
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
perror("hello world"); // perror 写 stderr -> 现在 stderr 指向 log.txt
close(fd);
return 0;
}运行后:
$ ./nobuf
$ cat log.txt
hello world: Success两相对比,你就彻底看清了"行缓冲 vs 无缓冲"的差异:往 stdout 写需要手动/按行刷新,往 stderr 写则水只要冒出来就立刻落地。
思考题 6.1:把 stdout 重定向到文件后,一条不含
\n的printf("hi"),在进程直接 return 正常退出的前提下,会不会出现在文件里?详解:会。进程正常退出(
return 0或exit(0))时会冲刷所有打开的流的缓冲区,哪怕那条内容没填满缓冲区、也没有换行符。但要注意,如果进程是用_exit(0)退出(注意下划线,它不经过 libc 的清理流程)或直接崩溃、或被SIGKILL杀掉,则缓冲未必会刷,内容就可能丢失。所以"正常 return 会刷"是可依赖的,但"异常终止不刷"也是最常见的丢数据坑。
FILE 结构体里封装的 fd 与缓冲区
既然 C 库要自己管理"缓冲区",那缓冲区放哪?答案在 FILE 结构体里。FILE 是 struct _IO_FILE 的别名(/usr/include/stdio.h 与 /usr/include/libio.h),它把"底层 fd + 一块用户缓冲"封装成了一个小对象。我们拣关键成员看:
// 这是 glibc 中 struct _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; // 输出缓冲末尾
char *_IO_buf_base; // 整体缓冲起始
char *_IO_buf_end; // 整体缓冲末尾
...
int _fileno; // ★ 封装的底层文件描述符!
};
typedef struct _IO_FILE FILE; // 对外就叫 FILE看到没——_fileno 就是那个 fd!所以 fopen 拿到一个 FILE*,本质上就是"拿到一个封装了 fd + 缓冲区的小对象"。printf 先往 stdout->_IO_write_ptr 指的那块内存攒,攒够了(或遇到 \n、或 fflush)再调用内核的 write(STDOUT_FILENO, ...)。这就是"库函数的缓冲是二次加上去的"最直接证据:write 系统调用本身没有用户级缓冲,缓冲区是 C 库在它上面套的一层"缓存"。
顺带一提,int fileno(FILE* stream) 那个函数,就是干"从 FILE 里取回 fd"的,它是验证"FILE 里藏着 fd"这句话的钥匙。
fork 与缓冲的经典坑:内容凭空多一份
把缓冲区、fork、写时拷贝放在一起,就是本节压轴戏。看这段代码:
// fork_buf.c —— printf/fwrite(有缓冲)+ write(无缓冲)+ fork
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
int main()
{
const char *msg0 = "hello printf\n";
const char *msg1 = "hello fwrite\n";
const char *msg2 = "hello write\n";
printf("%s", msg0); // C 库,有用户缓冲
fwrite(msg1, strlen(msg1), 1, stdout); // C 库,有用户缓冲
write(1, msg2, strlen(msg2)); // 系统调用,无缓冲,立刻进内核
fork(); // 关键:fork 一个子进程
return 0; // 父、子各自正常退出
}先直接跑,输出是这样(注意顺序):
$ gcc fork_buf.c -o hello && ./hello
hello printf
hello fwrite
hello write看起来很正常。但如果输出重定向到文件再跑呢?
$ ./hello > file
$ cat file
hello write
hello printf
hello fwrite
hello printf
hello fwrite你能数出大事了:hello write 只出现一次,但 hello printf 和 hello fwrite 各出现了两次!而且顺序也变了(write 跑到最前面)。这到底为什么?
我们用这节课的知识一点点拆(这是全篇含金量最高的一个案例,请务必看懂每一层):
-
直接跑(终端)时为什么正常? 因为 stdout 连着终端 = 行缓冲,
printf和fwrite里都有\n,一碰到换行符就立刻刷新了,用户缓冲区是空的。然后write直接进内核。此时 fork 时缓冲区已空,父、子都没东西可刷,于是各输出一次,共一次。一切正常。 -
重定向到文件时发生了什么? stdout 从"终端"变"普通文件",缓冲模式从行缓冲变全缓冲。于是
printf和fwrite的内容"hello printf\nhello fwrite\n"并没有立刻进内核,而是积压在用户缓冲区里没刷出来。而write(1, ...)是无缓冲的系统调用,立刻进内核写进了文件——所以文件里hello write出现在最前面。 -
fork()是分水岭。 fork 会复制父进程的整个地址空间(含用户缓冲区)。此时用户缓冲区里还躺着"hello printf\nhello fwrite\n"这份数据,于是父、子各复制了一份一模一样的缓冲——这就是"写时拷贝":fork 那一刻数据共享,一旦谁要改写就各自独立,最终两份。 -
父、子进程最终都正常退出,各自把那份缓冲冲刷进文件。 于是
hello printf\nhello fwrite\n写了两次,正正好多了一份! -
为什么
write只一次? 因为write没有用户缓冲,数据在 fork 之前已经进了内核(文件里),fork 复制的是用户态的缓冲,跟它无关。所以它老老实实只有一次。
这个坑在真实项目里排得非常痛苦("我的日志怎么打印了两遍?"),而病因就这么一套:库函数缓冲 + fork 写时拷贝 + 进程退出统一刷新。规避的关键动作:
- 需要精确可靠的控制时,在
fork之前就fflush掉所有有缓冲的流,把"残留数据"清干净。 - 或者干脆这个程序里别在 fork 前往带缓冲的流里写关键数据。
思考题 6.2:上例中把
printf的输出改成不含\n的"hello",然后不重定向(即 stdout 还是连显示器),直接跑并重定向到文件,两种情况分别是什么表现?详解:分两种情况说。 (a)stdout 连显示器(不重定向)时:是行缓冲,但没有
\n,缓冲不会按行刷;不过别忘了个例外——若缓冲塞满了也会刷,而这小数据远没满。所以此时hello会一直积压在缓冲里,先走write的hello write\n直接上屏,然后 fork,父子各自在退出时把hello刷到屏幕上 → 表现是hello write一次、hello(无换行,可能粘连)两次。这和重定向到文件的道理一模一样——只要有"缓冲未刷 + fork"存在,就会多一份。 (b)重定向到文件时:全缓冲 + 无\n+ 未满,hello一样积压 → fork → 父子退出时各写一份到文件。所以依然是hello出现两次。 换句话说,"fork 多复制走一份未刷的缓冲"这个坑,跟是不是重定向、有没有换行符关系不大,关键在于 fork 之前用户缓冲区里有没有还没刷出去的存量数据。这正是"fork 前务必 fflush"这句忠告的由来。
亲手实现一个迷你 libc
最后,我们把"FILE = fd + 缓冲区 + 刷新的时机"这套逻辑亲手写出来,写一个超级简化版的 fopen/fwrite/fclose,命名成 mFILE,你会瞬间看透 glibc 干的事。
头文件 my_stdio.h:
// my_stdio.h —— 自定义一个迷你"文件流"
#pragma once
#include <stddef.h>
#define SIZE 1024
// 刷新方式:不刷/行刷/全刷
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2
// 迷你 FILE:底层 fd + 一个输出缓冲 + 相关状态
struct IO_FILE
{
int flag; // 刷新方式
int fileno; // 文件描述符(fd)
char outbuffer[SIZE]; // 用户级输出缓冲
int cap; // 缓冲区容量
int size; // 当前已缓存了多少字节
};
typedef struct IO_FILE mFILE;
// 自定义的四个"函数",对应 fopen/fwrite/fflush/fclose
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);实现文件 my_stdio.c:
// my_stdio.c —— 迷你文件流的实现
#include "my_stdio.h"
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// mfopen:把 fopen 的 mode 翻译成 open 的标志,再开出一块带缓冲的 mFILE
mFILE *mfopen(const char *filename, const char *mode)
{
// 先按 mode 决定用 open 的哪种标志组合
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; // open 失败
// 分配 mFILE,把 fd 塞进去,初始化缓冲
mFILE *mf = (mFILE *)malloc(sizeof(mFILE));
if (mf == NULL)
{
close(fd); // 申请失败就回滚刚才的 open
return NULL;
}
mf->fileno = fd; // ★ FILE 里封装了 fd!
mf->flag = FLUSH_LINE; // 先以"行缓冲"演示
mf->size = 0;
mf->cap = SIZE;
return mf;
}
// mfflush:把用户缓冲里的数据一次性交给内核
void mfflush(mFILE *stream)
{
if (stream && stream->size > 0)
{
// 把缓存的 outbuffer 交给系统调用 write
write(stream->fileno, stream->outbuffer, stream->size);
// 等待内核数据真正落盘(这里示意性地加 fsync,真实 getc 一般不用)
fsync(stream->fileno);
stream->size = 0; // 清空"已缓存"计数
}
}
// mfwrite:先复制进缓冲,再根据刷新策略决定何时真正 write
int mfwrite(const void *ptr, int num, mFILE *stream)
{
// 1. 把数据先拷进用户缓冲
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;
}
// mfclose:先冲刷残余缓冲,再关掉底层 fd
void mfclose(mFILE *stream)
{
if (stream->size > 0)
{
mfflush(stream); // 关闭前把缓冲清干净
}
close(stream->fileno);
free(stream);
}跑一个例子 main.c:
// main.c —— 用我们的迷你 libc 往日志里写并模拟"进度条"
#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)
{
char buffer[64];
// 拼一行内容
snprintf(buffer, sizeof(buffer), "hello message, number is : %d", cnt);
mfwrite(buffer, strlen(buffer), fp); // 写进"用户缓冲"
printf("write %d\n", cnt); // 普通 printf 打行号
mfflush(fp); // 立刻把这条数据刷进 log.txt
cnt--;
sleep(1); // 模拟"进度条":每 1 秒写一条
}
mfclose(fp);
return 0;
}编译三件套并运行:
$ gcc my_stdio.c main.c -o mydemo && ./mydemo
write 10
write 9
write 8
...(每秒一条)对比 printf 输出立刻出现、而 write 10 这种"进度条"行为,你会直观地体会到:我这个 mFILE 就是把"一个 fd + 一块缓冲 + 何时刷新的策略"包给了你——现在,glibc 里的 FILE,你完全看得懂了(真实的 glibc 还包含读缓冲、可重入锁、错误标志等,但骨架就是上面这套)。
思考题 6.3:我的
mfwrite只实现了"行缓冲触发刷新"。如果我想加"全缓冲"(缓冲满了才刷),在该函数哪里补上判断最合适?详解:在
mfwrite函数的"复制进缓冲并记账"之后,补一个容量判断即可,例如:// 全缓冲:缓冲被塞满就刷新 else if (stream->flag == FLUSH_FULL && stream->size >= stream->cap) { mfflush(stream); }把它和上面的
if (FLUSH_LINE && 末尾是'\n')并列(用 if / else if),就同时支持了"行满即刷"和"装满即刷"两种策略。这也印证了全缓冲模式的本质:攒到攒不下才交一次系统调用,从而把系统调用次数压到最低。
把零散的知识串成一条链
写到这里,我们把这一趟 Linux 基础 IO 的旅程回望一遍,你会发现它其实是一条层层递进的链子:
第一环,文件到底是什么。狭义上它是磁盘上"元数据 + 内容"的集合,广义上"一切皆文件";而对文件的任何操作,本质都是进程在请求内核帮忙做 IO。
第二环,你熟悉的 C 文件接口。fopen/fwrite/fread 是 libc 对真实机制的封装,stdin/stdout/stderr 三个流一开机就摆在你面前,printf 的底层不过是对 stdout 的 fwrite。
第三环,系统的原生接口。open/close/read/write/lseek 这些系统调用才是"打开文件"的最终执行者,它们用统一的"成功返回非负值、失败返回 -1 + errno"来汇报结果,f 系列函数全是它们的翻译官。
第四环,文件描述符。它就是一个 fd 表的数组下标,从 3 开始往下排;正因为"最小可用下标"这条规则,关掉 1 再 open、或者干脆 dup2(fd,1),便实现了万能的输入/输出重定向;读懂 CLOSE_ON_EXEC,你就知道 exec 之后哪些 fd 该留、哪些该关。
第五环,一切皆文件的骨架。struct file 顶着 f_pos / f_mode / f_count,file_operations 用一张函数指针表把千差万别的设备统一成 read/write 一套接口。
最后一环,也是最贴近日常调试的:缓冲区。全缓冲、行缓冲、无缓冲分别对应普通文件、终端、stderr;write 没有用户缓冲,printf/fwrite 有,连 FILE 里的 _fileno 都摆在明面。而那个令无数人半夜挠头的"fork 之后内容多一份",说白了就是"未刷的缓冲 + 写时拷贝 + 进程退出刷新"三者合谋。
给你留一个终极大作业,把整个链条真刀真枪用一遍:写出 file < in.txt > out.txt 的完整执行过程(用文字 + fd 表变化)。回想我们在 minishell 里是怎么 fork、open、dup2、close、exec 的。这个题没有唯一标准答案,但只要你把"fd 表每一行此刻指向什么"画清楚,这一篇你就真正毕业了。提示起点:file 进程初始 fd 表是 {0:键盘, 1:显示器, 2:显示器};open("in.txt") 之后呢?dup2(fd,0) 之后呢?close(fd) 之后呢?exec 之后呢?——走完这四条,重定向的三个百分之一百就长在你脑子里了。
下一篇文章,我们该把目光从"怎么和文件交互"挪到"进程怎么跑起来"了:fork 到底复制了什么、exec 那满天飞的家族函数、父进程怎么收留自己的子进程。到时候你会发现,今天埋伏下的"fd、缓冲、fork"这些概念,正是理解进程控制的第一块砖——地基越牢,爬坡越稳。准备好了吗?
还没有评论 — 第一条由你来留。