基础IO与文件操作
目录
引言
无论是日常使用电脑时的“新建文档”“保存文件”,还是编程中读取配置、写入日志,我们都在和文件操作打交道。它是计算机中最基础、最常用的IO(输入/输出)功能,也是每一个程序员入门时必须掌握的核心知识点。对于C/C++开发者而言,掌握基础IO文件操作,不仅能实现简单的文件读写,更能理解用户态与内核态的交互逻辑、缓冲区的工作机制,为后续学习高级IO、网络编程打下坚实基础。
一、c/c++文件操作
文件=文件的内容+文件的属性(元数据)
因此未来我们对文件的操作就两类,一类是对文件内容的操作,一类是对文件的属性的操作。
访问文件之前,我们必须先找到对应文件的路径和文件名,然后打开对应的文件。
打开文件的操作 fopen,是动态被打开的过程,就是说,只有等程序运行起来之后,这个文件才会被打开。你把文件编译好之后,它其实并没有被打开。我们要知道,程序运行之后就变成了一个进程,所以,文件是被运行后的进程打开的。这么一来,程序和文件的关系,本质上就是进程和文件之间的关系。
有时候我们访问文件,不用自己手动带路径,比如访问上级目录的时候就不用,但这并不意味着,我们访问所有文件都不需要带路径。实际上,我们访问任何一个文件,都必须要有路径,要么是我们用户自己手动提供路径,要么就是我们使用系统提供的环境变量 cwd,来获取这个文件的路径,不然根本找不到要访问的文件。
由于cpu无法直接访问外设,只能访问内存上的数据,而文件是被保存在外设磁盘上的。所以打开文件的操作本质上是把文件从磁盘上加载到内存上,使得cpu能够访问文件。操作系统在打开文件时,会把文件的内容和属性(文件=内容+属性)加载进来供进程访问,当进程需要访问文件内容则加载内容到内存,如果需要访问属性则加载属性到内存。进程通过cpu访问内存中的文件。
在Linux中,大量的文件被分为打开的和未被打开的。也就是说,文件在位置上来说被分为在磁盘文件和内存级被打开的文件。Linux中进行的文件操作针对的都是内存级被打开的文件。
Linux中可以同时存在多个被打开的文件。如此多的文件,肯定需要进行管理。所以操作系统会对这些被打开的文件进行管理。如何管理呢——先描述再组织。用一个结构体记录这些被打开的文件,并用next指针将这些被打开的文件以链表的形式连接起来,将对文件的管理转化为对链表的增删查改。
1.文件操作
文件指针(文件句柄):FILE *
后续对文件的所有操作都是对这个文件指针FILE *的修改,例如:
打开文件操作fopen:
向文件写入fputs:

从文件中读取fgets:

feof 文件操作流是否以eof结尾
以下是fopen打开文件时对应的mode,指的是打开文件的方式

r 模式:以只读方式打开文本文件。文件必须存在,否则打开失败。
r+ 模式:以读写方式打开文本文件。文件必须存在,否则打开失败。
w 模式:以只写方式打开或创建文本文件(写入操作总是在文件开头进行)。如果文件存在,会清空其内容;如果不存在,则创建新文件。
w+ 模式:以读写方式打开或创建文本文件。如果文件存在,会清空其内容;如果不存在,则创建新文件。
a 模式:以追加方式打开或创建文本文件(写入操作总是在文件末尾进行)。如果文件不存在,则创建新文件。
a+ 模式:以读和追加的方式打开或创建文本文件。如果文件不存在,则创建新文件。
2.读写位置
以下的三个文件操作的函数都与读写位置有关
int fseek(FILE *stream, long offset, int whence);
功能:设置文件流的读写位置指针。
long ftell(FILE *stream);
功能:获取文件流当前的读写位置。
void rewind(FILE *stream);
功能:将文件流的读写位置指针重置到文件开头。
那什么是文件的读写位置呢?
保存在文件中的内容都是字符,像hello\n这样的。hello是字符,\n也是字符,只不过被文本解释器解释为换行符,遇到\n就换行。因此,其实可以将文件类似看作一个一维数组char[],那么读写位置就可以看作数组的下标,所以说读写位置本质上就是一个整数。
二、Linux进行文件操作
不仅c/c++能对文件进行操作,操作系统同样能对文件进行操作。下面介绍几个Linux中对文件进行操作的几个系统调用。
open函数
核心功能:打开文件
头文件:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);
第一个参数是要打开的文件的路径,第二个参数flag有三个必选的访问模式宏,分别为:
O_RDONLY:以只读的方式打开
O_WRONLY:以只写的方式打开
O_RDWR:以读写的方式打开
也有和创建和标志状态有关的宏,例如:
O_CREAT:若文件不存在则创建;需配合第三个参数 mode 指定文件权限(如 0644)
O_EXCL:与 O_CREAT 一起使用,若文件已存在则 open() 失败(避免覆盖)
O_TRUNC:若文件存在且以写模式打开,清空文件内容(长度置 0)
O_APPEND:以追加模式打开,每次写操作都会自动定位到文件末尾
O_NONBLOCK:以非阻塞模式打开
O_SYNC:写操作同步刷新到磁盘
c语言中对文件的打开操作返回值是FILE *,而在操作系统中,对文件的打开操作返回值为int
当没有成功打开,返回值为-1,如果这个文件不存在还需要系统新创建一个,需要在访问模式宏后按位或一个宏O_CREAT,同时需要设置文件的默认权限。例如要打开一个文件log.txt,可以这样写open(''log.txt'',O_RDONLY|O_CREAT,0666);
只有使用O_CREAT时才需要传第三个参数(如0666)
用法示例:
// O_WRONLY:只写
// O_CREAT: 不存在则创建
// 0666: 文件权限(最终会受 umask 影响)
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd == -1) {
perror("open create failed");
return 1;
}
printf("文件创建/只写打开成功!\n");
close(fd);
write函数
核心功能:向文件中写入
头文件:#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
第一个参数fd是open 返回的文件描述符;第二个参数buf是要写入的数据缓冲区(字符串 / 数组都可以);第三个参数count是要写入的字节数
write函数的返回值:成功时返回实际写入的字节数,失败时返回 -1
用法示例:
// 打开文件:只写+不存在则创建+清空原有内容
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd == -1) {
perror("open"); // 打印错误原因
return 1;
}
// 写入数据
char *str = "Hello Linux File!\n";
// fd:文件描述符,str:数据,strlen(str):写入长度
write(fd, str, strlen(str));
read函数
核心功能:读取文件中的数据
头文件:#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
第一个参数fd是文件描述符;第二个参数buf是读取数据存放的缓冲区(自己定义的数组);第三个参数count是期望读取的最大字节数
read函数的返回值:成功时返回实际读到的字节数,读到文件末尾时返回 0,失败时返回 -1
用法示例:
char buffer[1024] = {0}; // 定义缓冲区存读取内容
// 从fd指向的文件中读1023个字节到buffer
int n = read(fd, buffer, sizeof(buffer)-1);
if(n > 0) {
printf("读取到内容:%s\n", buffer); // 打印读取结果
}
lseek函数
核心作用:控制文件的读写位置(文件指针);可以获取文件大小、移动读写指针
头文件:
#include <unistd.h>
#include <sys/types.h>
off_t lseek(int fd, off_t offset, int whence);
第一个参数 fd 为文件描述符;第二个参数 offset 为偏移字节大小;第三个参数 whence 为偏移参考位置。
lseek 函数的返回值:成功返回当前文件指针相对于文件头部的偏移量,失败时返回 - 1。
whence 有三个取值:
SEEK_SET:从文件开头偏移 offset 字节
SEEK_CUR:从当前位置偏移
SEEK_END:从文件末尾偏移
用法示例:
// 获取文件大小(最常用)
int file_size = lseek(fd, 0, SEEK_END);
// 把文件指针移回开头(写完后想读,必须重置指针)
lseek(fd, 0, SEEK_SET);
close函数
核心功能:关闭文件,释放文件描述符
头文件:#include <unistd.h>
int close(int fd);
参数 fd 是需要关闭的文件描述符。
close 函数的返回值:成功时返回 0,失败时返回 -1。
库函数封装了系统调用
三、语言的文件操作和系统的文件操作的区别
C 标准库的文件函数(fopen、fread、fwrite、fclose)本质是对 Linux 系统调用的上层封装。
底层系统调用:open、read、write、close,上层库函数:fopen、fread、fwrite、fclose
系统调用由操作系统内核提供,运行在内核态,无用户层缓冲区;而C 库函数是在系统调用基础上,增加了用户态缓冲区,减少了频繁陷入内核的次数,效率更高;C库函数跨平台可移植,而系统调用仅适配 Linux 等类 Unix 系统。
1.文件描述符的本质
前面提到了,操作系统为了管理所有的文件,创建了结构体来管理。当我们一当要打开一个文件,操作系统都要在内核帮我们创建struct file{}这个文件属性结构体,这个结构体直接或间接地包含了文件的大部分属性。当我们打开一个一个文件时,有可能直接或间接地打开多个文件,有些文件是当前进程打开的,有些文件是其他进程打开的。然后,操作系统会把这些被打开的文件的sruct file连接起来,将对文件的管理转换为对链表的管理。
其中,file对象中会有一个指针,会指向文件缓冲区。当我们要访问一个文件,打开文件时,在磁盘中的文件的属性就会被加载到文件的属性结构体中,而文件的内容会被加载到文件自己的缓冲区中。由于操作系统不能直接对磁盘中文件的内容和属性进行修改,所有将来我们对文件的任何操作,操作系统都必须先将文件加载到内存中,将文件修改过后再将对应文件的内容和属性放回磁盘中。
由于不同的进程都会打开不同的文件,那我们怎么知道某个文件是由哪个进程打开的呢?
操作系统在进程的PCB中设置了一个文件指针*files_struct,这个指针会指向一个结构体对象struct files_struct,而这个结构体对象中会包含一个数组。这个数组就是struct file* fd_array[](文件对象对应的指针数组)。当我们打开一个文件时,操作系统就会在这个数组中寻找一个未被使用的空间(比如下标3对应的空间未被使用),并指向打开这个文件时所创建的文件结构体对象的地址,并将这个下标3返回给我们用户。而这个下标3就是文件描述符。而进程PCB中所维护的 struct file* fd_array[] 我们就把它称之为文件描述符表。
所有这也就解释了为什么前面在介绍Linux对文件操作的系统调用时,诸如read这些系统调用时要传入文件描述符fd这个参数。传入这个参数后,操作系统就会在进程中寻找对应编号的文件结构体对象,然后将磁盘中的内容拷贝到文件缓冲区,进而读取文件缓冲区中的内容,就能做到访问文件中的内容了。
2.为什么C语言要封装
前面提到过,在 C 语言中,标准库的文件操作(fopen/fwrite/fclose)是对系统调用(open/write/close)的一层封装。而C语言中的FILE* 结构体也是对文件描述符 fd的高层封装。
那为什么C语言要封装这些内容,而不是直接使用系统调用呢?
原因是,直接使用Linux的系统调用,不具备很好地跨平台性!一门编程语言要想生存下去,就必须有更多的人去使用。为了让更多的人使用,这门语言就必须具备很好地跨平台性。所以C语言标准库会在不同的平台下(例如Linux/Windows/Macos/...)都把文件操作(fopen/fwrite/fclose)各封装一份,当不同的平台下载C语言时,就会提供对应操作系统封装的内容。这就使得不同的操作系统都能使用C语言。
四、如何理解Linux下一切皆文件
我们日常电脑中有各式各样的硬件:显示器、键盘、磁盘、网卡等等。按理来说,不同硬件底层结构完全不同,访问方式、控制逻辑也天差地别。
那操作系统难道要为每一种硬件,单独设计一套专属调用接口、一套读写逻辑吗?显然不会,这样设计极度冗余、维护困难,使用起来也极其混乱。
为了解决这个问题,Linux 操作系统提出了一切皆文件的核心设计思想。Linux内核内部统一设计出 struct file 结构体,用来管理和控制所有被打开的硬件、设备、管道、套接字等资源。同时在这个结构体中,存放了统一的函数指针集合,里面封装了对应设备真正的读、写、关闭等底层操作方法。不同硬件只需要实现自身专属的底层读写接口,再把自己的硬件操作函数,挂载到 struct file 的函数指针中。上层用户无论操作的是键盘、显示器、磁盘文件、管道还是网络套接字,都只需要使用同一套系统调用:read、write、close 完成操作。这让我们用户只需要使用file便可调取Linux系统中的绝大部分资源。
上层调用统一接口,内核通过文件结构体里的函数指针,自动回调对应硬件的底层驱动方法。对外表现接口完全一致,底层硬件的差异被完美屏蔽。正是依靠这套机制:所有硬件、设备、进程通信资源、网络资源,在内核眼里全部统一抽象为文件。统一使用文件描述符管理,统一使用读写接口访问。这就是 Linux 一切皆文件 的本质含义。

五、文件描述符fd、重定向、缓冲区
操作系统会给每个运行的程序(进程)分配一套 “文件描述符表”,默认的核心描述符是:
0:标准输入(stdin)→ 指向键盘
1:标准输出(stdout)→ 指向屏幕
2:标准错误(stderr)→ 指向屏幕
1.文件描述符(fd)的分配规则
文件描述符本质是内核中文件描述符表数组的下标,操作系统分配 fd 遵循一个固定原则:在文件描述符表中,找到当前值最小、且未被分配使用的下标,分配给新打开的文件。
举个直观例子:默认情况下,进程启动会自动打开 0、1、2 三个标准文件描述符(分别对应标准输入、标准输出、标准错误)。如果我们手动执行 close(0) 关闭了 fd=0,此时文件描述符表中可用的最小未分配 fd 就是 0;后续新打开文件时,系统就会把 0 分配出去,当前已分配的 fd 就会变成 0、3、4。
同理,如果再执行 close(1) 关闭 fd=1,下一次新打开文件,系统就会优先分配最小未使用的 1,当前已分配 fd 变为 1、3、4。
2.重定向的本质
重定向的核心原理,就藏在文件描述符的分配规则里。我们关闭默认的文件描述符(比如 1 标准输出),再打开一个新文件,系统会自动把最小未使用的 1 分配给这个新文件—— 这就完成了重定向。
语言层(比如 C 语言的 stdout)完全感知不到这个变化,因为 stdout 只是对 fd=1 的封装,它只认数字 1,不关心 1 具体指向什么。我们修改的是内核中文件描述符表数组特定下标里存储的内容,把原本指向显示器的指针,改成了指向我们指定的文件。
所以一句话总结:所有重定向的本质,就是修改文件描述符表数组特定下标内的指向内容。
那么程序替换会不会影响已经打开的文件?答案是:完全不会。
程序替换的作用,仅仅是替换进程的代码段和数据段,用新程序的代码、数据覆盖原有内容,让进程执行新的逻辑。而进程打开的文件,是由内核维护的 files_struct 结构管理的,属于内核数据结构。
程序替换不修改任何内核数据结构,管理文件的 files_struct 保持不变,文件描述符表、已经打开的文件都不会受到任何影响。因此,程序替换前后,进程已经打开的文件可以正常使用,不会被关闭、也不会失效。
前面我们已经知道,重定向的本质,就是修改文件描述符表数组里,特定下标对应的文件指向。
而要安全、规范、原子地完成这件事,就必须用到三个系统调用:dup、dup2、dup3它们是 Linux 下实现重定向的核心工具。
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
1. dup
功能:复制 oldfd
分配规则:系统自动选择当前最小未被使用的 fd 返回
2. dup2(最常用、重定向标配)
功能:把 oldfd 强制复制到 newfd
如果 newfd 已经打开:先自动关闭,再复制(原子操作,不会被打断)
如果 oldfd 无效:不会关闭 newfd,直接报错
文件的重定向就是修改文件描述符表中,指定下标对应的文件指针。而语言层(C 的 stdin/stdout/stderr)根本不知道这件事。它们只认数字:0、1、2。所以只要我们把 1 指向文件,printf 就往文件里写。而 dup2 就是干这件事最标准的系统调用。下面给出一个使用dup2的例子:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main() {
// 1. 打开一个文件,得到 fd
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
// 2. 关键:dup2 将标准输出 1 重定向到 fd
dup2(fd, 1);
// 3. 关闭原 fd(已经复制到 1 了,关闭原来的文件描述符)
close(fd);
// 4. 下面的printf的内容将全部写入 log.txt
printf("这段文字被重定向了!\n");
printf("Hello, dup2!\n");
printf("重定向原理就是修改文件描述符的指向!\n");
// 刷新缓冲区
fflush(stdout);
return 0;
}
运行结果是屏幕上什么都不输出,而当前目录下会生成一个log.txt,printf的所有内容被输出到这个txt文件中。这就是标准的输出重定向。
标准的输入重定向的例子:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
// 以只读方式打开文件
int fd = open("test.txt", O_RDONLY);
// 输入重定向核心:将文件fd 复制到 标准输入 0
dup2(fd, 0);
// 关闭多余文件描述符
close(fd);
// 原本从键盘读取,现在从 test.txt 读取数据
char buf[128];
scanf("%s", buf);
printf("读取到的内容:%s\n");
return 0;
}
标准输入默认 fd 为 0,默认指向键盘设备。但是通过使用dup2(fd, 0)后,修改了文件描述符表下标 0 的指向,让原本指向键盘的 0,改为指向普通文件。这就是输入重定向。
追加重定向的例子:
// 追加重定向:内容追加到文件末尾,不覆盖原有内容
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main() {
// 追加重定向核心:打开文件时用 O_APPEND
int fd = open("append.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
// 把标准输出 1 重定向到文件
dup2(fd, 1);
// 关闭无用 fd
close(fd);
// 写入内容,会追加在文件末尾
printf("这是追加的第一行内容\n");
printf("这是追加的第二行内容\n");
fflush(stdout);
return 0;
}
追加重定向和普通输出重定向原理完全一样,唯一区别是打开文件时的标志位不同。普通重定向 >是O_TRUNC(清空文件再写),而追加重定向 >>是O_APPEND(在文件末尾写)。
3.深刻理解什么叫缓冲区
3.1文件打开和读写的基本过程
当我们打开一个文件时,首先我们需要找到这个文件,然后操作系统就会在内核帮我们创建struct file{}这个文件属性结构体,这个结构体中同时保存了一张文件的操作表,这个操作表中包含着用户想如何操作这个文件的相关信息。同时还会存在一个dentry表,用于组织这些进程打开的文件。结构体中还存在一个内核级文件缓冲区。当我们读取文件内容,例如使用read函数来读取文件内容(read(int fd, void *buf, size_t count))时,必须把磁盘中对应的文件的内容加载到文件缓冲区中,然后创建一个buffer缓冲区;再跟据传入的文件描述符找到对应的文件,检测文件缓冲区中是否有内容。如果文件缓冲区中有内容就读取;如果没有内容那么进程就会阻塞,等待操作系统从磁盘中将文件内容加载到文件缓冲区中。接着操作系统将进程唤醒,继续调用read函数读取文件内容。
所以文件读取的本质就是从文件缓冲区中把内容读取到read定义的buffer缓冲区当中。也就是说read函数本质是拷贝函数!
同理可得,当我们想要向对应文件进行写入的时候,就是将我们想要写入的内容放在write函数定义的buffer缓冲区中,然后将缓冲区里的内容拷贝到文件缓冲区中。最后,操作系统再将缓冲区中的内容刷新到磁盘中。也就是说wrtie函数本质上也是拷贝函数!
所以,在文件的IO操作中,大多数情况下都是需要经历先加载,再修改,再刷新这三个过程。也就是说,内容的拷贝工作,必须是内存级的!
正因为这些系统调用在进行文件操作时需要进行先加载,再修改,再刷新这多个过程,数据需要进行来回的拷贝工作,所以使用系统调用是有成本的,会比较慢一些。
3.2引入缓冲区
既然系统调用是有成本的,那我们应该怎么做来减少这种成本呢?
减少系统调用的成本,核心思想就是要尽量减少系统调用的次数。
于是C语言登场了。
C语言在为我们封装系统调用的同时,还提供了输入和输出缓冲区。它在用户层提前开辟一段内存空间,我们调用 printf、fputs、fread 这类库函数时,数据并不会立刻发起系统调用进入内核,而是先攒到这个用户层缓冲区里。
直到缓冲区满了、遇到换行符、手动调用 fflush,或者程序正常退出时,C 语言才会一次性执行系统调用,把攒好的一大块数据批量提交给内核。这样,通过一次系统调用,能完成大批量数据的读写。这就用极少的系统调用次数,完成了大量的 I/O 操作,完美降低了用户态与内核态切换带来的开销,极大提升了 I/O 效率。
这就是 C 语言标准 I/O 缓冲区存在的核心意义 ——用用户层缓冲,减少系统调用的次数,从而降低系统调用的成本。
所有以我们以前所谈的缓冲区指的是语言级缓冲区,不是内核缓冲区。
FILE *fopen(const char *path, const char *mode),在调用这个C语言标准库提供的文件打开接口时,会自动在内部malloc一个FILE对象,缓冲区就在FILE对象中。这个FILE结构体内部除了封装(stdin/stdout/stderr)这三个文件描述符外,还包含输入输出缓冲区:
struct FILE
{
int fd; // 底层的文件描述符
char inbuffer[]; // 输入缓冲区(读操作时用)
char outbuffer[]; // 输出缓冲区(写操作时用)
// ... 其他状态标志、缓冲区大小、当前位置等
};
这也就是为什么我们在使用fputs,fgets这样的库函数时,我们需要传入一个FILE*stream对象。这样做就是为了把我们传入的字符串拷贝到结构体中,当C语言程序结束时自动刷新一次,这样就减少了系统调用的次数,提高了读写的效率。
缓冲区相关的char *指针保存在FILE结构体内部,如下图:

输入和输出缓冲区会在程序结束时自动做一次刷新,然后使用fclose()函数关闭文件描述符,刷新缓冲区,并释放这个文件指针指向的FILE对象。
语言级缓冲区和内核文件缓冲区刷新方式对比
语言级缓冲区是 C 标准库为减少系统调用次数而在用户态维护的,刷新规则由目标文件类型决定:
1.终端默认行缓冲(遇\n即刷)
2.普通文件默认全缓冲(缓冲区满才刷)
3.进程正常退出也会自动刷新
而内核文件缓冲区(页缓存)是操作系统为减少磁盘 I/O 设计的,两者分属用户态与内核态,是完全不同层级的优化机制,重定向时缓冲区模式会变化,若不注意刷新时机很容易导致数据丢失。
显示器行刷新时,遇到\n会把\n前的数据全部刷新出来,这种就是终端默认行缓冲
标准输出(fd=1)和标准错误(fd=2)默认都输出到显示器,但它们是两个独立的文件描述符,可以单独重定向,实现消息分离,也能合并输出到同一个文件。
我们可以把错误信息单独重定向到 err.txt,让正常日志和错误日志完全分离;也可以将标准输出 + 标准错误全部重定向到同一个文件,统一收集所有输出。重定向的本质依旧是修改文件描述符指向,只操作内核结构,上层语言完全无感知。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)