【寻找Linux的奥秘】第十章:基础文件IO(上)
前言
本专题将基于Linux操作系统来带领大家学习操作系统方面的知识以及学习使用Linux操作系统。前面我们认识了Linux的各种指令以及工具,并且学习了进程的相关知识,那么接下来让我们进入新的章节,学习新的内容。本章我们要学习的是——基础文件IO。
本节重点:
- 复习C语言⽂件IO相关操作
- 认识⽂件相关系统调⽤接⼝
- 认识⽂件描述符,理解重定向
- 对⽐
fd
和FILE
,理解系统调⽤和库函数的关系- 理解重定向操作
1. 理解“文件”
1.1 一般角度
狭义上来说,文件是存储在磁盘上的数据,而磁盘内保存的数据与内存中不同,磁盘是永久性存储介质,当我们拔掉电源后磁盘上的数据不会消失,因此文件在磁盘上的存储是永久性的。
我们知道,磁盘也是外设,它既是输出设备,也是输入设备。因此我们对于文件的操作本质上是对外设的输入和输出,简称IO。
对于文件的理解,还有一个角度,叫做Linux下一切皆文件,在Linux系统中,像键盘、显示器、网卡、磁盘等等都是文件,Linux把各种硬件也都当作了文件。(至于是如何实现的后面再讲)
我们知道,文件中不单单只有文件内容,文件是文件属性(元数据)和文件内容的集合(文件 = 属性(元数据)+ 内容
),因此对于大小为0KB的空文件也是有大小的,会占用一定的磁盘空间。因此我们对文件的操作本质上是操作文件内容和操作文件属性两方面。
1.2 系统角度
我们知道,要想访问一个文件,首先我们需要先“打开”文件,那么是谁去打开文件呢?在操作系统中,实际上是由进程去打开文件,因此对文件的操作本质上是进程对文件的操作。
文件都保存在磁盘上,而磁盘的管理者是操作系统,也就是说文件的管理者是操作系统,那么操作系统对于被打开的文件会进行管理,管理的方法与进程的管理是一致的,都是先描述,再组织。(具体的管理方法下面会讲)
我们在之前学习C/C++的时候,包括其他的语言,都可以通过相关接口去对文件进行操作。例如在C语言/C++中我们可以使用对应的库函数去创建文件、修改文件等等。不过文件的读写本质其实不是通过这些库函数来操作的,而是通过在库函数中调用文件相关的系统调用接口来实现的。像C语言中的fopen、fwrire
等库函数都封装了底层OS的文件系统调用。
2. 回顾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
:文件指针返回值: 实际成功写入的数据项个数
一个简单的代码示例:
#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()
函数去改变文件偏移量:
-
rewind
:用于将文件指针重置到开头。rewind(FILE *stream); //stream为需要重置的文件指针
-
fseek
:用于手动控制文件指针的位置。fseek(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()
打开终端设备,很麻烦。
那么我们如果想将信息输出到显示器上就可以通过多种不同的方法了:
#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语言时学习过的有关文件方面的知识,我们进行了简单的回顾,下面让我们进入新的学习环节。
3. 系统文件IO
打开⽂件的⽅式不仅仅是fopen,ifstream
等语⾔层的⽅案,其实它们的底层都是通过系统调用去打开⽂件,我们这里主要讲解一下Linux系统下的系统调用。不过,在学习系统⽂件IO之前,先要了解下如何给函数传递标志位,该⽅法在系统⽂件IO接⼝中会使⽤到。
3.1 标志位
给函数传递标志位也就是通过给函数传入特定的参数,使其执行特定的功能。而这个功能的实现,我们一般采用位图加宏的方式,例如下面的代码:
#include <stdio.h>#define ONE 0001 //0000 0001
#define TWO 0002 //0000 0010
#define THREE 0004 //0000 0100void 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
创建文件时使用,指定新文件的权限。
例如:
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()
实际在源码中定义如下:int 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
是从哪个文件描述符中读取。下面是代码示例:
#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
这两个系统调用的使用方法极为类似,我们可以类比来看。下面让我们看一下代码示例:
#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
,该表最重要的部分就是包含⼀个指针数组,每个元素都是⼀个指向打开⽂件的指针!所以,本质上,⽂件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的⽂件。
文件描述符的分配规则:
我们通过代码来看:
#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呢?#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的系统调用。我们使用的这些语言它们在每个系统上都有属于该系统对应的库文件,它们确保了我们在使用库函数的时候可以根据系统的不同去调节库函数底层实现的具体细节,这样一来,我们写的程序便可以在不同的系统上运行,便具有了可移植性。
4. 重定向
当我们认识了文件描述符后,我们就可以对重定向操作进行解释了。我们先来看一段代码:
#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); // 标准输出 → out.txtclose(fd); // fd 已不再需要printf("Hello, world!\n"); // 实际写入到 out.txtreturn 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); // 标准输入 ← input.txtclose(fd);char buf[128];fgets(buf, sizeof(buf), stdin); // 从 input.txt 读取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); // 标准输出 → 追加到 log.txtclose(fd);printf("Appended line\n"); // 会追加而不是覆盖return 0;
}
像我们的printf
函数,我们知道它是在终端上打印相应的内容,实际上它的底层是向文件描述符为1的文件中写入,而在C语言中声明的stdin、stdout、stderr
实际上文件描述符为0、1、2的文件流,也就是说它们对应的文件描述符,而不是对应的文件,我们可以通过更改文件描述符使其代表的变为其他文件。
因此我们在终端中的重定向操作实际上是通过dup2系统调用和文件的打开权限一同完成的。
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!