【Linux】基础 I/O - 指南
目录
1. “文件”究竟是什么?
文件由两大组成部分构成:文件属性(元数据)和文件内容。
在文件未被打开时,所有数据都存储在磁盘等持久化存储设备中。当进程需要访问文件时,通过系统调用向操作系统发起请求,内核将文件的元数据加载到内存,并建立文件访问的上下文环境。
那么,Linux系统是如何高效地管理文件存储的呢?
核心思路遵循经典的"先描述,后组织"设计哲学。在C语言实现中,这种设计模式通常通过结构体来体现。Linux内核为每个文件创建了专门的结构体来进行精确描述,这些结构体相互关联,形成一个完整的管理体系,从而实现对文件的系统化组织与高效管理。
这样的设计不仅确保了文件操作的稳定性和可靠性,还为系统提供了灵活的文件管理能力。
结构如下图所示:
在内存中:每次打开文件时创建,描述文件的打开状态。
struct file {
struct path f_path; // 文件路径
struct inode *f_inode; // 对应的inode——磁盘层面的文件描述
const struct file_operations *f_op; // 文件操作函数
mode_t f_mode; // 打开模式
loff_t f_pos; // 文件当前位置指针
unsigned int f_flags; // 打开标志
atomic_long_t f_count; // 引用计数
// ...
};
进程中的文件管理:
struct task_struct {
// ...
struct files_struct *files; // 打开文件表
// ...
};struct files_struct {
atomic_t count; // 引用计数
struct fdtable *fdt; // 文件描述符表
struct file **fd_array; // 文件指针数组
unsigned int next_fd; // 下一个可用的fd
// ...
};
2. C语言中的文件接口
2.1 回顾
我们先简单回顾几个C语言中的文件接口:
#include <stdio.h>
// 打开/关闭文件
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);// 二进制读写
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
实际操作一下:
在Linux下,一切皆文件,所以显示器和键盘其实也是文件,我们之所以能使用printf在显示器上输出内容,本质上是将内容写到了显示器文件里。但文件在使用前都需要先打开,而我们在执行这段程序时,并没有打开显示器文件,那我们是怎么把内容写到显示器文件中的呢?
2.2 stdin/stdout/stderr
C默认会打开三个输入输出流:stdin/stdout/stderr,不需要用户自己打开就能直接使用
#include
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
这三个文件是我们在进程创建时就会自动打开。
3. 文件描述符(fd)
文件描述符(File Descriptor)本质上是进程文件描述符表的索引。其实我们在第一部分的图中就已经提到了,在文件中可以理解为file_struct中的file数组下标。
每个进程启动时自动获得三个标准描述符:0(stdin标准输入)、1(stdout标准输出)和2(stderr标准错误输出)。当使用open()打开文件时,内核会返回一个新的文件描述符(从最小的未使用的fd开始),这个数字代表该进程与打开文件之间的连接通道。
通过这个整数标识符,进程可以使用open()、write()等系统调用对文件进行读写操作,所有I/O资源都可以通过文件描述符这个统一的接口来访问,实现了Linux“一切皆文件”的设计哲学。
4. 系统文件 I/O
打开文件的方式不仅仅是fopen,ifstream等流式,语言层的方案,其实系统才是打开文件最底层的方案,fopen、fwrite...都是封装的系统文件。下面我们来学习一次系统层面是如何打开文件操作的。
4.1 位掩码技术
在Linux C/C++开发中,传递标志位最专业的方式是使用位掩码技术。通过 #define 定义每个标志的二进制位位置,然后将多个标志用按位或运算符组合后传递给函数。在函数内部使用按位与运算检查特定标志是否被设置。这种方法就是Linux系统调用(如open())的设计理念,单整数参数可传递多达32个独立标志,既高效又灵活,还能保持代码的简洁性和可读性。
我们先来写一段代码模仿一下Linux下系统调用中的open函数,认识一下这种位掩码技术:
#include
#include
// 模仿系统调用的标志定义
#define MY_O_READ (1 << 0)
#define MY_O_WRITE (1 << 1)
#define MY_O_CREATE (1 << 2)
#define MY_O_TRUNC (1 << 3)
#define MY_O_APPEND (1 << 4)
#define MY_O_SYNC (1 << 5)
int my_open(const char* filename, int flags) {
printf("Opening file: %s\n", filename);
if (flags & MY_O_READ) {
printf(" Read access requested\n");
}
if (flags & MY_O_WRITE) {
printf(" Write access requested\n");
}
if (flags & MY_O_CREATE) {
printf(" Create file if not exists\n");
}
if (flags & MY_O_TRUNC) {
printf(" Truncate file to zero length\n");
}
if (flags & MY_O_APPEND) {
printf(" Append mode\n");
}
if (flags & MY_O_SYNC) {
printf(" Synchronized I/O\n");
}
// 检查有效的标志组合
if ((flags & MY_O_READ) && (flags & MY_O_WRITE)) {
printf(" Read-write mode\n");
}
// 实际的文件打开逻辑
return 0; // 返回文件描述符
}
int main() {
// 类似系统调用的使用方式
my_open("file.txt", MY_O_READ | MY_O_WRITE | MY_O_CREATE);
my_open("log.txt", MY_O_WRITE | MY_O_APPEND | MY_O_CREATE);
return 0;
}
运行结果:
4.2 open函数
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
该函数返回值为被打开文件的文件描述符(fd)
pathname:要创建或打开的文件路径,也可以直接写文件名(默认创建或打开当前路径下的文件)
flags:打开方式标志位,使用按位或 | 组合:
基本访问模式(必须指定其一):
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:读写
常用可选标志:
O_CREAT:文件不存在则创建
O_TRUNC:打开时清空文件
O_APPEND:追加模式
mode: 创建文件时的权限(八进制数),仅在O_CREAT存在的情况下有效,并且这里设置的文件权限会受umask值的影响。
4.3 write函数
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
fd:文件描述符
buf:指向要写入数据的起始指针
count:要写入的字节数
接下来我们使用一下open和write:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7 int main()
8 {
9 int fd = open("log.txt",O_WRONLY|O_CREAT);
10 char* s = "hello myfile\n";
11 write(fd,s,strlen(s));
12 printf("%d\n",fd);
13 close(fd);
14 return 0;
15 }
运行结果:
5. 重定向
通过上面的知识,我们可以理解:printf这样的函数之所以可以将内容打印到显示器上,其实是因为它们默认将内容写入标准输出流 stdout。在 Linux 系统中,stdout在进程启动时自动关联到文件描述符 1,这个描述符默认指向显示设备。当程序调用这些输出函数时,数据首先被写入stdout 缓冲区,然后通过文件描述符 1 的写入操作最终传递给终端设备驱动程序,由驱动程序控制显示器显示内容。整个过程实现了从用户空间到内核空间,再到硬件设备的完整数据流传递。
那么,既然如此,我们要是手动关闭stdout,让其他文件打开到文件描述符1的位置,是不是就可以实现重定向的功能呢?
我们还是来用事实说话:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7 int main()
8 {
9 close(1);//关闭stdout
10 int fd = open("log.txt",O_WRONLY|O_CREAT|O_APPEND);//文件会自动占用文件描述符1的位置
11 printf("hello fd:%d\n",fd);
12 printf("mystdout\n");
13 // close(fd);
14 return 0;
15 }
现在我们就能理解操作系统中重定向大概是怎么实现的了。
6. 缓冲区
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间是用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。
缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
因为计算机对缓冲区的操作大大快于对磁盘的操作,所以缓冲区通过减少磁盘的读写次数,大大提高了计算机的运行速度。
6.1 三种缓冲模式
在标准I/O库中,FILE结构体封装了底层的文件描述符,并提供了三种缓冲机制来优化I/O性能:
6.1.1 全缓冲(_IOFBF)
默认用于普通文件操作
缓冲区满或手动调用fflush()时才会刷新
缓冲区大小通常为4KB或8KB,与系统页大小对齐
6.1.2 行缓冲(_IOLBF)
默认用于终端设备(stdin、stdout)
遇到换行符\n、缓冲区满或手动刷新时触发写入
6.1.3 无缓冲(_IONBF)
默认用于stderr错误输出
每次I/O操作都立即写入底层设备
确保错误信息及时显示,不因缓冲而延迟
6.2 FILE
在标准I/O库中的FILE结构:用户态标准I/O库的封装,提供缓冲机制。
// 在标准C库中(如glibc)
typedef struct _IO_FILE FILE;struct _IO_FILE {
int _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; // 写缓冲区结束位置
int _fileno; // 文件描述符
// ...
};
6.3 实操理解缓冲区
这里我们重新写了一个重定向的文件,本来是想实现和前面一样的功能,将printf打印的内容打印到log.txt文件中,但是这里我们什么都没看到,这是为什么呢?
这是因为我们把1号文件描述符重定向到普通的磁盘文件中,刷新方式从行缓冲变成了全缓冲,我们写的内容肯定没有装满缓冲区,所以没有刷新,此时我们可以手动刷新一下,就可以让内容刷新出来了。