内核知识收集
一. 概述
在块设备上的操作,涉及内核中的多个组成部分。下图
系统调用read()读取磁盘上的文件。内核响应步骤如下:
a. 系统调用read()传递文件描述符/文件偏移量/读取长度等参数, 触发相应的VFS函数。
b. VFS确定请求的数据是否已经在缓冲区中, 若数据不在缓冲区中, 确定如何执行读块设备操作。
内核将大多数最近从块设备读出或写入的数据保存在RAM,所以有时候没必要访问磁盘上的数据。
c. 内核通过映射层(Mapping Layer)确定数据在物理设备上的位置。由对应文件所在的文件系统(ext4/reiserfs/xfs等)来确定。
1>内核取得文件所在文件系统的块大小,计算所请求数据块的长度,确证数据所在块号。
2>映射层调用具体文件系统的函数,访问磁盘节点,根据逻辑块号确定所请求数据在磁盘上的位置。
3>磁盘也分块,内核须确定数据块(通用块)在磁盘上的块号(磁盘块)。
d. 内核通过通用块层(Generic Block Layer)在块设备上执行读操作,启动I/O操作, 传输请求的数据。
1>一个i/o操作只针对一组连续的块,请求的数据不必位于相邻的块中。
2>通用块层为所有的块设备提供一个抽象视图,隐藏硬件块设备的差异,可用通用数据结构描述。
e. 内核I/O调度层(I/O Scheduler Layer)根据内核的调度策略, 对等待处理的I/O等待队列排序。
把物理介质上相邻的数据请求聚集在一起。
f. 块设备驱动(Block Device Driver)通过向磁盘控制器发送相应的命令,执行真正的数据传输
这里讲述上面步骤f块设备驱动层的工作过程. 理解块设备驱动、如何处理I/O请求及与硬件进行数据交互过程
二. 块设备驱动相关数据结构
块设备提供大容量存储, 如硬盘。通常情况下, 硬盘执行I/O操作时, 需要移动器件(如磁头)
而且缓慢。因为I/O操作的代价很高,内核试图通过将数据缓存在内存中,以达到最大性能。而真正
的I/O操作是由设备驱动完成的,内核为设备驱动提供了不同的钩子(hooks)来注册它们的处理程
序(handlers)。
块设备层屏蔽底层硬件并提供统一的访问设备API。如ext3文件系统不需要关注底层的具体硬件是什么。
块设备的另外一个特性是,当有多个I/O请求到达时,请求之间的顺序会影响性能。对于硬盘来说,所有的I/O请求,
磁头向一个方向移动可以达到最大性能。内核在调用块设备驱动处理I/O请求前,会先搜集请求并进行排序。
通过合并对连续扇区上的操作,也可以提高性能。
用户空间通过
块设备文件来访问块设备。也就是,如/dev/sda1。块设备文件有主设备号和次设备号,设备文件存储这两个值。主设备号用来匹配设备驱动,次设备号用来匹配块设备内的分区;一个主设备号只对应一个设备驱动。
内核通过主、次设备号来匹配块设备。块设备文件可以在文件系统中的任何一个位置,也可以存在多个
块设备文件。通常情况下,块设备文件都存放在/dev目录下,当然也可以将设备文件放在/usr或其它目录下,只是
使用时要修改应用程序相应的参数。
1. 一些概念
块设备相关重要概念:扇区(sector)、块(Block)、段(Segment)。互相关系如下图:
扇区是硬件设备
传输数据的基本单位,多数块设备扇区大小为512字节。块是虚拟文件系统的
基本传输单元, 块大小与具体的硬件设备无关。段是一个内存页面或一个页面的部分,它包含磁盘
上相邻扇区的数据。段的引入是为了利用“分散/聚合”DMA操作,一个“分散/聚合”DMA操作可能会涉及多个段。
块设备驱动相关的主要数据结构包括block_device, gendisk, hd_struct, buffer_head, bio,
bio_vec, request, request_queue。
2. block_device
一个块设备驱动可以处理多个块设备。例如, SCSI设备驱动可以处理多个SCSI磁盘,每个SCSI
磁盘就是一个块设备。进而,每个磁盘可以有多个分区,如/dev/sda1, /dev/sda2...,每个分区都可
看成逻辑块设备。确切地说,块设备驱动必须在块设备之上进行的VFS系统调用。
每个块设备由数据结构block_device表示:
3. buffer_head
4. gendisk
内核使用gendisk结构来表示一个独立的磁盘设备。实际上内核还使用gendisk表示分区, 驱动程序的作者不需要了解这些。在gendisk结构中的许多成员必须由驱动程序进行初始化。
具体数据结构不罗列
5. hd_struct
物理磁盘通常被分成多个逻辑分区。每个块设备文件可以表示一个整个物理磁盘或者其中的一
个分区。如/dev/sda、/dev/sda1、/dev/sda2等。若一个物理磁盘有多个分区,则磁盘的布局保存在
hd_struct数据结构数组中,数组的地址由gendisk结构体中的part成员保存。
具体数据结构不罗列
6. block_device_operations
结构体中包括了对块设备操作的几个方法。
7. bio
8. request_queue
请求队列由request_queue数据结构表示
9. request
对每个块设备的等待请求表示为请求描述符(request discriptor),保存在request数据结构中。
10. 主要数据结构之间的关系
三. 块设备驱动注册和初始化
与字符设备一样,块设备必须使用一系列的注册函数,使内核知道设备的存在。
1. 自定义驱动数据结构
设备驱动需要为块设备一个自定义的数据结构,假设我们自定义的数据类型为foo_dev_t,
该结构体里包含了驱动硬件设备所需要的数据。
-
struct foo_dev_t {
-
int size; /*以扇区为单位,设备的大小*/
-
u8* data; /*数据数组*/
-
short users; /*用户数目*/
-
short media_change; /*介质改变标志*/
-
spinlock_t lock; /*用于互斥*/
-
struct request_queue *queue; /*设备请求队列*/
-
struct gendisk *gd; /*gendisk结构*/
-
struct timer_list timer; /*用于模拟介质改变*/
-
} foo;
对于每个设备来说,foo_dev_t类型的数据结构保存用户数目、数据数组、介质改变标志等(这
里只是举例,并不是所有块设备驱动都需要这样的成员变量)。同时自定义的数据结构中还包含了
一些块I/O系统所需要的数据。lock旋转锁用来保护foo_dev_t数据结构,gd指向gendisk数据结构。
2. 注册块设备驱动程序
对大多数块设备驱动程序来说,第一步是向内核注册驱动,而且设备驱动必须保留一个块设备
号供自己使用。执行该任务的函数是register_blkdev().
-
int register_blkdev(unsigned int major, const char* name);
参数是该块设备使用的主设备号及其名字,名字在/proc/devices文件中显示。如果传递的主设
备号是0,内核将分配一个新的主设备号,并将该设备号返回给调用者。注意,这里没有办法分配
次设备号,并且此时主设备号和自定义数据结构之间没有建立联系。
如果调用register_blkdev()返回负值, 则表示出现了一个错误。
与其对应的注销块设备驱动程序函数是:
-
int unregister_blkdev(unsigned int major, const char* name);
这里的参数要求与register_blkdev()的参数相匹配,否则函数将返回-EINVAL,并且不做任何注销工作。
3. 初始化自定义驱动数据结构
foo数据结构中的成员变量都必须被正确初始化。初始化和内核块I/O系统相关的成员变量,驱动可以执行以下类似语句:
-
spin_lock_init(&foo.lock);
-
foo.gd = alloc_disk(16);
-
if(!foo.gd)
-
goto error_no_gendisk;
上面语句初始化自旋锁,然后分配gendisk数据结构, 其中gendisk数据结构是内核块设备I/O系统中关键组成部分。
函数allock_disk ()分配gendisk数据结构,
参数16是成员变量hd_struct数组的元素个数,表示驱动可以支持磁盘有15个分区(分区0不使用),
就是我们常见的/dev/sdb1等。
4. 初始化gendisk数据结构
初始化gendisk数据结构,例如:
-
foo.gd->private_date = &foo;
-
foo.gd->major = FOO_MAJOR;
-
foo.gd->first_minor = 0;
-
set_capacity(foo.gd, foo_disk_capacity_in_sectors);
-
strcpy(foo.gd->disk_name, "foo");
-
foo.gd->fops = &foo_ops;
foo数据结构的地址保存在private_data中, 驱动中的底层函数可以快速找到驱动数据结构,提
高同时处理多个磁盘的效率。
5. 初始化块设备操作方法
gendisk数据结构中成员变量fops保存块设备操作方法。
6. 分配和初始化请求队列
一个块设备请求队列可以这样描述: 包含块设备I/O请求的序列。请求队列跟踪未完成的块设备
I/O请求。请求队列保存了描述设备所能处理的请求参数: 最大尺寸、在同一个请求中所能包含的独
立段的数目、硬件扇区大小、对齐要求等等。
-
foo.gd->rq = blk_init_queue(foo_strategy, &foo.lock);
-
if(!foo.gd->rq) goto error_no_request_queue;
-
blk_queue_hardsect_size(foo.gd->rd, foo_hard_sector_size);
-
blk_queue_max_sectors(foo.gd->rd, foo_max_sectors);
-
blk_queue_max_hw_segments(foo.gd->rd, foo_max_hw_segments);
-
blk_queue_max_phy_segments(foo.gd->rd, foo_max_phy_segments);
一个请求队列就是一个动态的数据结构,该结构必须由块设备的I/O子系统创建。创建和初始化
请求队列函数是:
-
request_queue_t *blk_init_queue(request_fn_proc *request, spin_lock_t *lock);
该函数的参数是处理这个队列的函数指针和控制访问队列权限的自旋锁。由于该函数负责分配
内存,因此可能会失败;所以在使用队列前一定要检查返回值。
在模块卸载的时候,需要把请求队列返回给系统,执行函数为blk_cleanup_queue():
-
void blk_cleanup_queue(request_queue_t*);
调用该函数后,驱动程序将不会再得到这个队列的请求, 也不能引用这个队列了。
7. 安装中断处理程序
安装中断处理程序如下:
-
request_irq(foo_irq, foo_interrupt, SA_INTERRUPT|SA_SHIRQ, "foo", NULL);
函数foo_interrupt()是该块设备的中断处理程序。
8. 注册磁盘
分配一个gendisk结构并不能使磁盘对系统可用。为了使磁盘可用,必须初始化gendisk结构并调用add_disk():
-
void add_disk(struct gendisk *gd);
调用了add_disk,磁盘设备就被“激活”,并随时会调用它提供的方法。
实际上第一次对这些方法的调用可能在add_disk返回前就发生了,这是因为,内核可能会读取前面
几个块的数据以获取分区表。因此在驱动程序完全被初始化并且能否响应对磁盘的请求前,请不要
调用add_disk()。
至此块设备驱动注册和初始化完成,之后处理请求队列例程(strategy routine)和中断处理程
序来完成I/O调度层派发过来的I/O请求。
四. 请求队列处理
请求队列处理例程是块设备驱动的一个函数或者一组函数,用来与硬件设备进行交互。处理例
程(strategy routine)通过request_fn方法来触发, I/O调度层将请求队列的地址q传递给它。
请求队列处理例程通常是在空的请求队列上插入新的请求后启动。一旦处理例程被激活,块设备
驱动程序就会处理请求队列中的请求,直到队列为空。
简单的处理例程可以使用以下方式:对于请求队列中的每个元素,从队列中摘除,然后与硬件
进行交互完成请求,直到数据传送完成;然后处理下一个请求。
处理方式虽然简单,但效率非常低下。即使可以通过DMA方式传送数据,处理例程仍需要阻塞自己
直到数据传送完成。这意味着处理例程需要有专门的内核线程来完成。
上面的方式不能支持新的磁盘控制器,它可以同时处理多个I/O请求。
大多数块设备驱动采用以下策略:
a. 对于请求队列中的第一个请求,处理例程开始数据传输;然后设置块设备控制器,这样当数据传送
结束时,控制器可以发出中断。然后处理例程结束。
b. 当磁盘控制器发出中断时,中断处理程序触发处理例程(通常是,激活一个工作队列)。处理例程可
以为当前请求启动另外一个数据传输,或者当请求数据传输完成时,将该请求从队列中摘除,然后
处理下一个请求。
请求可以由多个bio组成,进而可以由多个段组成。一般来说,块设备驱动有两种方式使用DMA:
a. 驱动为bio中的每个段(segment)建立一次DMA传输。
b. 驱动为所有的bio建立“分散/聚合”DMA传输。
块设备请求队列处理例程的设计依赖与块设备控制器特性有关,每个物理块设备与其他设备有所不
同(如软驱、硬盘以及我们的SSD)。
前面的示例代码中,使用的请求队列处理程序是foo_strategy()。其处理过程如下方式:
(1)通过I/O调度器函数elv_next_request()从调度队列中获取一个请求。若
调度队列为空,处理例程返回:
-
req = elv_next_request(q);
-
if(!req)
-
return;
(2)使用宏blk_fs_request检查请求是否设置了REQ_CMD标志,也就是请求是否包含了正常的读或写操作。
-
if(!blk_fs_request(rq))
-
goto handle_special_request;
(3)若块设备控制器支持“分散/聚合”DMA,就对磁盘控制器进行编程,以对整个请求进行数据
传输。函数blk_rq_map_sg()返回可以立即进行传输的“分散/聚合”链表。
(4)否则,驱动必须以段为单位进行数据传输。这种情况下,处理例程执行宏rq_for_each_bio()
和宏bio_for_each_segment()来遍历bio链表和bio中的所有段。
-
rq_for_each_bio(bio, rq)
-
bio_for_each_segment(bvec, bio, i) {
-
/*transfer the i-th segment bvec*/
-
local_irq_save(flags);
-
addr = kmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
-
foo_start_dma_transfer(addr+bvec->bv_offset, bvec->bv_len);
-
kunmap_atomic(bvec->bv_page, KM_BIO_SRC_IRQ);
-
local_irq_restore(flags);
-
}
若数据在高端内存中传输,则函数kmap_atomic()和kunmap_atomic()是必须的。函数foo_strart_dma_transfer()对
磁盘控制器编程,以进行DMA数据传输。
(5)返回。
五. 中断处理
一个“中断”仅仅是一个信号,当硬件需要获得处理器对它的关注时,就可以发送这个信号。
Linux处理中断的方式很大程度上与它在用户空间处理信号是一样的。在大多数情况下,一个驱动程
序只需要为它自己设备的中断注册一个处理例程,并且在中断到达时进行正确的处理。
中断信号线是非常珍贵且有限的资源。内核维护了一个中断信号线的注册表。模块在使用中断
前要先请求一个中断通道(或者中断请求IRQ),然后在使用后释放该通道。在很多场合下,模块也希望
可以和其他的驱动程序共享中断信号线。函数request_irq()、free_irq()实现了该接口。
-
int request_irq(unsigned int irq,
-
irq_handler_t handler,
-
unsigned long flags,
-
const char *dev_name,
-
void *dev_id);
-
-
void free_irq(unsigned int irq, void *dev_id);
通常,从request_irq()返回给请求函数的值为0时,表示申请成功,为负值时表示错误码。
函数返回-EBUSY表示已经有另外一个驱动程序占用了你要请求的中断信号线。这些函数的参数如
下:
unsigned int irq 要申请的中断号
irq_handler_t *handler 要安装的中断处理函数指针
unsigned long flags 与中断管理有关的位掩码选项
const char *dev_name 传递给request_irq的字符串, 用来在/proc/interrupts中显示中断的拥有者
void *dev_id 用于指向共享的中断信号线。它是唯一的标识符,在中断信号线空闲时可以使用它,驱动程
序也可以使用它指向驱动程序自己的私有数据区(用来识别哪个设备产生中断)。在没有强制使用
共享方式时,dev_id可以被设置成NULL,总之用它来指向设备的数据结构是一个比较好的思路。
调用request_irq()的正确位置应该是在设备第一次打开、硬件被告知产生中断之前。调用
free_irq()的位置是最后一次关闭设备、硬件被告知不再用中断处理器之后。
当DMA数据传输结束时发出中断,块设备驱动的中断处理程序就会被激活。它应该首先检查请
求的所有数据传输完成;完成的话,则中断处理程序触发请求队列处理例程处理派遣队列中的下一
个请求。否则,中断处理程序更新请求数据结构中的相关成员变量,然后触发请求队列处理例程处
理未完成的数据。中断处理程序示例代码如下:
-
irqreturn_t foo_intterupt(int irq, void* dev_id, struct pt_regs *regs)
-
{
-
struct foo_dev_t *p = (struct foo_dev_t*) dev_id;
-
struct request_queue *rq = p->gd->rq;
-
... ...
-
if(!end_that_request_first(rq, uptodate, nr_sectors)) {
-
blkdev_dequeue_request(rq);
-
end_that_request_last(rq);
-
}
-
rq->request_fn(rq);
-
... ...
-
return IRQ_HANDLED
-
}
函数end_that_request_first()和end_that_request_last()共同承担结束一个请求的任务。
当设备完成在一个I/O请求的部分或者全部的扇区时,它必须调用下面的函数通知块设备子系
统:
int end_that_request_first(struct request *req, int success, int count);
该函数告诉块设备代码:驱动程序从前一次结束的地方开始,完成了规定数目的扇区的传输。
如果I/O成功,则传递1表示成功;否则传递0。请注意必须报告从第一个扇区到最后一个扇区的完成
情况;如果驱动程序和设备因不明原因颠倒完成请求的顺序,必须保存颠倒完成的状态直到设计的
扇区传输完毕。
end_that_request_first()的返回值表明该请求中的所有扇区是否被传输。如果返回值是0,
表示所有的扇区都被传输了,该请求执行完毕。此时必须使用blkdev_dequeue_request()函数删
除请求(如果还没有做这步),并把其传递给:
void end_that_request_last(struct request *req);
end_that_request_last()通知任何等待已经完成请求的对象,并重复利用该request结构;调
用该函数时,必须拥有队列锁。该函数更新一些磁盘使用统计,从I/O调度器rq->elevator队列中摘
除该完成的请求;然后唤醒请求描述符上的waiting等待队列;最后释放请求数据结构。
六. 不使用请求队列
基于类似于磁盘的块设备,针对机械式、需要旋转磁头的磁盘,内核会优化队列中的请求顺序,
这些包括请求,甚至可能要停止这些请求,以期望某些其他请求的到来。在处理实际的旋转磁盘驱
动器的时候,这能帮助系统获得最好的性能。
许多面向块数据的设备, 如NAND flash、RAM盘等,它们都是完全的随机访问设备,并不能
从高级请求队列逻辑中获益。对于这些设备,最好还是从块设备层中直接接受请求,而不要去打乱
请求队列的好。
在这些情况下,块设备层支持“无队列”模式的操作。为了能使用该模式,驱动程序必须提供
一个“构造请求”的函数,而不是一个请求处理函数。下面是构造请求函数的原型:
-
typedef int (make_request_fn) (request_queue_t *q, struct bio *bio);
虽然从不拥有一个请求,但还是提供了一个请求队列。make_request()函数的主要
参数bio结构,它表示了要被传输的一个或者多个缓冲区。函数make_request()能够完成下面的
事情:直接进行传输,或者把请求重定向给其他设备。
直接进行传输是通过bio进行传输。由于没有request结构进行操作,因此函数应该能够调用
bio_endio(),告诉bio结构的创建者请求的完成情况:
-
void bio_endio(struct bio *bio, unsigned int bytes, int error);
这里bytes是要传输的字节数。它可以比bio结构中表示的字节数小;此时可以通知“部分完成”,
然后更新bio结构中的“当前缓冲区”指针。当设备需要再次传输时,可以再次调用bio_endio(),
如果不能完成这个请求,则给出一个错误。通过给error参数赋予一个非零的数来表示错误;这个值
通常诸如-EIO这样的错误码。无论I/O成功与否,make_request()都返回0。
另外还必须告诉块设备子系统,驱动程序使用定制的make_request()函数。为了做到这点,
必须使用下面的函数分配一个请求队列:
-
request_queue_t* blk_alloc_queue(int flags);
它并未真正的建立一个保存请求的队列。flags参数是一系列分配标志,用来为队列分配内存;
通常正确的值是GFP_KERNEL。
一旦拥有了队列,将它与make_request()函数传递给blk_queue_make_request():
-
void blk_queue_make_request(request_queue_t *queue, make_request_fn *func);