跳到主要内容
Linux 基础 IO 详解:从 C 标准库到系统调用的底层逻辑 | 极客日志
C
Linux 基础 IO 详解:从 C 标准库到系统调用的底层逻辑 Linux 基础 IO 涵盖从 C 标准库到系统调用的完整链路。文章解析了文件描述符机制、重定向原理及缓冲区策略,揭示了 open/read/write/close 与 fopen/fread/fwrite 的封装关系。通过剖析 file_operations 结构与 VFS 层,阐明“一切皆文件”的实现逻辑,帮助开发者理解底层 I/O 效率优化与资源抽象设计。
清心 发布于 2025/12/11 更新于 2026/4/23 1 浏览前言
作为 Linux 开发者,你是否曾有过这些疑问?为什么 fopen 和 open 都能操作文件,它们有什么区别?printf 输出有时会'消失',必须用 fflush 才能显示,这背后的缓冲区是怎么回事?重定向 > log.txt 是如何让程序输出从显示器转到文件的?为什么说'Linux 下一切皆文件',键盘和显示器明明是硬件啊?
这些问题的答案,都藏在'基础 IO'的知识里。今天,我们就从 C 语言的熟悉接口入手,一步步深入内核,拆解 Linux 基础 IO 的核心概念——系统调用、文件描述符、重定向、缓冲区,以及'一切皆文件'的设计哲学。
一. 理解'文件'
1.1 一般角度
狭义上来说,文件是存储在磁盘上的数据。磁盘是永久性存储介质,断电后数据不会消失,因此文件在磁盘上的存储是永久性的。
我们知道,磁盘也是外设,它既是输入设备,也是输出设备。因此我们对文件的操作本质上是对外设的输入和输出,简称 IO。
对于文件的理解,还有一个角度,叫做Linux 下一切皆文件 。在 Linux 系统中,像键盘、显示器、网卡、磁盘等等都是文件,Linux 把各种硬件也都当作了文件。(至于是如何实现的后面再讲)
我们知道,文件中不单单只有文件内容,文件是文件属性(元数据)和文件内容的集合 (文件 = 属性(元数据)+ 内容),因此对大小为 0KB 的空文件也是有占用的,会占用一定的磁盘空间。因此我们对文件的操作本质上是操作文件内容和操作文件属性两方面。
1.2 系统角度
要想访问一个文件,首先我们需要先'打开'文件。在操作系统中,实际上是由进程去打开文件,因此对文件的操作本质上是进程对文件的操作 。
文件都保存在磁盘上,而磁盘的管理者是操作系统,也就是说文件的管理者是操作系统。那么操作系统对于被打开的文件会进行管理,管理的方法与进程的管理是一致的,都是先描述,再组织 。(具体的管理方法下面会讲)
我们在之前学习 C/C++ 的时候,包括其他的语言,都可以通过相关接口去对文件进行操作。例如在 C 语言/C++ 中我们可以使用对应的库函数去创建文件、修改文件等等。不过文件的读写本质其实不是通过这些库函数来操作的,而是通过在库函数中调用文件相关的系统调用接口来实现的。像 C 语言中的 fopen、fwrite 等库函数都封装了底层 OS 的文件系统调用。
二. 回顾 C 语言文件接口
既然是讲文件操作,那让我们回顾一下之前在 C 语言中学习的文件操作,因为 Linux 的底层就是用 C 语言来实现的。
2.1 C 中的读写操作
在 C 语言中,我们如果想对文件进行操作,首先要使用 fopen 函数打开对应的文件,并且通过传递不同的参数来确定以什么样的权限来打开文件。
这里我们使用的函数都是库函数。
这些都是我们之前学习过的,我们简单介绍一下即可。
首先是两个参数:
path:
path 可以是相对路径,即以当前进程所在的路径。之前我们在讲解进程时讲过进程的 PCB 中包含着一个 cwd,也就是该进程当前的工作路径。所以我们可以直接输入文件名,这样查找文件和创建文件都是在 cwd 所对应的路径下进行的。
打开文件,本质是进程去打开文件。由于进程知道自己在哪里,即便文件不带路径,进程也知道。由此 OS 就能知道要创建的文件放在哪里。
mode:
:只读模式,打开文件进行读取。如果文件不存在,返回 。
'r'
NULL
'w':只写模式,打开文件进行写入。如果文件已存在,会将文件内容清空 ;如果文件不存在,则会创建新文件。
'a':追加模式,打开文件进行写入。如果文件存在,数据会被追加到文件末尾;如果文件不存在,则会创建新文件。
'r+':读写模式,打开文件进行读取和写入。如果文件不存在,返回 NULL。
'w+':读写模式,打开文件进行读取和写入。如果文件存在,文件内容会被清空;如果文件不存在,则会创建新文件。
'a+':读写模式,打开文件进行读取和写入。如果文件存在,数据会被追加到文件末尾;如果文件不存在,则会创建新文件。
'b':二进制模式。在文件操作时以二进制形式打开文件。例如,"rb" 表示以二进制方式读取文件,"wb" 表示以二进制方式写入文件。而它的返回值类型 FILE* 是结构体指针,它代表一个已经打开了的文件,并持有有关这个文件的所有信息。至于该类型的具体含义我们下面再说。
我们以相应的权限打开文件后,就可以进行相应的读写操作了,这里又需要用到两个库函数,分别是 fread 和 fwrite。它们用于读文件和写文件,我们简单的回顾一下:
fwrite:
功能: 向文件中写入二进制数据。
参数说明: ptr:指向要写入数据的内存缓冲区的指针;size:每个数据项的字节大小;nmemb:要写入的数据项个数;stream:文件指针
返回值: 实际成功写入的数据项个数
#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:文件指针
返回值: 实际成功读取的数据项个数。
一个简单的代码示例:我们从上面写入的文件中读出数据
#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() 函数去改变文件偏移量:
fseek(FILE *stream, long offset, int 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() 打开终端设备,很麻烦。
那么我们如果想将信息输出到显示器上就可以通过多种不同的方法了:
#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 标志位 给函数传递标志位也就是通过给函数传入特定的参数,使其执行特定的功能。而这个功能的实现,我们一般采用位图加宏的方式,例如下面的代码:
#include <stdio.h>
#define ONE 0x01
#define TWO 0x02
#define THREE 0x04
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 | TWO 时,在函数内部有 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")
mode(权限位):仅当使用 O_CREAT 创建文件时使用,指定新文件的权限。
flags:标志位,指定打开方式,常见的值如下表所示
宏名 含义 O_RDONLY只读 O_WRONLY只写 O_RDWR读写 O_CREAT文件不存在则创建 O_TRUNC文件存在则清空内容 O_APPEND每次写入都追加到文件末尾 O_EXCL和 O_CREAT 一起用,确保文件不存在 O_NONBLOCK非阻塞打开(如设备或管道)
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() 实际在源码中定义如下:
它用的是 C 语言中的变参(...)语法。这个机制允许传入 可选的第三个参数 (即 mode_t mode),用于在创建文件时指定权限。
这是 C 语言中通过 ...(变参)实现'伪重载'的一种技巧。
随着 Linux 的发展,open() 曾经是系统调用,但在现代 Linux 中已演进为库函数,它通过调用 openat() 系统调用来实现功能。这是 Linux 系统 API 演进的典型例子——保持接口兼容性的同时,底层实现更强大、更安全。从严格的技术角度,现在不应该说 open() 是系统调用,它是库函数。但由于历史习惯和使用体验相同,很多文档和程序员仍然这样称呼,在这里我们还是先称其为系统调用,因为其的确是我们 fopen 的底层调用。准确的说法是:'open() 是对 openat() 系统调用的包装'。
3.2.2 close close 这个系统调用就相对简单,它的作用就是用于关闭一个打开的文件描述符 fd,它的返回值成功返回 0,失败返回 -1 并设置 errno。
close 是 fclose 的底层调用,由于我们现在还并不了解什么是文件描述符,所以先了解一下即可。
3.2.3 read
fd:文件描述符(由 open()、socket() 等返回)
buf:数据缓冲区指针,读入的数据存放在这里
count:最多读取的字节数
成功:返回实际读取的字节数(<= count)
遇到文件结尾(EOF):返回 0
失败:返回 -1,并设置 errno
read 是 fread 的底层调用,所以它们的参数是比较相似的,不同的在于我们使用 fread 读取文件的时候我们需要的从哪个文件流中读取,而 read 是从哪个文件描述符中读取。下面是代码示例:
#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
fd:文件描述符
buf:要写的数据缓冲区指针
count:写入的字节数
成功:返回实际写入的字节数(可能 < count)
失败:返回 -1,并设置 errno
write 是 fwrite 的底层调用。read 和 write 这两个系统调用的使用方法极为类似,我们可以类比来看。下面让我们看一下代码示例:
#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);
}
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,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
文件描述符的分配规则:
我们通过代码来看:
我们可以看到结果是 fd:3。那么关闭文件描述符 0 呢?
发现是结果是:fd: 0。可见,文件描述符的分配规则:在 files_struct 数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
说了那么多,接下来让我们验证一下我们上面所说的是否正确,我们在 Linux 的内核源码中去寻找一下答案:
在操作系统接口层面,它们只认 fd,也就是文件描述符。那么为什么会存在文件描述符呢?这是我们 Linux 系统层面的概念,C 语言的 FILE 结构体中只是封装了 fd,其实不论是 C 语言也好,C++、java 也罢,它们都有自己的文件操作接口,这些接口的底层其实都调用的是系统接口,这是为了方便我们使用这些语言的可移植性 !试想我们在 Linux 中使用 C 语言写了一个程序,里面调用了 Linux 的系统调用,那么当我们把这个程序在 windows 下去执行就会发生错误,毕竟 windows 有自己的系统调用,它并不认识 Linux 的系统调用。我们使用的这些语言它们在每个系统上都有属于该系统对应的库文件,它们确保了我们在使用库函数的时候可以根据系统的不同去调节库函数底层实现的具体细节,这样一来,我们写的程序便可以在不同的系统上运行,便具有了可移植性。
四. 重定向 当我们认识了文件描述符后,我们就可以对重定向操作进行解释了。我们先来看一段代码:
#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 的原内容。
#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 );
close(fd);
printf ("Hello, world!\n" );
return 0 ;
}
#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 );
close(fd);
char buf[128 ];
fgets(buf, sizeof (buf), stdin );
printf ("Read: %s" , buf);
return 0 ;
}
#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 );
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 就是向标准错误中输出信息。
#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,所以我们知道虽然标准错误也是向显示器上打印,但是在我们进行输出重定向时可以使错误信息仍显示在屏幕上,方便调试。
那么我们该如何使上面程序中的标准输出和标准错误都重定向到同一个文件中呢?我们可以通过下面的命令来完成:
解析一下这条命令,首先是将文件描述符 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 结构体:
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
loff_t f_pos;
这个结构体中的成员除了 struct module* owner 其余都是函数指针:
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 );
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 *);
int (*open)(struct inode *, struct file *);
int (*flush)(struct file *, fl_owner_t id);
int (*release)(struct inode *, struct file *);
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 *);
ssize_t (*sendpage)(struct file *, struct page *, int , size_t , loff_t *, int );
unsigned long (*get_unmapped_area) (struct file *, unsigned long , unsigned long , 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) 实现'一切皆文件':
用户空间:open (), read (), write (), close ()
↓ VFS 层:统一的文件操作接口
↓ 具体文件系统:ext4, proc, sysfs, devfs, tmpfs...
↓ 硬件层:磁盘、内存、设备等
每种'文件'类型都有自己的文件系统驱动,但对外提供统一接口。
"一切皆文件"是 Linux 的设计哲学,它将系统中的各种资源(文件、设备、进程、网络等)都抽象为文件,提供统一的操作接口,使得系统简洁、一致且强大。
七. 缓冲区 从我们一开始学习 C 语言,我们就听过缓冲区,那么究竟什么是缓冲区呢?缓冲区有什么作用呢?它又在哪里呢?这些问题接下来让我们一一去解决。
7.1 什么是缓冲区 缓冲区是内存空间的一部分 。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
7.2 缓冲区的作用 读写文件时,如果不开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到 CPU 状态的切换,即从用户空间切换到内核空间,实现进程上下文切换,这将损耗一定的 CPU 时间,频繁的磁盘访问对程序的执行效率造成很大的影响。为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的 CPU 可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和 CPU 之间,用来缓存数据。它使得低速的输入输出设备和高速的 CPU 能够协调工作,避免低速的输入输出设备占用 CPU,解放出 CPU,使其能够高效率工作。
缓冲区用于暂存输入或输出的数据,以减少系统调用次数,提高 I/O 效率 。
不是每次 printf() 都立即写入到文件/屏幕
而是先攒一批数据在内存中,然后一次性写入
for (int i = 0 ; i < 1000 ; i++) {
write(fd, &i, sizeof (int ));
}
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 进行拷贝。
#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 强制刷新:
#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 访问的。下面我们通过一段代码来研究一下:
#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 标准库提供。
struct _IO_FILE {
int _flags;
#define _IO_file_flags 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;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers ;
struct _IO_FILE *_chain ;
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset;
#define __HAVE_COLUMN
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1 ];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
上述的结构体名字为 _IO_FILE,与我们熟知的 FILE 不同,这时因为在 stdio.h 中被 typedef 了:
typedef struct _IO_FILE FILE ;
7.3 简单设计一下 libc 库 下面让我们简单模拟一下 libc 库中的文件操作:
#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;
};
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) ;
#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) {
memcpy (stream->outbuffer + stream->size, ptr, num);
stream->size += num;
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);
}
#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 开发。
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online