持久化

I/O 设备

I/O 系统架构演进

传统系统

image-20241216183418923

CPU通过专用的内存总线连接到内存,通过通用I/O总线(如PCI)连接到显卡等高性能设备,通过外围总线连接到低速的设备(USB Flash, 磁盘驱动器 ),因为光速的限制以及各种物理因素,越快的总线越短,造价也贵,因此离CPU越远的设备性能越低。

现代系统

image-20241216185046235

undefined

在现代系统架构中,有更多的点对点连接到 CPU,PCIe 显卡总线和内存专用总线速度相近,以提供更高的显示性能,CPU通过 DMI 连接到一颗 I/O 芯片,所有其他外设通过各种总线连接到这颗I/O芯片

标准 I/O 系统

设备结构

image-20241216193431840

分为接口和内部两部分,接口用来和外界进行数据交流;内部是设备自有的处理系统,含有CPU、内存以及其他芯片,现代的 RAID 控制器中包含很多行固件(filmware,设备中的软件)。

控制方式

轮询(polling)

接口分为状态、指令、数据寄存器,在设备空闲时传给设备数据(如果主CPU参与了数据移动,就称为 PIO, Programmed I/O)与指令,在设备就绪的时候由设备内部的处理系统进行操作,在完成之前CPU一直等待。也叫程序查询。

1
2
3
4
5
6
7
While (STATUS == BUSY)
; // 等待设备就绪
Write data to DATA register
Write command to COMMAND register
(starts the device and executes the command)
While (STATUS == BUSY)
; // 等待设备完成此次I/O任务

中断(interrupt)

image-20241216195356310

轮询的问题:CPU花费大量时间进行等待,浪费时间。

image-20241216195414131

中断:CPU发出请求,发起IO的进程阻塞,然后就切换上下文到其他进程,当设备完成IO就向CPU抛出一个硬件中断,CPU跳转到操作系统的中断服务程序,发起IO的进程变为就绪,由操作系统调度。也就是重叠(Overlap)

中断的代价:但是,对于高性能的设备,I/O 处理的速度可以很快,中断因为要切换上下文,也是存在成本的,如果在短时间内出现大量中断,可能出现活锁,系统过载

中断的优化

  • 使用混合策略:先轮询一小段时间,到时间再切换到其他进程

  • Web 服务器的网络端突然遇到高并发的情况,偶尔使用轮询可以减小切换带来的副作用

  • 合并中断(coalescing)

DMA(Direct Memory Access)

image-20241216201210900

轮询、中断都属于 PIO,有一个开销很大的步骤:CPU 要把指令和数据从内存拷贝到磁盘,拷贝完才能切换上下文,而磁盘所属的外部总线速率很慢,因此这里是一个瓶颈。

image-20241216202811882

DMA 引擎是一个特殊设备,内部有处理机,可以协调完成内存和设备之间的数据传递,无需 CPU。而 OS 会提供一片专属的连续物理内存供 DMA 使用,告诉要拷贝内存的地址,拷贝大小以及目标设备。此后就是 DMA 的工作了,完成之后抛出中断给CPU。

OS 与设备

访问设备寄存器

  1. 使用明确的特定 I/O 指令(特权指令)
  2. Memory-mapped I/O 把设备的寄存器映射到虚拟地址中,硬件会直接跟寄存器通信,而不是通过主存

驱动程序

image-20241216205354583

  • 应用程序通过 POSIX API 和最上层的文件系统交互,但他们都不知道磁盘的具体类型,所有底层协议接口的区别都抽象为了块接口(Block Interface)

  • 根据协议划分的底层接口(Specific Block Interface)也是抽象出来的,为了兼容性屏蔽掉了一些区别,SCSI 的报错信息很丰富,而 ATA/IDE 很简单,这就导致 SCSI 的附加信息不能起作用

  • 操作系统通过驱动程序(Device Driver)控制最底层的硬件交互。

驱动程序面向特定的寄存器和特定的 I/O 地址编程:

  1. 请求排队,等待就绪,正式发送请求,把数据和指令写到磁盘寄存器,然后进程休眠
  2. 设备再次就绪会发出中断,操作系统负责响应:接收数据并唤醒进程,其中如果请求队列不为空,就继续发送请求
  3. 会使用锁机制确保数据一致性

磁盘驱动器(HDD)

Hard Disk Drive

机械结构

image-20241217135035184

  • 扇区(Sectors):磁盘存储数据的基本结构,扇区 512 B 大小
  • 盘片:磁盘的盘片可以划分为多个扇区,盘片绕中轴由电机驱动旋转
  • 磁道:扇区呈圆形分布,围一圈就是一个磁道,一个盘片上有很多磁道
  • 磁头:负责访问扇区,由一个磁臂驱动,在磁道之间移动

image-20241217135215631

  • 磁道偏斜
  • 缓存(track buffer):驱动器可能会一次读取一个磁道所有的扇区

I/O 参数

  • 每分钟转速:5,000 RPM~ 15,000 RPM 用 60s 相除即为周期 $T$

  • 旋转延迟(rotation):磁头只能等待扇区转过来才能读取,在 $[0,T]$ 之间,平均需要 $\frac{T}{2}$ 时间

  • 寻道时间(seek):磁头移动到正确磁道才能正确访问扇区,平均寻道时间为最大寻道时间的三分之一

$$
\sum_{x=0}^{N}\sum_{y=0}^{N}|x-y|=
\int_0^{N}\int_0^{N}|x-y|dxdy
$$

image-20241217133655708 $$ 总寻道距离为 \frac{N^3}{3},一次寻道的平均距离=\frac{N}{3} $$ ![image-20241217135359356](https://pub-9e727eae11e040a4aa2b1feedc2608d2.r2.dev/PicGo/image-20241217135359356.png)

随机访问和顺序访问:

image-20241217135637529

image-20241217135701360

随机访问花费在寻道等待旋转的时间占IO总时间比例较大(导致随机 I/O 耗时的主要成本)

磁盘调度算法

并发的I/O请求会排成队列,需要合适的调度策略才能提高效率

只考虑寻道时间(SCAN)

image-20241217141046652

  • SSTF 最短寻道时间优先
    • 问题:驱动程序无法看到具体的磁道只能看到块地址、饥饿
  • NBF 最近块优先
    • 问题:饥饿
  • SCAN 扫描算法
    • 顺序扫描磁道,每个磁道在每次扫描过程中只能被服务一次,顺序扫完逆序回来扫一遍
    • C-SCAN(Circular-SCAN):只在磁盘的一侧进行扫描,并且只按照一个方向扫描,直到到达磁盘边界,然后回到磁盘起点,重新开始循环。
    • LOOK:SCAN 算法中磁头到了磁盘的边界才改变移动方向,这样可能会做很多无用功,因为磁头移动方向上可能已经没有请求需要处理了。LOOK 算法对 SCAN 算法进行了改进,如果磁头移动方向上已经没有别的请求,就可以立即改变磁头移动方向,依此往复。
    • C-LOOK: C-SCAN 算法进行了改进,如果磁头移动的方向上已经没有磁道访问请求了,就可以立即让磁头返回,并且磁头只需要返回到有磁道访问请求的位置即可。

考虑寻道时间+旋转时间

image-20241217141031508

图中寻道时间和旋转时间需要具体进行比较,因此不能在OS层面实现,需要驱动器自己实现

  • SPTF 最短定位时间优先

真实的调度

  • 操作系统内部会把它认为最好的几个请求发出。磁盘内部能够得到完整的磁头位置以及磁道信息,因此内部可以实现SPTF,具体由驱动器调度
  • I/O 合并:操作系统需要考虑将多个地址相近的请求合并为一个请求,因此驱动器得到的请求都是合并之后的请求
  • 非工作保全:操作系统并不是收到一个请求就立即发给磁盘,除去I/O合并,可能还需要等待某段时间,等到更好的请求过来,从而整体提高效率

独立冗余磁盘阵列(RAID)

RAID

Redundant Array of Independent Disks

  1. 抽象:RAID 内部是一个复杂的计算机系统,有磁盘、CPU(可以是多个)、内存,但是对外仍然像一个磁盘,一个线性的块数组
  2. 性能:RAID 中可以有多个磁盘并行处理请求,并且有CPU调度,可以极大提升效率
  3. 容量:RAID 可以扩充总容量
  4. 可靠性:通过冗余来容忍一定程度的数据损失

接口与内部结构

文件系统向 RAID 接口发起逻辑 I/O,RAID 的处理器需要调度逻辑 I/O,向具体的一个或多个磁盘接口发出真正的物理 I/O,RAID 需要一些程序(固件 filmware)来协调磁盘之间的工作,同时需要RAM和ROM进行安全地缓冲数据,甚至可能包含专门的逻辑电路用来进行数据完整性的检验。

RAID 实现

  • 软件:主要由 主 CPU 处理数组存储作业,缺点为耗损较多CPU资源运算RAID,优点则是价格低(若用操作系统的RAID功能,则无需额外花费)

    • Fake RAID 卡辅助 仍然借助CPU进行运算。此类RAID卡的阵列较易迁移到其他电脑,其RAID功能靠执行于操作系统的厂商驱动程序和CPU运算提供。
    • 操作系统内置支持
  • 硬件:完全基于硬件自身的运算,不需要 主 CPU 参与

    • 主板支持 RAID,依靠主板芯片,不需要任何磁盘阵列卡。

    • RAID 卡:基于片上RAID (ROC, RAID-on-chip),具有独立的 CPU 资源及独立 BIOS。优点是读写性能快,不占用服务器资源,可用于任何操作系统,也能在系统断电后,透过备份电池模块(BBU, Backup Battery Unit)以及非易失性存储器(NVRAM)将硬盘读写日志(Journal)包含的剩余读写作业先记录在存储器中,等待电力供应撤销后,再由 NVRAM 取回日志资料,接着再完成读写作业,将剩余读写作业安全完成以确保读写完整性。此外,使用RAID卡还可能因内置的自检程序而导致电脑启动时间增加。

性能基准

RAID等级 最少硬盘 最大容错 可用容量 读取性能 写入性能 安全性 目的 应用产业
单一硬盘 (参考) 0 1 1 1
JBOD 1 0 n 1 1 无(同RAID 0) 增加容量 个人(暂时)存储备份
0 2 0 n n n 一个硬盘异常,全部资料都会异常 追求最大容量、速度 影片剪接缓存用途
1 2 n-1 1 n 1 高,一个正常即可 追求最大安全性 个人、企业备份
5 3 1 n-1 n-1 n-1 中下至中 追求最大容量、最小预算 个人、小型企业备份
6 4 2 n-2 n-2 n-2 中至中高,仅安全性较RAID 5高 同RAID 5,但较安全 个人、企业备份
10 4 综合RAID 0/1优点,理论速度较快 大型数据库、服务器
50 6 提升资料安全
60 8 提升资料安全
  1. n代表硬盘总数
  2. JBOD(Just a Bunch Of Disks)指将数个物理硬盘在操作系统中合并成一个逻辑硬盘,以直接增加容量,失去磁盘分割表即失去一切数据,若遭遇磁盘阵列资料或硬盘出错的状况,危险程度较RAID 0更剧。它的好处是不会像RAID 0,每次访问都要读写全部硬盘。同时,因为每次读写操作只作用于单一硬盘,JBOD的传输速率与I/O表现均与单颗硬盘无异。

RAID 方式

RAID 0(条带化)

RAID 0

RAID 0亦称为带区卷。它将两个以上的磁盘并联起来,成为一个大容量的磁盘。在存放数据时,分段后分散存储在这些磁盘中,因为读写时都可以并行处理,所以在所有的级别中,RAID 0的速度是最快的。但是RAID 0既没有冗余功能,也不具备容错能力,如果一个磁盘(物理)损坏,所有数据都会丢失,危险程度与JBOD相当。

RAID 1(镜像)

RAID 1

两组以上的N个磁盘相互作镜像,在一些多线程操作系统中能有很好的读取速度,理论上读取速度等于硬盘数量的倍数,与RAID 0相同。另外写入速度有微小的降低。只要一个磁盘正常即可维持运作,可靠性最高。其原理为在主硬盘上存放数据的同时也在镜像硬盘上写一样的数据。当主硬盘(物理)损坏时,镜像硬盘则代替主硬盘的工作。因为有镜像硬盘做数据备份,所以RAID 1的数据安全性在所有的RAID级别上来说是最好的。但无论用多少磁盘做RAID 1,仅算一个磁盘的容量,是所有RAID中磁盘利用率最低的一个级别。

如果用两个不同大小的磁盘建RAID 1,可用空间为较小的那个磁盘,较大的磁盘多出来的空间也可以分割成一个区来使用,不会造成浪费。

RAID 1没有校验机制。用两个磁盘组成RAID 1阵列,如果两个硬盘上的数据出现差异,RAID 1会不知道该相信哪一个硬盘,这种情形称作大脑分裂。事实上,RAID 1的磁盘数量越多,越有可能其中某个磁盘的数据变得不一致(但仍然工作),RAID 1只会从第一个工作的硬盘里提供数据,没有办法检测到底哪个硬盘的数据不对。

RAID 4(奇偶校验)

采用块交织技术(Block interleaving)。在分割时是以区块为单位分别存在硬盘中,但每次的数据访问都必须从同比特检查的那个硬盘中取出对应的同比特数据进行核对,由于过于频繁的使用,所以对硬盘的损耗可能会提高。

RAID 5(旋转奇偶校验)

RAID 5

RAID 5 是一种存储性能、数据安全和存储成本兼顾的存储解决方案。 RAID 5可以理解为是RAID 0和RAID 1的折中方案。RAID 5可以为系统提供数据安全保障,但保障程度要比Mirror低而磁盘空间利用率要比Mirror高。RAID 5具有和RAID 0相近似的数据读取速度,只是多了一个奇偶校验信息,写入数据的速度比对单个磁盘进行写入操作稍慢。同时由于多个数据对应一个奇偶校验信息,RAID 5的磁盘空间利用率要比RAID 1高,存储成本相对较低,是运用较多的一种解决方案

其他 RAID

RAID 2 是 RAID 0 的改良版,以汉明码(Hamming Code)的方式将数据进行编码后分割为独立的比特,并将数据分别写入硬盘中。因为在数据中加入错误修正码(ECC,Error Correction Code),所以数据整体的容量会比原始数据大一些。最少要三台磁盘驱动器方能运作。

RAID 3 采用Bit-interleaving(数据交错存储)技术,它需要通过编码再将数据比特分割后分别存在硬盘中,而将相同比特检查后单独存在一个硬盘中,但由于数据内的比特分散在不同的硬盘上,因此就算要读取一小段数据资料都可能需要所有的硬盘进行工作,所以这种规格比较适于读取大量数据时使用。

RAID 10 先做镜像卷 RAID 1 将所有硬盘分为两组,再做RAID 0 执行条带化操作分割数据,视为以RAID 1作为最低组合,然后将每组RAID 1视为一个“硬盘”组合为RAID 0运作。当RAID 10有一个硬盘受损,其余硬盘会继续运作。

文件系统

预备知识

文件和目录的抽象

文件可以看作一个线性字节数组,能够进行读或者写,并且每个文件都有一个唯一标识自己的 inode 号

目录也有一个 inode 号,但其内容非常具体:它包含一个“用户可读名称——inode号”对的列表。例如,假设有一个低级名称( inode 号 )为“10”的文件,并且由用户可读名称“foo”引用。因此,“foo”所在的目录将有一个条目(“foo”,“10”),它将用户可读名称映射到低级名称。目录中的每个条目引用文件或其他目录。通过将目录放置在其他目录中,用户可以构建任意目录树(或目录层次结构),所有文件和目录都存储在该目录树下。

文件操作

通过 strace 来追踪一条命令的系统调用

创建与打开 open(2)

int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);

“foo” 为文件名,“O_CREAT” 表示文件不存在则创建,“O_WRONLY” 表示write only只写,“O_TRUNC” 表示如果文件存在则截断大小为0字节从而删除任何现有内容(WRONLY以及读写方式),第三个参数表示权限,文件所有者可读可写。

要访问文件,进程必须使用系统调用(通常为 open())来请求操作系统的权限。如果授予权限,操作系统会返回一个文件描述符,然后可以在权限和意图允许的情况下将其用于读或写访问。

文件描述符 file descriptor

每个文件描述符都是一个私有的、每个进程的实体,它引用打开文件表中的一个条目。其中的条目跟踪这次访问引用了哪个文件、文件的当前偏移量(即下一次读取或写入将访问文件的哪一部分)以及其他相关信息。

open() 的一个重要方面是它返回的内容:文件描述符。文件描述符只是一个整数,每个进程私有,在 UNIX 系统中用于访问文件;因此,一旦打开文件,您就可以使用文件描述符来读取或写入该文件(假设您有这样做的权限)。 这样,文件描述符就是一个不透明的句柄,使您能够执行某些操作。将文件描述符视为指向文件类型对象的指针;一旦有了这样的对象,您就可以调用其他“方法”来访问该文件,例如 read() 和 write()

文件描述符是每个进程私有的,它引用打开文件表中的一个条目。其中的条目跟踪这次访问引用了哪个文件、文件的当前偏移量(即下一次读取或写入将访问文件的哪一部分)以及其他相关信息。

在进程结构中有一个代表文件描述符的数据结构——打开文件表,每个进程都维护一个文件描述符数组,每个文件描述符都引用系统范围的打开文件表中的一个条目。该表中的每个条目都会跟踪描述符引用的底层文件、当前偏移量以及其他相关详细信息,例如文件是否可读或可写。

1
2
3
4
5
6
struct proc {
...
struct file *ofile[NOFILE]; // Open files
...
};

这是一个 file 的二级指针,或者也可以说是指向 file 结构体的指针数组,表示打开的文件描述符,最大打开 NOFILE 个文件,文件描述符就是数组下标,ofile[0]代表指向 stdin 的指针。数组的每个元素都指向一个 file 结构体

读写文件 read(2) write(2)

调用 read() 和 write() 自然会更新当前偏移量[current offset]; 进程可以使用 lseek() 更改其值,从而实现对文件不同部分的随机访问

image-20241231110340615

ssize_t read(int fd, void buf[.count], size_t count);

第一个参数是fd,这里的3就是刚刚打开的foo文件描述符,第二个参数是缓冲区,第三个参数是缓冲区大小,返回读取的字节数6

ssize_t write(int fd, const void buf[.count], size_t count);

第一个参数是fd,这里的1是stdout标准输出,第二个是刚刚的缓冲区,第三个是要写入的字节数。

cat 可能会调用库例程 printf();最后,printf() 计算出传递给它的所有格式详细信息,并最终写入stdout 以将结果打印到屏幕上。 然后,cat 程序尝试从文件中读取更多内容,但由于文件中没有剩余字节,因此 read() 返回 0,程序知道这意味着它已读取整个文件。因此,程序调用 close() 来表明文件“foo”已完成,并传入相应的文件描述符。文件就这样关闭了,它的读取就这样完成了。

随机读写 lseek(2)

要注意这里的seek和磁盘寻道的seek没有关系

off_t lseek(int fildes, off_t offset, int whence);

第一个参数是fd,第二个是偏移,第三个是 lseek 模式

  • If whence is SEEK_SET, the offset is set to offset bytes.

  • If whence is SEEK_CUR, the offset is set to its current location plus offset bytes.

  • If whence is SEEK_END, the offset is set to the size of the file plus offset bytes.

current location(offset) 随着顺序读写在隐式变化:

image-20241231112124078

image-20241231112134972

image-20241231112341606

文件的数据结构(file
1
2
3
4
5
6
7
struct file {
int ref;
char readable;
char writable;
struct inode *ip;
uint off;
};

一个是 ref 引用计数,另外两个是可读/可写,inode是文件的元数据,剩下的就是current offset偏移量,OFT 保存的就是这样的打开文件结构。

共享打开文件表 fork(2) dup(2)

在许多情况下,文件描述符到打开文件表中的条目的映射是一对一的映射。例如,当一个进程运行时,它可能决定打开一个文件,读取它,然后关闭它;在此示例中,该文件将在打开的文件表中具有唯一的条目。即使其他进程同时读取同一个文件,每个进程也会在打开的文件表中拥有自己的条目。每个逻辑读写都是独立的,都有他们自己的文件偏移。

但是有时候进程的打开文件表是共享的

fork()在父级和子级之间共享打开的文件表条目有时很有用。例如,如果您创建多个协作处理某项任务的进程,它们可以写入同一输出文件,而无需任何额外的协调。

image-20241231121737897

image-20241231121907763

dup() 调用允许进程创建一个新的文件描述符,该文件描述符引用与现有描述符相同的底层打开文件。dup() 调用(特别是 dup2())在编写 UNIX shell 和执行输出重定向等操作时非常有用

image-20241231122001903

立即刷盘 fsync(2)

要强制更新到持久介质,进程必须使用 fsync() 或相关调用。然而,在保持高性能的同时正确执行此操作具有挑战性,因此在执行此操作时请仔细考虑。

大多数时候,当程序调用 write() 时,它只是告诉文件系统:请在将来的某个时刻将此数据写入持久存储。出于性能原因,文件系统会将此类写入在内存中缓冲一段时间(例如 5 秒或 30 秒);在稍后的时间点,写入实际上将被发送到存储设备。从调用应用程序的角度来看,写入似乎很快就能完成,并且只有在极少数情况下(例如,在 write() 调用之后但在写入磁盘之前机器崩溃)才会丢失数据。 但是对于DBMS,要求数据强一致,所以应该不时进行强制刷盘:

当进程为特定文件描述符调用 fsync() 时,文件系统会通过将指定文件描述符引用的文件的所有脏(即尚未写入)数据强制写入磁盘来做出响应。一旦所有这些写入完成,fsync() 例程就会返回。有趣的是,这个顺序并不能保证你所期望的一切。在某些情况下,您还需要 fsync() 包含文件 foo 的目录。添加此步骤不仅可以确保文件本身位于磁盘上,而且可以确保该文件(如果是新创建的)也永久地成为目录的一部分。毫不奇怪,此类细节经常被忽视,从而导致许多应用程序级错误 [P+13,P+14]。

重命名文件 rename(2)

调用 mv 命令

rename(char *old, char *new) 新名称和旧的名称

image-20241231122626242

编辑器所做的事情很简单:以临时名称 (foo.txt.tmp) 写出文件的新版本,使用 fsync() 将其强制写入磁盘,然后,当应用程序确定新文件时元数据和内容都在磁盘上,将临时文件重命名为原始文件的名称。最后一步以原子方式将新文件交换到位,同时删除旧版本的文件,从而实现原子文件更新。

内存映射 mmap(2)

mmap 为file结构中的offset和调用进程的虚拟地址之间创造出了一种映射关系,前者称为backing file,后者称为内存映射。进程可以使用 CPU 指令(即加载和存储)访问内存中映射的backing file。 通过将文件的持久性与内存的访问语义相结合,文件支持的内存映射支持称为持久性内存的软件抽象。消除内存和存储的不同数据格式之间的转换,简化应用程序。

image-20241231123711172

文件包含堆栈大小的计数以及保存堆栈内容的整数数组。在 mmap() 处理backing file之后,返回值为内存指针,我们可以使用 C 指针来访问这个持久堆栈文件,例如,p->n 访问堆栈上的计数,p->stack 访问整数数组。由于堆栈是持久的,因此一次调用 pstack 推送的数据可以由下一次调用弹出。

在 push 的增量和分配之间发生崩溃可能会使持久堆栈处于不一致的状态。应用程序通过使用针对故障自动更新持久内存的机制来防止此类损坏。WAL(write ahead logging)

获取文件的元数据 stat(2)

image-20241231123010037

stat 结构中包含文件的各种信息,在操作系统中,这些信息就存储在 inode 结构中现在,您应该将索引节点视为由文件系统保存的持久数据结构,其中包含我们上面看到的信息。所有 inode 都驻留在磁盘上;活动副本通常缓存在内存中以加快访问速度。

调用 rm 命令

要使文件系统中的多个 人类可读名称 引用同一基础文件,请使用硬链接或符号链接。每种方法在不同的情况下都有用,因此在使用之前请考虑它们的优点和缺点。请记住,删除文件只是从目录层次结构中执行最后一次 unlink() 操作。

硬链接(hard link)

通过 link() 的系统调用在文件系统树中创建条目,回到为什么通过 unlink() 执行删除文件的谜团。 link() 系统调用有两个参数,oldpath和newpath;当将newpath“链接”到oldpath,实际上创建了另一种方式来引用同一文件。

link() 的工作方式:在创建链接的目录中创建另一个名称,并将其引用到原始文件的相同 inode 号。该文件不会以任何方式复制;但是现在有两个人类可读的名称(file 和 file2),它们都引用同一个文件。命令行程序 ln 用于执行此操作:

image-20241231125743798

image-20241231125826092

可以看到两个文件的 inode 号完全一样。当创建文件时,文件系统实际上在做两件事。首先,创建一个结构(inode),inode 将跟踪有关文件的几乎所有相关信息,包括文件的大小、块在磁盘上的位置等等。其次,将一个人类可读名称链接到该 inode,并将该链接放入一个目录中。

创建文件的硬链接后,文件系统感知到原始文件名(file)和新创建的文件名(file2)之间没有区别;事实上,它们都只是指向该文件的元数据的链接,该元数据可通过 inode 号 67158084 找到。因此,要从文件系统中删除文件,我们调用 unlink()。在上面的示例中,我们可以删除名为 file 的文件,并且仍然可以毫无困难地访问该文件

引用计数(reference count)

这样做的原因是,当文件系统unlink文件时,它会检查 inode 号内的引用计数。该引用计数(有时称为链接计数)允许文件系统跟踪有多少不同的文件名已链接到该特定 inode。删除文件引用时,引用计数会减一,此文件引用到inode号的映射会消失。只有当引用计数为零时,文件系统才会释放inode和相关数据块,从而真正“删除”文件。 当然,您可以使用 stat() 查看文件的引用计数。让我们看看当我们创建和删除文件的硬链接时会发生什么:

image-20241231130430929

软链接(soft/symbolic link)

还有另一种非常有用的链接类型,它称为符号链接,有时也称为软链接。硬链接有一定的局限性:不能创建一个目录的硬链接(因为担心你会在目录树中创建一个循环);不能硬链接到其他磁盘分区中的文件(因为 ==inode 编号仅在特定文件系统内唯一==,而不是跨文件系统);因此,创建了一种称为符号链接的新型链接。

使用 ln -s file file2 创建file的软引用,软引用实际上是常规文件、目录之外的第三种文件,运行 ls 也揭示了这个事实。如果仔细观察 ls 输出的长格式的第一个字符,您会发现最左侧列中的第一个字符是 -(表示常规文件)、d(表示目录)和 l(软链接)。还可以查看符号链接的大小(在本例中为 4 字节)以及链接指向的内容(名为 file 的文件)。

image-20241231130958841

符号链接的形成方式是将链接到的文件的路径名作为链接文件的数据,因此会出现悬空引用:

image-20241231130739288

与硬链接完全不同,删除名为 file 的原始文件会导致软链接指向一个不存在的路径名。因此,这个就类似 windows 中的 .lnk 快捷方式文件

目录操作

创建删除目录 mkdir rmdir

目录虽然也属于一种特殊的文件,但是不能直接修改他们的信息,因为这正是文件系统为了保持一致性所作的工作,只能通过修改其中的文件信息来简介修改

通过mkdir()(由同名程序 mkdir 使用)创建目录,当创建这样的目录时,它被视为“空”,尽管它确实具有最少的内容。具体来说,空目录有两个条目:一个引用其自身的条目,另一个引用其父目录的条目。前者被称为“。” (点)目录,后者为“..”(点-点)。您可以通过将标志 (-a) 传递给程序 ls 来查看这些目录

您可以通过调用 rmdir()(由同名程序 rmdir 使用)来删除目录。然而,与文件删除不同,删除目录更危险,因为您可能使用单个命令删除大量数据。因此, rmdir() 要求目录在删除之前为空(即只有“.”和“..”条目)。如果您尝试删除非空目录,则对 rmdir() 的调用将会失败。

读取目录内容 opendir(2) readdir(2) closedir(2)

image-20241231125255802

如图即为读取目录,输出所有文件的信息。由于目录信息较少(基本上,只有将人类可读名称映射到 inode 号,以及一些其他详细信息),因此程序可能希望对每个文件调用 stat() 以获取每个文件的更多信息,例如其长度或其他详细信息。事实上,这正是 ls 当你传递 -l 标志时所做的事情;尝试在带有或不带有该标志的 ls 上执行 strace 来亲自查看。

访问许可与权限控制

大多数文件系统都有启用和禁用共享的机制。 此类控制的基本形式是由权限位提供的;更复杂的访问控制列表可以更精确地控制谁可以访问和操作信息。

许可控制位(permission bits)

文件系统中的访问权限位:-rw-r--r-- 1 remzi wheel 0 Aug 24 16:29 foo.txt

第一个是文件类型位,剩下九位是访问控制位,分成三部分:文件所有者|同一个group|其他人

其中 r 代表可读,w 代表可写,x 代表可执行,文件的所有者可以轻松更改这些权限,例如通过使用 chmod 命令(更改文件模式): 用三位二进制数表示权限许可位:

chmod 707 foo.sh : rwx—rwx

chmod 640 foo.txt : rw-r—–

  • 对于文件,可执行就代表脚本文件或者ELF二进制可执行文件能否直接被执行
  • 对于目录,可执行就代表能否 cd 进去
超级管理员(su/root)

允许哪个用户执行特权操作来帮助管理文件系统?例如,如果需要删除非活动用户的文件以节省空间,谁有权这样做? 在本地文件系统上,常见的默认设置是某种超级用户(即 root)可以访问所有文件,无论权限如何。在诸如 AFS(具有访问控制列表)之类的分布式文件系统中,名为 system:administrators 的组 包含受信任执行此操作的用户。在这两种情况下,这些受信任的用户都代表着固有的安全风险;如果攻击者能够以某种方式冒充此类用户,则攻击者可以访问系统中的所有信息,从而违反预期的隐私和保护保证。

访问控制表(access control lists)

setacl listacl

除了权限位之外,一些文件系统,例如称为 AFS 的分布式文件系统(将在后面的章节中讨论),还包括更复杂的控制。例如,AFS 以每个目录的访问控制列表 (ACL) 的形式执行此操作。访问控制列表是一种更通用、更强大的方式来准确表示谁可以访问给定资源。 在文件系统中,这使用户能够创建一个非常具体的列表,其中列出谁可以读取一组文件,谁不能读取一组文件,这与上述权限位的所有者//所有人结构不同,例如,以下是一位作者的 AFS 帐户中私有目录的访问控制:

image-20241231132720656

该列表显示,两者系统管理员和用户 remzi 可以查找、插入、删除和管理此目录中的文件,以及读取、写入和锁定这些文件。 要允许某人(在本例中为其他作者)访问此目录,用户 remzi 只需键入以下命令即可。 prompt> fs setacl private/ andrea rl

TOCTTOU(原子性问题)

Time Of Check to Time Of Use (TOCTTOU) 检查到使用之间存在空隙,没有原子性,因此容易发生安全问题:邮件服务有root权限,首先它必须检查这个收件箱文件是否合法的常规文件,然后负责将新消息附加到收件箱文件中。攻击者可以通过这两个操作的空隙,将收件箱文件指向本来无法访问的敏感区域,例如 /etc/passwd(其中保存有关用户及其密码的信息)

TOCTTOU 问题没有任何简单而出色的解决方案。

  • 一种方法是减少需要 root 权限才能运行的服务数量,这会有所帮助。
  • O_NOFOLLOW 标志,如果目标是软链接,open() 将失败,从而避免软链接攻击。
  • 更激进的方法,使用事务型文件系统,可以解决问题,但广泛部署的事务性文件系统并不多。
  • 因此,通常的(蹩脚的)建议是:编写以高权限运行的代码时要小心!

挂载文件系统 mount

如何从许多底层文件系统中组装出完整的目录树?此任务是通过首先创建文件系统,然后安装它们以使其内容可访问来完成的。

为了创建文件系统,大多数文件系统都提供了一个工具 mkfs 专门执行此任务。 想法如下:为工具提供一个设备(例如磁盘分区,例如 /dev/sda1)和文件系统类型(例如 ext3)作为输入,它只需写入一个空文件系统从根目录开始,到该磁盘分区。而mkfs说,要有一个文件系统! 然而,一旦创建了这样的文件系统,就需要在统一文件系统树中对其进行访问。该任务是通过 mount 程序来实现的(这使得底层系统调用 mount() 来完成真正的工作)。 mount 的作用很简单,就是将现有目录作为目标安装点,并将新的文件系统粘贴到该点的目录树上,用-t 来显式标记设备的文件系统

image-20241231133159940

sda1设备中有a和b两个文件,在挂载操作之后,在/home/users/ 下就会多出两个文件 a b

要查看挂载信息,直接运行 mount

image-20241231133404123

如图,这个操作系统上挂载了许多文件系统,包括 ext3(基于磁盘的标准文件系统)、proc 文件系统(用于访问当前进程信息的文件系统)、tmpfs(仅用于临时文件的文件系统) )和 AFS(分布式文件系统)都粘合到这台机器的文件系统树上