Linux内核设计与实现 第18章 调试
《Linux内核设计与实现 》阅读笔记
调试工作艰难是内核级开发区别于用户级开发的一个显著特点。相比于用户级开发,内核调试的难度确实要艰苦得多。更可怕的是,它带来的风险比用户级别更高,内核的一一个错误往往立刻就能让系统崩溃。
驾驭内核调试的能力(当然,最终是为了能够成功地开发内核)很大程度上取决于经验和对整个操作系统的把握。没错,玉树临风可能会对别的事情有帮助,但是调试内核的关键还是在于你对内核的深刻理解。然而我们必须找到可以开始着手的地方,所以,在这一章里我们从调试内核的一种可能步骤开始。
18.1准备开始
内核调试往往是一个令人挠头不已的漫长过程。不少bug已经让整个开发社区几个月都食不甘味了。幸运的是,在这些费劲的问题中也有不少比较简单,而且容易消灭的小bug。运气好时,你可能面对的是些简单的小bug。开始做一些调查之前,不会清楚到底面对的是什么。现在,需要的只是一个bug。听起来很可笑,但确实需要一个确定的bug。如果错误总是能够重现的话,那对我们会有很大的帮助(有一部分错误确实如此)。然而不幸的是,大部分bug通常都不是行为可靠而且定义明确的。
一个藏匿bug的内核版本。如果你知道这个bug最早出现在哪个内核版本中那就再理想不过了。如果你还不知道的话,别着急,本章会教你-一个快速找出这个bug首先出现在哪个内核版本中的方法。
相关内核代码的知识和运气。调试内核其实是-一个棘手的问题。不过对周围的代码理解得越多,调试起来也就越轻松。
本章中的大多数方法都假定能够让bug重现。因此,想要成功地进行调试,就取决于是否能让这些错误重现。如果不能,消灭bug就只能通过抽象出问题,再从代码中搜索蛛丝马迹来进行了。虽然有时也得这么做,但如果你能够让错误重现,成功的机会要大许多。
有一些bug存在而且有人没办法让它重现,这听起来可能感觉挺奇怪。在用户级的程序里,bug常常表现得很直截了当。比如,执行foo就会让程序立即产生核心信息转储( dump core)。但是内核中的bug表现却不是那么清晰。内核、用户程序和硬件之间的交互常常会很微妙。一个竞争条件可能在几百万次的算法迭代中才露出一次狰狞的面孔。设计不佳的(甚至是包含错误的)代码在某些系统上可能还让人可以忍受,而在其他的一些系统中却表现得相当糟糕。在一些特定的配置、一些特定的机器上,通常都需要付出额外的努力来触发某个bug,不然的话,根本看不到它。在跟踪bug的时候,掌握的信息越多越好。许多时候,当可以精确地重现一个bug的时候,就已经成功了一大半了。
18.2内核中的bug
内核中的bug多种多样。它们的产生可以有无数的原因,同时它们的表象也变化多端。从明白无误的错误代码(比如,没有把正确的值存放在恰当的位置)到同步时发生的错误(比如,共享变量锁定不当),再到错误地管理硬件(比如,给错误的控制寄存器发送错误的指令);从降低所有程序的运行性能到毁坏数据再到使得系统处于死锁状态,都可能是bug发作时的症状。
从隐藏在源代码中的错误到展现在目击者面前的bug,往往是经历一系列连锁反应的事件才可能触发的。举个例子,一个被共享的结构体,如果它没有引用计数,那么它就有可能会引发竞争条件。因为没有引用计数的话,一个进程可以在另外一个进程仍然需要使用该结构的时候就释放掉它。继而,第二个进程就有可能试图通过无效的指针去使用一个不存在的数据结构。这样做可能导致引用一个空指针,也可能导致读出一些垃圾数据,还可能并不产生什么恶果(如果该数据并没有被其他什么覆盖的话)。引用空指针会导致产生一个oops,而垃圾数据可能会导致系统崩溃(这种情形比oops还坏)。用户报告了oops或系统的错误现象之后,开发者回过头来观察错误情形,发现在释放数据之后还会对它进行读写,存在着一个竞争条件,于是就会进行修正,给这个共享的结构加上适当的引用计数。
内核调试听起来很难,但事实上Linux内核与其他大型的软件项目也没有什么太大的不同。内核确实有一些独特的问题需要考虑,像定时限制和竞争条件等,它们都是允许多个线程在内核中同时运行产生的结果。
18.3通过打印来调试
内核提供的打印函数printk0和C库提供的printf()函数功能几乎相同。实际上,在本书中我们都没有用到这两个函数的不同部分。从它实现的大部分意图来说,这个名字很不错,printk()就是内核的格式化打印函数。但是,printk() 确实还有一些自身特殊的功能。
1)健壮性
健壮性是printk()函数最容易让人们接受的一个特质。任何时候,任何地方都能调用它,内核中的printk()比比皆是。可以在中断上下文和进程上下中被调用;可以在任何持有锁时被调用;可以在多处理器上同时被调用,而且调用者连锁都不必使用。
它是一个弹性极佳的函数。这一点相当重要,printk()之所以这么有用,就在于它随时都能被调用。
printk()函数的健壮躯壳下也难免会有漏洞。在系统启动过程中,终端还没有初始化之前,在某些地方不能使用它。不过说实在的,如果终端没有初始化,你又能输出到什么地方去呢?这一般不是一个什么问题,除非你要调试的是启动过程最开始的那些步骤(比如说在负责执行硬件体系结构相关的初始化动作的setup_ _arch0 函数中)。着手进行这样的调试挑战性很强——没有任何打印函数能用,确实让问题更加棘手。
不过还是有一些可以指望的(虽然不多)。核心硬件部分的黑客依靠此时能够工作的硬件设备(比如说一个串口)与外界通信。绝大部分人对此都不会感兴趣。解决的办法是提供-一个printk0的变体函数——early_ printk(), 这个函数在启动过程的初期就具有在终端上打印的能力。它的功能与prink()完全相同,区别仅仅在于名字和能够更早地工作。不过,由于该函数在一些内核支持的硬件体系结构上无法实现,所以这种办法缺少可移植性。但是,如果所使用的硬件体
系可以实现这个函数(大多数硬件体系都可以,包括x86),它就是最好的指望。
除非在启动过程的初期就要在终端上输出,否则可以认为printk()在什么情况下都能工作。
2)日志等级
printk()和printf()在使用上最主要的区别就是前者可以指定一个日志级别。内核根据这个级别来判断是否在终端上打印消息。内核把级别比某个特定值低的所有消息显示在终端上。可以通过”下面这种方式指定一个记录级别:
printk (KERN_ WARNING "This is a warning1\n") ;
printk (KERN_ DEBUG "This is a debug notice!\n") ;
printk("I did not specify a 1oglevel!\n") ;
1
2
3
KERN_ WARING和KERN DEBUG都是
表18-1可供使用的记录等级
记录等级 描述
KERN_EMERG 一个紧急情况
KERN_ALERT 一个需要立即被注意到的错误
KERN_CRIT 一个临界情况
KERN_ERR 一个错误
KERN_WARNING 一个警告
KERN_NOTICE 一个普通的,不过也有可能需要注意的情况
KERN_INFO 一条非正式的消息
KERN_DEBUG 一条调试信息——一般是冗余信息
如果你没有特别指定一个记录等级,函数会选用默认的DEFAULT MESSAGE LOGLEVEL,现在默认等级是KERN_ WARNING。
由于这个默认值将来存在变化的可能性,所以还是应该给自己的消息指定一个记录等级。
内核将最重要的记录等级KERN_EMERG定为“<0>”,将无关紧要的记录等级“KERN_DEBUG"定为“<7>”。
举例来说,当编译预处理完成之后,前例中的代码实际被编译成如下格式:
printk("<4> This is a warning!\n") ;
printk("<7> This is a debug notice! \n") ;
printk("<4> did not specify a loglevel!\n") ;
1
2
3
怎样给调用的printk()赋记录等级完全取决于自己。那些正式的、需要你保持的消息应该有合适的记录等级。但是那些当你试图解决一个问题时加得到处都是的调试信息(必须承认,我们都这么干而且也确实行得通),可以按照你的想法赋予记录等级。一种选择是保持终端的默认记录等级不变,给所有调试信息KERN_ CRIT或更低的等级。相反,也可以给所有调试信息KERN_ DEBUG等级,而调整终端的默认记录等级。两种方法各有利弊,自己拿主意吧。
3)记录缓冲区
内核消息都被保存在一个LOG_BUF_LEN大小的环形队列中。该缓冲区大小可以在编译时通过设置CONFIG_LOG_BUF_SHIFT进行调整。
在单处理器的系统上其默认值是16KB。换句话说,就是内核在同一时间只能保存16KB的内核消息。如果消息队列已经达到最大值,那么如果再有printk() 调用时,新消息将覆盖队列中的老消息。这个记录缓冲区之所以称为环形,是因为它的读写都是按照环形队列方式进行操作的。
使用环形队列有许多好处。由于同时读写环形缓冲区时,其同步问题很容易解决,所以即使在中断上下文中也可以方便地使用printk()。 此外,它使记录维护起来也更容易。如果有大量的消息同时产生,新消息只需覆盖掉旧消息即可。在某个问题引发大量消息的时候,记录只会覆盖掉它本身,而不会因为失控而消耗掉大量内存。而环形缓冲区的唯一缺点——可能会丢失消息,但是与简单性和健壮性的好处相比,这点代价是值得的。
4)syslogdo和klogd
在标准的Linux系统上,用户空间的守护进程klogd 从记录缓冲区中获取内核消息,再通过syslogd守护进程将它们保存在系统日志文件中。
klogd 程序既可以从/proc/kmsg文件中,也可以通过syslog()系统调用读取这些消息。默认情况下,它选择读取/proc方式实现。不管是哪种方法,klogd 都会阻塞,直到有新的内核消息可供读出。在被唤醒之后,它会读取出新的内核消息并进行处理。默认情况下,它就是把消息传给syslogd守护进程。
syslogd守护进程把它接收到的所有消息添加进一个文件中,该文件默认是/ar/log/messages。也可以通过/etc/syslog.conf配置文件重新指定。
在启动klogd的时候,可以通过指定-c标志来改变终端的记录等级。
5)从printf()到printk()的转换
当刚开始开发内核代码的时候,往往会把printk()输入成printf()这很正常,你无法抗拒多年来在用户级程序中使用printf()的习惯。幸而这种错误不会持续很长时间,反复出现的链接错误很快就会让你在心烦意乱中开始培养新的习惯。在编写用户级程序的时候,你输入printf()的时候不小心输入了printk()。 恭喜你,成为一个真正的内核黑客的时刻终于到来了。
18.4oops
oopss是个拟声词, 类似 “哎哟” 的意思.。oops是内核告知用户有不幸发生的最常用的方式。
由于内核是整个系统的管理者,所以它不能采取像在用户空间出现运行错误时使用的那些简单手段,因为它很难自行修复,它也不能将自己杀死。
内核只能发布ops。这个过程包括向终端上输出错误消息,输出寄存器中保存的信息并输出可供跟踪的回溯线索。内核中出现的故障很难处理,所以内核往往要经历严峻的考验才能发送出oops和靠它自己完成的一些清理工作。
通常,发送完oops之后,内核会处于一种不稳定状态。举例来说,oops发生的时候内核可能正在处理非常重要的数据。它可能持有一把锁或正在和硬件设备交互。内核必须适当地从当前的上下文环境中退出并尝试恢复对系统的控制。多数时候,这种尝试都会失败。因为如果oops在中断上下文时发生,内核根本无法继续,它会陷入混乱。混乱的结果就是系统死机。如果oops在idle进程(pid 为0)或init进程(pid 为1)时发生,结果同样是系统陷人混乱,因为内核缺了这两个重要的进程根本就无法工作。不过,要是oops在其他进程运行时发生,内核就会杀死该进程并尝试着继续执行。
oops的产生有很多可能原因,其中包括内存访问越界或者非法的指令等。作为一个内核开发者,你将会经常处理(毫无疑问,也将导致) oops。
紧接着的是一个oops的实例,它是在一台PPC机器上的tulip网卡的定时器处理函数运行时发生的:
使用PC的读者可能对这么多的寄存器感到惊奇(居然有32个之多)。你可能对x86-32系统更熟悉一些,在这种系统上,oops 会简单一点。但是,oops 中包含的重要信息对于所有体系结构都是完全相同的:寄存器上下文和回溯线索。
回溯线索显示了导致镨误发生的函数调用链。这样我们就可以观察究竟发生了什么:机器处于空闲状态,正在执行idle循环,由cpu_ idle() 循环调用default_ idle()。 此时定时器中断产生了,它引起了对定时器的处理。tulip_timer()这个定时器处理函数被调用,而就是它引用了空指针。甚至可以通过偏移量(像0128/0x1c4这些出现在函数左侧的数字)找出导致问题的语句。
寄存器上下文信息可同样有用,尽管使用起来不那么方便。如果你有函数的汇编代码,这些寄存器数据可以帮助你重建引发问题的现场。在寄存器中发现一个本不应该出现的数值可能会在黑暗中给你带来第一丝光明。在上面的例子中,我们可以查看是哪个寄存器包含了NULL (一个所有位都为零的数值),进而找出是函数的哪个变量的值不正常。一般在这种情况下问题往往是竞争引起的,在本例中,是指定时器和这块网卡驱动的其他部分之间的竞争。调试一个竞争条件往往很有挑战性。
1)ksymoops
前面列举的oops可以说是一个经过解码的oops,因为内存地址都已经转换成了它们对应的函数。下面是其未解码版本:
回溯线索中的地址需要转化成有意义的符号名称才方便使用。这需要调用ksymoops命令,并且还必须提供编译内核时产生的System.map。如果使用的是模块,还需要一些模块信息。ksymoops通常会自行解析这些信息,所以一般可以这样调用它:
ksymoops saved_oops . txt
1
然后该程序就会吐出解码版的oops。如果ksymoops无法找到默认位置上的信息,或者想提供不同信息,该程序可以接受许多参数。ksymoops 的使用手册上提供了许多说明信息,使用之前最好先行查阅。ksymoops一般会随Linux发行版本提供。
2)kallsyms
谢天谢地,现在已经无须使用ksymoops工具了,这是一个了不起的工作。因为尽管开发者使用它的时候一般很少出现问题,但是最终用户常常会错误地匹配System.map文件或错误地对oops进行解码。
在2.6版的内核中,为了更方便的调试内核代码,开发者考虑将内核代码中所有函数以及所有非栈变量的地址抽取出来,形成是一个简单的
kallsyms:优化查找符号的性能
数据块(data blob:符号和地址对应),并将此链接进 vmlinux 中去。
在需要的时候,内核就可以将符号地址信息以及符号名称都显示出来,方便开发者对内核代码的调试。完成这一地址抽取+数据快组织封装功能的相关子系统就称之为 kallsyms。
反之,如果没有 kallsyms 的帮助,内核只能将十六进制的符号地址呈现给外界,因为它能理解的只有符号地址,并不能显示各种函数名等符号。
kallsyms抽取了内核用到的所有函数地址(全局的、静态的)和非栈数据变量地址,生成一个数据块,作为只读数据链接进kernel image,相当于内核中存了一个System.map。
相应地,解码oops也不再需要System.map或者ksymoops工具了。但是,这样做会使内核变大一些,因为从函数的地址到符号名称的映射必须永久地驻留在内核所映射的内存地址上。
然而,不管是在开发的过程中还是在部署的过程中,占用这些内存都是值得的。配置选项CONFIG_KALLSYMS_ALL表示不仅存放函数名称,还存放所有的符号名称。但一般只有那些特殊的调试器才会有此需要。
CONFIG_ KALLSYMS_ EXTRA_PASS选项会引起内核构建过程中再次忽略内核的目标代码。这个选项只有在调试kallsyms本身时才会有用。
18.5内核调试配置选项
在编译的时候,为方便调试和测试内核代码,内核提供了许多配置选项。
这些选项都在内核配置编辑器的内核开发(Kernel hacking)菜单项中,它们都依赖于CONFIG_DEBUG_KERNEL。当开发内核的时候,作为一种练习,不妨打开所有这些选项。
有些选项确实有用应该启用
slab layer debugging (slab 层调试选项)、
high memory debugging(高端内存调试选项)、
IO mapping debugging (I/O映射调试选项)、
spin-lock debugging (自旋锁调试选项)、
stack overflow checking (栈溢出检查选项)。
其中最有用的一个是sleep- inside -spinlockchecking (自旋锁内睡眠选项),这些选项确实能完成不少调试工作。
从2.5版开始,为了检查各类由原子操作引发的问题,内核提供了极佳的工具。回忆一下第9章,原子操作指那些能够不分隔执行的东西;在执行时不能中断否则就是完不成的代码。正在使用一个自旋锁或禁止枪占的代码进行的就是原子操作。在进行此类操作的时候,代码不能睡眠——使用锁时睡眠是引发死锁的元凶。
托内核抢占的福,内核提供了一个原子操作计数器。它可以被配置成一旦在原子操作过程中进程进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。所以,包括正使用锁的时候调用sdedule0,正使用锁的时候以阻塞方式请求分配内存和在引用单CPU数据时睡眠在内,各种潜在的bug都能够被探测到。这种调试方法捕获了大量bug,它也受到了大家极力推荐使用。
下面这些选项可以最大限度地利用该特性:
CONFIG_PREEMPT=y
CONFIG_DEBUG_KERNEL=y
CONFIG KALLSYMS=y
CONFIG_DEBUG_SPINOCK_SLEEP=Y
18.6引发bug并打印信息
a)BUG()和BUG_ON()
一些内核调用可以用来方便标记bug,提供断言并输出信息。断言是非常常见的,其语义就是判断某个条件,如果不为真,就执行一段非常规的动作,一般为程序立马结束运行。
最常用的两个是BUG()和BUG_ON()。当被调用的时候,它们会引发oops,导致栈的回溯和错误信息的打印。
这些声明会导致oops跟硬件的体系结构是相关的。大部分体系结构把BUG()和BUG_ON()定义成某种非法操作,这样自然会产生需要的oops。可以把这些调用当做断言使用,想要断言某种情况不该发生:
if (bad. thing)
BOGO) ;
或者使用更好的狱:
BUG_ON(bad_thing);
多数内核开发者相信BUG_ON()比BUG()更清晰、更可读,而且BUG_ON()会将其声明作为一个语句放入unlikely()中。
请注意,有些开发者在讨论是否能用一个编译选项将BUG_ON()声明在编译时剔除,以便能在嵌入内核中节约空间。这就意味着你可以放心地使用BUG_ON(),而不用担心BUG_ON()内的声明可能带来的任何“不良反应"。
BUILD_BUG_ON()与BUG_ON()作用相同,仅在编译时调用。如果在编译阶段已提供的声明为真,那么编译将会因为一个错误而中止。
b)panic()
可以用panic()引发更严重的错误。调用panic()不但会打印错误消息,而且还会挂起整个系统。显然,只应该在最糟糕的情况下使用它:
if (terrib1e_ thing)
panlcl"terrible thing is 1d\n",terrible thing) ;
c)dump. stack()
有些时候,只是需要在终端上打印一下栈的回潮信息来帮助调试。这个时候,dump. stack()就很有用了。它只在终端上打印寄存器上下文和函数的跟踪线索:
if (!debug_ check) {
printk (KERN DEBUG “provide some informatlon…\n”); .
dump stack{) ;
18.7神奇的系统请求键
神奇的系统请求键(Magic SysRq key)是另外一根救命稻草,该功能可以通过定义CONFIG_MAGIC_SYSRQ配置选项来启用。
SysRq (系统请求)键在大多数键盘上都是标准键。在i386和PPC上,它可以通过ALT-PrintScreen访问。
当该功能被启用的时候,无论内核处于什么状态,都可以通过特殊的组合键跟内核进行通信。这种功能可以让你在面对一台奄奄一息的系统时能完成一些有用的工作。
除了配置选项以外,还要通过一个sysctl用来标记该特性的开或关。需要启用它时使用如下命令:
echo 1 > /proc/ sys/kernel/sysrq
从终端上,你可以输入Sysrq-h获取一份可用的选项列表。SysRq-s将“脏”缓冲区跟硬盘交换分区同步,SysRq-u 卸载所有的文件系统,SysRq-b 重启设备。在一行内发送这三个键的组合可以重新启动濒临死亡的系统,这比直接按下机器的Reset键要安全一些。
如果机器已经完全锁死了,它也可能不会再响应神奇系统请求键,或者无法完成给定的命令。
不过如果运气稍好的话,这些选项或许可以保存数据或者进行调试。表18-2 列举了所有支持的系统请求命令。
表18-2支持 SysRq的命令
主要命令 描述
SysRq-b 重新启动机器
SysRq-e 向init以外的所有进程发送SIGTERM信号
SysRq-h 在控制台显示SysRq
SysRq-i 向init以外的所有进程发送SIGKILL信号
SysRq-k 安全访问键:杀死这个控制台上的所有程序
SysRq-l 向包括init的所有进程发送SIGKILL信号
SysRq-m 把内存信息输出到控制台
SysRq-o 关闭机器
SysRq-P 把寄存器的信息输出到控制台
SysRq-T 关闭键盘原始模式
SysRq-s 把所有已安装文件系统都刷新到磁盘
SysRq-t 把任务信息输出到控制台
SysRq-u 卸载所有已加载文件系统
内核代码中的Documentation/sysrq.txt对此有更详细的说明。实际的实现在drivers/char/sysrq.c中。神奇系统请求键是调试和挽救垂危系统所必需的一种工具。由于该功能对终端上的任何用户都提供服务,所以在重要的机器上启用它需要三思而行。可是对于自己用于开发的机器,启用它确实帮助很大。
18.8内核调试器的传奇
很多内核开发者一直以来都希望能拥有一个用于内核的调试器。不幸的是,Linus 不愿意在它的内核源代码树中加入一个调试器。他认为调试器会误导开发者,从而导致引入不良的修正。
没有人能对他的逻辑提出异议——从真正理解代码出发,确实更能保证修正的正确性。然而,许多内核开发者们还是希望有一个官方发布的、用于内核的调试器。因为这个要求看起来不会马上被满足,所以许多补丁应运而生了,它们为标准内核附加上了内核调试的支持。虽然这都是一些不被官方认可的附加补丁,但它们确实功能完善,十分强大。在我们深入这些解决方案之前,先看看标准的Linux调试器gdb能够给我们一些什么帮助是一个不错的选择。
1)官方调试器gdb
可以使用标准的GNU调试器对正在运行的内核进行查看。针对内核启动调试器的方法与针对进程的方法大致相同:
gdb vml inux /proc/kcore
其中vmlinux文件是未经压缩的内核映像,不是压缩过的zImage或bzlmage,它存放在源代码树的根目录上。
其中/proc/kcore作为一个参数选项,是作为core文件来用的,通过它能够访问到内核驻留的高端内存。只有超级用户才能读取此文件的数据。
可以使用gdb的所有命令来获取信息。
举个例子,为了打印一个变量的值,你可以用下面的命令:P global_ variable
反汇编一个函数:di sassemble function
如果编译内核的时候使用了-g参数(在内核的Makefile文件的CFLAGS变量中加入-g),gdb还可以提供更多的信息。比如,你可以打印出结构体中存放的信息或是跟踪指针。
当然,编译出的内核会大很多,所以不要把编译带调试信息的内核当做一种习惯。
接下来,就要说不幸的那一面了, gdb还是有很多局限性的。
它没有任何办法修改内核数据。它也不能单步执行内核代码,不能加断点。不能修改内核数据是个非常大的缺陷。尽管在必要时反汇编函数无疑是个非常有用的功能,但是能够修改数据的却更为有用。
2)补丁kgdb
kgdb是一个补丁,它可以让我们在远端主机上通过串口利用gdb的所有功能对内核进行调试。
这需要两台计算机:
第一台运行带有kgdb补丁的内核,
第二台通过串行线(不通过modem,直接连接两台机器的电缆)使用gdb对第一台进行调试。
通过kgdb,gdb的读取或修改变量值,设置断点,设置关注变量,单步执行等所有功能都能使用。(某些版本的gdb甚至允许执行函数。)
设置kgdb和连接串行线比较麻烦,但是一旦做完了,调试就变得很简单了。
该补丁会在Documentation/目录下安装很多说明文件,可以把它们挑出来研究一下。
不同体系结构、不同内核版本使用的kgdb由不同的人员维护,为了给需要调试的内核找到合适的补丁,还是在网上搜索一下比较好。
18.9 探测系统
如果对内核调试有丰富的经验的话,那么你会掌握一些诀窍来帮助你更进一步地探测系统从而找到想要的答案。内核调试很有挑战性,即使是一点小的暗示或者技巧都能给你很大的帮助。我们最好把它们联系起来。
1)用UID作为选择条件
如果你开发的是进程相关的部分,有些时候,你可以在提供替代物的同时不打破原有代码的可执行性。这在你重写重要系统调用的时候,或者在你希望进行调试时系统功能依旧健全的情况下非常有用。
举个例子,假设为了加入一个激动人心的新特性,你重写了fork()系统调用。除非第一次的尝试就完美无缺,否则系统调试就是一场噩梦。如果fork()系统调用不正常的话,压根就不用指望整个系统还能正常工作。当然,和任何时候一样,希望总是存在的。
一般情况下,只要保留原有的算法而把你的新算法加入到其他位置上,基本就能保证安全。可以利用把用户id (UID) 作为选择条件来实现这种功能,通过这种选择条件,可以安排到底执行哪种算法:
if (current->uid 1= 7777) {
/*老算法... */
} else {
/*新算法... */
1
2
3
4
除了UID为7777以外,其他所有的用户都用的是老算法。可以创建一个UID为7777的用户,专门来测试新算法。对于要求很严格的进程相关部分的代码来说,这种方法使得测试变得容易了许多。
2)使用条件变量
不使用UID,使用全局变量。
如果代码与进程无关,或者希望有一个针对所有情况都能使用的机制来控制某个特性,可以使用条件变量。这比使用UID还来得简单,只需要创建一个全局变量作为一个条件选择开关。如果该变量为零,就使用一个分支上的代码。如果它不为零,就选择另外-一个分支。可以通过某种接口提供对这个变量的操控,也可以直接通过调试器进行操控。
3)使用统计量
有些时候你需要掌握某个特定事件的发生规律。有些时候需要比较多个事件并从中得出规律。通过创建统计量并提供某种机制访问其统计结果,很容易就能满足这种需求。
举个例子,假设我们希望得到foo和bar的发生频率,那么在某个文件中,当然最好是在定义该事件的那个文件里,定义两个全局变量:
unsigned long foo_ stat = 0;
unsigned long bar_ stat = 0;
每当事件发生的时候,就让相应的变量加1。然后在觉得合适的地方输出它。比如,可以在/proc目录中创建一个文件,还可以新创建一个系统调用。最简单的办法当然还是通过调试器直接访问它们。
注意,这种实现并非是SMP安全的。理想的办法是通过原子操作进行实现。但是仅仅对于一个简单的每次加1的调试统计量,一般无须搞得这么麻烦.
4)重复频率限制
为了发现一个错误,开发者们往往在代码的某个部分加入很多错误检查语句(多数对应的都是一些打印语句)。在内核中,有些函数每秒都要被调用很多次。如果你在这样的函数中加入了prink(),那么系统马上就会被显示调试信息这-一个任务压得喘不过气来,很快就什么也干不成了。
有两种相关的技巧可以用来防止此类问题的发生。
不观察实时通知
第一种是重复频率限制如果某种事件发生的非常频繁,而又需要观察它的整体进展情况,就可以让这种技巧施展身手了。为了避免调试信息发生井喷,可以每隔几秒执行一次打印(或者是其他任何你想完成的操作)。举个例子: .
static unsigned long prev_ jiffy_jiffies;/频率限制/
if (time_ after(jiffies, prev_ jiffy + 2*HZ)) {
prev_ jiffy = jiffies;
printk (KERN_ ERR “blah blah blah\n”) ;
}
此例中,调试信息最多两秒打印一-次。这可以让你的终端不至于被汹涌而至的调试信息洪流充塞,也保证你的系统依旧能用。完全可以根据自己的需要,或低或高地调整这种重复频率。
如果只使用printk),可以用一个特殊的函数去限制printk()的调用频率:
if (error && printk_ ratelimit())
printk (KERN_ DEBUG “error=id\n”, error) ;
如果频率限制生效,那么printk_ ratelimit() 返回0;否则,返回非0。默认情况下,此函数限制每5秒产生一条信息,但是在施加这一条件之前,可以让起始频率为10条信息。可以通过printk_ ratelimit 和printk_ratelimit _burst sysctl来调整这些参数。
观察实时通知
另一种棘手的问题是你如何确认在特定情况下某段代码确实被执行了。与前面的例子不同,你想观察的不是一个实时通知。如果这种通知在被触发-一次之后依旧不停地到来,那就比较麻烦了。下面这种技巧针对的就不再是如何限制重复频率了,它要实现的是发生次数限制。
static unsigned 1ong limit = 0;
if (limit < 5) {
limit++i
printk (KERN_ ERR “blah blah blah\n”) ;
此例中,调试信息输出5次就封顶了。5次之后,打印条件总是不能成立。不管是上面提到的哪个示例,用到的变量都应该是静态的(static), 并且应该限制在函数的局部范围以内,这样才能保证变量的值在经历多次函数调用后仍然能够保留下来。
这些例子的代码都不是SMP或抢占安全的,不过,只需要用原子操作改造一下就没问题了。不过,对于一个临时的调试检测来说,没必要搞得这么复杂。
18.10用二分童找法找出引发罪恶的变更
知道bug是什么时候引入内核源代码的通常都是很有用的。如果你知道2.6.33版中出现了一个bug,而能肯定2.4.29中没有,那么就能够很容易地对引发这个bug的代码变更进行定位。消灭bug变得唾手可得——要么取消这个变更,要么对其进行修正。
可是,很多时候并不知道到底是哪个内核版本引入了bug。你知道当前版本里bug是确确实实存在的,不过,它好像就是存在于当前版本中。只需要花一点点力气,就能找出引发问题的代码变更了。元凶在手,消灭bug就指日可待了。
一开始,需要一个可靠的可复制的错误,最好是系统一启动就能查证的bug。接下来,需要一个能确保没问题的内核(你应该能够找到)。举个例子,你知道几个月前的内核没有这种错误,那么就从那时使用的内核中选取一个。如果发现问题,说明那时就存在了,那就找更早的。找到不含该bug的内核应该不会太难。
接下来需要一个肯定有问题的内核。为了简单起见,应该从已知最早出现该问题的内核开始。现在,你就可以在问题内核和良好的内核之间使用二分法了。举个例子,假定确保没有问题的内核版本是2.6.11,有问题的内核版本是2.6.20。从二者的正中选取一个内核版本,比如说2.6.15。检查2.6.15是否包含此bug。如果2.6.15没有问题,那么就知道错误是发生在此版本之后了。所以,再从2.6.15开始,在它和2.6.20正中选取下一个版本,比如说对2.6.17进行检查。如果2.6.15有问题,那么错误就可能发生在此版本之前了,那么就该选2.6.13作为下-一个待查目标了。就这样重复筛选。
最终你肯定能把问题局限在两个相继发行的版本之间——一个包含错误而另外一个不包含。
你就能够很容易地对引发这个bug的代码变更进行定位。
这种方式比依次对每个版本的内核进行核查要好得多。
18.11使用Gt进行二分搜索
Git源码管理工具提供了一个有用的二分搜索机制。如果你使用Git来控制Linux源码树的副本,那么Git将自动运行二分搜索进程。此外,Git 会在修订版本中进行二分搜索,这样可以找到具体哪次提交的代码引发了bug。很多Git相关的任务比较繁杂,但使用Git进行二分搜索并不那么的困难。
一开始,你得告诉Git你要进行二分搜索:
$ git bisect start
然后再为Git提供一个出现问题的最早内核版本:
$ git bisect bad
如果当前的内核版本就是引发bug的罪魁祸首,那么就不必提供内核版本:
$ git bisect bad
然后,还得为Git提供一个最新的可正常运行的内核版本:
$ git bisect good v2.6.28
接下来,Git将会利用二分搜索法在Linux源码树中,自动检测正常的内核版本和有bug的内核版本之间哪个版本有隐患。接着再编译、运行以及测试正被检测的版本。如果这个版本一切正常,可以运行下面的命令:
$ git bisect good
如果这个版本运行有异常——也就是说, 如果证明这个给定的内核版本有bug,可以运行:
$ git bisect bad
对于每一条命令,Git 将在每一个版本的基础上反复二分搜索源码树,并且返回所查的下一个内核版本。这个过程需要反复执行直到不能再进行二分搜索为止。Git 将最终打印出有问题的版本号。
这本应该是一个漫长的过程,但是Git使得这一过程变得容易起来。如果你已经知道引发bug的源(比如,x86 机型的启动代码),你可以指定git仅仅在与错误相关的目录列表中去二分搜索提交的补丁。
$ git bisect start - arch/x86
18.12 当所有的努力都失败时:社区
或许你已经做完了所有你能想到的尝试。你在键盘上呕心沥血了几个小时一实际 上,可能是无数日子,答案依旧没有眷顾你。此时,如果bug是在Linux内核的主流部分中,你可以在内核开发社区中寻求其他开发者的帮助。
你应该向内核邮件列表发送一份 电子邮件,对bug进行完整而又简洁地描述,你的发现可能会对找到最终的答案起到帮助。毕竟,没人希望bug存在。