内核编程带来了它自己的,独特的调试挑战。内核代码不能简单地在调试器中执行,也不能被简单地跟踪,因为它是一组不与特定进程相关的功能。内核代码的错误非常难重现并且可能导致整个系统崩溃,因此破坏很多用来发现它们的证据。
本章将介绍在如此恼人的情况下你可以用来监视内核代码和跟踪错误的技术。
在第二章中,我们建议你编译和安装你自己的内核,而不是运行你所使用的发行版中的原始内核。运行你自己的内核的最有力理由是内核开发者已经在内核中构建了很多调试特性。这些特性会创建额外的输出并使系统运行变慢,因此它们在经销商发行的内核中没有被激活。但是,作为一个内核开发者,你有不同的优先权并乐意地接受(最小的)内核调试支持带来的额外开销。
这里,我们列出应该在用于开发的内核中激活的配置选项。除非特别指明,否则,无论你使用的是你喜欢的任何内核配置工具,所有的这些选项都可以在“kernel hacking”菜单下找到。注意有些选项并不被所有的体系结构支持。
该选项只是使其它的调试选项可用;它必须被打开,但是,它自己不打开任何特性。
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实现相同的目标。
我们在前面的章节中使用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种可能的记录级别字符串,它们定义在头文件<linux/kernel.h>中;我们按照以重要性递减的顺序来列出它们:
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标志了;它可以保证信息显示在控制台上。
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) < 0) {
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文件中找到。
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区分根据设施和优先级来区分数据;设施和优先级的允许值都定义在<sys/syslog.h>中。内核信息以LOG_KERN设施在一个printk中使用的相应的优先级被记录(比如,LOG_ERR对应KERN_ERR信息)。如果klogd没有运行,数据存在于循环缓冲区中只到被读取或缓冲区溢出。
如果你想避免你的驱动程序的监视信息拉跨你的系统记录,你能为klogd指定-f(文件)选项以指示klogd在一个指定的文件中保存信息,或者定制/etc/syslog.conf文件来适应你的需要。还有一种可能就是采用暴力的方法:杀死klogd并在一个没有使用的虚拟终端打印详细的信息 ,或在一个没有使用的xterm上使用cat /proc/kmsg命令。
在驱动程序开发的初始阶段,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
#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
本节出现的宏依赖于ANSI C预处理器的gcc扩展,该扩展支持可变数目参数的宏。这种gcc依赖并不是一个难题,因为内核也严重地依赖于gcc的特性。此外,makefile依赖于GNU的make;再一次,内核已经依赖于GNU make,所以该依赖也不是问题。
如果你熟悉C预处理器,你可以扩展已经给出的定义来实现“调试级别”的概念,定义不同的级别并指派一个整型值(或为掩码)来决定每一个级别的信息有多么详细。
但是每个驱动程序有它自己的特性和监视需求。好的编程艺术就是在灵活性和效率之间作出最好的选择,但我们也不能告诉你什么是最好的选择。记住有条件的预处理代码(代码中的常量表达式也一样)是在编译时执行的,因此你必须通过重新编译来打开或关闭信息。一个可能的选择是使用在运行时执行的C条件语句,这样,就允许你在运行时关闭和打开信息。这是一个很好的方法,但是每次代码执行时都需要额外的处理,即使信息被禁用时也会影响性能。
本节展示的宏被证明在许多情况下都非常有用,唯一的缺陷就是它的信息的任何改变都需要重新编译该模块。
如果你不小心,你会发现你的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(速率限制之前接受的信息数)来定制。
有时侯,当从一个驱动程序中打印信息时,你想打印与硬件结合的设备号以引起注意。打印主次设备号并不是非常难,但是,为了一致性,内核提供了一对工具宏(在<linux/kdev_t.h>中定义)来达成这个目的:
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字节长。
前面的一节描述了printk怎样工作及如何使用它。我们没有谈到的是它的缺点。
大量使用printk能使系统显著地变慢,即使你降低console_loglevel来避免装载控制台设备,因为syslogd保持同步它的输出文件;因此,每一行的输出都导致一个磁盘操作。从syslogd的视角来看这是正确的实现。为了防止万一系统在打印消息之后就崩溃,它尝试把所有的信息都写到磁盘上;当然,你不想因为调试信息的原因拖慢系统的速度。这个问题可以通过给/etc/syslogd.conf文件中出现的记录文件加一个连字符前缀来解决。修改配置文件的问题是在你完成调试之后你的修改还保存在配置文件中,即使是普通的系统操作你也想把信息尽可能快地写到磁盘上。这样的持久改变的另一种选择是运行一个其它程序而不是klogd(比如cat /proc/kmsg,像前边建议的一样),但是这样可能不能为普通的系统操作提供一个合适的环境。
更多情况下,获得相应信息的最好方法当你需要信息的时候向系统查询,而不是持续地产生数据。事实上,每个Unix系统都提供了许多工具来获得系统信息:ps,netstat,vmstat,等等。
驱动开发者有一些可以用来查询系统信息的技术:在/proc文件系统中创建一个文件,使用ioctl驱动程序方法,和通过sysfs导出属性。使用sysfs需要很多驱动程序模式的背景知识。它在第14章讨论。
/proc文件系统是一个特殊的,软件创建的文件系统,内核使用它来向外界导出信息。每一个/proc下的文件都依赖于一个当文件被读取的时候匆忙产生文件内容的内核函数。我们已经看到过一些这样的文件在运作;例如/proc/modules,总是返回当前加载的模块列表。
/proc在Linux系统中使用非常多。现在的Linux发布版里的许多工具,比如ps,top,和uptime,就是从/proc中获取它们需要的信息。一些设备驱动程序也通过/proc导出信息,当然你也可以这样做。/proc文件系统是动态的,所以你的模块可以在任何时候添加和移除条目。
完整功能的/proc条目非常的复杂;其中,它们被写入和读取。但是大多数时候,/proc条目都是只读文件。本节只关注简单的只读情形。那些对实现更复杂的/proc条目感兴趣的人可以通过学习这里来打基础;然后可以向内核源代码查阅完整的信息。
但是在开始之前,我们并不鼓励你在/proc下添加文件。/proc文件系统被内核开发者认为有点混乱,它已经超越了它最初的目的(提供系统中运行的进程的信息)。我们建议你在新编写的代码中通过sysfs来提供信息。使用sysfs需要理解Linux设备模式,但是我们要等到第14章才能接触到它。其实,在/proc下创建文件是很简单的,并且它们非常适合用于调试,因此我们在这里学习它。
使用/proc的所有模块都必须包含<linux/proc_fs.h>文件以定义适当的函数。
创建一个只读的/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 < scull_nr_devs && len <= limit; i++) {
struct scull_dev *d = &scull_devices[i];
struct scull_qset *qs = q->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 < d->qset; j++) {
if (qs->data[j])
len += sprintf(buf + len,
" %4i: %8p\n",
j, qs->data[j]);
}
up(&scull_devices[i].sem);
}
*eof = 1;
return len;
}
这是相当典型的read_proc实现。它假设从不需要创建多于一页的数据,因此忽略start和offset的值。为了以防万一,你必须小心不要缓冲区越界。
如果你通读内核代码,你会遇到用旧的接口实现/proc文件的代码:
int (*get_info)(char *page, char **start, off_t offset, int count);
所有的参数都与read_proc中的参数有相同的意义和作用,但是它少了eof和data参数。这个接口仍然被支持,不过将来会被消除;新的代码应该改为使用read_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条目没有相应的拥有者,因此使用它们不会作用于模块的引用计数。比如,这个问题可以通过在移除模块之前运行sleep100</proc/myfile来简单地触发。另外的问题是以相同的名字注册两个条目。内核信任驱动程序且不检查名字是否已经被注册,因此一不小心你就会创建两个或多个相同名字的条目。这是一个常见的问题,并且这样的条目不能区分开来,当你访问它们或调用remove_proc_entry的时候是作用在所有同名的条目上的。
就像我们上面提到的一样,在/proc文件下实现一个大的文件有点笨拙。久而久之,/proc方法因为输出的信息变得越来越大,和实现上的诸多问题而变得臭名昭著。seq_file接口作为清除/proc代码并让内核开发人员的生活变得简单的方法被添加。该接口提供了一些简单的函数集来实现大容量的内核虚拟文件。
seq_file接口假设你创建一个必须返回用户空间的需要访问一个系列中的单个条目的虚拟文件。使用seq_file,你必须创建一个可以在顺序中建立位置,向前移动并输出系列中的一个条目的简单“迭代器”对象。听起来挺复杂,但事实上,过程相当简单。我们将一步步学习scull驱动程序中创建/proc文件的方法来演示它是怎么做的。
第一步,不可避免地,是包含文件<linux/seq_file.h>。然后你必须创建四个迭代器的方法,它们是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 < dev->qset; i++) {
if (d->data[i])
seq_printf(s, "%4i: %8p\n",
i, d->data[i]);
}
}
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。
ioctl是一个作用于文件描述符的系统调用,我们在第一章中描述过怎么使用它;它接收一个表明要执行的命令的数字和(可选地)另一个参数,通常是一个指针。作为使用/proc文件系统的另外一种选择,你可以实现一些为调试定制的ioctl命令。这些命令可以从驱动程序拷贝相关的数据结构到用户空间,你就可以在用户空间来检查它们。
ioctl的这种获取信息的使用方法比使用/proc稍微困难一些,因为你需要另一个程序来调用ioctl并显示结果。这个程序被编写,编译,并与你要测试的模块保持同步。从另一方面来说,这时的驱动方代码将比需要实现/proc文件的驱动代码更加简单。
ioctl是获取信息的最好方式已经有好长一段时间了,因为它比读取/proc更加快速。如果在数据写到屏幕之前必须对它进行处理,以二进制形式获取数据比读取一个文本文件更加高效。此外,ioctl不需要把小于一页的数据分段。
ioctl的另一个有趣的优点是即使调试被禁用后获取信息的命令也能留在驱动程序中。与/proc文件不同的是,/proc文件是对查看该目录的所有人都可见的(好多人可能会惊奇“这些奇怪的文件是做什么用的”),未公开的ioctl命令可能不容易被人注意到。此外,当有驱动程序有怪异的情况发生时,它仍然在那里。它们唯一的缺点是模块可能轻微的更大一些。
有的时候一些小问题可以通过监视用户空间的应用程序来跟踪。监视程序对创建一个可靠性的(驱动程序正确工作)驱动程序也有帮助。例如,我们在查看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调用或是演示程序不能提供足够的信息以供调试,并且能准确地发现哪一个参数引发了错误能有很大的帮助。
即使你使用了所有的监视和调试技术,有时bugs仍然存在于你的驱动程序中,并在驱动程序被执行时引发系统错误。如果这种情况发生,收集尽可能多的信息用于解决问题将非常重要。
注意“错误”并不意味这“panic”。Linux代码已经足够健壮,能优美地对错误作出回应:一个错误通常导致当前进程被破坏,系统继续运行。如果错误发生在进程上下文之外或系统中一些重要部分被损坏时,系统就会panic。但是问题是由驱动程序错误引起的,它通常仅仅导致不辛使用驱动程序的进程突然死亡。唯一的不可恢复的损坏是当分配给进程上下文的内存丢失时;举例来说,驱动程序通过kmalloc分配的动态链表可能丢失。但是,因为内核在进程死亡时对任何打开的设备调用close操作,你的驱动程序可以释放open方法分配的内存。
虽然oops通常不会让你的系统整个崩溃,你可能发现发生oops后你需要重启计算机。一个有bug的驱动程序会使硬件处于不可用的状态,使内核资源处于不一致的状态,或者更糟的情况是在任意位置破坏内核内存。通常情况下,你可以卸载有bug的驱动程序并在oops发生后重试。但是如果你看到任何关于系统整体运转不良好的信息,你最好的选择常常是马上重启系统。
我们提到当内核代码运行失常时,终端会打印出相关的信息。下一节我们解释怎样解码和使用这些信息。虽然对于新手来说,它们看起来非常难解,不过处理器转储包含许多有趣的信息,不用额外的测试就足够精确地定位一个程序bug。
许多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:[<d083a064>] 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:
[<c0150558>] vfs_write+0xb8/0x130
[<c0150682>] sys_write+0x42/0x70
[<c0103f8f>] 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];
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:[<00000000>]
Unable to handle kernel paging request at virtual
address ffffffff
printing eip:
ffffffff
Oops: 0000 [#5]
SMP
CPU: 0
EIP: 0060:[<ffffffff>] 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:
[<c0150612>] sys_read+0x42/0x70
[<c0103f8f>] 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选项,你才能看到符号化的调用栈(就像上面显示的一样)。否则,你只能看到单纯的十六进制列表,在你使用其它的方法解码它之前基本上没什么用处。
虽然大多数内核代码中的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服务器来管理的,它不可能被你的设备驱动程序影响。
调试模块的最后一种方法是使用一个调试器来单步执行代码,监视变量的值和机器的寄存器。这是一件耗时的工作,应该尽可能地避免。虽然如此,通过调试器来完成对代码的细粒度调试的价值是不可估量的。
在内核上使用一个交互性的调试器是一种挑战。内核在自己的地址空间代表系统的所有进程执行。因此,许多用户空间调试器提供的很多公用功能,例如断点和单步执行,在内核中很难获得。本节我们讨论许多调试内核的方法;它们各有优缺点。
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(马上就会学习到)这样的工具。
很多的读者可能会奇怪为什么内核中没有集成更高级的调试特性。答案很简单,因为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
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目录下找到一套完整的手册页。
我们迄今为止看到的交互型调试方法(在/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体系结构。
用户模式Linux(UML)是一个非常有趣的概念。它在它自己的arch/um子目录下结构化一个独立的Linux内核端口。但是它不能在新型的硬件上运行;它运行在一个通过Linux系统调用接口实现的虚拟机上。因此,UML允许Linux内核作为一个独立的,用户模式进程在Linux系统上运行。
有一个作为用户空间进程运行的内核好处。因为它运行在一个被约束的,虚拟处理器上,一个有bug的内核就不能破坏“真正的”系统。不同的硬件和软件配置就可以简单地在相同的盒子下测试。可能对于内核开发者最重要的是,用户模式的内核能非常简单地被gdb或其它的调试器操纵。
不过别忘了,它仅仅只是另一个进程。UML有潜力来加速内核的开发。
但是,从驱动程序编写者的角度来看,UML有一个很大的不足之处:用户模式的内核不能访问宿主系统的硬件。因此,虽然它能对调试本书中的大多数样例代码有用,但UML还不能对处理真正硬件设备的驱动程序的调试有帮助。
查看http://user-mode-linux.sf.net/以获得UML的更多信息。
Linux Trace Toolkit(LTT)是一个内核补丁和一系列允许跟踪内核事件的工具。跟踪信息包含计时信息并能对特定时期内核发生了什么创建一个合理完整的描述。因此,它不仅用来调试,还用来追踪性能问题。
LTT和它的广泛文档可以在http://www.opersys.com/LTT上找到。
4.6.6.Dynamic Probes
DynamicProbes(或者Dprobes)是一个IBM发行的(遵循GPL)针对IA-32体系结构的调试工具。它允许在几乎系统中的所有地方放置“探测点”,不管是用户空间还是内核空间。探测点包含一些代码(是由特殊的,面向栈的语言编写的),这些代码在控制到达给定的点时执行。这些代码能向用户空间报告信息,改变寄存器,或者做一些其它的事情。DProbes的有用特性就是一旦该功能被编译进内核,就能在运行中的系统中的任何地方插入探测点而不需要改变内核代码或重启。DProbes能与LTT一起工作,在任何地方插入新的跟踪事件。
DPorbes工具可以从IBM的开源网站:http://oss.software.ibm.com下载。
Linux内核调试方法
kdb:只能在汇编代码级进行调试;
优点是不需要两台机器进行调试。
gdb:在调试模块时缺少一些至关重要的功能,它可用来查看内核的运行情况,包括反汇编内核函数。
kgdb:能很方便的在源码级对内核进行调试,缺点是kgdb只能进行远程调试,它需要一根串口线及两台机器来调试内核(也可以是在同一台主机上用vmware软件运行两个操作系统来调试)
printk() 是调试内核代码时最常用的一种技术。在内核代码中的特定位置加入printk() 调试调用,可以直接把所关心的信息打打印到屏幕上,从而可以观察程序的执行路径和所关心的变量、指针等信息。 Linux 内核调试器(Linux kernel debugger,kdb)是 Linux 内核的补丁,它提供了一种在系统能运行时对内核内存和数据结构进行检查的办法。Oops、KDB在文章掌握 Linux 调试技术有详细介绍,大家可以参考。 Kprobes 提供了一个强行进入任何内核例程,并从中断处理器无干扰地收集信息的接口。使用 Kprobes 可以轻松地收集处理器寄存器和全局数据结构等调试信息,而无需对Linux内核频繁编译和启动,具体使用方法,请参考使用 Kprobes 调试内核。
/proc文件系统
在 /proc 文件系统中,对虚拟文件的读写操作是一种与内核通信的手段,要查看内核回环缓冲区中的消息,可以使用 dmesg 工具(或者通过 /proc 本身使用 cat /proc/kmsg 命令)。清单 6 给出了 dmesg 显示的最后几条消息。
清单 6. 查看来自 LKM 的内核输出
[root@plato]# dmesg | tail -5 cs: IO port probe 0xa00-0xaff: clean. eth0: Link is down eth0: Link is up, running at 100Mbit half-duplex my_module_init called. Module is now loaded. my_module_cleanup called. Module is now unloaded.
可以在内核输出中看到这个模块的消息。现在让我们暂时离开这个简单的例子,来看几个可以用来开发有用 LKM 的内核 API。
调试工具
使用调试器来一步步地跟踪代码,查看变量和计算机寄存器的值。在内核中使用交互式调试器是一个很复杂的问题。内核在它自己的地址空间中运行。许多用户空间下的调试器所提供的常用功能很难用于内核之中,比如断点和单步调试等。
[隐藏]
|
内核bug跟踪
oops消息分析
(1)oops消息产生机制
oops(也称 panic),称程序运行崩溃,程序崩溃后会产生oops消息。应用程序或内核线程的崩溃都会产生oops消息,通常发生oops时,系统不会发生死机,而在终端或日志中打印oops信息。
当使用NULL指针或不正确的指针值时,通常会引发一个 oops 消息,这是因为当引用一个非法指针时,页面映射机制无法将虚拟地址映像到物理地址,处理器就会向操作系统发出一个"页面失效"的信号。内核无法"换页"到并不存在的地址上,系统就会产生一个"oops"。
oops 显示发生错误时处理器的状态,包括 CPU 寄存器的内容、页描述符表的位置,以及其一些难理解的信息。这些消息由失效处理函数(arch/*/kernel/traps.c)中的printk 语句产生。较为重要的信息就是指令指针(EIP),即出错指令的地址。
由于很难从十六进制数值中看出含义,可使用符号解析工具klogd。klogd 守护进程能在 oops 消息到达记录文件之前对它们解码。klogd在缺省情况下运行并进行符号解码。
通常Oops文本由klogd从内核缓冲区里读取并传给syslogd,由syslogd写到syslog文件中,该文件典型为/var/log/messages(依赖于/etc/syslog.conf)。如果klogd崩溃了,用户可"dmesg > file"从内核缓冲区中读取数据并保存下来。还可用"cat /proc/kmsg > file"读取数据,此时,需要用户中止传输,因为kmsg是一个"永不结束的文件"。
当保护错误发生时,klogd守护进程自动把内核日志信息中的重要地址翻译成它们相应的符号。klogd执行静态地址翻译和动态地址翻译。静态地址翻译使用System.map文件将符号地址翻译为符号。klogd守护进程在初始化时必须能找到system.map文件。
动态地址翻译通常对内核模块中的符号进行翻译。内核模块的内存从内核动态内存池里分配,内核模块中符号的位置在内核装载后才最终确定。
Linux内核提供了调用,允许程序决定装载哪些模块和它们在内存中位置。通过这些系统调用,klogd守护进程生成一张符号表用于调试发生在可装载模块中的保护错误。内核模块的装载或者卸载都会自动向klogd发送信号,klogd可将内核模块符号的地址动态翻译为符号字符串。
(2)产生oops的样例代码
使用空指针和缓冲区溢出是产生oops的两个最常见原因。下面两个函数faulty_write和faulty_read是一个内核模块中的写和读函数,分别演示了这两种情况。当内核调用这两个函数时,会产生oops消息。
函数faulty_write删除一个NULL指针的引用,由于0不是一个有效的指针值,内核将打印oops信息,并接着,杀死调用些函数的进程。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; }
函数faulty_write产生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
上述oops消息中,字符串 3/576 表示处理器正处于函数的第3个字节上,函数整体长度为 576 个字节。 函数faulty_read拷贝一个字符串到本地变量,由于字符串比目的地数组长造成缓冲区溢出。当函数返回时,缓冲区溢出导致产生oops信息。因为返回指令引起指令指针找不到运行地址,这种错误很难发现和跟踪。
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, 0xff, 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; }
函数faulty_read产生oops信息列出如下:
EIP: 0010:[<00000000>]
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.
在上述oops消息中,由于缓冲区溢出,仅能看到函数调用栈的一部分,看不见函数名vfs_read和faulty_read,并且代码(Code)处仅输出"bad EIP value.",列在栈上开始处的地址"ffffffff"表示内核栈已崩溃。
(3)oops信息分析
面对产生的oops信息,首先应查找源程序发生oops的位置,通过查看指令指令寄存器EIP的值,可以找到位置,如:EIP: 0010:[faulty:faulty_write+3/576]。
再查找函数调用栈(call stack)可以得到更多的信息。从函数调用栈可辨别出局部变量、全局变量和函数参数。例如:在函数faulty_read的oops信息的函数调用栈中,栈顶为ffffffff,栈顶值应为一个小于ffffffff的值,为此值,说明再找不回调用函数地址,说明有可能因缓冲区溢出等原因造成指针错误。
在x86构架上,用户空间的栈从0xc0000000以下开始,递归值bfffda70可能是用户空间的栈地址。实际上它就是传递给read系统调用的缓冲区地址,系统调用read进入内核时,将用户空间缓冲区的数据拷贝到内核空间缓冲区。
如果oops信息显示触发oops的地址为0xa5a5a5a5,则说明很可能是因为没有初始化动态内存引起的。
另外,如果想看到函数调用栈的符号,编译内核时,请打开CONFIG_KALLSYMS选项。
klogd 提供了许多信息来帮助分析。为了使 klogd 正确地工作,必须在 /boot 中提供符号表文件 System.map。如果符号表与当前内核不匹配,klogd 就会拒绝解析符号。
有时内核错误会将系统完全挂起。例如代码进入一个死循环,系统不会再响应任何动作。这时可通过在一些关键点上插入 schedule 调用可以防止死循环。
系统崩溃重启动
由于内核运行错误,在某些极端情况下,内核会运行崩溃,内核崩溃时会导致死机。为了解决此问题,内核引入了快速装载和重启动新内核机制。内核通过kdump在崩溃时触发启动新内核,存储旧内存映像以便于调试,让系统在新内核上运行 ,从而避免了死机,增强了系统的稳定性。
(1)工具kexec介绍
kexec是一套系统调用,允许用户从当前正执行的内核装载另一个内核。用户可用shell命令"yum install kexec-tools"安装kexec工具包,安装后,就可以使用kexec命令。
工具kexec直接启动进入一个新内核,它通过系统调用使用户能够从当前内核装载并启动进入另一个内核。在当前内核中,kexec执行BootLoader的功能。在标准系统启动和kexec启动之间的主要区别是:在kexec启动期间,依赖于硬件构架的固件或BIOS不会被执行来进行硬件初始化。这将大大降低重启动的时间。
为了让内核的kexec功能起作用,内核编译配置是应确认先择了"CONFIG_KEXEC=y",在配置后生成的.config文件中应可看到此条目。
工具kexec的使用分为两步,首先,用kexec将调试的内核装载进内存,接着,用kexec启动装载的内核。
装载内核的语法列出如下:
kexec -l kernel-image --append=command-line-options --initrd=initrd-image
上述命令中,参数kernel-image为装载内核的映射文件,该命令不支持压缩的内核映像文件bzImage,应使用非压缩的内核映射文件vmlinux;参数initrd-image为启动时使用initrd映射文件;参数command-line-options为命令行选项,应来自当前内核的命令行选项,可从文件"/proc/cmdline"中提取,该文件的内容列出如下:
^-^$ cat /proc/cmdline
ro root=/dev/VolGroup00/LogVol00 rhgb quiet
例如:用户想启动的内核映射为/boot/vmlinux,initrd为/boot/initrd,则kexec加载命令列出如下:
Kexec –l /boot/vmlinux –append=/dev/VolGroup00/LogVol00 initrd=/boot/initrd
还可以加上选项-p或--load-panic,表示装载新内核在系统内核崩溃使用。
在内核装载后,用下述命令启动装载的内核,并进行新的内核中运行:
kexec -e
当kexec将当前内核迁移到新内核上运行时,kexec拷贝新内核到预保留内存块,该保留位置如图1所示, 原系统内核给kexec装载内核预保留一块内存(在图中的阴影部分),用于装载新内核,其他内存区域在未装载新内核时,由原系统内核使用。
在x86构架的机器上,系统启动时需要使用第一个640KB物理内存,用于内核装载,kexec在重启动进入转储捕捉的内核之前备份此区域。相似地,PPC64构架的机器在启动里需要使用第一个32KB物理内核,并需要支持64K页,kexec备份第一个64KB内存。
(2)kdump介绍
kdump是基于kexec的崩溃转储机制(kexec-based Crash Dumping),无论内核内核需要转储时,如:系统崩溃时,kdump使用kexec快速启动进入转储捕捉的内核。在这里,原运行的内核称为系统内核或原内核,新装载运行的内核称为转储捕捉的内核或装载内核或新内核。
在重启动过程中,原内核的内存映像被保存下来,并且转储捕捉的内核(新装载的内核)可以访问转储的映像。用户可以使用命令cp和scp将内存映射拷贝到一个本地硬盘上的转储文件或通过网络拷贝到远程计算机上。
当前仅x86, x86_64, ppc64和ia64构架支持kdump和kexec。
当系统内核启动时,它保留小部分内存给转储(dump)捕捉的内核,确保了来自系统内核正进行的直接内存访问(Direct Memory Access:DMA)不会破坏转储捕捉的内核。命令kexec –p装载新内核到这个保留的内存。
在崩溃前,所有系统内核的核心映像编码为ELF格式,并存储在内核的保留区域。ELF头的开始物理地址通过参数elfcorehdr=boot传递到转储捕捉的内核。
通过使用转储捕捉的内核,用户可以下面两种方式访问内存映像或旧内存:
(1)通过/dev/oldmem设备接口,捕捉工具程序能读取设备文件并以原始流的格式写出内存,它是一个内存原始流的转储。分析和捕捉工具必须足够智能以判断查找正确信息的位置。
(2)通过/proc/vmcore,能以ELF格式文件输出转储信息,用户可以用GDB(GNU Debugger)和崩溃调试工具等分析工具调试转储文件。
(3)建立快速重启动机制和安装工具
1)安装工具kexec-tools
可以下载源代码编译安装工具kexec-tools。由于工具kexec-tools还依赖于一些其他的库,因此,最好的方法是使用命令"yum install kexec-tools"从网上下载安装并自动解决依赖关系。
2)编译系统和转储捕捉的内核
可编译独立的转储捕捉内核用于捕捉内核的转储,还可以使用原系统内核作为转储捕捉内核,在这种情况下,不需要再编译独立的转储捕捉内核,但仅支持重定位内核的构架才可以用作转储捕捉的内核,如:构架i386和ia64支持重定位内核。
对于系统和转储捕捉内核来说,为了打开kdump支持,内核需要设置一些特殊的配置选项,下面分别对系统内核和转储捕捉内核的配置选项进行说明:
系统内核的配置选项说明如下:
在菜单条目"Processor type and features."中打开选项"kexec system call",使内核编译安装kexe系统调用。配置文件.config生成语句"CONFIG_KEXEC=y"。
在菜单条目"Filesystem"->"Pseudo filesystems."中打开选项"sysfs file system support",使内核编译安装文件系统sysfs.配置文件.config生成语句"CONFIG_SYSFS=y"。
在菜单条目"Kernel hacking."中打开选项"Compile the kernel with debug info ",使内核编译安装后支持调试信息输出,产生调试符号用于分析转储文件。配置文件.config生成语句"CONFIG_DEBUG_INFO=Y"。
转储捕捉内核配置选项(不依赖于处理器构架)说明如下:
在菜单条目"Processor type and features"中打开选项"kernel crash dumps",配置文件.config生成语句" CONFIG_CRASH_DUMP=y"。
在菜单条目"Filesystems"->"Pseudo filesystems"中打开选项"/proc/vmcore support",配置文件.config生成语句"CONFIG_PROC_VMCORE=y"。
转储捕捉内核配置选项(依赖于处理器构架i386和x86_64)说明如下:
在处理器构架i386上,在菜单条目"Processor type and features"中打开高端内存支持,配置文件.config生成语句"CONFIG_HIGHMEM64G=y"或"CONFIG_HIGHMEM4G"。
在处理器构架i386和x86_64上,在菜单条目"rocessor type and features"中关闭对称多处理器支持,配置文件.config生成语句"CONFIG_SMP=n"。如果配置文件中的设置为"CONFIG_SMP=y",则可在装载转储捕捉内核的内核命令行上指定"maxcpus=1"。
如果想构建和使用可重定位内核,在菜单条目"rocessor type and featuresIf"中打开选项"Build a relocatable kernel",配置文件.config生成语句"CONFIG_RELOCATABLE=y"。
在菜单"Processor type and features"下的条目"Physical address where the kernel is loaded"设置合适的值用于内核装载的物理地址。它仅在打开了"kernel crash dumps"时出现。合适的值依赖于内核是否可重定位。
如果设置了值"CONFIG_PHYSICAL_START=0x100000",则表示使用可重定位内核。它将编译内核在物理地址1MB处,内核是可重定位的,因此,内核可从任何物理地址运行。Kexec BootLoader将装载内核到用于转储捕捉内核的内核保留区域。
否则,将使用启动参数"crashkernel=Y@X"指定第二个内核保留内核区域的开始地址,其中,Y表示内存区域的大小,X表示保留给转储捕捉内核的内存区域的开始地址,通过X为16MB (0x1000000),因此用户可设置"CONFIG_PHYSICAL_START=0x1000000"。
在配置完内核后,编译和安装内核及内核模块。
3)扩展的crashkernel语法
在系统内核的启动命令行选项中,通常语法"crashkernel=size[@offset]"对于大多数据配置已够用了,但有时候保留的内存依赖于系统RAM。此时可通过扩展的crashkernel命令行对内存进行 限制避免从机器上移去一部分内核后造成系统不可启动。扩展的crashkernel语法列出如下:
crashkernel=<range1>:<size1>[,<range2>:<size2>,...][@offset]
其中,range=start-[end]。
例如:crashkernel=512M-2G:64M,2G-:128M,含义为:如果内存小于512M,不设置保留内存,如果内存为512M到2G之间,设置保留内存区域为64M,如果内存大于128M,设置保留内存区域为128M。
4)启动进入系统内核
必要时更新BootLoader。然后用参数"crashkernel=Y@X"启动系统内核,如:crashkernel=64M@16M,表示告诉系统内核保留从物理地址0x01000000 (16MB)开始的64MB大小给转储捕捉内核使用。通常x86和x86_64平台设置"crashkernel=64M@16M",ppc64平台设置"crashkernel=128M@32M"。
5)装载转储捕捉内核
在启动进入系统内核后,需要装载转储捕捉内核。根据处理器构架和映射文件的类型(可否重定位),可以选择装载不压缩的vmlinux或压缩的bzImage/vmlinuz内核映像。选择方法说明如下:
对于i386和x86_64平台:
如果内核不是可重定位的,使用vmlinux。
如果内核是可重定位的,使用bzImage/vmlinuz。
对于ppc64平台:
使用vmlinux。
对于ia64平台:
使用vmlinux或vmlinuz.gz。
kexec -p <dump-capture-kernel-vmlinux-image> \ --initrd=<initrd-for-dump-capture-kernel> --args-linux \ --append="root=<root-dev> <arch-specific-options>"
如果用户使用压缩的bzImage/vmlinuz映像,那么使用下面的命令装载转储捕捉内核:
kexec -p <dump-capture-kernel-bzImage>\
--initrd=<initrd-for-dump-capture-kernel> \
--append="root=<root-dev> <arch-specific-options>"
注意:参数--args-linux在ia64平台中不用指定。
下面是在装载转储捕捉内核时使用的构架特定命令行选项:
对于i386, x86_64和ia64平台,选项为"1 irqpoll maxcpus=1 reset_devices"。
对于ppc64平台,选项为"1 maxcpus=1 noirqdistrib reset_devices"。
在装载转储捕捉内核时需要注意的事项说明如下:
缺省设置下,ELF头以ELF64格式存储,以支持多于4GB内核的系统,在i386上,kexec自动检查物理RAM尺寸是否超过4GB限制,如果没有超过,使用ELF32。因此,在非PAE系统上ELF头总是使用ELF32格式。
选项--elf32-core-headers可用于强制产生ELF32头,这是必要的,因为在32位系统上,GDB当前不能打开带有ELF64头的vmcore文件。
在转储捕捉内核中,启动参数irqpoll减少了由于共享中断引起的驱动程序初始化失败。
用户必须以命令mount输出的根设备名的格式指定<root-dev>。
启动参数"1"将转储捕捉内核启动进入不支持网络的单用户模式。如果用户想使用网络,需要设置为3。
通常不必让转储捕捉内核以SMP方式运行。因此,通常编译一个单CPU转储捕捉内核或装载转储捕捉内核时指定选项"maxcpus=1"。
6)内核崩溃时触发内核启动
在装载转储捕捉内核后,如果系统发生崩溃(Kernel Panic),系统将重启动进入转储捕捉内核。重启动的触发点在函数die(), die_nmi()和sysrq处理例程(按ALT-SysRq-c组合键)。
下面条件将执行一个崩溃触发点:
如果检测到硬件锁住,并且配置了"NMI watchdog",系统将调用函数die_nmi()启动进入转储捕捉内核。
如果调用了函数die(),并且该线程的pid为0或1,或者在中断上下文中调用die(),或者设置了panic_on_oops并调用了die(),系统将启动进入转储捕捉内核。
在powerpc系统,当一个软复位产生时,所有的CPU调用die(),并且系统将启动进入转储捕捉内核。
为了测试目的,用户可以使用"ALT-SysRq-c","echo c > /proc/sysrq-trigger"触发一个崩溃,或者写一个内核模块强制内核崩溃。
7)写出转储文件
在转储捕捉内核启动后,可用下面的命令写出转储文件:
cp /proc/vmcore <dump-file>
用户还可以将转储内存作为设备/dev/oldmem以线性原始流视图进行访问,使用下面的命令创建该设备:
mknod /dev/oldmem c 1 12
使用命令dd拷贝转储内存的特定部分,拷贝整个内存的命令列出如下:
dd if=/dev/oldmem of=oldmem.001
8)转储文件分析
在分析转储映像之前,用户应重启动进入一个稳定的内核。用户可以用GDB对拷贝出的转储进行有限分析。编译vmlinux时应加上-g选项,才能生成调试用的符号,然后,用下面的命令调试vmlinux:
gdb vmlinux <dump-file>
SysRq魔术组合键打印内核信息
SysRq"魔术组合键"是一组按键,由键盘上的"Alt+SysRq+[CommandKey]"三个键组成,其中CommandKey为可选的按键。SysRq魔术组合键根据组合键的不同,可提供控制内核或打印内核信息的功能。SysRq魔术组合键的功能说明如表1所示。
键名 | 功能说明 |
b | 在没有同步或卸载硬盘的情况下立即启动。 |
c | 为了获取崩溃转储执行kexe重启动。 |
d | 显示被持的所有锁。 |
e | 发送信号SIGTERM给所有进程,除了init外。 |
f | 将调用oom_kill杀死内存热进程。 |
g | 在平台ppc和sh上被kgdb使用。 |
h | 显示帮助信息。 |
i | 发送信号SIGKILL给所有的进程,除了init外。 |
k | 安全访问密钥(Secure Access Key,SAK)杀死在当前虚拟终端上的所有程序。 |
m | 转储当前的内存信息到控制台。 |
n | 用于设置实时任务为可调整nice的。 |
o | 将关闭系统(如果配置为支持)。 |
p | 打印当前寄存器和标识到控制台。 |
q | 将转储所有正运行定时器的列表。 |
r | 关闭键盘Raw模式并设置为XLATE模式。 |
s | 尝试同步所有挂接的文件系统。 |
t | 将转储当前的任务列表和它们的信息到控制台。 |
u | 尝试以仅读的方式重挂接所有已挂接的文件系统。 |
v | 转储Voyager SMP处理器信息到控制台。 |
w | 转储的所有非可中断(已阻塞)状态的任务。 |
x | 在平台ppc/powerpc上被xmon(X监视器)接口使用。 |
0~9 | 设备控制台日志级别,控制将打印到控制台的内核信息。例如:0仅打印紧急信息,如:PANIC和OOPS信息。 |
默认SysRq组合键是关闭的。可用下面的命令打开此功能:
# echo 1 > /proc/sys/kernel/sysrq
关闭此功能的命令列出如下:
# echo 0 > /proc/sys/kernel/sysrq
如果想让此功能总是起作用,可在/etc/sysctl.conf文件中设置kernel.sysrq值为1。 系统重新启动以后,此功能将会自动打开。
打开SysRq组合键功能后,有终端访问权限的用户就可以自用它打印内核信息了。
注意:SysRq组合键在X windows上是无法使用的。必须先要切换到文本虚拟终端下。如果在图形界面,可以按Ctrl+Alt+F1切换到虚拟终端。在串口终端上,需要先在终端上发送Break信号,然后在5秒内输入sysrq组合键。如果用户有root权限,可把commandkey字符写入到/proc/sysrq-trigger文件,触发一个内核信息打印,打印的信息存放在/var/log/messages中。下面是一个命令样例:^-^$ echo 't' > sysrq-trigger ^-^vim /var/log/messages Oct 29 17:51:43 njllinux kernel: SysRq : Show State Oct 29 17:51:43 njllinux kernel: task PC stack pid father Oct 29 17:51:43 njllinux kernel: init S ffffffff812b76a0 0 1 0 Oct 29 17:51:43 njllinux kernel: ffff81013fa97998 0000000000000082 0000000000000000 ffff81013fa9795c Oct 29 17:51:43 njllinux kernel: 000000003fa97978 ffffffff81583700 ffffffff81583700 ffff81013fa98000 Oct 29 17:51:43 njllinux kernel: ffffffff813cc5b0 ffff81013fa98350 000000003c352a50 ffff81013fa98350 Oct 29 17:51:43 njllinux kernel: Call Trace: Oct 29 17:51:43 njllinux kernel: 000300000004 ffff8101333cb090 Oct 29 17:51:43 njllinux kernel: Call Trace: Oct 29 17:51:43 njllinux kernel: [<ffffffff81040c2e>] sys_pause+0x19/0x22 Oct 29 17:51:43 njllinux kernel: [<ffffffff8100c291>] tracesys+0xd0/0xd5 Oct 29 17:51:43 njllinux kernel: Oct 29 17:51:43 njllinux kernel: lighttpd S ffffffff812b76a0 0 3365 1 Oct 29 17:51:43 njllinux kernel: ffff810132d49b18 0000000000000082 0000000000000000 ffff810132d49adc Oct 29 17:51:43 njllinux kernel: ffff81013fb2d148 ffffffff81583700 ffffffff81583700 ffff8101354896a0 Oct 29 17:51:43 njllinux kernel: ffffffff813cc5b0 ffff8101354899f0 0000000032d49ac8 ffff8101354899f0 Oct 29 17:51:43 njllinux kernel: Call Trace: Oct 29 17:51:43 njllinux kernel: [<ffffffff81040722>] ? __mod_timer+0xbb/0xcd Oct 29 17:51:43 njllinux kernel: [<ffffffff8129b2ee>] schedule_timeout+0x8d/0xb4 Oct 29 17:51:43 njllinux kernel: [<ffffffff81040100>] ? process_timeout+0x0/0xb Oct 29 17:51:43 njllinux kernel: [<ffffffff8129b2e9>] ? schedule_timeout+0x88/0xb4 Oct 29 17:51:43 njllinux kernel: [<ffffffff810b9498>] do_sys_poll+0x2a8/0x370 ……
命令strace
命令strace 显示程序调用的所有系统调用。使用 strace 工具,用户可以清楚地看到这些调用过程及其使用的参数,了解它们与操作系统之间的底层交互。当系统调用失败时,错误的符号值(如 ENOMEM)和对应的字符串(如Out of memory)都能被显示出来。
starce 的另一个用处是解决和动态库相关的问题。当对一个可执行文件运行ldd时,它会告诉你程序使用的动态库和找到动态库的位置
strace命令行选项说明如表1。常用的选项为-t, -T, -e, -o等。
选项 | 说明 |
-c | 统计每个系统调用执行的时间、次数和出错的次数等。 |
-d | 输出一些strace自身的调试信息到标准输出。 |
-f | 跟踪当前进程由系统调用fork产生的子进程。 |
-ff | 如果使用选项-o filename,则将跟踪结果输出到相应的filename.pid中,pid是各进程的进程号。 |
-F | 尝试跟踪vfork调用.在-f时,vfork不被跟踪。 |
-h | 输出简要的帮助信息。 |
-i | 在系统调用的时候打印指令指针。 |
-q | 禁止输出关于粘附和脱离的信息,发生在输出重定向到文件且直接而不是粘附运行命令时。 |
-r | 依赖于每个系统调用的入口打印相对时间戳。 |
-t | 在输出中的每一行前加上时间信息。 |
-tt | 在输出中的每一行前加上时间信息,包括毫秒。 |
-ttt | 毫秒级输出,以秒表示时间。 |
-T | 显示系统调用所花费的时间。 |
-v | 输出所有的系统调用的信息。一些关于环境变量,状态,输入输出等调用由于使用频繁,默认不输出。 |
-V | 输出strace的版本信息。 |
-x | 以十六进制形式输出非ASCII标准字符串。 |
-xx | 所有字符串以十六进制形式输出。 |
-a column | 以特定的列数对齐返回值,缺省值为40。 |
-e expr | 指定一个表达式,用来控制如何跟踪.格式如下: [qualifier=][!]value1[,value2]... qualifier只能是 trace,abbrev,verbose,raw,signal,read,write其中之一。value是用来限定的符号或数字。默认的qualifier是 trace。感叹号是否定符号。 |
-eopen | 等价于 -e trace=open,表示只跟踪open调用。而-etrace!=open表示跟踪除了open以外的其他调用。 |
-e trace=set | 只跟踪指定的系统调用。例如:-e trace=open,close,rean,write表示只跟踪这四个系统调用。默认的为set=all。 |
-e trace=file | 只跟踪文件名作为参数的系统调用,一般为文件操作。 |
-e trace=process | 只跟踪有关进程控制的系统调用。 |
-e trace=network | 只跟踪与网络有关的所有系统调用。 |
-e strace=signal | 跟踪所有与系统信号有关的系统调用。 |
-e trace=ipc | 跟踪所有与进程间通信有关的系统调用。 |
-o filename | 将strace的输出写入文件filename。 |
-p pid | 跟踪指定的进程pid。 |
-s strsize | 指定最大字符串打印长度,默认值为32。 |
-u username | 以username的UID和GID执行命令。 |
execve("/bin/pwd", ["pwd"], [/* 39 vars */]) = 0 uname({sys="Linux", node="sammy", ...}) = 0 brk(0) = 0x804c000 old_mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x4001... fstat64(3, {st_mode=S_IFREG|0644, st_size=115031, ...}) = 0 old_mmap(NULL, 115031, PROT_READ, MAP_PRIVATE, 3, 0) = 0x40017000 close(3) = 0 open("/lib/tls/libc.so.6", O_RDONLY) = 3 read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\360U\1"..., 1024) = 1024 fstat64(3, {st_mode=S_IFREG|0755, st_size=1547996, ...}) = 0
用函数printk打印内核信息
Linux内核用函数printk打印调试信息,该函数的用法与C库打印函数printf格式类似,但在内核使用。用户可在内核代码中的某位置加入函数printk,直接把所关心的信息打打印到屏幕上或日志文件中。
函数printk根据日志级别(loglevel)对调试信息进行分类。日志级别用宏定义,展开为一个字符串,在编译时由预处理器将它和消息文本拼接成一个字符串,因此函数printk中的日志级别和格式字符串间不能有逗号。
下面两个 printk 的例子,一个是调试信息,一个是临界信息:printk(KERN_DEBUG "Here I am: %s:%i\n", _ _FILE_ _, _ _LINE_ _); printk(KERN_CRIT "I'm trashed; giving up on %p\n", ptr);
样例:在用户空间或内核中开启及关闭打印调试消息 用户还可以在内核或用户空间应用程序定义统一的函数打印调试信息,可在Makefile文件中打开或关闭调试函数。定义方法列出如下:
/*debug_on_off.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
在文件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" else DEBFLAGS = -O2 endif CFLAGS += $(DEBFLAGS)
更改makefile中的DEBUG值,需要调试信息时,DEBUG = y,不需要时,DEBUG赋其它值。再用make编译即可。
内核探测kprobe
kprobe(内核探测,kernel probe)是一个动态地收集调试和性能信息的工具,如:收集寄存器和全局数据结构等调试信息,无需对Linux内核频繁编译和启动。用户可以在任何内核代码地址进行陷阱,指定调试断点触发时的处理例程。工作机制是:用户指定一个探测点,并把用户定义的处理函数关联到该探测点,当内核执行到该探测点时,相应的关联函数被执行,然后继续执行正常的代码路径。
kprobe允许用户编写内核模块添加调试信息到内核。当在远程机器上调试有bug的程序而日志/var/log/messages不能看出错误时,kprobe显得非常有用。用户可以编译一个内核模块,并将内核模块插入到调试的内核中,就可以输出所需要的调试信息了。
内核探测分为kprobe, jprobe和kretprobe(也称return probe,返回探测)三种。kprobe可插入内核中任何指令处;jprobe插入内核函数入口,方便于访问函数的参数;return probe用于探测指定函数的返回值。
内核模块的初始化函数init安装(或注册)了多个探测函数,内核模块的退出函数exit将注销它们。注册函数(如:register_kprobe())指定了探测器插入的地方、探测点触发的处理例程。
(1)配置支持kprobe的内核
配置内核时确信在.config文件中设置了CONFIG_KPROBES、CONFIG_MODULES、CONFIG_MODULE_UNLOAD、CONFIG_KALLSYMS_ALL和CONFIG_DEBUG_INFO。
配置了CONFIG_KALLSYMS_ALL,kprobe可用函数kallsyms_lookup_name从地址解析代码。配置了CONFIG_DEBUG_INFO后,可以用命令"objdump -d -l vmlinux"查看源到对象的代码映射。
调试文件系统debugfs含有kprobe的调试接口,可以查看注册的kprobe列表,还可以关闭/打开kprobe。
查看系统注册probe的方法列出如下:
#cat /debug/kprobes/list
c015d71a k vfs_read+0x0
c011a316 j do_fork+0x0
c03dedc5 r tcp_v4_rcv+0x0
第一列表示探测点插入的内核地址,第二列表示内核探测的类型,k表示kprobe,r表示kretprobe,j表示jprobe,第三列指定探测点的"符号+偏移"。如果被探测的函数属于一个模块,模块名也被指定。
打开和关闭kprobe的方法列出如下:
#echo ‘1’ /debug/kprobes/enabled #echo ‘0’ /debug/kprobes/enabled
(2)kprobe样例
Linux内核源代码在目录samples/kpobges下提供了各种kprobe类型的探测处理例程编写样例,分别对应文件kprobe_example.c、jprobe_example.c和kretprobe_example.c,用户稍加修改就可以变成自己的内核探测模块。下面仅说明kprobe类型的探测例程。
样例kprobe_example是kprobe类型的探测例程内核模块,显示了在函数do_fork被调用时如何使用kprobe转储栈和选择的寄存器。当内核函数do_fork被调用创建一个新进程时,在控制台和/var/log/messages中将显示函数printk打印的跟踪数据。样例kprobe_example列出如下(在samples/kprobe_example.c中):
#include <linux/kernel.h> #include <linux/module.h> #include <linux/kprobes.h> /* 对于每个探测,用户需要分配一个kprobe对象*/ static struct kprobe kp = { .symbol_name= "do_fork", }; /* 在被探测指令执行前,将调用预处理例程 pre_handler,用户需要定义该例程的操作*/ static int handler_pre(struct kprobe *p, struct pt_regs *regs) { #ifdef CONFIG_X86 printk(KERN_INFO "pre_handler: p->addr = 0x%p, ip = %lx," " flags = 0x%lx\n", p->addr, regs->ip, regs->flags); /*打印地址、指令和标识*/ #endif #ifdef CONFIG_PPC printk(KERN_INFO "pre_handler: p->addr = 0x%p, nip = 0x%lx," " msr = 0x%lx\n", p->addr, regs->nip, regs->msr); #endif /* 在这里可以调用内核接口函数dump_stack打印出栈的内容*/ return 0; } /* 在被探测指令执行后,kprobe调用后处理例程post_handler */ static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags) { #ifdef CONFIG_X86 printk(KERN_INFO "post_handler: p->addr = 0x%p, flags = 0x%lx\n", p->addr, regs->flags); #endif #ifdef CONFIG_PPC printk(KERN_INFO "post_handler: p->addr = 0x%p, msr = 0x%lx\n", p->addr, regs->msr); #endif } /*在pre-handler或post-handler中的任何指令或者kprobe单步执行的被探测指令产生了例外时,会调用fault_handler*/ static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr) { printk(KERN_INFO "fault_handler: p->addr = 0x%p, trap #%dn", p->addr, trapnr); /* 不处理错误时应该返回*/ return 0; } /*初始化内核模块*/ static int __init kprobe_init(void) { int ret; kp.pre_handler = handler_pre; kp.post_handler = handler_post; kp.fault_handler = handler_fault; ret = register_kprobe(&kp); /*注册kprobe*/ if (ret < 0) { printk(KERN_INFO "register_kprobe failed, returned %d\n", ret); return ret; } printk(KERN_INFO "Planted kprobe at %p\n", kp.addr); return 0; } static void __exit kprobe_exit(void) { unregister_kprobe(&kp); printk(KERN_INFO "kprobe at %p unregistered\n", kp.addr); } module_init(kprobe_init) module_exit(kprobe_exit) MODULE_LICENSE("GPL");
Systemtap调试
(1)Systemtap原理
Systemtap是一个基于kprobe调试内核的开源软件。调试者只需要写一些脚本,通过Systemtap提供的命令行接口对正在运行的内核进行诊断调试,不需要修改或插入调试代码、重新编译内核、安装内核和重启动等工作,使内核调试变得简单容易。Systemtap调试过程与在gdb调试器中用断点命令行调试类似。
Systemtap用类似于awk语言的脚本语言编写调试脚本,该脚本命名事件并给这些事件指定处理例程。只要指定的事件发生,Linux内核将运行对应的处理例程。
有几种类型的事件,如:进入或退出一个函数,一个定时器超时或整个systemtap会话开始或停止。处理例程是一系列脚本语言语句指定事件发生时所做的工作,包括从事件上下文提取数据,存储它们进入内部变量或打印结果。
Systemtap的运行过程如图2所示,用户调试时用Systemtap编写调试脚本,Systemtap的翻译模块(translator)将脚本经语法分析(parse)、功能处理(elaborate)和翻译后生成C语言调试程序,然后,运行C编译器编译(build)创建调试内核模块。再接着将该内核模块装载入内核,通过kprobe机制,内核的hook激活所有的探测事件。当任何处理器上有这些事件发生时,对应的处理例程被触发工作,kprobe机制在内核获取的调试数据通过文件系统relayfs传回Systemtap,输出调试数据probe.out。在调试结束时,会话停止,内核断开hook连接,并卸载内核模块。整个操作过程由单个命令行程序strap驱动控制。
(2)stap程序
stap程序是Systemtap工具的前端,它接受用systemtap脚本语言编写的探测指令,翻译这些指令到C语言代码,编译C代码产生并装载内核模块到正运行的Linux内核,执行请求的跟踪或探测函数。用户可在一个命名文件中提供脚本或从命令行中提供调试语句。
命令stap的用法列出如下:
stap [ OPTIONS ] FILENAME [ ARGUMENTS ]
stap [ OPTIONS ] - [ ARGUMENTS ]
stap [ OPTIONS ] -e SCRIPT [ ARGUMENTS ]
stap [ OPTIONS ] -l PROBE [ ARGUMENTS ]
选项[ OPTIONS ]说明如下:
-h 显示帮助信息。
-V 显示版本信息。
-k 在所有操作完成后,保留临时目录。对于检查产生的C代码或重使用编译的内核对象来说,这是有用的。
-u 非优化编译模式。.
-w 关闭警告信息。
-b 让内核到用户数据传输使用bulk模式。
-t 收集时间信息:探测执行的次数、每个探测花费的平均时间量。
-sNUM 内核到用户数据传输使用NUM MB 的缓冲区。当多个处理器工作在bulk模式时,这是单个处理器的缓冲区大小。
-p NUM Systemtap在通过NUM个步骤后停止。步骤数为1-5: parse, elaborate, translate, compile, run。
-I DIR 添加tapset库(用于翻译C代码的函数集)搜索目录。
-D NAME=VALUE 添加C语言宏定义给内核模块Makefile,用于重写有限的参数。
-R DIR 在给定的目录查找Systemtap运行源代码。
-r RELEASE 为给定的内核发布版本RELEASE而不是当前运行内核编译内核模块。
-m MODULE 给编译产生的内核模块命名MODULE,替代缺省下的随机命名。产生的内核模块被拷贝到当前目录。
-o FILE 发送标准输出到命名文件FILE。在bulk模式,每个CPU的文件名将用"FILE_CPU序号"表示。
-c CMD 开始探测,运行CMD,当CMD完成时退出。
-x PID 设置target()