How tough life is, how strong you should be!
分类: LINUX
2012-07-24 17:29:36
+---------------------------------------------------+ | 写一个块设备驱动 | +---------------------------------------------------+ | 作者:赵磊 | | email: | +---------------------------------------------------+ | 文章版权归原作者所有。 | | 大家可以自由转载这篇文章,但原版权信息必须保留。 | | 如需用于商业用途,请务必与原作者联系,若因未取得 | | 授权而收起的版权争议,由侵权者自行负责。 | +---------------------------------------------------+ 上一章不但实现了一个最简单的块设备驱动程序,而且可能也成功地吓退了不少准备继续看下去的读者。 因为第一章看起来好像太难了。 不过读者也不要过于埋怨作者,因为大多数情况下第一次都不是什么好的体验...... 对于坚持到这里的读者,这一章中,我们准备了一些简单的内容来犒劳大家。 关于块设备与I/O调度器的关系,我们在上一章中已经有所提及。 I/O调度器可以通过合并请求、重排块设备操作顺序等方式提高块设备访问的顺序。 就好像吃街边的大排档,如果点一个冷门的品种,可能会等更长的时间, 而如果点的恰好与旁边桌子上刚点的相同,那么会很快上来,因为厨师八成索性一起炒了。 然而I/O调度器和块设备的情况却有一些微妙的区别,大概可以类比成人家点了个西红柿鸡蛋汤你接着就点了个西红柿炒蛋。 聪明的厨师一定会先做你的菜,因为随后可以直接往锅里加水煮汤,可怜比你先来的人喝的却是你的刷锅水。 两个菜一锅煮表现在块设备上可以类比成先后访问块设备的同一个位置,这倒是与I/O调度器无关,有空学习linux缓存策略时可以想想这种情况。 一个女孩子换了好多件衣服问我漂不漂亮,而我的评价只要一眼就能拿出来。 对方总觉得衣服要牌子好、面料好、搭配合理、要符合个人的气质、要有文化,而我的标准却简单的多:越薄越好。 所谓臭气相投,我写的块设备驱动程序对I/O调度器的要求大概也是如此。 究其原因倒不是因为块设备驱动程序好色,而是这个所谓块设备中的数据都是在内存中的。 这也意味着我们的“块设备”读写迅速、并且不存在磁盘之类设备通常面临的寻道时间。 因此对这个“块设备”而言,一个复杂的I/O调度器不但发挥不了丝毫作用,反而其本身将白白耗掉不少内存和CPU。 同样的情况还出现在固态硬盘、U盘、记忆棒之类驱动中。将来固态硬盘流行之时,大概就是I/O调度器消亡之日了。 这里我们试图给我们的块设备驱动选择一个最简单的I/O调度器。 目前linux中包含anticipatory、cfq、deadline和noop这4个I/O调度器。 2.6.18之前的linux默认使用anticipatory,而之后的默认使用cfq。 关于这4个调度器的原理和特性我们不打算在这里介绍,原因是相关的介绍满网都是。 但我们还是不能避免在这里提及一下noop调度器,因为我们马上要用到它。 noop顾名思义,是一个基本上不干事的调度器。它基本不对请求进行什么附加的处理,仅仅假惺惺地告诉通用块设备层:我处理完了。 但与吃空饷的公仆不同,noop的存在还是有不少进步意义的。至少我们现在就需要一个不要没事添乱的I/O调度器。 选择一个指定的I/O调度器需要这个函数: int elevator_init(struct request_queue *q, char *name); q是请求队列的指针,name是需要设定的I/O调度器的名称。 如果name为NULL,那么内核会首先尝试选择启动参数"elevator="中指定的调度器, 不成功的话就去选择编译内核时指定的默认调度器, 如果运气太背还是不成功,就去选择"noop"调度器。 不要问我怎么知道的,一切皆在RTFSC(Read the F**ing Source Code --Linus Torvalds)。 对于我们的代码,就是在simp_blkdev_queue = blk_init_queue(simp_blkdev_do_request, NULL)后面加上: elevator_init(simp_blkdev_queue, "noop"); 但问题是在blk_init_queue()函数中系统已经帮我们申请一个了,因此这里我们需要费点周折,把老的那个送回去。 所以我们的代码应该是: simp_blkdev_init()函数开头处: elevator_t *old_e; blk_init_queue()函数之后: old_e = simp_blkdev_queue->elevator; if (IS_ERR_VALUE(elevator_init(simp_blkdev_queue, "noop"))) printk(KERN_WARNING "Switch elevator failed, using default\n"); else elevator_exit(old_e); 为方便阅读并提高本文在google磁盘中的占用率,我们给出修改后的整个simp_blkdev_init()函数: static int __init simp_blkdev_init(void) { int ret; elevator_t *old_e; simp_blkdev_queue = blk_init_queue(simp_blkdev_do_request, NULL); if (!simp_blkdev_queue) { ret = -ENOMEM; goto err_init_queue; } old_e = simp_blkdev_queue->elevator; if (IS_ERR_VALUE(elevator_init(simp_blkdev_queue, "noop"))) printk(KERN_WARNING "Switch elevator failed, using default\n"); else elevator_exit(old_e); simp_blkdev_disk = alloc_disk(1); if (!simp_blkdev_disk) { ret = -ENOMEM; goto err_alloc_disk; } strcpy(simp_blkdev_disk->disk_name, SIMP_BLKDEV_DISKNAME); simp_blkdev_disk->major = SIMP_BLKDEV_DEVICEMAJOR; simp_blkdev_disk->first_minor = 0; simp_blkdev_disk->fops = &simp_blkdev_fops; simp_blkdev_disk->queue = simp_blkdev_queue; set_capacity(simp_blkdev_disk, SIMP_BLKDEV_BYTES>>9); add_disk(simp_blkdev_disk); return 0; err_alloc_disk: blk_cleanup_queue(simp_blkdev_queue); err_init_queue: return ret; } 本章的改动很小,我们现在测试一下这段代码: 首先我们像原先那样编译模块并加载: # make make -C /lib/modules/2.6.18-53.el5/build SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step2 modules make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686' CC [M] /root/test/simp_blkdev/simp_blkdev_step2/simp_blkdev.o Building modules, stage 2. MODPOST CC /root/test/simp_blkdev/simp_blkdev_step2/simp_blkdev.mod.o LD [M] /root/test/simp_blkdev/simp_blkdev_step2/simp_blkdev.ko make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686' # insmod simp_blkdev.ko # 然后看一看咱们的这个块设备现在使用的I/O调度器: # cat /sys/block/simp_blkdev/queue/scheduler [noop] anticipatory deadline cfq # 看样子是成功了。 哦,上一章中忘了看老程序的调度器信息了,这里补上老程序的情况: # cat /sys/block/simp_blkdev/queue/scheduler noop anticipatory deadline [cfq] # OK,我们完成简单的一章,并且用事实说明了作者并没有在开头撒谎。 当然,作者也会力图让接下来的章节同样比小说易读。 <未完,待续> |
第3章
+---------------------------------------------------+ | 写一个块设备驱动 | +---------------------------------------------------+ | 作者:赵磊 | | email: | +---------------------------------------------------+ | 文章版权归原作者所有。 | | 大家可以自由转载这篇文章,但原版权信息必须保留。 | | 如需用于商业用途,请务必与原作者联系,若因未取得 | | 授权而收起的版权争议,由侵权者自行负责。 | +---------------------------------------------------+ 上一章中我们讨论了mm的衣服问题,并成功地为她换上了一件轻如鸿毛、关键是薄如蝉翼的新衣服。 而这一章中,我们打算稍稍再前进一步,也就是:给她脱光。 目的是更加符合我们的审美观、并且能够更加深入地了解该mm(喜欢制服皮草的读者除外)。 付出的代价是这一章的内容要稍稍复杂一些。 虽然noop调度器确实已经很简单了,简单到比我们的驱动程序还简单,在2.6.27中的120行代码量已经充分说明了这个问题。 但显而易见的是,不管它多简单,只要它存在,我们就把它看成累赘。 这里我们不打算再次去反复磨嘴皮子论证不使用I/O调度器能给我们的驱动程序带来什么样的好处、面临的困难、以及如何与国际接轨的诸多事宜, 毕竟现在不是在讨论汽油降价,而我们也不是中石油。我们更关心的是实实在在地做一些对驱动程序有益的事情。 不过I/O调度器这层遮体衣服倒也不是这么容易脱掉的,因为实际上我们还使用了它捆绑的另一个功能,就是请求队列。 因此我们在前两章中的程序才如此简单。 从细节上来说,请求队列request_queue中有个make_request_fn成员变量,我们看它的定义: struct request_queue { ... make_request_fn *make_request_fn; ... } 它实际上是: typedef int (make_request_fn) (struct request_queue *q, struct bio *bio); 也就是一个函数的指针。 如果上面这段话让读者感到莫名其妙,那么请搬个板凳坐下,Let's Begin the Story。 对通用块层的访问,比如请求读某个块设备上的一段数据,通常是准备一个bio,然后调用generic_make_request()函数来实现的。 调用者是幸运的,因为他往往不需要去关心generic_make_request()函数如何做的,只需要知道这个神奇的函数会为他搞定所有的问题就OK了。 而我们却没有这么幸运,因为对一个块设备驱动的设计者来说,如果不知道generic_make_request()函数的内部情况,很可能会让驱动的使用者得不到安全感。 了解generic_make_request()内部的有效方法还是RTFSC,但这里会给出一些提示。 我们可以在generic_make_request()中找到__generic_make_request(bio)这么一句, 然后在__generic_make_request()函数中找到ret = q->make_request_fn(q, bio)这么一行。 偷懒省略掉解开谜题的所有关键步骤后,这里可以得出一个作者相信但读者不一定相信的正确结论: generic_make_request()最终是通过调用request_queue.make_request_fn函数完成bio所描述的请求处理的。 Story到此结束,现在我们可以解释刚才为什么列出那段莫名其妙的数据结构的意图了。 对于块设备驱动来说,正是request_queue.make_request_fn函数负责处理这个块设备上的所有请求。 也就是说,只要我们实现了request_queue.make_request_fn,那么块设备驱动的Primary Mission就接近完成了。 在本章中,我们要做的就是: 1:让request_queue.make_request_fn指向我们设计的make_request函数 2:把我们设计的make_request函数写出来 如果读者现在已经意气风发地拿起键盘跃跃欲试了,作者一定会假装谦虚地问读者一个问题: 你的钻研精神遇到城管了? 如果这句话问得读者莫名其妙的话,作者将补充另一个问题: 前两章中明显没有实现make_request函数,那时的驱动程序倒是如何工作的? 然后就是清清嗓子自问自答。 前两章确实没有用到make_request函数,但当我们使用blk_init_queue()获得request_queue时, 万能的系统知道我们搞IT的都低收入,因此救济了我们一个,这就是大名鼎鼎的__make_request()函数。 request_queue.make_request_fn指向了__make_request()函数,因此对块设备的所有请求被导向了__make_request()函数中。 __make_request()函数不是吃素的,马上喊上了他的兄弟,也就是I/O调度器来帮忙,结果就是bio请求被I/O调度器处理了。 同时,__make_request()自身也没闲着,它把bio这条咸鱼嗅了嗅,舔了舔,然后放到嘴里嚼了嚼,把鱼刺鱼鳞剔掉, 然后情意绵绵地通过do_request函数(也就是blk_init_queue的第一个参数)喂到驱动程序作者的口中。 这就解释了前两章中我们如何通过simp_blkdev_do_request()函数处理块设备请求的。 我们理解__make_request()函数本意不错,它把bio这条咸鱼嚼成request_queue喂给do_request函数,能让我们的到如下好处: 1:request.buffer不在高端内存 这意味着我们不需要考虑映射高端内存到虚存的情况 2:request.buffer的内存是连续的 因此我们不需要考虑request.buffer对应的内存地址是否分成几段的问题 这些好处看起来都很自然,正如某些行政不作为的“有关部门”认为老百姓纳税养他们也自然, 但不久我们就会看到不很自然的情况。 如果读者是mm,或许会认为一个摔锅把咸鱼嚼好了含情脉脉地喂过来是一件很浪漫的事情(也希望这位读者与作者联系), 但对于大多数男性IT工作者来说,除非取向问题,否则...... 因此现在我们宁可把__make_request()函数一脚踢飞,然后自己去嚼bio这条咸鱼。 当然,踢飞__make_request()函数也意味着摆脱了I/O调度器的处理。 踢飞__make_request()很容易,使用blk_alloc_queue()函数代替blk_init_queue()函数来获取request_queue就行了。 也就是说,我们把原先的 simp_blkdev_queue = blk_init_queue(simp_blkdev_do_request, NULL); 改成了 simp_blkdev_queue = blk_alloc_queue(GFP_KERNEL); 这样。 至于嚼人家口水渣的simp_blkdev_do_request()函数,我们也一并扔掉: 把simp_blkdev_do_request()函数从头到尾删掉。 同时,由于现在要脱光,所以上一章中我们费好大劲换上的那件薄内衣也不需要了, 也就是把上一章中增加的elevator_init()这部分的函数也删了,也就是删掉如下部分: old_e = simp_blkdev_queue->elevator; if (IS_ERR_VALUE(elevator_init(simp_blkdev_queue, "noop"))) printk(KERN_WARNING "Switch elevator failed, using default\n"); else elevator_exit(old_e); 到这里我们已经成功地让__make_request()升空了,但要自己嚼bio,还需要添加一些东西: 首先给request_queue指定我们自己的bio处理函数,这是通过blk_queue_make_request()函数实现的,把这面这行加在blk_alloc_queue()之后: blk_queue_make_request(simp_blkdev_queue, simp_blkdev_make_request); 然后实现我们自己的simp_blkdev_make_request()函数, 然后编译。 如果按照上述的描述修改出的代码让读者感到信心不足,我们在此列出修改过的simp_blkdev_init()函数: static int __init simp_blkdev_init(void) { int ret; simp_blkdev_queue = blk_alloc_queue(GFP_KERNEL); if (!simp_blkdev_queue) { ret = -ENOMEM; goto err_alloc_queue; } blk_queue_make_request(simp_blkdev_queue, simp_blkdev_make_request); simp_blkdev_disk = alloc_disk(1); if (!simp_blkdev_disk) { ret = -ENOMEM; goto err_alloc_disk; } strcpy(simp_blkdev_disk->disk_name, SIMP_BLKDEV_DISKNAME); simp_blkdev_disk->major = SIMP_BLKDEV_DEVICEMAJOR; simp_blkdev_disk->first_minor = 0; simp_blkdev_disk->fops = &simp_blkdev_fops; simp_blkdev_disk->queue = simp_blkdev_queue; set_capacity(simp_blkdev_disk, SIMP_BLKDEV_BYTES>>9); add_disk(simp_blkdev_disk); return 0; err_alloc_disk: blk_cleanup_queue(simp_blkdev_queue); err_alloc_queue: return ret; } 这里还把err_init_queue也改成了err_alloc_queue,希望读者不要打算就这一点进行提问。 正如本章开头所述,这一章的内容可能要复杂一些,而现在看来似乎已经做到了。 而现在的进度大概是......一半! 不过值得安慰的是,余下的内容只有我们的simp_blkdev_make_request()函数了。 首先给出函数原型: static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio); 该函数用来处理一个bio请求。 函数接受struct request_queue *q和struct bio *bio作为参数,与请求有关的信息在bio参数中, 而struct request_queue *q并没有经过__make_request()的处理,这也意味着我们不能用前几章那种方式使用q。 因此这里我们关注的是:bio。 关于bio和bio_vec的格式我们仍然不打算在这里做过多的解释,理由同样是因为我们要避免与google出的一大堆文章撞衫。 这里我们只说一句话: bio对应块设备上一段连续空间的请求,bio中包含的多个bio_vec用来指出这个请求对应的每段内存。 因此simp_blkdev_make_request()本质上是在一个循环中搞定bio中的每个bio_vec。 这个神奇的循环是这样的: dsk_mem = simp_blkdev_data + (bio->bi_sector << 9); bio_for_each_segment(bvec, bio, i) { void *iovec_mem; switch (bio_rw(bio)) { case READ: case READA: iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset; memcpy(iovec_mem, dsk_mem, bvec->bv_len); kunmap(bvec->bv_page); break; case WRITE: iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset; memcpy(dsk_mem, iovec_mem, bvec->bv_len); kunmap(bvec->bv_page); break; default: printk(KERN_ERR SIMP_BLKDEV_DISKNAME ": unknown value of bio_rw: %lu\n", bio_rw(bio)); #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, 0, -EIO); #else bio_endio(bio, -EIO); #endif return 0; } dsk_mem += bvec->bv_len; } bio请求的块设备起始扇区和扇区数存储在bio.bi_sector和bio.bi_size中, 我们首先通过bio.bi_sector获得这个bio请求在我们的块设备内存中的起始部分位置,存入dsk_mem。 然后遍历bio中的每个bio_vec,这里我们使用了系统提供的bio_for_each_segment宏。 循环中的代码看上去有些眼熟,无非是根据请求的类型作相应的处理。READA意味着预读,精心设计的预读请求可以提高I/O效率, 这有点像内存中的prefetch(),我们同样不在这里做更详细的介绍,因为这本身就能写一整篇文章,对于我们的基于内存的块设备驱动, 只要按照READ请求同样处理就OK了。 在很眼熟的memcpy前后,我们发现了kmap和kunmap这两个新面孔。 这也证明了咸鱼要比烂肉难啃的道理。 bio_vec中的内存地址是使用page *描述的,这也意味着内存页面有可能处于高端内存中而无法直接访问。 这种情况下,常规的处理方法是用kmap映射到非线性映射区域进行访问,当然,访问完后要记得把映射的区域还回去, 不要仗着你内存大就不还,实际上在i386结构中,你内存越大可用的非线性映射区域越紧张。 关于高端内存的细节也请自行google,反正在我的印象中intel总是有事没事就弄些硬件限制给程序员找麻烦以帮助程序员的就业。 所幸的是逐渐流行的64位机的限制应该不那么容易突破了,至少我这么认为。 switch中的default用来处理其它情况,而我们的处理却很简单,抛出一条错误信息,然后调用bio_endio()告诉上层这个bio错了。 不过这个万恶的bio_endio()函数在2.6.24中改了,如果我们的驱动程序是内核的一部分,那么我们只要同步更新调用bio_endio()的语句就行了, 但现在的情况显然不是,而我们又希望这个驱动程序能够同时适应2.6.24之前和之后的内核,因此这里使用条件编译来比较内核版本。 同时,由于使用到了LINUX_VERSION_CODE和KERNEL_VERSION宏,因此还需要增加#include 循环的最后把这一轮循环中完成处理的字节数加到dsk_mem中,这样dsk_mem指向在下一个bio_vec对应的块设备中的数据。 读者或许开始耐不住性子想这一章怎么还不结束了,是的,马上就结束,不过我们还要在循环的前后加上一丁点: 1:循环之前的变量声明: struct bio_vec *bvec; int i; void *dsk_mem; 2:循环之前检测访问请求是否超越了块设备限制: if ((bio->bi_sector << 9) + bio->bi_size > SIMP_BLKDEV_BYTES) { printk(KERN_ERR SIMP_BLKDEV_DISKNAME ": bad request: block=%llu, count=%u\n", (unsigned long long)bio->bi_sector, bio->bi_size); #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, 0, -EIO); #else bio_endio(bio, -EIO); #endif return 0; } 3:循环之后结束这个bio,并返回成功: #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, bio->bi_size, 0); #else bio_endio(bio, 0); #endif return 0; bio_endio用于返回这个对bio请求的处理结果,在2.6.24之后的内核中,第一个参数是被处理的bio指针,第二个参数成功时为0,失败时为-ERRNO。 在2.6.24之前的内核中,中间还多了个unsigned int bytes_done,用于返回搞定了的字节数。 现在可以长长地舒一口气了,我们完工了。 还是附上simp_blkdev_make_request()的完成代码: static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio) { struct bio_vec *bvec; int i; void *dsk_mem; if ((bio->bi_sector << 9) + bio->bi_size > SIMP_BLKDEV_BYTES) { printk(KERN_ERR SIMP_BLKDEV_DISKNAME ": bad request: block=%llu, count=%u\n", (unsigned long long)bio->bi_sector, bio->bi_size); #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, 0, -EIO); #else bio_endio(bio, -EIO); #endif return 0; } dsk_mem = simp_blkdev_data + (bio->bi_sector << 9); bio_for_each_segment(bvec, bio, i) { void *iovec_mem; switch (bio_rw(bio)) { case READ: case READA: iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset; memcpy(iovec_mem, dsk_mem, bvec->bv_len); kunmap(bvec->bv_page); break; case WRITE: iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset; memcpy(dsk_mem, iovec_mem, bvec->bv_len); kunmap(bvec->bv_page); break; default: printk(KERN_ERR SIMP_BLKDEV_DISKNAME ": unknown value of bio_rw: %lu\n", bio_rw(bio)); #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, 0, -EIO); #else bio_endio(bio, -EIO); #endif return 0; } dsk_mem += bvec->bv_len; } #if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24) bio_endio(bio, bio->bi_size, 0); #else bio_endio(bio, 0); #endif return 0; } 读者可以直接用本章的simp_blkdev_make_request()函数替换掉上一章的simp_blkdev_do_request()函数, 然后用本章的simp_blkdev_init()函数替换掉上一章的同名函数,再在文件头部增加#include 就得到了本章的最终代码。 在结束本章之前,我们还是试验一下: 首先还是编译和加载: # make make -C /lib/modules/2.6.18-53.el5/build SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step3 modules make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686' CC [M] /root/test/simp_blkdev/simp_blkdev_step3/simp_blkdev.o Building modules, stage 2. MODPOST CC /root/test/simp_blkdev/simp_blkdev_step3/simp_blkdev.mod.o LD [M] /root/test/simp_blkdev/simp_blkdev_step3/simp_blkdev.ko make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686' # insmod simp_blkdev.ko # 然后使用上一章中的方法看看sysfs中的这个设备的信息: # ls /sys/block/simp_blkdev dev holders range removable size slaves stat subsystem uevent # 我们发现我们的驱动程序在sysfs目录中的queue子目录不见了。 这并不奇怪,否则就要抓狂了。 本章中我们实现自己的make_request函数来处理bio,以此摆脱了I/O调度器和通用的__make_request()对bio的处理。 由于我们的块设备中的数据都是存在于内存中,不牵涉到DMA操作、并且不需要寻道,因此这应该是最适合这种形态的块设备的处理方式。 在linux中类似的驱动程序大多使用了本章中的处理方式,但对大多数基于物理磁盘的块设备驱动来说,使用适合的I/O调度器更能提高性能。 同时,__make_request()中包含的回弹机制对需要进行DMA操作的块设备驱动来说,也能提供不错帮助。 虽然说量变产生质变,通常质变比量变要复杂得多。 同理,相比前一章,把mm衣服脱光也比让她换一件薄一些的衣服要困难得多。 不过无论如何,我们总算连哄带骗地让mm脱下来了,而付出了满头大汗的代价: 本章内容的复杂度相比前一章大大加深了。 如果本章的内容不幸使读者感觉头部体积有所增加的话,作为弥补,我们将宣布一个好消息: 因为根据惯例,随后的1、2章将会出现一些轻松的内容让读者得到充分休息。 <未完,待续> |