Linux设备驱动程序(四)——调试技术

2023-08-10 10:37:10 浏览数 (1)

前言

由于内核是一个不与特定进程相关的功能集合,所以内核代码无法轻易地放在调试器中执行,而且也很难跟踪跟踪,本章节将介绍监视内核代码并跟踪错误的技术。

一、内核中的调试技术

我们列出用来开发的内核应当激活的配置选项,除了特别指出外,所有的这些选项都在内核配置工具的“kernel hacking” 菜单中。注意:并非所有体系架构都支持其中的某些选项

  • CONFIG_DEBUG_KERNEL
    • 这个选项只是使其他调试选项可用。它应当打开,但它本身不会打开所有的调试功能。
  • CONFIG_DEBUG_SLAB
    • 这是一个非常重要的选项,它打开内核内存分配函数中的多个类型的检查,打开该检查后,就可以检测许多内存溢出及忘记初始化的错误,被分配内存的每一个字节在递交给调用者之前都设成 0xa5,而在释放后被设成 0x6b,如果在自己驱动程序输出中,或者在 oops 信息中看到上述 "slab"字符,则可以轻松判断问题所在。 在打开该调试选项后,内核还会在每个已分配内存对象的前面和后面设置一些特殊的防护值;这样,当这些防护值发生变化时,内核就可以知道有些代码超出了内存的正常访问范围。
  • 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
    • 打开 “SysRq 魔法(magic SysRq)” 按键。我们将在本章后面的“系统挂起”一节中讲述该按键。
  • CONFIG_DEBUG_STACKOVERFLOW
  • CONFIG_DEBUG_STACK_USAGE
    • 这些选项可帮助跟踪内核栈的溢出问题。栈溢出的确切信号是不包含任何合理的反向跟踪信息的 oops 清单。第一个选项将在内核中增加明确的溢出检查;而第二个选项将让内核监视栈的使用,并通过 SysRq 按键输出一些统计信息
  • CONFIG_KALLSYMS
    • 该选项出现在“General setup/Standard features (一般设置/标准功能)”菜单中将在内核中包含符号信息;该选项默认是打开的。该符号信息用于调试上下文;没有此符号,oops 清单只能给出十六进制的内核反向跟踪信息,这通常没有多少用处。
  • CONFIG_IKCONFIG
  • CONFIG_IKCONFIG_PROC
    • 这些选项(在“Generl setup菜单)使得完整的内核配置状态被建立到内核中,可以通过 /proc 来使其可用,大部分内核开发者知道他们使用的哪个配置,并不需要这些选项(会使得内核更大)。但是如果你试着调试由其他人建立的内核中的问题它们可能有用
  • CONFIG_ACPI_DEBUG
    • 该选项出现在“Power management/ACPI(电源管理/ACPI)”菜单中。该选项将打开ACPI(Advanced Configuration and Power Interface,高级配置和电源接口)中的详细调试信息。如果怀疑自己所遇到的问题和ACPI相关,则可使用该选项。
  • CONFIG_DEBUG_DRIVER
    • 在“Device drivers(设备驱动程序)”菜单中。该选项打开驱动程序核心中的调试信息,它可以帮助跟踪底层支持代码中的问题。
  • CONFIG_SCSI_CONSTANTS
    • 该选项出现在“Device drivers/SCSI device support (设备驱动程序/SCSI设备支持)”菜单中,它将打开详细的 SCSI 错误消息。如果读者要编写 SCSI驱动程序则可使用该选项。
  • CONFIG_INPUT_EVBUG
    • 该选项可在“Device drivers/Input device support(设备驱动程序/输入设备支持)中找到,它会打开对输人事件的详细记录。如果读者要针对输入设备编写驱动程序,则可使用该选项。注意该选项会导致的安全问题:它会记录你键入的任何东西包括密码。
  • CONFIG_PROFILING
    • 这个选项位于"Profiling support"之下,剖析通常用在系统性能调整,但是在追踪一些内核挂起和相关问题上也有用

在我们讲解不同的内核问题跟踪方法时,将再次遇到上述选项。

二、通过打印调试

调试内核代码的时候,可以用printk 来完成相同的工作。

1、printk

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

代码语言:javascript复制
printk(KERN_DEBUG "Here I am: %s:%in", __FILE__, __LINE__);
printk(KERN_CRIT "I'm trashed; giving up on %pn", ptr);

在头文件 <linux/kernel.h> 中定义了八种可用的日志级别字符串,下面以严重程度的降序来列出这些级别:

  • 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 中被指定为一个整数。在 2.6.10 内核中,DEFAULT_MESSAGE_LOGLEVEL 就是 KERN_WARNING

根据日志级别,内核可能会把消息打印到当前控制台上,这个控制台可以是一个字符模式的终端、一个串口打印机或是一个并口打印机。当优先级小于 console_loglevel 这个整数变量的值,消息才能显示出来,而且每次输出一行(如果不以newline字符结尾,则不会输出)。如果系统同时运行了 klogd 和 syslogd,则无论 console_loglevel 为何值,内核消息都将追加到 /var/log/messages 中(否则按照 syslogd 的配置进行处理)如果 klogd 没有运行,这些消息就不会传递到用户空间,这种情况下,只能查看 /proc/kmsg 文件(使用 dmesg 命令可以轻松做到)。如果使用 klogd,则应该了解它不会保存连续相同的信息行,它只会保存连续相同的第一行,并在最后打印这一行的重复次数。

变量 console_loglevel 的初始值是 DEFAULT_CONSOLE_LOGLEVEL,而且还可以通过sys_syslog 系统调用进行修改。调用 klogd 时可以指定 -c 开关项来修改这个变量。注意,要修改其当前值,必须先杀掉 klogd,然后再用新的 -c 选项重新启动它。此外,还可以编写程序来改变控制台的日志级别。新优先级被指定为一个1~8 之间的整数值。如果值被设为1,则只有级别为0(KERN_EMERG)的消息才能到达控制台;如果被设为8,则包括调试信息在内的所有消息都能显示出来。

我们也可以通过对文本文件 /procsys/kernel/printk 的访问来读取和修改控制台的日志级别。这个文件包含了4个整数值,分别是:当前的日志级别、未明确指定日志级别时的默认消息级别、最小允许的日志级别以及引导时的默认日志级别。向该文件中写人单个整数值,将会把当前日志级别修改为这个值。例如,可以简单地输入下面的命令使所有的内核消息显示到控制台上:

代码语言:javascript复制
echo 8 > /proc/sys/kernel/printk

2、重定向控制台消息

对于控制台日志策略,Linux 允许有某些灵活性:内核可以将消息发送到一个指定的虚拟控制台(假如控制台是文本屏幕的话)。默认情况下,“控制台”就是当前的虚拟终端。可以在任何一个控制台设备上调用 ioctl(TIOCLINUX) 来指定接收消息的其他虚拟终端。下面的 setconsole 程序,可选择专门用来接收内核消息的控制台。这个程序必须由超级用户运行,在 misc-progs 目录里可以找到它 。 下面是该程序的完整清单。调用该程序时,请附加一个参数指定要接收消息的控制台编号。

代码语言:javascript复制
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 argn",argv[0]); exit(1);
		}
	if(ioctl(STDIN_FILENO, TIOCLINUX, bytes)<0) { /* use stdin */
		fprintf(stderr,"%s: ioctl(stdin, TIOCLINUX): %sn",
		argv[0], strerror(errno));
		exit(1);
	}
	exit(0);
}

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

3、消息如何被记录

printk 函数将消息写到一个长度为 __LOG_BUP_LEN 字节的循环缓冲区中(我们可在配置内核时为__LOG_BUP_LEN 指定 4 KB-1MB 之间的值)。然后,该函数会唤醒任何正在等待消息的进程即那些睡眠在 syslog 系统调用上的进程或者正在读取 /proc/kmsg 的进程。这两个访问日志引擎的接口几乎是等价的,不过请注意,对 /proc/kmsg 进行读操作时,日志缓冲区中被读取的数据就不再保留,而 syslog 系统调用却能通过选项返回日志数据并保留这些数据,以便其他进程也能使用。一般而言,读 /proc 文件要容易些,这也是 klogd 的默认方法。dmesg 命令可在不刷新缓冲区的情况下获得缓冲区的内容;实际上,该命令将缓冲区的整个内容返回到 sdout,而无论该缓冲区是否已经被读取。

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

如果循环缓冲区填满了,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 来满足自己的需求。

4、开启及关闭消息

下面给出了一个调用 printk 的编码方法,它可个别或全局地开关 printk 语句;这个技巧是定义一个宏,在需要时,这个宏展开为一个 printk(或printf) 调用:

  • 可以通过在宏名字中删减或增加一个字母来启用或禁用每一条打印语句。
  • 在编译前修改 CFLAGS 变量,则可以一次禁用所有消息。
  • 同样的打印语句可以在内核代码中也可以在用户级代码使用,因此,关于这些额外的调试信息,驱动程序和测试程序可以用同样的方法来进行管理。 下面这些来自头文件 scull.h 的代码片段就实现了这些功能:
代码语言:javascript复制
#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 中添加下面几行:

代码语言:javascript复制
# 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)

预处理条件语句(以及代码中的常量表达式)只在编译时执行,所以要再次打开或关闭消息就必须重新编译。另一种方法就是使用 C 条件语句,它在运行时执行,因此可以在程序运行期间打开或关闭消息。这是个很好的功能,但每次代码执行时系统都要进行额外的处理其至在禁用消息后仍然会影响性能,而有时这种性能损失是无法接受的。

5、速度限制

有时会一不小心利用 printk 产生了上千条消息,从而让日志信息充满控制台,更可能使系统日志文件溢出。如果使用某个慢速控制台设备(比如串口),过高的消息输出速度会导致系统变慢,甚至使系统无法正常响应。

在许多情况下,最好的办法是设置一个标志,表示“我已经就此声明过了”,并在该标志被设置时不再打印任何信息。但在某些情况下,仍然有理由偶尔发出一条“该设备仍停止工作”这样的消息。内核为这种情况提供了一个有用的函数:

代码语言:javascript复制
int printk_ratelimit(void);

在打印一条可能被重复的信息之前,应调用上面这个函数。如果该函数返回一个非零值则可以继续并打印我们的消息,否则就应该跳过。这样,典型的调用应如下所示:

代码语言:javascript复制
if (printk_ratelimit())
	printk(KERN_NOTICE "The printer is still on firen");

printk ratelimit 通过跟踪发送到控制台的消息数量工作。如果输出的速度超过一个阈值,printk ratelimit 将返回零,从而避免发送重复消息。 我们可通过修改 /proc/sys/kernel/printk ratelimit(在重新打开消息之前应该等待的秒数)以及 /proc/sys/kernel/printk ratelimit burst(在进行速度限制之前可以接受的消息数)来定制 printk ratelimit 的行为

6、打印设备编号

有时当从一个驱动程序打印消息时,我们会希望打印与硬件关联的设备编号。内核提供了一对辅助宏(在 <linux/kdev t.h> 中定义):

代码语言:javascript复制
int print_dev_t(char *buffer, dev_t dev);
char *format_dev_t(char *buffer, dev_t dev);

这两个宏均将设备编号打印到给定的缓冲区,其唯一的区别是 print_dev_t 返回的是打印的字符数,而format_dev_t 返回的是缓冲区,这样,它的返回值可直接作为调用 printk 时的参数使用不能忘记只有在结尾处存在newline(新行)字符时,printk才将消息刷新到控制台。传入上述宏的缓冲区必须足够保存一个设备编号。因为在未来的内核版本中,使用64位设备编号的可能性非常明显,因此,该缓冲区的大小应该至少有20字节长

三、通过查询调试

大量使用 printk 仍然会显著降低系统性能,然而,因处理调试信息而使系统性能减慢是我们所不希望的。这个问题可以通过在 /etc/syslogd.conf 中日志文件的名字前面加一个减号前缀来解决。修改配置文件带来的问题在于,在完成调试之后这些改动将依旧保留;如果不愿作这种持久性修改的话,另一个选择是运行一个非 klogd 程序(如前面介绍的 cat /proc/kmsg),但这样并不能为通常的系统操作提供一个合适的环境。

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

驱动程序开发人员可以用如下方法对系统进行查询:在proc 文件系统中创建文件、使用驱动程序的 ioctl 方法,以及通过 sysfs 导出属性等。

1、使用 /proc 文件系统

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

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

①、在/proc中实现文件

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

在某个进程读取 /proc 文件时,内核会分配一个内存页 (即PAGE_SIZE字节的内存块),驱动程序可以将数据通过这个内存页返回到用户空间。该缓冲区会传人我们定义的函数,而该函数称为 read_proc 方法:

代码语言:javascript复制
int (*read_proc)(char *page, char **start, off_t offset, int count, int *eof, void *data);
  • page:指向用来写入数据的缓冲区
  • start:返回实际的数据写到内存页的哪个位置
  • offset 、count:与read方法相同
  • eof:指向一个整型数,当没有数据可返回时,驱动程序必须设置这个参数
  • data:提供给驱动程序的专用数据指针,可用于内部记录

在我们的 read_proc 方法被调用时,start 的初始值为 NULL。如果保留 *start 为空,内核将假定数据保存在内存页偏移量0的地方;也就是说,内核将对 read_proc 作如下简单假定:该函数将虚拟文件的整个内容放到了内存页,并同时忽略 offset 参数。相反,如果我们将start 设置为非空值,内核将认为由 *start 指向的数据是 offset 指定的偏移量处的数据,可直接返回给用户。

关于 /proc 文件还有另一个主要问题,这也是 start 意图解决的一个问题。有时,在连续的 read 调用之间,内核数据结构的 ASCII 表述会发生变化,以至于读取进程发现前后两次调用所获得的数据不一致

注意,还有一个更好的方法可实现 /proc 文件,该方法称为 seg_file,我们稍后将讲述这个方法。现在我们来看一个例子,下面是scull设备 read_proc 函数的简单实现:

代码语言:javascript复制
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 = d->data;
		if (down_interruptible(&d->sem))
			return -ERESTARTSYS;
		len  = sprintf(buf len,"nDevice %i: qset %i, q %i, sz %lin", i, d->qset, d->quantum, d->size);
		for (; qs && len <= limit; qs = qs->next) { /* scan the list */
			len  = sprintf(buf   len, " item at %p, qset at %pn", qs, qs->data);
			if (qs->data && !qs->next) /* dump only the last item */
				for (j = 0; j < d->qset; j  ) {
					if (qs->data[j])
						len  = sprintf(buf   len, " % 4i: %8pn", j, qs->data[j]);
				}
		}
		up(&scull_devices[i].sem);
	}
	*eof = 1;
	return len;
}

②、创建自己的 /proc 文件

一旦定义好了一个 read_proc 函数,就需要把它与一个 /proc 入口项连接起来。这通过调用 create_proc_read_entry 实现:

代码语言:javascript复制
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:内核会忽略data参数,但是会将该参数传递给 read_proc

下面是 scull 调用该函数创建 /proc 文件的代码:

代码语言:javascript复制
create_proc_read_entry("scullmem", 0 /* default mode */, NULL /* parent dir */, scull_read_procmem, NULL /* client data */);

上述代码在 /proc 目录下创建了一个称为 scullmem 的文件并默认具有全局可读权限设置。

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

代码语言:javascript复制
remove_proc_entry("scullmem", NULL /* parent dir */);

在使用 /proc 文件时,读者必须谨记这种实现的几个不足之处,因此我们不鼓励使用/proc文件。

  • 最重要的问题和 /proc 项的删除有关。删除调用可能在文件正在被使用时发生,因为 /proc 入口项不存在关联的所有者,因此对这些文件的使用并不会作用到模块的引用计数上。在移除模块时,执行sleep 100 < /proc/myfile 命令就可以触发这个问题。
  • 另外一个问题是关于使用同一名字注册两个人口项。内核信任驱动程序,因此不会检查某个名称是否已经被注册,因此如果不小心,将可能导致两个或多个入口项具有相同的名字

③、seq_file 接口

为了让内核开发工作更加容易,通过对 /proc 代码的整理而增加了 seq_file 接口。这一接口为大的内核虚拟文件提供了一组简单的函数。

seq_file 接口假定我们正在创建的虚拟文件要顺序遍历一个项目序列,而这些项目正是必须要返回给用户空间的。为使用 seq_file,我们必须创建一个简单的“选代器(iterator)”对象,该对象用来表示项目序列中的位置,每前进一步,该对象输出序列中的一个项目。下面我们将使用这个方法针对 scull 驱动程序创建一个 /proc 文件。

显然,第一步是包含<linux/seg_file.h>头文件,然后必须建立四个迭代器对象,分别为 start、next、stop 和 show。

start 方法始终会首先调用,该函数的原型如下:

代码语言:javascript复制
void *start(struct seq_file *sfile, loff_t *pos);
  • sfile:大多数情况下忽略
  • pos:读取的位置,因为 seq_file 的实现通常都要遍历一个项目序列,因此位置通常被解释为指向序列中下一个项目的游标(cursor)。scull 驱动程序将每个设备当作序列中的一个项目,这样,传入的 pos 就可以简单作为scull_devices 数组的索引。

于是,scull的start方法可如下编写:

代码语言:javascript复制
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。该方法的原型是:

代码语言:javascript复制
void *next(struct seq_file *sfile, void *v, loff_t *pos);
  • v:先前对 start 或者 next 的调用所返回的选代器
  • pos :文件的当前位置

next 方法应增加 pos 指向的值,这依赖于迭代器的工作方式,在某些情况下,我们也许要让 pos 的增加值大于1。scull 的 next 方法如下实现:

代码语言:javascript复制
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 方法通知我们进行清除工作:

代码语言:javascript复制
void stop(struct seq_file *sfile, void *v);

scull 的实现不需要完成请除工作,因此它的 stop 方法为空。 值得注意的是,在设计上,seq_file 的代码不会在 start 和 stop 的调用之间执行其他的非原子操作。我们可以确信,start 被调用之后马上就会有对 stop 的调用。因此,在 start 方法中获取信号量或者自旋锁是安全的。只要其他 seq_file 方法是原子的,则整个调用过程也是原子的

在上述调用之间,内核会调用 show 方法来将实际的数据输出到用户空间。该方法的原型如下:

代码语言:javascript复制
int show(struct seq_file *sfile, void *v);

该方法应该为迭代器所指向的项目建立输出。但是,它不能使用 printk 函数,而要使用针对 seq_file 输出的一组特殊函数:

代码语言:javascript复制
int seq_printf(struct seq_file *sfile, const char *fmt, ...);

这是 seq_file 实现的 printf 等价函数;它需要通常的格式字符串以及额外的值参数。同时,我们还要将 show 函数传人的 seq_file 结构传递给这个函数。如果 seq_printf 返回了一个非零值,则意味着缓冲区已满,而输出被丢弃。大部分实现都会忽略这个返回值。

代码语言:javascript复制
int seq_putc(struct seq_file *sfile, char c);
int seq_puts(struct seq_file *sfile, const char *s);

这两个函数是用户空间常用的 putc 和 puts 函数的等价函数。

代码语言:javascript复制
int seq_escape(struct seq_file *m, const char *s, const char *esc);

这个函数等价于 seq_puts,只是若 s 中的某个字符也存在于 esc 中,则该字符会以八进制形式打印。传递给 esc 参数的常见值是"tn ",它可以避免要输出的空白字符弄乱屏幕或者迷惑 shel1脚本。

代码语言:javascript复制
int seq_path(struct seq_file *sfile, struct vfsmount *m, struct dentry *dentry, char *esc);

这个函数可用于输出与某个目录项关联的文件名。对设备驱动程序来讲,它没有多少价值,这里包含该函数只是出于完整性考虑。

在我们的例子中,scull 中使用的 show 方法代码如下所示:

代码语言:javascript复制
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 %lin", (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 %pn", d, d->data);
		if (d->data && !d->next) /* dump only the last item */
		for (i = 0; i < dev->qset; i  ) {
			if (d->data[i])
			seq_printf(s, " % 4i: %8pn",
			i, d->data[i]);
		}
	}
	up(&dev->sem);
	return 0;
}

这里,我们最终解释了自己的“迭代器”值,它实际就是一个指向 scull_dev 结构的指针。

现在,我们定义了完整的迭代器操作函数,scull 必须将这些函数打包并和 /proc 中的某个文件连接起来。首先要填充一个 seq_operations 结构:

代码语言:javascript复制
static struct seq_operations scull_seq_ops = {
	.start = scull_seq_start,
	.next = scull_seq_next,
	.stop = scull_seq_stop,
	.show = scull_seq_show
};

有了这个结构,我们必须创建一个内核能够理解的文件实现。在使用 seq_file 时,我们不使用先前描述过的 read_proc 方法而最好在略低的层次上连接到 /proc。也就是说我们将创建一个 file_operations 结构(即用于字符驱动程序的相同结构),这个结构将实现内核在该 /proc 文件上进行读取和定位时所需的所有操作。幸运的是,这一过程非常直接。首先创建一个 open 方法,该方法将文件连接到 seq_file 操作:

代码语言:javascript复制
static int scull_proc_open(struct inode *inode, struct file *file)
{
	return seq_open(file, &scull_seq_ops);
}

对 seq_open 的调用将 file 结构和我们上面定义的顺序操作连接在一起。open是唯一一个必须由我们自己实现的文件操作,因此,我们的 file_operations 结构可如下定义。

代码语言:javascript复制
static struct file_operations scull_proc_ops = {
	.owner = THIS_MODULE,
	.open = scull_proc_open,
	.read = seq_read,
	.llseek = seq_lseek,
	.release = seq_release
};

这里,我们指定了我们自己的 open 方法,但对其他的 file_operations 成员,我们使用了已经定义好的seq_read、seq lseek 和 seq_release 方法。

最后,我们建立实际的 /proc 文件:

代码语言:javascript复制
entry = create_proc_entry("scullseq", 0, NULL);
if (entry)
	entry->proc_fops = &scull_proc_ops;

这次,我们没有使用 create_proc_read_entry 函数,而是使用了低层的 create_proc_entry,它的原型定义如下:

代码语言:javascript复制
struct proc_dir_entry *create_proc_entry(const char *name,mode_t mode,struct proc_dir_entry *parent);

该函数的参数和 create_procread entry 等价,分别是文件的名称(name)访问保护掩码(mode)以及父(parent) 目录。

利用上面的代码,scull 就在 /proc 中拥有了一个和先前版本类似的文件。但显然,这个文件要更加灵活一些,不管输出有多大,它都能够正确处理文件定位,并且相关代码更加容易阅读和维护。如果读者的 /proc 文件包含有大量的输出行,则我们建议使用seq_file接口来实现该文件。

2、ioctl 方法

iocil 是作用于文件描述符之上的一个系统调用。ioctl 接收一个“命令”号以及另一个(可选的)参数,命令号用以标识将要执行的命令,而可选参数通常是个指针。作为替代 /proc 文件系统的方法,我们可以专为调试设计若干 ioctl 命令。这些命令从驱动程序复制相关的数据到用户空间,然后可在用户空间中检验这些数据。

四、通过监视调试

有许多方法可用来监视用户空间程序的工作情况,比如用调试器一步步跟踪它的函数插人打印语句,或者在 strace 状态下运行程序等等。在检查内核代码时,后面这种技术最值得关注。

strace 命令是一个功能非常强大的工具,它可以显示由用户空间程序所发出的所有系统调用。它不仅可以显示调用,而且还能显示调用参数以及用符号形式表示的返回值。当系统调用失败时,错误的符号值(如ENOMEM)和对应的字符串(如“Out of memory内存溢出”) 都能被显示出来。strace 有许多命令行选项,其中最为有用的是下面几个:

  • -t,该选项用来显示调用发生的时间;
  • -T,显示调用所花费的时间;
  • -e,限定被跟踪的语用类型;
  • -0,将输出重定向到一个文件中

默认情况下,strace 将跟踪信息打印到 stderr 上。

下面给出了 strace ls /dev > /dev/scull0 命令的最后几行输出信息:

代码语言:javascript复制
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, "MAKEDEVnadmmidi0nadmmidi1nadmmid"..., 4096) = 4000
write(1, "bnptywcnptywdnptywenptywfnptyx0n"..., 96) = 96
write(1, "bnptyxcnptyxdnptyxenptyxfnptyy0n"..., 4096) = 3904
write(1, "s17nvcs18nvcs19nvcs2nvcs20nvcs21"..., 192) = 192
write(1, "nvcs47nvcs48nvcs49nvcs5nvcs50nvc"..., 673) = 673
close(1) = 0
exit_group(0) = ?

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

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

代码语言:javascript复制
[...]
open("/dev/scull0", O_RDONLY|O_LARGEFILE) = 3
fstat64(3, {st_mode=S_IFCHR|0664, st_rdev=makedev(254, 0), ...}) = 0
read(3, "MAKEDEVnadmmidi0nadmmidi1nadmmid"..., 16384) = 4000
read(3, "bnptywcnptywdnptywenptywfnptyx0n"..., 16384) = 4000
read(3, "s17nvcs18nvcs19nvcs2nvcs20nvcs21"..., 16384) = 865
read(3, "", 16384) = 0
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
write(1, "8865 /dev/scull0n", 17) = 17
close(3) = 0
exit_group(0) = ?

正如我们所料,read每次只能读取 4000 个字节,但数据总量与前面例子中写入的总量是相同的。

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

五、调试系统故障

即使采用了所有这些监视和调试技术,有时驱动程序中依然会有错误,这样的驱动程序在执行时就会产生系统故障。

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

1、oops 消息

大部分错误都是因为对 NULL 指针取值或因为使用了其他不正确的指针值。这些错误通常会导致一个 oops 消息。

由处理器使用的地址几乎都是虚拟地址,这些地址(除了内存管理子系统本身所使用的物理内存之外)通过一个复杂的被称为“页表”的结构被映射为物理地址。当引用一个非法指针时,分页机制无法将该地址映射到物理地址,此时处理器就会向操作系统发出一个“页面失效 (page fault)”的信号。如果地址非法,内核就无法“换入 (page in)缺失页面;这时,如果处理器恰好处于超级用户模式,系统就会产生一个 oops。

oops 显示发生错误时处理器的状态,比如 CPU 寄存器的内容以及其他看上去无法理解的信息。这些消息由失效处理函数(arch/*/kernel/traps.c)中的 printk 语句产生,就像前面“printk”一节所介绍的那样处理。

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

代码语言:javascript复制
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 模块专为演示出错而编写。faulty.c 中 write 方法的实现很简单:

代码语言:javascript复制
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 决不会是个合法的指针值,所以产生了错误,内核进入上面的 oops 消息状态。这个调用进程接着就被杀掉了。

在 faulty 模块的 read 实现中,该模块还展示了更多有意思的错误状态:

代码语言:javascript复制
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;
}

该方法将一个字符串复制到一个局部变量,但不幸的是,字符串要比目标数组长。这样就会在该函数返回时因为缓冲区溢出而导致一个 oops 的产生。然而,由于 return 指令把指令指针带到了无法预期的地方,所以这种错误很难跟踪,所能获得的仅是如下的信息:

代码语言:javascript复制
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值 (bad EIP value)”。这一抱怨,以及开头处列出的明显错误的地址 (ffffffff)均说明内核栈已经被破坏。

通常,在我们面对一条 oops 时,首先要观察的是发生的问题所在的位置,这通常可通过调用栈信息得到。在上面给出的第一个 oops 中,相关的信息是:

代码语言:javascript复制
EIP is at faulty_write 0x4/0x10 [faulty]

从这里我们可以看到,故障所在的函数是 faulty_write,该函数位于 faulty 模块(列在中括号内)。十六进制的数据表明指令指针在该函数的 4 字节处,而函数本身是 10(十六进制)字节长。通常,这些信息足以让我们看到问题的真正所在。

如果需要更多信息,调用栈可以告诉我们系统是如何到达故障点的。栈本身以十六进制形式打印,通过一些工作,我们可通过栈清单确定局部变量和函数参数的值。有经验的内核开发人员通过此类模式可有效地发现问题所在。例如,如果我们观察 faulty_read 产生的 oops 的栈清单:

代码语言:javascript复制
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,故大于0xc0000000 的值几乎肯定是内核空间的地址,等等。

最后,在观察 oops 清单时还要记得观察本章前面讨论过的 “slab 毒剂” 值。例如,如果我们获得的内核 oops 中包含有 0xa5a5a5a5 这样的地址,那几乎可以肯定的是,我们在某处忘记了初始化动态分配到的内存。 需要注意的是,只有在构造内核时打开了 CONFIG_KALLSYMS 选项,我们才能看到符号化的调用栈(就像上面列出的那样);否则,我们只能看到裸的、十六进制的清单,因而只有通过其他途径解开这些数字的含义,才能弄清楚真正的调用栈。

2、系统挂起

尽管内核代码中的大多数错误只会导致一个 oops 消息,但有时它们会将系统完全挂起如果系统挂起了,任何消息都无法打印出来

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

有时系统看起来像挂起了,但其实并没有。例如,如果键盘因某种奇怪的原因被锁住了就会发生这种情况。这时,运行专为探明此种情况而设计的程序,通过查看它的输出情况,可以发现这种假的挂起。显示器上的时钟或系统负荷表就是很好的状态监视器,只要这些程序保持更新,就说明调度器仍在工作。

对于上述情形,一个不可缺少的工具是“SysRq 魔法键(magic SysRg key)”,大多数架构上都可以利用魔法键。SysRq 魔法可通过 PC 键盘上的 ALT 和 SysRq(F12键右边) 组合键来激活在其他平台上则通过其他特殊键激活(详情可见Documentation/sysrq.txt)串口控制台上也可激活。根据与这两个键一起按下的第三个键的不同,内核会执行许多有用动作中的其中一个,如下所示:

  • r:关闭键盘的 raw 模式。当某个崩溃的应用程序(比如X服务器)让键盘处于一种奇怪状态时,就可以用这个键关闭 raw 模式。
  • k:激活“留意安全键(secure attention key,SAK)”功能。SAK 将杀死当前控制台上运行的所有进程,留下一个干净的终端。
  • s:对所有磁盘进行紧急同步。
  • u:尝试以只读模式重新挂装所有磁盘。这个操作通常紧接着 s 动作之后立即被调用它可以在系统处于严重故障状态时节省很多检查文件系统的时间。
  • b:立即重启系统。注意先要执行同步并重新挂装磁盘。
  • p:打印当前的处理器寄存器信息。
  • t:打印当前的任务列表。
  • m:打印内存信息。

SysRq 功能必须显式地在内核配置中启用,不过,对于一个用于驱动程序开发的系统来说,为启用 SysRq 功能而带来的重新编译新内核的麻烦是值得的。在系统运行时,可通过下面的命令启用 SysRq 功能:

代码语言:javascript复制
echo 0 > /proc/sys/kernel/sysrq

因为 SysRq 功能非常有用,因此这些功能也对无法访问控制台的系统管理员开放。/proc/sysrq-trigger 是一个只写的 /proc 入口点,向这个文件写人对应的字符,就可以触发相应的 SysRq 动作。这个针对 SysRq 的入口点始终可用,即使控制台上的 SysRq是禁止的。

在复现系统的挂起故障时,另一个要采取的预防措施是,把所有的磁盘以只读的方式挂装在系统上(或干脆卸装它们)。如果磁盘是只读的或者并未挂装,就不存在破坏文件系统或致使文件系统处于不一致状态的风险。另一个可行方法是,通过 NFS(networkfilesystem网络文件系统)装所有的文件系统。这个方法要求内核具有“NFS-Root的能力,而且在引导时还需传入一些特定的参数

六、调试器和相关工具

1、使用 gdb

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

代码语言:javascript复制
gdb /usr/src/linux/vmlinux /proc/kcore

第一个参数是未经压缩的内核 ELF 可执行文件的名字,而不是 zlmage 或 bzlmage 以及其他任何针对特定引导环境创建的特殊内核映像。

**gdb 命令行的第二个参数是 core 文件的名字。与其他 /proc 中的文件类似,/proc/kcore 也是在被读取时产生的。**在 /proc 文件系统中执行 read 系统调用时,它会映射到一个用于数据生成而不是数据读取的函数上;。在 gdb 的使用中可以通过标准 gdb 命令查看内核变量。例如,p jiffies 命令可以打印从系统启动到当前时刻的时钟滴答数。

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

对内核进行调试时,gdb 的许多常用功能都不可用。例如,gdb 不能修改内核数据,因为在处理其内存映像之前,gdb 期望把待调试的程序运行在自己的控制之下。同样,我们也不能设置断点或观察点,或者单步跟踪内核函数。 注意,为了让 gdb 使用内核的符号信息,我们必须在打开 CONFIG_DEBUG_INFO 选项的情况下编译内核。其结果将产生一个非常大的内核映像,但若没有符号信息,观察内核变量的目的基本上无法完成。

Linux 的可装载模块是 ELF 格式的可执行映像,模块会被划分为许多代码段。一个典型的模块可能包含十多个或者更多的代码段,但对调试会话来讲,相关的代码段只有下面三个:

  • .text
    • 这个代码段包含了模块的可执行代码。调试器必须知道该代码段的位置才能给出追踪信息或者设置断点(当我们在/proc/kcore上运行调试器时,这两个操作均无法实现,但如果使用下面讲到的 kgdb,则这两个操作非常有用)。
  • .bss
  • .data
    • 这两个代码段保存模块的变量。任何编译时未初始化的变量保存在 .bss 段,而其他经过初始化的变量保存在 .data 段。

为了 gdb 能够处理可装载模块,必须告诉调试器装载模块代码段的具体位置。该信息可通过 sysfs 的 /sysfs/module 获得。例如,在装载了scull模块之后,/sys/module/sculll/sections目录中将包含类似 .text 这样名字的文件,这些文件的内容是对应代码段的基地址。

现在可以通过一条 gdb 命令告诉调试器有关模块的信息了。这条命令就是 add-symbol-file,该命令需要用模块目标文件的名称、.text 段的基地址以及其他一些选项作为参数,这些选项描述了其他必要的代码段信息。通过 sysfs 获取模块的代码段数据后,我们可以如下构造这条命令:

代码语言:javascript复制
(gdb) add-symbol-file .../scull.ko 0xd0832000 
-s .bss 0xd0837100 
-s .data 0xd0836be0

之后,就可以使用 gdb 来检查可装载模块中的变量了。下面是来自某个 scull 调试会话的示例:

代码语言:javascript复制
(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 设备目前保存有 20881 字节的数据。如果愿意,我们还可以跟踪数据链,或者查看模块中的其他任何感兴趣的数据。另外一个值得掌握的技巧是:

代码语言:javascript复制
(gdb)print *(address)

这里,填充 address 指向的一个 16 进制地址,输出是对应那个地址的代码的文件和行号,这个技术可能有用,例如,来找出一个函数指针真正指向哪里。

2、kdb 内核调试器

Linus 不信任交互式的调试器。他担心这些调试器会导致一些不良的修改,因此,他不支持在内核中内置调试器。然而,其他的内核开发人员偶尔也会用到一些交互式的调试工具。kdb 就是其中一种内置的内核调试器,它在oss.sgi.com 上以非正式的补丁形式提供。

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

代码语言:javascript复制
Entering kdb (0xc0347b80) on processor 0 due to Keyboard Entry
[0]kdb>

注意,当 kdb 运行时,内核所做的每一件事情都会停下来。

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

代码语言:javascript复制
[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 命令尝试读取这个设备,这样一来就会产生如下的状态:

代码语言:javascript复制
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 的开头位置。为了查明是怎样到达这个位置的,我们可以看看堆栈跟踪记录:

代码语言:javascript复制
[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 指针的值:

代码语言:javascript复制
[0]kdb> mds scull_devices 1
0xd0880de8 cf36ac00 ....

在这里,我们要查看的是从 scull_devices 指针位置开始的一个字大小(4个字节)的数据;该命令的结果告诉我们,设备数组的起始地址位于 0xd0880de8,而第一个设备结构本身位于 0xcf36ac00。要查看设备结构中的数据,我们需要用到这个地址:

代码语言:javascript复制
[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 还可以修改数据。假设我们要从设备中削减一些数据:

代码语言:javascript复制
[0]kdb> mm cf26ac0c 0x50
0xcf26ac0c = 0x50

接下来对设备的 cat 操作所返回的数据就会少于上次。 kdb 还有许多其他功能,包括单步调试(根据指令,而不是C源代码行),在数据访问中设置断点、反汇编代码、跟踪链表以及访问寄存器数据等等。在应用了 kdb 补丁之后在内核源代码树的 Documentation/kdb 目录下可以找到完整的 kdb 相关手册页。

0 人点赞