本文是 Linux 内核内存检测工具系列中的一篇,主要分析了 Kmemcheck 的原理,配置以及它的典型应用。读者在阅读完本文之后,能轻松地学会怎样利用 Kmemcheck 来对内核程序进行检测,同时也能从 Kmemcheck 的设计原理中得到一些启发。
Linux 内核内存检测工具 – Kmemcheck
本文是 Linux 内核内存检测工具系列中的一篇,主要分析了 Kmemcheck 的原理,配置以及它的典型应用。读者在阅读完本文之后,能轻松地学会怎样利用 Kmemcheck 来对内核程序进行检测,同时也能从 Kmemcheck 的设计原理中得到一些启发。
前言
访问非法的内存地址(如访问未初始化的内存,访问已经释放的内存)是一件很危险的事情,如果在内核程序中使用了非法内存中的内容,可能会导致系统崩溃,如何发现并消灭这些潜在的风险,是在编写程序时都必须考虑的问题。在 Linux 系统中,gcc 会在编译的时候对内存未初始化的情况发出警告,但是它只能做一些静态的检查;另外如果系统安装了 Valgrind,也可以利用其提供的 memcheck 来动态地对内存进行检查,但是它只能检查出一些用户态程序的问题,对工作在内核态的程序无能为力。因此,从事内核开发(如设备驱动程序)工作的时候,我们迫切需要一个能为内核程序提供动态内存检查的工具,所幸的是,在 Linux 2.6.31 的内核版本中,它提供了一个这样的内存检测功能 – Kmemcheck, 目前该功能只支持 x86 平台。
工作原理
Kmemcheck 工作在内核态,它使用了四个宏定义来标识内存的状态(以字节为单位来标识):
KMEMCHECK_SHADOW_UNALLOCATED
在 slab cache 中,如果没有设置构造函数,那么新分配的 slab 页面在还没有分配 object 之前,它都会被设置成此状态。
KMEMCHECK_SHADOW_UNINITIALIZED
一般情况下(不包含分配标志中置位了 __GFP_ZERO),新分配的页面都会被设置成这个状态。
KMEMCHECK_SHADOW_FREED
在 slab cache 中,当某一个 object 所占有的内存被释放后,该内存块会被设置成此状态。
KMEMCHECK_SHADOW_INITIALIZED
标识当前内存处于初始化状态(即对它的访问是正确的)
在以上四种内存状态中,对前三种状态的内存的访问都是非法的,kmemcheck 会给出相应的警告(本文中的访问都是指读操作,因为写入操作被认为是在初始化内存)。
为了使 kmemcheck 能够有效的工作,内核中修改(或新增)了一些数据结构,比较重要的有:
1. 在 struct page 中增加了一个 shadow 的指针,它指向该数据页面所对应的影子页面 ( 接下来会介绍),在影子页面中记录了数据页面中每个字节的状态。
清单 1. page 结构定义
|
2. 在页表项的页面属性域中新定义了一个 _PAGE_HIDDEN 的标志位,如果为 1,则说明该页面被 kmemcheck 跟踪。
清单 2. 页面属性定义
|
3. 增加了一个 slab cache 属性 SLAB_NOTRACK,当设置此属性时,cache 中的 slab 对象不会被 kmemcheck 跟踪。
清单 3. slab 分配标志定义
在 arch/x86/include/asm/slab.h 中: |
4. 增加了一个内存分配的 GFP 属性 __GFP_NOTRACK,当置位此标志位时,分配的内存不会被 kmemcheck 跟踪。
清单 5. GFP 分配标志定义
|
kmemcheck 究竟是怎么工作的呢? 下面从四个方面详细介绍了 kmemcheck 的工作原理 ( 假设 kmemcheck 功能被打开):
分配内存
对分配到的内存数据页面(分配标志中不包含 __GFP_NOTRACK,__GFP_HIGHMEM,对于 slab cache 的内存,cache 创建时标志中不包含 SLAB_NOTRACK),kmemcheck 会为其分配相同数量的影子页面(在分配影子页面时,置位了 __GFP_NOTRACK 标志位,所以它自己不会被 kmemcheck 跟踪),数据页面通过其 page 结构体中的 shadow 指针和影子页面联系起来。然后影子页面中的每个字节会标志为未初始化状态,同时将数据页面对应的页表项中 _PAGE_PRESENT 标志位清零(这样访问该数据页面时会引发页面异常),并置位 _PAGE_HIDDEN 标志位来表明该页面是被 kmemcheck 跟踪的。
访问内存
由于在分配过程中将数据页面对应的页表项中的 _PAGE_PRESENT 清零了,因此对该数据页面的访问会引发一次页面异常,在 do_page_fault 函数处理过程中,如果它发现页表项属性中的 _PAGE_HIDDEN 置位了,那么说明该页面是被 Kmemcheck 跟踪的,接下来就会进入 kmemcheck 的处理流程,其中会根据该次内存访问地址所对应的影子页面中的内容来检查这次访问是否是合法的,如果是非法的那么它就会将预先设置好的一个 tasklet(该 tasklet 负责错误处理)插入到当前 CPU 的 tasklet 队列中,然后去触发一个软中断,这样在中断的下半部分就会执行这个 tasklet。接下来 kmemcheck 会将影子页面中对应本次内存访问地址的内存区域标识为初始化状态(防止同一个地址警告两次),同时将数据页面页表项中的 _PAGE_PRESENT 置位,并将 CPU 标志寄存器 TF 置位开启单步调试功能,这样当页面异常处理返回后,CPU 会重新执行触发异常的指令,而这次是可以正确执行的。但是执行该指令完毕后,由于 TF 标志位置位了,所以在执行下一条指令之前,系统会进入调试陷阱(debug trap),在其处理函数 do_trap 中,kmemcheck 又会清零该数据页面页表项中的 _PAGE_PRESENT 属性标志位(并且清零标志寄存器中的 TF 位),从而当下次再访问到这个页面时,又会引发一次页面异常。
释放内存
影子页面会随着数据页面的释放而被释放,因此当数据页面被释放之后,如果再去访问该页面,不会出现 kmemcheck 报警。
错误处理
kmemcheck 用了一个循环缓冲区(包含了 CONFIG_KMEMCHECK_QUEUE_SIZE 个元素)来记录每次的警告信息,包括警告类型,引发警告的内存地址及其访问长度,各寄存器的值和 stack trace,同时还将访问地址附近(起始地址:以 2 的 CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT 次幂大小对该地址进行圆整后的值;大小:2 的 CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT 次幂)的数据页面和其对应影子页面中的内容保存在记录中(由同一指令地址引发的相邻的两次警告不会被重复记录)。当前文中注册的 tasklet 被调度执行时,会将循环缓冲区中所有的记录都打印出来。
Kmemcheck 的配置
为了在内核中使用 kmemcheck 功能,需要进行如下设置:
重新编译内核
下面例举了内核选项中针对 Kmemcheck 的配置选项,以及它们应该被设置的值(或推荐值):
CONFIG_CC_OPTIMIZE_FOR_SIZE=n
禁止 gcc 对数据长度进行优化,例如在 32 位的机器中,为了提高内存访问速度,gcc 可能会将一些 16 位的数据访问提升至 32 位(真正使用时会舍弃高 16 位),这样 kmemcheck 可能就会对高 16 位中数据内容访问发出警告(这种警告成为伪警告)。这个选项是配置 kmemcheck 的前提,否则 kmemcheck 不会出现在配置选项中。默认是 y,在选项”General setup” 中。
CONFIG_SLAB=y or CONFIG_SLUB=y
使用 slab 或者 slub 机制,默认是 CONFIG_SLUB=y,在选项”General setup” 中。
CONFIG_FUNCTION_TRACER=n
防止嵌套的页面异常,默认是 n,在选项”General setup” 中。
CONFIG_DEBUG_PAGEALLOC=n
关闭页面分配调试功能,默认是 n,在选项”Kernel hacking” 中。
CONFIG_DEBUG_INFO=y (推荐值)
打开内核调试信息,方便内核调试,在选项”Kernel hacking” 中。
CONFIG_KMEMCHECK=y
决定内核是否包含 kmemcheck 功能,在选项”Kernel hacking” 中
CONFIG_KMEMCHECK_[DISABLED|ENABLED| ONESHOT]_BY_DEFAULT
定义 Kmemcheck 在机器启动时的状态,默认是 ENABLED,在选项”Kernel hacking” 中。DISABLED 为不启动,ENABLED 为启动但它会降低启动的速度,ONESHOT 将在第一次警告之后关闭 Kmemcheck 功能。kmemcheck 的状态是可以在系统启动后通过修改 /proc/sys/kernel/kmemcheck 的值来进行动态调整的。
CONFIG_KMEMCHECK_QUEUE_SIZE
出错循环缓冲区大小,默认是 64,即最多一次可以保存 64 条警告记录,推荐保留默认值。
CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT
当发生警告时,保存下来的内存数据大小,默认是 5,即可以保存 32 字节的数据,推荐保留默认值。
CONFIG_KMEMCHECK_PARTIAL_OK
为了解决 gcc 对数据长度的优化,默认是 y,推荐保留默认值。
CONFIG_KMEMCHECK_BITOPS_OK
针对位域的访问,默认是 n,推荐保留默认值(如果需要用到 kmemcheck 来对位域的访问进行跟踪,推荐使用其提供的 Bitfield annotations)。
重启机器
选用新内核来启动系统,此时系统会根据 CONFIG_KMEMCHECK_[DISABLED|ENABLED|ONESHOT]_BY_DEFAULT 来决定 kmemcheck 在启动时的状态,如果需要动态修改,可以在引导程序的内核启动选项中加入 kmemcheck=x 参数(x 为 0 对应 CONFIG_KMEMCHECK_DISABLED_BY_DEFAULT,x 为 1 对应 CONFIG_KMEMCHECK_ENABLED_BY_DEFAULT,x 为 2 时对应 CONFIG_KMEMCHECK_ONESHOT_BY_DEFAULT)。例如可以设置如下 grub 参数选项来禁止 kmemcheck 在系统启动时启动:
title Fedora (2.6.31.1 with kmemcheck) |
另外还可以在系统启动完毕之后,动态启用 kmemcheck 功能,值得注意的是 kmemcheck 的该功能仍处在试验阶段,可能会产生一些伪警告信息。打开 kmemcheck 功能启动命令为:
echo 1 > /proc/sys/kernel/kmemcheck #0:disable 1:enable 2:one-shot |
Kmemcheck 使用示例
下面通过三个例子分别展示了 kmemcheck 所能检测出的三种内存访问错误:
1. 在本例中(完整代码请参阅附件 1 中的 kmemchk_ uninitialized.c),我们先用 alloc_pages 分配了两个页面大小的内存,然后在未初始化的情况下对其中的内容进行访问,我们会发现 kmemcheck 会发出内存未初始化警告信息(即 KMEMCHECK_SHADOW_UNINITIALIZED 类型的错误信息)。
清单 6: kmemchk_uninitialized.c 部分代码示例
|
加载模块后终端会显示 kmemcheck 打印的警告信息(完整 log 信息请参阅附件 2):
清单 6. Unintialized 警告日志
|
下面对清单 6 中的主要内容进行分析:
<a> WARNING: kmemcheck: Caught 8-bit read from uninitialized memory (cef5202b)
该行记录了非法访问的内存地址:0xcef5202b,以及错误类型: read from uninitialized memory。
<b> 0000000000000000000000000000000000000000000000000000000000000000
该行打印了数据页面中的 32 个字节(每个字节表示为二个十六进制的数字)的内容,实际上打印区间的大小是由上面配置的 CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT 来决定的(大小为 2 的 CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT 次幂,在本例中 CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT 采用的是系统默认值 5,所以打印出来的内存区间大小为 2^5=32)。另外,根据前文的介绍,可以计算出此内存区间的起始地址为 0xcef5202b & ~(2^5 – 1),即 0xcef52020。所以本行打印的是从地址 0xcef52020 开始的 32 个字节。
<c> u u u u u u u u u u u u u u u u u u u u u u u u u u u u u u u u
该行打印了上一行数据页面内存区间所对应的影子页面中的内容(相同的区间长度和页面偏移量),其中每一个字符对应一个字节(’u’表示 KMEMCHECK_SHADOW_UNINITIALIZED 类型的错误,’a’表示 KMEMCHECK_SHADOW_UNALLOCATED 类型的错误,’f’表示 KMEMCHECK_SHADOW_FREED 类型的错误)。
<d> ^
该符号指示了非法访问的内存地址所在的位置,由于地址 0xcef5202b 相对于起始地址 0xcef52020 的偏移为 11,因此’^’指示到第 12 个’u’。
<e>及其以后的内容记录的是当时 stack trace 和寄存器信息,其中 EIP 地址为 0xd09d306a,它就是引发警告的指令地址,通过下面的过程可以找出该指令对应的 C 语句位置:
- 通过如下命令得到模块在内核中的地址信息:
#cat /proc/kallsyms | grep kmemchk_uninitialized_init |
- 通过 objdump 对模块进行反编译:
#objdump –source -d kmemchk_uninitialized.ko |
命令输出为 :
清单 7. objdump 输出
|
由于错误发生在 0x6a 偏移处 (0xd09d306a 减去 0xd09d3000 为 0x6a),从而可以很清楚的看出是 if(*(addr + offset) == ‘a’ ) 这条语句引发了 kmemcheck 的警告,而该语句访问了一个未初始化的内存地址。值得一提的是,如果访问一个未初始化的局部变量(存储在内核栈中),kmemcheck 是不会报错的,其原因在于内核栈在分配的时候置位了 __GFP_NOTRACK 分配标志位,这个设计是很合理的,因为栈在函数调用中有很多进栈、出栈的操作,很难判断某一个地址是否是未初始化的非法访问。
2. 本例中(完整代码请参阅附件 1 中的 kmemchk_unallocated.c),我们先创建了一个 slab cache,然后对 slab 中未分配的对象进行访问,我们会发现 kmemcheck 会发出内存未分配警告信息(即 KMEMCHECK_SHADOW_UNALLOCATED 类型的错误信息)
清单 8. kmemchk_unallocated.c 部分代码示例
|
加载模块后就会出现 kmemcheck 的警告信息(完整 log 信息请参阅附件 1):
清单 9. Unallocated 警告日志
|
对清单 9 的分析可以参考清单 6。
3. 本例中(完整代码请参阅附件 1 中的 kmemchk_free.c),我们先用 kmalloc 分配内存,完毕后释放该内存,然后再去访问被释放了的内存空间,我们会发现 kmemcheck 会发出访问内存已释放警告信息(即 KMEMCHECK_SHADOW_FREED 类型的错误信息)
清单 10. Freed 警告日志
|
该模块在卸载时会出现如下 kmemcheck 的警告信息(完整 log 信息请参阅附件 2):
清单 9. Freed 警告日志
|
在该例中,由于 kmalloc 是基于 slab cache 机制的,对一个对象的释放不会马上释放该对象所在的页面,因而会出现非法访问警告信息(但是如果对象所在的数据页面也被释放了,则不会出现警告,因为影子页面也同时被释放了)。
小结
kmemcheck 的使用可以检查出对未初始化内存的非法访问,但是它有时候也会产生一些伪警告信息。另外,kmemcheck 的启用会消耗一些额外的内存(其中一个原因就是它会为跟踪的每一个数据页面分配一个影子页面),同时 kmemcheck 所用到的 CPU 单步调试功能以及因其所产生的多余的处理流程(如页面异常中挂接的处理函数)也将会影响到机器的运行速度,因此,kmemcheck 更多的只是一个内核调试功能,它目前还在不断的改进当中。
样例代码
code1.zip
code2.zip