在座的高级程序员们,搞Linux开发的肯定都被内核调试折磨过。代码跑着跑着突然就死机,或者功能莫名其妙出错,想找问题比登天还难;内核错误就像藏在黑暗里的幽灵,稍不留神就会让系统崩溃,还很难留下出错时的现场。
我自己就有过刻骨铭心的经历,之前负责一个重要的 Linux 服务器项目,上线没多久,用户反馈系统频繁卡顿甚至死机。当时我整个人都懵了,这可是面向大量用户的服务,每一秒的故障都可能造成巨大损失。我赶紧排查,可内核就像一个神秘的黑匣子,错误信息寥寥无几,根本无从下手。那几天我日夜颠倒,疯狂查阅资料、请教前辈,尝试各种方法,最终才艰难地解决了问题。那一刻我深刻意识到,掌握 Linux 内核调试技术是多么关键。
今天,我就把自己多年积累的干货和盘托出,涵盖 printk、kgdb、kprobes 等常用技术的原理与使用方法,还有实用工具和真实案例分析,让你在面对内核调试难题时不再迷茫。
在 Linux 操作系统的庞大体系中,内核就如同其 “心脏” 一般,占据着核心地位。它不仅负责管理计算机的硬件资源,像 CPU、内存、存储设备等,还为上层的应用程序提供了运行所需的基本服务和接口。从进程的创建与调度,到内存的分配与回收,再到文件系统的管理以及设备驱动的加载,Linux 内核都扮演着不可或缺的角色。毫不夸张地说,内核的稳定运行直接关系到整个 Linux 系统的可靠性和性能表现。
然而,如同任何复杂的软件系统一样,Linux 内核在开发和维护过程中也难免会出现各种问题。这些问题可能表现为系统崩溃、死机、性能下降等,严重影响用户的使用体验。例如,在一些服务器环境中,如果内核出现内存泄漏问题,随着时间的推移,系统的可用内存会逐渐减少,最终导致服务器响应变慢甚至无法正常工作,给企业带来巨大的经济损失。又比如,内核中的驱动程序与硬件设备不兼容,可能会导致设备无法正常使用,影响整个系统的功能完整性。
这时候,Linux 内核调试技术就显得尤为重要。通过有效的调试技术,开发者可以深入了解内核的运行状态,准确地定位问题的根源,进而采取针对性的措施进行修复。调试技术不仅有助于解决当前出现的问题,还能在开发过程中提前发现潜在的风险,优化内核的性能,提高系统的稳定性和可靠性。因此,掌握 Linux 内核调试技术,对于每一个从事 Linux 系统开发、维护和优化的人员来说,都是必备的技能。接下来,让我们一起深入了解 Linux 内核调试的基本原理与常用技术。
在调试一个bug之前,我们所要做的准备工作有:
有一个被确认的bug。
包含这个bug的内核版本号,需要分析出这个bug在哪一个版本被引入,这个对于解决问题有极大的帮助。可以采用二分查找法来逐步锁定bug引入版本号。
对内核代码理解越深刻越好,同时还需要一点点运气。
该bug可以复现。如果能够找到复现规律,那么离找到问题的原因就不远了。
最小化系统。把可能产生bug的因素逐一排除掉。
在开始调试 Linux 内核之前,首要任务是精准地确认 bug。这需要我们对系统出现的异常现象进行细致观察和分析。当系统出现崩溃时,要仔细查看内核崩溃时输出的错误信息,这些信息通常包含了关键的线索,如出错的函数名、代码行号以及相关的寄存器值等。通过这些信息,我们能够初步判断问题出在内核的哪个模块或功能区域。
分析问题出现的内核版本也是至关重要的。不同版本的内核在功能实现、代码结构以及对硬件的支持等方面都可能存在差异。一个在旧版本内核中出现的问题,在新版本中可能已经得到修复,或者由于代码的改动,问题的表现形式和原因也会有所不同。因此,明确问题出现的内核版本,有助于我们针对性地查阅相关的内核文档、补丁信息以及社区讨论,从而更快地找到问题的解决方案。
复现问题是内核调试过程中的关键环节。只有能够稳定地复现问题,我们才能进行有效的调试和分析。为了实现这一目标,我们需要进行多次测试,尝试不同的操作步骤和输入参数,以寻找问题复现的规律。
例如,在调试一个与文件系统相关的内核问题时,我们可以编写一系列的测试脚本,模拟不同的文件操作,如创建文件、删除文件、读写文件等,同时结合不同的文件大小、文件类型以及操作频率等因素,观察问题是否会复现。在测试过程中,要详细记录每次测试的环境、操作步骤以及出现的结果,通过对这些记录的分析,我们可能会发现问题复现的特定条件,如在同时进行大量文件读写操作时,系统会出现崩溃或数据丢失的情况。
深入理解内核代码是进行高效调试的关键。Linux 内核是一个庞大而复杂的系统,其代码量巨大,涉及到众多的功能模块和技术领域。在调试之前,我们需要对与问题相关的内核代码有一定的了解,包括代码的逻辑结构、函数的调用关系以及数据结构的定义和使用等。
以调试一个网络驱动相关的问题为例,我们需要熟悉网络驱动的工作原理,了解内核中网络协议栈的实现机制,以及该驱动与其他内核模块之间的交互方式。通过阅读相关的内核代码和文档,我们可以逐步理清代码的执行流程,从而更好地理解问题可能出现的位置和原因。此外,理解内核代码还有助于我们在调试过程中准确地解读调试信息,判断代码的执行是否符合预期。
在调试内核时,使用最小化系统可以大大简化调试过程,提高问题定位的效率。最小化系统是指在一个尽可能简单的系统环境中进行调试,只保留与问题相关的必要组件和服务,排除其他不必要的干扰因素。
比如,在调试一个特定的设备驱动时,我们可以搭建一个只包含该设备、基本的硬件支持以及最小化内核配置的测试系统。这样,当问题出现时,我们可以更加确定问题是出在该设备驱动或者与之直接相关的内核部分,而不是其他无关的系统组件。同时,最小化系统还可以减少系统资源的消耗,使调试过程更加流畅,提高调试的效率。
在 Linux 内核调试的工具库中,printk 函数堪称最为基础且常用的工具之一,它就像是一位忠实的 “日志记录员”,默默地记录着内核运行过程中的各种信息。printk 函数的主要作用是在 Linux 内核中打印调试信息,这些信息对于开发者了解内核的运行状态、排查问题起着至关重要的作用。它的使用方式与我们熟悉的标准 C 库中的 printf 函数极为相似,这使得熟悉 C 语言的开发者能够轻松上手。
printk(KERN_INFO "This is a debug message from the kernel.\n");
在上述示例中,我们使用 printk 函数打印了一条包含 KERN_INFO 日志级别的调试信息。这里的 KERN_INFO 是众多日志级别中的一种,它表示该信息是一般性的提示信息,有助于开发者了解内核的正常运行状态。
Linux 内核定义了多种不同的日志级别,这些级别就像是一个个不同的 “过滤器”,可以帮助开发者根据实际需求筛选出重要的信息。从紧急程度最高的 KERN_EMERG(表示系统面临崩溃,如硬件故障导致系统无法正常运行),到最低的 KERN_DEBUG(用于输出详细的调试信息,通常在开发和调试阶段使用),每个级别都有其特定的用途。例如,当内核中的某个驱动程序检测到硬件设备出现错误时,它可能会使用 KERN_ERR 级别来打印错误信息,以便开发者能够及时发现并解决问题。
printk(KERN_ERR "Hardware error detected in the disk driver.\n");
在系统启动的早期阶段,由于内核的初始化尚未完成,一些高级的调试工具可能无法正常使用,此时 printk 函数就成为了开发者获取系统信息的重要手段。通过在关键的代码位置插入 printk 语句,开发者可以了解系统启动过程中各个阶段的执行情况,判断是否存在异常。
然而,printk 函数在系统启动过程中也存在一些限制。由于系统启动时的资源有限,频繁地使用 printk 函数打印大量信息可能会导致系统性能下降,甚至影响系统的正常启动。此外,在某些情况下,printk 函数输出的信息可能会因为缓冲区溢出等问题而丢失。为了解决这些问题,开发者可以采用一些替代方法,比如在系统启动完成后,通过读取内核日志文件(如 /var/log/kern.log)来获取 printk 函数输出的信息,这样可以避免在启动过程中对系统性能造成过大的影响。
kgdb,全称为 Kernel GNU Debugger,是 Linux 内核调试领域的一把 “利器”,它为开发者提供了一种强大的内核调试手段。kgdb 的工作原理基于著名的 GDB(GNU Debugger)调试器,它通过在 Linux 内核中添加一个调试 stub,实现了主机上的 GDB 与目标内核之间的通信和调试功能。这个调试 stub 就像是一座桥梁,连接着主机和目标内核,使得开发者能够在主机上通过 GDB 对目标内核进行调试。
在实际的调试过程中,串口连接是一种常见的方式。通过串口线将主机和目标机(如开发板)连接起来,主机上运行的 GDB 可以通过串口与目标内核中的调试 stub 进行通信。当设置断点时,kgdb 会将断点处的指令替换为一条 trap 指令。当目标内核执行到断点时,控制权就会转移到调试 stub 中,调试 stub 会将当前内核的环境信息,如寄存器值、内存状态等,通过串口发送给主机上的 GDB。GDB 接收到这些信息后,开发者就可以进行各种调试操作,如查看变量值、单步执行代码等。
# 在主机上启动GDB,并通过串口连接到目标内核
gdb -tui /path/to/vmlinux
(gdb) target remote /dev/ttyS0
除了串口连接,kgdb 还支持通过网络连接进行调试,这在一些情况下更加方便和高效。通过网络连接,开发者可以在不同的地理位置对目标内核进行调试,无需受到物理距离的限制。在网络连接方式下,kgdb 使用 UDP 协议进行通信,通过配置目标机和主机的 IP 地址、端口号等参数,实现两者之间的通信。例如,在目标机的启动参数中添加 kgdboe=[target-port]@/[dev][target-macaddr],[host-port]@/[dev],指定网络连接的相关参数,然后在主机上使用 GDB 通过相应的网络配置连接到目标内核。
kprobes 是 Linux 内核提供的一种轻量级动态探测机制,它就像是一根根灵活的 “探针”,可以插入到内核的各个关键位置,帮助开发者获取调试信息。kprobes 的工作原理是通过在指定的内核函数或指令处插入探测点,当内核执行到这些探测点时,就会触发预先设置的回调函数,开发者可以在回调函数中收集各种调试信息,如函数的入参、返回值、执行时间等,而这一切几乎不会影响内核原有的执行流程。
当我们想要了解某个内核函数的执行情况时,可以使用 kprobes 在该函数的入口处插入探测点。kprobes 会备份被探测指令的原始内容,然后将其替换为一条断点指令(如在 x86 架构上通常使用 int3 指令)。当 CPU 执行到这个断点指令时,会触发异常,CPU 会将当前的寄存器状态保存起来,并将控制权转移到 kprobes 的处理程序中。kprobes 处理程序会首先执行预先注册的 pre_handler 回调函数,在这个函数中,开发者可以获取函数执行前的各种信息,如寄存器的值、函数的参数等。接着,kprobes 会单步执行被备份的原始指令,执行完成后,再执行 post_handler 回调函数,开发者可以在这个函数中获取函数执行后的结果和状态信息。最后,kprobes 会恢复被替换的原始指令,让内核继续正常执行。
#include
#include
// 定义pre_handler回调函数
static int pre_handler(struct kprobe *p, struct pt_regs *regs) {
printk(KERN_INFO "pre_handler: Entering function.\n");
return 0;
}
// 定义post_handler回调函数
static void post_handler(struct kprobe *p, struct pt_regs *regs, unsigned long flags) {
printk(KERN_INFO "post_handler: Exiting function.\n");
}
// 定义要探测的函数
static struct kprobe kp = {
.symbol_name = "sys_open",
.pre_handler = pre_handler,
.post_handler = post_handler,
};
static int __init kprobe_init(void) {
int ret;
// 注册kprobe
ret = register_kprobe(&kp);
if (ret < 0) {
printk(KERN_ERR "Failed to register kprobe\n");
return ret;
}
printk(KERN_INFO "kprobe registered successfully\n");
return 0;
}
static void __exit kprobe_exit(void) {
// 注销kprobe
unregister_kprobe(&kp);
printk(KERN_INFO "kprobe unregistered\n");
}
module_init(kprobe_init);
module_exit(kprobe_exit);
MODULE_LICENSE("GPL");
在上述示例中,我们定义了一个 kprobe,用于探测 sys_open 函数。通过注册 pre_handler 和 post_handler 回调函数,我们可以在 sys_open 函数执行前后获取相关的调试信息。
kprobes 在不同的硬件架构下都有广泛的支持,包括 x86、ARM、PowerPC 等常见架构。不过,由于不同架构的指令集和硬件特性存在差异,kprobes 在具体实现上会有所不同。例如,在 x86 架构上,使用 int3 指令作为断点指令;而在 ARM 架构上,则可能使用 BKPT(Breakpoint)指令。尽管存在这些差异,但 kprobes 的基本原理和使用方式在各个架构上是相似的,这使得开发者可以方便地在不同架构的系统中使用 kprobes 进行内核调试。
MEMWATCH 由 Johan Lindh 编写,是一个开放源代码 C 语言内存错误检测工具,您可以自己下载它。只要在代码中添加一个头文件并在 gcc 语句中定义了 MEMWATCH 之后,您就可以跟踪程序中的内存泄漏和错误了。MEMWATCH 支持ANSIC,它提供结果日志记录,能检测双重释放(double-free)、错误释放(erroneous free)、没有释放的内存(unfreedmemory)、溢出和下溢等等。
内存样本(test1.c)
#include
#include
#include "memwatch.h"
int main(void)
{
char *ptr1;
char *ptr2;
ptr1 = malloc(512);
ptr2 = malloc(512);
ptr2 = ptr1;
free(ptr2);
free(ptr1);
}
内存样本(test1.c)中的代码将分配两个 512 字节的内存块,然后指向第一个内存块的指针被设定为指向第二个内存块。结果,第二个内存块的地址丢失,从而产生了内存泄漏。
现在我们编译内存样本(test1.c) 的 memwatch.c。下面是一个 makefile 示例:
test1
gcc -DMEMWATCH -DMW_STDIO test1.c memwatchc -o test1
当您运行 test1 程序后,它会生成一个关于泄漏的内存的报告。下面展示了示例 memwatch.log 输出文件。
test1 memwatch.log 文件
MEMWATCH 2.67 Copyright (C) 1992-1999 Johan Lindh
...
double-free: <4> test1.c(15), 0x80517b4 was freed from test1.c(14)
...
unfreed: <2> test1.c(11), 512 bytes at 0x80519e4
{FE FE FE FE FE FE FE FE FE FE FE FE ..............}
Memory usage statistics (global):
N)umber of allocations made: 2
L)argest memory usage : 1024
T)otal of all alloc() calls: 1024
U)nfreed bytes totals : 512
MEMWATCH 为您显示真正导致问题的行。如果您释放一个已经释放过的指针,它会告诉您。对于没有释放的内存也一样。日志结尾部分显示统计信息,包括泄漏了多少内存,使用了多少内存,以及总共分配了多少内存。
YAMD 软件包由 Nate Eldredge 编写,可以查找 C 和 C++ 中动态的、与内存分配有关的问题。在撰写本文时,YAMD 的最新版本为 0.32。请下载 yamd-0.32.tar.gz。执行 make 命令来构建程序;然后执行 make install 命令安装程序并设置工具。
一旦您下载了 YAMD 之后,请在 test1.c 上使用它。请删除 #include memwatch.h 并对 makefile 进行如下小小的修改:
使用 YAMD 的 test1
gcc -g test1.c -o test1
展示了来自 test1 上的 YAMD 的输出,使用 YAMD 的 test1 输出
YAMD version 0.32
Executable: /usr/src/test/yamd-0.32/test1
...
INFO: Normal allocation of this block
Address 0x40025e00, size 512
...
INFO: Normal allocation of this block
Address 0x40028e00, size 512
...
INFO: Normal deallocation of this block
Address 0x40025e00, size 512
...
ERROR: Multiple freeing At
free of pointer already freed
Address 0x40025e00, size 512
...
WARNING: Memory leak
Address 0x40028e00, size 512
WARNING: Total memory leaks:
1 unfreed allocations totaling 512 bytes
*** Finished at Tue ... 10:07:15 2002
Allocated a grand total of 1024 bytes 2 allocations
Average of 512 bytes per allocation
Max bytes allocated at one time: 1024
24 K alloced internally / 12 K mapped now / 8 K max
Virtual program size is 1416 K
End.
YAMD 显示我们已经释放了内存,而且存在内存泄漏。让我们在另一个样本程序上试试 YAMD。
内存代码(test2.c)
#include
#include
int main(void)
{
char *ptr1;
char *ptr2;
char *chptr;
int i = 1;
ptr1 = malloc(512);
ptr2 = malloc(512);
chptr = (char *)malloc(512);
for (i; i <= 512; i++) {
chptr[i] = 'S';
}
ptr2 = ptr1;
free(ptr2);
free(ptr1);
free(chptr);
}
您可以使用下面的命令来启动 YAMD:
./run-yamd /usr/src/test/test2/test2
显示了在样本程序 test2 上使用 YAMD 得到的输出。YAMD 告诉我们在 for 循环中有“越界(out-of-bounds)”的情况,使用 YAMD 的 test2 输出
Running /usr/src/test/test2/test2
Temp output to /tmp/yamd-out.1243
*********
./run-yamd: line 101: 1248 Segmentation fault (core dumped)
YAMD version 0.32
Starting run: /usr/src/test/test2/test2
Executable: /usr/src/test/test2/test2
Virtual program size is 1380 K
...
INFO: Normal allocation of this block
Address 0x40025e00, size 512
...
INFO: Normal allocation of this block
Address 0x40028e00, size 512
...
INFO: Normal allocation of this block
Address 0x4002be00, size 512
ERROR: Crash
...
Tried to write address 0x4002c000
Seems to be part of this block:
Address 0x4002be00, size 512
...
Address in question is at offset 512 (out of bounds)
Will dump core after checking heap.
Done.
MEMWATCH 和 YAMD 都是很有用的调试工具,它们的使用方法有所不同。对于 MEMWATCH,您需要添加包含文件memwatch.h 并打开两个编译时间标记。对于链接(link)语句,YAMD 只需要 -g 选项。
多数 Linux 分发版包含一个 Electric Fence 包,不过您也可以选择下载它。Electric Fence 是一个由 Bruce Perens 编写的malloc()调试库。它就在您分配内存后分配受保护的内存。如果存在 fencepost 错误(超过数组末尾运行),程序就会产生保护错误,并立即结束。通过结合 Electric Fence 和 gdb,您可以精确地跟踪到哪一行试图访问受保护内存。ElectricFence 的另一个功能就是能够检测内存泄漏。
strace 是一款在 Linux 系统中广泛应用的强大调试工具,它犹如一位敏锐的 “监视器”,能够实时跟踪用户程序发起的系统调用,以及程序所接收的信号,为开发者深入了解程序与内核之间的交互过程提供了极大的便利。
在 Linux 系统中,用户程序无法直接访问硬件设备,当程序需要进行诸如读取磁盘文件、接收网络数据等操作时,必须从用户态切换至内核态,通过系统调用这一桥梁来实现对硬件设备的访问。strace 正是利用了这一机制,通过内核的 ptrace 特性,它能够捕捉到进程产生的每一个系统调用,详细记录下系统调用的参数、返回值以及执行所消耗的时间。
strace ls -l
当我们执行上述命令时,strace 会跟踪 ls -l 命令执行过程中所产生的所有系统调用,并将这些信息输出到终端。例如,我们可能会看到如下输出:
execve("/usr/bin/ls", ["ls", "-l"], [/* 40 vars */]) = 0
brk(NULL) = 0x1f12000
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f29379a7000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
...
每一行输出都代表一个系统调用,等号左边是系统调用的函数名及其参数,右边则是该调用的返回值。通过分析这些输出,我们可以清晰地了解 ls -l 命令在执行过程中是如何与内核进行交互的,比如它打开了哪些文件、读取了哪些目录信息等。
strace 还提供了丰富的参数选项,以满足不同的调试需求。使用-c参数可以统计每个系统调用的执行时间、次数和出错次数等信息,帮助我们快速定位性能瓶颈。
strace -c ls -l
使用-t参数会在输出的每一行前加上时间信息,精确到秒,这对于分析程序执行的时间顺序非常有帮助;而-tt参数则将时间精度提升到微秒级,提供更详细的时间记录。-e参数则允许我们指定要跟踪的系统调用,例如,只跟踪 open 和 close 系统调用,可以使用以下命令:
strace -e trace=open,close ls -l
当 Linux 内核遭遇错误时,会输出一系列详细的信息,我们通常将其称为 OOPS 信息。这些信息就像是一台高倍 “显微镜”,为我们深入剖析内核错误的根源提供了关键线索。
当内核出现诸如空指针引用、内存访问越界等严重错误时,会触发 OOPS。此时,内核会在控制台或者 dmesg 日志中输出详细的错误信息,其中包含了错误发生的位置、相关寄存器的值、函数调用栈等关键内容。
[ 515753.310000] kernel BUG at net/core/skbuff.c:1846!
[ 515753.310000] Unable to handle kernel NULL pointer dereference at virtual address 00000000
[ 515753.320000] pgd = c0004000
[ 515753.320000][00000000] *pgd=00000000
[ 515753.330000] Internal error: Oops: 817 [#1] PREEMPT SMP
[ 515753.330000] last sysfs file: /sys/class/net/eth0.2/speed
[ 515753.330000] module: http_timeout bf0980004142...
[ 515753.330000] CPU: 0 Tainted: P (2.6.36 #2)
[ 515753.330000] PC is at __bug+0x20/0x28
[ 515753.330000] LR is at __bug+0x1c/0x28
[ 515753.330000] pc : [c01472d0] lr : [c01472cc] psr: 60000113
[ 515753.330000] sp : c0593e20 ip : c0593d70 fp : cf1b5ba0
[ 515753.330000] r10: 00000014 r9 : 4adec78d r8 : 00000006
[ 515753.330000] r7 : 00000000 r6 : 0000003a r5 : 0000003a r4 : 00000060
[ 515753.330000] r3 : 00000000 r2 : 00000204 r1 : 00000001 r0 : 0000003c
[ 515753.330000] Flags: nZCv IRQs on FIQs on Mode SVC_32 ISA ARM Segment kernel
[ 515753.330000] Control: 10c53c7d Table: 4fb5004a DAC: 00000017
[ 515753.330000] Process swapper (pid: 0, stack limit = 0xc0592270)
在这段 OOPS 信息中,kernel BUG at net/core/skbuff.c:1846!明确指出了错误发生在 net/core/skbuff.c 文件的 1846 行,这为我们定位问题代码提供了重要的线索。PC is at __bug+0x20/0x28和LR is at __bug+0x1c/0x28则给出了程序计数器(PC)和链接寄存器(LR)的值,通过这些值,我们可以进一步分析函数的调用关系和执行流程。
为了更准确地解析这些信息,我们可以借助 ksymoops 和 kallsyms 等工具。ksymoops 工具能够将内核地址转换为对应的符号名称,帮助我们更直观地理解错误信息。而 kallsyms 则是内核自带的符号表,通过它,我们可以获取内核中函数和变量的地址信息,从而在分析 OOPS 信息时,能够将地址与具体的代码位置对应起来。
(1)ksymoops
在 Linux 中,调试系统崩溃的传统方法是分析在发生崩溃时发送到系统控制台的 Oops 消息。一旦您掌握了细节,就可以将消息发送到 ksymoops 实用程序,它将试图将代码转换为指令并将堆栈值映射到内核符号。
※ 如:回溯线索中的地址,会通过ksymoops转化成名称可见的函数名。
ksymoops需要几项内容:Oops 消息输出、来自正在运行的内核的 System.map 文件,还有 /proc/ksyms、vmlinux和/proc/modules。
关于如何使用 ksymoops,内核源代码 /usr/src/linux/Documentation/oops-tracing.txt 中或 ksymoops 手册页上有完整的说明可以参考。Ksymoops 反汇编代码部分,指出发生错误的指令,并显示一个跟踪部分表明代码如何被调用。
首先,将 Oops 消息保存在一个文件中以便通过 ksymoops 实用程序运行它。下面显示了由安装 JFS 文件系统的 mount命令创建的 Oops 消息。
ksymoops 处理后的 Oops 消息:
ksymoops 2.4.0 on i686 2.4.17. Options used
... 15:59:37 sfb1 kernel: Unable to handle kernel NULL pointer dereference at
virtual address 0000000
... 15:59:37 sfb1 kernel: c01588fc
... 15:59:37 sfb1 kernel: *pde = 0000000
... 15:59:37 sfb1 kernel: Oops: 0000
... 15:59:37 sfb1 kernel: CPU: 0
... 15:59:37 sfb1 kernel: EIP: 0010:[jfs_mount+60/704]
... 15:59:37 sfb1 kernel: Call Trace: [jfs_read_super+287/688]
[get_sb_bdev+563/736] [do_kern_mount+189/336] [do_add_mount+35/208]
[do_page_fault+0/1264]
... 15:59:37 sfb1 kernel: Call Trace: []...
... 15:59:37 sfb1 kernel: [>EIP; c01588fc <=====
...
Trace; c0106cf3
Code; c01588fc
00000000 <_EIP>:
Code; c01588fc <=====
0: 8b 2d 00 00 00 00 mov 0x0,%ebp <=====
Code; c0158902
6: 55 push %ebp
接下来,您要确定 jfs_mount 中的哪一行代码引起了这个问题。Oops 消息告诉我们问题是由位于偏移地址 3c 的指令引起的。做这件事的办法之一是对 jfs_mount.o 文件使用 objdump 实用程序,然后查看偏移地址 3c。Objdump 用来反汇编模块函数,看看您的 C 源代码会产生什么汇编指令。下面的代码显示了使用 objdump 后您将看到的内容,接着,我们查看jfs_mount 的 C 代码,可以看到空值是第 109 行引起的。偏移地址 3c 之所以很重要,是因为 Oops 消息将该处标识为引起问题的位置。
jfs_mount 的汇编程序清单:
109 printk("%d\n",*ptr);
objdump jfs_mount.o
jfs_mount.o: file format elf32-i386
Disassembly of section .text:
00000000 :
0:55 push %ebp
...
2c: e8 cf 03 00 00 call 400
31: 89 c3 mov %eax,%ebx
33: 58 pop %eax
34: 85 db test %ebx,%ebx
36: 0f 85 55 02 00 00 jne 291
3c: 8b 2d 00 00 00 00 mov 0x0,%ebp << problem line above
42: 55 push %ebp
(2)kallsyms
开发版2.5内核引入了kallsyms特性,它可以通过定义CONFIG_KALLSYMS编译选项启用。该选项可以载入内核镜像所对应的内存地址的符号名称(即函数名),所以内核可以打印解码之后的跟踪线索。相应,解码OOPS也不再需要System.map和ksymoops工具了。另外, 这样做,会使内核变大些,因为地址对应符号名称必须始终驻留在内核所在内存上。
#cat /proc/kallsyms
c0100240 T _stext
c0100240 t run_init_process
c0100240 T stext
c0100269 t init
…
KDB,即 Kernel Debugger,是 Linux 内核提供的一个强大的交互式调试环境,它就像是一个专门为内核调试打造的 “控制台”,为开发者提供了丰富的调试功能。
KDB 允许开发者在内核运行时,通过命令行界面执行各种调试操作,如设置断点、单步执行代码、查看和修改变量以及寄存器的值等。这使得开发者能够深入内核内部,实时监控和分析内核的运行状态,快速定位和解决问题。
在使用 KDB 之前,需要确保内核配置中启用了相关的选项,如CONFIG_DEBUG_INFO、CONFIG_FRAME_POINTER、CONFIG_MAGIC_SYSRQ、CONFIG_MAGIC_SYSRQ_SERIAL、CONFIG_KGDB_SERIAL_CONSOLE、CONFIG_KGDB_KDB和CONFIG_KGDB等。这些选项的启用,为 KDB 的正常工作提供了必要的支持。
当内核启动后,我们可以通过 sysrq 命令进入 KDB 调试环境。例如,执行echo g > /proc/sysrq-trigger命令,即可进入 KDB。此时,我们会看到 KDB 的命令提示符,如[3]kdb>,表示我们已经成功进入 KDB 调试环境,可以开始执行各种调试命令。
KDB 提供了众多实用的命令,常用的命令包括:
内存操作命令:md命令用于显示内存内容,md 0xffff0000可以查看指定地址 0xffff0000 处的内存内容;mm命令则用于修改内存地址的内容,mm 0xffff0000 0x1234可以将地址 0xffff0000 处的内存内容修改为 0x1234。
寄存器操作命令:rd命令用于显示 CPU 寄存器的内容,通过它可以查看当前 CPU 寄存器的值;rm命令用于修改 CPU 寄存器的内容,开发者可以根据调试需求对寄存器的值进行调整。
断点操作命令:通过设置断点,开发者可以让内核在执行到特定代码位置时暂停,以便进行详细的调试分析。例如,使用bp命令可以设置断点,bp function_name可以在名为 function_name 的函数处设置断点。
堆栈操作命令:bt命令用于显示堆栈回溯信息,通过它可以查看函数的调用栈,了解程序的执行流程和函数调用关系,这对于分析错误发生的原因非常有帮助。
在一个基于 Linux 系统的服务器环境中,近期频繁出现内核崩溃的情况。系统运行一段时间后,会突然停止响应,屏幕上出现大量的错误信息,随后系统自动重启。经过仔细观察,发现内核崩溃通常发生在系统负载较高,同时进行大量文件读写操作和网络通信的情况下。例如,当多个用户同时上传和下载大文件时,系统很容易触发内核崩溃。
从内核崩溃时输出的信息中,我们获取到关键线索:出现了 “Oops” 错误提示,其中提到了与内存访问相关的问题,如 “Unable to handle kernel paging request at virtual address”,这表明可能存在内存访问越界或内存管理异常的情况。此外,还发现一些与网络驱动和文件系统相关的函数调用栈信息,这进一步暗示问题可能与这两个模块在高负载下的协同工作有关。
①收集内核转储信息:为了获取更详细的内核崩溃信息,我们首先安装并启用了 kdump 工具。这个工具可以在系统崩溃时,将当前内核的内存状态保存到磁盘上,生成一个内核转储文件(vmcore)。
通过执行以下命令完成 kdump 的安装和启用:
sudo yum install kexec-tools
sudo systemctl enable kdump.service
sudo systemctl start kdump.service
在一次内核崩溃后,我们成功在/var/crash目录下找到了生成的 vmcore 文件。
②分析内核转储信息:使用 crash 工具加载 vmcore 文件和对应的内核符号文件(vmlinux),开始对内核崩溃的原因进行深入分析。
sudo crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/[vmcore文件名]
在 crash 的交互式环境中,我们首先使用bt命令查看了崩溃时的调用栈信息。从调用栈中,我们发现了一些异常的函数调用顺序,特别是在文件系统和网络驱动的交互部分。例如,在处理网络数据接收时,文件系统的某些数据结构被意外修改,这可能是导致内存访问错误的根源。
③利用 printk 辅助调试:为了进一步确定问题的具体位置,我们在内核代码中与文件系统和网络驱动相关的关键函数位置添加了 printk 语句,打印相关变量的值和函数执行的关键步骤。重新编译内核并部署到测试环境中,在模拟高负载的情况下进行测试。通过查看内核日志,我们发现当网络数据接收量达到一定阈值时,文件系统中用于缓存数据的内存区域出现了越界访问的情况,这与之前分析的调用栈信息相吻合。
④使用 kprobes 动态探测:为了更精确地观察问题发生时的内核执行情况,我们使用 kprobes 在文件系统和网络驱动中相关的函数入口和关键操作点插入了探测点。通过设置 pre_handler 和 post_handler 回调函数,我们可以在函数执行前后获取详细的参数和执行状态信息。经过测试,我们发现当网络驱动在高负载下快速接收数据时,向文件系统传递数据的过程中,由于数据量过大,导致文件系统的内存分配和管理出现了混乱,从而引发了内存访问错误,最终导致内核崩溃。
针对上述分析得出的问题原因,我们采取了以下解决方案:
①优化网络驱动的数据处理逻辑:在网络驱动中,增加了对接收数据量的动态监控机制。当接收到的数据量超过一定阈值时,不再立即将数据传递给文件系统,而是先进行缓存和队列处理,确保数据能够稳定、有序地传递给文件系统,避免因数据量过大导致文件系统处理不过来。
// 新增的接收数据缓存队列
struct sk_buff *rx_queue[QUEUE_SIZE];
int queue_head = 0;
int queue_tail = 0;
// 网络驱动接收数据函数
void network_driver_rx(struct sk_buff *skb) {
if (queue_full()) {
// 队列已满,丢弃数据或进行其他处理
kfree_skb(skb);
return;
}
// 将接收到的数据skb添加到队列中
rx_queue[queue_tail] = skb;
queue_tail = (queue_tail + 1) % QUEUE_SIZE;
// 启动数据处理线程或触发文件系统处理
wake_up_process(fs_process);
}
// 文件系统处理数据函数
void file_system_process_data() {
while (!queue_empty()) {
struct sk_buff *skb = rx_queue[queue_head];
queue_head = (queue_head + 1) % QUEUE_SIZE;
// 处理skb中的数据
//...
kfree_skb(skb);
}
}
②调整文件系统的内存管理策略:在文件系统中,优化了内存分配算法,确保在高负载情况下能够合理分配内存,避免内存碎片化和越界访问。同时,增加了对内存使用情况的检查和修复机制,当发现内存异常时,能够及时进行调整和恢复。
// 文件系统内存分配函数
void *fs_alloc_memory(size_t size) {
void *ptr = kmalloc(size, GFP_KERNEL);
if (!ptr) {
// 内存分配失败,尝试进行内存回收或其他处理
reclaim_memory();
ptr = kmalloc(size, GFP_KERNEL);
if (!ptr) {
// 再次分配失败,返回错误
return NULL;
}
}
// 记录内存分配信息,用于后续检查和管理
record_memory_allocation(ptr, size);
return ptr;
}
// 文件系统内存释放函数
void fs_free_memory(void *ptr) {
// 检查内存释放的合法性
if (!is_valid_memory(ptr)) {
// 非法释放,进行错误处理
return;
}
kfree(ptr);
// 更新内存管理信息
update_memory_management_info(ptr);
}
③验证效果:在实施上述解决方案后,我们在测试环境中进行了长时间的高负载压力测试。经过多次测试,系统不再出现内核崩溃的情况,文件读写操作和网络通信都能够稳定、高效地运行。通过查看内核日志和系统性能监控指标,发现内存访问错误的情况不再出现,系统的稳定性和可靠性得到了显著提升。这表明我们的解决方案有效地解决了内核崩溃的问题。
Linux 内核调试技术是保障 Linux 系统稳定运行、提升系统性能的关键所在。从基础的 printk 函数,到功能强大的 kgdb、kprobes,再到实用的调试工具 strace、OOPS 分析以及 KDB,每一种技术和工具都在不同的场景下发挥着重要作用,它们共同构成了一个完整的内核调试体系。
在实际的开发和维护工作中,我们需要根据具体的问题和场景,灵活运用这些调试技术和工具。有时候,一个简单的 printk 语句就能帮助我们快速定位问题;而在面对复杂的内核崩溃问题时,则需要综合运用多种调试手段,如收集内核转储信息、利用 crash 工具分析、借助 kprobes 动态探测等,才能准确地找到问题的根源并加以解决。
然而,Linux 内核调试技术的学习和掌握并非一蹴而就,它需要我们持续不断地学习和实践。随着 Linux 内核的不断发展和更新,新的功能和特性不断涌现,这也意味着会出现新的问题和挑战。因此,我们要保持对新技术的关注和学习热情,不断积累调试经验,提高自己解决问题的能力。
展望未来,随着计算机技术的飞速发展,尤其是人工智能、大数据、物联网等新兴技术的兴起,对 Linux 内核的性能和稳定性提出了更高的要求。这也将推动内核调试技术朝着更加智能化、自动化的方向发展。未来的调试工具可能会具备更强的智能分析能力,能够自动识别和诊断内核中的问题,并提供有效的解决方案;同时,调试过程也可能会更加自动化,减少人工干预,提高调试效率。
此外,随着硬件技术的不断进步,如多核处理器、新型存储设备等的出现,Linux 内核需要更好地适应这些硬件变化,这也将为内核调试技术带来新的发展机遇和挑战。我们有理由相信,在广大开发者的共同努力下,Linux 内核调试技术将不断完善和发展,为 Linux 系统的持续创新和进步提供坚实的保障。