1.调试技术
内核编程带来了它自己的,独特的调试挑战。内核代码不能简单地在调试器中执行,也不能被简单地跟踪,因为它是一组不与特定进程相关的功能。内核代码的错误非常难重现并且可能导致整个系统崩溃,因此破坏很多用来发现它们的证据。
本章将介绍在如此恼人的情况下你可以用来监视内核代码和跟踪错误的技术。
1.1.内核中的调试支持
在第二章中,我们建议你编译和安装你自己的内核,而不是运行你所使用的发行版中的原始内核。运行你自己的内核
的最有力理由是内核开发者已经在内核中构建了很多调试特性。这些特性会创建额外的输出并使系统运行变慢,因此它们在经销商发行的内核中没有被激活。但是,
作为一个内核开发者,你有不同的优先权并乐意地接受(最小的)内核调试支持带来的额外开销。
这里,我们列出应该在用于开发的内核中激活的配置选项。除非特别指明,否则,无论你使用的是你喜欢的任何内核配置工具,所有的这些选项都可以在“kernel hacking”菜单下找到。注意有些选项并不被所有的体系结构支持。
CONFIG_DEBUG_KERNEL
该选项只是使其它的调试选项可用;它必须被打开,但是,它自己不打开任何特性。
CONFIG_DEBUG_SLAB
这
个选项打开对许多种类型的内核内存分配函数的检查;激活这些检查,就可能发现许多内存越界和未初始化错误。每一个分配的内存字节在传递给调用者之前设置为
0xa5,释放之后设置为0x6b。如果你曾经看到这些“毒物”中的任意一个重复出现在你的驱动程序的输出中(或者经常出现在一个oops列表中),你就
能确切地知道该去找寻什么样的错误。当调试选项被激活的时候,内核在分配内存对象之前和之后都在其中放置特殊的监视值;如果这些值曾经被修改,内核就知道
有内存分配已经越界,它就会不客气地提出警告。许多其它不显眼的错误检查也被激活。
CONFIG_DEBUG_PAGEALLOC
页面被释放时是整个的从内核地址空间中移除的。该选项显著地降低了速度,但它也能迅速指出特定类型的内存崩溃错误。
CONFIG_DEBUG_SPINLOCK
激活该选项,内核捕捉对未初始化自旋锁的操作和各种其它错误(比如解锁一个锁两次)。
CONFIG_DEBUG_SPINLOCK_SLEEP
该选项能执行对持有自旋锁的时候试图睡眠的检查。事实上,如果你调用一个可能潜在地睡眠的函数,它会提出警告,即使调用的函数不可能睡眠。
CONFIG_INIT_DEBUG
有__init(或__initdata)标记的项在系统初始化或模块加载时被丢弃。该选项使你能对在初始化完成之后试图访问初始化时的内存的代码进行检查。
CONFIG_DEBUG_INFO
该选项使得内核编译时包含完整的调试信息。如果你想使用gdb来调试内核,你就需要这些信息。如果你计划使用gdb,你可能也想激活CONFIG_FRAME_POINTER选项。
CONFIG_MAGIC_SYSRQ
激活“神奇的系统请求键”。我们将在本章随后的“系统挂起”一节中看到这些键。
CONFIG_DEBUG_STACKOVERFLOW
CONFIG_DEBUG_STACK_USAGE
这些选项可以帮助追踪内核的栈溢出。栈溢出的确定迹象是一个没有任何合理的回溯线索。第一个选项给内核添加明确的溢出检查;第二个让内核监视栈使用并通过神奇的系统请求键提供一些可用的数据。
CONFIG_KALLSYMS
该选项(位于“General setup/Standard features”)使得内核符号信息编译到内核中;它默认是激活的。符号信息用来调试上下文;没有它,oops列表仅能让你以十六进制的方式回溯内核,这种方法没有什么用处。
CONFIG_IKCONFIG
CONFIG_IKCONFIG_PROC
这些选项(在“General setup”菜单下)使完整的内核配置状态编译进内核并通过/proc可用。大多数的内核开发者了解他们使用的配置,所以不需要这个选项(它使内核变得更大)。如果你尝试调试其它人编译的内核中的问题,它会非常有帮助。
CONFIG_ACPI_DEBUG
位于“Power management/ACPI”中。该选项打开详细的ACPI(Advanced Configuration and Power Interface)调试信息,当你怀疑出现ACPI相关的问题时将非常有用。
CONFIG_DEBUG_DRIVER
位于“Device drivers”。打开驱动程序核心的调试信息,跟踪底层支持代码产生的问题时很有用。我们将在第14章学习驱动程序核心(driver core)。
CONFIG_SCSI_CONSTANTS
该选项位于“Device drivers/SCSI device support”。它可以建立详细的SCSI错误信息。如果你编写一个SCSI驱动程序,你可能想激活这个选项。
CONFIG_INPUT_EVBUG
该选项(位于“Device drivers/Input device support”)打开对输入事件的详细记录。如果你在为一个输入设备编写驱动程序,这个选项可能非常有帮助。这个选项有潜在的安全问题,因为它记录你输入的所有信息,包括你的密码。
CONFIG_PROFILING
该选项在“Profiling support”中。Profiling通常用于系统性能调节,但是它对跟踪一些内核挂起及相关的错误也非常有用。
我们将在我们学习跟踪内核问题的许多方法时重新遇到上面的其中一些选项。但首先,我们来看看经典的调试技术:print语句。
1.2.打印调试信息
最常用的调试技术是监视,在应用程序中通过在适当的地方调用printf语句来完成。当你调试内核代码的时候,你可以使用printk实现相同的目标。
1.2.1.printk
我们在前面的章节中使用printk函数时简单地假设它像pirntf一样工作。现在是时候来介绍一些不同之处了。
一
个不同之处就是printk可以让你根据信息的不同记录级别或优先级来严格地分类要记录的信息。通常用一个宏来表示记录级别。例如,我们已经在一些前面的
print语句中看到过的KERN_INFO,是一个可能信息的记录级别。表示记录级别的宏扩展为一个字符串,在编译时连结到信息文本中;这就是为什么下
面的两个例子中在优先级与格式化字符串之间没有逗号的原因。这里是prink命令的两个例子,一条调试信息和个临界情况信息:
printk(KERN_DEBUG "Here I am: %s:%i\n", __FILE__, __LINE__);
printk(KERN_CRIT "I'm trashed; giving up on %p\n", ptr);
有8种可能的记录级别字符串,它们定义在头文件中;我们按照以重要性递减的顺序来列出它们:
KERN_EMERG
用于紧急情况信息,通常是那些在系统崩溃之前的信息。
KERN_ALERT
一个必须被立即处理的错误。
KERN_CRIT
临界情况,常常与严重的硬件或软件失败相关。
KERN_ERR
用于报告错误情况;设备驱动程序常常使用KERN_ERR报告一个硬件问题。
KERN_WARNING
对一个有问题的情况提出警告,这些情况并不对系统造成严重的问题。
KERN_NOTICE
一个普通的,不过也有可能需要注意到的情况。许多安全相关的情况都是在这个级别被报告的。
KERN_INFO
提供信息的消息。很多的驱动程序在这个级别打印它们在启动时找到的硬件的信息。
KERN_DEBUG
用于调试信息。
每一个字符串(宏扩展)表示一个在尖括号中的整数。整数范围从0到7,更小的值表示更高的优先级。
没
有指定优先级的prink语句的默认是DEFAULT_MESSAGE_LOGLEVEL,在kernel/printk.c中作为一个整型指定。在
2.6.10内核中,DEFAULT_MESSAGE_LOGLEVEL是KERN_WARNING,它已经与过去的不一样了。
以记录级别为基
础,内核可能向当前控制台打印信息,它可能是一个文本模式的终端,一个串口,或一个并行打印机。如果优先级比整型变量console_loglevel的
小,信息将会每次向控制台传递一行(只有遇到换行符的时候信息才会被传递)。如果klogd和syslogd都在系统中运行,内核信息追加在/var
/log/messages文件(或根据syslogd的配置来处理)中,与console_loglevel无关。如果klogd不在运行,除非你读取
/proc/kmsg文件(通常最简单的方法是使用dmesg方法),信息将不会到达用户空间。如果使用klogd,你要记住它不保存相同的连续的行;它
仅保存第一行,随后跟着它收到的重复的行的数目。
console_loglevel变量被初始化为
DEFAULT_CONSOLE_LOGLEVEL并且它可以通过sys_syslog系统调用来修改。一种方法是在调用klogd的时候指定-c开关,
就像klogd的帮助页说明的一样。注意要改变当前值,你必须首先杀死klogd然后重新使用-c选项启动它。另外的选择是写一个程序区改变控制台的记录
级别。你可以在O'Reilly的FTP站点提供的源代码文件中的找到一个这样的程序,它在misc-progs/setlevel.c文件中。新的级别
以一个1和8之间的整型值指定,包含1和8。如果它设置为1,那么仅有0级别(KERN_EMERG)的信息能到达控制台;如果设置为8,那么所有的信
息,包括调试信息,都在控制台显示。
也可以通过修改/proc/sys/kernel/printk文本文件来改变控制台的记录级别。这个文件包
含四个整型值:当前记录级别,缺乏明确记录级别的信息的默认级别,允许的最小级别,和系统引导时的默认级别。向文件中写入一个值就将当前的记录级别改变为
该值;如此,你可以使所有的内核信息出现在控制台上,例如,通过输入:
#echo 8 > /proc/sys/kernel/printk
现在应该非常清楚为什么hello.c样例代码使用KERN_ALERT标志了;它可以保证信息显示在控制台上。
1.2.2.重定向控制台信息
Linux在控制台的日志记录策略允许你把信息发送到一个指定的虚拟控制台(如果你的控制台是一个文本屏
幕)来提供一定的灵活性。默认情况下,“控制台”就是当前的虚拟终端。你可以在任何控制台设备上使用ioctl(TIOCLINUX)来选择一个不同的虚
拟终端来接收信息。下面的setconsole程序可以用来选择哪一个控制台接收内核信息;它在misc-progs目录下并且它必须由根用户来运行。
下面是整个的程序代码。你应该在调用它的时候使用一个参数来指定接收信息的控制台数目。
int main(int argc, char **argv)
{
/* 11 is the TIOCLINUX cmd number */
char bytes[2] = {11, 0};
if (argc == 2)
bytes[1] = atoi(argv[1]); /* the chosen console */
else {
fprintf(stderr, "%s: need a single arg\n", argv[0]);
exit(1);
}
/* use stdin */
if (ioctl(STDIN_FILENO, TIOCLINUX, bytes)
1.2.3.信息是怎样被记录的
printk函数把信息写入到一个__LOG_BUF_LEN字节长的循环缓冲区中:一个在配置内核的
时候选择的介于4KB到1MB之间的值。函数接着唤醒等待信息的所有进程,即那些在syslog系统调用或读取/proc/kmsg时睡眠的进程。这两个
记录引擎几乎是相同的,但是请注意从/proc/kmsg读取信息时会消耗记录缓冲区中的数据,然而syslog系统调用能随意地返回记录数据并把它留给
其它进程。一般来说,读取/proc文件更简单并且是klogd的默认行为。dmesg命令可以用来查看缓冲区的内容而不冲洗(清除)它的内容;事实上,
该命令将缓冲区的所有内容返回到stdout(标准输出)中,不论它是否已经被读取过。
如果你碰巧手工读取内核信息,停止klogd后,你会发现/proc文件就像一个FIFO,因为读者阻塞,等待更多的数据。很明显,如果klogd或其它的进程已经读取了相同的数据,你就不能使用这种方法,因为会发生竞争。
如
果循环缓冲区被填满,prink绕回并且在缓冲区开始的地方开始添加新数据,覆盖最老的数据。因此,记录进程失去最老的数据。与使用循环缓冲区的优势相比
这个问题可以忽略不计。例如,循环缓冲区允许系统在没有记录进程的情况下运行,虽然因为覆盖旧的数据浪费了少量的内存。Linux记录信息的方法的另一个
特点是prink函数可以从任何地方调用,甚至从一个中断处理程序,没有打印多少数据的限制。唯一的缺点就是可能丢失一些数据。
如果klogd进
程正在运行,它取回内核信息并发送它们到syslogd,由syslogd来检查/etc/syslog.conf文件以决定怎样处理它们。
syslogd区分根据设施和优先级来区分数据;设施和优先级的允许值都定义在中。内核信息以LOG_KERN
设施在一个printk中使用的相应的优先级被记录(比如,LOG_ERR对应KERN_ERR信息)。如果klogd没有运行,数据存在于循环缓冲区中
只到被读取或缓冲区溢出。
如果你想避免你的驱动程序的监视信息拉跨你的系统记录,你能为klogd指定-f(文件)选项以指示klogd在一个指
定的文件中保存信息,或者定制/etc/syslog.conf文件来适应你的需要。还有一种可能就是采用暴力的方法:杀死klogd并在一个没有使用的
虚拟终端打印详细的信息 ,或在一个没有使用的xterm上使用cat /proc/kmsg命令。
1.2.4.打开和关闭信息
在驱动程序开发的初始阶段,prink能很好地帮助你调试和测试你的新代码。但是,当你正式地发布你的驱动
程序的时候,你必须移除,或者至少禁止这样的打印语句。不辛的是,你可能会发现一旦你认为你不再需要这些信息并移除它们,你在驱动程序中实现一个新功能
(或者有人发现一个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)
# "-O" is needed to expand inlines
DEBFLAGS = -O -g -DSCULL_DEBUG
else
DEBFLAGS = -O2
endif
CFLAGS += $(DEBFLAGS)
本节出现的宏依赖于ANSI C预处理器的gcc扩展,该扩展支持可变数目参数的宏。这种gcc依赖并不是一个难题,因为内核也严重地依赖于gcc的特性。此外,makefile依赖于GNU的make;再一次,内核已经依赖于GNU make,所以该依赖也不是问题。
如果你熟悉C预处理器,你可以扩展已经给出的定义来实现“调试级别”的概念,定义不同的级别并指派一个整型值(或为掩码)来决定每一个级别的信息有多么详细。
但
是每个驱动程序有它自己的特性和监视需求。好的编程艺术就是在灵活性和效率之间作出最好的选择,但我们也不能告诉你什么是最好的选择。记住有条件的预处理
代码(代码中的常量表达式也一样)是在编译时执行的,因此你必须通过重新编译来打开或关闭信息。一个可能的选择是使用在运行时执行的C条件语句,这样,就
允许你在运行时关闭和打开信息。这是一个很好的方法,但是每次代码执行时都需要额外的处理,即使信息被禁用时也会影响性能。
本节展示的宏被证明在许多情况下都非常有用,唯一的缺陷就是它的信息的任何改变都需要重新编译该模块。
1.2.5.速率限制
如果你不小心,你会发现你的printk语句产生了成千上万的信息,塞满控制台或可能溢出系统的日志文件。当使用
的是慢速的控制台设备(比如,一个串行端口)时,过分的信息速率会拖慢系统速度或使系统变得反应迟钝。当控制台被不中断的数据拥塞时,你很难处理并发现系
统出了什么问题。因此,你必须非常小心你要打印什么样的信息,特别是在驱动程序的发布版本和一旦初始化完成后。一般来说,产品的代码中决不该在普通的操作
中打印任何信息;打印出的信息应该指示一个值得注意的异常情况。
另一方面,你想在你驱动的设备停止工作之后发出一个记录信息。但你必须注意不要做
过度的事情。一个愚蠢的永远运行的进程面对错误时每秒能产生成千上万的重试操作;如果你的驱动程序每次都打印“设备坏掉”的信息,它能产生大量的输出,如
果控制台设备很慢,它有可能过多地占用CPU——没有中断可以用来控制终端,即使它是一个串口或行式打印机。
很多情况下,最好的方法是设置一个标志来说明,“我已经输出过这个提示信息了”,一旦标志被设置,就不再输出任何进一步的信息。在有些情况下,也有偶尔发出“设备仍有错误”的提示的理由。内核提供了能在这些情况下有帮助的函数:
int printk_ratelimit(void);
这个函数应该在你考虑打印一个经常重复的信息之前调用。如果函数返回一个非0值,继续打印你的信息,否则跳过它(打印信息的语句)。因此,典型的调用像这样:
if (printk_ratelimit())
printk(KERN_NOTICE "The printer is still on fire\n");
printk_ratelimit通过跟踪有多少信息被发送到控制台来完成工作。如果输出的信息超过一个阀值,printk_ratelimit开始返回0值以使得信息被丢弃。
可以通过修改/proc/sys/kernel/prink_ratelimit(重新打开信息等待的妙数)和/proc/sys/kernel/printk_ratelimit_burst(速率限制之前接受的信息数)来定制。
1.2.6.打印设备号
有时侯,当从一个驱动程序中打印信息时,你想打印与硬件结合的设备号以引起注意。打印主次设备号并不是非常难,但是,为了一致性,内核提供了一对工具宏(在中定义)来达成这个目的:
int print_dev_t(char *buffer, dev_t dev);
char *format_dev_t(char *buffer, dev_t dev);
两
个宏都把设备号编码到给出的buffer中;唯一的区别是print_dev_t返回的是被打印的字符数目,而format_dev_t返回
buffer;因此,它可以直接作为printk调用的参数,虽然必须记住printk在遇到换行符之前不会输出。缓冲区必须足够大以能保存一个设备
号;64位的设备号在将来的内核中是明显可能的,缓冲区至少需要20字节长。
1.3.通过查询调试
前面的一节描述了printk怎样工作及如何使用它。我们没有谈到的是它的缺点。
大量使用printk能
使系统显著地变慢,即使你降低console_loglevel来避免装载控制台设备,因为syslogd保持同步它的输出文件;因此,每一行的输出都导
致一个磁盘操作。从syslogd的视角来看这是正确的实现。为了防止万一系统在打印消息之后就崩溃,它尝试把所有的信息都写到磁盘上;当然,你不想因为
调试信息的原因拖慢系统的速度。这个问题可以通过给/etc/syslogd.conf文件中出现的记录文件加一个连字符前缀来解决
。修改配置文件的问题是在你完成调试之后你的修改还保存在配置文件中,即使是普通的系统操作你也想把信息尽可能快地写到磁盘上。这样的持久改变的另一种选
择是运行一个其它程序而不是klogd(比如cat /proc/kmsg,像前边建议的一样),但是这样可能不能为普通的系统操作提供一个合适的环境。
更多情况下,获得相应信息的最好方法当你需要信息的时候向系统查询,而不是持续地产生数据。事实上,每个Unix系统都提供了许多工具来获得系统信息:ps,netstat,vmstat,等等。
驱动开发者有一些可以用来查询系统信息的技术:在/proc文件系统中创建一个文件,使用ioctl驱动程序方法,和通过sysfs导出属性。使用sysfs需要很多驱动程序模式的背景知识。它在第14章讨论。
1.3.1.使用/proc文件系统
/proc文件系统是一个特殊的,软件创建的文件系统,内核使用它来向外界导出信息。每一个
/proc下的文件都依赖于一个当文件被读取的时候匆忙产生文件内容的内核函数。我们已经看到过一些这样的文件在运作;例如/proc/modules,
总是返回当前加载的模块列表。
/proc在Linux系统中使用非常多。现在的Linux发布版里的许多工具,比如ps,top,和
uptime,就是从/proc中获取它们需要的信息。一些设备驱动程序也通过/proc导出信息,当然你也可以这样做。/proc文件系统是动态的,所
以你的模块可以在任何时候添加和移除条目。
完整功能的/proc条目非常的复杂;其中,它们被写入和读取。但是大多数时候,/proc条目都是只读文件。本节只关注简单的只读情形。那些对实现更复杂的/proc条目感兴趣的人可以通过学习这里来打基础;然后可以向内核源代码查阅完整的信息。
但
是在开始之前,我们并不鼓励你在/proc下添加文件。/proc文件系统被内核开发者认为有点混乱,它已经超越了它最初的目的(提供系统中运行的进程的
信息)。我们建议你在新编写的代码中通过sysfs来提供信息。使用sysfs需要理解Linux设备模式,但是我们要等到第14章才能接触到它。其实,
在/proc下创建文件是很简单的,并且它们非常适合用于调试,因此我们在这里学习它。
1.3.1.1.在/proc中实现文件
使用/proc的所有模块都必须包含文件以定义适当的函数。
创建一个只读的/proc文件,你的驱动程序必须实现一个当文件被读取时产生数据的函数。当一些进程读取这个文件时(使用read系统调用),该请求借助于到达你的模块。我们首先来看一下这个函数,在本节的后面再来了解注册接口。
当进程从你的/proc文件中读取数据时,内核分配一页内存(比如,PAGE_SIZE字节)来保存驱动程序写入的被返回用户空间的数据。这个缓冲区被传递到你的函数,该函数是一个名为read_proc的方法。
int (*read_proc)(char *page, char **start, off_t offset, int count,
int *eof, void *data);
page
指针是你写入数据的缓冲区;函数使用start来表明有趣的信息被写在page的何处(后面有更详细的讨论);offset和count与read方法的
意义是一样的。eof参数指向一个整型,驱动程序必须设置它来表明没有更多的数据返回,data是驱动程序特定的数据指针,你可以用它来作为内部簿记。
这个函数应该返回实际写入page缓冲区的字节数目,就象read方法针对其它文件那样。另外的输出值是*eof和*start。eof是一个简单的标志,但是start值的使用稍微更复杂一些;它的目的是帮助实现大/proc文件(大于一页)。
start
参数多少有些非传统的使用。它的目的是表明哪里(页面内)可以找到返回给用户的数据。当你的proc_read方法被调用时,*start是NULL值。
如果你保持它为NULL,内核假设数据被写入到页的offset为0的地方;换句话来说,它假设一个简单的proc_read版本,把整个虚拟文件的内容
放置在页中而不管offset参数。如果你设置*start为一个非NULL值,内核假设*start指向的数据已经把offset计算在内并准备直接返
回给用户。一般说来,简单的proc_read方法忽略start,返回少量的数据。更复杂一些的方法设置*start为page并只在请求的数据的位移
的开始处写入数据。
start也解决了/proc文件的另一个长久的主要问题。有的时候内核数据结构的ASCII表示被连续的read调用改变,
因此读取数据的进程必须找到该调用和下一个调用之间的不一致性。如果*start设置为一个小的整型值,调用者使用它来增加
filp->f_pos,而不管返回的数据的数量。例如,如果你的read_proc函数返回一个大型的结构数组,并在第一次调用时返回5个这样的
数据结构,*start应该被设置为5。下一个调用在位移之上提供相同的值;驱动程序就知道从数组中的第六个数据结构返回值。这是一个被公认的它的作者
“hack”,你可以在fs/proc/generic.c中看到它。
注意有一个更好的方法来实现大/proc文件;它被称为seq_file,我们不久就会讨论它。首先,是时候给出一个例子了。这是一个简单的(有些丑陋的)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 data;
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);
/* dump only the last item */
if (qs->data && !qs->next)
for (j = 0; j qset; j++) {
if (qs->data[j])
len += sprintf(buf + len,
" %4i: %8p\n",
j, qs->data[j]);
}
up(&scull_devices.sem);
}
*eof = 1;
return len;
}
这是相当典型的read_proc实现。它假设从不需要创建多于一页的数据,因此忽略start和offset的值。为了以防万一,你必须小心不要缓冲区越界。
1.3.1.2.旧的接口
如果你通读内核代码,你会遇到用旧的接口实现/proc文件的代码:
int (*get_info)(char *page, char **start, off_t offset, int count);
所有的参数都与read_proc中的参数有相同的意义和作用,但是它少了eof和data参数。这个接口仍然被支持,不过将来会被消除;新的代码应该改为使用read_proc接口。
1.3.1.3.创建你的/proc文件
一旦你定义了一个read_proc函数,必须把它与/proc层次结构下的一个条目相连接。可以通过调用create_proc_read_entry来完成:
struct proc_dir_entry *create_proc_read_entry(const char *name,
mode_t mode, struct proc_dir_entry *base,
read_proc_t *read_proc, void *data);
其
中,name是要创建的文件名,mode是文件的保护掩码(传递给它0值时使用系统的默认值)。base表明文件将在哪个目录下被创建(如果base为
NULL,文件将在/proc根目录下创建),read_proc是实现文件的read_proc函数,data被内核忽略(但它传递给
read_proc)。下面是scull中使得/proc函数对/proc/scullmem可用的调用:
create_proc_read_entry("scullmem", 0 /* default mode */,
NULL /* parent dir */, scull_read_procmem,
NULL /* client data */);
其中,我们在/proc下直接创建了一个名为scullmem的文件,使用默认的全局可读的保护法则。
目
录条目指针能在/proc下创建完整的目录层次。但是要注意,将一个条目放在以条目名字的一部分命名的目录下更为简单——只要目录本身已经存在。例如,一
个(常常被忽略)常用的协定是与设备驱动程序相关的条目应该放在driver/子目录下;scull应该简单地把它的条目放在该目录下并把它的proc文
件的名字命名为driver/scullmem。
/proc中的条目当然应该在模块被卸载的模块时候被移除。remove_proc_entry函数可以用来撤消create_proc_read_entry已经完成的功能:
remove_proc_entry("scullmem", NULL /* parent dir */
删除条目失败会导致时间的浪费,或者如果你的模块已经被卸载,内核就可能崩溃。
如果你使用/proc文件,你必须记住它实现上的一些缺点——现在不鼓励使用它你就不必觉得奇怪了。
最
重要的问题出现在移除/proc条目的时候。可能发生的情况是移除的时候文件正在被使用,因为/proc条目没有相应的拥有者,因此使用它们不会作用于模
块的引用计数。比如,这个问题可以通过在移除模块之前运行sleep
100
1.3.1.4.seq_file接口
就像我们上面提到的一样,在/proc文件下实现一个大的文件有点笨拙。久而久之,/proc方
法因为输出的信息变得越来越大,和实现上的诸多问题而变得臭名昭著。seq_file接口作为清除/proc代码并让内核开发人员的生活变得简单的方法被
添加。该接口提供了一些简单的函数集来实现大容量的内核虚拟文件。
seq_file接口假设你创建一个必须返回用户空间的需要访问一个系列中的单
个条目的虚拟文件。使用seq_file,你必须创建一个可以在顺序中建立位置,向前移动并输出系列中的一个条目的简单“迭代器”对象。听起来挺复杂,但
事实上,过程相当简单。我们将一步步学习scull驱动程序中创建/proc文件的方法来演示它是怎么做的。
第一步,不可避免地,是包含文件。然后你必须创建四个迭代器的方法,它们是start,next,stop和show。
start方法总是第一个被调用。该函数的原型为:
void *start(struct seq_file *sfile, loff_t *pos);
sfile
参数几乎总是可以被忽略。pos是一个表明从哪个位置开始读取的整型值。对位置的解释完全由实现决定;它不应该是一个文件中的字节位置。因为
seq_file典型的实现是遍历一个系列中感兴趣的条目,位置通常被解释为一个指向系列中的下一个条目的光标。scull驱动程序把每个设备解释为系列
中的一个条目,所以pos只是一个scull_devices数组中的简单索引。因此,在scull中使用的start方法为:
static void *scull_seq_start(struct seq_file *s, loff_t *pos)
{
if (*pos >= scull_nr_devs)
return NULL; /* No more to read */
return scull_devices + *pos;
}
如果返回值不为NULL,它是一个只能被迭代器实现使用的私有值。
next函数应该把迭代器移动到下一个位置,如果系列中没有余下的条目就返回NULL。该方法的原型为:
void *next(struct seq_file *sfile, void *v, loff_t *pos);
其中,v是一个从上一个start或next调用返回的迭代器,pos是文件中的当前位置。next应该递增被pos指向的值;根据你的迭代器的工作情况,你可能(虽然可能不会)想递增pos不止1个位置。下面是scull的实现方法:
static void *scull_seq_next(struct seq_file *s, void *v, loff_t *pos)
{
(*pos)++;
if (*pos >= scull_nr_devs)
return NULL;
return scull_devices + *pos;
}
当内核完成迭代器相关的工作后,它调用stop来清除:
void stop(struct seq_file *sfile, void *v);
scull实现中没有清除工作,所以它的stop方法是空的。
值
得注意的是,seq_file代码在设计上是在调用start和stop之间不可睡眠或执行其它的非原子操作任务的。有一天你肯定会看到在调用start
之后立即调用stop的情况。因此,你的start方法获得一个信号量或自旋锁是安全的。因为你的其它的seq_file方法是原子的,整个系列的调用都
是原子的。(如果你还不能理解这一段的内容,在你读了下一章之后再回来读它)。
在这些调用中,内核调用show方法来实际输出一些用户空间感兴趣的信息。该方法的原型是:
int show(struct seq_file *sfile, void *v);
该方法输出系列中由迭代器v标识的条目。但是,它不能使用printk;有用于seq_file输出的特殊函数集:
int seq_printf(struct seq_file *sfile, const char *fmt, ...);
这是printf函数的seq_file实现;它使用常用的格式化字符串和附加的参数值。而且你必须给它传送传递给show函数的seq_file结构。如果seq_printf返回一个非0值,就意味着缓冲区已满,并且输出被忽略。但是大多数的实现忽略返回值。
int seq_putc(struct seq_file *sfile, char c);
int seq_puts(struct seq_file *sfile, const char *s);
它们和用户空间的putc与puts函数是等价的。
int seq_escape(struct seq_file *m, const char *s, const char *esc);
该函数等价于seq_puts函数,除了在s中的字符如果也在esc中的话就以八进制的格式打印这些字符。esc的常用值是“ \t\n\\”,以防止嵌入的空白字符扰乱输出并使shell脚本的疑惑。
int seq_path(struct seq_file *sfile, struct vfsmount *m,
struct dentry *dentry, char *esc);
该函数可用于输出与给定的目录项相应的文件名。它在设备驱动程序中不太可能有用;我们只是为了完整才在这里包含它。
回到我们的例子;scull中使用的show方法为:
static int scull_seq_show(struct seq_file *s, void *v)
{
struct scull_dev *dev = (struct scull_dev *) v;
struct scull_qset *d;
int i;
if (down_interruptible(&dev->sem))
return -ERESTARTSYS;
seq_printf(s, "\nDevice %i: qset %i, q %i, sz %li\n",
(int) (dev - scull_devices), dev->qset,
dev->quantum, dev-size);
for (d = dev->data; d; d = d->next) { /* scan the list */
seq_printf(s, "item at %p, qset at %p\n",
d, d->data);
/* dump only the last item */
if (d->data && !d->next)
for (i = 0; i qset; i++) {
if (d->data)
seq_printf(s, "%4i: %8p\n",
i, d->data);
}
}
up(&dev->sem);
return 0;
}
其中,我们最后来解释我们的“迭代器”值,它是一个简单的指向scull_dev的结构。
现在我们有了迭代器操作的全集,scull必须将它们打包并连接到/proc中的文件。第一步是填充一个seq_operations结构:
static struct seq_operations scull_seq_ops = {
.start = scull_seq_start,
.next = scull_seq_next,
.stop = scull_seq_stop,
.show = scull_seq_show
};
创
建好上面的结构后,我们必须创建一个内核可以理解的文件实现。我们没有使用前边描述的read_proc方法;使用seq_file时,最好从更底层次上
来连接/proc。这意味着创建一个file_operations结构(是的,它和字符设备驱动程序使用的结构是相同的)来实现内核处理读和寻址文件必
须的所有操作。幸运的是,这个工作很简单。第一步是创建一个open方法来连接文件到seq_file操作:
static int scull_proc_open(struct inode *inode, struct file *file)
{
return seq_open(file, &scull_seq_ops);
}
seq_open调用使用我们上面定义的顺序操作来连接file结构。open是我们必须自己实现的文件方法,因此现在我们可以建立我们的file_operations结构了:
static struct file_operations scull_proc_ops = {
.owner = THIS_MODULE,
.open = scull_proc_open,
.read = seq_read,
.llseek = seq_lseek,
.release = seq_release
};
这里我们指定了我们自己的open方法,但是使用的其它函数不便,seq_read,seq_lseek,和seq_release。
最后的一步是在/proc中创建实际的文件:
entry = create_proc_entry("scullseq", 0, NULL);
if (entry)
entry->proc_fops = &scull_proc_ops;
我们使用底层的create_proc_entry,而不是使用create_proc_read_entry,create_proc_entry的原型如下:
struct proc_dir_entry *create_proc_entry(const char *name,
mode_t mode,
struct proc_dir_entry *parent);
参数和create_proc_read_entry中相应的参数相同:文件名字,文件的保护,和它的父目录。
使用上面的代码,scull就有了一个与前面的/proc条目非常相似的新的条目。但是它更出众,因为它不管输出有多大都能工作,它正确地处理寻址,并且它从整体上来说更容易阅读和维护。我们建议你在实现一个输出包含不止小数目的行的文件中使用seq_file。
1.3.2.ioctl方法
ioctl是一个作用于文件描述符的系统调用,我们在第一章中描述过怎么使用它;它接收一个表明要执行的命
令的数字和(可选地)另一个参数,通常是一个指针。作为使用/proc文件系统的另外一种选择,你可以实现一些为调试定制的ioctl命令。这些命令可以
从驱动程序拷贝相关的数据结构到用户空间,你就可以在用户空间来检查它们。
ioctl的这种获取信息的使用方法比使用/proc稍微困难一些,因为你需要另一个程序来调用ioctl并显示结果。这个程序被编写,编译,并与你要测试的模块保持同步。从另一方面来说,这时的驱动方代码将比需要实现/proc文件的驱动代码更加简单。
ioctl是获取信息的最好方式已经有好长一段时间了,因为它比读取/proc更加快速。如果在数据写到屏幕之前必须对它进行处理,以二进制形式获取数据比读取一个文本文件更加高效。此外,ioctl不需要把小于一页的数据分段。
ioctl
的另一个有趣的优点是即使调试被禁用后获取信息的命令也能留在驱动程序中。与/proc文件不同的是,/proc文件是对查看该目录的所有人都可见的(好
多人可能会惊奇“这些奇怪的文件是做什么用的”),未公开的ioctl命令可能不容易被人注意到。此外,当有驱动程序有怪异的情况发生时,它仍然在那里。
它们唯一的缺点是模块可能轻微的更大一些。
1.4.通过监视调试
有的时候一些小问题可以通过监视用户空间的应用程序来跟踪。监视程序对创建一个可靠性的(驱动程序正确工作)驱动程序也有帮助。例如,我们在查看read实现怎样应答对不同数量数据的请求后,能对我们的设备驱动程序更加有把握。
有很多监视用户空间程序工作的不同方法。你可以使用一个调试器单步执行它的函数,添加打印语句,或在strace下面运行该程序。这里我们只讨论最后一种技术,它的真实目的是检查内核代码,所以非常有趣。
strace
命令是一个强大的工具,它显示用户空间程序的所有系统调用。不仅显示这些调用,它还以符号化的形式显示调用的参数和它们的返回值。如果系统调用失败,错误
的符号值(比如,ENOMEM)和相应的字符串(内存不足)都被显示。strace有很多命令行选项;最有用的选项是-t选项,它显示每个系统调用开始执
行时的时间,-T显示调用花费的时间,-e限制被跟踪的调用的类型,-o重定向输出到一个文件。默认的,strace打印在stderr打印跟踪信息。
strace自己从内核接收信息。这就意味着一个程序可以被跟踪,不论它是否在编译的时候选择调试支持(gcc的-g选项),不管调试信息是不是被移除。你还能挂接你的跟踪到一个运行的进程,就像调试器连接到运行的进程并控制进程执行一样。
追踪信息常常用来支持发送到用户程序开发者的bug报告,但是它对内核程序员的作用也是不可估量的。我们可以通过对系统调用的反应来了解驱动程序是怎么执行的;strace允许我们检查每一个调用的数据输出和输入的一致性。
例如,下面的屏幕转储显示的是运行命令strace ls /dev > /dev/scull0的最后(包括大多数)几行:
open("/dev",
O_RDONLY|O_NONBLOCK|O_LARGEFILE|O_DIRECTORY) = 3
fstat64(3, {st_mode=S_IFDIR|0755, st_size=24576, ...}) = 0
fcntl64(3, F_SETFD, FD_CLOEXEC) = 0
getdents64(3, /* 141 entries */, 4096) = 4088
[...]
getdents64(3, /* 0 entries */, 4096) = 0
close(3) = 0
[...]
fstat64(1,
{st_mode=S_IFCHR|0664, st_rdev=makedev(254, 0), ...}) = 0
write(1,
"MAKEDEV\nadmmidi0\nadmmidi1\nadmmid"..., 4096) = 4000
write(1, "b\nptywc\nptywd\nptywe\nptywf\nptyx0\n"..., 96) = 96
write(1,
"b\nptyxc\nptyxd\nptyxe\nptyxf\nptyy0\n"..., 4096) = 3904
write(1,
"s17\nvcs18\nvcs19\nvcs2\nvcs20\nvcs21"..., 192) = 192
write(1,
"\nvcs47\nvcs48\nvcs49\nvcs5\nvcs50\nvc"..., 673) = 673
close(1) = 0
exit_group(0) = ?
很
明显,ls完成目标目录的查询后,第一个write调用尝试写4KB的数据。奇怪的是(对于ls),仅有4000字节的数据被写出,然后操作重试。但是,
我们知道scull中的write实现每次写单个量子,因此我们已经预期到不完全的写操作。几个步骤之后,所有的任务完成,程序成功退出。
另一个例子,我们来读取scull设备(使用wc命令):
[...]
open("/dev/scull0", O_RDONLY|O_LARGEFILE) = 3
fstat64(3,
{st_mode=S_IFCHR|0664, st_rdev=makedev(254, 0), ...}) = 0
read(3,
"MAKEDEV\nadmmidi0\nadmmidi1\nadmmid"..., 16384) = 4000
read(3,
"b\nptywc\nptywd\nptywe\nptywf\nptyx0\n"..., 16384) = 4000
read(3,
"s17\nvcs18\nvcs19\nvcs2\nvcs20\nvcs21"..., 16384) = 865
read(3, "", 16384) = 0
fstat64(1,
{st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
write(1, "8865 /dev/scull0\n", 17) = 17
close(3) = 0
exit_group(0) = ?
正
如所期望的那样,read每次能获取4000字节的数据,但是总的数据量与前面例子中写入到设备中的数据量是相同的。注意到这个例子中是怎样组织重试的,
你会发现非常有意思,它和前面的例子是相对的。wc对快速读取作了优化,因此,它绕过了标准库,每一个系统调用都试图读取更多的数据。你可以从read的
trace输出行中看到wc试图一次读取16KB的数据。
Linux专家能在strace的输出信息中找到很多有用的信息。如果所有的符号让你分心,你可以使用efile标志来限制仅跟踪文件方法(open,read等等)。
就个人来说,我们发现strace能准确地描述系统调用的运行错误。通常应用程序中的perror调用或是演示程序不能提供足够的信息以供调试,并且能准确地发现哪一个参数引发了错误能有很大的帮助。
1.5.调试系统错误
即使你使用了所有的监视和调试技术,有时bugs仍然存在于你的驱动程序中,并在驱动程序被执行时引发系统错误。如果这种情况发生,收集尽可能多的信息用于解决问题将非常重要。
注
意“错误”并不意味这“panic”。Linux代码已经足够健壮,能优美地对错误作出回应:一个错误通常导致当前进程被破坏,系统继续运行。如果错误发
生在进程上下文之外或系统中一些重要部分被损坏时,系统就会panic。但是问题是由驱动程序错误引起的,它通常仅仅导致不辛使用驱动程序的进程突然死
亡。唯一的不可恢复的损坏是当分配给进程上下文的内存丢失时;举例来说,驱动程序通过kmalloc分配的动态链表可能丢失。但是,因为内核在进程死亡时
对任何打开的设备调用close操作,你的驱动程序可以释放open方法分配的内存。
虽然oops通常不会让你的系统整个崩溃,你可能发现发生
oops后你需要重启计算机。一个有bug的驱动程序会使硬件处于不可用的状态,使内核资源处于不一致的状态,或者更糟的情况是在任意位置破坏内核内存。
通常情况下,你可以卸载有bug的驱动程序并在oops发生后重试。但是如果你看到任何关于系统整体运转不良好的信息,你最好的选择常常是马上重启系统。
我们提到当内核代码运行失常时,终端会打印出相关的信息。下一节我们解释怎样解码和使用这些信息。虽然对于新手来说,它们看起来非常难解,不过处理器转储包含许多有趣的信息,不用额外的测试就足够精确地定位一个程序bug。
1.5.1.oops信息
许多bug是由于解析NULL指针或使用其它错误的指针值引起。通常这些bug的输出结果是oops信息。
几
乎任何处理器使用的地址都是虚拟地址,它们通过一个复杂的页表结构(也有这样的例外情况,就是物理地址使用它自己的内存管理子系统)来映射到物理地址。当
一个无效(非法)的指针被解析时,页机制映射指针到物理地址时就会失败,处理器向操作系统通知一个页错误。如果这个地址是不正确的,内核就不能“调入”遗
失的页面;如果处理器在特权模式下,一旦有这种情况发生,内核就会产生oops。
oops显示错误发生时刻的处理器状态,包括CPU寄存器的内容和其它一些表面上看起来不可理解的信息。这些信息是由错误处理代码中的printk语句产生的并以前面“printk”节描述的那样被派发的。
让我们来看一个这样的信息。下面的例子是在一台运行2.6版本内核的机器上解析一个NULL指针的结果。这里最相关的信息是指令指针(EIP),即错误指令的地址:
Unable to handle kernel NULL pointer dereference
at virtual address 00000000
printing eip:
d083a064
Oops: 0002 [#1]
SMP
CPU: 0
EIP: 0060:[] Not tainted
EFLAGS: 00010246 (2.6.6)
EIP is at faulty_write+0x4/0x10 [faulty]
eax: 00000000 ebx: 00000000 ecx: 00000000 edx: 00000000
esi: cf8b2460 edi: cf8b2480 ebp: 00000005 esp: c31c5f74
ds: 007b es: 007b ss: 0068
Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0)
Stack: c0150558 cf8b2460 080e9408 00000005
cf8b2480 00000000 cf8b2460 cf8b2460
fffffff7 080e9408 c31c4000 c0150682 cf8b2460
080e9408 00000005 cf8b2480
00000000 00000001 00000005 c0103f8f 00000001
080e9408 00000005 00000005
Call Trace:
[] vfs_write+0xb8/0x130
[] sys_write+0x42/0x70
[] syscall_call+0x7/0xb
Code: 89 15 00 00 00 00 c3 90 8d 74 26 00 83 ec
0c b8 00 a6 83 d0
该信息是由于向一个faulty模块拥有的设备写入的时候产生的,一个为了演示错误而故意编写的模块。faulty.c中的write方法的实现很普通:
ssize_t faulty_write (struct file *filp, const char __user *buf,
size_t count, loff_t *pos)
{
/* make a simple fault by dereferencing a NULL pointer */
*(int *)0 = 0;
return 0;
}
从代码中可以看出,我们这里所做的就是解析一个NULL指针。因为0从来都不是一个有效的指针值,所以产生一个错误,内核进入错误处理代码并输出前面显示的信息。然后调用该函数的进程就被杀死。
faulty模块的read实现有不同的错误条件:
ssize_t faulty_read(struct file *filp, char __user *buf,
size_t count, loff_t *pos)
{
int ret;
char stack_buf[4];
/* Let's try a buffer overflow */
memset(stack_buf, oxff, 20);
if (count > 4)
count = 4; */ copy 4 bytes to the user */
ret = copy_to_user(buf, stack_buf, count);
if (!ret)
return count;
return ret;
}
该方法拷贝一个字符串到局部变量中;不辛的是,这个字符串比目的数组要长。该缓冲区溢出的结果是在函数返回的时候引发oops。return指令把指令指针指向毫无意义的地方,因此这类错误非常难于跟踪,不过你可以得到下面的一些信息:
EIP: 0010:[]
Unable to handle kernel paging request at virtual
address ffffffff
printing eip:
ffffffff
Oops: 0000 [#5]
SMP
CPU: 0
EIP: 0060:[] Not tainted
EFLAGS: 00010296 (2.6.6)
EIP is at 0xffffffff
eax: 0000000c ebx: ffffffff ecx: 00000000 edx: bfffda7c
esi: cf434f00 edi: ffffffff ebp: 00002000 esp: c27fff78
ds: 007b es: 007b ss: 0068
Process head (pid: 2331, threadinfo=c27fe000 task=c3226150)
Stack: ffffffff bfffda70 00002000 cf434f20 00000001
00000286 cf434f00 fffffff7
bfffda70 c27fe000 c0150612 cf434f00 bfffda70
00002000 cf434f20 00000000
00000003 00002000 c0103f8f 00000003 bfffda70
00002000 00002000 bfffda70
Call Trace:
[] sys_read+0x42/0x70
[] syscall_call+0x7/0xb
Code: Bad EIP value.
在上面的例子中,我们仅能看到一部分调用栈(vfs_read和faulty_read丢失了),并且内核发出“错误的EIP值”的警告。这个警告与开始处列出的地址(ffffffff)都是内核栈崩溃的提示。
当你遇到一个oops,通常情况下你要作的第一件事就是查看错误发生的地方,通常它(错误位置)和调用栈是分开列出的。在上面提供的第一个oops中相应的行为:
EIP is at faulty_write+0x4/0x10 [faulty]
其中我们可以看到我们位于faulty模块(在方括号中列出)的faulty_write函数中。十六进制数字表示指令指针在函数的4字节偏移处,它有10(十六进制)字节长。这通常已经足够指出问题的所在了。
如
果你需要更多的信息,调用栈可以提供怎么找到代码崩溃的地方的信息。调用栈是以十六进制的形式打印的;通过少许的工作,你就能通过栈列表决定局部变量的值
和函数的参数。有经验的内核开发者能通过特定的模式来获得重要信息;比如,如果我们来观察faulty_read的oops中的栈列表:
Stack: ffffffff bfffda70 00002000 cf434f20 00000001
00000286 cf434f00 fffffff7
bfffda70 c27fe000 c0150612 cf434f00 bfffda70
00002000 cf434f20 00000000
00000003 00002000 c0103f8f 00000003 bfffda70
00002000 00002000 bfffda70
在
栈顶部的ffffffff是产生错误的字符串的一部分。在x86体系结构中,用户空间栈的默认地址底于0xc0000000;因此,紧跟在它后边的值
0xbfffda70可能是一个用户空间的栈地址;实际上,它是传递到read系统调用的缓冲区的地址,每次都随内核调用链被复制并传递。在x86结构上
(又一次,默认的),内核空间从0xc0000000开始,因此高于该地址的值几乎应该是内核空间地址,以此类推。
最后,当我们查看oops列表的时候,应该时刻小心本章一开始提到的“slab毒物”。比如,如果你得到的oops中存在0xa5a5a5a5这样的地址,你就几乎可以肯定在其它地方忘记初始化动态内存。
请注意,只有你的内核在编译的时候激活CONFIG_KALLSYMS选项,你才能看到符号化的调用栈(就像上面显示的一样)。否则,你只能看到单纯的十六进制列表,在你使用其它的方法解码它之前基本上没什么用处。
1.5.2.系统挂起
虽然大多数内核代码中的bug都以oops信息的方式结束,有的时候它们可能完全地挂起系统。如果系统挂起,没有
信息被打印。比如,如果代码进入一个死循环
,内核就停止调度,系统不对任何事件作出反应,包括神奇的Ctrl-Alt-Del组合。你有两种选择可以用来处理系统挂起——事先阻止它们或在事后来调
试它们。
你可以通过在关键点插入schedule调用来阻止死循环。schedule调用(正如你所想的一样)通过请求调度器来允许其它进程从当前进程获取CPU时间。如果进程因为你的驱动程序的bug在内核空间循环,schedule调用能让你在跟踪发生的情况之后杀死进程。
你
当然应该意识到,任何对schedule的调用都可能产生对你的驱动程序的可重入调用,因为它允许其它进程运行。假设你已经在你的驱动程序中使用了适当的
锁机制,这种重入情况一般不是什么大问题。但是,你必须确定,不在你的驱动程序持有一个自旋锁的任何时刻调用schedule。
如果你的驱动程序真的使系统挂起,而且你不知道在什么地方插入schedule调用,最好的方法就是加入一些打印信息并把它们写到控制台(如果需要,你得改变console_loglevel值)。
有的时候系统可能显得已经挂起,但是实际上并没有。这有可能发生,比如,如果键盘由于一些奇怪的原因被持续锁住。这些错误挂起可以通过查看你专门为这一目的运行的程序的输出来发现。时钟或系统负载计量器是极好的状态监视器;只要它们保持更新,调度器就在运行。
处
理很多挂起事件的一个不可或缺的工具是“神奇的系统请求键”,它在许多体系结构上都可以使用。神奇的系统请求键通过PC键盘上的Alt键和系统请求键的组
合来产生一个请求,或者在其它平台(查看Documentation/sysrq.txt来获得详细的信息)上使用其它的特定键,它还可以在串口控制台上
使用。第三个键必须和这两个键同时按下,它完成其中的一系列的有用操作:
r
关闭键盘的原始模式;在崩溃程序(比如X服务器)使你的键盘处于奇怪的状态时使用。
k
请求“secure attention key”(SAK)函数。SAK杀死所有在当前控制台运行的进程,留给你一个干净的终端。
s
执行所有磁盘的紧急同步操作。
u
卸载。尝试以只读的方式重新挂载所有的磁盘。该操作通常在s之后马上调用,它在系统处于严重问题时可以节省很多文件系统检查时间。
b
引导。快速重启系统。确定首先同步并重新挂载磁盘。
p
打印处理器的寄存器信息。
t
打印当前的任务链表。
m
打印内存信息。
存
在其它神奇的系统请求函数;查看内核源代码Documentation目录下的sysrq.txt文件可以获得完整列表。注意神奇的系统请求键必须在内核
配置的时候明确地被激活,很多发行版因为明显的安全原因都没有激活它。但是一个用于开发驱动程序的内核,激活神奇的系统请求键是值得的。神奇的系统请求键
可以在运行的时候通过下面的命令来禁用:
echo 0 > /proc/sys/kernel/sysrq
如果非特权用户可以接触到你的键盘,你应该考虑禁用它,以防止无意或有意的系统破坏。一些以前的内核版本的系统请求键默认是被禁用的,因此你必须在运行的时候向相同的/proc/sys文件写入1来激活它。
系
统请求操作非常有用,因此它们可以被不能接触控制台管理员使用。/proc/sysrq_trigger文件是一个只写的接入点,在那你能通过写入相应的
命令字符来触发指定的系统请求操作;然后你就能从内核日志中收集任何的输出数据。系统请求的这一接入点总是可用,即使你在控制台禁用系统请求。
如
果你遇到一个“活动的挂起”,即你的驱动程序处于一个循环中但是系统作为一个整体仍然在运行,有许多技术值得我们来了解。一般情况下,系统请求p函数能直
接找到错误的例程。如果失败,你可以使用内核的审计函数。编译内核的时候激活审计选项,并在命令行启动时提供profile=2参数。使用
readprofile工具重置审计计数器,然后让驱动程序进入循环执行。一段时间后,再次使用readprofile工具来查看内核在各个部分花费的时
间。Documentation/basic_profiling.txt文件中有你开始使用审计工具所需的所有信息。
追捕系统挂起的一个值得使
用的预防措施是以只读的方式挂载所有的磁盘(或者卸载它们)。如果磁盘是只读的或是没被挂载的,就没有破坏文件系统或使它处于不一致状态的风险。另外的一
个可能的方法是网络文件系统,使用一台通过NFS挂载它所有文件系统的电脑。“NFS-Root”特性必须在内核中打开,并且在系统启动时必须给它传递特
殊的参数。这样,你甚至不用使用神奇的系统请求键就可以避免文件系统崩溃,因为文件系统的一致性是由NFS服务器来管理的,它不可能被你的设备驱动程序影
响。
1.6.调试器和相关的工具
调试模块的最后一种方法是使用一个调试器来单步执行代码,监视变量的值和机器的寄存器。这是一件耗时的工作,应该尽可能地避免。虽然如此,通过调试器来完成对代码的细粒度调试的价值是不可估量的。
在内核上使用一个交互性的调试器是一种挑战。内核在自己的地址空间代表系统的所有进程执行。因此,许多用户空间调试器提供的很多公用功能,例如断点和单步执行,在内核中很难获得。本节我们讨论许多调试内核的方法;它们各有优缺点。
1.6.1.使用gdb
gdb对检查系统内部非常有用。在这个级别上高效率地使用gdb要求一些使用gdb命令的信心,对目标平台的汇编代码的一定了解,和配对源代码与被优化过的汇编代码的能力。
调试器必须把内核看成一个应用程序方式来调用。除了指定ELF内核境象的文件名外,你必须在命令行提供一个core文件名。对于一个运行的内核,这个core文件是内核的核心境象,/proc/kcore。典型的gdb调用像下面这样:
gdb /usr/src/linux/vmlinux /proc/kcore
第一个参数是没有压缩过的可执行的ELF内核名,不是zImage或bzImage或其它任何为特殊启动环境编译的内核。
gdb
命令行的第二个参数是core文件的名字。像其它/proc中的文件一样,/proc/kcore是读取的时候产生的。当read系统调用在/proc文
件系统中执行时,它映射到一个数据产生函数而不是一个数据获取函数;我们已经在前面的“使用/proc文件系统”一节中使用过这一特性。kcore以一个
core文件的格式来表示“可执行的”的内核;它是一个很大的文件,因为它代表整个与物理内存相应的内核地址空间。在gdb中,你能通过标准的gdb命令
来查看内核变量的值。比如,p jiffies打印从系统启动到当前时间的时钟滴答数。
当你在gdb中打印数据时,内核仍然在运行,并且不同的数
据条目在不同的时间有不一样的值;但是,gdb通过缓存已经读取的数据来优化对core文件的访问。如果你尝试再次读取jiffies变量,你会得到与前
面相同的值。对于常规core文件来说,缓存值可以避免额外的磁盘访问,它是正确的选择,但是但使用“动态”core境象的时候很不方便。解决方法是在你
需要更新gdb缓存的任何时刻发出core-file
/proc/kcore命令;调试器使用新的core文件并丢弃所有的旧信息。但是你并不总是需要在读取新数据的时候都使用core-file命
令;gdb每次读取几千字节的一块数据而且仅缓存它已经被它引用过的块。
许多标准gdb提供的功能在调试内核是不能使用。比如,gdb不能修改内核数据;在处理内存境象之前,它被期望在自己的控制下运行一个被调试的程序。也不能设置断点或观察点,或是单步执行内核函数。
注意,为了让gdb能使用符号信息,你必须在编译内核的时候打开CONFIG_DEBUG_INFO选项集。这样的做的结果是磁盘上更大的内核境象,但是,没有那些信息,获取内核变量的信息几乎是不可能的。
如
果调试信息可以使用,你就能了解许多内核内部正在进行什么工作的信息。gdb能打印出结构,跟随指针,等等。但是检查模块非常困难。因为模块不是传递到
gdb的vmlinux境象的一部分,调试器对它一无所知。辛运的是,在2.6.7内核中,你可以传递给gdb检查可加载模块所需要的信息。
Linux的可加载模块是ELF格式的可执行境象;它们被划分为很多段。一个典型的模块可以包含一打或更多的段,但是与调试期间相应的段只有典型的下面三个:
.text
该段包含模块的可执行代码。调试器要能跟踪或设置断点就必须指定这一段的位置。(这些操作都与在/proc/kcore上运行调试器无关,但是当使用kgdb是它们很有用,kgdb在后面讨论)。
.bss
.data
这两个段包含模块的变量。编译的时候没有被初始化的变量保存在.bss中,而那些被初始化的变量保存在.data段。
要
让gdb能在可加载模块上工作,需要给调试器提供被加载模块的各个段的位置。这些信息可以在/sys/module下的sysfs中找到。例如,加载
scull模块后,/sys/module/scull/sections目录包含以各个段命名的文件,比如.text文件;每个文件的内容就是各个段的
基地址。
现在是我们执行gdb命令来告诉它我们模块的信息的时候了。我们需要的命令是add-symbol-file;该命令需要的参数是模块目
标文件的名字,.text的基地址,和一些可选的描述其它段的位置的参数。通过查看sysfs中的模块的各个段的相关数据后,我们就可以构建命令:
(gdb)add-symbol-file …/scull.ko 0xd0832000 \
-s .bss 0xd0837100 \
-s .data 0xd0836be0
我们在样例(gdbline)中包含一个简单的脚本,它可以为指定的模块创建该命令。
现在我们可以使用gdb来检查我们的可加载模块了。下面是一个scull调试过程中的一个例子:
(gdb)add-symbol-file scull.ko 0xd0832000 \
-s .bss 0xd0837100 \
-s .data 0xd0836be0
add symbol table from file "scull.ko" at
.text_addr = 0xd0832000
.bss_addr = 0xd0837100
.data_addr = 0xd0836be0
(y or n) y
Reading symbols from scull.ko...done.
(gdb) p scull_devices[0]
$1 = {data = 0xcfd66c50,
quantum = 4000,
qset = 1000,
size = 20881,
access_key = 0,
...}
其中我们可以看出第一个scull设备现在持有20,881字节的数据。如果我们愿意,我们可以跟随数据链,或查看模块中其它的我们感兴趣的任何信息。
另一个值得学习的有用的诀窍是:
(gdb)print *(address)
在address中填入一个十六进制的地址;输出是一个文件和与该地址对应的代码的行号。这一技术可能有用,例如,找出一个函数指针的确确指向。
我们仍然不能执行像设置断点或修改数据的典型调试任务;要执行这些操作,我们必须使用像kdb(下一个讨论)或kgdb(马上就会学习到)这样的工具。
1.6.2.kdb内核调试器
很多的读者可能会奇怪为什么内核中没有集成更高级的调试特性。答案很简单,因为Linus不信任交互性的调试器。他当心调试器会引入不良的修正,它们修补症状而不是寻找到问题的真正根源。因此,没有内置的调试器。
但
是其它的内核开发者,偶尔会使用交互型的调试工具。其中的一个是kdb,它是编译到内核中的调试器,可以从oss.sgi.com获得一个非官方的补丁
包。使用kdb,你必须获取补丁包(确定获取的是与你的内核版本相匹配的补丁包),应用它,并重新编译和重新安装内核。注意,在编写本书时,kdb仅仅能
在IA-32(x86)系统上使用(虽然针对IA-64版本的补丁包在正式的内核源代码包中存在了一段时间,但不久就被移除了)。
一旦你运行的是一个能使用kdb的内核,有很多方法进入调试器。在控制台按下Pause(或Bread)键启动调试器。当内核oops发生或到达断点时,kdb也会启动。不管怎样,你将看到像下面这样的信息:
Entering kdb (0xc0347b80) on processor 0 due to Keyboard Entry
[0]kdb>
注意在kdb运行的时候,内核的所有部分都停止运行。当你调用kdb时,不应该在系统上运行其它的任何东西;特别是,你不能有网络连接打开——除非你在调试一个网络设备驱动程序。如果你将使用kdb,以单用户模式启动系统通常是一个好注意。
我们使用scull的调试过程作为例子。假设驱动程序已经加载,我们就能像下面一样告诉kdb在scull_read中设置一个断点:
[0]kdb> bp scull_read
Instruction(i) BP #0 at 0xcd087c5dc (scull_read)
is enabled globally adjust 1
[0]kdb> go
bp命令告诉kdb在下一次内核进入scull_read时停止执行。然后你键入go来继续执行。在给其中的一个scull设备添加数据之后,我们就能在另一个终端的shell下执行cat来读取它的内容,输出如下:
Instruction(i) breakpoint #0 at 0xd087c5dc (adjusted)
0xd087c5dc scull_read: int3
Entering kdb (current=0xcf09f890, pid 1575) on
processor 0 due to
Breakpoint @ 0xd087c5dc
[0]kdb>
现在我们处于scull_read函数的起始处。想了解我们是怎样到达该位置的,我们可以获得堆栈痕迹:
[0]kdb> bt
ESP EIP Function (args)
0xcdbddf74 0xd087c5dc [scull]scull_read
0xcdbddf78 0xc0150718 vfs_read+0xb8
0xcdbddfa4 0xc01509c2 sys_read+0x42
0xcdbddfc4 0xc0103fcf syscall_call+0x7
[0]kdb>
kdb试着打印调用栈的每一个函数的参数。但是它被编译器使用的优化诀窍给搞混了。因此,它没有能打印出scull_read的参数。
是查看数据的时候了。mds命令用来操纵数据;我们可以使用下面的命令来查询scull_devices指针的值:
[0]kdb> mds scull_devices 1
0xd0880de8 cf36ac00 ....
其中我们请求一个从scull_devices位置开始处的一个(4字节)字的数据;结果告诉我们我们的设备数组的地址为0xd0880de8;第一个设备结构的地址为0xcf36ac00。为了查看这个设备结构,我们必须使用这个地址:
[0]kdb> mds cf36ac00
0xcf36ac00 ce137dbc ....
0xcf36ac04 00000fa0 ....
0xcf36ac08 000003e8 ....
0xcf36ac0c 0000009b ....
0xcf36ac10 00000000 ....
0xcf36ac14 00000001 ....
0xcf36ac18 00000000 ....
0xcf36ac1c 00000001 ....
上
面的8行输出与scull_dev结构中的各个数据项的起始地址相对应。因此,我们看到第一个设备的内存是在地址0xce137dbc分配的,量子大小是
4000(十六进制的fa0),量子集的大小是1000(十六进制的3e8),现在有155(十六进制的9b)字节存储在设备中。
kdb可以修改数据的值。假设我们想削减一些设备中的数据:
[0]kdb> mm cf26ac0c 0x50
0xcf26ac0c = 0x50
紧接着的cat命令将比之前的命令返回更少的数据。
kdb有很多其它的功能,包括单步执行(按指令,而不是按C源代码的行数),设置数据访问的断点,反汇编代码,遍历链表,访问寄存器数据,等。使用kdb补丁包后,可以在内核源代码树的Documentation/kdb目录下找到一套完整的手册页。
1.6.3.kgdb补丁
我们迄今为止看到的交互型调试方法(在/proc/kcore上使用gdb和kdb)都缺乏用户空间应用程序开发者所熟悉的那种环境。难道就没有更好的供内核使用的能支持像修改变量值,设置断点等等特性的调试器了吗?
像
所描述的一样,解决方法确实存在。在编写本书时,有两个独立的补丁在流通使用,它们允许gdb在调试内核使用它的全部功能。令人迷惑的是,两个补丁都叫做
kgdb。它们通过把运行测试内核的系统与运行调试器的系统分开来完成工作;它们都是通过一个串口线连接两个系统的。因此,开发人员可以在他或她的稳定版
的桌面系统上运行gdb,而在一个运行在作为牺牲的测试盒里的内核上操作。以这种模式建立gdb需要在开始时花费一些时间,但是这些投资在一个非常困难的
bug显现的时候就能迅速得到回报。
这些补丁处于很强的交互状态,甚至有些特性是合并在一起的,因此我们避免谈论它们的更多信息,除了在哪里获得它们和它们的基本特性外。感兴趣的读者我们支持你了解事件的当前状态。
第
一个kgdb补丁现在可以在-mm内核树下找到——加入2.6主内核树必须经历的路径。这一版本的补丁支持
x86,SuperH,ia64,x86_64,SPARC,和32位的PPC体系结构。除了通过普通模式下的串口操作外,该版本的kgdb能通过局域网
来交互。只要激活以太网模式并在启动时提供kgdboe参数设置来设置可以发出调试命令的IP地址。Documentation/i386/kgdb目录
下的文档包含怎样设置的参数的内容 。
你可以有另外选择,使用在
http://kgdb.sf.net/
找到的补丁。这一版本的调试器不支持网络交互模式(不过听说已经在开发当中),但是它有调试可加载模块的内置支持。它支持x86,x86,x86_64,PowerPC,和S/390体系结构。
1.6.4.用户模式的Linux端口
用户模式Linux(UML)是一个非常有趣的概念。它在它自己的arch/um子目录下结构化
一个独立的Linux内核端口。但是它不能在新型的硬件上运行;它运行在一个通过Linux系统调用接口实现的虚拟机上。因此,UML允许Linux内核
作为一个独立的,用户模式进程在Linux系统上运行。
有一个作为用户空间进程运行的内核好处。因为它运行在一个被约束的,虚拟处理器上,一个有
bug的内核就不能破坏“真正的”系统。不同的硬件和软件配置就可以简单地在相同的盒子下测试。可能对于内核开发者最重要的是,用户模式的内核能非常简单
地被gdb或其它的调试器操纵。
不过别忘了,它仅仅只是另一个进程。UML有潜力来加速内核的开发。
但是,从驱动程序编写者的角度来看,UML有一个很大的不足之处:用户模式的内核不能访问宿主系统的硬件。因此,虽然它能对调试本书中的大多数样例代码有用,但UML还不能对处理真正硬件设备的驱动程序的调试有帮助。
查看
http://user-mode-linux.sf.net/
以获得UML的更多信息。
1.6.5.Linux Trace Toolkit
Linux Trace Toolkit(LTT)是一个内核补丁和一系列允许跟踪内核事件的工具。跟踪信息包含计时信息并能对特定时期内核发生了什么创建一个合理完整的描述。因此,它不仅用来调试,还用来追踪性能问题。
LTT和它的广泛文档可以在
http://www.opersys.com/LTT
上找到。
4.6.6.Dynamic Probes
Dynamic
Probes(或者Dprobes)是一个IBM发行的(遵循GPL)针对IA-32体系结构的调试工具。它允许在几乎系统中的所有地方放置“探测点”,
不管是用户空间还是内核空间。探测点包含一些代码(是由特殊的,面向栈的语言编写的),这些代码在控制到达给定的点时执行。这些代码能向用户空间报告信
息,改变寄存器,或者做一些其它的事情。DProbes的有用特性就是一旦该功能被编译进内核,就能在运行中的系统中的任何地方插入探测点而不需要改变内
核代码或重启。DProbes能与LTT一起工作,在任何地方插入新的跟踪事件。
DPorbes工具可以从IBM的开源网站:
http://oss.software.ibm.com
下载。