攻克Linux内核Oops:手把手教你从崩溃到破案!

作为一名长期深耕 Linux 内核开发的博主,在这条探索之路上,我遭遇过无数的挑战,而 Linux 内核 Oops 问题,绝对是其中让人最为头疼的难题之一。

还记得那是一个为某项目开发定制 Linux 内核模块的紧张时期,我满心期待地将新编写的驱动程序模块加载到内核中,本以为一切会顺利进行,结果屏幕上突然跳出一大串密密麻麻的 Oops 错误信息,系统也陷入了不稳定的状态。那一刻,我的心瞬间悬了起来,望着那看似杂乱无章的错误提示,内心充满了焦虑与困惑,完全不知道问题究竟出在哪里。

这种经历并非个例,相信许多和我一样在 Linux 内核开发领域摸爬滚打的朋友都有过类似的痛苦遭遇。Oops 错误就像隐藏在暗处的幽灵,一旦出现,就会让我们精心构建的系统陷入混乱,耗费大量的时间和精力去排查和修复。它不仅考验着我们的技术能力,更考验着我们的耐心和毅力。

那么,Oops 错误究竟是什么呢?简单来说,当 Linux 内核遇到无法正常处理的严重错误,如空指针引用、非法内存访问、内核堆栈溢出等情况时 ,就会输出一段包含丰富信息的错误报告,这段报告就是 Oops 信息。Oops 堪称是内核开发者和系统调试人员的得力助手,它详细地记录下错误发生时内核的各种状态信息,为我们定位和解决问题提供了关键线索。接下来,就让我们一起深入探寻 Linux 内核 Oops 调试方法,揭开它神秘的面纱,希望能帮助大家在今后遇到 Oops 问题时更加从容地应对。

一、Oops 是什么?

1.1定义阐述

在 Linux 内核的世界里,Oops 是当内核检测到严重错误,无法继续正常执行当前操作时,输出的一段详细错误信息。它就像是内核在遇到无法处理的状况时,向开发者发出的紧急求救信号。从本质上讲,Oops 是内核的一种自我诊断机制,通过输出关键的系统状态和错误相关信息,为调试提供关键线索。

与用户空间的 Segmentation Fault(段错误)类似,Oops 同样源于程序对内存的非法访问或其他严重错误。比如在用户空间中,当一个程序试图访问未分配给它的内存区域,或者访问已释放的内存时,就会触发 Segmentation Fault 错误,导致程序崩溃。而在内核中,Oops 的出现意味着内核在执行过程中遇到了类似的严重问题,如空指针引用、非法内存访问、内核堆栈溢出等 。这些问题一旦发生,会使内核的正常运行受到严重影响,甚至导致系统死机。因此,Oops 对于内核调试至关重要,它所包含的信息是我们深入了解内核错误原因、定位问题根源的关键。

1.2引发原因

(1)非法内存访问

这是引发 Oops 最为常见的原因之一。当内核代码试图访问未被映射到物理内存的虚拟地址,或者访问权限不足的内存区域时,就会触发非法内存访问错误。例如,在驱动程序开发中,如果对设备内存的映射和访问操作不当,就很容易出现这种问题。假设我们正在编写一个硬件驱动程序,需要与特定的硬件设备进行交互。在访问设备的寄存器时,错误地计算了寄存器的地址,导致访问了一个非法的内存地址,这时就极有可能引发 Oops 错误。

(2)空指针引用

当内核代码试图解引用一个空指针时,空指针引用错误便会发生。这通常是由于代码逻辑错误,在使用指针之前没有对其进行有效的初始化或检查。比如,在一个链表操作的内核模块中,当遍历链表时,如果没有正确判断链表节点指针是否为空,就尝试访问节点的数据成员,一旦指针为空,就会触发 Oops。具体来说,假设有如下链表节点定义和遍历代码:

复制
struct list_node { int data; struct list_node *next; }; void traverse_list(struct list_node *head) { struct list_node *current = head; while (current != NULL) { // 错误示范:没有检查current是否为空就访问其成员 printk(KERN_INFO "Data: %d\n", current->data); current = current->next; } }1.2.3.4.5.6.7.8.9.10.11.12.13.

在上述代码中,如果head指针为空,或者在遍历过程中current指针意外变为空,就会发生空指针引用,进而导致 Oops。

(3)内核模块错误

内核模块作为可动态加载到内核中的代码,若其中存在编程错误,也常常会引发 Oops。例如,模块在初始化或卸载过程中,如果没有正确处理资源的分配和释放,就可能留下隐患。曾经在开发一个网络设备驱动模块时,在模块初始化函数中申请了内存资源,但在卸载函数中却忘记释放这些内存,当多次加载和卸载该模块后,系统的内存管理就出现了混乱,最终引发了 Oops 错误 。此外,模块之间的兼容性问题也可能导致 Oops,比如不同模块对同一内核数据结构的访问和修改方式不一致,就容易引发冲突。

二、调试前的关键准备

在调试一个 bug 之前,我们所要做的准备工作有:

有一个被确认的 bug。包含这个 bug 的内核版本号,需要分析出这个 bug 在哪一个版本被引入,这个对于解决问题有极大的帮助。可以采用二分查找法来逐步锁定 bug 引入版本号。对内核代码理解越深刻越好,同时还需要一点点运气。该 bug 可以复现。如果能够找到复现规律,那么离找到问题的原因就不远了。最小化系统。把可能产生 bug 的因素逐一排除掉。

2.1确认并定位 bug

在着手调试之前,首先要明确存在的问题,即确认并定位 bug。确定一个被确认的 bug 是调试的基础,只有明确了问题所在,才能有针对性地进行后续的调试工作。同时,获取包含这个 bug 的内核版本号也至关重要,它能帮助我们快速定位问题出现的范围。例如,在某个项目中,我发现系统在加载特定内核模块时出现 Oops 错误,通过查看系统日志,确定了问题出现的内核版本号为 5.10.10。

若能进一步分析出这个 bug 在哪一个版本被引入,对于解决问题更是大有裨益。这里可以采用二分查找法来逐步锁定 bug 引入版本号。假设我们怀疑某个问题是在 2.6.11 到 2.6.20 这一系列内核版本中引入的,我们可以先从中间版本 2.6.15 开始检查 。如果在 2.6.15 版本中没有发现问题,那就说明错误是在 2.6.15 之后的版本引入的;接下来,我们可以在 2.6.15 和 2.6.20 的中间版本(如 2.6.17)继续检查。

反之,如果在 2.6.15 版本中出现了问题,那就说明错误是在 2.6.15 之前的版本引入的,我们就需要检查 2.6.13 版本。通过不断重复这样的筛选过程,最终就能将问题锁定在两个相继发行的版本之间,从而更容易对引发这个 bug 的代码变更进行定位。

2.2环境搭建

搭建一个完备的调试环境是进行 Linux 内核 Oops 调试的基础,它为我们提供了必要的工具和条件,使得调试工作能够顺利进行。在这个过程中,需要安装和配置一系列的工具,这些工具相互协作,共同助力我们解决内核 Oops 问题。

GCC(GNU Compiler Collection)作为一款强大的编译器,是编译内核和内核模块必不可少的工具。以 Ubuntu 系统为例,在终端中输入命令 “sudo apt-get install build-essential”,即可轻松完成 GCC 的安装。这行命令会自动下载并安装 GCC 以及相关的编译依赖库,确保 GCC 能够正常工作。安装完成后,我们可以通过 “gcc -v” 命令来查看 GCC 的版本信息,验证是否安装成功。

GDB(GNU Debugger)则是调试的核心工具,它允许我们在内核运行时进行单步执行、设置断点、查看变量值等操作,帮助我们深入了解内核的运行状态,从而找到问题的根源。在 Ubuntu 系统上,同样可以使用 “sudo apt-get install gdb” 命令进行安装。安装完成后,在调试时,我们可以使用 “gdb vmlinux” 命令来加载内核符号表,这里的 “vmlinux” 是内核的可执行文件,加载符号表后,GDB 就能准确地定位到内核代码中的具体位置,为调试提供极大的便利。

make 工具在构建内核和内核模块时发挥着重要作用,它能够根据 Makefile 文件中的规则,自动编译和链接源代码,生成可执行文件或模块。安装 make 同样很简单,在 Ubuntu 系统中,执行 “sudo apt-get install make” 即可。安装完成后,我们可以通过 “make -v” 命令查看 make 的版本,确认安装无误。

除了上述工具,还需要安装一些与内核调试相关的依赖包,如 libncurses5-dev、bison、flex、libssl-dev、libelf-dev 等。这些依赖包提供了内核编译和调试所需的各种库和工具。在 Ubuntu 系统中,可以使用 “sudo apt-get install libncurses5-dev bison flex libssl-dev libelf-dev” 命令一次性安装多个依赖包,确保调试环境的完整性。

2.3内核配置优化

为了更有效地进行内核调试,对内核配置进行优化是关键步骤。通过 make menuconfig 命令,我们可以进入内核配置界面,这是一个基于文本的交互式界面,类似于一个菜单树,我们可以通过上下左右键进行选择和操作。

在这个界面中,开启 Magic SysRq key 选项尤为重要。Magic SysRq key 是一个强大的系统请求键,它可以在系统出现问题时,通过组合键的方式向内核发送特定的命令,获取系统的关键信息,如内存使用情况、任务列表等,为调试提供重要线索。例如,当系统出现死机等异常情况时,我们可以按下 Alt + SysRq + m 组合键,内核会将内存信息输出到控制台,帮助我们分析内存使用是否存在问题。

Kernel debugging 选项的开启也不可或缺,它会在内核中添加大量的调试信息,使得我们在调试时能够获取更详细的内核运行状态信息。比如,开启该选项后,内核在出现 Oops 错误时,会输出更多关于错误发生时的上下文信息,包括寄存器的值、函数调用栈等,这些信息对于准确分析错误原因至关重要。

此外,还有一些其他的调试相关选项也可以根据具体需求开启,如 Debug slab memory allocations 用于调试内存分配问题,Spinlock and rw-lock debugging: basic checks 用于检查自旋锁和读写锁的基本问题等。这些选项就像是调试过程中的得力助手,能够帮助我们从不同角度发现和解决内核中的问题。

三、内核异常详解

3.1BUG() —开发者触发的逻辑错误

BUG 是指那些不符合内核的正常设计,但内核能够检测出来并且对系统运行不会产生影响的问题,比如在原子上下文中休眠,在内核中用 BUG 标识。

有过驱动调试经验的人肯定都知道这个东西,这里的 BUG 跟我们一般认为的 “软件缺陷” 可不是一回事,这里说的 BUG() 其实是linux kernel中用于拦截内核程序超出预期的行为,属于软件主动汇报异常的一种机制。这里有个疑问,就是什么时候会用到呢?一般来说有两种用到的情况:

一是软件开发过程中,若发现代码逻辑出现致命 fault 后就可以调用BUG()让kernel死掉(类似于assert),这样方便于定位问题,从而修正代码执行逻辑;另外一种情况就是,由于某种特殊原因(通常是为了debug而需抓ramdump),我们需要系统进入kernel panic的情况下使用;

对于 arm64 来说 BUG() 定义如下:

复制
arch/arm64/include/asm/bug.h #ifndef _ARCH_ARM64_ASM_BUG_H #define _ARCH_ARM64_ASM_BUG_H #include <linux/stringify.h> #include <asm/asm-bug.h> #define __BUG_FLAGS(flags) \ asm volatile (__stringify(ASM_BUG_FLAGS(flags))); #define BUG() do { \ __BUG_FLAGS(0); \ unreachable(); \ } while (0) #define __WARN_FLAGS(flags) __BUG_FLAGS(BUGFLAG_WARNING|(flags)) #define HAVE_ARCH_BUG #include <asm-generic/bug.h> #endif /* ! _ARCH_ARM64_ASM_BUG_H */1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

注意最后的 define HAVE_ARCH_BUG ,对于arm64 架构来说,会通过 include asm-generict/bug.h对 BUG() 进行重定义。

复制
include/asm-generic/bug.h #ifndef HAVE_ARCH_BUG #define BUG() do { \ printk("BUG: failure at %s:%d/%s()!\n", __FILE__, __LINE__, __func__); \ barrier_before_unreachable(); \ panic("BUG!"); \ } while (0) #endif #ifndef HAVE_ARCH_BUG_ON #define BUG_ON(condition) do { if (unlikely(condition)) BUG(); } while (0) #endif1.2.3.4.5.6.7.8.9.10.11.12.13.

也就是在 arm64 架构中 BUG() 和 BUG_ON() 都是执行的 panic()。而对于 arm 32位架构来说,BUG() 会向CPU 下发一条未定义指令而触发ARM 发起未定义指令异常,随后进入 kernel 异常处理流程,通过调用die() 经历Oops 和 panic。

3.2OOPS —错误报告框架

Oops 就意外着内核出了异常,此时会将产生异常时出错原因,CPU的状态,出错的指令地址、数据地址及其他寄存器,函数调用的顺序甚至是栈里面的内容都打印出来,然后根据异常的严重程度来决定下一步的操作:杀死导致异常的进程或者挂起系统。

例如,在编写驱动或内核模块时,常常会显示或隐式地对指针进行非法取值或使用不正确的指针,导致内核发生一个 oops 错误。当处理器在内核空间中访问一个分发的指针时,因为虚拟地址到物理地址的映射关系还没有建立,会触发一个缺页中断,在缺页中断中该地址是非法的,内核无法正确地为该地址建立映射关系,所以内核触发一个oops 错误。代码如下:

复制
arch/arm64/mm/fault.c static void die_kernel_fault(const char *msg, unsigned long addr, unsigned int esr, struct pt_regs *regs) { bust_spinlocks(1); pr_alert("Unable to handle kernel %s at virtual address %016lx\n", msg, addr); mem_abort_decode(esr); show_pte(addr); die("Oops", regs, esr); bust_spinlocks(0); do_exit(SIGKILL); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

通过 die() 会进行oops 异常处理,详细的 die() 函数流程看第 3 节。当出现 oops,并且如果有源码,可以通过 arm 的 arch64-linux-gnu-objdump 工具看到出错的函数的汇编情况,也可以通过 GDB 工具分析。如果出错的地方为内核函数,可以使用 vmlinux 文件。

如果没有源码,对于没有编译符号表的二进制文件,可以使用:

复制
arch64-linux-gnu-objdump -d oops.ko1.

命令来转储 oops.ko 文件内核也提供了一个非常好用的脚本,可以快速定位问题,该脚本位于 Linux 源码目录下的 scripts/decodecode 中,会把出错的 oops 日志信息转换成直观有用的汇编代码,并且告知具体出错的汇编语句,这对于分析没有源码的 oops 错误非常有用。

3.3die() — 硬件异常处理函数

复制
arch/arm64/kernel/traps.c static DEFINE_RAW_SPINLOCK(die_lock); /* * This function is protected against re-entrancy. */ void die(const char *str, struct pt_regs *regs, int err) { int ret; unsigned long flags; raw_spin_lock_irqsave(&die_lock, flags); oops_enter(); console_verbose(); bust_spinlocks(1); ret = __die(str, err, regs); if (regs && kexec_should_crash(current)) crash_kexec(regs); bust_spinlocks(0); add_taint(TAINT_DIE, LOCKDEP_NOW_UNRELIABLE); oops_exit(); if (in_interrupt()) panic("Fatal exception in interrupt"); if (panic_on_oops) panic("Fatal exception"); raw_spin_unlock_irqrestore(&die_lock, flags); if (ret != NOTIFY_STOP) do_exit(SIGSEGV); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.

oops_enter() ---> oops_exit() 为Oops 的处理流程,获取console 的log 级别,并通过 __die() 通过对Oops 感兴趣的模块进行callback,打印模块状态不为 MODULE_STATE_UNFORMED 的模块信息,打印PC、LR、SP、x0 等寄存器信息,打印调用栈信息,等等。

(1)__die()
复制
arch/arm64/kernel/traps.c static int __die(const char *str, int err, struct pt_regs *regs) { static int die_counter; int ret; pr_emerg("Internal error: %s: %x [#%d]" S_PREEMPT S_SMP "\n", str, err, ++die_counter); /* trap and error numbers are mostly meaningless on ARM */ ret = notify_die(DIE_OOPS, str, regs, err, 0, SIGSEGV); if (ret == NOTIFY_STOP) return ret; print_modules(); show_regs(regs); dump_kernel_instr(KERN_EMERG, regs); return ret; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

打印 EMERG 的log,Internal error: oops.....;

notify_die() 会通知所有对 Oops 感兴趣的模块并进行callback;print_modules() 打印模块状态不为 MODULE_STATE_UNFORMED 的模块信息;show_regs() 打印PC、LR、SP 等寄存器的信息,同时打印调用堆栈信息;dump_kernel_instr() 打印 pc指针和前4条指令;

这里不过多的剖析,感兴趣的可以查看下源码。这里需要注意的是 notify_die() 会通知所有的Oops 感兴趣的模块,模块会通过函数 register_die_notifier() 将callback 注册到全局结构体变量 die_chain 中(多个模块注册进来形成一个链表),然后在通过 notify_die() 函数去解析这个 die_chain,并分别调用callback:

复制
kernel/notifier.c static ATOMIC_NOTIFIER_HEAD(die_chain); int notrace notify_die(enum die_val val, const char *str, struct pt_regs *regs, long err, int trap, int sig) { struct die_args args = { .regs = regs, .str = str, .err = err, .trapnr = trap, .signr = sig, }; RCU_LOCKDEP_WARN(!rcu_is_watching(), "notify_die called but RCU thinks were quiescent"); return atomic_notifier_call_chain(&die_chain, val, &args); } NOKPROBE_SYMBOL(notify_die); int register_die_notifier(struct notifier_block *nb) { vmalloc_sync_mappings(); return atomic_notifier_chain_register(&die_chain, nb); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.
(2)oops同时有可能panic

从上面 die() 函数最后看到,oops_exit() 之后也有可能进入panic():

复制
arch/arm64/kernel/traps.c void die(const char *str, struct pt_regs *regs, int err) { ... if (in_interrupt()) panic("Fatal exception in interrupt"); if (panic_on_oops) panic("Fatal exception"); ... }1.2.3.4.5.6.7.8.9.10.11.12.

处于中断或panic_on_oops 打开时进入 panic。

中断的可能性:

硬件 IRQ;软件 IRQ;NMI;

panic_on_oops 的值受 CONFIG_PANIC_ON_OOPS_VALUE 影响。当然该值也可以通过节点/proc/sys/kernel/panic_on_oops 进行动态修改。

3.4panic() —系统终止函数

panic 本意是“恐慌”的意思,这里意旨 kernel 发生了致命错误导致无法继续运行下去的情况。根据实际情况 Oops最终也可能会导致panic 的发生。

复制
kernel/panic.c /** * panic - halt the system * @fmt: The text string to print * * Display a message, then perform cleanups. * * This function never returns. */ void panic(const char *fmt, ...) { static char buf[1024]; va_list args; long i, i_next = 0, len; int state = 0; int old_cpu, this_cpu; bool _crash_kexec_post_notifiers = crash_kexec_post_notifiers; //禁止本地中断,避免出现死锁,因为无法防止中断处理程序(在获得panic锁后运行)再次被调用panic local_irq_disable(); //禁止任务抢占 preempt_disable_notrace(); //通过this_cpu确认是否调用panic() 的cpu是否为panic_cpu; //即,只允许一个CPU执行该代码,通过 panic_smp_self_stop() 保证当一个CPU执行panic时, //其他CPU处于停止或等待状态; this_cpu = raw_smp_processor_id(); old_cpu = atomic_cmpxchg(&panic_cpu, PANIC_CPU_INVALID, this_cpu); if (old_cpu != PANIC_CPU_INVALID && old_cpu != this_cpu) panic_smp_self_stop(); //把console的打印级别放开 console_verbose(); bust_spinlocks(1); va_start(args, fmt); len = vscnprintf(buf, sizeof(buf), fmt, args); va_end(args); if (len && buf[len - 1] == \n) buf[len - 1] = \0; //解析panic所携带的message,前缀为Kernel panic - not syncing pr_emerg("Kernel panic - not syncing: %s\n", buf); #ifdef CONFIG_DEBUG_BUGVERBOSE /* * Avoid nested stack-dumping if a panic occurs during oops processing */ if (!test_taint(TAINT_DIE) && oops_in_progress <= 1) dump_stack(); #endif //如果kgdb使能,即CONFIG_KGDB为y,在停掉所有其他CPU之前,跳转kgdb断点运行 kgdb_panic(buf); if (!_crash_kexec_post_notifiers) { printk_safe_flush_on_panic(); //会根据当前是否设置了转储内核(使能CONFIG_KEXEC_CORE)确定是否实际执行转储操作; //如果执行转储则会通过 kexec 将系统切换到新的kdump 内核,并且不会再返回; //如果不执行转储,则继续后面流程; __crash_kexec(NULL); //停掉其他CPU,只留下当前CPU干活 smp_send_stop(); } else { /* * If we want to do crash dump after notifier calls and * kmsg_dump, we will need architecture dependent extra * works in addition to stopping other CPUs. */ crash_smp_send_stop(); } //通知所有对panic感兴趣的模块进行回调,添加一些kmsg信息到输出 atomic_notifier_call_chain(&panic_notifier_list, 0, buf); /* Call flush even twice. It tries harder with a single online CPU */ printk_safe_flush_on_panic(); //dump 内核log buffer中的log信息 kmsg_dump(KMSG_DUMP_PANIC); /* * If you doubt kdump always works fine in any situation, * "crash_kexec_post_notifiers" offers you a chance to run * panic_notifiers and dumping kmsg before kdump. * Note: since some panic_notifiers can make crashed kernel * more unstable, it can increase risks of the kdump failure too. * * Bypass the panic_cpu check and call __crash_kexec directly. */ if (_crash_kexec_post_notifiers) __crash_kexec(NULL); #ifdef CONFIG_VT unblank_screen(); #endif console_unblank(); //关掉所有debug锁 debug_locks_off(); console_flush_on_panic(CONSOLE_FLUSH_PENDING); panic_print_sys_info(); if (!panic_blink) panic_blink = no_blink; //如果sysctl配置了panic_timeout > 0则在panic_timeout后重启系统 //首先,这里会每隔100ms重启 NMI watchdog if (panic_timeout > 0) { /* * Delay timeout seconds before rebooting the machine. * We cant use the "normal" timers since we just panicked. */ pr_emerg("Rebooting in %d seconds..\n", panic_timeout); for (i = 0; i < panic_timeout * 1000; i += PANIC_TIMER_STEP) { touch_nmi_watchdog(); if (i >= i_next) { i += panic_blink(state ^= 1); i_next = i + 3600 / PANIC_BLINK_SPD; } mdelay(PANIC_TIMER_STEP); } } //其次,这里确定reboot_mode,并重启系统 if (panic_timeout != 0) { /* * This will not be a clean reboot, with everything * shutting down. But if there is a chance of * rebooting the system it will be rebooted. */ if (panic_reboot_mode != REBOOT_UNDEFINED) reboot_mode = panic_reboot_mode; emergency_restart(); } #ifdef __sparc__ { extern int stop_a_enabled; /* Make sure the user can actually press Stop-A (L1-A) */ stop_a_enabled = 1; pr_emerg("Press Stop-A (L1-A) from sun keyboard or send break\n" "twice on console to return to the boot prom\n"); } #endif #if defined(CONFIG_S390) disabled_wait(); #endif pr_emerg("---[ end Kernel panic - not syncing: %s ]---\n", buf); /* Do not scroll important messages printed above */ suppress_printk = 1; local_irq_enable(); for (i = 0; ; i += PANIC_TIMER_STEP) { touch_softlockup_watchdog(); if (i >= i_next) { i += panic_blink(state ^= 1); i_next = i + 3600 / PANIC_BLINK_SPD; } mdelay(PANIC_TIMER_STEP); } } EXPORT_SYMBOL(panic);1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146.147.148.149.150.151.152.153.154.155.156.157.158.159.160.161.162.163.164.165.166.167.

详细信息见代码注释。panic_timeout 是根据节点 /proc/sys/kernel/panic 值配置,用以指定在重启系统之前需要 wait 的时长。

(1)panic_print_sys_info()
复制
kernel/panic.c #define PANIC_PRINT_TASK_INFO 0x00000001 #define PANIC_PRINT_MEM_INFO 0x00000002 #define PANIC_PRINT_TIMER_INFO 0x00000004 #define PANIC_PRINT_LOCK_INFO 0x00000008 #define PANIC_PRINT_FTRACE_INFO 0x00000010 #define PANIC_PRINT_ALL_PRINTK_MSG 0x00000020 static void panic_print_sys_info(void) { if (panic_print & PANIC_PRINT_ALL_PRINTK_MSG) console_flush_on_panic(CONSOLE_REPLAY_ALL); if (panic_print & PANIC_PRINT_TASK_INFO) show_state(); if (panic_print & PANIC_PRINT_MEM_INFO) show_mem(0, NULL); if (panic_print & PANIC_PRINT_TIMER_INFO) sysrq_timer_list_show(); if (panic_print & PANIC_PRINT_LOCK_INFO) debug_show_all_locks(); if (panic_print & PANIC_PRINT_FTRACE_INFO) ftrace_dump(DUMP_ALL); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

panic_print 默认值为 0,可以通过 /proc/sys/kernel/panic_print 节点配置,当 panic 发生的时候,用户可以通过如下bit 位配置打印系统信息:

bit 0:打印所有的进程信息;bit 1:打印系统内存信息;bit 2:打印定时器信息;bit 3:打印当 CONFIG_LOCKEDP 打开时的锁信息;bit 4:打印所有 ftrace;bit 5:打印串口所有信息;

四、内核调试配置选项

学习编写驱动程序要构建安装自己的内核(标准主线内核)。最重要的原因之一是:内核开发者已经建立了多项用于调试的功能。但是由于这些功能会造成额外的输出,并导致能下降,因此发行版厂商通常会禁止发行版内核中的调试功能。

4.1内核配置

为了实现内核调试,在内核配置上增加了几项:

复制
Kernel hacking --->1.

启用选项例如:

复制
slab layer debugging(slab层调试选项)1.

4.2调试原子操作

从内核 2.5 开发,为了检查各类由原子操作引发的问题,内核提供了极佳的工具。内核提供了一个原子操作计数器,它可以配置成,一旦在原子操作过程中,进城进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。所以,包括在使用锁的时候调用 schedule (),正使用锁的时候以阻塞方式请求分配内存等,各种潜在的 bug 都能够被探测到。

下面这些选项可以最大限度地利用该特性:

复制
CONFIG_PREEMPT = y1.

五、核心调试方法

当 Linux 内核出现 Oops 错误时,掌握有效的调试方法至关重要。接下来,我们将详细介绍几种核心调试方法,这些方法在定位和解决 Oops 问题时非常实用。

5.1 printk 函数运用

printk 堪称 Linux 内核中的 “万能调试助手”,它拥有强大的健壮性。无论在内核的中断上下文还是进程上下文,printk 都能稳定地发挥作用。这意味着,当内核在处理紧急的中断事件,或者在正常的进程执行流程中出现问题时,我们都可以借助 printk 输出关键的调试信息。它还可以在任何持有锁时被调用,并且能够在多处理器环境下同时被调用,无需额外的锁机制来保证线程安全 。不过,在系统功能启动的初期,终端还未完成初始化时,printk 存在一定的局限性,此时它无法正常工作。

printk ()内核提供的格式化打印函数;健壮性是 printk 最容易被接受的一个特质,几乎在任何地方,任何时候内核都可以调用它(中断上下文、进程上下文、持有锁时、多处理器处理时等)。

printk 支持 8 种不同的日志级别,从高到低依次为:

KERN_EMERG(0),表示系统不可用,是最为紧急的情况,比如系统硬件出现严重故障,导致系统无法继续运行;KERN_ALERT(1),意味着必须立即采取行动,通常用于报告那些可能导致系统崩溃或严重影响系统运行的问题;KERN_CRIT(2),代表严重情况,如硬盘故障、内存不足等;KERN_ERR(3),表示错误情况,用于输出一般性的错误信息,帮助开发者定位代码中的错误;KERN_WARNING(4),即警告情况,提示一些可能会引发问题的潜在风险,但系统仍可继续运行;KERN_NOTICE(5),表示正常但重要的情况,用于记录一些需要关注的系统状态变化;KERN_INFO(6),提供一般信息,如系统启动过程中的一些关键步骤、设备驱动的加载信息等;KERN_DEBUG(7),用于调试信息,在开发和调试阶段,通过输出大量详细的调试信息,帮助开发者深入了解内核的运行状态 。这些日志级别可以通过修改 /proc/sys/kernel/printk 文件来调整输出级别。例如,当我们将该文件中的第一个数字设置为 7 时,意味着只有日志级别小于等于 7(即 KERN_DEBUG 及以上级别)的信息才会被输出,这样可以在调试时获取更详细的信息。而在正式发布的系统中,通常会将该值设置为较低的数字,如 4,以减少不必要的日志输出,提高系统性能。

在系统启动过程中,终端初始化之前,在某些地方是不能调用的。如果真的需要调试系统启动过程最开始的地方,有以下方法可以使用:

使用串口调试,将调试信息输出到其他终端设备。使用 early_printk (),该函数在系统启动初期就有打印能力。但它只支持部分硬件体系。

printk 和 printf 一个主要的区别就是前者可以指定一个 LOG 等级。内核根据这个等级来判断是否在终端上打印消息。内核把比指定等级高的所有消息显示在终端。

可以使用下面的方式指定一个 LOG 级别:printk(KERN_CRIT “Hello, world!\n”); 注意,第一个参数并不一个真正的参数,因为其中没有用于分隔级别(KERN_CRIT)和格式字符的逗号(,)。KERN_CRIT 本身只是一个普通的字符串(事实上,它表示的是字符串 "<2>";表 1 列出了完整的日志级别清单)。

作为预处理程序的一部分,C 会自动地使用一个名为 字符串串联 的功能将这两个字符串组合在一起。组合的结果是将日志级别和用户指定的格式字符串包含在一个字符串中。

内核使用这个指定 LOG 级别与当前终端 LOG 等级 console_loglevel 来决定是不是向终端打印。下面是可使用的 LOG 等级:

复制
#define KERN_EMERG "<0>" /* system is unusable */ #define KERN_ALERT "<1>" /* action must be taken immediately */ #define KERN_CRIT "<2>" /* critical conditions */ #define KERN_ERR "<3>" /* error conditions */ #define KERN_WARNING "<4>" /* warning conditions */ #define KERN_NOTICE "<5>" /* normal but significant condition */ #define KERN_INFO "<6>" /* informational */ #define KERN_DEBUG "<7>" /* debug-level messages */ #define KERN_DEFAULT "<d>" /* Use the default kernel loglevel */1.2.3.4.5.6.7.8.9.

注意,如果调用者未将日志级别提供给 printk,那么系统就会使用默认值 KERN_WARNING "<4>"(表示只有 KERN_WARNING 级别以上的日志消息会被记录)。由于默认值存在变化,所以在使用时最好指定 LOG 级别。有 LOG 级别的一个好处就是我们可以选择性的输出 LOG。

比如平时我们只需要打印 KERN_WARNING 级别以上的关键性 LOG,但是调试的时候,我们可以选择打印 KERN_DEBUG 等以上的详细 LOG。而这些都不需要我们修改代码,只需要通过命令修改默认日志输出级别:

复制
mtj@ubuntu :~$ cat /proc/sys/kernel/printk 4 4 1 7 mtj@ubuntu :~$ cat /proc/sys/kernel/printk_delay 0 mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit 5 mtj@ubuntu :~$ cat /proc/sys/kernel/printk_ratelimit_burst 101.2.3.4.5.6.7.8.

第一项定义了 printk API 当前使用的日志级别。这些日志级别表示了控制台的日志级别、默认消息日志级别、最小控制台日志级别和默认控制台日志级别。printk_delay 值表示的是 printk 消息之间的延迟毫秒数(用于提高某些场景的可读性)。

注意,这里它的值为 0,而它是不可以通过 /proc 设置的。printk_ratelimit 定义了消息之间允许的最小时间间隔(当前定义为每 5 秒内的某个内核消息数)。消息数量是由 printk_ratelimit_burst 定义的(当前定义为 10)。

如果您拥有一个非正式内核而又使用有带宽限制的控制台设备(如通过串口), 那么这非常有用。注意,在内核中,速度限制是由调用者控制的,而不是在 printk 中实现的。

如果一个 printk 用户要求进行速度限制,那么该用户就需要调用 printk_ratelimit 函数。

内核消息都被保存在一个 LOG_BUF_LEN 大小的环形队列中。关于 LOG_BUF_LEN 定义:

复制
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)1.

※ 变量 CONFIG_LOG_BUF_SHIFT 在内核编译时由配置文件定义,对于 i386 平台,其值定义如下(在 linux26/arch/i386/defconfig 中):

复制
CONFIG_LOG_BUF_SHIFT=181.

记录缓冲区操作:① 消息被读出到用户空间时,此消息就会从环形队列中删除。② 当消息缓冲区满时,如果再有 printk () 调用时,新消息将覆盖队列中的老消息。③ 在读写环形队列时,同步问题很容易得到解决。

※ 这个纪录缓冲区之所以称为环形,是因为它的读写都是按照环形队列的方式进行操作的。

在标准的 Linux 系统上,用户空间的守护进程 klogd 从纪录缓冲区中获取内核消息,再通过 syslogd 守护进程把这些消息保存在系统日志文件中。klogd 进程既可以从 /proc/kmsg 文件中,也可以通过 syslog () 系统调用读取这些消息。默认情况下,它选择读取 /proc 方式实现。klogd 守护进程在消息缓冲区有新的消息之前,一直处于阻塞状态。

一旦有新的内核消息,klogd 被唤醒,读出内核消息并进行处理。默认情况下,处理例程就是把内核消息传给 syslogd 守护进程。syslogd 守护进程一般把接收到的消息写入 /var/log/messages 文件中。不过,还是可以通过 /etc/syslog.conf 文件来进行配置,可以选择其他的输出文件。

dmesg 命令也可用于打印和控制内核环缓冲区。这个命令使用 klogctl 系统调用来读取内核环缓冲区,并将它转发到标准输出(stdout)。这个命令也可以用来清除内核环缓冲区(使用 -c 选项),设置控制台日志级别(-n 选项),以及定义用于读取内核日志消息的缓冲区大小(-s 选项)。注意,如果没有指定缓冲区大小,那么 dmesg 会使用 klogctl 的 SYSLOG_ACTION_SIZE_BUFFER 操作确定缓冲区大小。

a) 虽然 printk 很健壮,但是看了源码你就知道,这个函数的效率很低:做字符拷贝时一次只拷贝一个字节,且去调用 console 输出可能还产生中断。所以如果你的驱动在功能调试完成以后做性能测试或者发布的时候千万记得尽量减少 printk 输出,做到仅在出错时输出少量信息。否则往 console 输出无用信息影响性能。b) printk 的临时缓存 printk_buf 只有 1K,所有一次 printk 函数只能记录 <1K 的信息到 log buffer,并且 printk 使用的 “ringbuffer”.

内核 printk 和日志系统的总体结构:

动态调试:

动态调试是通过动态的开启和禁止某些内核代码来获取额外的内核信息。首先内核选项 CONFIG_DYNAMIC_DEBUG 应该被设置。所有通过 pr_debug ()/dev_debug () 打印的信息都可以动态的显示或不显示。可以通过简单的查询语句来筛选需要显示的信息。

源文件名函数名行号(包括指定范围的行号)模块名格式化字符串

将要打印信息的格式写入 /dynamic_debug/control 中。

复制
nullarbor:~ # echo file svcsock.c line 1603 +p >1.

在调试过程中,合理地在关键代码处插入 printk 输出调试信息是非常有效的方法。比如,在一个网络设备驱动程序中,当我们怀疑数据包的接收处理过程存在问题时,可以在接收函数的关键步骤处插入 printk 语句,输出数据包的相关信息,如数据包的长度、源地址、目的地址等。假设我们有如下代码:

复制
#include <linux/module.h> #include <linux/kernel.h> #include <linux/skbuff.h> static int __init net_driver_init(void) { // 初始化相关变量和设备 return 0; } static void __exit net_driver_exit(void) { // 释放资源 } module_init(net_driver_init); module_exit(net_driver_exit); MODULE_LICENSE("GPL"); // 假设这是数据包接收函数 void net_rx_handler(struct sk_buff *skb) { printk(KERN_INFO "Received packet, length: %u\n", skb->len); // 进一步处理数据包 }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

在上述代码中,通过在net_rx_handler函数中插入 printk 语句,我们可以清晰地看到接收到的数据包的长度信息,这对于判断数据包是否正常接收以及后续的处理逻辑是否正确提供了重要依据。

5.2 BUG 与 BUG_ON 宏

①BUG () 和 BUG_ON ()

一些内核调用可以用来方便标记 bug,提供断言并输出信息。最常用的两个是 BUG () 和 BUG_ON ()。

定义在中:

复制
#ifndef HAVE_ARCH_BUG1.

当调用这两个宏的时候,它们会引发 OOPS,导致栈的回溯和错误消息的打印。※ 可以把这两个调用当作断言使用,如:BUG_ON (bad_thing);

②dump_stack()

有些时候,只需要在终端上打印一下栈的回溯信息来帮助你调试。这时可以使用 dump_stack ()。这个函数只在终端上打印寄存器上下文和函数的跟踪线索。

复制
if (!debug_check) { printk(KERN_DEBUG “provide some information…/n”); dump_stack(); }1.2.3.4.

(1)功能作用

在 Linux 内核开发中,BUG 和 BUG_ON 宏就像是隐藏在代码中的 “问题探测器”。当调用这两个宏时,会立刻引发 Oops 错误。它们的主要作用是标记代码中那些不应该出现的情况,一旦这些宏被触发,就表明代码中存在潜在的严重问题。比如,在一段代码中,我们期望某个指针永远不会为空,那么就可以使用BUG_ON(ptr == NULL)来进行断言,如果在运行过程中ptr真的为空,就会触发 Oops,从而让开发者能够及时发现这个潜在的错误。

(2)使用场景

在开发过程中,当我们怀疑代码逻辑存在致命错误,或者某些条件在正常情况下绝对不应该成立时,就可以巧妙地使用 BUG 和 BUG_ON 宏。例如,在一个内存管理模块中,假设我们有一个函数用于分配内存,并且在函数内部做了一些假设,如分配的内存大小必须大于 0。此时,我们可以在函数开头使用BUG_ON(size <= 0)来检查传入的内存大小参数。

如果在实际运行中,由于某些原因导致size小于等于 0,就会触发 Oops,这样我们就能迅速定位到这个错误的源头,避免在后续的代码执行中出现更严重的问题。再比如,在一个多线程同步的场景中,我们使用信号量来控制对共享资源的访问。假设某个线程在获取信号量之前,不应该直接访问共享资源,那么可以在访问共享资源的代码处使用BUG_ON(sem_count < 1)来确保信号量的状态是正确的,如果违反了这个假设,就会触发 Oops,帮助我们发现潜在的同步问题。

5.3 dump_stack 函数

当内核出现 Oops 错误时,dump_stack 函数就如同一位 “线索侦探”,发挥着关键作用。它能够打印出寄存器上下文和函数跟踪线索,为我们提供了深入了解内核运行状态的关键信息。

寄存器上下文包含了内核在错误发生时各个寄存器的值,这些值反映了当时内核的执行环境,如程序计数器(PC)指示了当前正在执行的指令地址,栈指针(SP)指向了当前的栈顶位置等。通过分析这些寄存器的值,我们可以大致了解内核在出错时的执行流程和状态。

函数跟踪线索则展示了函数的调用关系,它从当前出错的函数开始,逐步回溯到调用它的上层函数,形成一条完整的函数调用链。例如,假设我们有一个内核模块,其中包含多个函数之间的嵌套调用。当在某个函数中出现 Oops 错误时,调用 dump_stack 函数后,我们可能会得到如下的函数跟踪线索:function_c -> function_b -> function_a,这清晰地表明了function_c是在function_b中被调用,而function_b又是在function_a中被调用的,从而帮助我们梳理出代码的执行路径,快速定位到问题可能出现的函数范围 。通过这些线索,我们能够更准确地分析错误发生的原因,为解决 Oops 问题提供有力的支持。

5.4 GDB调试工具

(1)工作环境配置

使用 GDB 调试 Linux 内核 Oops 问题,首先需要进行一系列的环境配置。确保系统中已经安装了 GDB,可以通过包管理器进行安装,如在 Ubuntu 系统中,使用 “sudo apt - get install gdb” 命令即可完成安装。准备好编译好的内核源码,这是进行调试的基础,只有拥有完整的内核源码,GDB 才能准确地定位到代码中的具体位置。还需要准备带有调试信息的内核镜像,通常在编译内核时,通过配置编译选项,如添加 “-g” 选项,来生成包含调试信息的内核镜像。例如,在编译内核时,修改 Makefile 文件,在 CFLAGS 变量中添加 “-g”,然后重新编译内核,这样生成的内核镜像就包含了丰富的调试信息,能够被 GDB 识别和利用。

(2)基本调试流程

下面结合一个实际的 Oops 案例来演示 GDB 的基本调试流程。假设我们的内核在运行某个驱动程序时出现了 Oops 错误,首先,使用 “gdb vmlinux” 命令启动 GDB,并加载内核符号表,这里的 “vmlinux” 是编译生成的内核文件。接着,通过 “file vmlinux” 命令再次确认加载的内核文件。然后,使用 “target remote /dev/ttyS0” 命令连接到目标机的串口,这里假设我们通过串口进行调试。连接成功后,使用 “load” 命令加载带有调试信息的内核镜像。接下来,就可以设置断点来暂停内核的执行,以便进行调试。

比如,我们怀疑问题出在驱动程序的某个函数中,就可以使用 “b function_name” 命令在该函数处设置断点,其中 “function_name” 是我们要设置断点的函数名。设置好断点后,使用 “c” 命令继续执行内核,当执行到断点处时,内核会暂停运行。此时,我们可以使用 “info registers” 命令查看当前寄存器的值,使用 “backtrace” 命令查看函数调用栈,还可以使用 “print variable_name” 命令查看变量的值,通过这些操作来分析内核的运行状态,找出问题所在。

例如,在调试一个网络驱动程序时,我们发现系统在接收数据包时出现 Oops 错误。通过上述步骤,我们在驱动程序的接收函数处设置断点,当执行到断点时,查看寄存器的值发现某个与数据包处理相关的寄存器值异常,进一步查看函数调用栈和相关变量的值,最终发现是由于在数据包校验过程中,一个校验和计算错误导致了 Oops,通过这样的调试流程,我们成功定位并解决了问题。

5.5 objdump 工具

objdump 是一个功能强大的反汇编工具,在调试 Linux 内核 Oops 问题时,它能帮助我们深入分析内核模块或相关二进制文件的汇编代码。通过使用 “objdump -d” 命令,我们可以对内核模块或二进制文件进行反汇编操作。例如,对于一个名为 “module.ko” 的内核模块,我们可以在终端中输入 “objdump -d module.ko” 命令,此时,objdump 会将该模块的二进制代码转换为汇编代码,并输出到终端。

在分析出错地址的汇编代码时,我们首先需要从 Oops 信息中获取出错的地址。然后,在 objdump 输出的汇编代码中,找到与该地址对应的汇编指令。通过仔细分析这些汇编指令,我们可以了解内核在出错时的具体操作,判断是否存在指令错误、内存访问异常等问题。比如,在一个 Oops 案例中,Oops 信息显示出错地址为 “0x12345678”,我们使用 objdump 对相关的内核模块进行反汇编后,在输出的汇编代码中找到该地址对应的指令是 “mov [eax], ebx”,通过进一步分析发现,此时 “eax” 寄存器的值是一个非法的内存地址,从而找到了导致 Oops 的原因是非法内存访问。objdump 工具为我们从底层汇编代码的角度分析 Oops 问题提供了有力的支持,帮助我们更深入地理解内核错误的根源。

5.6 decodecode脚本

在 Linux 源码目录下,有一个名为 scripts/decodecode 的脚本,它就像是一把 “解码钥匙”,专门用于将 oops 日志信息转换为直观的汇编代码。这个脚本的作用不可小觑,当我们面对复杂的 oops 日志信息时,往往很难直接从中分析出问题的关键所在。而 decodecode 脚本能够将这些晦涩难懂的 oops 日志信息进行转换,以汇编代码的形式呈现出来,让我们能够更直观地了解内核在出错时的执行情况。

使用 decodecode 脚本的方法相对简单,我们只需在终端中切换到 Linux 源码目录,然后执行 “./scripts/decodecode oops_log_file” 命令,其中 “oops_log_file” 是包含 oops 日志信息的文件。脚本执行后,会输出转换后的汇编代码,我们可以根据这些汇编代码来分析出错的原因。例如,在一个内核调试过程中,我们获取到了一份 oops 日志文件,通过执行 decodecode 脚本,将日志信息转换为汇编代码后,发现其中一段汇编代码在进行内存操作时,使用了错误的寄存器索引,导致了内存访问错误,从而引发了 Oops。通过 decodecode 脚本,我们能够快速定位到问题的关键,提高了调试的效率和准确性 。

六、内存调试工具

6.1MEMWATCH

MEMWATCH 由 Johan Lindh 编写,是一个开放源代码 C 语言内存错误检测工具,您可以自己下载它。只要在代码中添加一个头文件并在 gcc 语句中定义了 MEMWATCH 之后,您就可以跟踪程序中的内存泄漏和错误了。MEMWATCH 支持 ANSIC,它提供结果日志纪录,能检测双重释放(double-free)、错误释放(erroneous free)、没有释放的内存(unfreedmemory)、溢出和下溢等等。

清单 1. 内存样本(test1.c)
复制
#include <stdlib.h> #include <stdio.h> #include "memwatch.h" int main(void) { char *ptr1; char *ptr2; ptr1 = malloc(512); ptr2 = malloc(512); ptr2 = ptr1; free(ptr2); free(ptr1); }1.2.3.4.5.6.7.8.9.10.11.12.13.

清单 1 中的代码将分配两个 512 字节的内存块,然后指向第一个内存块的指针被设定为指向第二个内存块。结果,第二个内存块的地址丢失,从而产生了内存泄漏。现在我们编译清单 1 的 memwatch.c。下面是一个 makefile 示例:test1

复制
gcc -DMEMWATCH -DMW_STDIO test1.c memwatch c -o test11.2.

当您运行 test1 程序后,它会生成一个关于泄漏的内存的报告。清单 2 展示了示例 memwatch.log 输出文件。

清单 2. 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 : 5121.2.3.4.5.6.7.8.9.10.11.

MEMWATCH 为您显示真正导致问题的行。如果您释放一个已经释放过的指针,它会告诉您。对于没有释放的内存也一样。日志结尾部分显示统计信息,包括泄漏了多少内存,使用了多少内存,以及总共分配了多少内存。

6.2 YAMD

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 test11.

清单 3 展示了来自 test1 上的 YAMD 的输出。

清单 3. 使用 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.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.

YAMD 显示我们已经释放了内存,而且存在内存泄漏。让我们在清单 4 中另一个样本程序上试试 YAMD。

清单 4. 内存代码(test2.c)
复制
#include <stdlib.h> #include <stdio.h> 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); }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.

您可以使用下面的命令来启动 YAMD:

复制
./run-yamd /usr/src/test/test2/test21.

清单 5 显示了在样本程序 test2 上使用 YAMD 得到的输出。YAMD 告诉我们在 for 循环中有 “越界(out-of-bounds)” 的情况。

清单 5. 使用 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.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.

MEMWATCH 和 YAMD 都是很有用的调试工具,它们的使用方法有所不同。对于 MEMWATCH,您需要添加包含文件 memwatch.h 并打开两个编译时间标记。对于链接(link)语句,YAMD 只需要 -g 选项。

6.3 Electric Fence

多数 Linux 分发版包含一个 Electric Fence 包,不过您也可以选择下载它。Electric Fence 是一个由 Bruce Perens 编写的 malloc () 调试库。它就在您分配内存后分配受保护的内存。如果存在 fencepost 错误(超过数组末尾运行),程序就会产生保护错误,并立即结束。通过结合 Electric Fence 和 gdb,您可以精确地跟踪到哪一行试图访问受保护内存。ElectricFence 的另一个功能就是能够检测内存泄漏。

6.4 strace

strace 命令是一种强大的工具,它能够显示所有由用户空间程序发出的系统调用。strace 显示这些调用的参数并返回符号形式的值。strace 从内核接收信息,而且不需要以任何特殊的方式来构建内核。

将跟踪信息发送到应用程序及内核开发者都很有用。在清单 6 中,分区的一种格式有错误,清单显示了 strace 的开头部分,内容是关于调出创建文件系统操作(mkfs )的。strace 确定哪个调用导致问题出现。清单 6. mkfs 上 strace 的开头部分

复制
execve("/sbin/mkfs.jfs", ["mkfs.jfs", "-f", "/dev/test1"], & ... open("/dev/test1", O_RDWR|O_LARGEFILE) = 4 stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0 ioctl(4, 0x40041271, 0xbfffe128) = -1 EINVAL (Invalid argument) write(2, "mkfs.jfs: warning - cannot setb" ..., 98mkfs.jfs: warning - cannot set blocksize on block device /dev/test1: Invalid argument ) = 98 stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0 open("/dev/test1", O_RDONLY|O_LARGEFILE) = 5 ioctl(5, 0x80041272, 0xbfffe124) = -1 EINVAL (Invalid argument) write(2, "mkfs.jfs: can\t determine device"..., ..._exit(1) = ?1.2.3.4.5.6.7.8.9.10.11.12.13.

清单 6 显示 ioctl 调用导致用来格式化分区的 mkfs 程序失败。ioctl BLKGETSIZE64 失败。( BLKGET-SIZE64 在调用 ioctl 的源代码中定义。) BLKGETSIZE64 ioctl 将被添加到 Linux 中所有的设备,而在这里,逻辑卷管理器还不支持它。因此,如果 BLKGETSIZE64 ioctl 调用失败,mkfs 代码将改为调用较早的 ioctl 调用;这使得 mkfs 适用于逻辑卷管理器。

七、Linux内核Oops错误案例分析

7.1案例引入

下面我们来看一个实际的 Linux 内核 Oops 错误案例,假设我们在开发一个自定义的内核模块时,遇到了如下的 Oops 错误信息:

复制
[ 10.234567] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000 [ 10.234572] Mem abort info: [ 10.234574] ESR = 0x96000045 [ 10.234577] EC = 0x25: DABT (current EL), IL = 32 bits [ 10.234580] SET = 0, FnV = 0 [ 10.234582] EA = 0, S1PTW = 0 [ 10.234584] Data abort info: [ 10.234586] ISV = 0, ISS = 0x00000045 [ 10.234588] CM = 0, WnR = 1 [ 10.234590] user pgtable: 4k pages, 39-bit VAs, pgdp=0000000108782000 [ 10.234594] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000 [ 10.234603] Internal error: Oops: 96000045 [#1] PREEMPT SMP [ 10.234608] Modules linked in: custom_module(O+) [ 10.234616] CPU: 0 PID: 1234 Comm: some_process Tainted: G O 5.15.0 #1 [ 10.234621] Hardware name: Some_Hardware_Model (DT) [ 10.234623] pstate: 60400009 (nZCv daif +PAN -UAO -TCO BTYPE=--) [ 10.234628] pc : custom_function+0x28/0x1000 [custom_module] [ 10.234638] lr : custom_function+0x24/0x1000 [custom_module] [ 10.234644] sp : ffffffc01391bb20 [ 10.234647] x29: ffffffc01391bb20 x28: ffffff811e6db3b8 [ 10.234652] x27: 0000000000000003 x26: 0000000000000000 [ 10.234658] x25: 0000000000000019 x24: 0000000000000000 [ 10.234662] x23: 0000000000000000 x22: ffffffc011fa28c0 [ 10.234667] x21: ffffffc011fa4380 x20: ffffffc009035000 [ 10.234672] x19: ffffffc011fa2900 x18: 0000000000000000 [ 10.234677] x17: 0000000000000000 x16: 0000000000000000 [ 10.234682] x15: 180f0a0700000000 x14: 00656c75646f6d5f [ 10.234688] x13: 0000000000000000 x12: 0000000000000018 [ 10.234692] x11: 0101010101010101 x10: ffffffff7f7f7f7f [ 10.234697] x9 : ffffffc0100a07d0 x8 : 74696e6920656c75 [ 10.234702] x7 : 646f6d2073706f6f x6 : ffffffc012055ae9 [ 10.234707] x5 : ffffffc012055ae8 x4 : ffffff81feeb1b70 [ 10.234712] x3 : 0000000000000000 x2 : 0000000000000000 [ 10.234717] x1 : ffffff8119b2eac0 x0 : 0000000000000000 [ 10.234722] Call trace: [ 10.234725] custom_function+0x28/0x1000 [custom_module] [ 10.234732] another_function+0xb4/0x210 [ 10.234739] yet_another_function+0x68/0x210 [ 10.234747] some_kernel_function+0x1cb4/0x2258 [ 10.234752] __do_sys_some_syscall+0xe0/0x100 [ 10.234758] __arm64_sys_some_syscall+0x28/0x34 [ 10.234763] el0_svc_common.constprop.0+0x154/0x204 [ 10.234769] do_el0_svc+0x8c/0x98 [ 10.234774] el0_svc+0x20/0x30 [ 10.234780] el0_sync_handler+0xd8/0x184 [ 10.234785] el0_sync+0x1a0/0x1c0 [ 10.234790] [ 10.234790] PC: 0xffffffc009034f28:.... [ 10.239344] Code: 910003fd 91000000 95fee5c3 d2800000 (b900001f) [ 10.239349] ---[ end trace 0000000000000002 ]---1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.

7.2分析过程

(1)信息提取出错地址:从 “Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000” 可以看出,这是一个空指针解引用错误,出错的虚拟地址为 0x0000000000000000。寄存器值:通过 “pc : custom_function+0x28/0x1000 [custom_module]” 可知程序计数器(PC)指向custom_function函数内偏移 0x28 的位置;“lr : custom_function+0x24/0x1000 [custom_module]” 表明链接寄存器(LR)指向custom_function函数内偏移 0x24 的位置;还有其他众多寄存器的值,如 “sp : ffffffc01391bb20” 表示栈指针(SP)的值 ,这些寄存器值反映了出错时内核的运行状态。调用栈:从 “Call trace:” 后面的信息可以看到函数的调用关系,从custom_function开始,依次经过another_function、yet_another_function等函数,这些调用关系展示了程序执行到出错点的路径,对于分析错误原因非常关键。(2)工具运用

①首先,根据出错地址和函数名,我们可以使用 GDB 进行调试。假设我们已经准备好编译好的内核源码和带有调试信息的内核镜像,启动 GDB 并加载内核符号表:

复制
gdb vmlinux file vmlinux1.2.

②然后,通过 Oops 信息中 PC 指向的函数和偏移,在 GDB 中设置断点:

复制
b custom_function+0x281.

③接着,使用 “info registers” 命令查看当前寄存器的值,与 Oops 信息中的寄存器值进行对比分析,进一步确认出错时的状态。

④利用 “backtrace” 命令查看函数调用栈,与 Oops 信息中的调用栈进行核对,检查是否存在异常的函数调用。我们还可以使用 objdump 工具对custom_module模块进行反汇编分析。假设custom_module模块的文件名为 “custom_module.ko”,执行如下命令:

复制
objdump -d custom_module.ko1.

④通过反汇编代码,找到 PC 指向的偏移 0x28 处的汇编指令,分析该指令的操作,判断是否存在指令错误或内存访问异常等问题。例如,如果该指令是对某个指针进行解引用操作,而该指针为空,就会导致空指针解引用错误,与 Oops 信息中的错误类型相符合。

7.3解决办法

经过上述分析,我们发现问题出在custom_function函数中对一个指针的使用上。假设该函数的代码如下:

复制
#include <linux/module.h> #include <linux/kernel.h> static void custom_function(void) { int *ptr = NULL; // 错误操作:没有对ptr进行初始化就解引用 *ptr = 10; } static int __init custom_module_init(void) { printk(KERN_INFO "Custom module initialized\n"); custom_function(); return 0; } static void __exit custom_module_exit(void) { printk(KERN_INFO "Custom module exited\n"); } module_init(custom_module_init); module_exit(custom_module_exit); MODULE_LICENSE("GPL");1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

从代码中可以明显看出,ptr指针被初始化为 NULL,然后在没有进行任何初始化的情况下就被解引用,这正是导致空指针解引用错误的原因。

解决办法很简单,就是在使用指针之前对其进行正确的初始化。修改后的代码如下:

复制
#include <linux/module.h> #include <linux/kernel.h> static void custom_function(void) { int value = 10; int *ptr = &value; *ptr = 10; } static int __init custom_module_init(void) { printk(KERN_INFO "Custom module initialized\n"); custom_function(); return 0; } static void __exit custom_module_exit(void) { printk(KERN_INFO "Custom module exited\n"); } module_init(custom_module_init); module_exit(custom_module_exit); MODULE_LICENSE("GPL");1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.

在修改后的代码中,我们先定义了一个变量value,然后将ptr指针指向value,这样就确保了ptr在被解引用时指向的是一个有效的内存地址,从而避免了空指针解引用错误。重新编译内核模块并加载到系统中,Oops 错误应该就不会再出现了。

THE END