在之前博文 Linux 内核学习笔记:初始化程序(第 4 部分)我们介绍了缓冲区,主要涉及缓冲区管理和初始化。在本文,我们将更深入理解缓冲区。
按照《Linux 内核设计的艺术》的说法,设计缓冲区能给操作系统的运行带来如下两个好处:
- 形成所有块设备数据的统一集散地,对这些数据的管理更方便、灵活。
- 对块设备的数据操作效率更高。原因在于缓冲区中的缓冲块是可以被不同进程共享的。
缓冲区总体结构
缓冲区总体结构如下图所示:
图片来源:《Linux 内核设计的艺术》
其中,buffer_head 和 request 是缓冲区管理非常重要的两个数据结构:
- 缓冲区管理结构(buffer_head)主要负责
进程与缓冲区
中缓冲块的数据交互,在确保数据交互正确的前提下,让数据在缓冲区中停留的时间尽可能长 - 块设备请求项(request)主要负责
块设备与缓冲区
之间的数据交互,在确保数据交互正确的前提下,尽可能地及时将进程修改过的缓冲块中的数据同步到块设备中
这两个数据结构定义如下:
1 | // include/linux/fs.h -------------------------- |
缓冲块与逻辑块一一映射
注:逻辑块在这里指的是块设备上的数据块(磁盘块),大小跟缓冲块一样,都是 1024 个字节。
如果缓冲块没有映射到正确的逻辑块,那必然会导致读入缓冲块或者写入逻辑块的数据是垃圾数据。这样一来,进程与外设的通信也就无正确性可言。所以,我们要保证缓冲块与逻辑块时一一对应的。在 Linux 0.11,这种唯一的映射关系是通过 buffer_head 数据结构中的 b_dev
和 b_blocknr
字段来保证的。
在我们阐述这是怎么做到之前,我们先来看一下逻辑块号的计算。
逻辑块号
Linux 0.11 内核使用的是 MINIX 文件系统 1.0 版本。该文件系统由 6 个部分组成,各部分在块设备上的分布如下图所示:
图片来源:《Linux 内核完全注释》
这 6 个部分都有该文件系统的数据结构 super_block 来描述。其中,各个部分的长度(以块为单位)如下:
1 | 长度(以块为单位) |
注意,对于进程而言,数据都是连续的(包括文件),但实际上,对于块设备(以磁盘为例)而言,除了数据区前的数据进程能够直接对应以外,数据区上的数据进程并不能够直接对应,而是要通过 bmap
函数来映射到具体的逻辑块。如下图所示:
下边我们来看一下具体怎么计算逻辑块号。
数据区前逻辑块号计算
当我们要取得引导块、超级块、i 节点位图、逻辑块位图、i 节点数所在的逻辑块时,我们很快就知道具体要从块设备中取得哪一块,因为只要根据 super_block 信息很容易就能算出来。给定
1 | 逻辑块号 |
数据区逻辑块号计算
对于数据区而言,它的所有逻辑块不是被有规律地引用,所以无法利用 super_block 的信息推导出进程需要的逻辑块。另外,对于进程而言,文件的数据是连续的,但对块设备而言,文件的数据不一定是连续存储在一起的。
这意味着,进程所需读取的数据块跟块设备上的逻辑块并不一一对应,而是要通过文件的 i 节点信息找到具体需要的逻辑块,具体利用的是 bmap
函数。
每个文件(一切皆文件)都有对应的 i 节点。其中,i 节点数据结构中的 i_zone[9]
数组指向
(注意措辞!!!)文件的数据。这些数据可能散落在数据区各个逻辑块上,但可以通过 i_zone[9] 数组链接得到
,如下图所示:
图片来源:《Linux 内核完全注释》
bmap
函数输入的是进程需要的数据块号,输出的是块设备的逻辑块号
,其定义如下:
1 | // fs/inode.c ---------------------------------- |
b_dev 和 b_blocknr
在这里,我们重申一下这两个字段的意义:
b_dev
:缓冲块对应的设备号,如第一块硬盘设备号是 0x300b_blocknr
:缓冲块对应的“设备上”的逻辑块号,如硬盘上的第 0 块
将缓冲块跟块设备上的逻辑块一一对应起来的是 getblk 函数
:
getblk 函数
1 | // fs/buffer.c --------------------------------- |
该函数主要在 bread 函数中被调用:
1 | // fs/buffer.c --------------------------------- |
bread 函数实际是通过 getblk 函数来申请空闲缓冲块。getblk 是一个比较底层的函数,它用 dev 和 block 来唯一标记一个缓冲块。需要注意的是这里的 block 指的是“块设备上的逻辑块号”。
另外,bread 函数作为比较底层的函数被诸多上层函数调用,如:
1 | // fs/super.c ---------------------------------- |
跟请求项映射
getblk
函数只是绑定了缓冲块跟逻辑块之间的关系,具体的读写还得由请求项 request 来管理。而块设备的数据单位是扇区,不是块,所以请求项还得做一定的变换:
1 | // kernel/blk_drv/ll_rw_block.c ---------------- |
uptodate 和 dirt 的作用
b_uptodate 和 b_dirt
对缓冲块的共享有两个方向:
- 进程方向:进程能共享哪些缓冲块,不能共享哪些
- 硬盘方向:哪些缓冲块需要同步到外设,哪些不用同步
缓冲块管理结构 buffer_head 的 b_uptodate
和 b_dirt
字段就是为了保证缓冲块和逻辑块数据的正确性:
b_uptodate
:针对进程方向。它的作用是告诉内核,只要缓冲块的 b_uptodate 字段被设置为 1,缓冲块的数据已经是最新的,可以放心地支持进程共享缓冲块的数据;反之,如果设置为 0,就提醒内核缓冲块数据并没有更新,不支持进程共享该缓冲块。
需要特别注意的是,b_uptodate 字段为 1 并不代表缓冲块的数据跟块设备逻辑块上的数据是一致的,它们可以不一样。
详细解释看下文。
b_dirt
:针对硬盘方向。只要缓冲块的 b_dirt 字段被设置为 1,就是告诉内核,这个缓冲块中的内容已经被进程修改过,需要同步到块设备上;反之,如果为 0,不需要同步。
下边分别对这两个字段进行阐述。所用块设备假设为硬盘。
b_uptodate
读取和写入数据
利用 getblk
函数申请到缓冲块时,b_uptodate 被设置为 0,表示该缓冲块的数据还没更新:
1 | // fs/buffer.c --------------------------------- |
从硬盘读取数据
在之前博文 Linux 内核学习笔记:进程 1 的创建及执行(第 3 部分)我们知道,内核实际是调用 read_intr
函数从硬盘读取数据。在数据读取结束后,该函数会调用 end_request
函数设置刚读入数据的缓冲块的 b_uptodate 为 1:
1 | // kernel/blk_drv/hd.c ------------------------- |
b_uptodate 设置为 1 后,,表示该缓冲块跟硬盘上的数据是一致的,可以直接被进程使用。
向硬盘写入数据
向硬盘写入数据实际是调用 write_intr
函数将缓冲块的数据写入硬盘。在数据写入结束后,该函数会调用 end_request
函数设置该缓冲块的 b_uptodate 为 1:
1 | // kernel/blk_drv/hd.c ------------------------- |
b_uptodate 设置为 1 后,,表示该缓冲块跟硬盘上的数据是一致的,可以直接被进程使用。
申请逻辑块
在之前博文 Linux 内核学习笔记:文件操作我们知道,要在硬盘上申请空闲的逻辑块,需要调用 new_block
函数:
1 | // fs/inode.c ---------------------------------- |
在上述 new_block
函数中,在申请到硬盘上的一个逻辑块后,将跟该逻辑块绑定的缓冲块清零,并且置 b_uptodate 字段为 1;但并没有将逻辑块的数据清零。
申请逻辑块是一种特殊情况。将 b_uptodate 字段设置为 1 只是表明该缓冲块的数据是最新,进程可以直接读或写,但并不意味着缓冲块的数据跟逻辑块的数据是一致的。
设想将 b_uptodate 字段设置为 0,进程以为该缓冲块数据不是最新的,就会从逻辑块将数据读入,而此时逻辑块数据并没有清零,读入的必然是垃圾数据。另外,因为是新建逻辑块,内核不会去读该逻辑块,只会向该逻辑块写数据,所以当该缓冲块的 b_dirt 字段为 1 时,将缓冲块数据同步到该逻辑块自然能够覆盖掉该逻辑块之前的垃圾数据。
b_dirt
b_uptodate 字段设置为 1 后,内核就支持进程共享该缓冲块的数据,读写都可以。读操作不会改变缓冲块的数据;但写操作会改变缓冲块的内容,需要将 b_dirt 字段设置为 1,标志该缓冲块需要同步。
需要注意的是,b_dirt 字段设置为 1,b_uptodate 字段没必要设置为 0,继续保持 1 即可。但 b_dirt 为 1 限定了该缓冲块在同步之前不能被其他进程使用。
缓冲块的同步有两种方法:一种是 update 进程定期同步;另一种是因缓冲区使用达到极限,操作系统强行同步。
这两种同步方法最终都会调用 sys_sync
函数:
1 | // fs/buffer.c --------------------------------- |
i_update、i_dirt 和 s_dirt
i_update 和 i_dirt
i_update 和 i_dirt 是 i 节点数据结构 struct m_inode 中的字段。前者 i_update 并没有实际使用,因为 i 节点在块设备上都是以逻辑块的形式存储的,也以块的形式载入缓冲区的,而且缓冲块已经有 b_uptodate 字段来确保缓冲块的有效性,所以 i_update 并不需要用到;而对于后者 i_dirt,当 i 节点相关信息被改变(如文件大小变化)后就要置 1,表示需要将数据同步到块设备上。但实际上,i 节点信息的同步跟缓冲块的同步不一样,i 节点的同步是要先将 i 节点信息同步到缓冲块,最后再将缓冲块同步到块设备上。我们看一下 write_inode
函数就比较清楚了:
1 | // fs/buffer.c --------------------------------- |
s_dirt
s_dirt 超级块数据结构 struct super_block 中的字段。struct super_block 中没有类似 s_update 的字段,理由跟 i_update 一样。而对于 s_dirt,作用跟 i_dirt,但它自超级块被读进 super_block[8] 后就一直保持值 0。
count、lock 和 wait 的作用
b_count、b_lock 和 *b_wait
b_count、b_lock 和 b_wait 都是缓冲块数据结构 struct buffer_head 中的字段:
1 | // include/linux/fs.h -------------------------- |
b_count
b_count
字段的数值表示了共享某个缓冲块的进程数。当 b_count 字段为 0 时,表示所有进程跟该缓冲块的关系都已经解除,该缓冲块可以当做新缓冲块用。跟 b_count 字段相关部分代码摘写如下:
1 | // fs/buffer.c --------------------------------- |
b_lock 和 *b_wait
内核为进程申请到缓冲块,尤其是申请到 b_count 为 0 的缓冲块时,因为同步的原因,有可能这个缓冲块正在与硬盘交互数据,为此 buffer_head 数据结构设置了 b_lock 字段。如果该字段被设置为 1,就说明缓冲块正在和硬盘交互数据,内核就会拦截进程对该缓冲块的操作,等到与硬盘的交互结束,再把该字段置 0,以此解除对进程的拦截。
如果为进程申请到的缓冲块中 b_lock 字段被设置为 1,即便已经申请到了,该进程也需要挂起,直到该缓冲块被解锁后,才能访问。在缓冲块被加锁的过程中,无论有多少个进程申请到了该缓冲块,都不能立即操作该缓冲块,都要挂起,并切换到其他进程去运行。这就需要记录有哪些进程因为等待这个缓冲块的解锁而被挂起了。由于使用了进程等待队列
,所以一个字段就可以解决这个记录问题。这个字段就是 *b_wait。
注:进程等待队列
请参考《Linux 内核设计的艺术》(第 2 版)第 7.6 节“实例 1:关于缓冲块的进程等待队列”。这一小节写的特别好!
b_lock 和 *b_wait 字段往往一起使用。相关代码如下:
1 | // kernel/blk_drv/ll_rw_blk.c ------------------ |
i_count、i_lock、*i_wait、s_lock、*s_wait 和 f_count
这几个字段跟 b_count、b_lock 和 *b_wait 是类似的,定义如下:
1 | // include/linux/fs.h -------------------------- |
题外:i_nlinks
i_nlinks 表示的是 i 节点的硬链接数:
1 | // include/linux/fs.h -------------------------- |
当该字段为 0 时,表示已经没有“文件目录项”指向该 i 节点,需要释放该 i 节点和该 i 节点所指向的逻辑块。具体请参考之前博文 Linux 内核学习笔记:文件操作“删除文件”部分。
题外:mem_map[PAGING_PAGES]
mem_map[PAGING_PAGES] 数组
记录了主内存区每个物理页面的引用计数。当某物理页面对应的 mem_map 项为 0,则表示该页面空闲。