亲爱的读者朋友们😃,此文开启知识盛宴与思想碰撞🎉。
快来参与讨论💬,点赞👍、收藏⭐、分享📤,共创活力社区。
在 Linux 系统中,文件输入输出(IO)操作是程序开发的基础部分,它涉及从简单的文件读写到复杂的系统调用和缓冲区管理。本文将基于相关知识进行详细梳理,帮助读者深入理解 Linux 基础 IO 的核心概念和操作原理。
目录
一、文件的多元理解
1.1 狭义与广义认知
狭义:
- 文件在磁盘里
- 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
- 磁盘是外设(即是输出设备也是输入设备)
- 磁盘上的文件 本质是对文件的所有操作,都是对外设的输入和输出 简称 IO
广义:
在 Linux “一切皆文件” 的理念下,键盘、显示器、网卡等硬件设备都被抽象为文件。这种广义理解极大地简化了系统资源的访问方式,开发者可以使用统一的文件操作接口来与不同设备交互。
1.2 文件构成与操作本质
文件由文件属性(元数据)和文件内容组成。所有文件操作归根结底是对文件内容和属性的操作,⭐而这些操作在系统层面是由进程执行的,进程通过系统调用接口来实现文件的读写等操作,C 语言的库函数只是为用户提供了更便捷的调用方式🌈。
二、以C语言为主,先回忆一下C接口
2.1 文件打开、读写与关闭
C 标准库提供了一系列文件操作函数,如fopen
用于打开文件,其打开路径默认是程序当前路径,可通过/proc/[进程id]/cwd
查看。fwrite
和fread
分别用于文件的写入和读取,在使用fread
时需注意返回值和参数设置。文件使用完毕后,要用fclose
关闭,以释放资源。
fopen
是 C 语言中用于打开文件的函数,其函数原型为:
FILE *fopen(const char *filename, const char *mode);
特性
- 文件打开模式:
mode
参数决定了文件的打开方式,常见的有:
r
:以只读方式打开文本文件。文件必须存在,否则打开失败。w
:以写入方式打开文本文件。若文件不存在则创建;若存在则会清空文件原有内容 ,从头开始写入新数据。a
:以追加方式打开文本文件。文件不存在时创建;存在时则定位到文件末尾,新写入的数据会追加在原有内容之后。- 返回值:函数调用成功时返回一个指向
FILE
类型的指针,后续对文件的读写操作可通过该指针进行;若打开失败则返回NULL
。
FILE- 与操作系统交互:
fopen
函数依赖操作系统提供的文件管理功能,其文件打开模式的特性(如w
模式清空文件)是操作系统文件系统的特性,并非 C 语言本身独有。
代码示例
#include <stdio.h>
#include <stdlib.h>
int main() {
// 以写入模式打开文件
FILE *writeFile = fopen("test.txt", "w");
if (writeFile == NULL) {
perror("Failed to open file for writing");
return 1;
}
fprintf(writeFile, "This is a test content.\n");
fclose(writeFile);
// 以追加模式打开文件
FILE *appendFile = fopen("test.txt", "a");
if (appendFile == NULL) {
perror("Failed to open file for appending");
return 1;
}
fprintf(appendFile, "This content is appended.\n");
fclose(appendFile);
// 以只读模式打开文件
FILE *readFile = fopen("test.txt", "r");
if (readFile == NULL) {
perror("Failed to open file for reading");
return 1;
}
char buffer[100];
while (fgets(buffer, sizeof(buffer), readFile) != NULL) {
printf("%s", buffer);
}
fclose(readFile);
return 0;
}
上述代码中:
- 首先以
w
模式打开文件test.txt
,写入一行文本,此时若文件存在会被清空。- 接着以
a
模式打开同一文件,追加写入一行文本。- 最后以
r
模式打开文件,读取并打印文件内容,展示了不同打开模式的效果。每次操作完成后,都使用fclose
函数关闭文件,以释放相关资源。
fwrite
函数原型为size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream)
,其中ptr
是指向要写入的数据块的指针;size
是每个数据块的大小(以字节为单位);count
是要写入的数据块的数量;stream
是指向目标文件的指针。返回值是实际写入的数据块数目。fprintf
函数原型为int fprintf(FILE *stream, const char *format, ...)
,stream
是指向要写入的文件的指针;format
是格式化字符串。
2.2 标准输入输出流
C 默认打开stdin
(标准输入) 0 、stdout
(标准输出) 1 和stderr
(标准错误) 2 三个流,它们的类型都是FILE*
。可以使用fwrite
、printf
、fprintf
等函数向stdout
输出信息,不同函数在输出方式和格式化上各有特点。
2.3 文件打开模式
文件打开模式决定了文件的访问权限和初始位置,如r
表示只读打开,w
表示只写打开并截断文件,a
表示追加写等。正确选择打开模式对文件操作的准确性和安全性至关重要。
三、系统文件 I/O 详解
3.1 系统调用接口与标志位传递
系统文件 IO 提供了底层的文件访问方式,以open
函数为例,它可以通过标志位指定打开文件的方式。标志位采用位运算进行组合,如O_RDONLY
(只读)、O_WRONLY
(只写)、O_CREAT
(创建文件)等,开发者可根据实际需求进行灵活设置。
#include <stdio.h>
// 定义标志位 ONE,对应的二进制为 0000 0001
#define ONE 0001
// 定义标志位 TWO,对应的二进制为 0000 0010
#define TWO 0002
// 定义标志位 THREE,对应的二进制为 0000 0100
#define THREE 0004
// 该函数用于根据传入的标志位判断包含哪些特定标志并输出信息
void func(int flags) {
// 通过位与运算判断 flags 中是否包含 ONE 标志位
if (flags & ONE) printf("flags has ONE! ");
// 通过位与运算判断 flags 中是否包含 TWO 标志位
if (flags & TWO) printf("flags has TWO! ");
// 通过位与运算判断 flags 中是否包含 THREE 标志位
if (flags & THREE) printf("flags has THREE! ");
// 换行
printf("\n");
}
int main() {
// 调用 func 函数,传入 ONE 标志位
func(ONE);
// 调用 func 函数,传入 THREE 标志位
func(THREE);
// 调用 func 函数,传入通过位或运算组合的 ONE 和 TWO 标志位
func(ONE | TWO);
// 调用 func 函数,传入通过位或运算组合的 ONE、TWO 和 THREE 标志位
func(ONE | THREE | TWO);
// 程序正常结束返回 0
return 0;
}
open
函数是用于打开或创建文件的系统调用
函数原型:
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数解释
pathname
:这是一个字符串,代表要打开或创建的文件的路径。它可以是绝对路径(例如/home/user/file.txt
),也可以是相对路径(例如file.txt
)。flags
:该参数是一个整数,用来指定文件的打开方式。以下是一些常见的标志:
O_RDONLY
:以只读模式打开文件。O_WRONLY
:以只写模式打开文件。O_RDWR
:以读写模式打开文件。O_CREAT
:若文件不存在,则创建该文件。使用此标志时,需要提供第三个参数mode
来指定文件的权限。O_EXCL
:与O_CREAT
一起使用时,如果文件已经存在,则open
调用会失败。O_TRUNC
:若文件存在且以写模式打开,则将文件截断为长度为 0。O_APPEND
:以追加模式打开文件,写入数据时会追加到文件末尾。mode
:当使用O_CREAT
标志时,需要提供这个参数来指定新创建文件的权限。它是一个八进制数,例如0644
表示文件所有者有读写权限,而组用户和其他用户只有读权限。
返回值
- 若
open
调用成功,会返回一个新的文件描述符(一个非负整数),后续可使用该文件描述符进行文件操作。- 若调用失败,会返回 -1,并设置
errno
来指示错误类型。
3.2 文件描述符
文件描述符是一个小整数,Linux 进程默认有 0(标准输入)、1(标准输出)、2(标准错误)三个缺省打开的文件描述符。文件描述符本质是进程打开文件表中对应文件指针数组的下标,通过它可以找到对应的文件。其分配规则是在数组中寻找当前未使用的最小下标,这一特性在文件操作中具有重要意义。(高效利用资源)
3.3 重定向原理与实现
重定向是改变文件描述符默认指向的操作,常见的重定向符号有>
(输出重定向)、>>
(追加输出重定向)、<
(输入重定向)。
通过关闭特定的文件描述符并重新打开文件,可以实现重定向。
// 标准输出重定向函数
void redirect_stdout(const char *filename) {
// 关闭标准输出文件描述符
close(STDOUT_FILENO);
// 以写入和创建模式打开文件,若文件存在则截断内容
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
// 此时新打开的文件会占用之前关闭的标准输出文件描述符(1)
printf("这行内容会被重定向到文件中。\n");
close(fd);
}
dup2
系统调用也可用于实现重定向,它能将一个文件描述符的内容复制到另一个文件描述符,从而改变数据的流向。
下面将详细介绍 dup2
函数的用法:
函数原型
#include <unistd.h>
int dup2(int oldfd, int newfd);
参数解释
oldfd
:这是已存在的文件描述符,⭐dup2
会将这个文件描述符所引用的文件表项复制到newfd
所代表的文件描述符上。newfd
:这是目标文件描述符,dup2
会把oldfd
的文件表项复制到这里。如果newfd
之前已经打开了一个文件,dup2
会先关闭newfd
对应的文件,然后再进行复制操作。
返回值
- 若调用成功,
dup2
会返回newfd
,也就是复制后的文件描述符。- 若调用失败,会返回 -1,并设置
errno
来指示具体的错误类型。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
int main() {
int fd;
// 以写入模式打开文件,如果文件不存在则创建,存在则截断内容
fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
return 1;
}
// 使用 dup2 将标准输出(文件描述符为 1)重定向到新打开的文件
if (dup2(fd, STDOUT_FILENO) == -1) {
perror("dup2");
close(fd);
return 1;
}
// 关闭原文件描述符,因为已经完成复制
close(fd);
// 此时 printf 的输出会重定向到文件中
printf("这行内容会被重定向到 output.txt 文件中。\n");
return 0;
}
那重定向的本质是什么呢?
⭐重定向的实现原理:
重定向的核心是改变文件描述符对应的数据流向。在操作系统中,每个进程有标准输入(文件描述符 0,默认连键盘)、标准输出(文件描述符 1,默认连屏幕)和标准错误输出(文件描述符 2,默认连屏幕)。重定向操作就是修改这些文件描述符的连接目标:
- 输出重定向:
>
将标准输出重定向到文件,覆盖原有内容;>>
同样重定向到文件,但追加内容。- 输入重定向:
<
把标准输入从键盘重定向到文件。- 错误输出重定向:
2>
将标准错误输出重定向到文件。
四、“一切皆文件” 的深度剖析
在 Linux 系统中,不仅常规文件,进程、硬件设备、管道、套接字等都被视为文件。操作系统为每个打开的文件创建file
结构体,其中的f_op
指针指向file_operations
结构体,该结构体中的函数指针对应着各种系统调用,如read
、write
等。这种机制使得开发者可以用统一的文件操作接口来访问不同类型的资源,极大地简化了开发过程,提高了代码的通用性和可维护性。
上图中的外设,每个设备都可以有自己的 read、write,但一定是对应着不同的操作方法!😎 但通过
struct file
下file_operation
中的各种函数回调,让我们开发者只用 file 便可调取 Linux 系统中绝大部分的资源!👏 这便是 “linux 下一切皆文件” 的核心理解。😄
五、缓冲区机制探究
5.1 引入一些奇怪的现象
- 现象 1:在向文件写入时,write 函数的内容会先被打印出来。通过如下代码验证:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
const char *fstr = "hello fwrite\n";
const char *str = "hello write\n";
printf("hello printf\n"); // stdout -> 1
fprintf(stdout, "hello fprintf\n"); // stdout -> 1
fwrite(fstr, strlen(fstr), 1, stdout); // fread, stdout->1
write(1, str, strlen(str)); // 1
return 0;
}
当直接运行程序时,输出顺序为:
当将输出重定向到文件(./text > log.txt
)后,用 cat log.txt
查看,输出顺序变为:
这是因为 printf
、fprintf
、fwrite
这些 C 库函数有缓冲区,数据先存缓冲区,而 write
是系统调用,直接操作,所以先被打印。重定向时,缓冲区刷新策略改变,之前在缓冲区的数据后被刷新到文件。
变化如下:
输出到显示器时
此时标准输出采用行缓冲策略,即:
- 数据先写入用户空间的缓冲区,当遇到换行符(
\n
)、缓冲区被填满、程序正常结束 ,或者调用了fflush
等刷新函数时,缓冲区中的数据才会被刷新到内核缓冲区,进而显示在屏幕上。在代码printf("hello printf\n");
、fprintf(stdout, "hello fprintf\n");
、fwrite(fstr, strlen(fstr), 1, stdout);
中,输出的内容带有换行符,满足行缓冲的刷新条件,所以会依次输出。- 像
write
这类系统调用函数,它直接操作内核缓冲区,不经过 C 库的用户缓冲区,数据会立即被写入,所以write
的内容也能及时显示 ,因此运行程序时看到的输出顺序是按代码顺序来的。重定向到普通文件时
这种情况下标准输出采用全缓冲策略,即:
printf
、fprintf
、fwrite
这类 C 库函数写入的数据先暂存于用户缓冲区,直到缓冲区被填满,或者程序结束,又或者主动调用fflush
函数时,数据才会被刷新到内核缓冲区,最终写入文件。在重定向输出时,printf
、fprintf
、fwrite
这几个函数输出的数据因为缓冲区未满且未主动刷新,所以一直留在缓冲区中 。- 而
write
作为系统调用,不依赖 C 库缓冲区,会直接将数据写入文件,所以在使用cat
查看重定向文件内容时,write
的内容先出现。直到程序结束,缓冲区统一刷新,printf
、fprintf
、fwrite
对应的数据才依次写入文件,这就导致用cat
查看文件时出现了和直接在终端运行程序不同的输出顺序。
- 现象 2:加入
fork
之后,向文件写入时 C 接口会被调两次,且write
先被打印。代码如下:
int main()
{
const char *fstr = "hello fwrite\n";
const char *str = "hello write\n";
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
fwrite(fstr, strlen(fstr), 1, stdout);
write(1, str, strlen(str));
/*函数原型:ssize_t write(int fd, const void *buf, size_t count)
fd:文件描述符,通过open等函数打开文件或设备得到,用于指定要写入的目标。
buf:指向要写入数据的缓冲区的指针,可以是字符数组等。
count:要写入的字节数。
返回值:成功时返回实际写入的字节数(可能小于count);失败时返回-1 */
fork();
return 0;
}
和现象 1 类似,前三个 C 接口数据存缓冲区,write
先输出。
fork
时,子进程复制父进程缓冲区数据,当进程退出刷新缓冲区,所以 C 接口数据被打印两次。
- 现象 3:关闭文件描述符 1(
close(1)
)后,就没有结果输出。代码如下:
int main()
{
const char *str = "hello write";
write(1, str, strlen(str));
fprintf(stdout,str);
close(1);
return 0;
}
这段代码运行时没有完整输出结果,主要是因为标准输出缓冲区和文件描述符关闭机制的影响,下面为你详细解释:
1. 标准输出的缓冲机制
在 C 语言中,标准输出(
stdout
,文件描述符为 1)存在缓冲区。当你使用printf
、fprintf
等 C 库函数进行输出时,数据并不会立即被输出到终端,而是先被存储在用户空间的缓冲区中。缓冲区的刷新时机取决于不同的缓冲策略,常见的有行缓冲、全缓冲和无缓冲:
- 行缓冲:当遇到换行符(
\n
)、缓冲区满或者手动调用fflush
函数时,缓冲区中的数据会被刷新到内核,进而输出到终端。标准输出连接到终端时默认采用行缓冲策略。- 全缓冲:只有当缓冲区满、程序正常结束或者手动调用
fflush
函数时,缓冲区中的数据才会被刷新。标准输出重定向到文件时通常采用全缓冲策略。- 无缓冲:数据会立即被输出,不会进行缓冲。标准错误输出(
stderr
)通常采用无缓冲策略。2. 代码中
write
函数的执行情况在代码里,
write
是一个系统调用函数,它直接将数据写入内核,不经过 C 库的用户缓冲区。当执行write(1, str, strlen(str));
时,"hello write"
会被直接写入到标准输出对应的内核缓冲区。但由于字符串末尾没有换行符,在行缓冲策略下,这些数据可能还没有被刷新到终端。3.
close(1)
的影响
close(1)
会关闭标准输出的文件描述符。文件描述符是操作系统用来标识打开文件的整数,关闭文件描述符后,后续无法再通过该文件描述符进行读写操作。由于之前write
写入的数据还在标准输出的内核缓冲区中,没有被刷新到终端,关闭文件描述符后,这些数据就无法再被输出,因此看不到完整的输出结果。解决方案
若要确保数据能完整输出,可以在关闭文件描述符之前手动刷新缓冲区。以下是修改后的代码:
#include <stdio.h> #include <unistd.h> #include <string.h> int main() { const char *str = "hello write\n"; // 添加换行符,触发行缓冲刷新 write(1, str, strlen(str)); fprintf(stdout,str); // 或者使用 fflush(stdout); 手动刷新标准输出缓冲区 close(1); return 0; }
![]()
在上述代码中,给字符串添加了换行符
\n
,这样在执行write
时,由于行缓冲策略,数据会被立即刷新到终端。或者使用fflush(stdout);
手动刷新标准输出的缓冲区,也能确保数据完整输出。
5.2 缓冲区不在操作系统内部
通过现象 3 可知,
close
作为系统调用接口不能在关闭文件前刷新缓冲区,说明操作系统看不到这个缓冲区。printf
、fprintf
、fwrite
等 C 库函数提供了一个缓冲区,先将内容存于此,需要刷新时才调用write
函数写入。进程退出时会刷新缓冲区,但close
后,文件描述符关闭,即使调用write
也无法写入,缓冲区数据被丢弃。
5.3 缓冲区的刷新策略
- 无缓冲:数据直接刷新,常见于
stderr
,比如fputs
操作直接刷新到显示器文件。- 行缓冲:遇到换行符时刷新,像标准输出到显示器默认是行缓冲 ,数据先存缓冲区,遇到换行才写入。
- 全缓存:缓冲区写满或者调用
fflush
等函数强制刷新时,数据才写入,普通文件默认是全缓存。
不同策略是为满足不同场景需求。显示器行刷新可及时显示信息,普通文件全缓存可减少系统调用次数提升效率。
5.4 为什么要有缓冲区
- 方便使用:如同寄快递,不用自己长途运送物品,交给快递站更省心。数据先存缓冲区,不用每次小量数据就直接操作硬件,使用更便捷。
- 提高效率:攒够一定量数据再操作硬件,类似装满一袋快递再运送,减少操作次数,提高整体效率。
- 配合格式化:比如 C 语言中整数格式化后再写入,可在缓冲区完成格式化操作,减少对硬件的直接频繁操作。
5.5 用户层缓冲区在哪
exit
函数会调用 fflush
刷新缓冲区数据。fflush
函数操作的是 FILE*
类型指针指向的流对应的缓冲区。FILE
结构体不仅封装了文件描述符 fd
信息,还维护了对应文件的缓冲区字段和文件信息。打开一个文件就有专属缓冲区,通过 fprintf
等函数操作文件时,数据先进入该缓冲区。
5.6 内核缓冲区在哪
内核缓冲区由操作系统的 file
结构体维护,和用户层缓冲区类似。用户一般无需关心其刷新时间,只要数据刷新到内核,就能到达硬件 ,因为现代操作系统会高效管理,避免浪费资源。
通过对 Linux 基础 IO 各个方面的梳理,我们对文件操作、系统调用、缓冲区机制等有了更深入的理解。这些知识是 Linux 系统开发的重要基础,对于开发者编写高效、稳定的程序具有重要指导意义。在实际开发中,应根据具体需求合理选择文件操作方式和缓冲区策略,以充分发挥 Linux 系统的优势。