块设备读写流程

 

 

块设备与字符设备的区别

1、  从字面上理解,块设备和字符设备最大的区别在于读写数据的基本单元不同。块设备读写数据的基本单元为块,例如磁盘通常为一个 sector ,而字符设备的基本单元为字节。所以 Linux 中块设备驱动往往为磁盘设备的驱动,但是由于磁盘设备的 IO 性能与 CPU 相比很差,因此,块设备的数据流往往会引入文件系统的 Cache 机制。

2、 从实现角度来看, Linux 为块设备和字符设备提供了两套机制。字符设备实现的比较简单,内核例程和用户态 API 一一对应,用户层的 Read 函数直接对应了内核中的 Read 例程,这种映射关系由字符设备的 file_operations 维护。块设备接口相对于字符设备复杂, read write API 没有直接到块设备层,而是直接到文件系统层,然后再由文件系统层发起读写请求。

 

块设备读写流程

 

在学习块设备原理的时候,我最关系块设备的数据流程,从应用程序调用 Read 或者 Write 开始,数据在内核中到底是如何流通、处理的呢?然后又如何抵达具体的物理设备的呢?下面对一个带 Cache 功能的块设备数据流程进行分析。

 

1、  用户态程序通过 open() 打开指定的块设备,通过 systemcall 机制陷入内核,执行 blkdev_open() 函数,该函数注册到文件系统方法( file_operations )中的 open 上。在 blkdev_open 函数中调用 bd_acquire() 函数, bd_acquire 函数完成文件系统 inode 到块设备 bdev 的转换,具体的转换方法通过 hash 查找实现。得到具体块设备的 bdev 之后,调用 do_open() 函数完成设备打开的操作。在 do_open 函数中会调用到块设备驱动注册的 open 方法,具体调用如下: gendisk->fops->open(bdev->bd_inode, file)

2、  用户程序通过 read write 函数对设备进行读写,文件系统会调用相应的方法,通常会调用如下两个函数: generic_file_read blkdev_file_write 。在读写过程中采用了多种策略,首先分析读过程。

3、  用户态调用了 read 函数,内核执行 generic_file_read ,如果不是 direct io 方式,那么直接调用 do_generic_file_read->do_generic_mapping_read() 函数,在 do_generic_mapping_read (函数位于 filemap.c )函数中,首先查找数据是否命中 Cache ,如果命中,那么直接将数据返回给用户态;否则通过 address_space->a_ops->readpage 函数发起一个真实的读请求。在 readpage 函数中,构造一个 buffer_head ,设置 bh 回调函数 end_buffer_async_read ,然后调用 submit_bh 发起请求。在 submit_bh 函数中,根据 buffer_head 构造 bio ,设置 bio 的回调函数 end_bio_bh_io_sync ,最后通过 submit_bio bio 请求发送给指定的快设备。

4、  如果用户态调用了一个 write 函数,内核执行 blkdev_file_write 函数,如果不是 direct io 操作方式,那么执行 buffered write 操作过程,直接调用 generic_file_buffered_write 函数。 Buffered write 操作方法会将数据直接写入 Cache ,并进行 Cache 的替换操作,在替换操作过程中需要对实际的快设备进行操作, address_space->a_ops 提供了块设备操作的方法。当数据被写入到 Cache 之后, write 函数就可以返回了,后继异步写入的任务绝大部分交给了 pdflush daemon (有一部分在替换的时候做了)

5、  数据流操作到这一步,我们已经很清楚用户的数据是如何到内核了。与用户最接近的方法是 file_operations ,每种设备类型都定义了这一方法(由于 Linux 将所有设备都看成是文件,所以为每类设备都定义了文件操作方法,例如,字符设备的操作方法为 def_chr_fops ,块设备为 def_blk_fops ,网络设备为 bad_sock_fops )。每种设备类型底层操作方法是不一样的,但是通过 file_operations 方法将设备类型的差异化屏蔽了,这就是 Linux 能够将所有设备都理解为文件的缘由。到这里,又提出一个问题:既然这样,那设备的差异化又该如何体现呢?在文件系统层定义了文件系统访问设备的方法,该方法就是 address_space_operations ,文件系统通过该方法可以访问具体的设备。对于字符设备而言,没有实现 address_space_operations 方法,也没有必要,因为字符设备的接口与文件系统的接口是一样的,在字符设备 open 操作的过程中,将 inode 所指向的 file_operations 替换成 cdev 所指向的 file_operations 就可以了。这样用户层读写字符设备可以直接调用 cdev file_operations 方法了。

6、  截至到步骤( 4 ),读操作在没有命中 Cache 的情况下通过 address_space_operations 方法中的 readpage 函数发起块设备读请求;写操作在替换 Cache 或者 Pdflush 唤醒时发起块设备请求。发起块设备请求的过程都一样,首先根据需求构建 bio 结构, bio 结构中包含了读写地址、长度、目的设备、回调函数等信息。构造完 bio 之后,通过简单的 submit_bio 函数将请求转发给具体的块设备。从这里可以看出,块设备接口很简单,接口方法为 submit_bio (更底层函数为 generic_make_request ),数据结构为 struct bio

7、  submit_bio 函数通过 generic_make_request 转发 bio generic_make_request 是一个循环,其通过每个块设备下注册的 q->make_request_fn 函数与块设备进行交互。如果访问的块设备是一个有 queue 的设备,那么会将系统的 __make_request 函数注册到 q->make_request_fn 中;否则块设备会注册一个私有的方法。在私有的方法中,由于不存在 queue 队列,所以不会处理具体的请求,而是通过修改 bio 中的方法实现 bio 的转发,在私有 make_request 方法中,往往会返回 1 ,告诉 generic_make_request 继续转发比 bio Generic_make_request 的执行上下文可能有两种,一种是用户上下文,另一种为 pdflush 所在的内核线程上下文。

8、  通过 generic_make_request 的不断转发,最后请求一定会到一个存在 queue 队列的块设备上,假设最终的那个块设备是某个 scsi disk /dev/sda )。 generic_make_request 将请求转发给 sda 时,调用 __make_request ,该函数是 Linux 提供的块设备请求处理函数。在该函数中实现了极其重要的操作,通常所说的 IO Schedule 就在该函数中实现。在该函数中试图将转发过来的 bio merge 到一个已经存在的 request 中,如果可以合并,那么将新的 bio 请求挂载到一个已经存在 request 中。如果不能合并,那么分配一个新的 request ,然后将 bio 添加到其中。这一切搞定之后,说明通过 generic_make_request 转发的 bio 已经抵达了内核的一个站点—— request ,找到了一个临时归宿。此时,还没有真正启动物理设备的操作。在 __make_request 退出之前,会判断一个 bio 中的 sync 标记,如果该标记有效,说明请求的 bio 是一个是实时性很强的操作,不能在内核中停留,因此调用了 __generic_unplug_device 函数,该函数将触发下一阶段的操作;如果该标记无效的话,那么该请求就需要在 queue 队列中停留一段时间,等到 queue 队列触发闹钟响了之后,再触发下一阶段的操作。 __make_request 函数返回 0 ,告诉 generic_make_request 无需再转发 bio 了, bio 转发结束。

9、  到目前为止,文件系统( pdflush 或者 address_space_operations )发下来的 bio 已经 merge request queue 中,如果为 sync bio ,那么直接调用 __generic_unplug_device ,否则需要在 unplug timer 的软中断上下文中执行 q->unplug_fn 。后继 request 的处理方法应该和具体的物理设备相关,但是在标准的块设备上如何体现不同物理设备的差异性呢?这种差异性就体现在 queue 队列的方法上,不同的物理设备, queue 队列的方法是不一样的。举例中的 sda 是一个 scsi 设备,在 scsi middle level scsi_request_fn 函数注册到了 queue 队列的 request_fn 方法上。在 q->unplug_fn (具体方法为: generic_unplug_device )函数中会调用 request 队列的具体处理函数 q->request_fn Ok ,到这一步实际上已经将块设备层与 scsi 总线驱动层联系在了一起,他们的接口方法为 request_fn (具体函数为 scsi_request_fn )。

10、 明白了第( 9 )点之后,接下来的过程实际上和具体的 scsi 总线操作相关了。在 scsi_request_fn 函数中会扫描 request 队列,通过 elv_next_request 函数从队列中获取一个 request 。在 elv_next_request 函数中通过 scsi 总线层注册的 q->prep_rq_fn scsi 层注册为 scsi_prep_fn )函数将具体的 request 转换成 scsi 驱动所能认识的 scsi command 。获取一个 request 之后, scsi_request_fn 函数直接调用 scsi_dispatch_cmd 函数将 scsi command 发送给一个具体的 scsi host 。到这一步,有一个问题: scsi command 具体转发给那个 scsi host 呢?秘密就在于 q->queuedata 中,在为 sda 设备分配 queue 队列时,已经指定了 sda 块设备与底层的 scsi 设备( scsi device )之间的关系,他们的关系是通过 request queue 维护的。

11、  scsi_dispatch_cmd 函数中,通过 scsi host 的接口方法 queuecommand scsi command 发送给 scsi host 。通常 scsi host queuecommand 方法会将接收到的 scsi command 挂到自己维护的队列中,然后再启动 DMA 过程将 scsi command 中的数据发送给具体的磁盘。 DMA 完毕之后, DMA 控制器中断 CPU ,告诉 CPU DMA 过程结束,并且在中断上下文中设置 DMA 结束的中断下半部。 DMA 中断服务程序返回之后触发软中断,执行 SCSI 中断下半部。

12、      SCSi 中断下半部中,调用 scsi command 结束的回调函数,这个函数往往为 scsi_done ,在 scsi_done 函数调用 blk_complete_request 函数结束请求 request ,每个请求维护了一个 bio 链,所以在结束请求过程中回调每个请求中的 bio 回调函数,结束具体的 bio Bio 又有文件系统的 buffer head 生成,所以在结束 bio 时,回调 buffer_head 的回调处理函数 bio->bi_end_io (注册为 end_bio_bh_io_sync )。自此,由中断引发的一系列回调过程结束,总结一下回调过程如下: scsi_done->end_request->end_bio->end_bufferhead

13、   回调结束之后,文件系统引发的读写操作过程结束。

 

初始化一个块设备

 

每个块设备都拥有一个操作接口: struct block_device_operations ,该接口定义了 open close ioctl 等函数接口,但没有,也没有必要定义 read write 函数接口。

 

初始化一个块设备的过程如下:

int setup_device(block_dev_t *dev, int minor)

{

       int hardsect_size = HARDSECT_SIZE;

       int chunk_size;

       sector_t dev_size;

      

       /* 分配一个请求队列 */

       dev->queue = blk_alloc_queue(GFP_KERNEL);

       if (dev->queue == NULL) {

              printk(ERROR, "blk_alloc_queue failure!/n");

              return -ENOMEM;

       }

 

       chunk_size = dev->chunk_size >> 9;    //sectors

 

       /* block_make_request 注册到 q->make_request */

       blk_queue_make_request(dev->queue, block_make_request);

      

       blk_queue_max_sectors(dev->queue, chunk_size);

       blk_queue_hardsect_size(dev->queue, hardsect_size);

       blk_queue_merge_bvec(dev->queue, block_mergeable_bvec);

 

       dev->queue->queuedata = dev;

       /* block_unplug 注册到 q->unplug_fn */

       dev->queue->unplug_fn = block_unplug;

      

       /* 分配一个 gendisk */

       dev->gd = alloc_disk(1);

       if (!dev->gd) {

              prink(ERROR, "alloc_disk failure!/n");

              blk_cleanup_queue(dev->queue);

              return -ENOMEM;

       }

      

       dev->gd->major = block_major;               /* 设备的 major */

       dev->gd->first_minor = minor;            /* 设备的 minor */

       dev->gd->fops = &block_ops;                 /* 块设备的操作接口, open close ioctl */

       dev->gd->queue = dev->queue;           /* 块设备的请求队列 */

       dev->gd->private_data = dev;                    

       snprintf(dev->gd->disk_name, 32, dev->block_name);

 

       dev_size = (sector_t) dev->dev_size >> 9;  

       set_capacity(dev->gd, dev_size);         /* 设置块设备的容量 */

 

       add_disk(dev->gd);                                   /* 添加块设备 */

 

       return 0;

}

 

注册 / 释放一个块设备

       通过 register_blkdev 函数将块设备注册到 Linux 系统。示例代码如下:

 

static int blockdev_init(void)

{

      

 

block_major = register_blkdev(block_major, "blockd");

       if (block_major <= 0) {

              printk(ERROR, "blockd: cannot get major %d/n", block_major);

              return -EFAULT;

       }

      

}

 

       通过 unregister_blkdev 函数清除一个块设备。示例代码如下:

static int blockdev_cleanup(void)

{

      

 

unregister_blkdev(block_major, "blockd");

 

}

make_request 函数

make_request 函数是块设备中最重要的接口函数,每个块设备都需要提供 make_request 函数。如果块设备为有请求队列的实际设备,那么 make_request 函数被注册为 __make_request ,该函数由 Linux 系统提供;反之,需要用户提供私有函数。 __make_request 函数功能在前文已述。

 

在用户提供的私有 make_request 函数中往往对 bio 进行过滤处理,这样的驱动在 Linux 中有 md raid0 raid1 raid5 ),过滤处理完毕之后,私有 make_request 函数返回 1 ,告诉 generic_make_request 函数进行 bio 转发。

你可能感兴趣的:(块设备读写流程)