
前言
作为 Linux 开发者,你是否曾有过这些疑问?
- 为什么
fopen和open都能操作文件,它们有什么区别?printf输出有时会 "消失",必须用fflush才能显示,这背后的缓冲区是怎么回事?- 重定向
> log.txt是如何让程序输出从显示器转到文件的?- 为什么说 "Linux 下一切皆文件",键盘和显示器明明是硬件啊?
这些问题的答案,都藏在 "基础 IO" 的知识里。今天,我们就从 C 语言的熟悉接口入手,一步步深入内核,拆解 Linux 基础 IO 的核心概念 ------ 系统调用、文件描述符、重定向、缓冲区,以及 "一切皆文件" 的设计哲学。
请君浏览
-
- 前言
- [一. 理解"文件"](#一. 理解“文件”)
-
- [1.1 一般角度](#1.1 一般角度)
- [1.2 系统角度](#1.2 系统角度)
- [二. 回顾C语言文件接口](#二. 回顾C语言文件接口)
-
- [2.1 C中的读写操作](#2.1 C中的读写操作)
- [2.2 标准输入输出流](#2.2 标准输入输出流)
- [三. 系统文件IO](#三. 系统文件IO)
-
- [3.1 标志位](#3.1 标志位)
- [3.2 文件系统调用](#3.2 文件系统调用)
-
- [3.2.1 open](#3.2.1 open)
- [3.2.2 colse](#3.2.2 colse)
- [3.2.3 read](#3.2.3 read)
- [3.2.4 write](#3.2.4 write)
- [3.3 文件描述符](#3.3 文件描述符)
- [四. 重定向](#四. 重定向)
- [五. 理解标准错误](#五. 理解标准错误)
- [六. 理解"一切皆文件"](#六. 理解“一切皆文件”)
- [七. 缓冲区](#七. 缓冲区)
-
- [3.1 什么是缓冲区](#3.1 什么是缓冲区)
- [3.2 缓冲区的作用](#3.2 缓冲区的作用)
- [3.3 简单设计⼀下libc库](#3.3 简单设计⼀下libc库)
- [八. 总结:基础 IO 的核心脉络](#八. 总结:基础 IO 的核心脉络)
- 尾声
一. 理解"文件"
1.1 一般角度
狭义上来说,文件是存储在磁盘上的数据,而磁盘内保存的数据与内存中不同,磁盘是永久性存储介质,当我们拔掉电源后磁盘上的数据不会消失,因此文件在磁盘上的存储是永久性的。
我们知道,磁盘也是外设,它既是输出设备,也是输入设备。因此我们对于文件的操作本质上是对外设的输入和输出,简称IO。
对于文件的理解,还有一个角度,叫做Linux下一切皆文件,在Linux系统中,像键盘、显示器、网卡、磁盘等等都是文件,Linux把各种硬件也都当作了文件。(至于是如何实现的后面再讲)
我们知道,文件中不单单只有文件内容,文件是文件属性(元数据)和文件内容的集合 (文件 = 属性(元数据)+ 内容),因此对于大小为0KB的空文件也是有大小的,会占用一定的磁盘空间。因此我们对文件的操作本质上是操作文件内容和操作文件属性两方面。
1.2 系统角度
我们知道,要想访问一个文件,首先我们需要先"打开"文件,那么是谁去打开文件呢?在操作系统中,实际上是由进程去打开文件,因此对文件的操作本质上是进程对文件的操作。
文件都保存在磁盘上,而磁盘的管理者是操作系统,也就是说文件的管理者是操作系统,那么操作系统对于被打开的文件会进行管理,管理的方法与进程的管理是一致的,都是先描述,再组织。(具体的管理方法下面会讲)
我们在之前学习C/C++的时候,包括其他的语言,都可以通过相关接口去对文件进行操作。例如在C语言/C++中我们可以使用对应的库函数去创建文件、修改文件等等。不过文件的读写本质其实不是通过这些库函数来操作的,而是通过在库函数中调用文件相关的系统调用接口来实现的。像C语言中的fopen、fwrire等库函数都封装了底层OS的文件系统调用。
二. 回顾C语言文件接口
既然是讲文件操作,那让我们回顾一下之气在C语言中学习的文件操作,因为Linux的底层就是用C语言来实现的。
2.1 C中的读写操作
在C语言中,我们如果想对文件进行操作,首先要使用fopen函数打开对应的文件,并且通过传递不同的参数来确定以什么样的权限来打开文件。
这里我们使用的函数都是库函数。

这些都是我们之前学习过的,我们简单介绍一下即可。
首先是两个参数:
path:
-
path可以是相对路径,就是以当前进程所在的路径,之前我们在讲解进程时讲过进程的PCB中包含着一个cwd,也就是该进程当前的工作路径。所以我们可以直接输入文件名,这样查找文件和创建文件都是在cwd所对应的路径下进行的。打开⽂件,本质是进程去打开文件。由于进程知道⾃⼰在哪⾥,即便⽂件不带路径,进程也知道。由此OS就能知道要创建的⽂件放在哪⾥。
-
此外,
path还可以直接写绝对路径。
mode:
'r':只读模式,打开文件进行读取。如果文件不存在,返回NULL。'w':只写模式,打开文件进行写入。如果文件已存在,会将文件内容清空;如果文件不存在,则会创建新文件。'a':追加模式,打开文件进行写入。如果文件存在,数据会被追加到文件末尾;如果文件不存在,则会创建新文件。'r+':读写模式,打开文件进行读取和写入。如果文件不存在,返回NULL。'w+':读写模式,打开文件进行读取和写入。如果文件存在,文件内容会被清空;如果文件不存在,则会创建新文件。'a+':读写模式,打开文件进行读取和写入。如果文件存在,数据会被追加到文件末尾;如果文件不存在,则会创建新文件。'b':二进制模式。在文件操作时以二进制形式打开文件。例如,"rb"表示以二进制方式读取文件,"wb"表示以二进制方式写入文件。
而它的返回值类型FILE*是结构体指针,它代表一个已经打开了的文件,并持有有关这个文件的所有信息。至于该类型的具体含义我们下面再说。
我们以相应的权限打开文件后,就可以进行相应的读写操作了,这里又需要用到两个库函数,分别是fread和fwrite。它们用于读文件和写文件,我们简单的回顾一下:
fwrite:功能: 向文件中写入二进制数据。
参数说明:
ptr:指向要写入数据的内存缓冲区的指针size:每个数据项的字节大小nmemb:要写入的数据项个数stream:文件指针返回值: 实际成功写入的数据项个数
一个简单的代码示例:
c
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *file;
int numbers[] = {1, 2, 3, 4, 5};
// 写入数据
file = fopen("data.bin", "wb");
if (file != NULL) {
size_t written = fwrite(numbers, sizeof(int), 5, file);
printf("写入了 %zu 个整数\n", written);
fclose(file);
}
return 0;
}
fread:功能: 从文件中读取二进制数据。
参数说明:
ptr:指向存储读取数据的内存缓冲区的指针size:每个数据项的字节大小nmemb:要读取的数据项个数stream:文件指针返回值: 实际成功读取的数据项个数。
一个简单的代码示例:我们从上面写入的文件中读出数据
c
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *file;
int read_numbers[5];
// 读取数据
file = fopen("data.bin", "rb");
if (file != NULL) {
size_t read = fread(read_numbers, sizeof(int), 5, file);
printf("读取了 %zu 个整数\n", read);
for (int i = 0; i < read; i++) {
printf("%d ", read_numbers[i]);
}
printf("\n");
fclose(file);
}
return 0;
}
这里需要注意的是,当我们对一个文件连续调用fread()读取文件时并不会从头开始读,这是因为文件指针的位置是自动向前移的,也就是说每次调用 fread() 后,文件指针 FILE* 会自动移动到读取数据的末尾处 ,下一次再调用 fread() 时,就会从上次读取完的位置继续往后读。
这是因为当我们用 fopen() 打开一个文件时,系统为你创建了一个文件指针(FILE* 类型,它其实是一个结构体),它内部维护了一个"当前位置"(文件偏移量)的变量:
- 第一次读取时,从文件开头读取
- 每次读取完数据后,偏移量自动向前移动
- 所以不会重复读相同位置
我们可以通过使用 rewind() 或 fseek() 函数去改变文件偏移量:
-
rewind:用于将文件指针重置到开头。crewind(FILE *stream); //stream为需要重置的文件指针 -
fseek:用于手动控制文件指针的位置。cfseek(FILE *stream, long offset, int whence); //stream为需要设置的文件指针 //offset为偏移量,单位为字节 //whence为基准值,也就是从哪个位置偏移whence有三个取值(定义在<stdio.h>中):常量 含义 SEEK_SET从文件开头开始偏移 SEEK_CUR从当前位置偏移 SEEK_END从文件末尾开始偏移
注意事项:
fread和fwrite是按块(block)读写,适合处理结构体、数组等二进制数据。- 返回值是成功读/写的块数(不是字节数),要用它判断操作是否成功。
- 文件必须以
"rb"/"wb"模式打开,否则可能会出错或产生不可预期行为。- 对文本文件请使用
fprintf/fscanf,不要用fwrite/fread。
2.2 标准输入输出流
我们先来认识一下什么是流,大家可能一直听过各种流,但流究竟是什么呢?
在 C 语言中,"流" (stream )指的是数据的有序传输通道,用于在程序和输入/输出设备(如文件、终端、网络)之间进行数据传输。
简单来说:流是你和外部世界之间的桥梁,数据像水一样通过这条"流"流进来或流出去。
在 C 标准库中:
- 不直接操作"文件"或"终端",而是操作一种抽象对象:
FILE*(流指针)- 你使用
fopen()打开一个文件,实际上系统为你创建了一个流对象(指针)在 C 语言中,"流"是你与文件、终端等设备交换数据的通用通道,用来隐藏底层设备差异,统一进行读写操作。
我们平常往显示器上输出信息,实际上就是往标准输出流中进行写入,标准输出流一般就是显示器文件。
当我们在执行C程序时,C会默认打开三个输入输出流:

分别是:stdin、stdout、stderr,观察可以发现它们的类型都是FILE,而fopen的返回值类型也是FILE*,也就是说它们其实都是一个个被打开的文件。
| 名称 | 类型 | 默认连接的设备 | 用途 |
|---|---|---|---|
stdin |
输入流 | 键盘 | 接收输入 |
stdout |
输出流 | 屏幕(终端) | 打印正常输出内容 |
stderr |
输出流 | 屏幕(终端) | 打印错误或调试信息 |
为什么这三个文件流会默认打开呢?
因为这三个标准流是所有程序与"外部世界"交互的最基本通道 ,C语言运行时会自动打开它们 ,这样你的程序就能立即读入数据、打印输出、报告错误 ,无需手动处理底层设备逻辑 。如果这三个流不自动打开,程序连最基本的输入输出都做不了------你必须自己用 open() 或 fopen() 打开终端设备,很麻烦。
那么我们如果想将信息输出到显示器上就可以通过多种不同的方法了:
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");
fprintf(stdout, "hello fprintf\n");
return 0;
}
这些是我们之前学习C语言时学习过的有关文件方面的知识,我们进行了简单的回顾,下面让我们进入新的学习环节。
三. 系统文件IO
打开⽂件的⽅式不仅仅是fopen,ifstream等语⾔层的⽅案,其实它们的底层都是通过系统调用去打开⽂件,我们这里主要讲解一下Linux系统下的系统调用。不过,在学习系统⽂件IO之前,先要了解下如何给函数传递标志位,该⽅法在系统⽂件IO接⼝中会使⽤到。
3.1 标志位
给函数传递标志位也就是通过给函数传入特定的参数,使其执行特定的功能。而这个功能的实现,我们一般采用位图加宏的方式,例如下面的代码:
c
#include <stdio.h>
#define ONE 0001 //0000 0001
#define TWO 0002 //0000 0010
#define THREE 0004 //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);
func(THREE);
func(ONE | TWO);
func(ONE | THREE | TWO);
return 0;
}
如上面的代码所示,我们通过位操作来实现通过传递不同的参数使函数执行不同的功能。简单解释一下上面的代码,我们将不同的标志位定义为ONE、TWO、THREE,当我们传入ONE时,在函数内部只有flag & ONE的结果为真,因此只会执行该代码块内的代码;而当我们传入ONE | WTO时,在函数内部有flag & ONE和flag & TWO的结果为真,所以会执行这两个代码块中的代码,其他类似。
在C语言中,像fopen、fclose、fread、fwrite这些库函数在底层实际上是封装了系统调用,在Linux系统中,这些系统调用分别是open、close、read、write,下面让我们来认识一下这些系统调用接口。
3.2 文件系统调用
有了上面标志位的介绍,下面让我们来看一看Linux中关于文件操作的系统调用:
3.2.1 open

在 Linux 系统中,open() 是一个用于打开或创建文件或设备 的系统调用,它返回一个文件描述符(file descriptor) (后面详将),供后续的 read()、write()、close() 等函数使用。
参数说明:
-
pathname: 要打开的文件路径(如"file.txt"、"/dev/sda") -
flags:标志位,指定打开方式,常见的值如下表所示宏名 含义 O_RDONLY只读 O_WRONLY只写 O_RDWR读写 O_CREAT文件不存在则创建 O_TRUNC文件存在则清空内容 O_APPEND每次写入都追加到文件末尾 O_EXCL和 O_CREAT一起用,确保文件不存在O_NONBLOCK非阻塞打开(如设备或管道) -
mode(权限位):仅当使用O_CREAT创建文件时使用,指定新文件的权限。
例如:
c
open("log.txt", O_WRONLY | O_CREAT, 0644);
表示:
- 以只写方式打开
log.txt - 如果文件不存在就创建它
- 新文件的权限为
rw-r--r--
也就是说,当我们在C语言中使用fopen打开文件时,如果我们打开文件的权限设为'w',那么在fopen的底层实现中,实际上是调用了open这个系统调用并且给它传入的flags为O_WRONLY | O_CREAT | O_TRUNC,如果打开文件的权限为'a',那么传入的flags为O_WRONLY | O_CREAT | O_APPEND。当我们传入的标志位中如果有O_CREAT ,那么我们就需要在传入一个参数mode,也就是权限位,指定创建新文件的权限。
函数具体使⽤哪个,和具体应⽤场景相关,如⽬标⽂件不存在,需要open创建,则第三个参数表⽰创建⽂件的默认权限,否则,使⽤两个参数的open。
open成功执行,返回值是新打开文件的文件描述符(后面详将),如果失败则返回-1。
C 语言不支持函数重载,但
open()有两个同名版本,它是怎么做到的?这是不是 C 语言函数重载 ,而是函数的可变参数机制(变参) + 宏
Linux 的
open()实际在源码中定义如下:
cint open(const char *pathname, int flags, ...);它用的是 C 语言中的变参(
...)语法。这个机制允许传入 可选的第三个参数 (即mode_t mode),用于在创建文件时指定权限。这是 C 语言中通过
...(变参)实现"伪重载"的一种技巧。
随着Linux的发展,open() 曾经是系统调用,但在现代 Linux 中已演进为库函数,它通过调用 openat() 系统调用来实现功能。这是 Linux 系统 API 演进的典型例子------保持接口兼容性的同时,底层实现更强大、更安全。从严格的技术角度,现在不应该说 open() 是系统调用,它是库函数。但由于历史习惯和使用体验相同,很多文档和程序员仍然这样称呼,在这里我们还是先称其为系统调用,因为其的确是我们fopen的底层调用。准确的说法是:"open() 是对 openat() 系统调用的包装"。
3.2.2 colse

close这个系统调用就相对简单,它的作用就是用于关闭一个打开的文件描述符fd,它的返回值成功返回0,失败返回-1并设置errno。
close是fclose的底层调用,由于我们现在还并不了解什么是文件描述符,所以先了解一下即可。
3.2.3 read
read() ------ 从文件描述符中读取数据

参数说明:
fd:文件描述符(由open()、socket()等返回)buf:数据缓冲区指针,读入的数据存放在这里count:最多读取的字节数
返回值:
- 成功:返回实际读取的字节数(
<= count) - 遇到文件结尾(EOF):返回
0 - 失败:返回
-1,并设置errno
read是fread的底层调用,所以它们的参数是比较相似的,不同的在于我们使用fread读取文件的时候我们需要的从哪个文件流中读取,而read是从哪个文件描述符中读取。下面是代码示例:
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
const char *msg = "hello bit!\n";
char buf[1024];
while(1){
ssize_t s = read(fd, buf, strlen(msg));
if(s > 0){
printf("%s", buf);
}else{
break;
}
}
close(fd);
return 0;
}
3.2.4 write
write() ------ 向文件描述符写入数据

参数说明:
fd:文件描述符buf:要写的数据缓冲区指针count:写入的字节数
返回值:
- 成功:返回实际写入的字节数(可能
< count) - 失败:返回
-1,并设置errno
write是fwrite的底层调用。read和write这两个系统调用的使用方法极为类似,我们可以类比来看。下面让我们看一下代码示例:
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);
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--){
write(fd, msg, len);//fd: 后⾯讲, msg:缓冲区⾸地址, len: 本次读取,期望写⼊多少个字节的数据。 返回值:实际写了多少字节数据
}
close(fd);
return 0;
}
read() 和 write() 是 Linux 中最基本、最通用的系统调用,它们直接操作文件描述符,支持各种 I/O 对象,包括文件、设备、socket 等,是一切高级 I/O 的基础。
3.3 文件描述符
前面我们认识文件操作的系统调用时我们发现,这些系统调用都和一个称作文件描述符 的整数有关,就跟我们文件操作的库函数中的文件流FILE*指针一样。那么文件描述符到底是什么呢?在回答这个问题之前我们先回顾一下刚开始所说的东西。
我们知道操作系统不仅要管理我们的进程,还需要管理被打开的文件。我们知道对文件操作的本质实际上进程对文件进行操作,那么在一个进程中我们可以打开很多个文件,那么操作系统就需要对这些打开的文件进行管理,因此在我们进程的PCB中就存在一个*files指针,它的类型是files_struct的结构体,在这个结构体中包含着当前进程所打开的文件的一些信息,其中包含一个指针数组,它的类型是file*,对应着一个个文件对象,每打开一个对象操作系统就会创建一个对应的file结构体对象,里面存放了文件相关的inode元信息。
而我们的文件描述符,其实就是上面我们所说的指针数组的下标!下面我们通过图示来理解一下:

所以,我们所说的文件描述符,其实就是fd_array[]数组的下标,当我们程序运行的时候,系统会默认打开三个文件,分别是stdin、stdout、stderr三个文件,它们也刚好对应了fd_array[]数组的前三个元素,因此它们所对应的文件描述符就是0、1、2。那么我们也就知道了在C语言中的FILE结构体中一定封装了文件描述符fd。
⽽现在知道,⽂件描述符就是从0开始的⼩整数。当我们打开⽂件时,操作系统在内存中要创建相应的数据结构来描述⽬标⽂件。于是就有了file结构体。表⽰⼀个已经打开的⽂件对象。⽽进程执⾏open系统调⽤,所以必须让进程和⽂件关联起来。每个进程都有⼀个指针*files, 指向⼀张表files_struct,该表最重要的部分就是包含⼀个指针数组,每个元素都是⼀个指向打开⽂件的指针!所以,本质上,⽂件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的⽂件。
文件描述符的分配规则:
我们通过代码来看:
c#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } printf("fd: %d\n", fd); close(fd); return 0; }我们可以看到结果是
fd:3。那么关闭文件描述符0呢?
c#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { close(0); int fd = open("myfile", O_RDONLY); if(fd < 0){ perror("open"); return 1; } printf("fd: %d\n", fd); close(fd); return 0; }发现是结果是:
fd: 0。可⻅,⽂件描述符的分配规则:在files_struct数组当中,找到当前没有被使⽤的最⼩的⼀个下标,作为新的⽂件描述符。
说了那么多,接下来让我们验证一下我们上面所说的是否正确,我们在Linux的内核源码中去寻找一下答案:

在操作系统接口层面,它们只认
fd,也就是文件描述符。那么为什么会存在文件描述符呢?这是我们Linux系统层面的概念,C语言的FILE结构体中只是封装了fd,其实不论是C语言也好,C++、java也罢,它们都有自己的文件操作接口,这些接口的底层其实都调用的是系统接口,这是为了方便我们使用这些语言的可移植性!试想我们在Linux中使用C语言写了一个程序,里面调用了Linux的系统调用,那么当我们把这个程序在windows下去执行就会发生错误,毕竟windows有自己的系统调用,它并不认识Linux的系统调用。我们使用的这些语言它们在每个系统上都有属于该系统对应的库文件,它们确保了我们在使用库函数的时候可以根据系统的不同去调节库函数底层实现的具体细节,这样一来,我们写的程序便可以在不同的系统上运行,便具有了可移植性。
四. 重定向
当我们认识了文件描述符后,我们就可以对重定向操作进行解释了。我们先来看一段代码:
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
上述代码中我们先关闭了文件描述符1,也就是标准输出stdout,这样一来,我们新打开的文件myfile的文件描述符是1,运行该程序我们发现,本来应该输出到显⽰器上的内容,输出到了⽂件 myfile 当中,其中fd==1。这种现象叫做输出重定向。
那么重定向的本质是什么呢?

因此,重定向的本质实际上就是把对某个文件的操作通过改变文件描述符指向的内容从而改变操作的文件。这个过程是通过dup2系统调用来实现的:

它的作用就是把 oldfd 的文件指针复制给 newfd,替换 newfd 的原内容。
下面我们来看一下具体示例:
输出重定向:
c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
return 1;
}
dup2(fd, 1); // 标准输出 → out.txt
close(fd); // fd 已不再需要
printf("Hello, world!\n"); // 实际写入到 out.txt
return 0;
}
输入重定向:
c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("input.txt", O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
dup2(fd, 0); // 标准输入 ← input.txt
close(fd);
char buf[128];
fgets(buf, sizeof(buf), stdin); // 从 input.txt 读取
printf("Read: %s", buf);
return 0;
}
追加重定向:
c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd < 0) {
perror("open");
return 1;
}
dup2(fd, 2); // 标准输出 → 追加到 log.txt
close(fd);
printf("Appended line\n"); // 会追加而不是覆盖
return 0;
}
像我们的printf函数,我们知道它是在终端上打印相应的内容,实际上它的底层是向文件描述符为1的文件中写入,而在C语言中声明的stdin、stdout、stderr实际上文件描述符为0、1、2的文件流,也就是说它们对应的文件描述符,而不是对应的文件,我们可以通过更改文件描述符使其代表的变为其他文件。
因此我们在终端中的重定向操作实际上是通过dup2系统调用和文件的打开权限一同完成的。
五. 理解标准错误
我们知道每一个进程都会默认打开三个文件流,分别是stdin、stdout、stderr,分别对应标准输入、标准输出、标准错误。对于标准输出和标准输入我们都已经比较了解了,它们一般对应的就是键盘文件和显示器文件,可以使进程读入我们输入的数据,并且输出给我们想要的结果。
那么标准错误呢?实际上标准错误是一个与 标准输出并行的 I/O 通道,它的默认指向也是显示器文件,只不过它的用途是输出错误信息。
标准错误的主要作用是:让错误信息和正常输出分离 。它可以在我们重定向输出时不丢失错误信息。像我们C语言中的perror和C++中的cerr就是向标准错误中输出信息。
下面让我们通过代码示例来深入理解:
c
#include<iostream>
#include<cstdio>
int main()
{
printf("hello printf\n");
std::cout << "hello cout" << std::endl;
fprintf(stderr, "hello perror\n");
std::cerr << "hello cerr" << std::endl;
return 0;
}
上面的代码也很好理解,前两句是向标准输出中写入,后两句是向标准错误中写入,下面让我们来看一下运行结果:

我们发现无论是向标准输出中写入还是向标准错误中写入,最终的结果都是在显示器上打印,因此我们可以确定标准错误的默认指向也是显示器文件,下面我们继续执行该程序,并且将结果进行重定向:

我们发现,虽然我们将程序运行的结果输出重定向到了log.txt文件,但在终端上还是打印了向标准错误中写入的数据,这是因为我们写的输出重定向并不完整,上面的输出重定向的完整写法应该是./a.out 1 > log.txt,所以我们知道虽然标准错误也是向显示器上打印,但是在我们进行输出重定向时可以使错误信息仍显示在屏幕上,方便调试。
那么我们该如何使上面程序中的标准输出和标准错误都重定向到同一个文件中呢?我们可以通过下面的命令来完成:
./a.out 1>log.txt 2>&1

解析一下这条命令,首先是将文件描述符1(stdout)重定向到log.txt文件,也就是说此时的文件描述符1指向的是log.txt,接下来2>&1意味着将文件描述符1重定向到文件描述符2,此时文件描述符1指向的是log.txt,所以文件描述符2也指向了log.txt,这样在程序执行时往文件描述符1和文件描述符2中写入实际上都是向log.txt中写入。
标准错误(stderr)是一个专门用于报告错误的输出流,文件描述符默认为 2,不受标准输出重定向影响,默认是无缓冲的,能最大限度确保错误信息及时显示。(关于缓冲区下面会将)
六. 理解"一切皆文件"
在我们第一次接触Linux的时候,我们就被告知在Linux中一切皆文件,那么究竟什么是一切皆文件呢?今天让我们系统的来认识一下。
⾸先,在Windows中是⽂件的东西,它们在Linux中也是⽂件;其次,⼀些在Windows中不是⽂件的东西,⽐如进程、磁盘、显⽰器、键盘这样硬件设备在Linux中也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访问它们获得信息;甚⾄管道也是⽂件;将来我们要学习⽹络编程中的socket(套接字)这样的东西, 使⽤的接⼝跟⽂件接⼝也是⼀致的。
这样做最明显的好处是,开发者仅需要使⽤⼀套 API 和开发⼯具,即可调取 Linux 系统中绝⼤部分的资源。举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write 函数来进⾏。
"一切皆文件"的核心思想:
在 Linux 中,几乎所有的系统资源都被抽象为"文件",并通过统一的文件操作接口(如
open()、read()、write()、close())来访问和操作。
这意味着:无论是普通文件、设备、进程信息、网络连接,还是系统配置,都可以像操作文件一样来处理。
那么Linux是如何实现通过统一的文件操作接口来进行访问和操作的呢?拿硬件举例,我们知道对于不同的设备想要对其进行访问和操作底层的实现必然是不同的,之前我们讲过,当打开⼀个⽂件时,操作系统为了管理所打开的⽂件,都会为这个⽂件创建⼀个file结构体,在该结构体中值得关注是f_op 指针指向了⼀个 file_operations 结构体:

c
atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指向它,就会增加f_count的值。
unsigned int f_flags; // 表⽰打开⽂件的权限fmode_t f_mode; // 设置对⽂件的访问模式,例如:只读,只写等。所有的标志在头⽂件<fcntl.h> 中定义
loff_t f_pos; // 表⽰当前读写⽂件的位置
这个结构体中的成员除了struct module* owner 其余都是函数指针:

c
struct file_operations {
struct module *owner;
//指向拥有该模块的指针;
loff_t (*llseek) (struct file *, loff_t, int);
//llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
//⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以 -EINVAL("Invalid argument") 失败. ⼀个⾮负返回值代表了成功读取的字节数( 返回值是⼀个"signed size" 类型, 常常是⽬标平台本地的整数类型).
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
//发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负, 返回值代表成功写的字节数.
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);
//对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤.
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
//mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤返回 -ENODEV.
int (*open) (struct inode *, struct file *);
//打开⼀个⽂件
int (*flush) (struct file *, fl_owner_t id);
//flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤;
int (*release) (struct inode *, struct file *);
//在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL.
int (*fsync) (struct file *, struct dentry *, int datasync);
//⽤⼾调⽤来刷新任何挂着的数据.
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
//lock ⽅法⽤来实现⽂件加锁; 加锁对常规⽂件是必不可少的特性, 但是设备驱动⼏乎从不实现它.
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsignedlong, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *,
size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
};
file_operation 就是把系统调⽤和驱动程序关联起来的关键数据结构,这个结构的每⼀个成员都对应着⼀个系统调⽤。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从⽽完成了Linux设备驱动程序的⼯作。
总结一下设备驱动和系统调用(如 read/write)之间的关系:
- 内核有一个统一的
struct file结构体表示"打开的文件对象",其中包含一个指向file_operations的指针:f_op file_operations是一个函数指针表,包含设备操作接口,如read、write- 每个驱动程序会定义自己的一套
file_operations并实现对应的函数 - 当用户通过系统调用
read()或write()访问设备时,内核会通过f_op->read或f_op->write跳转到对应驱动里的函数!
因此,驱动开发中,每个驱动会定义自己的 file_operations 并注册给内核,这样内核才能通过 f_op 找到正确的函数。如下图所示:

上图中的外设,每个设备都可以有⾃⼰的read、write,但⼀定是对应着不同的操作⽅法。但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只⽤file便可调取 Linux 系统中绝⼤部分的资源,这便是**"Linux下⼀切皆⽂件"**的核⼼理解。因此,在Linux中访问设备,也就是访问设备文件,都是通过函数指针指向的方法进行访问的,这些函数指针的类型、命名、参数都一样,这又何尝不是一种多态呢?
Linux 通过 VFS(Virtual File System) 实现"一切皆文件":
css
用户空间: open(), read(), write(), close()
↓
VFS 层: 统一的文件操作接口
↓
具体文件系统: ext4, proc, sysfs, devfs, tmpfs...
↓
硬件层: 磁盘、内存、设备等
每种"文件"类型都有自己的文件系统驱动,但对外提供统一接口。
"一切皆文件"是 Linux 的设计哲学,它将系统中的各种资源(文件、设备、进程、网络等)都抽象为文件,提供统一的操作接口,使得系统简洁、一致且强大。
七. 缓冲区
从我们一开始学习C语言,我们就听过缓冲区,那么究竟什么是缓冲区呢?缓冲区有什么作用呢?它又在哪里呢?这些问题接下来让我们一一去解决。
3.1 什么是缓冲区
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间⽤来缓冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设备,分为输⼊缓冲区和输出缓冲区
3.2 缓冲区的作用
读写⽂件时,如果不开辟对⽂件操作的缓冲区,直接通过系统调⽤对磁盘进⾏操作(读、写等),那么每次对⽂件进⾏⼀次读写操作时,都需要使⽤读写系统调⽤来处理此操作,即需要执⾏⼀次系统调⽤,执⾏⼀次系统调⽤将涉及到CPU状态的切换,即从⽤⼾空间切换到内核空间,实现进程上下⽂的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。 为了减少使⽤系统调⽤的次数,提⾼效率,我们就可以采⽤缓冲机制。⽐如我们从磁盘⾥取信息,可以在磁盘⽂件进⾏操作时,可以⼀次从⽂件中读出⼤量的数据到缓冲区中,以后对这部分的访问就不需要再使⽤系统调⽤了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作⼤⼤快于对磁盘的操作,故应⽤缓冲区可⼤⼤提⾼计算机的运⾏速度。⼜⽐如,我们使⽤打印机打印⽂档,由于打印机的打印速度相对较慢,我们先把⽂档输出到打印机相应的缓冲区,打印机再⾃⾏逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它⽤在输⼊输出设备和CPU之间,⽤来缓存数据。它使得低速的输⼊输出设备和⾼速的CPU能够协调⼯作,避免低速的输⼊输出设备占⽤CPU,解放出CPU,使其能够⾼效率⼯作。
缓冲区用于暂存输入或输出的数据,以减少系统调用次数,提高 I/O 效率。
简单来说:
- 不是每次
printf()都立即写入到文件/屏幕 - 而是先攒一批数据在内存中,然后一次性写入
如下面代码所示:
c
// 没有缓冲区:每次都是系统调用
for (int i = 0; i < 1000; i++) {
write(fd, &i, sizeof(int)); // 1000 次系统调用!
}
// 有缓冲区:攒够了再写
// 可能只需要几次系统调用
for (int i = 0; i < 1000; i++) {
fprintf(fp, "%d ", i); // 数据先存在缓冲区
}
// 缓冲区满了或程序结束时才真正写入
我们通过fopen, printf, fwrite这些函数进行写入都是写入到用户级语言层的缓冲区中,也就是C标准库中的缓冲区,只有当用户:
- 强制刷新
- 满足刷新条件
- 进程退出
时才会通过系统调用将缓冲区中的内容刷新到文件内核缓冲区。满足刷新条件对应着三种不同类型的缓冲区:
- 全缓冲区:这种缓冲⽅式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。
- ⾏缓冲区:在⾏缓冲情况下,当在输⼊和输出中遇到换⾏符时,标准I/O库函数将会执⾏系统调⽤操作。当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使⽤⾏缓冲⽅式。因为标准I/O库每⾏的缓冲区⻓度是固定的,所以只要填满了缓冲区,即使还没有遇到换⾏符,也会执⾏I/O系统调⽤操作,默认⾏缓冲区的⼤⼩为1024。
- ⽆缓冲区:⽆缓冲区是指标准I/O库不对字符进⾏缓存,直接调⽤系统调⽤。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显⽰出来。
每种缓冲区有自己特定的刷新条件,只有当满足了上述三种其中一个时,在C标准库中的缓冲区的数据才能被刷新到文件内核缓冲区,这个刷新的过程,其实就是拷贝 ,在计算机中的数据流动本质就是拷贝!拷贝的过程就是通过文件描述符加上系统调用将C语言中的缓冲区的数据交给OS进行拷贝。
下面我们通过一个代码示例来理解一下:
c
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 0;
}
printf("hello world: %d\n", fd);
close(fd);
return 0;
}
执行上方程序,我们发现对应文件中并没有相应的内容,这是因为我们通过printf写入到log.txt中的内容是先写入C语言的缓冲区中,之后我们直接关闭了对应的文件,由于此时我们没有强制刷新,而对于普通文件采取的刷新方式是全缓冲,也并没有满足全缓冲的刷新条件,关闭对应文件描述符时进程也没有结束,因此在C语言中缓冲区的内容并没有刷新到文件内核缓冲区,所以在log.txt文件中没有对应的数据。
对于这种情况,我们可以直接通过write系统调用进行写入,使用write是写入到文件内核缓冲区中,而不是C语言的缓冲区中;或者使用fflush强制刷新:
c
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 0;
}
printf("hello world: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
在C语言中每一个被打开的文件都有其对应FILE结构体,因此每一个结构体中都包含对应文件的缓冲区,这样我们才可以通过fflush(FILE* stream)来强制刷新对应文件的缓冲区。
因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调⽤,所以本质上,访问⽂件都是通过fd访问的。下面我们通过一段代码来研究一下:
c
#include <stdio.h>
#include <string.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(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
}
我们直接运行该程序的运行结果:

但如果对进程实现输出重定向呢?

我们发现 printf 和 fwrite (库函数)都输出了2次,⽽ write 只输出了⼀次(系统调⽤)。为什么呢?肯定和fork有关:
- ⼀般C库函数写⼊⽂件时是全缓冲的,⽽写⼊显⽰器是⾏缓冲。
printf、fwrite库函数会⾃带缓冲区(进度条例⼦就可以说明),当发⽣重定向到普通⽂件时,数据的缓冲⽅式由⾏缓冲变成了全缓冲。- ⽽我们放在缓冲区中的数据,就不会被⽴即刷新。
- 但是进程退出之后,会统⼀刷新,写⼊⽂件当中。
- 但是
fork的时候,⽗⼦数据会发⽣写时拷⻉,所以当你⽗进程准备刷新的时候,⼦进程也就有了同样的⼀份数据,随即产⽣两份数据。 write没有变化,说明没有所谓的缓冲
综上: printf、fwrite 库函数会⾃带缓冲区,⽽ write 系统调⽤没有带缓冲区。另外,我们这⾥所说的缓冲区,都是⽤⼾级缓冲区。
那这个缓冲区谁提供呢? printf、fwrite 是库函数, write 是系统调⽤,库函数在系统调⽤的"上层", 是对系统调⽤的"封装",但是 write 没有缓冲区,⽽ printf、fwrite 有,⾜以说明,该缓冲区是⼆次加上的,⼜因为是C语言,所以由C标准库提供。
我们可以看看C语言中的FILE结构体:
c
//在/usr/include/libio.h
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain; //链接进程中打开的文件
int _fileno; //封装的⽂件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
上述的结构体名字为_IO_FILE,与我们熟知的FILE不同,这时因为在stdio.h中被typedef了:
c
//在/usr/include/stdio.h
typedef struct _IO_FILE FILE;
3.3 简单设计⼀下libc库
下面让我们简单模拟一下libc库中的文件操作:
my_stdio.h:
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; // ⽂件描述符
char outbuffer[SIZE]; //缓冲区
int cap;
int size;
// TODO
};
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);
my_stdio.c:
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(stream->fileno, stream->outbuffer, stream->size);
// 刷新到外设
fsync(stream->fileno);
stream->size = 0;
}
}
int mfwrite(const void *ptr, int num, mFILE *stream)
{
// 1. 拷⻉
memcpy(stream->outbuffer+stream->size, ptr, num);
stream->size += num;
// 2. 检测是否要刷新
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);
}
main.c:
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", cnt);
cnt--;
mfwrite(buffer, strlen(buffer), fp);
mfflush(fp);
sleep(1);
}
mfclose(fp);
}
我们通过自己简单设计一下libc库中的文件接口,可以帮助我们更深入地了解这些文件操作。
八. 总结:基础 IO 的核心脉络
Linux 基础 IO 看似零散,实则围绕 "效率" 和 "统一" 两大目标展开:
- 接口统一 :用 "一切皆文件" 和
file_operations抽象所有资源,一套 API 操作所有 IO; - 效率提升:用 "缓冲区" 减少系统调用,用 "库函数封装" 简化使用;
- 灵活控制:用 "文件描述符" 定位文件,用 "重定向" 改变 IO 流向;
这些知识点是我们后续学习 Linux 进阶内容的基础,例如:网络编程中的socket(本质是 fd)、驱动开发中的file_operations、shell 的重定向功能等等都依赖本章讲的基础逻辑。
如果能动手实现一个 "带重定向的简易 shell",或者模拟libc的缓冲区,你会对 Linux IO 有更深刻的理解。毕竟,IO 是程序与系统交互的 "桥梁",吃透它,才算真正入门 Linux 开发。
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

