本期测评主要讲解一下内核常用的调试方法,因为对于内核和驱动开发的程序员,死锁检测和内存检测是不可避免的,所以本期测评将会重点讲讲本书提到的内核调试的方法。
1.什么是死锁
死锁(deadlock)是指两个或多个进程因争夺资源而造成的互相等待的现象,如进程A需要资源X,进程B需要资源Y,而双方都掌握对方所需要的资源,且都不释放,这就会导致死锁。在内核开发中,时常要考虑并发设计,即使采用正确的编程思路,也不可避免的会发生死锁。在Linux内核中,常见的死锁有如下两种:
- 递归死锁:如在中断等延迟操作中使用了锁,和外面的锁构成了递归死锁。
- AB-BA死锁;多个锁因处理不当而引发死锁,多个内核路径上的锁处理顺序不一致也会导致死锁。
下面是一个死锁的示例程序,函数在已经获取锁的情况下再次尝试获取锁,造成了死锁。
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/kthread.h>
#include <linux/freezer.h>
#include <linux/delay.h>
static DEFINE_SPINLOCK(hack_spinA);
static struct page *page;
static struct task_struct *lock_thread;
static int nest_lock(void)
{
int order = 5;
spin_lock(&hack_spinA);
page = alloc_pages(GFP_KERNEL, order);
if (!page) {
printk("cannot alloc pages\n");
return -ENOMEM;
}
spin_lock(&hack_spinA);
msleep(10);
__free_pages(page, order);
spin_unlock(&hack_spinA);
spin_unlock(&hack_spinA);
return 0;
}
static int lockdep_thread(void *nothing)
{
set_freezable();
set_user_nice(current, 0);
while (!kthread_should_stop()) {
msleep(10);
nest_lock();
}
}
static int __init my_init(void)
{
lock_thread = kthread_run(lockdep_thread, NULL, "lockdep_test");
if (IS_ERR(lock_thread)) {
printk("create kthread fail\n");
return PTR_ERR(lock_thread);
}
return 0;
}
static void __exit my_exit(void)
{
kthread_stop(lock_thread);
}
MODULE_LICENSE("GPL");
module_init(my_init);
module_exit(my_exit);
2.内核调试方法
(1)printk
我以前在做简单的驱动开发的时候,最常用调试工具就是printk,因为我觉得这种方式最简单直接。本书也详细提到了这种调试方法。printk()函数和c语言体统的printf()函数类似,其中他们的一个最重要的区别就是printk()函数提供输出等级,内核可以根据这个等级来判断是否在终端或者串口中输出。下面是printk()函数的输出等级:
Linux内核为printk定义了8个输出等级,KERN EMERG等级最高,KERN DEBUG等级最低。在配置内核时,由一个宏来设置系统默认的输出等级CONFIG MESSAGE LOGLEVEL_Linux内核为printk定义了8个输出等级,KERN EMERG等级最高,KERN DEBUG等级最低。在配置内核时,由一个宏来设置系纱默认的输出等级CONFIG MESSAGE LOGLEVEL_DEFAULT,通常该值设置为4,因此只有输出等级高于4时才会输出到终端或者串口,即只有KERN_EMERG~KERN ERR满足这个条件。通常在产品开发阶段,会把系统默认等级设置为最低,以便在开发测试阶段可以暴更多的问题和调试信息,在发布产品时再把输出等级设置为0或者4。
(2)动态输出
动态输出(dynamic print)是内核子系统开发者最喜欢的输出技术之一。在运行系统时,动态输出可以由系统维护者动态选择打开哪些内核子系统的输出,可以有选择性地打开某些模块的输出,而printk是全局的,只能设置输出等级。要使用动态输出,必须在配置内核时打开CONFIG_DYNAMIC_DEBUG宏。内核代码里使用大量 pr_debug()/dev_dbg()函数来输出信息,这些就使用了动态输出技术。另外,还需要系统挂载debugfs 文件系统。动态输出在debugfs文件系统中有一个control文件节点,这个文件节点记录了系统中所有使用动态输出技术的文件名路径、输出所在的行号、模块名字和要输出的语句。可以使用下面的命令查看。
cat /sys/kernel/debug/dynamic_debug/control
下面几个例子讲解了如何使用动态输出技术:
(3)oops分析
在编写驱动或内核模块时,常常会显式或隐式地对指针进行非法取值或使用不正确的指针,导致内核发生一个oops错误。当处理器在内核空间中访问一个非法的指针时,因为虚拟地址到物理地址的映射关系没有建立,会触发一个缺页中断,在缺页中断中该地址是非法的,内核无法正确地为该地址建立映射关系,所以内核触会发一个 oops错误。
下面举一个例子来验证如何分析一个内核oops错误
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
static void create_oops(void)
{
* (int * )0 = 0; //人为编造一个空指针访问
}
static int __init my_oops_init(void)
{
printk("oops module init\n");
create_oops();
return 0;
}
static void __exit my_oops_exit(void)
{
printk("goodbye\n");
}
module_init(my_oops_init);
module_exit(my_oops_exit);
MODULE_LICENSE("GPL");
将上面编译成内核模块并用insmod命令加载模块,将会出现以下错误
PC 指针指向出错的地址,另外“Call trace”也展示了出错时程序的调用关系。首先观察出错信息 create_oops+0x14/0x24,其中,0x14 表示指令指针在 create_oops()函数的第 0x14 字节处,create_oops()函数本身共 0x24 字节。继续分析这个问题,假设两种情况:一是有出错模块的源代码,二是没有源代码。在某些实际工作场景中,可能需要调试和分析没有源代码的 oops 错误。
先看有源代码的情况,通常在编译时添加到符号信息表中。在 Makefile 中添加如下语句,并重新编译内核模块。
KBUILD_CFLAGS +=-g
下面用两种方法来分析。首先,使用 objdump 工具反汇编。
aarch64-linux-gnu-objdump -Sd oops.o
通过反汇编工具 objdump 可以看到出错函数 create_oops()的汇编情况,第 0x10~0x14 字节的指令用于把 0 赋值给 x0 寄存器,然后往 x0 寄存器里写入 0。wzr 是一种特殊寄存器,值为 0,所以这里发生了写空指针错误。然后,使用 gdb 工具。为了快捷地定位到出错的具体位置,使用 gdb 中的“list”指令加上出错函数和偏移量即可。
aarch64-linux-gnu-gdb oops.o
下面来看没有源代码的情况。对于没有编译符号表的二进制文件,可以使用objdump 工具来转储汇编代码,例如使用“aarch64-linux-gnu-objdump -d oops.o”命令来转储 oops.o 文件。内核提供了一个非常好用的脚本,可以快速定位问题,该脚本位于 Linux 内核源代码目录的 scripts/decodecode 文件夹中。我们把出错日志保存到一个.txt 文件中。
export ARCH=arm64
export CROSS_COMPILE=aarch64-linux-gnu-
./scripts/decodecode < oops.txt
decodecode 脚本会把出错的 oops 日志信息转换成直观有用的汇编代码,并且告知具体是哪个汇编语句出错了,这对于分析没有源代码的 oops 错误非常有用。
总结:本期测评主要介绍了死锁出现的情况以及一些常见的内核调试方法,并就oops错误详细的介绍了oops分析内核错误的步骤。内核调试的方法如果只停留在阅读层面上是不能够完全掌握的,因此以后在写内核驱动的时候要多多实践这些方法,将他们真正转换为自己的技能。