Linux kernel的调试debug技术

内核中的调试支持

内核开发者在内核中建立了很多调试功能。但是这些调试功能会造成额外的输出,并且导致性能下降,因此发行版厂商通常会禁止发行版内核中的这些功能。但是作为一名内核开发者,调试需求具有更高优先级,从而乐意接受额外的调试支持而带来的性能损失。

这里列出内核开发的几个配置选项,除特殊指出,所有这些选项均出现在“kernel hacking”菜单。并非所有体系架构都支持其中的某些选线。更多的调试功可能查看lib/Kconfig.debug文件,或者在menuconfig中搜索关键字debug。

  • CONFIG_DEBUG_KERNEL

该选项仅仅使得其他的调试选项可用。我们应该打开该选项,但它本身不会打开所有的调试功能。

  • CONFIG_DEBUG_SLAB

这是一个非常重要的选项,它打开内核分配函数中的多个类型的检查;打开该检查后,就可以检测许多内存溢出以及忘记初始化的错误。在将已分配内存返回给调用者之前,内核将其中的每个字节设置为0xa5,而在释放后将其设置为0x6b。如果读者在自己驱动程序的输出中,或者在oops信息中看到上述“毒剂”字符,则可轻松判断问题所在。在打开该选项后,内核还会在每个已分配内存对象的前面和后面放置一些特殊的防护值,这样当这些防护值发生变化时,内核就可以知道有些代码超出了内存的正常访问范围。同时,该选项还会检查更多的隐藏错误。

  • CONFIG_DEBUG_PAGEALLOC

在释放时,全部内存页从内核地址空间中移除。该选项将大大降低运行速度,但可以快速定位特定的内存损坏错误的所在位置。

  • CONFIG_DEBUG_SPINLOCK

内核将捕获自旋锁的错误操作,比如操作未初始化自旋锁、两次解开同一锁的操作等其他错误。

  • CONFIG_DEBUG_ATOMIC_SLEEP

该选项将会检查拥有原子锁的休眠企图

  • CONFIG_DEBUG_INFO

该选项将使内核的构造包含完整的调试信息。如果读者打算用gdb调试内核,将需要这些信息,还需要打开CONFIG_FRAME_POINTER。

  • CONFIG_DEBUG_STACKOVERFLOW
  • CONFIG_DEBUG_STACK_USAGE

这些选项帮助跟踪内核栈溢出问题。栈溢出的确切信号是不包含任何合理的反向跟踪信息的oops清单。第一个选项将在内核中增加明确的溢出检查;第二个选项将让内核监视栈的使用(打印最大栈深度),并通过sysrq按键输出一些统计信息。

  • CONFIG_DEBUG_KMEMLEAK

kmemleak是内核提供的一种检测内存泄露工具,启动一个内核线程扫描内存,每隔一定时间扫描内存(默认10分钟),并打印发现新的未引用对象数量。

CONFIG_HAVE_DEBUG_KMEMLEAK=y
CONFIG_DEBUG_KMEMLEAK=y
CONFIG_DEBUG_KMEMLEAK_EARLY_LOG_SIZE=16000
CONFIG_DEBUG_KMEMLEAK_DEFAULT_OFF=y---关闭此选项,则不需要在命令行添加kmemleak=on

使用方法:

立即触发保存扫描结果
echo scan > /sys/kernel/debug/kmemleak

显示可能的内存泄漏的详细信息,需要先挂载debugfs文件系统:
mount -t debugfs nodev /sys/kernel/debug
cat /sys/kernel/debug/kmemleak

  • CONFIG_KALLSYMS

该选项将在内核中包含符号信息,默认是打开的,符号信息用于调试上下文,没有此符号,oops清单只能给出十六进制的内核反向跟踪信息,这通常没有多少用处。

  • ONFIG_IKCONFIG //放到镜像中
  • CONFIG_IKCONFIG_PROC //放到 proc目录

打开这些选项(在“General setup”菜单下)使完整的内核配置信息编译进内核,并可通过/proc/config.gz访问(zcat免解压显示内容;gzip解压文件;adb pull proc/config.gz)。大多数的内核开发者了解他们使用的配置,所以不需要这个选项(它使内核变得更大)。如果你尝试调试其它人编译的内核中的问题,它会非常有帮助。也可以在boot查看配置信息:cat /boot/config-$(uname -r) | grep IKCONFIG

  • CONFIG_DEBUG_DRIVER

位于“Device drivers”。打开驱动程序核心的调试信息,跟踪底层支持代码产生的问题时很有用

通过打印调试printk

printk介绍

  • printk是打印内核消息的函数
  • printk通过附加不同日志级别(loglevel)或者说消息优先级,让printk对消息进行分类,这是与printf最大的区别
  • 在编译时,日志级别宏会被展开为一个字符串,然后与消息本文拼接在一起,因此printk中优先级和格式字符串之间没有逗号。

如果编译内核时选择了CONFIG_PRINTK=y,则会增加这个功能,否则所有printk都会被替换成空语句。
rsyslog从/proc/kmsg中持续读取,并写入/var/log/messages文件(通过/etc/rsyslog.conf配置)。
dmesg从/dev/kmsg中读取。
cat /dev/kmsg可以获得和dmesg几乎同样的效果,区别是命令最后会阻塞住等待新日志并持续打印。

这里是prink命令的两个例子,一条调试信息和一个临界信息:
printk(KERN_DEBUG "Here I am: %s:%i\n", FILE, LINE);
printk(KERN_CRIT "I'm trashed; giving up on %p\n", ptr);

在头文件<linux/kernel.h>中定义了8中可用的日志级别字符串,下面以严重程序的降序方式排列:

  • KERN_EMEGR:用于紧急事件消息,一般是系统崩溃之前提示的消息
  • KERN_ALERT:用于需要立即采取动作的情况
  • KERN_CRIT:临界状态,通常设计严重的硬件或软件操作失败
  • KERN_ERR:用于报告错误状态。设备驱动程序会经常使用该宏来报告来自硬件的问题
  • KERN_WARNING:对可能出现问题的情况进行警告,但这类情况通常不会对系统造成严重问题
  • KERN_NOTICE:有必要进行提示的正常情形。许多与安全相关的状态用这个级别进行汇报
  • KERN_INFO:提示性信息。很多驱动程序在启动的时候以这个级别来打印出它们找到的硬件信息
  • KERN_DEBUG:用于调试信息

上面每个字符串(以宏的形式展开)表示一个尖括号中的整数。范围分别为0~7。数值越小,优先级越高。

打印级别

  • 默认打印级别

MESSAGE_LOGLEVEL_DEFAULT、CONSOLE_LOGLEVEL_DEFAULT宏

  • 未指定优先级的printk语句采用的默认级别是MESSAGE_LOGLEVEL_DEFAULT;该宏在kernel/printk/printk.c中被指定为另一个宏CONFIG_MESSAGE_LOGLEVEL_DEFAULT,该宏通过config配置

  • 在Linux 2.6.10内核中,MESSAGE_LOGLEV_ELDEFAULT就是KERN_WARNGIN(从config.gz中看到其值为4,说明为KERN_WARNING)

  • 根据日志级别,内核会把消息打印到控制台上或者保存到dmesg中。这个控制台可以是一个字符串终端、一个打印机。当优先级小于console_loglevel这个整数变量时,消息才会打印到控制台,而且每次输出一行。

  • 动态修改打印级别

我们可以通过对文本文件/proc/sys/kernel/printk的访问来读取和修改控制台的日志级别。文件中分别有4个数值字段,从左到右分别为:当前的日志级别未明确指定日志级别时的默认消息级别最小允许的日志级别引导时的默认日志级别。向该文件中写入单个整数值,将会把当前日志级别修改为这个值。

~/$ cat /proc/sys/kernel/printk
4 4 1 7

消息如何被记录

printk函数将消息写到一个长度为__LOG_BUF_LEN字节的循环缓冲区中(ring buff),我们可在配置内核时为__LOG_BUF_LEN指定4KB-1MB之间的值。Linux消息处理方法的另一特点是,可以在任何地方调用printk,甚至在中断处理函数里,而且对数据的大小没有限制,唯一缺点是可能会丢失某些数据。

rsyslogd日志记录器由两个守护进程(rklogd,rsyslogd)和一个配置文件(/etc/rsyslog.conf)组成。rklogd不使用配置文件,它负责截获内核消息,它既可以独立使用也可以作为rsyslogd的客户端运行。rsyslogd默认使用/etc/syslog.conf作为配置文件,负责截获应用程序消息,还可以截获rklogd向其转发的内核消息,然后根据不同服务产生的消息分别记录到不同的文件中。

/var/log/messages或/var/log/syslog — 包括整体系统信息,其中也包含系统启动期间的日志。此外,mail,cron,daemon,kern和auth等内容也记录在var/log/messages日志中。
/var/log/dmesg — 包含内核缓冲信息(kernel ring buffer)。在系统启动时,会在屏幕上显示许多与硬件有关的信息。可以用dmesg查看它们。
/var/log/auth.log — 包含系统授权信息,包括用户登录和使用的权限机制等。
/var/log/boot.log — 包含系统启动时的日志。
/var/log/daemon.log — 包含各种系统后台守护进程日志信息。
/var/log/dpkg.log — 包括安装或dpkg命令清除软件包的日志。
/var/log/kern.log — 包含内核产生的日志,有助于在定制内核时解决问题。
/var/log/lastlog — 记录所有用户的最近信息。这不是一个ASCII文件,因此需要用lastlog命令查看内容。
/var/log/maillog 与 /var/log/mail.log — 包含来着系统运行电子邮件服务器的日志信息。例如,sendmail日志信息就全部送到这个文件中。
/var/log/user.log — 记录所有等级用户信息的日志。
/var/log/Xorg.x.log — 来自X的日志信息。
/var/log/alternatives.log — 更新替代信息都记录在这个文件中。
/var/log/btmp — 记录所有失败登录信息。使用last命令可以查看btmp文件。例如,last -f /var/log/btmp | more 。
/var/log/cups — 涉及所有打印信息的日志。
/var/log/anaconda.log — 在安装Linux时,所有安装信息都储存在这个文件中。
/var/log/yum.log — 包含使用yum安装的软件包信息。
/var/log/cron — 每当cron进程开始一个工作时,就会将相关信息记录在这个文件中。
/var/log/secure — 包含验证和授权方面信息。例如,sshd会将所有信息记录(其中包括失败登录)在这里。
/var/log/wtmp或/var/log/utmp — 包含登录信息。使用wtmp可以找出谁正在登陆进入系统,谁使用命令显示这个文件或信息等。
/var/log/faillog —包含用户登录失败信息。此外,错误登录命令也会记录在本文件中。

  • 开启及关闭消息

在程序开发的初期阶段,printk对于调试和测试新代码是相当有帮助的。不过在正式发布驱动程序时,就得删除这些打印,或者至少禁用它们。不幸的是,你可能会发生这样的情况,即在删除了那些已被认为不再需要的提示消息后,又需要实现一个新的功能(或者发现一个bug),这时,又希望恢复那些log。解决办法:

  1. 使用条件语句,因此可在运行期间打开或者关闭,这是一个好功能;但是每次都要进行额外的处理,甚至在禁用消息后仍然会影响性能
  2. 定义一个宏,在需要的时候这个宏展开为一个printk调用。麻烦的是需要重新编译,好处是不影响性能
  • 速度限制

有时读者会一不小心利用printk产生了上千条消息,从而让日志信息充满控制台,更可能使系统日志文件溢出。如果使用某个慢速控制台(如串口),过快的消息输出会导致系统变慢产生其他时序的问题。因此我们应该非常小心的管理我们的打印信息。通常,正式代码不应该在正常操作下打印任何信息,而打印出的信息应该作为在异常时的提示信息。另一方面,在我们设备异常停止工作时,也许希望产生一条日志信息,但是我们要小心,不能在重试过程中不断地打印失败的提示消息,这样的巨量输出会阻塞CPU运行。

在许多情况下,最好的办法是设置一个标志,表示我已经就此声明过了,并在该标志被设置时不再打印任何消息。但在某些情况下,仍然希望偶尔发出一条该设备停止工作的提示消息。

printk_ratelimit函数(kernel建议用printk_ratelimited代替)通过跟踪发送到控制台的消息数量工作,如果输出的速度超过一个阈值,printk_ratelimit函数将返回零。从而避免发送重复消息。printk_ratelimit函数返回非零值表示我们可以继续打印,否则就应该跳过。

修改/proc/sys/kernel/printk_ratelimit(在重新打开消息之前应该等待的秒数);/proc/sys/kernel/printk_ratelimit_burst(在进行速度限制之前可以接受的消息数)

if (printk_ratelimit()) {
printk(KERN_NOTICE "the printer is still on fire\n");
}

打印设备号

有时侯,当从一个驱动程序中打印信息时,你想打印与硬件结合的设备号以引起注意。打印主次设备号并不是非常难,但是,为了一致性,内核提供了一对工具宏(在<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字节长。

通过查询调试

由于rsyslogd一直保持对输出文件的同步刷新,即使我们通过console_loglevel控制打印到控制台的信息,但是大量使用printk仍然会显著降低系统性能。多数情况下,获取相关信息的最好方法是在需要的时候采取查询系统信息,而不是持续不断地产生数据。

使用/proc文件系统

/proc文件系统是一种特殊的、由软件创建的文件系统,内核使用它向外界导出信息。/proc下面的每个文件都绑定了一个内核函数,用户读取其中的文件时,该函数动态地生成文件的“内容”。在Linux系统中对/proc的使用很频繁,很多系统工具都市通过/proc来获取它们需要的信息,例如ps、top和uptime。/proc文件大多是只读文件,不过也可以写入数据。最初的用途是用于提供系统中进程的信息,所以不鼓励在/proc目录下添加过多文件,建议放到/sys目录。

在老版本内核中, 是通过实现read_proc_t 回调函数,再通过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);
void remove_dir_entry(const char *name, struct proc_dir_entry *parent);

seq_file接口

由于procfs的默认操作函数只使用一页的缓存,在处理较大的proc文件时就有点麻烦,并且在输出一系列结构体中的数据时也比较不灵活,需要自己在read_proc函数中实现迭代,容易出现Bug。所以内核黑客们对一些/proc代码做了研究,抽象出共性,最终形成了seq_file(Sequence file:序列文件)接口。 这个接口提供了一套简单的函数来解决以上proc接口编程时存在的问题,使得编程更加容易,降低了Bug出现的机会。

seq相关头文件linux/seq_file.h,seq相关函数的实现在fs/seq_file.c。seq函数最早是在2001年就引入了,但以前内核中一直用得不多,而到了2.6内核后,许多/proc的只读文件中大量使用了seq函数处理。

seq_file相关学习: http://blog.chinaunix.net/uid-28253945-id-3382865.html

使用观察来调试

有时小问题可以通过观察用户态应用程序的行为来跟踪。可用调试器单步执行,增加打印语句,或者使用调试工具strace来监视系统调用。

strace是一个有力的工具,常用来跟踪用户空间进程执行时的系统调用和所接收的信号。 在Linux世界,进程不能直接访问硬件设备,当进程需要访问硬件设备(比如读取磁盘文件,接收网络数据等等)时,必须由用户态模式切换至内核态模式,通过系统调用访问硬件设备。strace可以跟踪到一个进程产生的系统调用,包括参数,返回值,执行消耗的时间。

调试系统故障

即使使用了所有的监视和调试技术,有时bugs仍然存在于你的驱动程序中,并在驱动程序被执行时引发系统错误。如果这种情况发生,收集尽可能多的信息用于解决问题将非常重要。注意“故障(fault)”并不意味这“惊恐(panic)”。Linux代码非常健壮,可以很好的响应大部分错误:一个错误通常导致当前进程被破坏,系统继续运行。如果错误发生在进程上下文之外或系统中关键部分被损坏时,系统就会panic。唯一不可恢复的就是分配给进程上下文的内存;举例来说,驱动程序通过kmalloc分配的动态链表可能丢失。然而,内核在进程终止时会对已打开的设备调用close操作,驱动程序仍可以释放由open方法分配的资源。

尽管oops消息通常不会导致整个系统崩溃,但我们发现遇到此类情况时还是要重新引导系统。一个有bug的驱动程序可能导致硬件不可用,或者导致内核资源处于不一致的状态,或者更糟的情况是在任意位置破坏内核内存。通常情况下,可以卸载有bug的驱动程序并在oops发生后重试。但是如果看到系统整体出现问题的消息后最好的方法就是立即重新引导系统。

oops消息

大部分错误都是因为对NULL指针取值或因为使用了其他不正确的指针值,这些错误通常会导致一个oops消息。由处理器使用的地址几乎都是虚拟地址,这些地址通过MMU映射为物理地址。当引用一个非法指针时,分页机制无法将该页映射到物理地址,此时处理器就会向操作系统发出页面缺失的信号。如果地址非法,内核就无法换入页面(page in),如果这时处理器处于特权模式,内核就会产生一个oops。

oops显示发生错误时处理器的状态。比如CPU寄存器的内容以及其他看上去无法理解的信息。这些消息由失效处理函数中的printk语句产生(arch/*/kernel/traps.c),如果必要可深入研究一下traps文件。

让我们看看系统访问了一个NULL指针时显示oops消息的例子。通常,在我们面对一条oops时,首先要观察的是发生的问题所在的位置,这通常可通过调用栈信息得到。“EIP is at faulty_write+0x4/0x10 [faulty]”表明故障所在函数是faulty_write,该函数位于faulty模块列在括号内,十六进制的数据表明指令指针在该函数的4字节处,而函数本身是0x10字节长。通常这些信息足以让我们看到问题所在。调用栈stack可以告诉我们系统是如何到达故障点的,如果内核打开了CONFIG_KALLSYMS选项,就能看到符号化的调用栈而不是裸的16进制清单。

Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip:
d083a064
Oops: 0002 [#1]
SMP
CPU: 0
EIP: 0060:[] Not tainted
EFLAGS: 00010246 (2.6.6)
EIP is at faulty_write+0x4/0x10 [faulty]
eax: 00000000 ebx: 00000000 ecx: 00000000 edx: 00000000
esi: cf8b2460 edi: cf8b2480 ebp: 00000005 esp: c31c5f74
ds: 007b es: 007b ss: 0068
Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0)
Stack: c0150558 cf8b2460 080e9408 00000005
cf8b2480 00000000 cf8b2460 cf8b2460
fffffff7 080e9408 c31c4000 c0150682 cf8b2460
080e9408 00000005 cf8b2480
00000000 00000001 00000005 c0103f8f 00000001
080e9408 00000005 00000005
Call Trace:
[] vfs_write+0xb8/0x130
[] sys_write+0x42/0x70
[] syscall_call+0x7/0xb
Code: 89 15 00 00 00 00 c3 90 8d 74 26 00 83 ec
0c b8 00 a6 83 d0

系统挂起后sysrq功能

尽管内核代码中的大多数错误只会导致一个oops信息,但有时它们会把系统完全挂起,导致任务消息无法打印出来。有时系统看似挂起了,但其实并没有,时钟或系统负载依然在更新。这时一个不可或缺的工具是“sysrq魔法键”,通过PC键盘上的ALT和sysrq组合键来激活系统。根据第三个键有不同的功能。其他一些sysrq功能可参考内核源码Documentation/sysrq.txt。注意sysrq功能必须显式的在内核配置中启用,处于安全原因,大多数发行版本并未开启这一功能。不过可通过写文件节点来手动打开,“echo 1 > /proc/sys/kernel/sysrq”。还对无法访问控制台的系统管理员开发了/proc/sysrq-trigger,写入对应的字符,则触发相应的sysrq动作,这个sysrq入口点始终可用。

b 理解重启系统。注意先要执行同步并重新挂装磁盘

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

t 打印当前的任务列表

m 打印内存信息。

调试器相关工具

最后一种调试模块的方法就是使用一个调试器来单步执行代码,监视变量和寄存器的值。这种方法非常耗时,应该尽量避免,不过。在某些情况下通过调试器对代码进行细粒度的分析是很有价值的。在内核中使用交互式调试器是一个很复杂的问题,出于对系统所有进程的整体利益的考虑,内核在它自己的地址空间中运行。其结果就是许多用户空间的调试器所提供的常用功能很难用于内核之中,例如断点和单步执行,在内核中很难获得。本节我们讨论许多调试内核的方法;它们各有优缺点。

使用gdb

gdb在探究系统内部行为非常有用。在这个层次上进行调试需要具备以下基本素养:

  1. 熟练使用调试器,掌握gdb命令
  2. 了解目标平台的汇编代码
  3. 具备对源代码和优化后的汇编代码进行匹配的能力

启动调试器时必须把内核看作一个应用程序。除了指定未压缩的内核映像文件名以外,还应在命令中提供core文件。对于正在运行的内核,所谓的core文件就是这个内核在内存中的核心映像,即/proc/kcore。典型的gdb命令如下gdb /usr/src/linux/vmlinux /proc/kcore。第一个参数是未经压缩的内核ELF可执行文件,而不是zImage或bzImage以及其他特殊的内核映像。第二个参数是core文件。与其他/proc中的文件类似,/proc/kcore也是在被读取时产生的。由于它要表示对应与所有物理内存的整个内核地址空间,所以是一个非常巨大的文件。

对内核调试时,gdb很多功能都不可用,例如不能修改内核数据,不能设置断点或者观察点,也不能单步踪内核。其原因是内核不信任交互式的调试器,担心调试器有不良修改导致系统异常。只能简单的查看信息。而且必须打开CONFIG_DEBUG_INFO选项编译的内核才能看到变量。在调试信息可用的情况下,我们可了解到许多内核内部的工作情况。但是困难在于处理模块,因为模块不是传递给gdb的vmlinux映像的一部分,因此调试器不知道模块的存在。可以通过一条gdb命令告诉调试器有关模块的信息,这条命令就是add-symbol-file,该命令指定目标文件的名称,代码段基地址、数据段基地址以及其他参数。

kdb内核调试器

kdb是一个内核调试器,但是是以非正式的补丁形式提供,要使用kdb必须先获得补丁,然后对内核源代码进行patch操作,再重新编译并安装内核。其用法是一旦支持kdb就可以在命令行进入kdb调试模式,从而支持查看修改变量、设置断点、单步调试等功能。目前kdb被收购不再开源,对我们来说用处不大,不对其做过多介绍。

posted @ 2021-05-16 11:27  zephyr~  阅读(3093)  评论(0编辑  收藏  举报