Linux中设备分为字符设备,块设备,网络设备三大类。对于字符设备与块设备区别在于能否随机访问数据,对于字符设备键盘来说,键盘驱动程序会按照和用户输入完全相同的顺序读取字符,并且得到一个字符流。但是对于块设备硬盘来说,硬盘驱动程序可能需要读取磁盘上任意块的内容,这些块不一定是连续的,所以说可以被随机访问。
所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务的子系统。不仅仅是因为块设备的复杂性远远高于字符设备,更重要的原因是块设备对执行性能的要求很高﹔对硬盘每多一份利用都
会对整个系统的性能带来提升。另外,我们将会看到,块设备的复杂性会为这种优化留下很大的施展空间。对块设备和块设备的请求进行管理,在内核中称作块IO层。
块设备
块设备中最小的可寻址单元为扇区,扇区最常见的大小为512字节。块是文件系统的一种抽象–只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区级进行的,但是内核执行的所有磁盘操作都是按照块进行的。
内核(对有扇区的硬件设备)要求块大小是2的整数倍,而且不能超过一个页的长度。所以,对块大小的最终要求是,必须是扇区大小的2的整数倍,并且要小于页面大小。所以通常块大小是512KB、1KB或4KB。
块设备基本操作单元
缓冲区和缓冲区头
高速缓存这一层分为页缓存(page cache)和缓冲区(buffer cache),buffer cache是建立在page cache之上的。page cache是面向文件的抽象,而buffer cache则是面向块设备的抽象。
当一个块被调入内存时(也就是说,在读入后或等待写出时),它要存储在一个缓冲区中。每个缓冲区与一个块对应,它相当于是磁盘块在内存中的表示。前面提到过,块包含一个或多个扇区,但大小不能超过一个页面,所以一个页可以容纳一个或多个内存中的块。由于内核处理数据时需要一些相关的控制信息(比如块属于哪一个块设备,块对应于哪个缓冲区等),所以每一个缓冲区都有一个对应的描述符。该描述符用buffer_head结构体表示,称作缓冲区头,在文件<linux/buffer_head.h>(基于内核4.1.14)中定义,它包含了内核操作缓冲区所需要的全部信息。
随着时代的发展,访问单独磁盘块的场景越来越少,页缓存(page cache)慢慢占了主要地位,在Linux kernel 2.4以后,缓冲区(buffer cache)已经不单独实现了,而是建立在页缓存(page cache)的基础上。
1 | struct buffer_head { |
b_count域表示缓冲区的使用记数,可通过两个定义在文件<linux/buffer_head.h>中的内联
函数对此域进行增减。
1 | static inline void get_bh(struct buffer_head *bh) |
在操作缓冲区头之前,应该先使用get_bh()函数增加缓冲区头的引用计数,确保该缓冲区头
不会再被分配出去;当完成对缓冲区头的操作之后,还必须使用put_bh()函数减少引用计数。
与缓冲区对应的磁盘物理块由b__blocknr-th域索引,该值是b__bdev域指明的块设备中的逻
辑块号。
与缓冲区对应的内存物理页由b_ page 域表示,另外,b_data域直接指向相应的块(它位于
b_ page域所指明的页面中的某个位置上),块的大小由b_size 域表示,所以块在内存中的起始位
置在b_data处,结束位置在(b_data + b_size)处。
在2.6内核以前,将缓冲区作为I/O操作单元带来了两个弊端。
- 缓冲区头是一个很大且不易控制的数据结构体(现在是缩减过的了),而且缓冲区头对数据的操作既不方便也不清晰。对内核来说,它更倾向于操作页面结构,因为页面操作起来更为简便,同时效率也高。
- 缓冲区头仅能描述单个缓冲区,当作为所有I/O的容器使用时,缓冲区头会促使内核把对大块数据的I/O操作(比如写操作)分解为对多个buffer_head结构体进行操作。这样做必然会造成不必要的负担和空间浪费。所以2.5开发版内核的主要目标就是为块I/O操作引入一种新型、灵活并且轻量级的容器,bio结构体。
bio结构体
目前内核中块I/O操作的基本容器由bio结构体表示,它定义在文件<linux/blk_types.h>中。该结构体代表了正在现场的(活动的)以片断(segment)链表形式组织的块I/O操作,对缓冲区进行了封装,使用一个结构体数组存储多个缓冲区片段。结构体数组的一个元素是一小块连续的内存缓冲区,整个结构体数组表示了一个完整的缓冲区。这样的话,就不需要保证单个缓冲区一定要连续。 所以通过用片段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。
1 | struct bio { |
使用bio结构体的目的主要是代表正在现场执行的I/O操作,所以该结构体中的主要城都是用来管理相关信息的,其中最重要的几个域是bi_io_vecs、bi_vcnt。 下图显示了bio结构体及其他结构体之间的关系。
I/O向量
bi_io_vec指针指向一个 bio_vec 结构体数组,该结构体链表包含了一个特定IO操作所需要使用到的所有片段。每个bio_vec结构都是一个形式为<page,offset,len>的向量,它描述的是一个特定的片段:片段所在的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。整个bio_io_vec结构体数组表示了一个完整的缓冲区。bio_vec结构定义在<linux/bvec.h> 文件中:
1 | struct bio_vec { |
总而言之,每一个块I0请求都通过一个bio结构体表示。每个请求包含一个或多个块,这些块存储在bio_vec 结构体数组中。这些结构体描述了每个片段在物理页中的实际位置,并且像向量一样被组织在一起。I/O 操作的第一个片段由b_io_vec结构体所指,其他的片段在其后依次放置,共有bi_vcnt个片段。当块I/O层开始执行请求、需要使用各个片段时,bi jidx 域会不断更新,从而总指向当前片段。
bi_cnt 域记录bio结构体的使用计数,如果该域值减为0,就应该撤销该bio结构体,并释放它占用的内存。通过下面两个函数管理使用计数。
1 | static inline void bio_get(struct bio *bio) |
前者增加使用计数,后者减少使用计数(如果计数减到0,则撤销bio结构体)。在操作正在活动的bio结构体时,-定要首先增加它的使用计数,以免在操作过程中该bio结构体被释放;相反在操作完毕后,要减少使用计数。
请求队列
块设备将它们挂起的块I/O请求保存在请求队列中,该队列由reques_queue结构体表示,定义在文件<linux/blkdev.h>中,包含一个双向请求链表以及相关控制信息。通过内核中像文件系统这样高层的代码将请求加入到队列中。请求队列只要不为空,队列对应的块设备驱动程序就会从队列头获取请求,然后将其送入对应的块设备上去。请求队列表中的每一项都是一个单独的请求,由reques结构体表示。
队列中的请求由结构体requcst 表示,它定义在文件<linux/blkdev.h>中。因为一个请求可能要操作多个连续的磁盘块,所以每个请求可以由多个bio结构体组成。注意,虽然在磁盘上的块必须连续,但是在内存中这些块并不一定要连续,每一个bio结构体都可以描述多个片段(回忆一下,片段是内存中连续的小区域),而每个请求也可以包含多个bio结构体。
I/O调度程序
I/O调度程序虚拟块设备给多个磁盘请求,以便降低磁盘寻址时间,确保磁盘性能的最优化。