。Linux 基础 IO 学习笔记
最近学习了 Linux 的基础 IO,从底层原理到实际应用,整理一下核心概念。
一、从磁盘说起
要理解文件 IO,先得知道数据存在哪。
磁盘的物理结构是这样的:多个盘片叠在一起,每个盘片有上下两个盘面,每个盘面对应一个磁头。盘面上有很多同心圆叫磁道,磁道被切成小块叫扇区,一个扇区通常 512 字节。
所有盘面上同一位置的磁道组成一个柱面。定位数据需要三个参数:柱面(磁头移到哪个位置)、磁头(选哪个盘面)、扇区(那一圈上的哪一块),简称 CHS。
但是对程序员来说,CHS 太复杂了。所以操作系统把磁盘抽象成线性结构,给所有扇区编号,变成一维数组。定位一个扇区只需要一个数字 LBA(逻辑块地址)。磁盘控制器会自动把 LBA 转换成 CHS,程序员不用关心物理结构。
二、一切皆文件
Linux 有个核心设计思想:一切皆文件。
不管是磁盘文件、键盘、显示器还是网络,都被抽象成文件,都可以用 read/write 来操作。
这样做的好处是,开发者只需要学一套 API 就能操作各种设备。但问题是,每个硬件的读写方式明明不一样,怎么做到统一的?
答案是函数指针。内核用 struct file 来描述打开的文件,里面有个 f_op 字段,是个函数指针,指向具体的操作函数。磁盘文件的 f_op->read 指向 disk_read,键盘的指向 keyboard_read,网络的指向 socket_read。
用户调用 read(fd, buf, n) 的时候,内核通过 fd 找到对应的 struct file,然后调用 file->f_op->read()。接口统一,实现各异,这其实就是多态的思想,只不过是用 C 语言的函数指针实现的。
三、文件描述符 fd
那 fd 是什么?
fd 就是进程文件描述符表的下标。每个进程都有一个文件描述符表,本质是个数组,里面的指针指向对应的 struct file。
每个进程默认打开三个文件:fd=0 是 stdin,fd=1 是 stdout,fd=2 是 stderr。
当你 open 一个文件,内核会创建一个 struct file,然后在 fd 表里找一个最小的空位,把指针填进去,返回这个下标给你,这就是 fd。
所以 fd 是连接用户程序和内核文件结构的桥梁。通过 fd 可以找到 struct file,struct file 里有缓冲区地址、读写位置、操作函数等所有信息。
四、struct file 里有什么
每次 open 都会创建一个新的 struct file,里面包含:
f_pos:当前读写位置
f_mode:打开模式(只读/读写)
f_flags:打开标志(O_APPEND 等)
f_op:操作函数指针
f_inode:指向文件的元信息
还有内核缓冲区的指针
不同进程打开同一个文件,各自有独立的 struct file,所以 f_pos、f_mode 这些是独立的。但是 f_inode 指向同一个 inode,因为是同一个物理文件。
这就解释了为什么两个进程可以用不同的模式打开同一个文件,各自的读写位置也互不影响。
五、两层缓冲区
这是重点中的重点。
缓冲区分了两层:语言层缓冲区和内核缓冲区。
语言层缓冲区在用户态,比如 C 库的 FILE 结构体里就有一个。printf、fprintf、fwrite 这些函数都是先把数据写到这个缓冲区里,不会立刻进入内核。
内核缓冲区在内核态,在 struct file 里。数据从语言缓冲区刷新到内核缓冲区后,什么时候写入磁盘就是操作系统决定的事了。
数据流向:printf 写数据 -> 语言缓冲区 -> 刷新 -> 内核缓冲区 -> 操作系统决定时机 -> 磁盘
我们认为把数据交给操作系统就相当于交给了硬件,剩下的内核负责。
六、C 库的 FILE 和内核的 struct file
这两个都叫 file,容易混淆。
C 库的 FILE 在用户态,定义在 stdio.h 里。里面包含了 fd(_fileno 字段)和用户态缓冲区的指针。
内核的 struct file 在内核态,定义在 linux/fs.h 里。里面包含了内核缓冲区指针、inode 指针、操作函数指针等。
两者通过 fd 联系。FILE 里存了 fd,刷新的时候通过 write(fd, ...) 把用户态缓冲区的数据送到内核。
各自提供各自的接口:
C 库管用户态缓冲区:fopen/fread/fwrite/fflush/fclose
内核管内核缓冲区:open/read/write/fsync/close
七、缓冲区的刷新
语言缓冲区什么时候刷新到内核?
- 遇到 \n(行缓冲模式下,比如 stdout 指向终端时)
- 缓冲区满了
- 手动调用 fflush
- 进程正常退出(exit 会刷新,_exit 不会)
- 关闭文件 fclose
内核缓冲区什么时候刷新到磁盘?
- 内核定时刷新(后台线程)
- 缓冲区满了
- 手动调用 fsync
- 文件关闭
- 系统关机
语言层缓冲区存在的意义是减少系统调用次数。系统调用很贵,每次都要从用户态切换到内核态。有了缓冲区,可以攒一批数据再一起 write,比频繁小写入快得多。
八、read 和 write 的本质
理解了缓冲区,就能理解 read/write 的本质:拷贝。
write 是把用户缓冲区的内容拷贝到内核缓冲区。
read 是把内核缓冲区的内容拷贝到用户缓冲区。
数据在各层之间流动,本质都是拷贝。这也是为什么 IO 操作相对慢,后来才有了 mmap、零拷贝等技术来减少拷贝次数。
还有一点要注意:read 只是把数据读到内存里,不会自动显示在屏幕上。要显示还得再 write 到 stdout。比如 cat 命令,内部就是 read 从文件读到 buf,然后 write 把 buf 写到 fd=1。
九、重定向原理
理解了 fd,重定向就很好理解了。
c
close(1); // 关闭 stdout
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
// fd 会分配为 1,因为 1 是最小可用的
printf("hello"); // 写到 log.txt 而不是屏幕
因为 fd=1 现在指向 log.txt,printf 默认写到 fd=1,所以就写到文件里了。shell 里的 ./a.out > log.txt 就是这么实现的。
重定向还会改变缓冲模式:stdout 指向终端时是行缓冲,\n 会触发刷新;重定向到文件后变成全缓冲,\n 不再触发刷新。
十、fork 和缓冲区的经典问题
这个问题很经典,面试常考:
c
printf("hello"); // 没有 \n
fork();
直接运行,输出一次 hello。重定向到文件,输出两次。
原因:重定向后 stdout 变成全缓冲,\n 不触发刷新。fork 的时候,用户态缓冲区里还有 hello 没刷新。fork 会复制整个用户空间内存,包括 C 库的缓冲区。所以父子进程各有一份 hello,各刷新一次,就输出两遍了。
write 是系统调用,直接写到内核,没有用户态缓冲区,所以不受影响,只输出一次。
解决方法:加 \n、fork 前 fflush、用 write 代替 printf。
十一、其他细节
open 的标志位:O_RDONLY、O_WRONLY 这些是宏定义,底层是数字,用位运算组合。用 | 组合多个选项,内核用 & 判断设置了哪些。
文件的三个时间:atime(访问时间)、mtime(修改时间)、ctime(属性改变时间)。
引用计数:struct file 有引用计数,用来决定什么时候释放资源。这也是为什么删除一个正在被使用的文件,它不会立刻消失,等最后一个进程关闭才真正删除。
总结
学完这些,对文件系统的理解清晰多了:
从磁盘的物理结构开始,理解数据是怎么存储的。然后是一切皆文件的设计思想,通过函数指针实现统一接口。fd 是连接用户态和内核态的桥梁。两层缓冲区各有各的作用,语言层减少系统调用,内核层减少磁盘 IO。数据在各层之间流动,本质都是拷贝。fork 会复制用户态缓冲区,这是很多坑的来源。
理解了这些底层原理,很多以前觉得奇怪的现象就都能解释了。