[转]写一个块设备驱动(第七章)

第7章

+---------------------------------------------------+
|                 写一个块设备驱动                  |
+---------------------------------------------------+
| 作者:赵磊                                        |
| email: [email protected]                      |
+---------------------------------------------------+
| 文章版权归原作者所有。                            |
| 大家可以自由转载这篇文章,但原版权信息必须保留。  |
| 如需用于商业用途,请务必与原作者联系,若因未取得  |
| 授权而收起的版权争议,由侵权者自行负责。          |
+---------------------------------------------------+

上一章中我们对驱动程序做了很大的修改,单独分配每一页的内存,然后使用基树来进行管理。
这使得驱动程序占用的非线性映射区域大大减少,让它看起来朝优秀的代码又接近了一些。
因为优秀的代码是相似的,糟糕的代码却各有各的糟糕之处。

本章中我们将讨论一些细枝末节的问题,算是对上一章中内容的巩固,也是为后面的章节作一些铺垫。

首先聊一聊低端内存、高端内存和非线性映射区域的问题:
在i386结构中,由于任务使用32位寄存器表示地址,这造成每个任务的最大寻址范围是4G。
无论任务对应的是用户程序还是内核代码,都逃脱不了这个限制。

让问题更糟糕的是,普通的linux内核又将4G的地址划分为2个部分,前3G让用户空间程序使用,后1G由内核本身使用。
这又将内核实际使用的空间压缩了4倍。
不过linux采用这样的方案倒也不是由于开发者脑瘫,因为这样一来,内核可以与用户进程共用同一个页表,
因而在进行用户态和内核态的切换时不必刷新页表,提高了系统的效率。
而带来的麻烦就是内核只有1G的地址范围可用。
其实也有一个相当出名的4G+4G的patch,就是采用上述相反的方法,让内核与用户进程使用独立的地址空间,其优缺点也正好与现在的实现相反。
但这毕竟不是标准内核的情况,对大多数系统而言,我们不得不接受内核只有1G的地址范围可用的现实。

然后我们再来看内核如何使用这1G的地址范围。
作为内核,当然需要有能力访问到所有的物理内存,而在保护模式下,内存需要通过页表映射到一个虚拟地址上,再进行访问。
虽然内核可以在访问任何物理内存时都采用映射->访问->取消映射的方法,但这很可能将任意一台机器彻底变成386的速度。
因此,内核一般把尽可能多的物理内存事先映射到它的地址空间中去,这里的“尽可能多”指的是896M。
原因是内核手头只有1G的地址空间,而其中的128M还需要留作非线性映射空间。
这样一来,内核地址空间中的3G~3G+896M便映射了0~896M范围的物理内存。
这个映射关系在启动系统时完成,并且在系统启动后不会改变。
物理内存中0~896M的这段空间是幸运的,因为它们在内核空间中有固定的住所,
这也使它们能够方便、快速地被访问。相对896M以上的物理内存,它们地址是比较低的,
正因为此,我们通常把这部分内存区域叫做低端内存。

但地址高于896M的物理内存就没这么幸运了。
由于它们没有在启动时被固定映射到内核空间的地址空间中,我们需要在访问之前对它们进行映射。
但映射到哪里呢?幸好内核没有把整个1G的地址空间都用作映射上面所说的低端内存,好歹还留下128M。
其实这128M还是全都能用,在其开头和结尾处还有一些区域拿去干别的事情了(希望读者去详细了解一下),
所以我们可以用这剩下的接近128M的区域来映射高于896M的物理内存。
明显可以看出这时是僧多粥少,所以这部分区域最好应该节约使用。
但希望读者不要把访问高于896M的物理内存的问题想得过于严重,因为一般来说,内核会倾向于把这部分内存分配给用户进程使用,而这是不需要占用内核空间地址的。

其实非线性映射区域还有另一个作用,就是用来作连续地址的映射。
内核采用伙伴系统管理内存,这使得内核程序可以一次申请2的n次幂个页面。
但如果n比较大时,申请失败的风险也会随之增加。正如桑拿时遇到双胞胎的机会很少、遇到三胞胎的机会更少一样,
获得地址连续的空闲页面的机会总是随着连续地址长度的增加而减少。
另外,即使能够幸运地得到地址连续的空闲页面,可能产生的浪费问题也是不能回避的。
比如我们需要申请地址连续513K的内存,从伙伴系统中申请时,由于只能选择申请2的n次幂个页面,因此我们不得不去申请1M内存。
不过这两个问题倒是都能够通过使用非线性映射区域来解决。
我们可以从伙伴系统中申请多个小段的内存,然后把它们映射到非线性映射区域中的连续区域中访问。
内核中与此相关的函数有vmalloc、vmap等。

其实80前的作者很羡慕80后和90后的新一代,不仅因为可以在上中学时谈恋爱,
还因为随着64位系统的流行,上面这些与32位系统如影随形的问题都将不复存在。
关于64位系统中的内存区域问题就留给有兴趣的读者去钻研了。

然后我们再谈谈linux中的伙伴系统。
伙伴系统总是分配出2的n次幂个连续页面,并且首地址以其长度为单位对齐。
这增大了将回收的页与其它空白页合并的可能性,也就是减少了内存碎片。

我们的块设备驱动程序需要从伙伴系统中获得所需的内存。
目前的做法是每次获得1个页面,也就是分配页面时,把2的n次幂中的n指定为0。
这样做的好处是只要系统中存在空闲的页面,不管空闲的页面是否连续,分配总是能成功。
但坏处是增加了造就页面碎片的几率。
当系统中没有单独的空闲页面时,伙伴系统就不得不把原先连续的空闲页面拆开,再把其中的1个页面返回给我们的程序。
同时,在伙伴系统中需要使用额外的内存来管理每一组连续的空闲页面,因此增大页面碎片也意味着需要更多的内存来管理这些碎片。
这还不算,如果系统中的空闲页面都以碎片方式存在,那么真正到了需要分配连续页面的时候,即使存在空闲的内存,也会因为这些内存不连续而导致分配失败。
除了对系统的影响以外,对我们的驱动程序本身而言,由于使用了基树来管理每一段内存,将内存段定义得越短,意味着需要管理更多的段数,也意味着更大的基树结构和更慢的操作。

因此我们打算增加单次从伙伴系统中获得连续内存的长度,比如,每次分配2个、4个、或者8个甚至64个页,来避免上述的问题。
每次分配更大的连续页面很明显拥有不少优势,但其劣势也同样明显:
当系统中内存碎片较多时,吃亏的就是咱们的驱动程序了。原本分很多次一点一点去系统讨要,最终可以要到足够的内存,但像现在这样子狮子大开口,却反而要不到了。
还有就是如果系统中原先就存在不少碎片,原先的分配方式倒是可以把碎片都利用起来,而现在这种挑肥捡瘦的分配会同样无视那些更小的不连续页面,反而可能企图去拆散那些更大的连续页面。

折中的做法大概就是选择每次分配一块不大不小的连续的页,暂且我们选择每次分配连续的4个页。
现在开始修改代码:
为简单起见,我们了以下的4个宏:
#define SIMP_BLKDEV_DATASEGORDER        (2)
#define SIMP_BLKDEV_DATASEGSHIFT        (PAGE_SHIFT + SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGSIZE                (PAGE_SIZE << SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGMASK                (~(SIMP_BLKDEV_DATASEGSIZE-1))
SIMP_BLKDEV_DATASEGORDER表示我们从伙伴系统中申请内存时使用的order值,把这个值设置为2时,每次将从伙伴系统中申请连续的4个页面。
我们暂且把这样的连续页面叫做内存段,这样一来,在i386结构中,每个内存段的大小为16K,假设块设备大小还是16M,那么经历了本章的修改后,
驱动程序所使用的内存段数量将从原先的4096个减少为现在的1024个。
SIMP_BLKDEV_DATASEGSHIFT是在偏移量和内存段之间相互转换时使用的移位值,类似于页面处理中的PAGE_SHIFT。这里就不做更详细地介绍了,毕竟这不是C语言教程。
SIMP_BLKDEV_DATASEGSIZE是以字节为单位的内存段的长度,在i386和SIMP_BLKDEV_DATASEGORDER=2时它的值是16384。
SIMP_BLKDEV_DATASEGMASK是内存段的屏蔽位,类似于页面处理中的PAGE_MASK。
其实对于功能而言,我们只需要SIMP_BLKDEV_DATASEGORDER和SIMP_BLKDEV_DATASEGSIZE就足够了,其它的宏用于快速的乘除和取模等计算。
如果读者对此感到有些迷茫的话,建议最好还是搞明白,因为在linux内核的世界中这一类的位操作将随处可见。

然后要改的是申请和释放内存代码。
原先我们使用的是__get_free_page()和free_page()函数,这一对函数用来申请和释放一个页面。
这显然不能满足现在的要求,我们改用它们的大哥:__get_free_pages()和free_pages()。
它们的原型是:
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
可以注意到与__get_free_page()和free_page()函数相比,他们多了个order参数,正是用于指定返回2的多少次幂个连续的页。
因此原先的free_diskmem()和alloc_diskmem()函数将改成以下这样:
void free_diskmem(void)
{
int i;
void *p;

for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
>> SIMP_BLKDEV_DATASEGSHIFT; i++) {
p = radix_tree_lookup(&simp_blkdev_data, i);
radix_tree_delete(&simp_blkdev_data, i);
/* free NULL is safe */
free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
}
}

int alloc_diskmem(void)
{
int ret;
int i;
void *p;

INIT_RADIX_TREE(&simp_blkdev_data, GFP_KERNEL);

for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
>> SIMP_BLKDEV_DATASEGSHIFT; i++) {
p = (void *)__get_free_pages(GFP_KERNEL,
SIMP_BLKDEV_DATASEGORDER);
if (!p) {
ret = -ENOMEM;
goto err_alloc;
}

ret = radix_tree_insert(&simp_blkdev_data, i, p);
if (IS_ERR_VALUE(ret))
goto err_radix_tree_insert;
}
return 0;

err_radix_tree_insert:
free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
err_alloc:
free_diskmem();
return ret;
}
除了用__get_free_pages()和free_pages()代替了原先的__get_free_page()和free_page()函数以外,
还使用刚刚定义的那几个宏代替了原先的PAGE宏。
这样一来,所需内存段数的计算方法也完成了修改。

剩下的就是使用内存段的simp_blkdev_make_request()代码。
实际上,我们只要用刚才定义的SIMP_BLKDEV_DATASEGSIZE、SIMP_BLKDEV_DATASEGMASK和 SIMP_BLKDEV_DATASEGSHIFT替换原先代码中的PAGE_SIZE、PAGE_MASK和PAGE_SHIFT就大功告成了,
当然,这个结论是作者是经过充分检查和实验后才得出的,希望不要误认为编程时可以大大咧咧地随心所欲。作为程序员,严谨的态度永远都是需要的。

现在,我们的simp_blkdev_make_request()函数变成了这样:
static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio)
{
struct bio_vec *bvec;
int i;
unsigned long long dsk_offset;

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_offset = bio->bi_sector << 9;

bio_for_each_segment(bvec, bio, i) {
unsigned int count_done, count_current;
void *iovec_mem;
void *dsk_mem;

iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset;

count_done = 0;
while (count_done < bvec->bv_len) {
count_current = min(bvec->bv_len - count_done,
(unsigned int)(SIMP_BLKDEV_DATASEGSIZE
- ((dsk_offset + count_done) &
~SIMP_BLKDEV_DATASEGMASK)));

dsk_mem = radix_tree_lookup(&simp_blkdev_data,
(dsk_offset + count_done)
>> SIMP_BLKDEV_DATASEGSHIFT);
if (!dsk_mem) {
printk(KERN_ERR SIMP_BLKDEV_DISKNAME
": search memory failed: %llu\n",
(dsk_offset + count_done)
>> SIMP_BLKDEV_DATASEGSHIFT);
kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, 0, -EIO);
#else
bio_endio(bio, -EIO);
#endif
return 0;
}
dsk_mem += (dsk_offset + count_done)
& ~SIMP_BLKDEV_DATASEGMASK;

switch (bio_rw(bio)) {
case READ:
case READA:
memcpy(iovec_mem + count_done, dsk_mem,
count_current);
break;
case WRITE:
memcpy(dsk_mem, iovec_mem + count_done,
count_current);
break;
default:
printk(KERN_ERR SIMP_BLKDEV_DISKNAME
": unknown value of bio_rw: %lu\n",
bio_rw(bio));
kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
bio_endio(bio, 0, -EIO);
#else
bio_endio(bio, -EIO);
#endif
return 0;
}
count_done += count_current;
}

kunmap(bvec->bv_page);
dsk_offset += 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;
}

本章的到这里就完成了,接下去我们还是打算试验一下效果。
其实这个实验不太好做,因为linux本身也会随时分配和释放页面,这会影响我们看到的结果。
如果读者看到的现象与预期不同,这也属于预期。

不过为了降低试验受到linux自身活动影响的可能性,建议试验开始之前尽可能关闭系统中的服务、不要同时做其它的操作、不要在xwindows中做。
然后我们开始试验:
先编译模块:
# make
make -C /lib/modules/2.6.18-53.el5/build SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step07 modules
make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686'
CC [M]  /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.o
Building modules, stage 2.
MODPOST
CC      /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.mod.o
LD [M]  /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.ko
make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686'
#

现在看看伙伴系统的情况:
# cat /proc/buddyinfo
Node 0, zone      DMA    288     63     34      0      0      0      0      1      1      1      0
Node 0, zone   Normal   9955   1605     24      1      0      1      1      0      0      0      1
Node 0, zone  HighMem   2036    544     13      6      2      1      1      0      0      0      0
#

加载模块后再看看伙伴系统的情况:
# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone      DMA    337    140      1      1      1      0      0      0      1      0      0
Node 0, zone   Normal  27888   8859     18      0      0      1      0      0      1      0      0
Node 0, zone  HighMem   1583    544     13      6      2      1      1      0      0      0      0
#

释放模块后再看看伙伴系统的情况:
# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone      DMA    337    140     35      0      0      0      0      1      1      1      0
Node 0, zone   Normal  27888   8860    632      7      0      1      1      0      0      0      1
Node 0, zone  HighMem   1583    544     13      6      2      1      1      0      0      0      0
#

首先补充说明一下伙伴系统对每种类型的内存区域分别管理,这在伙伴系统中称之为zone。
在i386中,常见的zone有DMA、Normal和HighMem,分别对应0~16M、16~896M和896M以上的物理内存。
DMA zone的特点是老式ISA设备只能使用这段区域进行DMA操作。
Normal zone的特点它被固定映射在内核的地址空间中,我们可以直接使用指针访问这段内存。(不难看出,DMA zone也有这个性质)
HighMem zone的特点它没有以上两种zone的特点。
其实我们在上文中讲述的低端内存区域是这里的DMA和Normal zone,而高端内存区域是这里的HighMem zone。

/proc/buddyinfo用于显示伙伴系统的各个zone中剩余的各个order的内存段个数。

我们的模块目前使用低端内存来存储数据,而一般情况下系统会尽可能保留DMA zone的空域内存不被分配出去,
因此我们主要关注/proc/buddyinfo中的Normal行。
行中的各列中的数字表示伙伴系统的这一区域中每个order的剩余内存数量。
比如:
Node 0, zone   Normal   9955   1605     24      1      0      1      1      0      0      0      1
这一行表示Normal zone中剩余9955个独立的内存页、1605个连续2个页的内存、24连续4个页的内存等。

由于我们现在每次申请4个页的内存,因此最关注的Normal行的第3列。

首先看模块加载前,Normal行的第3列数字是24,表示系统中剩余24个连续4页的内存区域。
然后我们看模块加载之后的情况,Normal行的第3列从24变为了18,减少了6个连续4页的内存区域。
这说明我们的程序只用掉了6个连续4页的内存区域------明显不可能。
因为作为模块编者,我们很清楚程序需要使用1024个连续4页的内存区域。

继续看这一行的后面,原先处在最末尾的1便成了0。
我们可以数出来最末尾的数字对应order为10的连续页面,也就是连续4M的页面,原来是空闲的,而现在被拆散用掉了。
但即使它被用掉了,也不够我们的的16M空间,数字的分析变得越来越复杂,是坚持下去还是就此停止?
这一次我们决定停止,因为真相是现在进行的模块加载前后的剩余内存对比确实产生不了什么结论。

详细解释一下,其实我们可以看出在模块加载之前,Normal区域中order>=2的全部空闲内存加起来也不够这个模块使用。
甚至加上DMA区域中order>=2的全部空闲内存也不够。
虽然剩余的order<2的一大堆页面凑起来倒是足够,但谁让我们的模块挑食,只要order=2的页面呢。
因此这时候系统会试图释放出空闲内存。比如:释放一些块设备缓冲页面,或者将用户进程的内存转移到swap中,以获得更多的空闲内存。
很幸运,系统通过释放内存操作拿到了足够的空闲内存使我们的模块得以顺利加载,
但同时由于额外增加出的空闲内存使我们对比模块加载前后的内存差别失去了意义。

其实细心一些的话,刚才的对比中,我们还是能够得到一些结论的,比如,
我们可以注意到模块加载后order为0和1的两个数字的暴增,这就是系统释放页面的证明。
详细来说,系统释放出的页面既包含order<2的,也包含order>=2的,但由于其中order>=2的页面多半被我们的程序拿走了,
这就造成模块加载后的空闲页面中大量出现order<2的页面。

既然我们没有从模块加载前后的空闲内存变化中拿到什么有意义的结论,
我们不妨换条路走,去看看模块释放前后空闲内存的变化情况:

首先还是看Normal区域:
order为0和1的页面数目基本没有变化,这容易解释,因为我们释放出的都是order=2的连续页面。
order=2的连续页面从18增加到632,增加了614个。这应该是模块卸载时所释放的内存的一部分。
由于这个模块在卸载时,会释放1024个order=2的连续页面,那么我们还要继续找出模块释放的内存中其他部分的行踪。
也就是1024-614=410个order=2的连续页到哪去了。

回顾上文中的伙伴系统说明,伙伴系统会适时地合并连续页面,那么我们假设一部分模块释放出的页面被合并成更大order的连续页面了。
让我们计算一下order>2的页面的增加情况:
order=3的页面增加了7个,order=6的页面增加了1个,order=8的页面减少了1个,order=10的页面增加了1个。
这分别相当于order=2的页面增加14个、增加16、减少64个、增加256个,综合起来就是增加222个。
这就又找到了一部分,剩下的行踪不明的页面还有410-222=188个。

我们继续追查,现在DMA zone区域。
我们的程序所使用的是低端内存,其实也包含0~16M之间的DMA zone。
刚才我们说过,系统会尽可能不把DMA区域的内存分配出去,以保证真正到必须使用这部分内存时,能够拿得出来。
但“尽可能”不代表“绝对不”,如果出现内存不足的情况,DMA zone的空闲内存也很难幸免。
但刚才我们的试验中,已经遇到了Normal区域内存不足情况,这时把DMA zone中的公主们拿去充当Normal zone的军妓也是必然的了。
因此我们继续计算模块释放后DMA区域的内存变化。在DMA区域:
order=2的页面增加了34个,order=3的页面减少了1个,order=4的页面减少了1个,order=7的页面增加了1个,order=9的页面增加了1个。
这分别相当于order=2的页面增加34个、减少2、减少4个、增加32个,增加128个,综合起来就是增加188个。

数字刚好吻合,我们就找到了模块释放出的全部页面的行踪。
这也验证了本章中改动的功能符合预期。

然后我们再一次加载和卸载模块,同时查看伙伴系统中空闲内存的变化:
# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone      DMA    336    141      0      0      0      1      1      0      1      0      0
Node 0, zone   Normal  27781   8866      0      1      0      1      0      0      1      0      0
Node 0, zone  HighMem   1459    544     13      6      2      1      1      0      0      0      0
#
# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone      DMA    336    141     35      0      0      0      0      1      1      1      0
Node 0, zone   Normal  27781   8867    633      7      0      1      1      0      0      0      1
Node 0, zone  HighMem   1459    544     13      6      2      1      1      0      0      0      0
#

我们可以发现这一次模块加载前后的内存变化情况与上一轮有些不同,而分析工作就留给有兴趣的读者了。

本章对代码的改动量不大,主要说明一下与我们程序中出现的linux内存管理知识。
其实上一章的改动中已经涉及到了这部分知识,只是因为那时的重点不在这个方面,并且作者也不希望在同一章中加入过多的内容,
因此在本章中做个补足。
同时,本章中的说明也给后续章节中将要涉及到的内容做个准备,这样读者在将来也可以惬意一些。

不过在开始写这一章时,作者曾反复考虑该不该这样组织本章,
正如我们曾经说过的,希望读者在遇到不明白的地方时主动去探索教程之外更多的知识,
而不是仅仅读完这个教程本身。
本教程的目的是牵引出通过实现一个块设备驱动程序来牵引出相关的linux的各个知识点,
让读者们以此为契机,通过寻求疑问的答案、通过学习更细节的知识来提高自己的能力。
因此教程中对于不少涉及到的知识点仅仅给出简单的介绍,因为读者完全有能力通过google了解更详细的内容,
这也是作者建议的看书方法。
不过本章是个例外,因为作者最终认为对这些知识的介绍对于这部教程的整体性是有帮助的。
但这里的介绍其实仍然只属于皮毛,因此还是希望读者进一步了解教程以外的更多知识。

<未完,待续>
 

你可能感兴趣的:(linux,开发,内核,驱动,块设备)