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: 进入中断处理程序

流程大致是:

  1. OS 向设备发出请求。
  2. 发起请求的进程睡眠。
  3. OS 切换去运行别的进程。
  4. 设备完成 I/O 后发硬件中断。
  5. CPU 进入 OS 的中断处理程序。
  6. OS 完成收尾工作,并唤醒等待 I/O 的进程。

但是,轮询也不一定就比中断更好:因为中断是有固定开销的:上下文切换、中断处理、切回。

有时候采用折中方案:先轮询一会儿,再中断。

DMA

使用 PIO 的缺陷在于 CPU 自己需要把内存中的数据写入设备寄存器,浪费 CPU 资源。

引入 DMA,作用是让一个专门的控制器负责在内存和设备之间搬数据,CPU 只负责发起和收尾

流程:

  1. OS 告诉 DMA 控制器:数据在内存哪里、长度多少、要送到哪个设备。
  2. DMA 控制器开始搬数据。
  3. CPU 不用亲自复制,可以去运行别的进程。
  4. DMA 完成后发中断通知 OS。

OS 和设备寄存器的通信

两种方式:

  • 显式IO指令,比如 x86 中的 in out,是特权指令,只有 OS 能用;
  • 内存映射 IO,硬件把设备寄存器映射到一段特殊的物理地址,然后使用 load store 来访问。

设备驱动

作用:把具体设备的复杂协议封装,向 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

所谓「表项

  1. 每次 open 一个文件的时候 OS 就会在系统级的 open file table 中创建一个条目,然后返回一个 fd
  2. 记录的信息有 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 号的。

创建一个文件的流程:

  1. 创建一个 inode 号,这个数字跟踪这个文件的所有元数据
  2. 将人类可读的名词链接到这个 inode 号。

所以,unlink() 就是把这个文件的引用计数减一。当前仅当引用计数达到 0 时,才会真正释放这个文件的内容。

符号链接(软链接)

硬链接的局限:

  • 不能创建目录的硬链接(会在文件树中形成一个环,文件树必须是 DAG)
  • 不能链接其他磁盘分区的文件

创建软链接使用 ln -s,用法看着是一样的,但是底层实现完全不同:软链接相当于直接指向路径名

  • 第一个区别是符号链接本身实际上是一个不同类型的文件。我们已经讨论过常规文件和目录。符号链接是文件系统知道的第三种类型。对符号链接运行stat 会显示类型不同;

  • 输入 ll 显示的内容不同;

  • 可能会导致悬空引用,假设原文件被删除的话。

权限、ACL 和挂载

UNIX 基本权限用 9 个 bit 表示:

owner: rwx
group: rwx
other: rwx

更复杂的系统还支持 ACL(access control list),可以更精细地指定谁能访问什么。

mount 把新的文件系统挂载在目录树的点上。

第40章 文件系统实现