yangshiquan / Linux / 内核模块调试方法 - driver - 坚定

0 0

   

内核模块调试方法 - driver - 坚定

2011-05-03  yangshiqu...
内核模块调试方法
对于任何一位内核代码的编写者来说,最急迫的问题之一就是如何完成调试。由于内核是一个不与特定进程相关的功能集合,所以内核代码无法轻易地放在调试器中执行,而且也很难跟踪。同样,要想复现内核代码中的错误也是相当困难的,因为这种错误可能导致整个系统崩溃,这样也就破坏了可以用来跟踪它们的现场。

本章将介绍在这种令人痛苦的环境下监视内核代码并跟踪错误的技术。

4.1  通过打印调试
最普通的调试技术就是监视,即在应用程序编程中,在一些适当的地点调用printf 显示监视信息。调试内核代码的时候,则可以用 printk 来完成相同的工作。

4.1.1  printk
在前面的章节中,我们只是简单假设 printk 工作起来和 printf 很类似。现在则是介绍它们之间一些不同点的时候了。

其中一个差别就是,通过附加不同日志级别(loglevel),或者说消息优先级,可让 printk根据这些级别所标示的严重程度,对消息进行分类。一般采用宏来指示日志级别,例如,KERN_INFO,我们在前面已经看到它被添加在一些打印语句的前面,它就是一个可以使用的消息日志级别。日志级别宏展开为一个字符串,在编译时由预处理器将它和消息文本拼接在一起;这也就是为什么下面的例子中优先级和格式字串间没有逗号的原因。下面有两个 printk 的例子,一个是调试信息,一个是临界信息:

printk(KERN_DEBUG "Here I am: %s:%i\n", _ _FILE_ _, _ _LINE_ _);
printk(KERN_CRIT "I'm trashed; giving up on %p\n", ptr);

在头文件 <linux/kernel.h> 中定义了 8 种可用的日志级别字符串。

KERN_EMERG
用于紧急事件消息,它们一般是系统崩溃之前提示的消息。

KERN_ALERT
用于需要立即采取动作的情况。

KERN_CRIT
临界状态,通常涉及严重的硬件或软件操作失败。

KERN_ERR
用于报告错误状态;设备驱动程序会经常使用 KERN_ERR 来报告来自硬件的问题。

KERN_WARNING
对可能出现问题的情况进行警告,这类情况通常不会对系统造成严重问题。

KERN_NOTICE
有必要进行提示的正常情形。许多与安全相关的状况用这个级别进行汇报。

KERN_INFO
提示性信息。很多驱动程序在启动的时候,以这个级别打印出它们找到的硬件信息。

KERN_DEBUG
用于调试信息。

每个字符串(以宏的形式展开)代表一个尖括号中的整数。整数值的范围从0到7,数值越小,优先级就越高。

没有指定优先级的 printk 语句默认采用的级别是 DEFAULT_MESSAGE_LOGLEVEL,这个宏在文件 kernel/printk.c 中指定为一个整数值。在 Linux 的开发过程中,这个默认的级别值已经有过好几次变化,所以我们建议读者始终指定一个明确的级别。

根据日志级别,内核可能会把消息打印到当前控制台上,这个控制台可以是一个字符模式的终端、一个串口打印机或是一个并口打印机。如果优先级小于 console_loglevel 这个整数值的话,消息才能显示出来。如果系统同时运行了 klogd  和 syslogd,则无论 console_loglevel 为何值,内核消息都将追加到 /var/log/messages 中(否则的话,除此之外的处理方式就依赖于对 syslogd 的设置)。如果 klogd 没有运行,这些消息就不会传递到用户空间,这种情况下,就只好查看 /proc/kmsg 了。

变量 console_loglevel 的初始值是 DEFAULT_CONSOLE_LOGLEVEL,而且还可以通过sys_syslog 系统调用进行修改。调用 klogd 时可以指定 -c 开关选项来修改这个变量, klogd 的 man 手册页对此有详细说明。注意,要修改它的当前值,必须先杀掉 klogd,再加 -c选项重新启动它。此外,还可以编写程序来改变控制台日志级别。读者可以在 O’Reilly 的 FTP 站点提供的源文件 miscprogs/setlevel.c 里找到这样的一段程序。新优先级被指定为一个 1 到 8 之间的整数值。如果值被设为 1,则只有级别为 0(KERN_EMERG) 的消息才能到达控制台;如果设为 8,则包括调试信息在内的所有消息都能显示出来。

如果在控制台上工作,而且常常遇到内核错误(参见本章后面的“调试系统故障”一节)的话,就有必要降低日志级别,因为出错处理代码会把 console_loglevel 增为它的最大数值,导致随后的所有消息都显示在控制台上。如果需要查看调试信息,就有必要提高日志级别;这在远程调试内核,并且在交互会话未使用文本控制台的情况下,是很有帮助的。

从2.1.31这个版本起,可以通过文本文件 /proc/sys/kernel/printk 来读取和修改控制台的日志级别。这个文件容纳了 4 个整数值。读者可能会对前面两个感兴趣:控制台的当前日志级别和默认日志级别。例如,在最近的这些内核版本中,可以通过简单地输入下面的命令使所有的内核消息得到显示:

# echo 8 > /proc/sys/kernel/printk

不过,如果仍在 2.0 版本下的话,就需要使用 setlevel 这样的工具了。

现在大家应该清楚为什么在 hello.c范例中使用 <1> 这些标记了,它们用来确保这些消息能在控制台上显示出来。

对于控制台日志策略,Linux考虑到了某些灵活性,也就是说,可以发送消息到一个指定的虚拟控制台(假如控制台是文本屏幕的话)。默认情况下,“控制台” 就是当前地虚拟终端。可以在任何一个控制台设备上调用 ioctl(TIOCLINUX),来指定接收消息的虚拟终端。下面的 setconsole  程序,可选择专门用来接收内核消息的控制台;这个程序必须由超级用户运行,在 misc-progs 目录里可以找到它。下面是程序的代码:

int main(int argc, char **argv)
{
  char bytes[2] = {11,0}; /* 11 is the TIOCLINUX cmd number */

  if (argc==2) bytes[1] = atoi(argv[1]); /* the chosen console */
  else {
      fprintf(stderr, "%s: need a single arg\n",argv[0]); exit(1);
  }
  if (ioctl(STDIN_FILENO, TIOCLINUX, bytes)<0) {    /* use stdin */
      fprintf(stderr,"%s: ioctl(stdin, TIOCLINUX): %s\n",
              argv[0], strerror(errno));
      exit(1);
  }
  exit(0);
}

setconsole 使用了特殊的ioctl命令:TIOCLINUX ,这个命令可以完成一些特定的 Linux 功能。使用 TIOCLINUX 时,需要传给它一个指向字节数组的指针参数。数组的第一个字节指定所请求子命令的数字,接下去的字节所具有的功能则由这个子命令决定。在 setconsole 中,使用的子命令是 11,后面那个字节(存于bytes[1]中)标识虚拟控制台。关于 TIOCLINUX 的详尽描述可以在内核源码中的 drivers/char/tty_io.c 文件得到。

4.1.2  消息如何被记录
printk 函数将消息写到一个长度为 LOG_BUF_LEN(定义在 kernel/printk.c 中)字节的循环缓冲区中,然后唤醒任何正在等待消息的进程,即那些睡眠在 syslog 系统调用上的进程,或者读取 /proc/kmesg 的进程。这两个访问日志引擎的接口几乎是等价的,不过请注意,对 /proc/kmesg 进行读操作时,日志缓冲区中被读取的数据就不再保留,而 syslog 系统调用却能随意地返回日志数据,并保留这些数据以便其它进程也能使用。一般而言,读 /proc 文件要容易些,这使它成为 klogd 的默认方法。

手工读取内核消息时,在停止klogd之后,可以发现 /proc 文件很象一个FIFO,读进程会阻塞在里面以等待更多的数据。显然,如果已经有 klogd 或其它的进程正在读取相同的数据,就不能采用这种方法进行消息读取,因为会与这些进程发生竞争。

如果循环缓冲区填满了,printk就绕回缓冲区的开始处填写新数据,覆盖最陈旧的数据,于是记录进程就会丢失最早的数据。但与使用循环缓冲区所带来的好处相比,这个问题可以忽略不计。例如,循环缓冲区可以使系统在没有记录进程的情况下照样运行,同时覆盖那些不再会有人去读的旧数据,从而使内存的浪费减到最少。Linux消息处理方法的另一个特点是,可以在任何地方调用printk,甚至在中断处理函数里也可以调用,而且对数据量的大小没有限制。而这个方法的唯一缺点就是可能丢失某些数据。

klogd 运行时,会读取内核消息并将它们分发到 syslogd,syslogd 随后查看 /etc/syslog.conf ,找出处理这些数据的方法。syslogd 根据设施和优先级对消息进行区分;这两者的允许值均定义在 <sys/syslog.h> 中。内核消息由 LOG_KERN 设施记录,并以 printk 中使用的优先级记录(例如,printk 中使用的 KERN_ERR对应于syslogd 中的 LOG_ERR)。如果没有运行 klogd,数据将保留在循环缓冲区中,直到某个进程读取或缓冲区溢出为止。

如果想避免因为来自驱动程序的大量监视信息而扰乱系统日志,则可以为 klogd 指定 -f (file) 选项,指示 klogd 将消息保存到某个特定的文件,或者修改 /etc/syslog.conf 来适应自己的需求。另一种可能的办法是采取强硬措施:杀掉klogd,而将消息详细地打印到空闲的虚拟终端上。*

注: 例如,使用下面的命令可设置 10 号终端用于消息的显示:
setlevel 8
setconsole 10

或者在一个未使用的 xterm 上执行cat /proc/kmesg来显示消息。

4.1.3  开启及关闭消息
在驱动程序开发的初期阶段,printk 对于调试和测试新代码是相当有帮助的。不过,当正式发布驱动程序时,就得删除这些打印语句,或至少让它们失效。不幸的是,你可能会发现这样的情况,在删除了那些已被认为不再需要的提示消息后,又需要实现一个新的功能(或是有人发现了一个 bug),这时,又希望至少把一部分消息重新开启。这两个问题可以通过几个办法解决,以便全局地开启或禁止消息,并能对个别消息进行开关控制。

我们在这里给出了一个编写 printk 调用的方法,可个别或全局地对它们进行开关;这个技巧是定义一个宏,在需要时,这个宏展开为一个printk(或printf)调用。

可以通过在宏名字中删减或增加一个字母,打开或关闭每一条打印语句。

编译前修改 CFLAGS 变量,则可以一次关闭所有消息。

同样的打印语句既可以用在内核态也可以用在用户态,因此,关于这些额外的信息,驱动和测试程序可以用同样的方法来进行管理。

下面这些来自 scull.h 的代码,就实现了这些功能。

#undef PDEBUG             /* undef it, just in case */
#ifdef SCULL_DEBUG
#  ifdef _ _KERNEL_ _
   /* This one if debugging is on, and kernel space */
#    define PDEBUG(fmt, args...) printk( KERN_DEBUG "scull: " fmt,
                                       ## args)
#  else
   /* This one for user space */
#    define PDEBUG(fmt, args...) fprintf(stderr, fmt, ## args)
#  endif
#else
#  define PDEBUG(fmt, args...) /* not debugging: nothing */
#endif

#undef PDEBUGG
#define PDEBUGG(fmt, args...) /* nothing: it's a placeholder */

符号 PDEBUG 依赖于是否定义了SCULL_DEBUG,它能根据代码所运行的环境选择合适的方式显示信息:内核态运行时使用printk系统调用;用户态下则使用 libc调用fprintf,向标准错误设备进行输出。符号PDEBUGG则什么也不做;它可以用来将打印语句注释掉,而不必把它们完全删除。

为了进一步简化这个过程,可以在 Makefile加上下面几行:

# Comment/uncomment the following line to disable/enable debugging
DEBUG = y

# Add your debugging flag (or not) to CFLAGS
ifeq ($(DEBUG),y)
DEBFLAGS = -O -g -DSCULL_DEBUG # "-O" is needed to expand inlines
else
DEBFLAGS = -O2
endif

CFLAGS += $(DEBFLAGS)

本节所给出的宏依赖于gcc 对ANSI C预编译器的扩展,这种扩展支持了带可变数目参数的宏。对 gcc 的这种依赖并不是什么问题,因为内核对 gcc 特性的依赖更强。此外,Makefile依赖于 GNU 的make 版本;基于同样的道理,这也不是什么问题。

如果读者熟悉 C 预编译器,可以将上面的定义进行扩展,实现“调试级别”的概念,这需要定义一组不同的级别,并为每个级别赋一个整数(或位掩码),用以决定各个级别消息的详细程度。

但是每一个驱动程序都会有自身的功能和监视需求。良好的编程技术在于选择灵活性和效率的最佳折衷点,对读者来说,我们无法预知最合适的点在哪里。记住,预处理程序的条件语句(以及代码中的常量表达式)只在编译时执行,要再次打开或关闭消息必须重新编译。另一种方法就是使用C条件语句,它在运行时执行,因此可以在程序运行期间打开或关闭消息。这是个很好的功能,但每次代码执行时系统都要进行额外的处理,甚至在消息关闭后仍然会影响性能。有时这种性能损失是无法接受的。

在很多情况下,本节提到的这些宏都已被证实是很有用的,仅有的缺点是每次开启和关闭消息显示时都要重新编译模块。

4.2  通过查询调试
上一节讲述了 printk 是如何工作的以及如何使用它,但还没谈到它的缺点。

由于 syslogd 会一直保持对其输出文件的同步刷新,每打印一行都会引起一次磁盘操作,因此大量使用 printk 会严重降低系统性能。从 syslogd 的角度来看,这样的处理是正确的。它试图把每件事情都记录到磁盘上,以防系统万一崩溃时,最后的记录信息能反应崩溃前的状况;然而,因处理调试信息而使系统性能减慢,是大家所不希望的。这个问题可以通过在 /etc/syslogd.conf 中日志文件的名字前面,前缀一个减号符解决。*

注: 这个减号是个“特殊”标记,避免 syslogd 在每次出现新信息时都去刷新磁盘文件,这些内容记述在 syslog.conf(5) 中,这个手册页很值得一读。


修改配置文件带来的问题在于,在完成调试之后改动将依旧保留;即使在一般的系统操作中,当希望尽快把信息刷新到磁盘时,也是如此。如果不愿作这种持久性修改的话,另一个选择是运行一个非 klogd 程序(如前面介绍的cat /proc/kmesg),但这样并不能为通常的系统操作提供一个合适的环境。

多数情况中,获取相关信息的最好方法是在需要的时候才去查询系统信息,而不是持续不断地产生数据。实际上,每个 Unix 系统都提供了很多工具,用于获取系统信息,如:ps、netstat、vmstat等等。

驱动程序开发人员对系统进行查询时,可以采用两种主要的技术:在 /proc 文件系统中创建文件,或者使用驱动程序的 ioctl 方法。/proc 方式的另一个选择是使用 devfs,不过用于信息查找时,/proc 更为简单一些。

4.2.1  使用 /proc 文件系统
/proc 文件系统是一种特殊的、由程序创建的文件系统,内核使用它向外界输出信息。/proc 下面的每个文件都绑定于一个内核函数,这个函数在文件被读取时,动态地生成文件的“内容”。我们已经见到过这类文件的一些输出情况,例如, /proc/modules 列出的是当前载入模块的列表。

Linux系统对/proc的使用很频繁。现代Linux系统中的很多工具都是通过 /proc 来获取它们的信息,例如 ps、top 和 uptime。有些设备驱动程序也通过 /proc 输出信息,你的驱动程序当然也可以这么做。因为 /proc 文件系统是动态的,所以驱动程序模块可以在任何时候添加或删除其中的文件项。

特征完全的 /proc 文件项相当复杂;在所有的这些特征当中,有一点要指出的是,这些 /proc 文件不仅可以用于读出数据,也可以用于写入数据。不过,大多数时候,/proc 文件项是只读文件。本节将只涉及简单的只读情形。如果有兴趣实现更为复杂的事情,读者可以先在这里了解基础知识,然后参考内核源码来建立完整的认识。

所有使用 /proc 的模块必须包含 <linux/proc_fs.h>,通过这个头文件定义正确的函数。

为创建一个只读 /proc 文件,驱动程序必须实现一个函数,用于在文件读取时生成数据。当某个进程读这个文件时(使用 read 系统调用),请求会通过两个不同接口的其中之一发送到驱动程序模块,使用哪个接口取决于注册情况。我们先把注册放到本节后面,先直接讲述读接口。

无论采用哪个接口,在这两种情况下,内核都会分配一页内存(也就是 PAGE_SIZE 个字节),驱动程序向这片内存写入将返回给用户空间的数据。

推荐的接口是 read_proc,不过还有一个名为 get_info 的老一点的接口。

int (*read_proc)(char *page, char **start, off_t offset, int count, int *eof, void *data);

参数表中的 page 指针指向将写入数据的缓冲区;start 被函数用来说明有意义的数据写在页面的什么位置(对此后面还将进一步谈到);offset 和 count 这两个参数与在 read 实现中的用法相同。eof 参数指向一个整型数,当没有数据可返回时,驱动程序必须设置这个参数;data 参数是一个驱动程序特有的数据指针,可用于内部记录。*

注: 纵览全书,我们还会发现这样的一些指针;它们表示了这类处理中有关的“对象”,与C++ 中的同类处理有些相似。

这个函数可以在2.4内核中使用,如果使用我们的 sysdep.h 头文件,那么在2.2内核中也可以用这个函数。

int (*get_info)(char *page, char **start, off_t offset, int count);  

get_info 是一个用来读取 /proc 文件的较老接口。所有的参数与 read_proc 中的对应参数用法相同。缺少的是报告到达文件尾的指针和由data 指针带来的面向对象风格。这个函数可以用在所有我们感兴趣的内核版本中(尽管在它 2.0 版本的实现中有一个额外未用的参数)。

这两个函数的返回值都是实际放入页面缓冲区的数据的字节数,这一点与 read 函数对其它类型文件的处理相同。另外还有 *eof 和 *start 这两个输出值。eof 只是一个简单的标记,而 start 的用法就有点复杂了。

对于 /proc 文件系统的用户扩展,其最初实现中的主要问题在于,数据传输只使用单个内存页面。这样就把用户文件的总体尺寸限制在了 4KB 以内(或者是适合于主机平台的其它值)。start 参数在这里就是用来实现大数据文件的,不过该参数可以被忽略。

如果 proc_read 函数不对 *start 指针进行设置(它最初为 NULL),内核就会假定 offset 参数被忽略,并且数据页包含了返回给用户空间的整个文件。反之,如果需要通过多个片段创建一个更大的文件,则可以把 *start 赋值为页面指针,因此调用者也就知道了新数据放在缓冲区的开始位置。当然,应该跳过前 offset 个字节的数据,因为这些数据已经在前面的调用中返回。

长久以来,关于 /proc 文件还有另一个主要问题,这也是 start 意图解决的一个问题。有时,在连续的 read 调用之间,内核数据结构的 ASCII 表述会发生变化,以至于读进程发现前后两次调用所获得的数据不一致。如果把 *start 设为一个小的整数值,调用程序可以利用它来增加 filp->f_pos 的值,而不依赖于返回的数据量,因此也就使 f_pos 成为read_proc 或 get_info 程序中的一个内部记录值。例如,如果 read_proc 函数从一个大的结构数组返回数据,并且这些结构的前 5 个已经在第一次调用中返回,那么可将 *start 设置为 5。下次调用中这个值将被作为偏移量;驱动程序也就知道应该从数组的第六个结构开始返回数据。这种方法被它的作者称作“hack”,可以在 /fs/proc/generic.c 中看到。

现在我们来看个例子。下面是scull 设备 read_proc 函数的简单实现:

int scull_read_procmem(char *buf, char **start, off_t offset,
                 int count, int *eof, void *data)
{
  int i, j, len = 0;
  int limit = count - 80; /* Don't print more than this */

  for (i = 0; i < scull_nr_devs && len <= limit; i++) {
      Scull_Dev *d = &scull_devices[ i];
      if (down_interruptible(&d->sem))
              return -ERESTARTSYS;
      len += sprintf(buf+len,"\nDevice %i: qset %i, q %i, sz %li\n",
                     i, d->qset, d->quantum, d->size);
      for (; d && len <= limit; d = d->next) { /* scan the list */
          len += sprintf(buf+len, "  item at %p, qset at %p\n", d,
                                  d->data);
          if (d->data && !d->next) /* dump only the last item
                                                  - save space */
              for (j = 0; j < d->qset; j++) {
                  if (d->data[j])
                      len += sprintf(buf+len,"    % 4i: %8p\n",
                                                  j,d->data[j]);
              }
      }
      up(&scull_devices[ i].sem);
  }
  *eof = 1;
  return len;
}

这是一个相当典型的 read_proc 实现。它假定决不会有这样的需求,即生成多于一页的数据,因此忽略了 start 和 offset 值。但是,小心不要超出缓冲区,以防万一。

使用 get_info 接口的 /proc 函数与上面说明的 read_proc 非常相似,除了没有最后的那两个参数。既然这样,则通过返回少于调用者预期的数据(也就是少于 count 参数),来提示已到达文件尾。

一旦定义好了一个 read_proc 函数,就需要把它与一个 /proc 文件项连接起来。依赖于将要支持的内核版本,有两种方法可以建立这样的连接。最容易的方法是简单地调用 create_proc_read_entry,但这只能用于2.4内核(如果使用我们的 sysdep.h 头文件,则也可用于 2.2 内核)。下面就是 scull 使用的调用,以 /proc/scullmem 的形式来提供 /proc 功能。

create_proc_read_entry("scullmem",
                     0    /* default mode */,
                     NULL /* parent dir */,
                     scull_read_procmem,
                     NULL /* client data */);

这个函数的参数表包括:/proc 文件项的名称、应用于该文件项的文件许可权限(0是个特殊值,会被转换为一个默认的、完全可读模式的掩码)、文件父目录的 proc_dir_entry 指针(我们使用 NULL 值使该文件项直接定位在 /proc 下)、指向 read_proc 的函数指针,以及将传递给 read_proc 函数的数据指针。

目录项指针(proc_dir_entry)可用来在 /proc 下创建完整的目录层次结构。不过请注意,将文件项置于 /proc 的子目录中有更为简单的方法,即把目录名称作为文件项名称的一部分――只要目录本身已经存在。例如,有个新的约定,要求设备驱动程序对应的 /proc 文件项应转移到子目录 driver/ 中;scull 可以简单地指定它的文件项名称为 driver/scullmem,从而把它的 /proc 文件放到这个子目录中。

当然,在模块卸载时,/proc 中的文件项也应被删除。 remove_proc_entry 就是用来撤消 create_proc_read_entry 所做工作的函数。

remove_proc_entry("scullmem", NULL /* parent dir */);

另一个创建 /proc 文件项的方法是,创建并初始化一个 proc_dir_entry 结构,并将该结构传递给函数 proc_register_dynamic (2.0 版本)或 proc_register(2.2 版本,如果结构中的索引节点号为0,该函数即认为是动态文件)。作为一个例子,当在2.0内核的头文件下进行编译时,考虑下面 scull 所使用的这些代码:

static int scull_get_info(char *buf, char **start, off_t offset,
              int len, int unused)
{
  int eof = 0;
  return scull_read_procmem (buf, start, offset, len, &eof, NULL);
}

struct proc_dir_entry scull_proc_entry = {
      namelen:    8,
      name:       "scullmem",
      mode:       S_IFREG | S_IRUGO,
      nlink:      1,
      get_info:   scull_get_info,
};

static void scull_create_proc()
{
  proc_register_dynamic(&proc_root, &scull_proc_entry);
}

static void scull_remove_proc()
{
  proc_unregister(&proc_root, scull_proc_entry.low_ino);
}

代码声明了一个使用 get_info 接口的函数,并填写了一个 proc_dir_entry 结构,用于对文件系统进行注册。

这段代码借助sysdep.h 中宏定义的支持,提供了 2.0 和 2.4 内核之间的兼容性。因为 2.0 内核不支持 read_proc,它使用了 get_info 接口。如果对 #ifdef 作一些更多的处理,可以使这段代码在 2.2 内核中使用 read_proc,不过这样收益并不大。

4.2.2  ioctl 方法
ioctl是作用于文件描述符之上的一个系统调用,我们会在下一章介绍它的用法;它接收一个“命令”号,用以标识将执行的命令;以及另一个(可选的)参数,通常是个指针。

做为替代 /proc文件系统的方法,可以为调试设计若干ioctl命令。这些命令从驱动程序复制相关数据到用户空间,在用户空间中可以查看这些数据。

使用ioctl 获取信息比起 /proc 来要困难一些,因为需要另一个程序调用 ioctl 并显示结果。这个程序是必须编写并编译的,而且要和测试模块配合一致。从另一方面来说,相对实现 /proc 文件所需的工作,驱动程序的编码则更为容易些。

有时 ioctl 是获取信息的最好方法,因为它比起读 /proc 要快得多。如果在数据写到屏幕之前要完成某些处理工作,以二进制获取数据要比读取文本文件有效得多。此外,ioctl 并不要求把数据分割成不超过一个内存页面的片断。

ioctl 方法的一个优点是,在结束调试之后,用来取得信息的这些命令仍可以保留在驱动程序中。/proc文件对任何查看这个目录的人都是可见的(很多人可能会纳闷 “这些奇怪的文件是用来做什么的”),然而与 /proc文件不同,未公开的 ioctl 命令通常都不会被注意到。此外,万一驱动程序有什么异常,这些命令仍然可以用来调试。唯一的缺点就是模块会稍微大一些。

4.3  通过监视调试
有时,通过监视用户空间中应用程序的运行情况,可以捕捉到一些小问题。监视程序同样也有助于确认驱动程序工作是否正常。例如,看到 scull 的 read 实现如何响应不同数据量的 read 请求后,我们就可以判断它是否工作正常。

有许多方法可监视用户空间程序的工作情况。可以用调试器一步步跟踪它的函数,插入打印语句,或者在 strace 状态下运行程序。在检查内核代码时,最后一项技术最值得关注,我们将在此对它进行讨论。

strace 命令是一个功能非常强大的工具,它可以显示程序所调用的所有系统调用。它不仅可以显示调用,而且还能显示调用参数,以及用符号方式表示的返回值。当系统调用失败时,错误的符号值(如 ENOMEM)和对应的字符串(如Out of memory)都能被显示出来。strace 有许多命令行选项;最为有用的是 -t,用来显示调用发生的时间;-T,显示调用所花费的时间; -e,限定被跟踪的调用类型;-o,将输出重定向到一个文件中。默认情况下,strace将跟踪信息打印到 stderr 上。

strace从内核中接收信息。这意味着一个程序无论是否按调试方式编译(用 gcc 的 -g选项)或是被去掉了符号信息都可以被跟踪。与调试器可以连接到一个运行进程并控制它一样,strace 也可以跟踪一个正在运行的进程。

跟踪信息通常用于生成错误报告,然后发给应用开发人员,但是它对内核编程人员来说也同样非常有用。我们已经看到驱动程序是如何通过响应系统调用得到执行的;strace 允许我们检查每次调用中输入和输出数据的一致性。

例如,下面的屏幕信息显示了 strace ls /dev > /dev/scull0 命令的最后几行:

[...]
open("/dev", O_RDONLY|O_NONBLOCK)     = 4
fcntl(4, F_SETFD, FD_CLOEXEC)         = 0
brk(0x8055000)                        = 0x8055000
lseek(4, 0, SEEK_CUR)                 = 0
getdents(4, /* 70 entries */, 3933)   = 1260
[...]
getdents(4, /* 0 entries */, 3933)    = 0
close(4)                              = 0
fstat(1, {st_mode=S_IFCHR|0664, st_rdev=makedev(253, 0), ...}) = 0
ioctl(1, TCGETS, 0xbffffa5c)          = -1 ENOTTY (Inappropriate ioctl
                                                   for device)
write(1, "MAKEDEV\natibm\naudio\naudio1\na"..., 4096) = 4000
write(1, "d2\nsdd3\nsdd4\nsdd5\nsdd6\nsdd7"..., 96) = 96
write(1, "4\nsde5\nsde6\nsde7\nsde8\nsde9\n"..., 3325) = 3325
close(1)                              = 0
_exit(0)                              = ?

很明显,ls 完成对目标目录的检索后,在首次对 write 的调用中,它试图写入 4KB 数据。很奇怪(对于 ls 来说),实际只写了4000个字节,接着它重试这一操作。然而,我们知道scull的 write 实现每次最多只写一个量子(scull 中设置的量子大小为4000个字节),所以我们所预期的就是这样的部分写入。经过几个步骤之后,每件工作都顺利通过,程序正常退出。

另一个例子,让我们来对 scull 设备进行读操作(使用 wc 命令):

[...]
open("/dev/scull0", O_RDONLY)           = 4
fstat(4, {st_mode=S_IFCHR|0664, st_rdev=makedev(253, 0), ...}) = 0
read(4, "MAKEDEV\natibm\naudio\naudio1\na"..., 16384) = 4000
read(4, "d2\nsdd3\nsdd4\nsdd5\nsdd6\nsdd7"..., 16384) = 3421
read(4, "", 16384)                      = 0
fstat(1, {st_mode=S_IFCHR|0600, st_rdev=makedev(3, 7), ...}) = 0
ioctl(1, TCGETS, {B38400 opost isig icanon echo ...}) = 0
write(1, "   7421 /dev/scull0\n", 20)   = 20
close(4)                                = 0
_exit(0)                                = ?

正如所料,read 每次只能读取4000个字节,但数据总量与前面例子中写入的数量是相同的。与上面的写跟踪相对比,请读者注意本例中重试工作是如何组织的。为了快速读取数据,wc 已被优化了,因而它绕过了标准库,试图通过一次系统调用读取更多的数据。可以从跟踪的 read 行中看到 wc 每次均试图读取 16KB 数据。

Linux行家可以在 strace 的输出中发现很多有用信息。如果觉得这些符号过于拖累的话,则可以仅限于监视文件方法(open,read 等)是如何工作的。

就个人观点而言,我们发现 strace 对于查找系统调用运行时的细微错误最为有用。通常应用或演示程序中的 perror 调用在用于调试时信息还不够详细,而 strace 能够确切查明系统调用的哪个参数引发了错误,这一点对调试是大有帮助的。

4.4  调试系统故障
即使采用了所有这些监视和调试技术,有时驱动程序中依然会有错误,这样的驱动程序在执行时就会产生系统故障。在出现这种情况时,获取尽可能多的信息对解决问题是至关重要的。

注意,“故障”不意味着“panic”。Linux 代码非常健壮(用术语讲即为鲁棒,robust),可以很好地响应大部分错误:故障通常会导致当前进程崩溃,而系统仍会继续运行。如果在进程上下文之外发生故障,或是系统的重要组成被损害时,系统才有可能 panic。但如果问题出在驱动程序中时,通常只会导致正在使用驱动程序的那个进程突然终止。唯一不可恢复的损失就是进程被终止时,为进程上下文分配的一些内存可能会丢失;例如,由驱动程序通过 kmalloc 分配的动态链表可能丢失。然而,由于内核在进程中止时会对已打开的设备调用 close 操作,驱动程序仍可以释放由 open 方法分配的资源。

我们已经说过,当内核行为异常时,会在控制台上打印出提示信息。下一节将解释如何解码并使用这些消息。尽管它们对于初学者来说相当晦涩,不过处理器在出错时转储出的这些数据包含了许多值得关注的信息,通常足以查明程序错误,而无需额外的测试。

4.4.1  oops消息
大部分错误都在于 NULL指针的使用或其他不正确的指针值的使用上。这些错误通常会导致一个 oops 消息。

由处理器使用的地址都是虚拟地址,而且通过一个复杂的称为页表(见第 13 章中的“页表”一节)的结构映射为物理地址。当引用一个非法指针时,页面映射机制就不能将地址映射到物理地址,此时处理器就会向操作系统发出一个“页面失效”的信号。如果地址非法,内核就无法“换页”到并不存在的地址上;如果此时处理器处于超级用户模式,系统就会产生一个“oops”。

值得注意的是,2.0 版本之后引入的第一个增强是,当向用户空间移动数据或者移出时,无效地址错误会被自动处理。Linus 选择了让硬件来捕捉错误的内存引用,所以正常情况(地址都正确时)就可以更有效地得到处理。

oops 显示发生错误时处理器的状态,包括 CPU 寄存器的内容、页描述符表的位置,以及其它看上去无法理解的信息。这些消息由失效处理函数(arch/*/kernel/traps.c)中的 printk 语句产生,就象前面“printk”一节所介绍的那样分发出来。

让我们看看这样一个消息。当我们在一台运行 2.4 内核的 PC 机上使用一个 NULL 指针时,就会导致下面这些信息显示出来。这里最为相关的信息就是指令指针(EIP),即出错指令的地址。

Unable to handle kernel NULL pointer dereference at virtual address 00000000
printing eip:

c48370c3
*pde = 00000000
Oops: 0002
CPU:    0
EIP:    0010:[<c48370c3>]
EFLAGS: 00010286
eax: ffffffea   ebx: c2281a20   ecx: c48370c0   edx: c2281a40
esi: 4000c000   edi: 4000c000   ebp: c38adf8c   esp: c38adf8c
ds: 0018   es: 0018   ss: 0018
Process ls (pid: 23171, stackpage=c38ad000)
Stack: 0000010e c01356e6 c2281a20 4000c000 0000010e c2281a40 c38ac000 \
          0000010e
     4000c000 bffffc1c 00000000 00000000 c38adfc4 c010b860 00000001 \
          4000c000
     0000010e 0000010e 4000c000 bffffc1c 00000004 0000002b 0000002b \
          00000004
Call Trace: [<c01356e6>] [<c010b860>]
Code: c7 05 00 00 00 00 00 00 00 00 31 c0 89 ec 5d c3 8d b6 00 00

这个消息是通过对 faulty  模块的一个设备进行写操作而产生的,faulty 这个模块专为演示出错而编写。faulty.c 中 write 方法的实现很简单:

ssize_t faulty_write (struct file *filp, const char *buf, size_t count,
loff_t *pos)
{
  /* make a simple fault by dereferencing a NULL pointer */
  *(int *)0 = 0;
  return 0;
}

正如读者所见,我们这使用了一个 NULL 指针。因为 0 决不会是个合法的指针值,所以错误发生,内核进入上面的 oops 消息状态。这个调用进程接着就被杀掉了。在 read 实现中,faulty 模块还有更多有意思的错误状态。

char faulty_buf[1024];

ssize_t faulty_read (struct file *filp, char *buf, size_t count,
                   loff_t *pos)
{
  int ret, ret2;
  char stack_buf[4];

  printk(KERN_DEBUG "read: buf %p, count %li\n", buf, (long)count);
  /* the next line oopses with 2.0, but not with 2.2 and later */
  ret = copy_to_user(buf, faulty_buf, count);
  if (!ret) return count; /* we survived */

  printk(KERN_DEBUG "didn't fail: retry\n");
  /* For 2.2 and 2.4, let's try a buffer overflow  */
  sprintf(stack_buf, "1234567\n");
  if (count > 8) count = 8; /* copy 8 bytes to the user */
  ret2 = copy_to_user(buf, stack_buf, count);
  if (!ret2) return count;
  return ret2;
}

这段程序首先从一个全局缓冲区读取数据,但并不检查数据的长度,然后通过对一个局部缓冲区进行写入操作,制造一次缓冲区溢出。第一个操作仅在 2.0 内核会导致 oops 的发生,因为后期版本能自动地处理用户拷贝函数。缓冲区溢出则会在所有版本的内核中造成 oops;然而,由于 return 指令把指令指针带到了不知道的地方,所以这种错误很难跟踪,所能获得的仅是如下的信息:

EIP:    0010:[<00000000>]
[...]
Call Trace: [<c010b860>]
Code:  Bad EIP value.

用户处理 oops 消息的主要问题在于,我们很难从十六进制数值中看出什么内在的意义;为了使这些数据对程序员更有意义,需要把它们解析为符号。有两个工具可用来为开发人员完成这样的解析:klogd 和 ksymoops。前者只要运行就会自行进行符号解码;后者则需要用户有目的地调用。下面的讨论,使用了在我们第一个 oops 例子中通过使用NULL 指针而产生的出错信息。

使用 klogd
klogd 守护进程能在 oops 消息到达记录文件之前对它们解码。很多情况下,klogd 可以为开发者提供所有必要的信息用于捕捉问题的所在,可是有时开发者必须给它一定的帮助。

当 faulty 的一个oops 输出送达系统日志时,转储信息看上去会是下面的情况(注意 EIP 行和 stack 跟踪记录中已经解码的符号):

Unable to handle kernel NULL pointer dereference at virtual address \
   00000000
printing eip:
c48370c3
*pde = 00000000
Oops: 0002
CPU:    0
EIP:    0010:[faulty:faulty_write+3/576]
EFLAGS: 00010286
eax: ffffffea   ebx: c2c55ae0   ecx: c48370c0   edx: c2c55b00
esi: 0804d038   edi: 0804d038   ebp: c2337f8c   esp: c2337f8c
ds: 0018   es: 0018   ss: 0018
Process cat (pid: 23413, stackpage=c2337000)
Stack: 00000001 c01356e6 c2c55ae0 0804d038 00000001 c2c55b00 c2336000 \
          00000001
     0804d038 bffffbd4 00000000 00000000 bffffbd4 c010b860 00000001 \
          0804d038
     00000001 00000001 0804d038 bffffbd4 00000004 0000002b 0000002b \
          00000004
Call Trace: [sys_write+214/256] [system_call+52/56]  
Code: c7 05 00 00 00 00 00 00 00 00 31 c0 89 ec 5d c3 8d b6 00 00  

klogd 提供了大多数必要信息用于发现问题。在这个例子中,我们看到指令指针(EIP)正执行于函数 faulty_write 中,因此我们就知道该从哪儿开始检查。字串 3/576 告诉我们处理器正处于函数的第3个字节上,而函数整体长度为 576 个字节。注意这些数值都是十进制的,而非十六进制。

然而,当错误发生在可装载模块中时,为了获取错误相关的有用信息,开发者还必须注意一些情况。klogd 在开始运行时装入所有可用符号,并随后使用这些符号。如果在 klogd 已经对自身初始化之后(一般在系统启动时),装载某个模块,那 klogd 将不会有这个模块的符号信息。强制 klogd取得这些信息的办法是,发送一个 SIGUSR1 信号给 klogd 进程,这种操作在时间顺序上,必须是在模块已经装入(或重新装载)之后,而在进行任何可能引起 oops 的处理之前。

还可以在运行 klogd 时加上 -p 选项,这会使它在任何发现 oops 消息的时刻重新读入符号信息。不过,klogd 的man 手册不推荐这个方法,因为这使 klogd 在出问题之后再向内核查询信息。而发生错误之后,所获得的信息可能是完全错误的了。

为了使 klogd 正确地工作,必须给它提供符号表文件 System.map 的一个当前复本。通常这个文件在 /boot 中;如果从一个非标准的位置编译并安装了一个内核,就需要把 System.map 拷贝到 /boot,或告知 klogd 到什么位置查看。如果符号表与当前内核不匹配,klogd 就会拒绝解析符号。假如一个符号被解析在系统日志中,那么就有理由确信它已被正确解析了。

使用 ksymoops
有些时候,klogd 对于跟踪目的而言仍显不足。开发者经常既需要取得十六进制地址,又要获得对应的符号,而且偏移量也常需要以十六进制的形式打印出来。除了地址解码之外,往往还需要更多的信息。对 klogd 来说,在出错期间被杀掉,也是常用的事情。在这些情况下,可以调用一个更为强大的 oops 分析器,ksymoops 就是这样的一个工具。

在 2.3 开发系列之前,ksymoops 是随内核源码一起发布的,位于 scripts 目录之下。它现在则在自己的FTP 站点上,对它的维护是与内核相独立的。即使读者所用的仍是较早期的内核,或许还可以从 ftp://ftp.ocs.com.au/pub/ksymoops 站点上获取这个工具的升级版本。

为了取得最佳的工作状态,除错误消息之外,ksymoops 还需要很多信息;可以使用命令行选项告诉它在什么地方能找到这些各个方面的内容。ksymoops 需要下列内容项:

System.map 文件这个映射文件必须与 oops 发生时正在运行的内核相一致。默认为 /usr/src/linux/System.map。
模块列表ksymoops 需要知道 oops 发生时都装入了哪些模块,以便获得它们的符号信息。如果未提供这个列表,ksymoops 会查看 /proc/modules。
在 oops 发生时已定义好的内核符号表默认从 /proc/ksyms 中取得该符号表。
当前正运行的内核映像的复本注意,ksymoops 需要的是一个直接的内核映像,而不是象 vmlinuz、zImage 或 bzImage 这样被大多数系统所使用的压缩版本。默认是不使用内核映像,因为大多数人都不会保存这样的一个内核。如果手边就有这样一个符合要求的内核的话,就应该采用 -v 选项告知 ksymoops 它的位置。
已装载的任何内核模块的目标文件位置ksymoops 将在标准目录路径寻找这些模块,不过在开发中,几乎总要采用 -o 选项告知 ksymoops 这些模块的存放位置。

虽然 ksymoops 会访问 /proc 中的文件来取得它所需的信息,但这样获得的结果是不可靠的。在 oops 发生和 ksymoops 运行的时间间隙中,系统几乎一定会重新启动,这样取自 /proc 的信息就可能与故障发生时的实际状态不符合。只要有可能,最好在引起 oops 发生之前,保存 /proc/modules 和 /proc/ksyms 的复本。

我们强烈建议驱动程序开发人员阅读 ksymoops 的手册页,这是一个很好的资料文档。

这个工具命令行中的最后一个参数是 oops 消息的位置;如果缺少这个参数,ksymoops 会按Unix 的惯例去读取标准输入设备。运气好的话,消息可以从系统日志中重新恢复;在发生很严重的崩溃情况时,我们可能不得不将这些消息从屏幕上抄下来,然后再敲进去(除非用的是串口控制台,这对内核开发人员来说,是非常棒的工具)。

注意,当 oops 消息已经被 klogd 处理过时,ksymoops 将会陷于混乱。如果 klogd 已经运行,而且 oops 发生后系统仍在运行,那么经常可以通过调用 dmesg 命令来获得一个干净的 oops 消息。

如果没有明确地提供全部的上述信息,ksymoops 会发出警告。对于载入模块未作符号定义这类的情况,它同样会发出警告。一个不作任何警告的 ksymoops 是很少见的。

ksymoops 的输出类似如下:

>>EIP; c48370c3 <[faulty]faulty_write+3/20>   <=====
Trace; c01356e6 <sys_write+d6/100>
Trace; c010b860 <system_call+34/38>
Code;  c48370c3 <[faulty]faulty_write+3/20>
00000000 <_EIP>:
Code;  c48370c3 <[faulty]faulty_write+3/20>   <=====
 0:   c7 05 00 00 00    movl   $0x0,0x0   <=====
Code;  c48370c8 <[faulty]faulty_write+8/20>
 5:   00 00 00 00 00
Code;  c48370cd <[faulty]faulty_write+d/20>
 a:   31 c0             xorl   %eax,%eax
Code;  c48370cf <[faulty]faulty_write+f/20>
 c:   89 ec             movl   %ebp,%esp
Code;  c48370d1 <[faulty]faulty_write+11/20>
 e:   5d                popl   %ebp
Code;  c48370d2 <[faulty]faulty_write+12/20>
 f:   c3                ret    
Code;  c48370d3 <[faulty]faulty_write+13/20>
10:   8d b6 00 00 00    leal   0x0(%esi),%esi
Code;  c48370d8 <[faulty]faulty_write+18/20>
15:   00

正如上面所看到的,ksymoops 提供的 EIP 和内核堆栈信息与 klogd 所做的很相似,不过要更为准确,而且是十六进制形式的。可以注意到,faulty_write 函数的长度被正确地报告为 0x20个字节。这是因为 ksymoops 读取了模块的目标文件,并从中获得了全部的有用信息。

而且在这个例子中,还可以得到错误发生处代码的汇编语言形式的转储输出。这些信息常被用于确切地判断发生了些什么事情;这里很明显,错误在于一个向 0 地址写入数据 0 的指令。

ksymoops 的一个有趣特点是,它可以移植到几乎所有 Linux 可以运行的平台上,而且还利用了 bfd (二进制格式描述)库同时支持多种计算机结构。走出 PC 的世界,我们可以看到 SPARC64 平台上显示的 oops 消息是何等的相似(为了便于排版有几行被打断了):

Unable to handle kernel NULL pointer dereference
tsk->mm->context = 0000000000000734
tsk->mm->pgd = fffff80003499000
            \/ ____ \/
            "@'/ .. \`@"
           /_| \_ _/ |_\
              \_ _ _/
ls(16740): Oops
TSTATE: 0000004400009601 TPC: 0000000001000128 TNPC: 0000000000457fbc \
Y: 00800000
g0: 000000007002ea88 g1: 0000000000000004 g2: 0000000070029fb0 \
g3: 0000000000000018
g4: fffff80000000000 g5: 0000000000000001 g6: fffff8000119c000 \
g7: 0000000000000001
o0: 0000000000000000 o1: 000000007001a000 o2: 0000000000000178 \
o3: fffff8001224f168
o4: 0000000001000120 o5: 0000000000000000 sp: fffff8000119f621 \
ret_pc: 0000000000457fb4
l0: fffff800122376c0 l1: ffffffffffffffea l2: 000000000002c400 \
l3: 000000000002c400
l4: 0000000000000000 l5: 0000000000000000 l6: 0000000000019c00 \
l7: 0000000070028cbc
i0: fffff8001224f140 i1: 000000007001a000 i2: 0000000000000178 \
i3: 000000000002c400
i4: 000000000002c400 i5: 000000000002c000 i6: fffff8000119f6e1 \
i7: 0000000000410114
Caller[0000000000410114]
Caller[000000007007cba4]
Instruction DUMP: 01000000 90102000 81c3e008 <c0202000> \
30680005 01000000 01000000 01000000 01000000

请注意,指令转储并不是从引起错误的那个指令开始,而是之前的三条指令:这是因为 RISC 平台以并行的方式执行多条指令,这样可能产生延期的异常,因此必须能回溯最后的几条指令。

下面是当从 TSTATE 行开始输入数据时,ksymoops 所打印出的信息:

>>TPC; 0000000001000128 <[faulty].text.start+88/a0>   <=====
>>O7;  0000000000457fb4 <sys_write+114/160>
>>I7;  0000000000410114 <linux_sparc_syscall+34/40>
Trace; 0000000000410114 <linux_sparc_syscall+34/40>
Trace; 000000007007cba4 <END_OF_CODE+6f07c40d/????>
Code;  000000000100011c <[faulty].text.start+7c/a0>
0000000000000000 <_TPC>:
Code;  000000000100011c <[faulty].text.start+7c/a0>
 0:   01 00 00 00       nop
Code;  0000000001000120 <[faulty].text.start+80/a0>
 4:   90 10 20 00       clr  %o0     ! 0 <_TPC>
Code;  0000000001000124 <[faulty].text.start+84/a0>
 8:   81 c3 e0 08       retl
Code;  0000000001000128 <[faulty].text.start+88/a0>   <=====
 c:   c0 20 20 00       clr  [ %g0 ]   <=====
Code;  000000000100012c <[faulty].text.start+8c/a0>
10:   30 68 00 05       b,a   %xcc, 24 <_TPC+0x24> \
                      0000000001000140 <[faulty]faulty_write+0/20>
Code;  0000000001000130 <[faulty].text.start+90/a0>
14:   01 00 00 00       nop
Code;  0000000001000134 <[faulty].text.start+94/a0>
18:   01 00 00 00       nop
Code;  0000000001000138 <[faulty].text.start+98/a0>
1c:   01 00 00 00       nop
Code;  000000000100013c <[faulty].text.start+9c/a0>
20:   01 00 00 00       nop

要打印出上面显示的反汇编代码,我们就必须告知 ksymoops 目标文件的格式和结构(之所以需要这些信息,是因为 SPARC64 用户空间的本地结构是32位的)。本例中,使用选项 -t elf64-sparc -a sparc:v9 可进行这样的设置。

读者可能会抱怨对调用的跟踪并没带回什么值得注意的信息;然而,SPARC 处理器并不会把所有的调用跟踪记录保存到堆栈中:07 和 I7 寄存器保存了最后调用的两个函数的指令指针,这就是它们出现在调用跟踪记录边上的原因。在这个例子中,我们可以看到,故障指令位于一个由 sys_write 调用的函数中。

要注意的是,无论平台/结构是怎样的一种配合情况,用来显示反汇编代码的格式与 objdump 程序所使用的格式是一样的。objdump 是个很强大的工具;如果想查看发生故障的完整函数,可以调用命令: objdump -d faulty.o(再次重申,对于 SPARC64 平台,需要使用特殊选项:--target elf64-sparc-architecture sparc:v9)。

关于 objdump 和它的命令行选项的更多信息,可以参阅这个命令的手册页帮助。

学习对 oops 消息进行解码,需要一定的实践经验,并且了解所使用的目标处理器,以及汇编语言的表达习惯等。这样的准备是值得的,因为花费在学习上的时间很快会得到回报。即使之前读者已经具备了非 Unix 操作系统中PC 汇编语言的专门知识,仍有必要花些时间对此进行学习,因为Unix 的语法与 Intel 的语法并不一样。(在 as 命令 infor 页的“i386-specific”一章中,对这种差异进行了很好的描述。)

4.4.2  系统挂起
尽管内核代码中的大多数错误仅会导致一个oops 消息,但有时它们则会将系统完全挂起。如果系统挂起了,任何消息都无法打印。例如,如果代码进入一个死循环,内核就会停止进行调度,系统不会再响应任何动作,包括 Ctrl-Alt-Del 组合键。处理系统挂起有两个选择――要么是防范于未然;要么就是亡羊补牢,在发生挂起后调试代码。

通过在一些关键点上插入 schedule 调用可以防止死循环。schedule 函数(正如读者猜到的)会调用调度器,并因此允许其他进程“偷取”当然进程的CPU时间。如果该进程因驱动程序的错误而在内核空间陷入死循环,则可以在跟踪到这种情况之后,借助 schedule 调用杀掉这个进程。

当然,应该意识到任何对 schedule 的调用都可能给驱动程序带来代码重入的问题,因为 schedule 允许其他进程开始运行。假设驱动程序进行了合适的锁定,这种重入通常还并不致于带来问题。不过,一定不要在驱动程序持有spinlock 的任何时候调用 schedule。

如果驱动程序确实会挂起系统,而又不知该在什么位置插入 schedule 调用时,最好的方法是加入一些打印信息,并把它们写入控制台(通过修改 console_loglevel 的数值)。

有时系统看起来象挂起了,但其实并没有。例如,如果键盘因某种奇怪的原因被锁住了就会发生这种情况。运行专为探明此种情况而设计的程序,通过查看它的输出情况,可以发现这种假挂起。显示器上的时钟或系统负荷表就是很好的状态监视器;只要它保持更新,就说明 scheduler 正在工作。如果没有使用图形显示,则可以运行一个程序让键盘LED闪烁,或不时地开关软驱马达,或不断触动扬声器(通常蜂鸣声是令人烦恼的,应尽量避免;可改为寻求 ioctl 命令 KDMKTONE ),来检查 scheduler 是否工作正常。O’Reilly FTP站点上可以找到一个例子(misc-progs/heartbeat.c),它会使键盘LED不断闪烁。

如果键盘不接收输入,最佳的处理方法是从网络登录到系统中,杀掉任何违例的进程,或是重新设置键盘(用 kdb_mode -a)。然而,如果没有可用的网络用来帮助恢复的话,即使发现了系统挂起是由键盘死锁造成的也没有用了。如果是这样的情况,就应该配置一种可替代的输入设备,以便至少可以正常地重启系统。比起去按所谓的“大红钮”,在你的计算机上,通过替代的输入设备来关机或重启系统要更为容易些,而且它可以免去fsck 对磁盘的长时间扫描。

例如,这种替代输入设备可以是鼠标。1.10或更新版本的 gpm 鼠标服务器可以通过命令行选项支持类似的功能,不过仅限于文本模式。如果没有网络连接,并且以图形方式运行,则建议采用某些自定义的解决方案,比如,设置一个与串口线 DCD 针脚相连的开关,并编写一个查询 DCD 信号状态变化的脚本,用于从外界干预键盘已被死锁的系统。

对于上述情形,一个不可缺少的工具是“magic SysRq key”,2.2 和后期版本内核中,在其它体系结构上也可利用得到它。SysRq 魔法键是通过PC键盘上的 ALT 和 SysRq 组合键来激活的,在 SPARC 键盘上则是 ALT 和 Stop 组合键。连同这两个键一起按下的第三个键,会执行许多有用动作中的其中一种,这些动作如下:

r
在无法运行 kbd_mode 的情况中,关闭键盘的 raw 模式。

k
激活“留意安全键”(SAK)功能。SAK 将杀掉当前控制台上运行的所有进程,留下一个干净的终端。

s
对所有磁盘进行紧急同步。

u
尝试以只读模式重新挂装所有磁盘。这个操作通常紧接着 s 动作之后被调用,它可以在系统处于严重故障状态时节省很多检查文件系统的时间。

b
立即重启系统。注意先要同步并重新挂装磁盘。

p
打印当前的寄存器信息。

t
打印当前的任务列表。

m
打印内存信息。

还有其它的一些 SysRq 功能;要获得完整列表,可参阅内核源码 Documentation 目录下的sysrq.txt 文件。注意,SysRq 功能必须明确地在内核配置中被开启,出于安全原因,大多数发行系统并未开启它。不过,对于一个用于驱动程序开发的系统来说,为开启 SysRq 功能而带来的重新编译新内核的麻烦是值得的。SysRq 必须在运行时通过下面的命令启动:

echo 1 > /proc/sys/kernel/sysrq

在复现系统的挂起故障时,另一个要采取的预防措施是,把所有的磁盘都以只读的方式挂装在系统上(或干脆就卸装它们)。如果磁盘是只读的或者并未挂装,就不会发生破坏文件系统或致使文件系统处于不一致状态的危险。另一个可行方法是,使用通过 NFS (网络文件系统)将其所有文件系统挂装入系统的计算机。这个方法要求内核具有“NFS-Root”的能力,而且在引导时还需传入一些特定参数。如果采用这种方法,即使我们不借助于 SysRq,也能避免任何文件系统的崩溃,因为NFS 服务器管理文件系统的一致性,而它并不受驱动程序的影响。

4.5  调试器和相关工具
最后一种调试模块的方法就是使用调试器来一步步地跟踪代码,查看变量和计算机寄存器的值。这种方法非常耗时,应该尽量避免。不过,某些情况下通过调试器对代码进行细粒度的分析是很有价值的。

在内核中使用交互式调试器是一个很复杂的问题。出于对系统所有进程的整体利益考虑,内核在它自己的地址空间中运行。其结果是,许多用户空间下的调试器所提供的常用功能很难用于内核之中,比如断点和单步调试等。本节着眼于调试内核的几种方法;它们中的每一种都各有利弊。

4.5.1  使用 gdb
gdb在探究系统内部行为时非常有用。在我们这个层次上,熟练使用调试器,需要掌握 gdb 命令、了解目标平台的汇编代码,还要具备对源代码和优化后的汇编码进行匹配的能力。

启动调试器时必须把内核看作是一个应用程序。除了指定未压缩的内核映像文件名外,还应该在命令行中提供“core 文件”的名称。对于正运行的内核,所谓 core 文件就是这个内核在内存中的核心映像,/proc/kcore。典型的 gdb 调用如下所示:

gdb /usr/src/linux/vmlinux /proc/kcore

第一个参数是未经压缩的内核可执行文件的名字,而不是 zImage 或 bzImage 以及其他任何压缩过的内核。

gdb 命令行的第二个参数是是 core 文件的名字。与其它 /proc中的文件类似,/proc/kcore也是在被读取时产生的。当在 /proc文件系统中执行 read 系统调用时,它会映射到一个用于数据生成而不是数据读取的函数上;我们已在“使用 /proc文件系统”一节中介绍了这个特性。kcore 用来按照 core 文件的格式表示内核“可执行文件”;由于它要表示对应于所有物理内存的整个内核地址空间,所以是一个非常巨大的文件。在 gdb 的使用中,可以通过标准gdb命令查看内核变量。例如,p jiffies可以打印从系统启动到当前时刻的时钟滴答数。

从gdb 打印数据时,内核仍在运行,不同数据项的值会在不同时刻有所变化;然而,gdb为了优化对 core 文件的访问,会将已经读到的数据缓存起来。如果再次查看jiffies变量,仍会得到和上次一样的值。对通常的 core 文件来说,对变量值进行缓存是正确的,这样可避免额外的磁盘访问。但对“动态的”core 文件来说就不方便了。解决方法是在需要刷新gdb 缓冲区的时候,执行命令core-file /proc/kcore;调试器将使用新的 core 文件并丢弃所有的旧信息。不过,读新数据时并不总是需要执行core-file 命令;gdb 以几KB大小的小数据块形式读取 core 文件,缓存的仅是已经引用的若干小块。

对内核进行调试时,gdb 通常能提供的许多功能都不可用。例如,gdb 不能修改内核数据;因为在处理其内存映像之前,gdb 期望把待调试程序运行在自己的控制之下。同样,也不能设置断点或观察点,或者单步跟踪内核函数。

如果用调试选项(-g)编译了内核,产生的 vmlinux 会比没有使用 -g选项的更适合于gdb。不过要注意,用 -g选项编译内核需要大量的磁盘空间(每个目标文件和内核自身都会比通常的大三倍甚至更多)。

在非PC类计算机上,情况则不尽相同。在 Alpha 上,make boot会在生成可启动映像前将调试信息去掉,所以最终会获得 vmlinux 和 vmlinux.gz 两个文件。gdb 可以使用前者,后者用来启动。在SPARC上,默认情况则是不把内核(至少是2.0内核)调试信息去掉。

当用 -g选项编译内核并且和 /proc/kcore一起使用 vmlinux 运行调试器时,gdb 可以返回很多内核内部信息。例如,可以使用下面的命令来转储结构数据,如p *module_list、p *module_list->next 和 p *chrdevs[4]->fops 等。为了在使用 p 命令时取得最好效果,有必要保留一份内核映射表和随手可及的源码。

利用 gdb 可在当前内核上执行的另一个有用任务是,通过disassemble命令(可缩写为 disass )或是“检查指令”(x/i)命令对函数进行反汇编。disassemble 命令的参数可以是函数名或是内存范围;而 x/i 则使用一个内存地址做为参数,也可以是符号名称的形式。例如,可以用 x/20i 反汇编 20 条指令。注意,不能反汇编一个模块的函数,因为调试器作用的是 vmlinux,它并不知道模块的情况。如果试图通过地址反汇编模块代码,gdb 很有可能会返回“Cannot access memory at xxxx(不能访问 xxxx 处的内存)”这样的信息。基于同样的原因,也不能查看属于模块的数据项。如果已知道变量的地址,可以从 /dev/mem 中读出它们的值,但要弄明白从系统内存中分解出的原始数据的含义,难度是相当大的。

如果需要反汇编模块函数,最好对模块的目标文件用 objdump 工具进行处理。很不幸,该工具只能对磁盘上的文件复本进行处理,而不能对运行中的模块进行处理;因此,由objdump给出的地址都是未经重定位的地址,与模块的运行环境无关。对未经链接的目标文件进行反汇编的另一个不利因素在于,其中的函数调用仍是未作解析的,所以就无法轻松地区分是对 printk 的调用呢,还是对 kmalloc 的调用。

正如上面看到的,当目的在于查看内核的运行情况时,gdb是一个有用的工具,但对于设备驱动程序的调试,它还缺少一些至关重要的功能。

4.5.2  kdb 内核调试器
很多读者可能会奇怪这一点,即为什么不把一些更高级的调试功能直接编译进内核呢。答案很简单,因为 Linus 不信任交互式的调试器。他担心这些调试器会导致一些不良的修改,也就是说,修补的仅是一些表面现象,而没有发现问题的真正原因所在。因此,没有在内核中内建调试器。

然而,其他的内核开发人员偶尔也会用到一些交互式的调试工具。 kdb 就是其中一种内建的内核调试器,它在 oss.sgi.com 上以非正式的补丁形式提供。要使用 kdb,必须首先获得这个补丁(取得的版本一定要和内核版本相匹配),然后对当前内核源码进行 patch 操作,再重新编译并安装这个内核。注意,kdb 仅可用于 IA-32(x86) 系统(虽然用于 IA-64 的一个版本在主流内核源码中短暂地出现过,但很快就被删去了)。

一旦运行的是支持 kdb 的内核,有几个方法可以进入 kdb 的调试状态。在控制台上按下 Pause(或 Break)键将启动调试。当内核发生 oops,或到达某个断点时,也会启动 kdb。无论是哪一种情况,都看到下面这样的消息:

Entering kdb (0xc1278000) on processor 1 due to Keyboard Entry [1]kdb>  

注意,当 kdb 运行时,内核所做的每一件事情都会停下来。当激活 kdb 调试时,系统不应运行其他的任何东西;尤其是,不要开启网络――当然,除非是在调试网络驱动程序。一般来说,如果要使用 kdb 的话,最好在启动时进入单用户模式。

作为一个例子,考虑下面这个快速的 scull 调试过程。假定驱动程序已被载入,可以象下面这样指示 kdb 在 scull_read 函数中设置一个断点:

[1]kdb> bp scull_read
Instruction(i) BP #0 at 0xc8833514 (scull_read)     is enabled on cpu 1

[1]kdb> go

bp 命令指示 kdb 在内核下一次进入 scull_read 时停止运行。随后我们输入 go 继续执行。在把一些东西放入 scull 的某个设备之后,我们可以在另一个终端的 shell 中运行 cat 命令尝试读取这个设备,这样一来就会产生如下的状态:

Entering kdb (0xc3108000) on processor 0 due to Breakpoint @ 0xc8833515
Instruction(i) breakpoint #0 at 0xc8833514
scull_read+0x1:   movl   %esp,%ebp
[0]kdb>

我们现在正处于 scull_read 的开头位置。为了查明是怎样到达这个位置的,我们可以看看堆栈跟踪记录:

[0]kdb> bt
  EBP       EIP         Function(args)
0xc3109c5c 0xc8833515  scull_read+0x1
0xc3109fbc 0xfc458b10  scull_read+0x33c255fc( 0x3, 0x803ad78, 0x1000,
0x1000, 0x804ad78)
0xbffffc88 0xc010bec0  system_call
[0]kdb>

kdb 试图打印出调用跟踪所记录的每个函数的参数列表。然而,它往往会被编译器所使用的优化技巧弄糊涂。所以在这个例子中,虽然 scull_read 实际只有四个参数,kdb 却打印出了五个。

下面我们来看看如何查询数据。mds 命令是用来对数据进行处理的;我们可以用下面的命令查询 scull_devices 指针的值:

[0]kdb> mds scull_devices 1
c8836104: c4c125c0 ....

在这里,我们请求查看的是从 scull_devices 指针位置开始的一个字大小(4个字节)的数据;应答告诉我们设备数据数组的起始地址位于 c4c125c0。要查看设备结构自身的数据值,我们需要用到这个地址:

[0]kdb> mds c4c125c0
c4c125c0: c3785000  ....
c4c125c4: 00000000  ....
c4c125c8: 00000fa0  ....
c4c125cc: 000003e8  ....
c4c125d0: 0000009a  ....
c4c125d4: 00000000  ....
c4c125d8: 00000000  ....
c4c125dc: 00000001  ....

上面的8行分别对应于 Scull_Dev 结构中的8个成员。因此,通过显示的这些数据,我们可以知道,第一个设备的内存是从 0xc3785000 开始分配的,链表中没有下一个数据项,量子大小为 4000(十六进制形式为 fa0)字节,量子集大小为 1000(十六进制形式为 3e8),这个设备中有 154 个字节(十六进制形式为 9a)的数据,等等。

kdb 还可以修改数据。假设我们要从设备中削减一些数据:

[0]kdb> mm c4c125d0 0x50
0xc4c125d0 = 0x50

接下来对设备的 cat 操作所返回的数据就会少于上次。

kdb 还有许多其他的功能,包括单步调试(根据指令,而不是C源代码行),在数据访问中设置断点,反汇编代码,跟踪链表,访问寄存器数据等等。加上 kdb 补丁之后,在内核源码树的 Documentation/kdb 目录可以找到完整的手册页。

4.5.3  集成的内核调试器补丁
有很多内核开发人员为一个名为“集成的内核调试器”的非正式补丁作出过贡献,我们可将其简称为 IKD(integrated kernel debugger)。IKD 提供了很多值得关注的内核调试工具。x86 是这个补丁的主要平台,不过它也可以用于其它的结构体系之上。IKD 补丁可以从 ftp://ftp.kernel.org/pub/linux/kernel/people/andrea/ikd 下载。它是一个必须应用于内核源码的patch 补丁;因为这个 patch 是与版本相关的,所以要确保下载的补丁与正使用的内核版本相一致。

IKD 补丁的功能之一是内核堆栈调试。如果开启这个功能,内核就会在每个函数调用时检查内核堆栈的空闲空间的大小,如果过小的话就会强制产生一个 oops。如果内核中的某些事情引起堆栈崩溃,这个工具就能用来帮助查找问题。这其实也就是一种“堆栈计量表”的功能,可以在任何特定的时刻查看堆栈的填充程度。

IKD 补丁还包含了一些用于发现内核死锁的工具。如果某个内核过程持续时间过久而没有得到调度的话,“软件死锁”探测器就会强制产生一个 oops。这是简单地通过对函数调用进行计数来实现的,如果计数值超过了一个预定义的阈值,探测器就会动作,并中止一些工作。IKD 的另一个功能是可以连续地把程序计数器打印到虚拟控制台上,这可以作为跟踪死锁的最后手段。“信号量死锁”探测器则是在某个进程的 down 调用持续时间过久时强制产生 oops。

IKD 中的其它调试功能包括内核的跟踪功能,它可以记录内核代码的执行路径。还有一些内存调试工具,包括一个内存泄漏探测器和一些称为“poisoner”的工具,它们在跟踪内存崩溃问题时非常有用。

最后,IKD 也包含前一节讨论过的 kdb 调试器。不过,IKD 补丁中的 kdb 版本有些老。如果需要 kdb 的话,我们推荐直接从 oss.sgi.com 获取当前的版本。

4.5.4  kgdb 补丁
kgdb 是一个在Linux 内核上提供完整的 gdb 调试器功能的补丁,不过仅限于 x86 系统。它通过串口连线以钩子的形式挂入目标调试系统进行工作,而在远端运行 gdb。使用 kgdb 时需要两个系统――一个用于运行调试器,另一个用于运行待调试的内核。和 kdb 一样,kgdb 目前可从 oss.sgi.com 获得。

设置 kgdb包括安装内核补丁并引导打过补丁之后的内核两个步骤。两个系统之间需要通过串口电缆(或空调制解调器电缆)进行连接,在 gdb 这一侧,需要安装一些支持文件。kgdb 补丁把详细的用法说明放在了文件 Documentation/i386/gdb-serial.txt 中;我们在这里就不再赘述。建议读者阅读关于“调试模块”的说明:接近末尾的地方,有一些出于这个目的而编写的很好的 gdb 宏。

4.5.5  内核崩溃转储分析器
崩溃转储分析器使系统能把发生 oops 时的系统状态记录下来,以便在随后空闲的时候查看这些信息。如果是对于一个异地用户的驱动程序进行支持,这些工具就会特别有用。用户可能不太愿意把 oops 复制下来,因此安装崩溃转储系统可以使技术支持人员不必依赖于用户的工作,也能获得用于跟踪用户问题的必要信息。也正是出于这样的原因,可供利用的崩溃转储分析器都是由那些对用户系统进行商业支持的公司开发的,这也就不足为奇了。

目前有两个崩溃转储分析器的补丁可以用于 Linux。在编写本节的时候,这两个工具都比较新,而且都处在不断的变化之中。与其提供可能已经过时的详细信息,我们倒不如只是给出一个概观,并指点读者在哪里可以找到更多的信息。

第一个分析器是 LKCD(Linux Kernel Crash Dumps,“Linux内核崩溃转储”)。这个工具仍可以从 oss.sgi.com 上获得。当内核发生 oops 时,LKCD 会把当前系统状态(主要指内存)写入事先指定好的转储设备中。这个转储设备必须是一个系统交换区。下次重启中(在存储交换功能开启之前)系统会运行一个称为 LCRASH 的工具,来生成崩溃的概要记录,并可选择地把转储的复本保存在一个普通文件中。LCRASH 可以交互方式地运行,提供了很多调试器风格的命令,用以查询系统状态。

LKCD 目前只支持 Intel 32位体系结构,并只能用在 SCSI 磁盘的交换分区上。

另一个崩溃转储设施可以从 www.missioncriticallinux.com 获得。这个崩溃转储子系统直接在目录 /var/dumps 中创建崩溃转储文件,而且并不使用交换区。这样就使某些事情变得更为容易,但也意味着在知道问题已经出现在哪里的时候,文件系统已被系统修改。生成的崩溃转储的格式是标准的 core 文件格式,所以可以利用 gdb 这类工具进行事后的分析。这个工具包也提供了另外的分析器,可以从崩溃转储文件中解析出比 gdb 更丰富的信息。

4.5.6  用户模式的 Linux 虚拟机
用户模式 Linux 是一个很有意思的概念。它作为一个独立的可移植的 Linux 内核而构建,包含在子目录 arch/um 中。然而,它并不是运行在某种新的硬件上,而是运行在基于 Linux 系统调用接口所实现的虚拟机之上。因此,用户模式 Linux 可以使 Linux 内核成为一个运行在 Linux 系统之上单独的、用户模式的进程。

把一个内核的复本当作用户模式下的进程来运行可以带来很多好处。因为它运行在一个受约束的虚拟处理器之上,所以有错误的内核不会破坏“真正的”系统。对软/ 硬件的不同配置可以在相同的框架中轻易地进行尝试。并且,对于内核开发人员来说最值得注目的特点在于,可以很容易地利用 gdb 或其它调试器对用户模式 Linux 进行处理。归根结底,它只是一个进程。很明显,用户模式 Linux 有潜力加快内核的开发过程。

迄今为止,用户模式 Linux 虚拟机还未在主流内核中发布;要下载它,必须访问它的 web 站点(http://user-mode-linux.sourceforge.net)。需要提醒的是,它仅可以集成到 2.4.0 之后的早期 2.4 内核版本中;当然等到本书出版的时候,版本支持方面可能会做得更好。

目前,用户模式 Linux 虚拟机也存在一些重大的限制,不过大部分可能很快就会得到解决。虚拟处理器当前只能工作于单处理器模式;虽然虚拟机可以毫无问题地运行在 SMP 系统上,但它仍是把主机模拟成单 CPU 模式。不过,对于驱动编写者来说,最大的麻烦在于,用户模式内核不能访问主机系统上的硬件设备。因此,尽管用户模式 Linux虚拟机对于本书中的大多数样例驱动程序的调试非常有用,却无法用于调试那些处理实际硬件的驱动程序。最后一点,用户模式 Linux虚拟机仅能运行在 IA-32 体系结构之上。

因为对所有这些问题的修补工作正在进行之中,所以在不远的将来,对于 Linux 设备驱动程序的开发人员,用户模式 Linux虚拟机可能会成为一个不可或缺的工具。

4.5.7  Linux 跟踪工具包
Linux 跟踪工具包(LTT)是一个内核补丁,包含了一组可以用于内核事件跟踪的相关工具集。跟踪内容包括时间信息,而且还能合理地建立在一段指定时间内所发生事件的完整图形化描述。因此,LTT不仅能用于调试,还能用来捕捉性能方面的问题。

在 Web 站点 www.opersys.com/LTT 上,可以找到 LTT 以及大量的资料。

4.5.8  Dynamic Probes
Dynamic Probes (或 DProbes )是 IBM 为基于 IA-32 结构的Linux 发布的一种调试工具(遵循 GPL 协议)。它可以在系统的几乎任何一个地方放置一个“探针”,既可以是用户空间也可以是内核空间。这个探针由一些当控制到达指定地点即开始执行的代码(用一种特别设计的,面向堆栈的语言编写)组成。这种代码能把信息传送回用户空间,修改寄存器,或者完成许多其它的工作。DProbes 很有用的特点是,一旦内核编译进了这个功能,探针就可以插到一个运行系统的任一个位置,而无需重建内核或重新启动。DProbes 也可以协同 LTT 工具在任意位置插入新的跟踪事件。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。如发现有害或侵权内容,请点击这里 或 拨打24小时举报电话:4000070609 与我们联系。

    猜你喜欢

    0条评论

    发表

    请遵守用户 评论公约

    类似文章
    喜欢该文的人也喜欢 更多