《Linux内核设计与实现》第十八章读书笔记

第十八章——内核调试

一、 准备开始

需要:

1.一个bug。
2.一个藏匿bug的内核版本。
3.相关内核代码的知识和运气。

在用户级的程序里,bug常常表现得很直截了当;但在内核中却不那么清晰。

二、 内核中的bug

内核中bug多种多样,不仅产生原因千奇百怪,表象也变化多端。如:

  • 明白无误的错误代码(例如没有把正确的值存放在恰当的位置);

  • 同步时发生错误(例如共享变量锁定不当);

  • 错误的管理硬件(例如给错误的控制器发送错误的指令);

  • 系统处于死锁状态等等。

从隐藏在源代码中的错误到展现出来的bug,往往是经历一系列连锁反应的时间才可能触发的。

内核调试有一些需要考虑的独特问题,像定时限制和竞争条件,它们都是允许多个线程在内核中同时运行产生的结果。

三、 通过打印来调试

内核打印函数printk()和C库的printf()功能几乎相同。printk()就是内核的格式化打印函数,但还有一些特殊功能:

1. 健壮性

弹性极佳,随时可以被调用。但终端还没有初始化之前,在某些地方不能使用printk()。

所以,除非在启动过程的初期就要在终端上输出,否则可以认为printk()在什么情况下都能工作。

2. 日志等级

Printk()可以指定一个日志级别。内核根据这个级别来判断是否在终端上打印消息。内核把级别比某个特定值低的所有消息显示在终端上。

指定一个记录级别:

printk(KERN_WARNING “This is a warning!\n”);
printk(KERN_DEBUG “This is a debug notice!\n”);
printk(“I did not specify a loglevel!\n”);

解释:KERN_WARNING和KERN_DEBUG都是<linux/kernel.h>中的简单宏定义。它们扩展开是像“<4>”或“<7>”这样的字符串,加进printk()函数要打印的消息的开头。内核用这个指定的记录等级和当前终端的记录等级console_loglevel来决定是不是向终端上打印。

  • 如果没有指定记录等级,会选默认的DEFAULT_MESSAGE_LOGLEVEL,现在默认等级是KERN_WARNING。

  • 内核将最重要的记录等级KERN_EMERG定为“<0>”,将无关紧要的记录等级“KERN_DEBUG”定为“<7>”。

3. 记录缓冲区

内核消息都被保存在一个LOG_BUF_LEN大小的环形队列中。该缓冲区大小可以在编译时通过设置CONFIG_LOG_BUF_SHIFT进行调整。内核在同一时间只能保存16KB的内核消息 。若已达最大值,再有pringk()调用时,新消息会覆盖老消息。

  • 好处:

(1)同时读写环形缓冲区,同步问题容易解决。在中断上下文中也可以方便地使用printk()。

(2)记录和维护更容易。大量的消息同时产生,新消息会覆盖掉老消息。

  • 缺点:可能会丢失消息。

4. syslogd和klogd

标准的Linux系统上,用户空间的守护进程klogd从记录缓冲区中获取内核消息,在通过syslogd守护进程将它们保存在系统日志文件中。klogd程序既可以从/proc/kmsg文件中(默认方式),也可以通过syslog()系统调用读取这些消息。

Klogd会阻塞,直到有新的内核消息可供读出。在被唤醒后,读取出新的内核消息并进行处理。默认情况下,它就是把消息传给syslog守护进程。

syslog守护进程把它接收到的所有消息添加到一个文件中(默认为/war/log/messages,也可通过/etc/syslog.conf配置文件重新指定)。

在启动klpgd时,可通过指定-c标志来改变终端的记录等级。

5. 从printf()到printk()的转换

慢慢养成顺手写printk()的习惯。

四、 oops

  • oops是内核告知用户有不幸发生的最常用的方式。
  • 内核只能发布oops,包括向终端上输出错误消息,输出寄存器中保存的信息并输出可供跟踪的回溯线索。发完oops后,内核会处于不稳定状态。
  • Oops的产生原因很多,包括内在访问越界或者非法的指令等。
  • oops中包含的重要信息对于所有体系结构都是完全相同的:寄存器上下文和回溯线索(显示导致错误发生的函数调用链)。

1. ksymoops

调用ksymoops命令并提供变异内核是产生的System.map将回溯线索中的地址转化有意义的符号名称。调用:ksymoops saved_oops.txt。

2. Kallsyms

(1) Kallsyms特性可以通过定义CONFIG_KALLSYMS配置选项(存放函数名称和所有的符号名称)启用。该选项存放着内核镜像中相应函数地址的符号名称,所以内核可以打印解码好的跟踪线索。

(2) CONFIG_KALLSYMS_EXTRA-PASS选项会引起内核构建过程中再次忽略内核的目标代码。这个选项只有在调试kallsyms本身时才会有用。

五、 内核的调试配置选项

配置选项都在内核配置编辑器的内核开发菜单项中,都依赖于CONFIG_DEBUG_KERNEL。

有用的选项:

slab layer debugging(slab层调试选项)
high-memory debugging(高端内存调试选项)
I/O mapping debugging(I/O映射调试选项)
spin-lock debugging(自旋锁调试选项)
stack-overflaw checking(栈溢出检查选项)
sleep-inside-spinlock checking(自旋锁内睡眠选项)<最有用>

内核提供原子操作计数器。可以被配制成一旦在原子操作过程中进程进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。这种调试方法可以捕获大量bug。

  • 下面这些选项可以最大限度地利用该特性:

    CONFIG_PREEMPT=y
    CONFIG_DEBUG_KERNEL=y
    CONFIG_KALLSYMS=y
    CONFIG_DEBUG_SPINOCK_SLEEP=y

六、 引发bug并打印信息

  • BUG()和BUG_ON():标记bug,提供断言并输出信息。当被调用的时候,会引发oops,导致栈的回溯和错误信息的打印。

  • 大部分体系把BUG()和BUG_ON()定义成某种非法操作,这样自然会产生需要的oops。可以把这些调用当作断言使用,想要断言某种情况不该发生:

      If (bad_thing)
           BUG();
    

    或者更好的形式:

      BUG_ON(bad_thing);
    
  • BUG_ON()比BUG()更清晰、更可读。BUG_ON()会将其声明作为一个语句放入unlikely()中。BUILD_BUG_ON()与BUG_ON()作用相同,仅在编译时调用。如果在编译阶段已提供的声明为真,那么编译将会因为一个错误而中止。

  • panic():引发更严重的错误。调用panic()不但会打印错误消息,而且会挂起整个系统。在最糟糕的情况下使用它:

      if (terriblr_thing)
          panic(“terrible thing is %ld\n”,terrible_thing); 
    
  • dump_stack():在终端上打印栈的回溯信息帮助调试。它只在终端上打印寄存器上下文和函数的跟踪线索:

      if  (!debug_check) {
          printk(KERN_DEBUG “provide some information…\n”);
          dump_stack();
      }
    

七、 神奇的系统请求键

  • 神奇的系统请求键可以通过定义CONFIG_MAGIC_SYSRQ配置选项来启用。启用后,无论内核处于什么状态,都可以通过特殊的组合键跟内核进行通信。

  • SysRq(系统请求键)在大多数键盘上都是标准键。在i386和PPC上,可以通过ALT-PrintScreen访问。

  • 通过sysctl标记该特性的开或关,启用命令:echo 1 > /proc/sys/kernel/sysrq

  • 重启濒临死亡的系统:SysRq-s,SysRq-u,SysRq-b组合

八、 内核调试器的传奇

1. gdb

  • 针对内核启动调试器:gdb vmlinux /proc/kcore

      解释:vmlinux文件是未经压缩的内核映像,存放在源代码树的根目录上。
           /proc/kcore参数选项,作为core文件来用,通过它能够访问到内核驻留的高端内存。只有超级用户才能读取此文件的数据。
    
  • 打印变量值:p global_variable;

  • 反汇编一个函数:disassemble function;

  • gdb没有任何办法修改内核数据,也不能单步执行内核代码,不能加断点,不能修改内核数据。

2. kgdb

kgdb是一个补丁,可以在远端主机上通过串口利用gdb的所有功能对内核进行调试。该补丁会在Documentation/目录下安装很多说明文件。需要两台计算机:第一台运行带有kgdb补丁的内核,第二胎通过串行线使用gdb对第一台进行调试。

九、探测系统

1.用UID作为选择条件

一般情况下,只要保留原有的算法把新算法加入到其他位置上,就能保证安全。可利用用户id(UID)作为选择条件实现:

if (current ->uid !=7777){
     /*老算法…*/
} else {
     /*新算法…*/
}

解释:除了UID为7777外其他所有的用户都用的是老算法。可以创建一个UID为7777的永固专门用来测试新算法。

2.使用条件变量

代码与进程无关或需要一个针对所有情况都能使用的机制来控制某个特性,可以使用条件变量。只需要创建一个全局变量作为一个条件选择开关。若变量为0,就使用一个分支上的代码;如果不为0,就选择另一个分支。可以通过某种接口或调试器对这个变量操控。

3. 使用统计量

通过创建统计量并提供某种机制访问其统计结果,可以满足:掌握某个特定事件的发生规律;比较多个事件并从得出规律。

4. 重复频率限制

为了防止系统太过繁忙,两种技巧:

(1) 重复频率限制

(2) 发生次数限制

九、 用二分查找法找出引发罪恶的变更

一开始需要一个可靠的可复制的错误。接下来,需要一个确保没问题的内核和一个肯定有问题的内核。然后在问题内核和良好内涵之间使用二分法,重复筛选直至局限在两个相继发行的版本之间,从而容易定位。

十、使用Git进行二分搜索

  • Git bisect start //告诉Git进行二分搜索
  • Git bisect bad //为Git提供一个出现问题的最早内核版本
  • Git bisect bad //如果当前的内核版本就是引发bug的罪魁祸首,那么就不必提供内核版本
  • Git bisect good v2.6.28 //为Git提供一个最新的可正常运行的内核版本
  • Git bisect good/bad版本运行正常或是异常
  • 如果已经知道引发bug的源,可以指定git仅仅在错误相关的目录列表中二分搜索提交的补丁:git bisect start – arch/x86。

十一、当所有的努力都失败时:社区

我们可以在内核开发社区中寻求其他开发者的帮助。以后会重点推荐社区和最重要的论坛——Linux内核邮件列表(LKML)。

小结:

调试过程其实是一个漫长又复杂的过程,是一种寻求实现与目标偏差的行为。从内核内置的调试架构到调试程序,从记录日至盗用Git二分法查找等。感觉会困难重重,与调试用户程序完全不一样。通过这章学习,我现在也只是了解了一些皮毛,还应该再加以深入理解并多加尝试,慢慢体会。

posted on 2016-03-31 11:36  20135318刘浩晨  阅读(195)  评论(0编辑  收藏  举报