一、系统文件I/O
日常开发中打开文件的方式多为 fopen、ifstream 等语言层流式接口,而系统文件I/O是操作系统提供的最底层文件操作方案 ,所有高级语言的文件操作接口本质都是对系统I/O接口的封装。在学习系统文件I/O前,需要先掌握系统接口中高频使用的标志位传递方法。
1.1 标志位传递方法
系统I/O的函数参数常需要同时传入多个功能选项,这类选项通过宏定义整数 + 按位或运算实现多参数叠加,搭配按位与运算判断标志位是否生效,是Linux系统调用的通用传参方式。
原理:每个宏对应二进制中唯一的一位有效位,多个宏通过 | 运算可叠加多个功能,通过 & 运算可精准判断对应标志位是否被启用。
cpp
#include <stdio.h>
// 二进制单标志位宏定义,每一个宏仅占用一个独立二进制位
#define ONE 0001
// 0000 0001
#define TWO 0002
// 0000 0010
#define THREE 0004
// 0000 0100
// 解析传入的标志位参数
void 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);
// 仅启用ONE标志
func(THREE);
// 仅启用THREE标志
func(ONE | TWO);
// 同时启用ONE、TWO标志
func(ONE | THREE | TWO);
// 同时启用全部标志
return 0;
}
1.2 系统I/O写文件案例
通过系统调用 open、write、close 实现文件创建与内容写入,区别于C库函数,属于内核级原生文件操作。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
// 清空文件权限掩码,保证创建文件的权限与设定一致
umask(0);
// 打开/创建文件:只写、不存在则创建,权限0644
int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
if(fd < 0)
{
// 文件打开失败返回-1
perror("open");
// 打印系统级错误信息
return 1;
}
int count = 5;
const char *msg = "hello bit!\n";
int len = strlen(msg);
// 循环5次写入内容
while(count--)
{
// 参数:文件描述符、数据缓冲区、写入字节数
write(fd, msg, len);
}
close(fd);
// 关闭文件,释放资源
return 0;
}
1.3 系统I/O读文件案例
通过 open 只读打开文件,搭配 read 系统调用读取文件内容,循环读取直至文件末尾。
cpp
#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;
}
char buf[1024];
// 定义读取数据的缓冲区
// 循环读取文件内容
while(1)
{
// 参数:文件描述符、接收缓冲区、期望读取字节数
ssize_t s = read(fd, buf, strlen("hello bit!\n"));
if(s > 0)
{
// 读取成功,返回实际读取字节数
printf("%s", buf);
}
else
{
// 读取完毕或读取失败,退出循环
break;
}
}
close(fd);
return 0;
}
1.4 open函数接口详解
可通过 man open 命令查看官方手册,函数需要引入对应头文件,提供两个重载接口。
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
// 打开已有文件
int open(const char *pathname, int flags);
// 创建并打开新文件
int open(const char *pathname, int flags, mode_t mode);
参数说明
-
pathname:字符串类型,指定目标文件的路径+文件名
-
flags:文件打开模式标志位,通过多个常量按位或组合使用,分为必选模式和辅助模式
-
必选模式(三选一且仅能选一个):O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)
-
辅助模式:O_CREAT(文件不存在则创建,必须搭配mode参数指定权限)、O_APPEND(追加写入,不覆盖原有内容)
-
-
mode:仅O_CREAT生效,指定新文件的访问权限(八进制数值,如0644)
返回值
-
成功:返回非负整数,即文件描述符
-
失败:返回-1,并设置全局错误变量errno
1.5 系统调用与库函数区别
文件操作函数分为两类,核心层级与调用逻辑完全不同:
-
C标准库函数:fopen、fclose、fread、fwrite,属于用户层库函数(libc),是对系统调用的二次封装,自带用户缓冲区,使用更便捷
-
系统调用函数:open、close、read、write、lseek,属于内核原生接口,无用户层缓冲区,是最底层的文件操作方式
简单理解:所有语言的流式文件操作,本质都是封装了系统调用,屏蔽了底层内核细节。
1.6 文件描述符fd
1.6.1 默认文件描述符
Linux进程启动后,默认自动打开三个文件描述符,全程默认占用,无需手动创建:
-
0:标准输入(stdin),默认对应键盘设备
-
1:标准输出(stdout),默认对应显示器设备
-
2:标准错误(stderr),默认对应显示器设备
文件描述符本质 :是进程内核结构体 files_struct 中文件指针数组的下标。进程通过files指针绑定files_struct结构体,该结构体维护一个指针数组,每个数组元素指向一个内核struct file文件对象,通过下标即可快速定位已打开文件,实现进程与文件的关联。
1.6.2 文件描述符分配规则
系统分配新文件描述符时,会遍历files_struct指针数组,选择当前未被占用的最小下标作为新的fd。默认0、1、2已占用,普通文件打开后默认从3开始分配。
1.6.3 文件重定向(dup2系统调用)
通过 dup2 系统调用可修改文件描述符的指向,实现文件重定向,需要引入头文件 <unistd.h>。
int dup2(int oldfd, int newfd);
函数逻辑:若newfd原本绑定了打开的文件,会先关闭原有文件连接,再让newfd强制指向oldfd对应的文件对象,最终两个文件描述符共享同一个内核文件对象。
Shell重定向对比:命令行 n>&m 表示将文件描述符n重定向到m指向的文件,与dup2功能逻辑一致,是系统重定向机制的上层应用。
二、理解Linux"一切皆文件"
"一切皆文件"是Linux的核心设计思想,统一了系统所有资源的操作方式,具体含义包含两层:
-
普通文件、目录文件在Linux中是标准文件;
-
Windows中不属于文件的硬件设备、进程、管道、网络接口等资源,在Linux中均被抽象为文件管理。
核心优势:开发者只需掌握 read、write、open、close 一套系统API,即可操作系统绝大多数软硬件资源,无需为不同设备适配专属接口。例如读文件、读管道、读设备状态均可使用read函数,写文件、改系统参数、推送管道数据均可使用write函数。
2.1 内核file结构体
进程每打开一个文件,内核都会创建一个struct file结构体描述该文件对象,结构体源码位于内核目录/usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h,核心成员如下:
cpp
struct file
{
struct inode *f_inode;
// 文件inode节点指针,存储文件底层信息
const struct file_operations *f_op;
// 文件操作方法表指针
atomic_long_t f_count;
// 文件引用计数,多fd指向同一文件时计数累加
unsigned int f_flags;
// 文件打开标志位(只读、追加等)
fmode_t f_mode;
// 文件访问权限模式
loff_t f_pos;
// 文件当前读写偏移位置
} __attribute__((aligned(4)));
2.2 file_operations操作方法表
struct file的f_op指针指向file_operations结构体,该结构体是系统调用与设备驱动的关联核心,内部除owner成员外,其余均为函数指针,对应各类文件操作的系统调用。
cpp
struct file_operations
{
struct module *owner;
// 模块归属指针
loff_t (*llseek) (struct file *, loff_t, int);
// 修改文件读写偏移
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
// 读设备/文件
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
// 写设备/文件
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
// 异步读
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, unsigned long); // 异步写
int (*readdir) (struct file *, void *, filldir_t);
// 读取目录(仅文件系统生效)
unsigned int (*poll) (struct file *, struct poll_table_struct *);
// 轮询检测
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
// 设备控制
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
};
核心原理:不同硬件、不同文件类型的读写逻辑完全不同,但内核通过file_operations统一接口规范。各类设备/文件在驱动层实现专属的read、write方法,系统调用时通过函数指针回调对应逻辑,实现统一接口、差异化实现,与C++虚函数多态机制原理一致。
三、缓冲区
3.1 缓冲区定义
缓冲区是内存中预留的一段临时存储空间,用于缓存输入、输出数据。根据数据流向可分为输入缓冲区 和输出缓冲区,是衔接高速CPU与低速IO设备的中间层。
3.2 缓冲区的作用
无缓冲区时,每次读写磁盘都需要触发一次系统调用,导致CPU频繁在用户态、内核态切换,产生大量性能损耗。
补充CPU特权级知识:Linux将CPU权限分为两级,保障系统安全稳定
-
用户态(Ring3):权限受限,无法直接操作硬件、访问内核内存,普通程序默认运行在用户态
-
内核态(Ring0):最高权限,可操作所有硬件、访问全部内存,系统调用时会切换至内核态
缓冲区核心价值:减少系统调用次数,提升IO效率,解放CPU
具体表现:一次性从磁盘读取大量数据存入缓冲区,程序后续读取直接从高速内存缓冲区获取,无需频繁访问低速磁盘;输出数据时先写入缓冲区,攒够数据后一次性落盘,大幅减少磁盘读写次数。同时可匹配低速外设(打印机、显示器)速度,让CPU无需等待IO完成,并行处理其他任务。
3.3 缓冲区分类(标准I/O)
C标准库提供三种缓冲区类型,默认刷新规则不同,适配不同使用场景:
1. 全缓冲区
默认用于磁盘文件操作。必须缓冲区填满后,才会触发系统调用完成IO操作,IO效率最高。
2. 行缓冲区
默认用于终端设备(标准输入、标准输出)。遇到换行符\n 或缓冲区填满(默认1024字节)时,触发缓冲区刷新、执行IO操作。
3. 无缓冲区
无缓存机制,数据写入后立即触发系统调用。标准错误输出stderr默认无缓冲区,保证错误信息实时输出。
通用缓冲区刷新条件
除默认触发规则外,三种缓冲区均可通过以下条件强制刷新:
-
缓冲区空间已满
-
主动调用fflush手动刷新
-
进程正常结束,系统自动刷新缓冲区
3.4 缓冲区核心案例解析
测试代码
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
const char *msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char *msg2="hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
// 创建子进程
return 0;
}
场景1:直接终端运行(./hello)
输出结果:
hello printf
hello fwrite
hello write
原理:终端属于外设,stdout默认是行缓冲模式。printf、fwrite的内容自带换行符,触发缓冲区自动刷新,数据立即输出到终端;write是系统调用,无用户缓冲区,直接输出数据,因此输出顺序与代码执行顺序一致。
场景2:重定向输出到文件(./hello > file)
文件输出结果:
hello write
hello printf
hello fwrite
hello printf
hello fwrite
现象说明:write数据仅输出一次,printf、fwrite数据重复输出两次。
核心原因解析
-
缓冲区模式切换 :输出重定向到文件后,stdout从「行缓冲」切换为「全缓冲」。代码中printf、fwrite的内容未填满缓冲区,且无手动刷新操作,数据会暂存到用户层缓冲区,不会立即写入文件;而write是原生系统调用,无用户缓冲区,数据直接写入文件。
-
fork写时拷贝机制 :fork创建子进程时,默认父子进程共享父进程的地址空间,包括未刷新的缓冲区数据。进程退出前,缓冲区刷新属于写操作,会触发Linux写时拷贝机制,父子进程各自拷贝一份独立的缓冲区数据。
-
重复输出根源:父进程、子进程退出时,会分别刷新各自的缓冲区,导致printf、fwrite缓存的数据被写入两次;write无用户缓冲区,无数据拷贝,仅写入一次。
关键结论
-
C库函数(printf、fwrite)自带用户层缓冲区,由libc库维护;
-
系统调用(write、read)无用户层缓冲区,仅存在内核层缓冲区(用户无法直接操作);
-
缓冲区刷新时机、进程拷贝机制,是IO输出异常的核心诱因。