全部博文(2065)
分类: 服务器与存储
2010-07-18 09:02:30
1、 从字面上理解,块设备和字符设备最大的区别在于读写数据的基本单元不同。块设备读写数据的基本单元为块,例如磁盘通常为一个 sector ,而字符设备的基本单元为字节。所以 Linux 中块设备驱动往往为磁盘设备的驱动,但是由于磁盘设备的 IO 性能与 CPU 相比很差,因此,块设备的数据流往往会引入文件系统的 Cache 机制。[CPU处理速度远远比磁盘IO高所以要做一次cache]
2、 从实现角度来看, Linux 为块设备和字符设备提供了两套机制。字符设备实现的比较简单,内核例程和用户态 API 一一对应,用户层的 Read 函数直接对应了内核中的 Read 例程,这种映射关系由字符设备的 file_operations 维护。块设备接口相对于字符设备复杂, read 、 write API 没有直接到块设备层,而是直接到文件系统层,然后再由文件系统层发起读写请求。
笔记:所以当我们编写自己的FS的时候要编写读写磁盘的方法。如何高效地读写磁盘上面的数据是关键。不像字符设备是直接内核例程与用户态API一一映射的。这里面我们需要自己编写方法处理读写操作!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 函数发起一个真实的读请求[WEB架构里面也是这样处理的如果能够在缓存中找到数据就直接返回给用户层!]。在 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 能够将所有设备都理解为文件的缘由[在LINUX世界里面都是FS的。]。到这里,又提出一个问题:既然这样,那设备的差异化又该如何体现呢?在文件系统层定义了文件系统访问设备的方法,该方法就是 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、 回调结束之后,文件系统引发的读写操作过程结束。