OSTEP - 持久化部分笔记
本文最后更新于 2026年7月24日 下午
第36章 I/O设备
本章的目的主要是建立 OS 与设备交互的基本模型。
现代计算机的系统架构
- CPU 和内存之间通过很快的内存总线互连;
- 显卡、网卡、高性能存储可能挂在更快的通用 I/O 总线上,比如 PCIe;
- 键盘、鼠标、普通磁盘、USB 设备可能挂在更慢的外设总线上。
理由:高性能设备离 CPU 近,慢设备挂在更外围。
对于一个标准设备的抽象
- 接口(interface):OS 能看到和操作的那一面。
- 内部实现(internals):设备内部具体怎么完成工作。
接口表现为一些寄存器:
- status register:状态寄存器,用来看设备是不是忙、有没有完成、有没有错误。
- command register:命令寄存器,用来告诉设备要做什么。
- data register:数据寄存器,用来传入或取出数据。
OS 主要通过设备暴露的接口与之交互,而不关心其内部实现。
一个朴素的设备协议(轮询 + PIO):
while (STATUS == BUSY)
; // wait until device is not busy
Write data to DATA register;
Write command to COMMAND register;
while (STATUS == BUSY)
; // wait until device is done首先等待不BUSY,然后写DATA寄存器(这一步叫PIO (Programmed I/O ));然后再写COMMAND寄存器,最后再轮询STATUS。
- 好处:简单;
- 坏处:浪费大量 CPU 时间等待慢设备。
使用中断减少 CPU 等待
区别于轮询方式的,OS 会一直“主动”询问 IO 设备好没好,中断方式是:
OS: 你慢慢做,做完叫我
设备: 做完后发中断
OS: 进入中断处理程序
流程大致是:
- OS 向设备发出请求。
- 发起请求的进程睡眠。
- OS 切换去运行别的进程。
- 设备完成 I/O 后发硬件中断。
- CPU 进入 OS 的中断处理程序。
- OS 完成收尾工作,并唤醒等待 I/O 的进程。
但是,轮询也不一定就比中断更好:因为中断是有固定开销的:上下文切换、中断处理、切回。
有时候采用折中方案:先轮询一会儿,再中断。
DMA
使用 PIO 的缺陷在于 CPU 自己需要把内存中的数据写入设备寄存器,浪费 CPU 资源。
引入 DMA,作用是让一个专门的控制器负责在内存和设备之间搬数据,CPU 只负责发起和收尾。
流程:
- OS 告诉 DMA 控制器:数据在内存哪里、长度多少、要送到哪个设备。
- DMA 控制器开始搬数据。
- CPU 不用亲自复制,可以去运行别的进程。
- DMA 完成后发中断通知 OS。
OS 和设备寄存器的通信
两种方式:
- 显式IO指令,比如 x86 中的
inout,是特权指令,只有 OS 能用; - 内存映射 IO,硬件把设备寄存器映射到一段特殊的物理地址,然后使用
loadstore来访问。
设备驱动
作用:把具体设备的复杂协议封装,向 OS 上层提供统一的接口。
第37章 磁盘驱动器
这章主要讲磁盘的结构。
对外提供的接口
从 OS 的角度来看,磁盘像一个巨大的一维数组(这一点比较像内存),分成若干个 sector;每个 sector 通常是 512 B。
磁盘只保证单个 sector 的写是原子的,更大的写入可能只完成一部分。
磁盘的基本结构
对于机械硬盘:
- platter(盘片):真正存储数据的圆盘;
- surface(盘面):每个盘片有两面;
- spindle(主轴):带动盘片高速旋转;
- track(磁道):盘面上的同心圆;
- sector(扇区):磁道被切成的小块;
- disk head(磁头):负责读写;
- disk arm(磁臂):移动磁头到指定磁道。
想象成一个 CD 机,盘片一直在转,等待目标的扇区移动到磁头下面,再进行读写。
三个核心时间
$$ T_{IO} = T_{seek} + T_{rotation} + T_{transfer} $$
- Seek time(寻道时间) 是磁臂移动到目标磁道的时间。很贵,因为涉及机械移动;
- Rotational delay(旋转延迟) 是等待目标 sector 转到磁头下面的时间;
- Transfer time(传输时间) 是真正读写数据的时间。
前两者涉及机械时间,后者往往很小。
因此:
- 随机 IO,大部分时间浪费在 seek 和 rotation 上;
- 顺序 IO,开始 seek 和 rotation 好之后,就可以连续传输,效率高;
- 二者差距非常非常大。
磁盘调度
通过重新安排 I/O 顺序,减少 seek 和 rotation 成本。
SSTF / NBF
SSTF(Shortest Seek Time First) 的想法是:优先服务离当前磁头最近的磁道请求。
实际的实现一般是NBF(Nearest Block First):优先处理 block 地址更接近当前地址的请求。
缺点:会有饥饿,近处的请求远远不断会导致远处请求一直得不到服务。
SCAN 电梯算法
磁头沿一个方向移动,顺路服务请求;到头后再反方向移动。
C-SCAN(Circular SCAN) 只朝一个方向扫,比如从外到内,扫完后回到外侧重新开始。它比普通 SCAN 更公平一些,因为普通 SCAN 会更偏向中间区域。
F-SCAN 会在一次 sweep 开始时冻结当前请求队列,新来的请求放到下一轮处理,这样可以避免新请求不断插队。
SPTF
SSTF 的目的主要是为了减少 seek,而 SPTF(Shortest Positioning Time First)进一步考虑了 rotation。
选择最早能开始传输的请求,但是问题在于 OS 通常不知道磁盘内部精确的几何结构,所以这些调度经常是在硬件内部完成的。
一些细节
现代磁盘:OS 选出请求发给磁盘,磁盘内部根据真实几何信息重新排序。
这使得磁盘内部更接近 SPTF。
I/O merging(I/O 合并) 是把相邻请求合并成一个更大的请求,比如把:
read block 33
read block 8
read block 34里面的 33 和 34 合并成一次 2-block 读取。
Anticipatory scheduling(预期调度):有请求不立刻执行,因为可能能等到一个更符合局部性原理的请求,从整体上减少 seek 时间。
第38章 RAID
RAID (Redundant Array of Inexpensive Disks),把多块磁盘组合起来,表现得像一块磁盘的技术。
关键点:透明性:对于上层的 OS 表现得仍然像一整块线性数组。
感觉这章对理解 OS 其实没什么用,先跳过了。
第39章 插叙:文件和目录
这一章主要是讲 UNIX 系统对于原始的磁盘的抽象。
文件和目录
文件:一个可读写的字节数组。每个文件在其底层有一个与之关联的 inode 号。
目录:本质也是一个文件,但是内容特殊,存放的是下面的文件。举个例子,a 下面有 b c d (假设号码分别是 1 2 3),那么 a 的内容就会是 {(b, 1), (c, 2), (d, 3)}。层层嵌套,就形成了文件树。
创建和打开文件
int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC,
S_IRUSR | S_IWUSR);几个 flag 的含义是:
O_CREAT:文件不存在就创建。O_WRONLY:只写打开。O_TRUNC:如果文件已存在,把长度截断为 0。- 第三个参数设置权限,这里是 owner 可读写。
返回值是 fd 即 file descriptor 文件描述符,是进程用来访问已经打开的文件的句柄。每个进程都有自己的 fd 表。
通常从 3 开始,因为:
- 0: stdin
- 1: stdout
- 2: stderr
前三个 fd 会被这三个文件所占用。
UNIX 的哲学就是「一切皆文件」
读写文件
read(fd, buffer, size);
write(fd, buffer, size);这里在系统级的 fd 表项中会记录一个 offset,表示当前读到哪里了。
举个例子,打开一个空文件,然后:
write(fd, "Hello", 5);
write(fd, "World", 5);开始 offset 是 0,所以写 Hello,写完之后 offset 变成 5,然后继续往后写 Hello。
所谓「表项」
- 每次
open一个文件的时候 OS 就会在系统级的 open file table 中创建一个条目,然后返回一个 fd - 记录的信息有 inode、offset、打开模式(可读/只写等)、锁等等
lseek():改变内存里的 offset
off_t lseek(int fd, off_t offset, int whence);第三个参数:
SEEK_SET:从开头算SEEK_CUR:从当前位置算SEEK_END:从结尾算
比如 whence == SEEK_SET,则操作之后表项中的 offset 变为第二个参数。
dup() 和 fork()
通常每次 open() 都会产生一个新的 open file table 条目,所以不同 fd 有不同 offset。比如同一个进程两次打开同一个文件:
fd1 = open("file", O_RDONLY);
fd2 = open("file", O_RDONLY);但是有特殊情况会共享 fd:
fork() 后,父子进程的 fd 可能指向同一个 open file table entry。因此子进程如果 lseek(fd, 10, SEEK_SET),父进程随后看到的 offset 也会是 10。
dup(fd) 是在同一个进程内创建一个新的 fd,但是新的 fd 和旧的 fd 指向同一个 open file table entry(指向同一个底层打开文件对象,因此共享 offset):
int fd = open("README", O_RDONLY);
int fd2 = dup(fd);fsync()
write() 之后数据不会立刻落盘,而是会先缓存在内存中。
想要强制落盘,使用 fsync(fd) 立刻同步内存中的脏数据。
但是,如果你新建了文件,有时光 fsync(file) 不够,还要 fsync() 所在目录,确保「这个文件名已经持久地出现在目录里」。
元数据、目录、删除
文件系统存储的元数据,使用 stat() 查看:
- inode number
- 文件大小
- 权限
- link count
- owner/group
- 访问/修改时间
删除文件的系统调用是 unlink(),调用 rm 会调用这个。
操作目录的 API 和操作普通文件的 API 不同,比如说 rmdir 之类的。
硬链接
为什么删除的 system call 是 unlink()?
先理解 link()(在 CLI 中使用 ln):
link(old file directory, new file directory)相当于是创建了引用同一个文件的方法,另一个名字是指向同一个 inode 号的。
创建一个文件的流程:
- 创建一个 inode 号,这个数字跟踪这个文件的所有元数据
- 将人类可读的名词链接到这个 inode 号。
所以,unlink() 就是把这个文件的引用计数减一。当前仅当引用计数达到 0 时,才会真正释放这个文件的内容。
符号链接(软链接)
硬链接的局限:
- 不能创建目录的硬链接(会在文件树中形成一个环,文件树必须是 DAG)
- 不能链接其他磁盘分区的文件
创建软链接使用 ln -s,用法看着是一样的,但是底层实现完全不同:软链接相当于直接指向路径名。
-
第一个区别是符号链接本身实际上是一个不同类型的文件。我们已经讨论过常规文件和目录。符号链接是文件系统知道的第三种类型。对符号链接运行
stat会显示类型不同; -
输入
ll显示的内容不同; -
可能会导致悬空引用,假设原文件被删除的话。
权限、ACL 和挂载
UNIX 基本权限用 9 个 bit 表示:
owner: rwx
group: rwx
other: rwx更复杂的系统还支持 ACL(access control list),可以更精细地指定谁能访问什么。
mount 把新的文件系统挂载在目录树的点上。