【Linux系统】基础IO(下)
1. 系统文件IO
打开文件的方式不仅仅是通过高级语言提供的fopen、ifstream等流式API,这些其实都是建立在系统调用之上的封装。操作系统才是真正负责文件打开和管理的最底层实现方案。
在Unix/Linux系统中,最基础的文件IO操作是通过系统调用如open()、read()、write()等完成的。而在Windows系统中,则是通过CreateFile()、ReadFile()、WriteFile()等API实现的。这些系统级的文件IO接口通常比语言层的封装更底层,能够提供更精细的控制。
在学习这些系统文件IO接口之前,有一个重要的基础概念需要掌握:如何高效地传递标志位参数。这种方法在系统文件IO接口中被广泛使用。例如,Linux的open()系统调用就通过位掩码(bitmask)的方式接收多个标志位参数。
1.1 一种传递标志位的方法
下面代码通过C语言演示,系统调用是如何传递标志位的
#include <stdio.h>#define ONE_FLAG (1<<0) // 0000 0000 0000...0000 0001
#define TWO_FLAG (1<<1) // 0000 0000 0000...0000 0010
#define THREE_FLAG (1<<2) // 0000 0000 0000...0000 0100
#define FOUR_FLAG (1<<3) // 0000 0000 0000...0000 1000void Print(int flags)
{if(flags & ONE_FLAG){printf("One!\n");}if(flags & TWO_FLAG){printf("Two\n");}if(flags & THREE_FLAG){printf("Three\n");}if(flags & FOUR_FLAG){printf("Four\n");}
}int main()
{Print(ONE_FLAG);printf("\n");Print(ONE_FLAG | TWO_FLAG);printf("\n");Print(ONE_FLAG | TWO_FLAG | THREE_FLAG);printf("\n");Print(ONE_FLAG | TWO_FLAG | THREE_FLAG | FOUR_FLAG);printf("\n");Print(ONE_FLAG | FOUR_FLAG);printf("\n");return 0;
}
运行结果:
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ./flagbit
One!One!
TwoOne!
Two
ThreeOne!
Two
Three
FourOne!
Four
标志位定义:使用宏(如
#define ONE_FLAG (1<<0)
)定义每个标志位。(1<<n)
表示将1左移n位,生成一个唯一的位掩码(bitmask),例如:ONE_FLAG
对应位0(二进制...0001
)。TWO_FLAG
对应位1(二进制...0010
)。- 这些位互不重叠,确保每个标志位独立(一个比特位代表一种状态)。
标志位传递:在调用
Print
函数时,通过按位或(|
)操作组合多个标志位。例如:Print(ONE_FLAG | TWO_FLAG)
:将ONE_FLAG
(位0)和TWO_FLAG
(位1)组合,生成一个整数(如二进制...0011
),表示同时设置两个标志。- 按位或操作允许将多个选项打包到一个整数参数中,避免传递多个单独参数。
标志位检查:在
Print
函数内部,使用按位与(&
)操作检查每个标志位是否被设置。例如:flags & ONE_FLAG
:如果结果非零,表示ONE_FLAG
被设置。- 这种方法高效,因为位操作是CPU级别的指令,速度快。
这个机制的核心是“位图”(bitmap),一个整数的32个比特位可以表示多达32种独立状态(如果使用64位系统则更多),实现了用一个参数传递多个选项。
引入系统文件I/O中的标志位传递
在Linux系统文件I/O中,标志位传递采用相同的位操作机制,尤其是open
系统调用中。open
函数用于打开文件,其原型为:
#include <fcntl.h>
int open(const char *pathname, int flags, ... /* mode_t mode */);
flags参数的作用:
flags
是一个整数参数,用于指定文件打开方式(如只读、只写、创建文件等)。每个选项由一个预定义的宏表示,这些宏是位掩码,类似于代码中的ONE_FLAG
。如何传递标志位:
- 宏定义:系统头文件(如
fcntl.h
)定义了标准宏,例如:O_RDONLY
:只读(位掩码,如二进制...0001
)。O_WRONLY
:只写(位掩码,如二进制...0010
)。O_CREAT
:如果文件不存在则创建(位掩码,如二进制...1000
)。- 这些宏通过位移(如
(1<<n)
)定义,确保每个选项占用唯一比特位。
- 组合标志位:用户通过按位或(
|
)组合多个宏,传递一个整数给flags
参数。例如:open("file.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
表示以读写方式打开文件,如果不存在则创建,并截断文件。- 这类似于代码中的
Print(ONE_FLAG | TWO_FLAG)
。
- 内部检查:
open
函数内部使用按位与(&
)检查每个标志位是否设置,以执行相应操作(如if (flags & O_CREAT) { /* 创建文件 */ }
)。
- 宏定义:系统头文件(如
为什么使用这种机制:
- 高效性:一个整数参数传递多个选项,减少函数参数数量,避免参数爆炸问题。
- 灵活性:支持扩展新选项(只需添加新宏),不影响现有代码。
- 标准化:在Linux内核中广泛使用,例如
open
、fcntl
等系统调用都依赖此方法。
注意:
- 宏定义差异:有些宏使用十六进制(如
#define ONE 0x1
)或十进制(如#define ONE 0001
),但原理相同:确保每个宏对应一个唯一比特位。例如, 使用#define ONE 0001
,而代码中使用(1<<0)
,两者等效。
1.2 系统文件IO接口
1. open
– 打开或创建文件
函数原型:
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode); // mode 仅在创建文件时生效
参数说明:
pathname
:文件路径。flags
:标志位,控制打开方式(核心机制见下文)。mode
:创建文件时的权限(如0644,权限掩码umask为022时
),使用八进制表示。- 返回值
成功:返回 最小未使用的文件描述符(非负整数,≥3)。
int fd = open("file.txt", O_RDWR); // 成功时返回 3,4,5...
失败:返回
-1
,常见errno
:错误码 含义 ENOENT
文件不存在 EACCES
权限不足 EEXIST
文件已存在(`O_CREAT
标志位(flags
)的位图机制:
实现原理:每个标志位对应一个二进制位,通过按位或(
|
)组合多个选项,函数内部用按位与(&
)检测。
示例:int fd = open("file.txt", O_RDWR | O_CREAT | O_TRUNC, 0666); // 读写模式+创建+截断
常用标志位:
标志位 功能 O_RDONLY
只读(与 O_WRONLY
、O_RDWR
互斥)O_WRONLY
只写 O_RDWR
读写 O_CREAT
文件不存在时创建,需指定 mode
权限O_TRUNC
若文件存在且为普通文件,截断长度为 0 O_APPEND
每次写操作前将指针移至文件末尾 O_NONBLOCK
非阻塞模式(用于设备文件、管道) O_SYNC
每次 write
后同步数据到磁盘(物理写入完成才返回)O_DSYNC
仅同步普通数据(不同步元数据),语义同 O_SYNC
在 Linux 的实现O_DIRECT
直接 I/O(绕过页缓存),需对齐内存地址和块大小
2. read
/ write
– 读写数据
函数原型:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count); // 从 fd 读取 count 字节到 buf
ssize_t write(int fd, const void *buf, size_t count); // 从 buf 写入 count 字节到 fd
关键行为:
- 同步性:
read
总是同步等待数据返回;write
默认异步(数据写入页缓存即返回),需O_SYNC
或fsync()
强制同步。 - 返回值:成功时返回实际读写字节数(可能小于
count
),失败返回-1
并设置errno
。 - 非阻塞模式:若设
O_NONBLOCK
,无数据时read
立即返回EAGAIN
错误。
3. close
– 关闭文件
- 成功:返回
0
。 - 失败:返回
-1
,常见errno
:
错误码 | 含义 |
---|---|
EBADF | 无效文件描述符 |
EINTR | 被信号中断 |
#include <unistd.h>
int close(int fd); // 释放文件描述符及关联资源
注意:进程退出时内核自动关闭未释放的文件描述符,但显式关闭可避免资源泄漏。
4. lseek
– 移动文件指针
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
参数:
- 返回值
成功:返回 新的文件偏移量(从文件头计算的字节数)。
off_t pos = lseek(fd, 0, SEEK_END); // 获取文件大小
失败:返回
(off_t)-1
,常见errno
:
错误码 | 含义 |
---|---|
ESPIPE | 文件不支持寻址(如管道) |
EINVAL | 无效偏移量 |
whence
:基准位置(SEEK_SET
文件头、SEEK_CUR
当前位置、SEEK_END
文件尾)。- 空洞文件:向超出文件末尾的位置
write
会创建“空洞”(不占用磁盘块)。
1.3 文本写入 VS 二进制写入
一、本质区别:存储与处理逻辑
1. 底层存储一致性
所有文件本质是二进制:无论是文本还是二进制文件,数据在磁盘上均以二进制位序列存储。
关键差异在解释层:
维度 文本写入 二进制写入 数据解释 字符序列(如Unicode) 原始字节流 编码转换 自动编码/解码 无转换 系统视角 语言层抽象 直接映射内存值
2. 编码机制详解
文本写入流程(以字符串"199"为例):
- 读取时逆向解码,依赖系统编码(如Windows默认ASCII,Linux UTF-8),跨平台可能乱码。
二进制写入流程(以整数199为例):
- 读取时需精确匹配数据类型和顺序,无编码依赖。
二、技术实现对比
1. 系统调用层(Linux/Windows)
文本模式特殊行为:
- 换行符处理:Windows文本模式将
\n
自动转为\r\n
,读取时逆向转换;二进制模式无此操作。 - 字符截断风险:文本写入遇
'\0'
可能终止输出。
- 换行符处理:Windows文本模式将
API使用差异:
操作 文本模式 二进制模式 C语言 fopen(path, "r")
fopen(path, "rb")
系统调用 open(path, O_RDWR)
open(path, O_RDWR)
💡 系统调用无显式二进制标志,实际行为由打开模式决定。
1.4 文件描述符
一、文件描述符的本质:数组下标
1. 内核数据结构链
task_struct
:进程控制块(PCB),包含进程所有资源信息,其中*files
指向文件描述符表 。files_struct
:管理进程打开的文件,核心成员为fd_array[]
(文件指针数组)。file
结构体:描述已打开文件的属性(如读写位置、操作函数集)。
2. 文件描述符的生成逻辑
当进程调用 open("file.txt", O_RDONLY)
时:
- 内核创建
file
结构体描述该文件; - 在
files_struct->fd_array[]
中寻找最小空闲索引(如下标3); - 将
file
结构体地址存入fd_array[3]
; - 返回下标值
3
作为文件描述符 。
📌 本质:文件描述符是
fd_array[]
的整数索引,通过索引可找到对应文件的file
结构体。
二、标准文件描述符:0, 1, 2
1. 默认分配与物理设备映射
文件描述符 | 宏定义 | 物理设备 | 用途 |
---|---|---|---|
0 | STDIN_FILENO | 键盘 | 标准输入 |
1 | STDOUT_FILENO | 显示器 | 标准输出 |
2 | STDERR_FILENO | 显示器 | 标准错误输出 |
继承机制:由父进程(如Shell)创建子进程时自动继承 。
代码示例:
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> int main() {char buf[1024];ssize_t s = read(0, buf, sizeof(buf));// 从键盘读取数据if(s > 0){buf[s] = 0;write(1, buf, strlen(buf));// 向显示器输出write(2, buf, strlen(buf));// 向错误输出写入}return 0; }
此代码通过文件描述符直接操作设备,无需显式打开文件 。
2. 内核层验证
在 files_struct
初始化时,默认填充前三个描述符:
// 内核源码片段(简化)
struct files_struct init_files = {.fd_array = {[0] = &stdin_file, // 键盘设备文件[1] = &stdout_file, // 显示器设备文件[2] = &stderr_file, // 显示器设备文件}
};
💡 进程创建时复制此结构,保证0/1/2的全局一致性 。
三、文件描述符内核实现验证
1. 关键结构体源码位置
结构体 | 内核源码路径 |
---|---|
task_struct | /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/sched.h |
files_struct | /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fdtable.h |
file | /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h |
(3.10.0-1160.71.1.el7.x86_64 是内核版本,可通过执行 uname -a
命令查看服务器配置。由于该文件夹唯一存在,无需刻意区分具体版本),在 Windows 系统下可直接使用 VSCode 打开内核源代码进行查看
2. 核心字段解析
task_struct
(sched.h):struct task_struct {struct files_struct *files; // 指向文件描述符表 };
files_struct
(fdtable.h):struct files_struct {struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 文件指针数组 };
NR_OPEN_DEFAULT
通常为64,表示进程默认支持的文件数量 。file
(fs.h):struct file {loff_t f_pos; // 文件读写位置const struct file_operations *f_op; // 操作函数集(read/write)struct dentry *f_dentry; // 关联目录项 };
f_dentry
进一步指向inode
,包含文件元数据(权限、大小等)。
1.5 文件描述符分配规则
1. 基础规则:最小空闲下标分配
Linux 文件描述符(File Descriptor, fd)的分配遵循最小空闲下标原则:
核心逻辑:在进程的
files_struct->fd_array[]
中,扫描从0
开始的数组下标,选择第一个未被占用的最小整数作为新打开文件的描述符。验证代码:
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h>int main() {int fd0 = open("file0", O_CREAT | O_RDONLY, 0644); // fd0 = 3(默认0/1/2被占用)int fd1 = open("file1", O_CREAT | O_RDONLY, 0644); // fd1 = 4close(fd0); // 释放下标3int fd2 = open("file2", O_CREAT | O_RDONLY, 0644); // fd2 = 3(复用最小空闲下标)printf("fd2=%d\n", fd2); // 输出:fd2=3return 0; }
结果说明:新文件始终占用当前最小的空闲下标(如释放
3
后新文件复用3
)。
2. 标准描述符(0/1/2)的默认占用
进程启动时自动分配:
fd 宏定义 设备 0 STDIN_FILENO
键盘 1 STDOUT_FILENO
显示器 2 STDERR_FILENO
显示器
关闭0或者2呢?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{close(0);//close(2);int fd = open("myfile", O_RDONLY);if(fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);close(fd);return 0;
}
结果:关闭
0
后新文件占用0
;若关闭2
则占用2
。
1.6 重定向
1. 代码现象与核心问题
那如果关闭1呢?看代码:
#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, 0644);if(fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);fflush(stdout);close(fd);exit(0);
}
结果:printf
内容写入 myfile
而非屏幕。
现象:标准输出被重定向到文件。
核心问题:
重定向如何通过文件描述符(fd)实现?其本质是什么?
2. 重定向的本质:内核文件指针的替换
内核数据结构链
初始状态:
fd_array[1]
指向显示器文件对象(stdout_file
),数据输出到屏幕。重定向操作:
close(1)
:释放fd_array[1]
的指针(断开与显示器的关联)。open("myfile")
:- 内核分配新
file
对象描述myfile
。 - 按最小空闲规则,将新对象地址存入
fd_array[1]
(原显示器位置)。
- 内核分配新
- 此时
fd=1
,但指向已从显示器变为文件。
✅ 本质:
上层 fd 值不变(仍为1) ,但内核中fd_array[1]
的指针被替换为新文件对象地址,实现输出目标的切换 。
常见Shell重定向符
符号 | 本质操作 | 等效内核行为 |
---|---|---|
> | 关闭fd=1 + 打开文件(覆盖写) | close(1); open(..., O_WRONLY) |
>> | 关闭fd=1 + 打开文件(追加写) | close(1); open(..., O_APPEND) |
< | 关闭fd=0 + 打开文件(输入源) | close(0); open(..., O_RDONLY) |
使用 dup2 系统调用
一、dup2 的核心功能与原理
1. 功能定义
dup2
是 Linux/Unix 系统调用,用于原子性地复制文件描述符并重定向 I/O:
#include <unistd.h>
int dup2(int oldfd, int newfd);
oldfd
:源文件描述符(必须为已打开的有效 fd)newfd
:目标文件描述符(任意合法整数值)- 返回值:成功返回
newfd
,失败返回-1
并设置errno
2. 内核级操作原理
- 文件表项复制:将
oldfd
指向的内核file
结构体地址复制到newfd
的files_struct->fd_array[newfd]
- 原子性操作:关闭原
newfd
文件 + 复制操作不可中断,避免竞态条件 - 共享状态:复制后
oldfd
和newfd
共享:- 文件偏移量(
f_pos
) - 文件状态标志(
O_APPEND
等) - 文件操作函数集(
f_op
)
- 文件偏移量(
二、dup2 的核心应用场景
1. I/O 重定向:标准流控制
// 将标准输出重定向到文件
int fd = open("log.txt", O_WRONLY|O_CREAT, 0644);
dup2(fd, STDOUT_FILENO); // STDOUT_FILENO=1
printf("写入文件而非屏幕!");
close(fd);
实现效果:
- 所有写入
stdout
的数据输出到log.txt
- 适用于
>
和>>
重定向的底层实现
2. 错误流分离:记录错误日志
int err_fd = open("errors.log", O_WRONLY|O_APPEND);
dup2(err_fd, STDERR_FILENO); // 重定向标准错误
fprintf(stderr, "Critical failure!"); // 写入文件
3. 设备控制:输出到空设备
int null_fd = open("/dev/null", O_WRONLY);
dup2(null_fd, STDOUT_FILENO); // 丢弃所有标准输出
printf("This is silenced"); // 无任何输出
三、高级使用技巧与陷阱规避
1. 重定向的临时性与恢复
// 保存原始 stdout
int saved_stdout = dup(STDOUT_FILENO);// 重定向到文件
int file_fd = open("temp.txt", O_WRONLY);
dup2(file_fd, STDOUT_FILENO);// 恢复原始 stdout
dup2(saved_stdout, STDOUT_FILENO);
close(saved_stdout);
关键点:
- 通过
dup
预先保存原 fd - 恢复时需再次调用
dup2
(而非直接赋值) - 避免错误:恢复后关闭临时 fd
2. 文件描述符泄漏防护
// 错误示例:未关闭旧 fd
int fd1 = open("a.txt", O_RDONLY);
int fd2 = open("b.txt", O_RDONLY);
dup2(fd1, fd2); // 此时 fd2 指向 a.txt
// 忘记 close(fd2) 导致 b.txt 资源泄漏!// 正确做法
dup2(fd1, fd2);
close(fd1); // 显式关闭不再需要的 fd
内核原理:
dup2
会关闭原newfd
指向的文件- 但
oldfd
需手动关闭(除非需保留多个访问点)
2. 理解Linux“一切皆文件”
本质:统一抽象层的实现
Linux的“一切皆文件”本质是通过虚拟文件系统(VFS)建立的统一抽象层,将异构资源转化为标准文件接口。其核心实现包含三个层次:
- 物理资源层:硬件设备(键盘/磁盘)、进程信息、网络套接字等实体资源
- 抽象层:VFS定义统一的
file_operations
结构体(包含read/write等函数指针) - 接口层:用户空间通过文件描述符(fd)访问资源,仅需基础API(open/read/write)
首先,在 Windows 中是文件的对象(如普通文档、图片等),在 Linux 中自然也是以文件形式存在。其次,一些在 Windows 中不被视为文件的对象,比如:
- 进程(/proc/[pid]目录下的各种文件)
- 磁盘设备(/dev/sd*)
- 显示器设备(/dev/fb*)
- 键盘设备(/dev/input/event*)
- 声卡设备(/dev/snd/*)
这些硬件设备都被抽象成了特殊的设备文件,用户可以通过标准的文件操作接口来访问它们。比如要读取键盘输入,可以直接对 /dev/input/event 文件执行 read 操作。此外,进程间通信的管道(pipe)也被实现为特殊的文件。在 shell 中通过 "|" 符号创建的管道,本质上就是内存中的临时文件。更有趣的是,网络编程中的 socket(套接字)虽然不直接对应磁盘上的文件,但其接口设计也完全遵循文件操作的模式。
这种统一的设计带来了显著的优势:
- 简化了开发接口:开发者只需要掌握一套文件操作 API(如 open、read、write、close 等)就能处理绝大多数系统资源
- 统一了权限管理:所有资源的访问权限都可以通过文件权限系统(rwx)来管理
- 提高了开发效率:相同的调试工具(如 strace、lsof)可以用于各种资源类型的调试
具体来说,Linux 系统中几乎所有的读操作(读取文件内容、获取系统状态、从管道读取数据)都可以使用 read() 系统调用完成;而几乎所有的写操作(修改文件内容、调整系统参数、向管道写入数据)都可以使用 write() 系统调用来实现。
在 Linux 内核中,每个打开的文件都会对应一个 file 结构体,该结构体定义在 fs.h 头文件中。以下是其关键成员详解:
struct file {...struct inode *f_inode; /* cached value */ // 指向该文件的 inode 结构,包含文件元数据const struct file_operations *f_op; // 文件操作函数表...atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指向它,就会增加f_count的值。unsigned int f_flags; // 表⽰打开⽂件的权限fmode_t f_mode; // 设置对⽂件的访问模式,例如:只读,只写等。所有的标志在头⽂件<fcntl.h> 中定义loff_t f_pos; // 表⽰当前读写⽂件的位置...} __attribute__((aligned(4))); /* lest something weird decides that 2 is OK*/
其中最重要的成员是 f_op 指针,它指向一个 file_operations 结构体,这个结构体定义了针对该文件的所有操作函数指针:
struct file_operations {struct module *owner; // 指向拥有该操作模块的指针//指向拥有该模块的指针;loff_t (*llseek) (struct file *, loff_t, int);//llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);//⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以 -EINVAL("Invalid argument") 失败. ⼀个⾮负返回值代表了成功读取的字节数( 返回值是⼀个"signed size" 类型, 常常是⽬标平台本地的整数类型).ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);//发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负,返回值代表成功写的字节数.ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化⼀个异步读 -- 可能在函数返回前不结束的读操作.ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化设备上的⼀个异步写.int (*readdir) (struct file *, void *, filldir_t);//对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤.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);int (*mmap) (struct file *, struct vm_area_struct *);//mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤返回 -ENODEV.int (*open) (struct inode *, struct file *);//打开⼀个⽂件int (*flush) (struct file *, fl_owner_t id);//flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤;int (*release) (struct inode *, struct file *);//在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL.int (*fsync) (struct file *, struct dentry *, int datasync);//⽤⼾调⽤来刷新任何挂着的数据.int (*aio_fsync) (struct kiocb *, int datasync);int (*fasync) (int, struct file *, int);int (*lock) (struct file *, int, struct file_lock *);//lock ⽅法⽤来实现⽂件加锁; 加锁对常规⽂件是必不可少的特性, 但是设备驱动⼏乎从不实现它.ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,int);unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsignedlong, unsigned long, unsigned long);int (*check_flags)(int);int (*flock) (struct file *, int, struct file_lock *);ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t*, size_t, unsigned int);ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,size_t, unsigned int);int (*setlease)(struct file *, long, struct file_lock **);
};
file_operations 结构体是连接系统调用和设备驱动的关键桥梁。当应用程序发起系统调用时,内核会:
- 根据文件描述符找到对应的 file 结构体
- 通过 file->f_op 找到对应的操作函数表
- 调用表中对应的函数指针(如 read/write)
- 将控制权转移给具体的驱动实现
这种设计使得 Linux 可以:
- 用统一的接口支持不同类型的文件系统(ext4、NTFS等)
- 通过设备文件抽象硬件设备
- 为特殊功能(如管道、socket)提供一致的访问方式
- 方便开发者扩展新的文件类型和设备驱动
- 多态实现:不同资源类型注册不同的
f_op
函数集:- 普通文件:ext4_file_operations
- 字符设备:tty_fops
- 网络套接字:socket_file_ops
例如,当开发一个字符设备驱动时,开发者只需要实现 file_operations 中的必要函数(如 open、read、write 等),就能让应用程序通过标准文件接口来访问设备。
一张图总结:
在Linux系统中,所有硬件外设和系统资源都被抽象为文件形式进行管理。上图中展示的各类外设,虽然每个设备都具有各自独特的硬件特性和操作方式(例如键盘需要轮询扫描码,磁盘需要处理块读写操作),但它们都通过统一的文件操作接口对外提供服务。
具体实现机制如下:
设备驱动开发者需要为每种设备类型实现对应的file_operations结构体,其中包含:
- read:用于数据读取(如键盘驱动填充按键缓冲区)
- write:用于数据写入(如向打印机发送打印数据)
- ioctl:特殊控制命令(如调整串口波特率)
- mmap:内存映射操作(如显存映射)
当应用程序通过open()系统调用打开设备文件时(如/dev/ttyS0),内核会:
- 在进程文件描述符表中创建file结构体实例
- 关联对应的file_operations操作集
- 返回文件描述符给用户空间
典型应用场景示例:
- 访问串口设备时,用户程序只需:
int fd = open("/dev/ttyS0", O_RDWR); write(fd, "AT命令", strlen("AT命令")); read(fd, buffer, sizeof(buffer));
这种设计哲学带来三大优势:
- 统一访问接口:fopen/read/write等标准文件操作适用于所有设备
- 权限控制:通过文件权限位管理设备访问权限
- VFS抽象:用户程序无需区分普通文件、管道、套接字或硬件设备
正是通过file_operations这个关键抽象层,Linux实现了"一切皆文件"的设计理念,使得开发者可以用统一的文件操作接口访问系统中的各种资源。
3. 缓冲区
一、缓冲区的本质与层级结构
缓冲区是什么?
本质: 内存中预留的一块区域。
作用: 临时存放输入设备(如键盘、磁盘)或输出设备(如屏幕、打印机、磁盘)的数据,充当数据中转站。
分类:
输入缓冲区: 接收来自输入设备的数据(如键盘敲入的内容先存缓冲区)。
输出缓冲区: 存放准备发送给输出设备的数据(如程序输出的内容先存缓冲区)。
缓冲区是内存中的临时数据存储区,在I/O操作中充当数据中转站,形成三级缓冲体系:
用户态缓冲区:
- 由标准I/O库(如glibc)维护
- 类型:全缓冲/行缓冲/无缓冲
- 位置:进程地址空间
内核页缓存:
- 由操作系统管理
- 位置:内核空间
- 大小:动态调整(通常占内存20%)
设备缓冲区:
- 硬件自带缓存(如磁盘控制器缓存)
- 大小:固定(通常4-64MB)
二、缓冲机制核心价值
为什么需要缓冲区?
根本原因:减少系统调用次数,提高效率。
关键问题:
系统调用开销大: 每次直接读写设备都需要CPU从用户态切换到内核态(上下文切换),消耗CPU时间。
设备速度差异大: CPU速度 >> 内存速度 >> 磁盘/打印机速度。
缓冲区如何解决:
批处理思想: 代替频繁的“一次一读/写”,变为“一次读入大量数据到缓冲区”或“一次写出缓冲区的大量数据”。大大减少实际发生的系统调用次数。
速度适配:
CPU vs 慢设备: CPU快速处理数据放入输出缓冲区后即可返回处理其他任务,慢设备(如打印机)自行从缓冲区取数据输出。避免CPU被慢设备阻塞。
CPU vs 快设备: CPU从输入缓冲区(内存速度)取数据远快于直接从磁盘读取。
减少物理访问: 对于磁盘,批量读写减少磁头移动次数,显著提升I/O效率。
1. 减少系统调用开销
// 无缓冲:每次write触发系统调用
for (int i=0; i<1000; i++) {write(fd, data+i, 1); // 1000次上下文切换
}// 有缓冲:合并为1次系统调用
char buf[4096];
for (int i=0; i<1000; i++) {buf[i] = data[i];
}
write(fd, buf, 1000); // 1次上下文切换
性能对比:
操作方式 | 1000次1字节写入 | 开销来源 |
---|---|---|
无缓冲 | 1000µs | 上下文切换占90% |
4KB缓冲 | 50µs | 减少99.5%系统调用 |
2. 解决速度不匹配问题
设备 | 访问延迟 | 缓冲区作用 |
---|---|---|
CPU寄存器 | 0.3ns | 协调速度差 |
内存 | 100ns | ↓ 1000倍差距 |
SSD | 50μs | ↓ 500倍差距 |
机械硬盘 | 10ms | ↓ 200,000倍差距 |
网络设备 | 1-100ms | ↓ 百万倍差距 |
三、缓冲区类型与刷新机制
1. 标准I/O缓冲类型
类型 | 刷新条件 | 典型应用 | 缓冲区大小 |
---|---|---|---|
全缓冲 | 缓冲区满/fflush/fclose | 磁盘文件 | 8KB (默认) |
行缓冲 | 遇到'\n'/缓冲区满 | 终端设备 | 1KB (默认) |
无缓冲 | 立即写入 | stderr | 0 |
1. 全缓冲区:
特点: 必须等缓冲区完全填满后,才会执行实际的I/O系统调用(读写磁盘/设备)。
刷新时机: 缓冲区满、显式调用
fflush
、程序正常结束。典型应用: 读写磁盘文件(速度慢,批处理效率最高)。
2. 行缓冲区:
特点:
遇到换行符(
\n
) 时,立即刷新缓冲区执行I/O。即使没遇到换行符,缓冲区填满时也会立即刷新。
刷新时机: 遇到换行符、缓冲区满、显式调用
fflush
、程序正常结束。典型应用: 与终端交互的标准输入(
stdin
)、标准输出(stdout
)(用户期望看到按行输出)。缓冲区大小: 通常为1024字节(实现相关,可设置)。
3. 无缓冲区:
特点: 数据不经过任何缓存,直接调用系统调用进行I/O操作。
典型应用: 标准错误输出(
stderr
)。(目的:确保错误信息立即显示给用户,不被延迟,便于快速诊断问题)。
2. 刷新触发条件
// 内核刷新条件判断伪代码
void check_flush(buffer_t *buf) {if (buf->type == UNBUFFERED) flush_now();else if (buf->type == LINE_BUF) {if (memchr(buf->data, '\n', buf->size)) flush_now();else if (buf->size >= MAX_LINE_BUF) flush_now();}else if (buf->size >= MAX_FULL_BUF) flush_now();
}
3. 特殊刷新场景
- 进程退出:
exit()
自动刷新所有缓冲区 - 缓冲区满:写操作超出缓冲区容量
- 文件关闭:
fclose()
强制刷新 - 手动刷新:
fflush(stdout)
示例1:
#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>int main()
{// 库函数printf("hello printf\n");fprintf(stdout, "hello fprintf\n");const char *s = "hello fwrite\n";fwrite(s, strlen(s), 1, stdout);// 系统调用const char *ss = "hello write\n";write(1, ss, strlen(ss));return 0;
}
我们可以通过这段代码来感受一下用户级缓冲区和内核缓冲区的差异
运行结果:
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ g++ stream.cc
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ls
a.out flagbit flagbit.c stream.cc
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ./a.out
hello printf
hello fprintf
hello fwrite
hello write
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ./a.out > log.txt
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
1. 终端直接输出结果
hello printf
hello fprintf
hello fwrite
hello write
特征:
- 所有输出按代码顺序显示
- 每条语句独占一行(含
\n
)
2. 重定向到文件后的结果
hello write # 系统调用最先输出
hello printf # 库函数输出
hello fprintf # 库函数输出
hello fwrite # 库函数输出
特征:
- 系统调用
write
的内容最先出现 - 库函数(printf/fprintf/fwrite)输出顺序不变但整体后移
为什么重定向到文件之后,系统调用最先输出呢?
缓冲机制深度解析
1. 缓冲层级体系与刷新策略
输出方式 | 缓冲区层级 | 终端输出策略 | 文件输出策略 |
---|---|---|---|
printf /fwrite | 用户级缓冲区 | 行缓冲(遇\n 刷新) | 全缓冲(缓冲区满或进程退出刷新) |
write | 内核级缓冲区 | 无缓冲直通设备 | 异步刷新 |
2. 终端与文件缓冲策略差异
- 终端设备:C库采用行缓冲(
_IOLBF
)- 遇到
\n
立即刷新 - 示例中每条语句含
\n
→ 实时输出
- 遇到
- 磁盘文件:C库切换为全缓冲(
_IOFBF
)- 默认缓冲区大小8KB
- 进程退出时统一刷新
关键点:
系统调用write
绕过用户缓冲直接进入内核,而库函数数据在进程退出时才从用户缓冲刷入内核
结果顺序解释:
write
的内容最先进入内核 → 文件首行- 库函数数据在进程退出时批量追加到内核 → 后续行
那如果我们在程序退出前fork()一下,创建子进程呢?
示例2:
#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>int main()
{// 库函数printf("hello printf\n");fprintf(stdout, "hello fprintf\n");const char *s = "hello fwrite\n";fwrite(s, strlen(s), 1, stdout);// 系统调用const char *ss = "hello write\n";write(1, ss, strlen(ss));fork();return 0;
}
运行结果:
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ g++ stream.cc
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ./a.out
hello printf
hello fprintf
hello fwrite
hello write
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ ./a.out > log.txt
ltx@hcss-ecs-d90d:~/Linux_system/lesson8$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite
- 直接运行(终端输出):
所有输出正常(hello printf
等),因为行缓冲模式下,\n
会触发刷新缓冲区。 - 重定向到文件(
./a.out > log.txt
):
输出顺序混乱且重复:hello write // 系统调用(无缓冲) hello printf // 库函数(全缓冲) hello fprintf hello fwrite hello printf // 重复输出 hello fprintf hello fwrite
原因:
- 文件重定向时,库函数(如 printf
)使用全缓冲,数据暂存于缓冲区。
- fork()
复制了父进程的未刷新缓冲区到子进程。
- 父子进程退出时各自刷新缓冲区,导致库函数输出被打印两次。
- write
是系统调用,无缓冲,不受影响。
关键机制:
- 父子进程复制:
fork()
会创建子进程,子进程复制父进程的内存空间(包括代码、数据和缓冲区),但两者独立运行。
- 写时拷贝(Copy-On-Write):
父子进程共享内存页,直到一方修改数据时才触发拷贝。但缓冲区在刷新前未被修改,因此复制时内容一致。
在 fork()
前未刷新缓冲区会导致输出重复,尤其在重定向时。根本原因是全缓冲模式下缓冲区被复制。解决方法是在 fork()
前调用 fflush(stdout)
强制刷新缓冲区,或统一输出方式规避缓冲策略差异。
四.、FILE结构体
1. FILE
结构体的本质
FILE
是C标准库(如 glibc
)定义的结构体,用于封装文件操作的高层接口。其核心作用是:
- 封装文件描述符:
FILE
结构体中必包含int _fileno
成员(即文件描述符fd
),通过该成员关联内核级的文件资源 。 - 管理缓冲区:包含指针如
_IO_read_ptr
、_IO_buf_base
等,用于实现用户态缓冲(全缓冲/行缓冲/无缓冲) 。 - 维护文件状态:通过
_flags
记录打开模式(读/写/追加等),_lock
支持线程安全操作 。
2. FILE
与 fd
的映射关系
(1)层次结构
fd
的作用:是进程打开文件的唯一整数标识,通过进程的文件描述符表(数组)映射到内核的file
结构体 。FILE*
的作用:提供fopen
、fwrite
等高级函数,内部调用open
、write
等系统调用,并通过_fileno
传递fd
。
(2)转换函数
int fileno(FILE *stream)
:从FILE*
提取fd
。FILE *fdopen(int fd, const char *mode)
:将fd
包装为FILE*
。
3. 文件操作流程剖析
以 fopen
和 fwrite
为例:
fopen("file", "w")
:- 调用
open()
返回fd
。 - 初始化
FILE
结构体,将_fileno = fd
,并设置缓冲区指针 。
- 调用
fwrite(data, size, 1, FILE*)
:- 数据写入用户态缓冲区。
- 缓冲区满时,通过
_fileno
调用write(fd, buffer, size)
。
fclose(FILE*)
:- 刷新缓冲区并调用
close(fd)
。 - 释放
FILE
结构体内存 。
- 刷新缓冲区并调用
FILE内核源码:
//在/usr/include/stdio.h
typedef struct _IO_FILE FILE;
struct _IO_FILE {int _flags; /* High-order word is _IO_MAGIC; rest is flags.
*/
#define _IO_file_flags _flags//缓冲区相关/* The following pointers correspond to the C++ streambuf protocol. *//* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */char* _IO_read_ptr; /* Current read pointer */char* _IO_read_end; /* End of get area. */char* _IO_read_base; /* Start of putback+get area. */char* _IO_write_base; /* Start of put area. */char* _IO_write_ptr; /* Current put pointer. */char* _IO_write_end; /* End of put area. */char* _IO_buf_base; /* Start of reserve area. */char* _IO_buf_end; /* End of reserve area. *//* The following fields are used to support backing up and undo. */char *_IO_save_base; /* Pointer to start of non-current get area. */char *_IO_backup_base; /* Pointer to first valid character of backup area*/char *_IO_save_end; /* Pointer to end of non-current get area. */struct _IO_marker *_markers;struct _IO_FILE *_chain;int _fileno; //封装的⽂件描述符
#if 0int _blksize;
#elseint _flags2;
#endif_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary *//* 1+column number of pbase(); 0 is unknown. */unsigned short _cur_column;signed char _vtable_offset;char _shortbuf[1];/* char* _save_gptr; char* _save_egptr; */_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};