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

本文涉及的产品
公网NAT网关,每月750个小时 15CU
网络型负载均衡 NLB,每月750个小时 15LCU
应用型负载均衡 ALB,每月750个小时 15LCU
简介: Linux设备驱动程序(四)——调试技术3

五、调试系统故障

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

注意,“故障(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)即出错指令的地址

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 方法的实现很简单:

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 实现中,该模块还展示了更多有意思的错误状态:

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 指令把指令指针带到了无法预期的地方,所以这种错误很难跟踪,所能获得的仅是如下的信息:

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 中,相关的信息是:

EIP is at faulty_write+0x4/0x10 [faulty]

从这里我们可以看到,故障所在的函数是 faulty_write,该函数位于 faulty 模块(列在中括号内)。十六进制的数据表明指令指针在该函数的 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,故大于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 功能:

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 调用如下所示:

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 获取模块的代码段数据后,我们可以如下构造这条命令:

(gdb) add-symbol-file .../scull.ko 0xd0832000 \
-s .bss 0xd0837100 \
-s .data 0xd0836be0

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

(gdb)print *(address)

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

2、kdb 内核调试器

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

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

Entering kdb (0xc0347b80) on processor 0 due to Keyboard Entry
[0]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 目录下可以找到完整的 kdb 相关手册页。


相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
高可用应用架构
欢迎来到“高可用应用架构”课程,本课程是“弹性计算Clouder系列认证“中的阶段四课程。本课程重点向您阐述了云服务器ECS的高可用部署方案,包含了弹性公网IP和负载均衡的概念及操作,通过本课程的学习您将了解在平时工作中,如何利用负载均衡和多台云服务器组建高可用应用架构,并通过弹性公网IP的方式对外提供稳定的互联网接入,使得您的网站更加稳定的同时可以接受更多人访问,掌握在阿里云上构建企业级大流量网站场景的方法。 学习完本课程后,您将能够: 理解高可用架构的含义并掌握基本实现方法 理解弹性公网IP的概念、功能以及应用场景 理解负载均衡的概念、功能以及应用场景 掌握网站高并发时如何处理的基本思路 完成多台Web服务器的负载均衡,从而实现高可用、高并发流量架构
目录
相关文章
|
1月前
|
Linux 虚拟化
Vmware 傻瓜式安装(不可不知道的Linux基础知识和技术 01)
本文介绍了VMware虚拟机的下载与安装步骤。首先,通过提供的网盘链接下载VMware安装包。接着,详细描述了安装流程,包括接受协议、选择安装路径(建议避免系统C盘)、取消更新选项等。最后,输入许可证密钥完成安装,并展示了打开虚拟机后的主界面。整个过程简单易懂,适合新手操作。
128 1
|
2月前
|
安全 Linux Android开发
Linux CFI (Control-flow integrity)技术相关资料汇总
Linux CFI (Control-flow integrity)技术相关资料汇总
|
3月前
|
NoSQL Linux C语言
Linux GDB 调试
Linux GDB 调试
59 10
|
2月前
|
Linux 程序员 编译器
Linux内核驱动程序接口 【ChatGPT】
Linux内核驱动程序接口 【ChatGPT】
|
3月前
|
存储 缓存 Unix
Linux 设备驱动程序(三)(上)
Linux 设备驱动程序(三)
36 3
|
3月前
|
Ubuntu Linux
内核实验(四):Qemu调试Linux内核,实现NFS挂载
本文介绍了在Qemu虚拟机中配置NFS挂载的过程,包括服务端的NFS服务器安装、配置和启动,客户端的DHCP脚本添加和开机脚本修改,以及在Qemu中挂载NFS、测试连通性和解决挂载失败的方法。
167 0
内核实验(四):Qemu调试Linux内核,实现NFS挂载
|
3月前
|
Linux
Linux 设备驱动程序(四)
Linux 设备驱动程序(四)
20 1
|
3月前
|
存储 数据采集 缓存
Linux 设备驱动程序(三)(中)
Linux 设备驱动程序(三)
33 1
|
3月前
|
存储 前端开发 大数据
Linux 设备驱动程序(二)(中)
Linux 设备驱动程序(二)
28 1
|
3月前
|
存储 缓存 安全
Linux 设备驱动程序(三)(下)
Linux 设备驱动程序(三)
28 0
下一篇
无影云桌面