Linux 文件系统与 I/O 编程核心原理及实践笔记
文章目录
- 一、理解文件
- 1.1 狭义理解
- 1.2 广义理解
- 1.3 文件操作的归类认识
- 1.4 系统角度:进程与文件的交互
- 1.5 实践示例
- 二、回顾 C 文件接口
- 2.1 hello.c 打开文件
- 2.2 hello.c 写文件
- 2.3 hello.c 读文件
- 2.4 输出信息到显示器的几种方法
- 2.5 stdin & stdout & stderr
- 三、系统文件I/O
- 3.1 一种传递标志位的方法
- 3.2 接口介绍
- 3.5 3-5 open函数返回值
- 3.6 文件描述符 fd
- 3.6.1 0 & 1 & 2
- 3.6.2 文件描述符的分配规则:最小未使用下标优先
- 3.6.3 应用场景:重定向的实现
- 3.6.4 dup2 系统调用
- 五、缓冲区
- 5.1 什么是缓冲区
- 5.2 为什么要引入缓冲区机制
- 5.3 缓冲类型
一、理解文件
1.1 狭义理解
- 文件在磁盘里
*磁盘作为永久性存储介质,通过文件系统(如 EXT4、XFS)管理文件存储。文件系统将磁盘划分为inode(索引节点)和block(数据块):- inode:存储文件元数据(权限、所有者、修改时间等),每个文件唯一对应一个 inode。
- block:存储文件实际数据,大小由文件系统决定(如 4KB)。
注意:即使 0KB 的空文件也会占用inode 空间(不同文件系统 inode 大小不同,如 EXT4 默认 256 字节),但不占用数据块(block)。
- 磁盘是外设(输入 / 输出设备)
对磁盘文件的操作本质是IO(Input/Output),涉及内核与外设的数据交互(如通过 DMA 控制器读写磁盘)。
1.2 广义理解
Linux下一切皆文件
系统将硬件设备、进程信息、通信管道等抽象为文件,通过统一接口管理:
- 硬件设备:
- 块设备:以块为单位读写(如硬盘
/dev/sda
,文件类型b
)。 - 字符设备:以字符流读写(如键盘
/dev/input/event0
,文件类型c
)。
- 块设备:以块为单位读写(如硬盘
- 虚拟文件系统:
/proc
:动态映射进程信息(如/proc/self/exe
是当前进程二进制文件)。/sys
:暴露内核设备驱动细节(如/sys/class/leds/
控制 LED 灯)。
- 进程通信:
- 管道文件(类型
p
):mkfifo mypipe
创建命名管道。 - 套接字文件(类型 s):
/run/docker.sock
用于Docker
进程通信。
这种抽象屏蔽了底层差异,例如读写/dev/tty1
(终端设备文件)与读写普通文件使用相同 API。
- 管道文件(类型
1.3 文件操作的归类认识
文件 = 元数据(属性) + 数据内容
- 元数据:
- 基础属性:权限(
rwx
)、所有者(uid/gid
)、硬链接数(ls -l
第二列)。 - 时间戳:修改时间(
mtime
)、状态改变时间(ctime
)、访问时间(atime
)。 - 技术属性:inode 编号(
ls -i
)、文件大小(ls -l
第五列)、块数(ls -s
)。
- 基础属性:权限(
- 数据内容:
分为文本(ASCII/UTF-8)和二进制(如可执行程序、图片),通过cat
、hexdump
等工具查看。 - 操作分类
- 内容操作:读写(
read/write
系统调用)、定位(lseek
)、截断(truncate
)。
- 内容操作:读写(
- 属性操作:
- 修改权限:
chmod
(对应chmod
系统调用)。 - 更改所有者:
chown
(对应chown
系统调用)。 - 查看元数据:
stat命令
(对应stat
系统调用,返回struct stat
结构体)。
- 修改权限:
1.4 系统角度:进程与文件的交互
- 一切文件操作由进程触发
内核通过 ** 文件描述符(File Descriptor, FD)** 标识进程打开的资源,FD
是0~1023
的整数(默认:0=stdin
,1=stdout
,2=stderr
)。
可通过ls -l /proc/$$/fd
查看当前进程打开的文件($$
为当前进程PID
)。 - 系统调用 vs 库函数
- 系统调用:内核提供的底层接口(如
open
、read
),需从用户态陷入内核态,开销较高但更直接。 - 库函数:C 标准库封装的高层接口(如
fopen
、fread
),内部调用系统调用并提供缓存机制(如stdio
的缓冲区)。 - 示例:
fprintf(stdout, "hello")
最终会调用write(1, "hello", 5)
系统调用。
- 系统调用:内核提供的底层接口(如
- 内核如何管理文件
- 每个打开的文件对应内核中的 file结构体,记录文件位置、引用计数等。
- 多个进程可通过不同 FD 指向同一file结构体(如父子进程共享文件),实现数据共享。
1.5 实践示例
- 查看文件元数据
stat test.txt # 显示inode、权限、时间戳等详细信息
ls -li test.txt # 查看inode编号和硬链接数
- 操作设备文件
echo "Hello zkp!" > /home/zkp/linux/25/6/7/file/test.txt # 向文件写入信息
cat /home/zkp/linux/25/6/7/file/test.txt # 查看文件内容
- 理解文件描述符
exec 3<> file.txt # 在当前Shell中打开文件,FD=3可读可写
echo "test" >&3 # 通过FD=3写入文件
cat <&3 # 通过FD=3读取文件
exec 3>&- # 关闭FD=3
二、回顾 C 文件接口
2.1 hello.c 打开文件
打开的myfile
文件在哪个路径下?
- 在程序的当前路径下,那系统怎么知道程序的当前路径在哪里呢?
可以使用ls /proc/[进程id]
命令查看当前正在运行进程的信息:
其中: cwd
:指向当前进程运行目录的一个符号链接。exe
:指向启动当前进程的可执行文件(完整路径)的符号链接。
打开文件,本质是进程打开,所以,进程知道自己在哪里,即便文件不带路径,进程也知道。由此OS就能知道要创建的文件放在哪里。
2.2 hello.c 写文件
2.3 hello.c 读文件
2.4 输出信息到显示器的几种方法
2.5 stdin & stdout & stderr
- C 默认会打开三个输出流,分别是 stdin,stdout,stderr
- 这三个流的类型都是
FILE*
,而fopen
返回值类型也是文件指针
三、系统文件I/O
我们知道,文件的权限分为 rwx
,对应的标志位为 4,2,1。
3.1 一种传递标志位的方法
核心原理:位掩码(Bit Mask)
每个标志对应 唯一的二进制位(如第 0 位、第 1 位…),通过 位运算 组合 / 解析:
- 设置标志:用
|
(按位或)组合多个标志(如FLAG_A | FLAG_B
)。 - 检查标志:用
&
(按位与)判断某一位是否为 1(如if (flags & FLAG_A)
)。
#include <stdio.h>// 定义权限标志位(与Linux系统保持一致)
#define PERM_READ (1 << 2) // 4: 读权限
#define PERM_WRITE (1 << 1) // 2: 写权限
#define PERM_EXEC (1 << 0) // 1: 执行权限// 解析权限并打印
void func(int perms) {printf("用户权限: ");printf(perms & USER_PERMS(PERM_READ) ? "r" : "-");printf(perms & USER_PERMS(PERM_WRITE) ? "w" : "-");printf(perms & USER_PERMS(PERM_EXEC) ? "x" : "-");printf("\n");
}int main() {// 组合权限:用户有读写,组有读,其他用户无权限int perms = (PERM_READ | PERM_WRITE)printf("权限掩码(八进制): 0%o\n", perms); // 输出: 0x6func(perms);return 0;
}
3.2 接口介绍
参数:
pathname
:要打开或创建的目标文件flags
:打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags
。O_RDONLY
:只读打开O_WRONLY
:只写打开O_RDWR
:读,写打开
这三个常量,必须指定一个且只能指定一个O_CREAT
:若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限O_APPEND
:追加写
返回值:
- 成功:新打开的文件描述符
- 失败:-1
open
函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open
创建,则第三个参数表示创建文件的默认权限;否则,使用两个参数的open
。
write
、read
、close
、lseek
,类比c文件相关接口。
3.5 3-5 open函数返回值
在认识返回值之前,先来认识一下两个概念:系统调用和库函数
- 上面的
fopen
fclose
fread
fwrite
都是C标准库当中的函数,我们称之为库函数(libc)。 - 而
open
close
read
write
lseek
都属于系统提供的接口,称之为系统调用接口
看下面这张图:
系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#
系列的函数,都是对系统调用的封装,方便二次开发。
3.6 文件描述符 fd
- 通过对
open
函数的学习,我们知道了文件描述符就是一个小整数
3.6.1 0 & 1 & 2
- Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0,标准输出1,标准错误2。
- 0,1,2对应的物理设备一般是:键盘,显示器,显示器
所以输入输出还可以采用如下方式:
#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;
}
3.6.2 文件描述符的分配规则:最小未使用下标优先
- 默认初始状态:
进程启动时,内核会自动打开 3 个标准文件描述符:- 0:标准输入(
stdin
,默认关联键盘) - 1:标准输出(
stdout
,默认关联终端) - 2:标准错误(
stderr
,默认关联终端)
因此,首次打开新文件时,文件描述符从 3 开始分配(依次递增:3、4、5…)。
- 0:标准输入(
- 关闭后复用:
如果进程主动关闭某个文件描述符(如close(0)
),后续调用open
时,内核会 扫描文件描述符表,选择最小的未被使用的下标 分配给新文件。- 例:关闭 0 后,新打开的文件会优先占用 0;
- 若同时关闭 0 和 2,新打开的文件会依次占用 0、2,再继续递增(如 3、4…)。
代码验证:
#include <stdio.h>
#include <unistd.h> // close
#include <fcntl.h> // open, O_RDWR, O_CREATint main() {// 1. 初始打开:未关闭默认FD,从3开始int fd1 = open("test1.txt", O_RDWR | O_CREAT, 0644);printf("fd1: %d\n", fd1); // 输出:3(0、1、2已占用)// 2. 关闭标准输入(FD=0),后续打开优先复用0close(0); int fd2 = open("test2.txt", O_RDWR | O_CREAT, 0644);printf("fd2: %d\n", fd2); // 输出:0(最小未使用下标)// 3. 关闭标准错误(FD=2),后续打开优先复用2close(2); int fd3 = open("test3.txt", O_RDWR | O_CREAT, 0644);printf("fd3: %d\n", fd3); // 输出:2(当前最小未使用下标)// 4. 继续打开,下一个最小未使用是3(0、2已用,1仍被stdout占用)int fd4 = open("test4.txt", O_RDWR | O_CREAT, 0644);printf("fd4: %d\n", fd4); // 输出:3return 0;
}
运行结果:
fd1: 3
fd2: 0
fd3: 2
fd4: 3
3.6.3 应用场景:重定向的实现
- 输出重定向示例:
# 将命令输出写入文件(本质是修改FD=1的指向)
ls -l > output.txt
实现逻辑:
- Shell 先关闭 FD=1(标准输出),再打开
output.txt
,此时新文件会占用 FD=1; - 后续
ls
命令的输出会写入 FD=1(即output.txt
),而非终端。
- 代码模拟重定向:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>int main() {// 1. 关闭标准输出(FD=1)close(1); // 2. 打开新文件,会复用FD=1int fd = open("redirect.txt", O_WRONLY | O_CREAT, 0644);// 3. printf 会向 FD=1 写入(此时指向 redirect.txt)printf("Hello, Redirect!\n"); close(fd);return 0;
}
运行后,redirect.txt 会包含 Hello, Redirect!,而非终端输出
注意事项
- 关闭默认描述符(如
close(1)
)后,若后续代码依赖标准输出(如printf
),会导致输出丢失或异常。 - 建议使用 dup2 实现重定向(安全关闭旧描述符,避免冲突)。
重定向的本质:
3.6.4 dup2 系统调用
示例:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>int main() {// 1. 打开文件(获取新的文件描述符,如3)int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);if (fd == -1) {perror("open failed");return 1;}// 2. 将标准输出(FD=1)重定向到 fd 指向的文件if (dup2(fd, 1) == -1) {perror("dup2 failed");close(fd);return 1;}// 3. 此时 printf 会写入 output.txt,而非终端printf("Hello, dup2!\n");// 4. 关闭 fd(注意:标准输出仍指向 output.txt)close(fd);// 5. 验证:继续向标准输出写入fprintf(stdout, "This will also appear in output.txt\n");return 0;
}
printf
是C库当中的IO函数,一般往stdout
中输出,但是stdout
底层访问文件的时候,找的还是fd:1,但此时,fd:1下标所表示内容,已经变成了myfifile
的地址,不再是显示器文件的地址,所以,输出的任何消息都会往文件中写入,进而完成输出重定向。
五、缓冲区
5.1 什么是缓冲区
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
5.2 为什么要引入缓冲区机制
读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。
又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
5.3 缓冲类型
标准I/O提供了3种类型的缓冲区。
- 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
- 行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准1/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/0系统调用操作,默认行缓冲区的大小为1024。
- 无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:
- 缓冲区满时;
- 执行
flush
语句;