
目录
一、理解"文件"
无论在什么操作系统下,文件 = 文件内容 + 文件属性,其中文件内容是数据,文件属性也是数据,也就是说如果你创建了一个空文件,里面没有任何内容,它也是要占据磁盘空间的,因为文件存在属性信息。
因此,我们后续做的所有文件操作,实质上是对文件内容操作或者对文件属性的操作。
我们之前在学习C语言文件操作的内容时,每次对文件的操作,一定是先要打开文件,这里的打开文件实质上是将文件的属性或者内容加载到内存中 (因为根据冯诺依曼体系结构,CPU只能读取内存的数据)。而操作系统中一定还存在着很多没有被打开的文件,这些没有被打开的文件一定是在磁盘上存储着的。
因此,我们操作系统里的所有文件 ,在宏观上可以分为"打开的文件(内存文件)"和"磁盘文件"两类。
通常我们做的文件操作例如打开文件、关闭文件,读写文件等,是我们在写代码的时候调用相关的文件操作函数 ,这些文件操作函数帮我们实现的文件操作。但更深入一层次地理解,并不是我们所写的代码在进行文件操作,而是我们所写的代码变成程序运行起来以后,执行相应的文件操作代码,然后才会完成对文件的操作。
因此,这本质上是进程在进行文件操作!
好,经过这一系列的论述,我们已经明白了,什么是文件,通常的文件操作对象是谁,接下来我们就要回顾一下C语言的文件操作。
二、C语言的文件操作
(1)文件指针
我们对文件进行操作,首先得要有该文件的文件指针。每一个被使用的文件都会在内存中开辟一个相应的文件信息区,用来存放文件的相关信息。和我们之前提到的进程管理的本质相同,也是先描述再组织 ,描述文件的信息是保存在结构体当中的,这个结构体的指针就是文件指针。
(2)文件的打开和关闭
C语言中文件打开用的是fopen函数:
cpp
#include <stdio.h>
FILE *fopen(const char *filename, const char *mode);
第一个参数:打开的文件名,可以带路径
第二个参数:打开文件的方式,常见的选项如下:
- "r" read(只读) 文件必须存在,否则打开失败
- "w" write(只写) 清空原内容;若文件不存在则创建(每次写入会清除目标文件内容)
- "a" append(追加) 在文件末尾追加内容;若文件不存在则创建(末尾继续写入目标文件)
关闭文件使用的是fclose函数:
cpp
int fclose(FILE *stream);
- 返回值:关闭成功返回0,否则返回EOF
- FILE *stream:需要关闭的文件指针
(3)文件顺序写入函数
文件写入函数是将数据写入到文件当中的,顺序写入的意思是按照从前往后的顺序,先写入的数据在前面,后写入的数据在后面,类似于顺序表的尾插操作。
第一个顺序写入函数是fprintf函数
- 返回值:如果写入成功,则返回写入的字符个数,否则返回EOF
- FILE *stream:**要输出的流(**即数据要写入到哪里)
- char *format[, argument, ...]:要输出的格式,为可变参数
- 功能:格式化地将数据写入到指定的流中,输出格式与printf函数一样,每遇到一个%,就按规定的格式,依次输出argument的值到流stream中
cpp
int fprintf(FlLE *stream, char *format[, argument, ... ])
第二个顺序写入函数是fputs函数
- 返回值:如果写入成功,则返回一个非负值,否则返回EOF
- char *str:要写入到文件里的字符串
- FILE *stream:要写入的流,即写入到哪个地方
- 功能:将一个以'\0'结尾的字符串送入指定的流中,不加换行符'\n'和结束符'\0'
cpp
int fputs(char *str, FlLE *stream);
第三个顺序输入函数是fputc函数
- 返回值:如果写入成功,则返回写入的字符(以int形式返回),否则返回EOF
- int ch:以int形式传递需要写入的字符
- FILE *stream:要写入的流
cpp
int fputc(int ch, FlLE *stream);
接下来我们用代码来测试一下三者的区别:


所以我们总结一下:
- fputc 是最小粒度写入,只写单个字符,适合逐字符处理场景;
- fputs 专写纯字符串,无格式化能力,简单高效;
- fprintf 支持格式化拼接(数字、字符串、浮点数混合),功能最强大,是实际开发中最常用的文件写入函数;
- 但是三个函数写入后都会自动移动文件指针,无需手动调整。
(4)文件顺序读取函数
第一个顺序读取函数是fscanf函数
- 返回值:如果读取成功,则返回读取的字符个数,否则返回EOF
- FILE *stream:要读取的流(即要到哪里去读取)
- char *format[, argument, ...]:要按什么格式读取,为可变参数
- 功能:从一个流的起始位置开始扫描,从流中读取数据,每读取到一个数据,就依次从format所指的格式串中取一个格式(%d、%c这种格式控制),进行格式化之后存入对应的地址当中
cpp
int fscanf(FlLE *stream, char *format[, argument, ...]);
第二个顺序读取函数是fgets函数
- 返回值:如果读取成功,则返回字符串的首地址,否则返回NULL
- char* s:用于保存读取到的数据
- int n:需要读取的n个字符(实际读取的是n-1个字符,最后一个字符自动设置为'\0')
- FILE* stream:需要获取字符串的流(即从哪里获取)
- 功能:从指定的流中读取一个字符串,可以规定读取的字符串长度,在读取的过程中,如果还未到达指定的读取长度,就已经遇到文件里的换行符'\n',则会停止读取,把读到的内容保存到指定的地址中,并且保留这个换行符'\n'
cpp
char* fgets(char* s, int n, FlLE *stream);
第三个顺序读取函数是fgetc函数
- 返回值:如果读取成功,则返回读取到的字符,否则返回EOF
- FILE* stream:要读取的流(即从哪里读取)
- 功能:从指定的流中读取字符
cpp
int fgetc(FlLE* stream);
老样子,我们再来看一下三者的区别,其实这里和写入差不多是一一对应的关系。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
FILE *fp = NULL;
char buf[100] = {0}; // 存储读取的内容
int ch; // 存储fgetc读取的字符(用int而非char,兼容EOF)
// ========== 1. 写入测试数据到文件 ==========
fp = fopen("read_compare.txt", "w+");
if (fp == NULL) {
perror("文件打开失败");
exit(1);
}
// 写入测试内容:包含字符、字符串、数字、混合数据
fprintf(fp, "A\n"); // 单个字符+换行
fprintf(fp, "Hello fgets!\n"); // 字符串+换行
fprintf(fp, "Name: Tom Age: 20 Score: 95.5\n"); // 格式化混合数据
rewind(fp); // 重置文件指针到开头,准备读取
// ========== 2. 测试 fgetc():读取单个字符 ==========
printf("===== 1. fgetc 读取(单个字符)=====\n");
// 循环读取直到文件末尾(EOF是文件结束符,值为-1)
while ((ch = fgetc(fp)) != EOF) {
// 打印字符和对应的ASCII码(方便看换行符等不可见字符)
printf("读取字符:'%c' (ASCII: %d)\n", ch, ch);
// 读到第一个换行符就停止,避免读取过多
if (ch == '\n') break;
}
printf("\n");
// ========== 3. 测试 fgets():按行读取字符串 ==========
printf("===== 2. fgets 读取(整行字符串)=====\n");
// 重置指针到开头,重新读取
rewind(fp);
// 读取第一行
fgets(buf, sizeof(buf), fp);
printf("第一行内容:%s", buf); // fgets保留换行符,不用额外加\n
// 读取第二行
fgets(buf, sizeof(buf), fp);
printf("第二行内容:%s", buf);
// 读取第三行
fgets(buf, sizeof(buf), fp);
printf("第三行内容:%s", buf);
memset(buf, 0, sizeof(buf)); // 清空缓冲区
printf("\n");
// ========== 4. 测试 fscanf():格式化读取数据 ==========
printf("===== 3. fscanf 读取(格式化数据)=====\n");
rewind(fp);
// 跳过前两行(先读取前两行丢弃)
fscanf(fp, "%*[^\n]\n"); // 跳过第一行所有字符直到换行
fscanf(fp, "%*[^\n]\n"); // 跳过第二行所有字符直到换行
// 读取第三行的格式化数据:字符串、整数、浮点数
char name[10];
int age;
float score;
// 按格式匹配:%s读字符串(遇空格停),%d读整数,%f读浮点数
fscanf(fp, "Name: %s Age: %d Score: %f", name, &age, &score);
printf("格式化读取结果:\n");
printf("姓名:%s\n", name);
printf("年龄:%d\n", age);
printf("分数:%.1f\n", score);
// ========== 5. 关闭文件 ==========
fclose(fp);
fp = NULL;
return 0;
}

三、Linux操作系统下的文件操作
我们上面复习到的C语言下的文件操作方法,是在语言层面对文件进行的操作。当我们向文件写入/读取 的时候,由于文件是存储在磁盘中 的,所以最终是向磁盘进行写入/读取。
磁盘是一个硬件,在计算机中能对硬件操作的只有操作系统,因为操作系统是计算机软硬件的管理者,也就是说我们对文件的所有操作,都是操作系统帮我们完成的。
那么如果我们要让操作系统帮我们做某些事情,就只能够通过调用操作系统提供的系统调用接口来完成,所以我们在C语言下的文件操作本质上是调用了操作系统提供的文件操作接口,只不过C语言对这些接口进行了封装,至于为什么要有这一层封装,原因有两个:
- 如果不封装系统调用接口,这些接口的使用成本会很高,使用起来非常不方便
- 不同操作系统间各种系统接口是不一样的,如果直接使用系统调用接口的话,就不具有跨平台性 了,所以高级程序语言会对这些系统调用接口做封装**(对外只提供统一的封装方法,对内针对不同操作系统有不同的执行方式,这就是多态的思想)**
(1)open
这是Linux操作系统下用来打开文件的接口,很有意思的是这个函数是一个重载函数

pathname: 要打开或创建的⽬标⽂件
flags: 打开文件时,可以传⼊多个参数选项,用下面的⼀个或者多个常量进行"或"运算,构成flags。
参数:
- O_RDONLY: 文件以只读的方式打开
- O_WRONLY:文件以只写的方式打开(格式化写入),当只以O_WRONLY打开时,只会从头开始覆盖式的写入,原先的内容并不会清空
- O_RDWR:文件以读写的方式打开(格式化写入)
- O_APPEND:文件以只写的方式打开(追加写入)
- O_CREAT:如果文件不存在则创建之
- O_TRUNC:将文件进行截断清空
返回值:
- 成功:新打开的⽂件描述符
- 失败:-1
其实flags参数还可以传递很多标记位,这里只着重介绍我们必须掌握的用的最多的这几种。但很快就会意识到一个问题:flags参数是int类型的,并且它不是可变参数,如果我们要以只写的方式打开并且当文件不存在的时候我们要创建这个文件,那应该如何传递参数呢?
其实,flags参数的传递采用的是位图结构。
上面提到的O_RDONLY这些标记实质上是宏,要使用位图结构来传递这些标记的话,一般只需要每一个宏标记有一个比特位的值是1,并且其他比特位的值与其它宏对应比特位的值都不重叠。
(2)close
这是Linux操作系统下用来关闭文件的接口,这个函数使用起来非常简单,只需要将文件描述符传递进去就可以关闭相应的文件。
- 头文件:<unistd.h>
- 返回值:如果关闭成功,则返回0,否则返回-1
- 参数: (1)int fd:要关闭文件的文件描述符

(3)write
这是Linux操作系统下用来对文件进行写入操作的接口,它不像C语言下有fprintf、fputs、fputc这么多写入函数,Linux系统接口只有这一个写入函数。

头文件:<unistd.h>
返回值:如果写入成功,则返回写入数据的字节数,否则返回-1
参数:
(1)int fd:需要写入文件的文件描述符,表明要向哪个文件进行写入操作
(2)const void* buf:需要写入的缓冲区
(3)size_t count:缓冲区的数据长度
(4)read

头文件:<unistd.h>
返回值:如果读取成功,则返回读取到的字节数,否则返回-1
参数:
(1)int fd:需要读取文件的文件描述符,表明要从哪个文件读取
(2)viod *buf:缓冲区,将文件内容读取到这里
(3)size_t count:需要读取的大小
我们再用一段代码来看一下,这四个系统调用接口实际的功能:
cpp
#include <stdio.h>
#include <fcntl.h> // open() 标志位、mode_t
#include <unistd.h> // open/close/read/write/lseek 系统调用
#include <string.h> // strlen()
#include <errno.h> // 错误码 errno
int main() {
const char *filename = "syscall_demo.txt";
int fd; // 文件描述符(open() 返回值,核心句柄)
ssize_t ret; // 存储 read/write 的返回值(成功=字节数,失败=-1)
char read_buf[100] = {0}; // 读取数据的缓冲区
// ===================== (1) open:打开/创建文件 =====================
// 模式说明:
// O_RDWR:读写模式 | O_CREAT:文件不存在则创建 | O_TRUNC:文件存在则清空
// 0644:文件权限(八进制),仅 O_CREAT 时需要
fd = open(filename, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) { // 检查打开是否失败(Linux 下失败返回 -1)
perror("open 失败"); // 打印具体错误原因(如权限不足、路径不存在)
return 1;
}
printf("(1) open 成功 → 文件描述符 fd = %d\n", fd);
// ===================== (2) write:向文件写入数据 =====================
const char *write_data = "Linux 系统调用:open/close/write/read 示例\n";
// 参数:fd(文件句柄)、数据首地址、数据长度(字节数)
ret = write(fd, write_data, strlen(write_data));
if (ret == -1) {
perror("write 失败");
close(fd); // 写入失败也要关闭 fd,避免资源泄漏
return 1;
}
printf("(2) write 成功 → 写入字节数 = %zd\n", ret);
// 重置文件指针到开头(否则 read 会从写入后的位置开始,读不到内容)
lseek(fd, 0, SEEK_SET);
printf("→ 文件指针已重置到开头\n");
// ===================== (3) read:从文件读取数据 =====================
// 参数:fd(文件句柄)、缓冲区首地址、缓冲区大小(留 1 位存 '\0' 避免乱码)
ret = read(fd, read_buf, sizeof(read_buf) - 1);
if (ret == -1) {
perror("read 失败");
close(fd);
return 1;
}
printf("(3) read 成功 → 读取字节数 = %zd\n", ret);
printf("→ 读取的内容:%s", read_buf);
// ===================== (4) close:关闭文件 =====================
// 参数:仅需文件描述符 fd
if (close(fd) == -1) { // 关闭失败返回 -1(极少发生,如 fd 已关闭)
perror("close 失败");
return 1;
}
printf("(4) close 成功 → 文件描述符 %d 已释放\n", fd);
return 0;
}

理解文件描述符
通过对open函数的学习,我们知道了文件描述符就是⼀个小整数。我们将其打印出来看一下


我们可以发现,是连续的正整数。这就引发我们的一些思考了,
每一个被打开的文件都有其独自的文件描述符,当文件打开失败时,文件描述符 fd<0 ;当文件打开成功时,文件描述符 fd>=0 。但是为什么我们上面打开的文件其文件描述符是从3开始的而不是从0开始的呢?文件描述符0、1、2又在哪呢?
实际上,文件描述符0、1、2是操作系统默认打开的文件,0号文件是标准输入(对应的硬件是键盘),1号文件是标准输出(对应的硬件是显示器),2号文件是标准错误(对应的硬件是显示器)。
文件描述符的原理
一个被打开的文件是存在在内存里的,一个进程也是存在在内存里的。一个进程可以打开多个文件,所以在内核中,进程与被打开文件的数量比例可能是 1:n ,也就是说可能会存在大量的被打开的文件。操作系统针对这些被打开的文件也是要进行管理的,管理的本质还是先描述再组织。
一个文件被打开了,操作系统就会为这个被打开的文件创建一个结构体内核数据结构struct file ,这个数据结构里包含了这个被打开文件的内容信息,这就是描述的过程 ;每一个被打开的文件都有描述其内容信息的数据结构,多个被打开的文件之间就通过链表将这些数据结构联系起来,这就是组织的过程。
那么到底进程和被打开的文件是怎么建立映射关系的呢?
我们知道文件的创建或者打开都是通过进程来完成的,而每个进程都会创建一个task_struct的结构对象来进行管理,在这个结构对象里面有一个 FILE* 的结构体指针,来指向一个文件描述表,这个文件描述表是一个指针数组,存放的是文件指针,来指向打开的文件,fd 即是数组的下标 ,例如:
所以,我们这里突然醒悟,
文件描述符实际上是struct file *fd_array[]结构体指针数组的下标,
对应的正是一个个被打开的文件!
每一个打开的文件都有一个struct file来描述该文件的内容信息,那么打开一个文件以后,它们是怎么和外设硬件建立联系的呢?用标准输入文件、标准输出文件和标准错误文件为例,我们虽然进行的是文件操作,是对这些文件进行写入/读取操作,但最终的效果确实从键盘中读取,显示到显示器上,这其中是怎么建立联系的呢?
每一个外设都有对应的驱动程序,在它们的驱动程序代码中会实现读和写的函数,通过调用这些函数来完成对硬件的读写操作。而在我们的struct file中存在着函数指针,这些函数指针分别指向对应的函数,所以我们就可以通过调用函数指针的方式来调用硬件的读写函数,从而实现相应的读写操作。

从图中我们便清晰明了得知:上图中的外设,每个设备都可以有自己的read、write,但⼀定是对应着不同的操作方法!!
但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只用file便可调取 Linux 系统中绝大部分的资源!!
这便是"linux下一切皆文件"的含义!
文件描述符的分配规则
文件描述符的分配规则:在files_struct数组当中当前没有被使用的最小的一个下标,作为新的文件描述符。
这也就是为什么当我们的标准输入文件、标准输出文件和标准错误文件默认打开的时候,新打开的文件描述符是从3开始的。如果我们关闭默认打开的文件,比如关闭了标准输出文件,那么 fd_array[] 数组里没有被使用的最小下标就是1,新打开的文件其文件描述符就是1。
四、重定向
上文说到关闭一号文件,那么我们就关闭一号文件试试看:
cpp
#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);
}

我们看到结果并没有打印在显示器上,再查看一下新打开的文件发现,刚刚写入到stdout里的内容写入到了新打开的文件里。
为什么我们明明是向stdout写入,最终却写入到了log.txt文件呢?
原因是stdout是一个struct file变量,它里面封装了文件描述符fd,且它的fd值是1,而一开始默认打开的1号文件是标准输出文件,所以stdout理所当然指向标准输出文件。 但当我们关闭了标准输出文件以后,再新打开一个myfile文件,这个新打开的文件的文件描述符就是1,也就是说此时的1号文件是新打开的myfile文件而不是原来的标准输出文件 。但这是操作系统完成的工作,stdout是不知道这些变化的,它依然指向着1号文件,只不过此时指向的就是myfile文件而不是标准输出文件了。所以最后内容写入到了myfile文件中。

这种现象就叫做输出重定向!
重定向的操作方法
重定向是操作系统层面的操作,因此实现重定向的操作就要使用到操作系统提供的系统接口。Linux操作系统下我们一般使用dup2()接口实现重定向操作。

这个接口也很有意思,还有一个dup3,可以根据标记位来进行输出重定向。
头文件:<unistd.h>、<fcntl.h>
返回值:如果重定向成功,则返回重定向后新的文件描述符(即形参newfd),否则返回-1
参数:
(1)int oldfd:旧的文件描述符
(2)int newfd:新的文件描述符
我们可以看一下dup2这个函数接口的描述,它是将oldfd拷贝给newfd,而oldfd和newfd都是文件描述符,代表的是它们各自指向的文件,这就意味着oldfd的指向拷贝给newfd,使得最后newfd的指向就是oldfd的指向。
五、缓冲区
缓冲区的本质,就是一段内存。
缓冲区是我们内存的一部分,它是在内存中预留出来的一段存储空间,用来缓冲输入数据或输出数据。
缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
为什么要有缓冲区?
我们其实马上就能想到是一个缓冲的作用,但是具体怎么样我们拿网购举个例子:
这里把你网购的商品 = 程序要写入的数据;
小区快递驿站 = 程序中的缓冲区;
你去驿站取件的动作 = 计算机的底层 IO 操作
假如没有快递驿站,快递员每收到你的一个包裹,就得立刻打电话让你下楼取件。最终结果是:你可能一天接 10 次取件电话,每次都要停下做饭、工作等手头事下楼;快递员也得为你的包裹跑 10 次单元楼,双方效率都极低。但是有了驿站快递员会把你所有的包裹先集中存到小区驿站,等你方便的时候,只需一次去驿站就能把所有包裹取回家。最终结果是:你只跑 1 次、快递员也只送 1 次到驿站,双方效率大幅提升 ------ 这正是缓冲区的核心价值:先将数据暂存到内存缓冲区,等攒到一定量后再集中执行底层 IO 操作。
而,快递员在送快递的时候你可以做自己手头的事,这里我们就总结出两个意义:
- 缓冲区的存在,可以解放使用该缓冲区的进程的时间。
- 缓冲区的存在可以集中处理数据刷新,减少IO的次数,从而达到提高整机效率的目的。
缓冲区在哪里?
我们先回顾我们现有的C语言的知识:
- printf()函数是C语言的标准输出函数,它打印的数据不是直接打印到显示器上的,而是先写到缓冲区中,最后缓冲区刷新才会写入到显示器上。
- 如果我们用printf()函数输出时带上 '\n' 换行符,缓冲区会立即刷新。
- printf()函数是C语言的库函数,它底层是封装了系统接口write()的。
这样我们就有了疑惑,从这里来看,缓冲区好像既有操作系统的影子又有操作语言这些库函数的影子。那么它到底藏在哪里?
其实所有缓冲区的物理载体都是计算机内存(RAM) ------ 既不在 CPU 里,也不在磁盘 / 终端等外设中,只是内存中一块临时存数据的区域。
**根据归属不同,可分为两类:
- 用户态缓冲区(标准库层面)
位置:程序运行时,C 标准库如stdio.h在用户内存空间申请的内存区域;
控制权:属于应用程序,程序员可通过fflush、setbuf等函数手动控制;**printf、fopen对应的缓冲区都属于这类,比如printf("hello")的内容会先存在这里,而非直接输出。
2. 内核态缓冲区(操作系统层面)
位置:操作系统内核在内核内存空间划出的内存区域;
控制权:属于操作系统,普通程序只能通过write、fsync等系统调用间接触发刷新;
比如调用write()写入文件时,数据会先存到这里,内核会在合适时机再写入磁盘。
所以 数据写入磁盘的完整路径
程序数据 → 用户态缓冲区(内存)→ 内核缓冲区(内存)→ 磁盘(外设)
也正是这样,我们操作系统经过层层缓冲,越来越高效。
缓冲区什么时候刷新
标准I/0提供了3种类型的缓冲区。
- 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/0系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
- 行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/0库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准I/0库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/0系统调用操作,默认行缓冲区的大小为1024。
- 无缓冲区:无缓冲区是指标准I/0库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除此之外,缓冲区刷新还有两种特殊情况
- 当进程退出时,有一些像C语言这样被封装过的接口会强制要求进程退出前对缓冲区进行刷新
- 用户也可以强制刷新,利用fflush()函数即可

所以,我们这里突然醒悟,