向下之旅(二十五):调试

  内核中的bug和用户空间应用程序中bug一样多种多样。它们的产生可以有无数的原因,同时它们的表象也变化多端。从明白无误的错误代码(比如,没有把正确的值存放在恰当的位置)和同步时发生的错误(比如,共享变量锁不当),都是bug的温床。从降低所有东西的运行性能到毁坏数据,都可能是bug发生时的症状。

  printk()

  内核提供的打印函数printk(),和C库提供的printf()函数功能几乎相同。不过差异确实也是存在的。

  健壮性是printk()的一个特点。任何时候,任何地方都能调用它,内核中的prink()比比皆是。可以在中断上下文中和进程上下文中调用,可以在持有锁的时候调用,可以在多处理器上同时调用,而且调用者连锁都不必使用。

  不过在系统启动过程中,终端还没有初始化之前,在某些地方不能使用它。此时可以使用early_printk()函数,不过该函数缺少移植性,在一些内核支持的硬件体系结构上无法实现。

  记录等级

  printk()和printf()一个最主要的区别就是前者可以指定一个记录级别。内核根据这个级别来判断是否在终端上打印消息。内核把级别比某个特定值低的所有信息显示在终端上。通过下面的方式指定一个记录级别:

  

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

  若未指定一个记录等级,函数会选用默认的DEFAULT_MESSAGE_LOGLEVEL,现在默认的等级是KERN_WARNING。最重要的记录等级KERN_EMERG定为"<0>"。

  记录缓冲区

  内核消息都被保存在一个LOG_BUF_LEN大小的环形队列中。该缓冲区大小可以在编译时通过CONFIG_LOG_BUF_SHIFT进行调整。在单处理器的系统上其默认的是16kb,当消息队列已经达到最大值,那么如果再有printk()调用时,新消息将覆盖队列中的老消息。

  syslogd和klogd

  在标准的Linux系统上,用户空间的守护进程klogd从记录缓冲区中获取内核信息,再通过syslogd守护进程将它们保存在系统日志文件中。klogd程序既可以从/proc/kmsg文件中也可以通过syslog()系统调用读取这些信息。默认情况下,它选择读取/proc方式实现。不管是哪种方法,知道有新的内核信息可供读出,klogd都会阻塞。在被唤醒之后,它会读取新的内核信息进行处理。默认方式下,处理例程就是把消息传给syslogd守护进程。

  syslogd守护进程把它收到的所有信息加进一个文件中。该文件默认是/var/log/messages。你也可以通过/etc/syslog.conf配置文件重新指定。

  oops

  oops是内核告知用户有不幸发生的最常用的方式。内核空间出现错误很难自行修复,它也不能将自己杀死。内核只能发布oops。这个过程包括向终端上输出错误消息,输出寄存器中保存的信息并输出可供跟踪的回溯线索。

  oops的产生有很多可能的原因,其中包括内存访问越界或者非法的指令等。作为一个内核开发者,你将会经常处理(毫无疑问,也将导致)oops。

  紧接着的是一个oops的实例,它是一台PPC机器上的tulip网卡的定时器处理函数运行时发生的:

  oops中包含了重要的信息对于所有体系结构都是完全相同的:寄存器上下文和回溯线索。回溯线索显示了导致错误发生的函数调用链。寄存器上下文信息可能同样有用,尽管使用起来不那么方便。如果你有函数的汇编代码,这些寄存器数据可以帮助你重建引发问题的现场。

  ksymoops

  前面列举的oops可以说是一个经过解码的oops,因为内存地址都已经被转换成了它们对应的函数。下面是未解码的版本:

  回溯线索中的地址需要被转化成有意义的符号名称才方便使用。这需要调用ksymoops命令,还必须提供编译内核时产生的System.map。如果使用的是模块,还需要一些模块信息。ksymoops通常会自行解析这些信息,所以可以这样调用它:

  ksymoops saved_oops.txt

  然后程序就会吐出解码板的oops。

  kallsyms

    用户常常会错误的匹配system.map文件或错误地对oops进行解码。因此引进了 kallsyms,它可以通过定义CONFIG_KALLSYSMS配置选项启用。该选项可以载入内核镜像对应的内存地址的符号名称,所以内核可以打印解码好的跟踪线索。相应的,解码oops也不再需要system.map或者ksymoops工具了。另外,这样做会使内核变大一些,因为地址对应的符号名称必须始终驻留在内核所在的内存上。

  内核调试配置选项

  在编译的时候,为了方便调试和测试代码,内核提示了许多配置选项。它们都依赖于CONFIG_DEBUG_KERNEL。有些选项确实有用,你应该启用slab layer debugging(slab层调试选项),high-memory debugging(高端内存调用选项),I/O mapping debugging(I/O映射调试选项),spin-lock debugging(自旋锁调试选项)和stack-overflow checking(栈溢出检查选项)。其中最有用的一个是sleep-inside-spinlock checking(自旋锁内睡眠选项),这些选项确实能完成不少调试工作。

  对于调试原子操作,内核提供了一个原子操作计数器。它可以被配置成一旦在原子操作过程中进程进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。所以包括正使用锁的时候调用schedule(),正使用锁的时候以阻塞方式请求分配内存和在引用单CPU数据时睡眠在内,各种潜在的bug都能够被探测到。

  下面选项可以最大限度的利用该特性:

  

  引发bug并打印信息

  一些内核调用可以用来方便标记bug,提供断言并输出信息。最常用的两个是BUG()和BUG_ON()。当被调用的时候,它们会引发oops,导致栈的回溯和错误信息的打印。大部分体系结构把BUG()和BUG_ON()定义成某种非法操作,这样自然会产生需要的oops。可以吧这些调用当做断言使用,想要断言某种情况不该发生:

  if(bad_thing)

    BUG();

  或者使用更好的形式:

  BUG_ON(bad_thing);

  可以用panic()引发更严重的错误。调用panic()不但会打印错误信息,而且还会挂起整个系统。显然,你只应该在极端恶劣的情况下使用它:

  if(terrible_thing)

    panic("foo is %ld!\n",foo);

  有些时候,只需要在终端上打印一下栈的回溯信息来帮助测试。此时可以使用dump_stack()。它只在终端上打印寄存器上下文和函数的跟踪线索:

  

  神奇的SysRq

  SysRq(系统请求键)通过定义CONFIG_MAGIC_SYSRQ配置选项来启用,都是键盘上的标准键。当该功能启动时,无论内核处于什么状态,你都可以通过特殊的组合键跟内核进行通信。同时还要通过一个sysctl用来标记特性的开或关。需要启动它时用下命令:

  echo 1 > /proc/sys/kernel/sysrq

  其主要的命令如下:

  

  内核调试器

  Linux不愿意在内核源代码树中加入一个调试器,因此,许多不定应运而生,它们为标准内核附加上了内核调试的支持。虽然这都是一些不被官方认可的附加补丁,但它们确实功能完善,十分强大。

  1.gdb

  可以使用标准的GNU调试器对正在运行的内核进行查看。针对内核启动调试器的方法与针对进程的方法大致相同:

  gdb vmlinux /proc/kcore

  其中vmlinux文件是未经压缩的内核映像,不是压缩过的zImage或bzImage,它存放在源代码树的根目录上。

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

  2.kgdb

  kgdb是一个补丁,它可以让我们在远端主机上通过串口利用gdb的所有功能对内核进行调试。这需要两台计算机。第一台运行带有kgdb补丁的内核。第二台通过串行线(不通过modem,直接连接两台机器的电缆)使用gdb对第一台进行调试。

  3.kdb

  kdb是kgdb的一种替代品。它对内核源代码进行了很多修改,使调试内核在本地主机上就可以进行,它提供了变量修改、设置断点、单步执行等许多功能。 

  刺探系统

  1.用UID作为选择条件

  在提供替代物的同时不打破原有代码的可执行性。这在开发重要系统调用的时候,或者在你希望进行调试时系统功能依旧健全的情况下非常有用。

  一般情况下,只要保留原有的算法而把你的新算法加入到其他位置上,基本就能保证安全。你可以利用把用户的idUID)作为选择条件来实现这种功能,通过这种选择条件,你可以安排到底执行那种算法:

  If(current->uid != 7777){

  /* 老算法 */

  } else {

  /* 新算法 */

  }

  除了uid7777外,其他的所有用户用的是老算法。你可以创建一个UID7777的用户,专门测试新算法。

  2.使用条件变量

  如果代码与进程无关,或者你希望有一个针对所有情况都能使用的机制来控制某个特性,你可以使用条件变量。这比使用UID还来得简单。你只需要创建一个全局变量作为一个条件选择开关。如果该变量为零,你就是用一个分支上的代码。如果它不为零,你就选择另外一个分支。你可以通过某种界面提供对这个变量的操控,也可直接通过调试器进行操控。

  3.使用统计量

      如果要求数据的规律性,则可以通过创建统计量并提供某种机制访问其统计结果。

  4.重复频率限制

      为了避免一些错误检查语句(多数对应的都是一些prink语句)的频繁调用,有两种相关的技巧可以用来防止此类问题的发生。第一种是重复频率限制,如果某种事件发生的非常频繁,而有需要观察它的整体进展情况,你就可以让这种技巧施展身手了。为了避免调试信息发生井喷,你可以每隔几秒执行一次打印。另一种技巧不再是如何限制重复频率了,它要实现的是发生次数的限制。

 

  参考自:《Linux Kernel Development》.

posted on 2016-04-06 17:53  画家丶  阅读(214)  评论(0编辑  收藏  举报