常在河边走,哪能不湿鞋。用Linux,总有死机的时候,如果运气好,会看到一些所谓”Oops”信息(在屏幕上或系统日志中),Oops这个英文单词的意思是“哎呀”,当内核出错时(比如访问非法地址),输出的信息就成为Oops信息,Oops可以看成是内核级的Segmentation Fault。
内核开发比用户空间开发更难的一个因素就是内核调试艰难。内核错误往往会导致系统宕机,很难保留出错时的现场,调试内核的关键在于你的对内核的深刻理解。我们在了解Linux内核Oops调试前,在调试一个 bug,我们所要做的准备工作有:
-
有一个被确认的 bug。
-
包含这个 bug 的内核版本号,需要分析出这个 bug 在哪一个版本被引入,这个对于解决问题有极大的帮助。可以采用二分查找法来逐步锁定 bug 引入版本号。
-
对内核代码理解越深刻越好,同时还需要一点点运气。
-
该 bug 可以复现。如果能够找到复现规律,那么离找到问题的原因就不远了。
-
最小化系统。把可能产生 bug 的因素逐一排除掉。
一、Linux内核Oops是什么?
在 Linux 内核的广袤世界里,Oops 是一个特殊的存在。简单来说,Oops 是 Linux 内核在运行过程中遇到严重错误时,输出的一段包含丰富信息的错误报告 。当内核遭遇诸如空指针引用、非法内存访问、内核堆栈溢出等无法正常处理的错误情况时 ,就会生成 Oops 信息。
从作用上看,Oops 堪称是内核开发者和系统调试人员的得力助手。它就像是一位忠实的记录者,详细地记录下错误发生时内核的各种状态信息。这些信息涵盖了导致错误的代码位置,比如具体是哪个函数、哪一行代码出现了问题;还有当时的寄存器状态,寄存器在计算机运行中起着关键作用,它们保存着正在处理的数据和指令相关信息,了解寄存器状态有助于分析错误发生时的运算情况;以及堆栈跟踪信息,堆栈跟踪能展示函数调用的顺序和层次,让我们清晰地看到错误发生前的操作流程,从而找到错误的根源。可以说,Oops 为我们提供了一把深入了解内核错误的钥匙,极大地帮助我们定位和解决问题。
举个例子,假设你在开发一个基于 Linux 内核的驱动程序,当你将驱动模块加载到内核中时,如果出现了 Oops 信息,就意味着你的驱动程序中存在一些问题,通过分析 Oops 信息,你就有可能快速找到问题所在,然后进行修复。
二、Linux内核三种异常
内核异常的级别大致分为三个:BUG、oops、panic。
2.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 */
注意最后的 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)
#endif
也就是在 arm64 架构中 BUG() 和 BUG_ON() 都是执行的 panic()。而对于 arm 32位架构来说,BUG() 会向CPU 下发一条未定义指令而触发ARM 发起未定义指令异常,随后进入 kernel 异常处理流程,通过调用die() 经历Oops 和 panic。
2.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);
}
通过 die() 会进行oops 异常处理,详细的 die() 函数流程看第 3 节。当出现 oops,并且如果有源码,可以通过 arm 的 arch64-linux-gnu-objdump 工具看到出错的函数的汇编情况,也可以通过 GDB 工具分析。如果出错的地方为内核函数,可以使用 vmlinux 文件。
如果没有源码,对于没有编译符号表的二进制文件,可以使用:
arch64-linux-gnu-objdump -d oops.ko
命令来转储 oops.ko 文件内核也提供了一个非常好用的脚本,可以快速定位问题,该脚本位于 Linux 源码目录下的 scripts/decodecode 中,会把出错的 oops 日志信息转换成直观有用的汇编代码,并且告知具体出错的汇编语句,这对于分析没有源码的 oops 错误非常有用。
die()函数
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);
}
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;
}
打印 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 we're 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);
}
(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");
...
}
处于中断或panic_on_oops 打开时进入 panic。
中断的可能性:
-
硬件 IRQ;
-
软件 IRQ;
-
NMI;
panic_on_oops 的值受 CONFIG_PANIC_ON_OOPS_VALUE 影响。当然该值也可以通过节点/proc/sys/kernel/panic_on_oops 进行动态修改。
2.3panic
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 can't 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);
详细信息见代码注释。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);
}
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:打印串口所有信息;
三、Oops产生的原因
Oops 的出现绝非偶然,背后有着多种复杂的因素,下面我们就来深入剖析一下常见的引发原因。
内存访问错误:这是导致 Oops 的常见原因之一,就好比你去图书馆借书,却拿着一个错误的书架编号去找书,肯定找不到,还可能引发混乱。在 Linux 内核中,内存访问错误通常表现为访问了非法的内存地址 ,比如空指针解引用。当代码试图访问一个被定义为 NULL 的指针时,就像试图从一个不存在的地方获取数据,必然会触发 Oops。
例如,在一个简单的内核模块中,如果有这样一段代码:
#include <linux/module.h>
#include <linux/kernel.h>
static int __init oops_demo_init(void)
{
int *ptr = NULL;
*ptr = 10; // 空指针解引用,会引发Oops
return 0;
}
static void __exit oops_demo_exit(void)
{
printk(KERN_INFO "Module removed\n");
}
module_init(oops_demo_init);
module_exit(oops_demo_exit);
MODULE_LICENSE("GPL");
当这个模块被加载到内核中时,就会因为空指针解引用而产生 Oops。
-
非法操作:计算机的运行就像一场严格遵循规则的游戏,CPU 指令也有自己的规则,执行非法的 CPU 指令或操作,就如同在游戏中违反规则,会导致严重的后果,Oops 便可能随之而来。比如,在某些情况下,代码可能会尝试执行未定义的指令,或者对某些特殊的寄存器进行不恰当的操作。假设内核代码中出现了这样的指令序列,尝试执行一个在当前 CPU 架构下未定义的操作码,这就会触发非法操作错误,进而引发 Oops。
-
内核模块中的 bug:内核模块就像是一个个插件,为内核增添了各种功能,但如果这些插件本身存在问题,就会给内核带来麻烦。当加载或卸载内核模块时,很可能触发一些潜在的 bug。例如,在模块的初始化函数中,如果没有正确地分配和初始化资源,或者在卸载函数中没有正确地释放资源,都可能导致 Oops。比如一个简单的字符设备驱动模块,在初始化时没有正确设置设备号,当其他部分的代码尝试通过这个错误的设备号访问设备时,就可能引发 Oops。
-
资源竞争:在多任务处理的环境中,就像多个工人同时使用有限的工具,如果没有合理的协调,就容易出现冲突。内核中的资源竞争也是如此,并发访问导致的竞态条件常常引发 Oops。比如,多个内核线程同时访问和修改共享资源,而没有进行适当的同步处理,就可能导致数据不一致或其他错误,最终引发 Oops。假设有两个内核线程同时对一个共享的全局变量进行读写操作,没有使用锁机制进行同步,就可能出现一个线程读取到的数据是另一个线程未完全修改完成的数据,从而导致错误。
-
硬件问题:硬件是计算机系统的基础,就像房子的地基,如果硬件出现故障,如损坏的内存、硬盘或不兼容的硬件,也可能导致内核出现错误,进而产生 Oops。例如,内存中的某个存储单元出现了物理损坏,当内核访问该内存地址时,就可能引发内存错误,导致 Oops。又或者新安装的硬件与现有系统不兼容,在驱动程序与硬件交互的过程中,可能会产生各种意想不到的错误,引发 Oops。
四、调试前的准备工作
在正式开始调试 Linux 内核 Oops 之前,我们需要做好充分的准备工作,就像建造高楼前要打好坚实的地基一样,这些准备工作对于后续顺利解决问题至关重要。
4.1确认环境
明确问题:首先,必须有一个被确认的 bug。这就好比医生看病,得先明确病人的症状,我们要清楚地知道系统出现了哪些异常表现,是频繁死机、某个功能无法正常使用,还是出现了特定的错误提示等。只有明确了问题,才能有的放矢地进行调试。
确定内核版本:包含这个 bug 的内核版本号也非常关键,最好能分析出这个 bug 在哪一个版本被引入 。这就像追踪病毒的传播源头,知道了 bug 是从哪个版本开始出现的,对于解决问题有极大的帮助。我们可以采用二分查找法来逐步锁定 bug 引入版本号,通过不断缩小范围,最终找到问题的根源。
理解内核代码:对内核代码理解越深刻越好,调试内核就像是在一个复杂的迷宫中寻找出口,对内核代码的熟悉程度决定了你在这个迷宫中的导航能力。同时,调试过程中也需要一点点运气,毕竟有些问题可能隐藏得非常深,需要花费大量的时间和精力去排查。
复现问题:该 bug 可以复现是非常重要的一点。如果能够找到复现规律,那么离找到问题的原因就不远了。就像做科学实验,只有能够重复得到相同的结果,才能进一步深入研究。比如,某个操作序列会导致 Oops 出现,我们就可以通过不断重复这个操作序列,来观察和分析问题。
4.2最小化系统
最小化系统是调试过程中的一个重要策略。我们要把可能产生 bug 的因素逐一排除掉,只保留最基本的组件和驱动 ,就像清理杂物,只留下最关键的物品,这样可以让问题更加清晰地暴露出来。例如,如果我们怀疑某个特定的硬件驱动导致了 Oops,就可以先卸载该驱动,然后观察系统是否还会出现问题。通过这样的方式,我们可以逐步缩小问题的范围,确定问题所在。
4.3安装调试工具
调试工具是我们解决问题的有力武器,以下是一些常用的调试工具及其作用:
GDB:GDB(GNU Debugger)是一款功能强大的调试器,它可以帮助我们在程序运行时进行调试,查看变量的值、跟踪函数调用等。在内核调试中,GDB 可以用于加载内核镜像和符号表,通过设置断点、单步执行等操作,深入分析内核代码的执行过程,找出导致 Oops 的原因。比如,当我们怀疑某个内核函数中的某一行代码存在问题时,就可以使用 GDB 在该行代码处设置断点,然后运行内核,当程序执行到断点时,就可以查看此时的变量值和寄存器状态,从而判断问题所在。
objdump:objdump 是一个反汇编工具,它可以将二进制文件(如内核镜像 vmlinux)反汇编成汇编代码。通过查看反汇编代码,我们可以了解程序的执行流程,找到与 Oops 相关的代码段。例如,当 Oops 信息中给出了错误发生的地址时,我们可以使用 objdump 反汇编内核镜像,找到该地址对应的汇编代码,进而分析问题。
readelf:readelf 用于显示 ELF(Executable and Linkable Format)格式文件的信息,ELF 格式是 Linux 系统中常用的可执行文件、目标文件和共享库文件的格式。通过 readelf,我们可以查看文件的头信息、节头信息、符号表等,这些信息对于理解程序的结构和功能非常有帮助,在调试过程中,有助于我们更好地分析问题。比如,我们可以使用 readelf 查看内核模块的符号表,了解模块中函数和变量的地址,以便在调试时进行定位。
五、内核调试配置选项
学习编写驱动程序要构建安装自己的内核(标准主线内核)。最重要的原因之一是:内核开发者已经建立了多项用于调试的功能。但是由于这些功能会造成额外的输出,并导致能下降,因此发行版厂商通常会禁止发行版内核中的调试功能。
5.1开启调试功能
为了更好地调试 Linux 内核 Oops,我们需要在内核配置中开启一些关键的调试功能。这些功能就像是给黑暗中的探索者点亮了一盏明灯,能帮助我们更清晰地看到内核运行中的问题。在内核配置上增加了几项:
Kernel hacking --->
启用选项例如:
slab layer debugging(slab层调试选项)
首先,我们要增加 Magic SysRq key 选项。Magic SysRq key 是一个非常强大的调试工具,它就像是一个万能钥匙,能够在系统出现问题时,提供各种有用的调试信息 。比如,当系统出现死机等异常情况时,我们可以通过按下特定的组合键(通常是 Alt + SysRq + 某个字母)来触发 Magic SysRq key 的功能,获取系统的各种状态信息,如内存使用情况、进程列表、寄存器状态等,这些信息对于我们分析问题非常有帮助。要启用 Magic SysRq key,我们可以在配置内核时,将 CONFIG_MAGIC_SYSRQ 设置为 Y。
对于支持 SysRq 的内核,我们还可以通过修改 /proc/sys/kernel/sysrq 文件来控制它的启用与否 。如果该文件内容为 0,则 SysRq 被禁用;如果内容为 1,则 SysRq 被启用。我们也可以通过运行命令echo "0" > /proc/sys/kernel/sysrq和echo "1" > /proc/sys/kernel/sysrq来暂时启用或禁用 SysRq。如果需要永久启用或者禁用 SysRqs,则可在 /etc/sysctl.conf 中设置kernel.sysrq =1(启用 SsyRq)或kernel.sysrq = 0(禁用 SysRq) 。
其次,Kernel debugging 选项也至关重要。开启这个选项后,内核会记录更多详细的调试信息,就像给内核安装了一个监控摄像头,能捕捉到更多运行过程中的细节 。它可以帮助我们追踪内核代码的执行流程,查看变量的值,以及发现潜在的问题。我们可以在内核配置工具中,找到 Kernel hacking 选项,然后勾选 Kernel debugging,将其启用。
另外,Compile the kernel with debug info 选项也不容忽视。这个选项会让内核在编译时包含调试信息,就像在地图上标注出各个地点的详细信息,方便我们在调试时定位问题 。这些调试信息包括符号表、行号信息等,对于使用 GDB 等调试工具进行内核调试非常关键。在配置内核时,我们可以进入 Kernel hacking -> Compile-time checks and compiler options,然后勾选 Compile the kernel with debug info,确保内核编译时包含调试信息。
5.2配置原子操作调试
从内核 2.5 开发,为了检查各类由原子操作引发的问题,内核提供了极佳的工具。内核提供了一个原子操作计数器,它可以配置成,一旦在原子操作过程中,进城进入睡眠或者做了一些可能引起睡眠的操作,就打印警告信息并提供追踪线索。所以,包括在使用锁的时候调用 schedule (),正使用锁的时候以阻塞方式请求分配内存等,各种潜在的 bug 都能够被探测到。
下面这些选项可以最大限度地利用该特性:
CONFIG_PREEMPT = y
原子操作在 Linux 内核中起着关键作用,它确保了某些操作的原子性,即这些操作要么完全执行,要么完全不执行,不会被其他操作打断 。然而,原子操作中也可能出现各种问题,为了调试这些问题,我们需要进行一些特定的配置。
CONFIG_PREEMPT 选项是一个重要的配置项,它用于控制内核的抢占行为 。当 CONFIG_PREEMPT 被设置为 y 时,内核支持抢占式调度,这意味着在某些情况下,内核可以暂停当前正在执行的任务,转而执行其他更高优先级的任务。在原子操作调试中,启用抢占式调度可以帮助我们发现一些与任务调度相关的问题,比如在原子操作过程中,任务被抢占后可能出现的竞态条件等。例如,在多核系统中,如果一个原子操作在执行过程中被其他核上的任务抢占,可能会导致共享资源的访问冲突,通过启用 CONFIG_PREEMPT,我们可以更容易地发现这类问题。
CONFIG_DEBUG_KERNEL 选项也是必不可少的 。它使得其他的调试选项可用,就像打开了一个调试工具库的大门,让我们可以使用更多的调试功能 。虽然它本身不会打开所有的调试功能,但它是许多其他调试选项的基础。当我们想要调试原子操作时,打开 CONFIG_DEBUG_KERNEL 是一个重要的前提。
CONFIG_KALLSYMS 选项会在内核中包含符号信息 。这些符号信息就像是内核代码的索引,在调试过程中非常有用。在原子操作调试中,符号信息可以帮助我们准确地定位到问题所在的函数和代码行。比如,当我们分析 Oops 信息时,如果内核中包含了符号信息,我们就可以直接看到导致错误的函数名和变量名,而不是一堆难以理解的地址,这大大提高了我们调试的效率。
CONFIG_SPINLOCK_SLEEP 选项则用于检查在持有自旋锁时的休眠企图 。自旋锁是一种用于保护共享资源的同步机制,它的特点是当一个线程尝试获取自旋锁时,如果锁已经被占用,它会一直循环等待,而不会进入睡眠状态 。然而,如果在持有自旋锁时调用了可能会导致睡眠的函数,就会出现问题。CONFIG_SPINLOCK_SLEEP 选项可以帮助我们检测到这类问题,当检测到在持有自旋锁时调用了可能导致睡眠的函数时,内核会打印警告信息并提供追踪线索,让我们能够及时发现和解决问题。
六、分析Oops信息
当 Linux 内核出现 Oops 时,其输出的信息犹如一座蕴含丰富宝藏的矿山,通过深入挖掘这些信息,我们便能找到解决问题的关键线索。接下来,让我们详细剖析一下如何解读这些重要的信息。
6.1错误类型
Oops 信息的开头部分往往是错误类型的描述,这是我们理解问题的重要线索。例如,“Unable to handle kernel NULL pointer dereference” 明确表示这是内核空指针解引用错误 。这就好比在一个工厂里,工人拿着一个没有指向任何实际物品的指针(空指针)去操作,肯定会引发问题。在这种情况下,我们需要重点检查代码中对指针的使用,是否在使用指针前没有进行有效的初始化,或者在指针可能变为 NULL 的情况下没有进行合理的判断和处理 。
再比如,“kernel BUG at some_file.c:123” 这样的错误信息,说明内核在 some_file.c 文件的第 123 行检测到了一个逻辑错误 。这就像是在建造房子时,按照设计图纸施工到某一步时,发现这一步的设计存在问题,导致无法继续正常施工。遇到这种错误,我们需要仔细查看对应的代码行,分析代码逻辑,找出错误的根源。
6.2寄存器值
在 Oops 信息中,寄存器值是非常关键的部分,它们记录了错误发生时 CPU 的运行状态 。
PC(Program Counter)即程序计数器,它记录了下一条将要执行的指令的地址 。在 Oops 信息中,我们可以看到类似 “PC is at some_function+0x10/0x100” 这样的信息,这表示错误发生时,PC 指向了 some_function 函数内偏移 0x10 的位置,而该函数的总大小为 0x100 。通过这个信息,我们可以快速定位到出错的代码位置,就像在地图上找到了一个具体的坐标。例如,如果我们使用的是 GDB 调试工具,就可以通过这个地址信息在对应的内核源代码中设置断点,进一步分析代码的执行情况。
LR(Link Register)是链接寄存器,用于保存子程序或者中断的返回地址 。当一个函数调用另一个函数时,LR 会保存调用函数的下一条指令的地址,以便在被调用函数执行完毕后能够返回到正确的位置继续执行 。在 Oops 信息中,查看 LR 的值可以帮助我们了解函数的调用关系,比如 “LR is at another_function+0x20/0x200”,这表明当前函数是从 another_function 函数调用过来的,并且返回地址位于 another_function 函数内偏移 0x20 的位置。
SP(Stack Pointer)是堆栈指针,它始终指向栈的下一个空闲空间的地址 。在函数调用过程中,SP 用于存储函数的返回地址、参数和局部变量等 。通过分析 Oops 信息中的 SP 值,我们可以了解到堆栈的使用情况,比如是否存在堆栈溢出的问题。如果 SP 的值异常,比如指向了一个非法的内存地址,就可能意味着堆栈出现了错误,需要进一步检查堆栈的操作是否正确。
6.3堆栈回溯
堆栈回溯信息是 Oops 信息中最有价值的部分之一,它展示了函数调用的顺序和层次,就像一个家族树,清晰地呈现了各个函数之间的关系 。
在 Oops 信息中,堆栈回溯信息通常以 “Backtrace” 开头,后面跟着一系列的函数调用信息 。例如:
Backtrace:
[] (function1+0x0/0x100) from [] (function2+0x20/0x200)
[] (function2+0x0/0x200) from [] (function3+0x30/0x300)
这表示 function3 函数调用了 function2 函数,而 function2 函数又调用了 function1 函数 。通过分析这个调用链,我们可以从出错的函数开始,逐步回溯到它的调用者,从而找出错误是从哪里开始引入的 。就像追踪一条河流的源头,从河流的下游(出错函数)开始,沿着河流(函数调用链)向上游(调用者)追溯,最终找到问题的根源。
在实际调试中,堆栈回溯信息可以帮助我们确定错误发生的上下文 。比如,如果在某个驱动程序的函数调用过程中出现了 Oops,通过堆栈回溯信息,我们可以了解到这个驱动程序是如何被调用的,以及它与其他内核组件之间的交互情况,从而更准确地定位问题所在 。
七、常用调试方法
7.1printk 函数
printk 函数是 Linux 内核中极为常用的调试工具,它就像是内核的 “嘴巴”,能够输出各种调试信息 。其健壮性是一大显著优势,几乎在任何情况下,内核都可以调用它 。无论是在中断上下文,还是进程上下文,亦或是持有锁时、多处理器处理时,printk 都能正常工作。例如,在一个中断处理函数中,我们可以使用 printk 来输出中断发生时的一些关键信息,帮助我们了解中断的触发原因和处理过程。
不过,printk 也并非十全十美,它存在一些脆弱之处 。在系统启动过程中,终端初始化之前,printk 在某些地方是不能调用的。这就好比一个人在还没学会说话之前,是无法表达自己的想法的。如果此时确实需要调试信息,我们可以采用其他替代方法,比如使用串口调试,将调试信息输出到其他终端设备;或者使用 early_printk () 函数,该函数在系统启动初期就具备打印能力,但它只支持部分硬件体系 。
printk 和 printf 的一个主要区别在于,printk 可以指定一个 LOG 等级 。内核会根据这个等级来判断是否在终端上打印消息,只有比指定等级高的消息才会显示在终端 。我们可以通过以下方式指定 LOG 级别,例如:printk(KERN_CRIT "Hello, world!\n"); ,这里的 KERN_CRIT 是一个日志等级,它表示 “critical conditions”,即关键条件。
LOG 等级
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 */
注意,如果调用者未将日志级别提供给 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
10
第一项定义了 printk API 当前使用的日志级别。这些日志级别表示了控制台的日志级别、默认消息日志级别、最小控制台日志级别和默认控制台日志级别。printk_delay 值表示的是 printk 消息之间的延迟毫秒数(用于提高某些场景的可读性)。
注意,这里它的值为 0,而它是不可以通过 /proc 设置的。printk_ratelimit 定义了消息之间允许的最小时间间隔(当前定义为每 5 秒内的某个内核消息数)。消息数量是由 printk_ratelimit_burst 定义的(当前定义为 10)。
如果您拥有一个非正式内核而又使用有带宽限制的控制台设备(如通过串口), 那么这非常有用。注意,在内核中,速度限制是由调用者控制的,而不是在 printk 中实现的。
如果一个 printk 用户要求进行速度限制,那么该用户就需要调用 printk_ratelimit 函数。
注意,第一个参数并不是真正的参数,因为其中没有用于分隔级别(KERN_CRIT)和格式字符的逗号(,) 。KERN_CRIT 本身只是一个普通的字符串(事实上,它表示的是字符串 "<2>"),作为预处理程序的一部分,C 会自动地使用字符串串联的功能将这两个字符串组合在一起 。内核使用这个指定的 LOG 级别与当前终端 LOG 等级 console_loglevel 来决定是否向终端打印 。
常见的 LOG 等级从高到低依次为:KERN_EMERG(系统不可用)、KERN_ALERT(必须立即采取行动)、KERN_CRIT(关键条件)、KERN_ERR(错误条件)、KERN_WARNING(警告条件)、KERN_NOTICE(正常但重要的条件)、KERN_INFO(信息性消息)、KERN_DEBUG(调试级消息) 。如果调用者未将日志级别提供给 printk,那么系统就会使用默认值 KERN_WARNING "<4>" ,表示只有 KERN_WARNING 级别以上的日志消息会被记录 。
(2)记录缓冲区
内核消息都被保存在一个 LOG_BUF_LEN 大小的环形队列中。关于 LOG_BUF_LEN 定义:
#define __LOG_BUF_LEN (1 << CONFIG_LOG_BUF_SHIFT)
※ 变量 CONFIG_LOG_BUF_SHIFT 在内核编译时由配置文件定义,对于 i386 平台,其值定义如下(在 linux26/arch/i386/defconfig 中):
CONFIG_LOG_BUF_SHIFT=18
记录缓冲区操作:① 消息被读出到用户空间时,此消息就会从环形队列中删除。② 当消息缓冲区满时,如果再有 printk () 调用时,新消息将覆盖队列中的老消息。③ 在读写环形队列时,同步问题很容易得到解决。
※ 这个纪录缓冲区之所以称为环形,是因为它的读写都是按照环形队列的方式进行操作的。
(3)syslogd/klogd
在标准的 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 操作确定缓冲区大小
7.2引发 bug并打印信息
BUG () 和 BUG_ON () 是 Linux 内核中用于标记 bug 和提供断言的重要工具 。它们定义在<include/asm-generic>中,具体定义如下:
#ifndef HAVE_ARCH_BUG
#define BUG() do {printk("BUG: failure at %s:%d/%s()! ", __FILE__, __LINE__, __FUNCTION__);panic("BUG!"); } while(0)
#endif
#ifndef HAVE_ARCH_BUG_ON
#define BUG_ON(condition) do { if (unlikely(condition)) BUG(); } while(0)
#endif
当调用这两个宏的时候,它们会引发 OOPS,导致栈的回溯和错误消息的打印 。这就像是在黑暗中点亮了一盏明灯,帮助我们快速定位到问题所在 。我们可以把这两个调用当作断言使用,例如:BUG_ON(bad_thing); ,这里的 bad_thing 是一个条件表达式,如果这个条件为真,就会触发 BUG (),进而引发 Oops,输出详细的错误信息,包括错误发生的文件、行号和函数名等 。
通过分析这些信息,我们能够迅速定位到代码中出现问题的位置,就像在地图上找到了错误的坐标 。例如,在一个内核模块中,如果我们怀疑某个指针在特定情况下可能为 NULL,就可以使用BUG_ON(ptr == NULL); 来进行断言,如果指针确实为 NULL,就会触发 Oops,帮助我们发现这个潜在的问题 。
7.3dump_stack ()
有些时候,我们只需要在终端上打印一下栈的回溯信息来帮助调试,这时就可以使用 dump_stack () 函数 。这个函数就像是一个记录员,它只在终端上打印寄存器上下文和函数的跟踪线索 。例如,当我们怀疑某个函数的调用过程存在问题时,在该函数中调用 dump_stack (),它会输出当前进程的栈回溯信息,包括当前执行代码的位置、相邻的指令、产生错误的原因、关键寄存器的值以及函数调用关系等 。这些信息对于我们理解内核代码的执行流程,定位问题所在非常有帮助 。
比如,在一个复杂的驱动程序中,函数之间的调用关系可能比较复杂,当出现问题时,通过 dump_stack () 输出的信息,我们可以清晰地看到函数的调用顺序和层次,从而找到问题的根源 。其工作原理是通过遍历堆栈,找到所有可能是内核函数的内容,并打印对应的函数 。函数调用时会把下一条指令的地址放到堆栈中,因此只要找到这些返回地址,就可以找到它们所在的函数,进而打印出函数的调用关系 。
具体来说,dump_stack () 函数会从当前进程的栈中找到当前函数的返回地址,根据函数返回地址,从代码段中定位该地址位于哪个函数中,找到的函数即为调用者(caller)函数,然后打印 caller 函数的函数名,重复以上步骤,直到返回值为 0 或不在内核记录的符号表范围内 。
7.4GDB 调试
GDB 是一款功能强大的调试工具,在 Linux 内核 Oops 调试中发挥着重要作用 。下面我们通过一个实际示例来演示如何使用 GDB 进行调试 。
假设我们有一个简单的内核模块,在加载过程中出现了 Oops 。首先,我们需要确保内核在编译时包含调试信息,这可以通过在配置内核时勾选 “Compile the kernel with debug info” 选项来实现 。
加载调试信息。启动 GDB,并加载内核镜像和符号表 。例如,使用命令gdb vmlinux,这里的 vmlinux 是内核镜像文件 。加载完成后,GDB 就可以利用符号表来解析内核代码中的函数名、变量名等信息 。
设置断点。根据 Oops 信息中提供的错误发生地址,我们可以在 GDB 中设置断点 。比如,Oops 信息显示错误发生在some_function+0x10/0x100,我们可以使用命令b *some_function+10来设置断点 。这里的b是 break 的缩写,表示设置断点,*表示后面跟着的是一个地址 。
单步执行。设置好断点后,我们可以使用r命令来运行内核,当程序执行到断点处时,会暂停下来 。此时,我们可以使用n命令(next 的缩写)进行单步执行,每次执行一条语句,观察程序的执行流程和变量的变化 。如果遇到函数调用,我们可以使用s命令(step 的缩写)进入函数内部,查看函数内部的执行情况 。
查看变量值。在程序暂停时,我们可以使用p命令(print 的缩写)来查看变量的值 。例如,p variable_name可以查看名为variable_name的变量的值 。这对于我们判断程序的执行逻辑是否正确非常有帮助 。通过这些操作,我们可以逐步分析内核代码的执行过程,找到导致Oops的原因 。例如,如果发现某个变量的值在某个时刻出现了异常,我们就可以进一步检查相关的代码逻辑,找出问题所在 。
八、实战案例分析
8.1案例背景
在一个基于 Linux 内核的嵌入式系统开发项目中,该系统主要用于工业自动化控制,涉及大量的设备驱动和实时数据处理。当系统运行一段时间后,出现了内核 Oops 问题,导致系统不稳定,部分设备无法正常工作。
出现 Oops 时的相关信息如下:
[ 1234.567890] Unable to handle kernel NULL pointer dereference at virtual address 00000000
[ 1234.567900] pgd = c0004000
[ 1234.567910][00000000] *pgd=00000000
[ 1234.567920] Internal error: Oops:817 [#1] PREEMPT SMP
[ 1234.567930] last sysfs file: /sys/class/net/eth0/speed
[ 1234.567940] module: my_device_driver 0x80000000
[ 1234.567950] CPU:0 Tainted: P (4.19.10 #1)
[ 1234.567960] PC is at device_io_function+0x20/0x100 [my_device_driver]
[ 1234.567970] LR is at another_function+0x1c/0x80 [my_device_driver]
[ 1234.567980] pc : [c0123450] lr : [c012344c] psr: 60000113
[ 1234.567990] sp : c0567890 ip : c05678a0 fp : c05678b0
[ 1234.568000] r10:00000014 r9 : 4adec78d r8 : 00000006
[ 1234.568010] r7 :00000000 r6 : 0000003a r5 : 0000003a r4 : 00000060
[ 1234.568020] r3 :00000000 r2 : 00000204 r1 : 00000001 r0 : 0000003c
[ 1234.568030] Flags: nZCv IRQs on FIQs onMode SVC_32 ISA ARM Segment kernel
[ 1234.568040] Control: 10c53c7d Table: 4fb5004a DAC: 00000017
[ 1234.568050] Process my_process (pid:123, stack limit = 0xc0566000)
[ 1234.568060] Stack: (0xc0567890 to 0xc0568000)
[ 1234.568070] 7880: ce2ce900 c0543cf400000000 ceb4c400000010cc c8f9b5d80000000000000000
[ 1234.568080] 78a0:00000001 cd469200 c8f9b5d800000000 ce2ce8bc 000000060000002600000010
[ 1234.568090] [c0123450] (device_io_function+0x20/0x100 [my_device_driver]) from [c0543cf4] (another_function+0x3f8/0x400 [my_device_driver])
[ 1234.568100] [c0543cf4] (another_function+0x3f8/0x400 [my_device_driver]) from [bf11a8f8] (third_function+0x2b4/0x3dc [my_device_driver])
8.2分析过程
(1)错误类型分析:从 “Unable to handle kernel NULL pointer dereference at virtual address 00000000” 可以明确这是一个内核空指针解引用错误,即代码尝试访问一个空指针,这是导致 Oops 的直接原因。
(2)寄存器值分析:
-
-
PC(程序计数器)“PC is at device_io_function+0x20/0x100 [my_device_driver]” 表明错误发生在 my_device_driver 模块的 device_io_function 函数内,偏移量为 0x20 的位置,该函数总大小为 0x100 。这就像在一本书中,告诉我们问题出在某一章的某一页。
-
LR(链接寄存器)“LR is at another_function+0x1c/0x80 [my_device_driver]” 显示当前函数是从 another_function 函数调用过来的,返回地址位于 another_function 函数内偏移 0x1c 的位置,该函数大小为 0x80 。通过这个信息,我们可以了解函数之间的调用关系,为进一步分析提供线索。
-
(3)堆栈回溯分析:
-
堆栈回溯信息 “[c0123450] (device_io_function+0x20/0x100 [my_device_driver]) from [c0543cf4] (another_function+0x3f8/0x400 [my_device_driver])” 表明 another_function 函数调用了 device_io_function 函数,而错误就发生在 device_io_function 函数中。
-
“[c0543cf4] (another_function+0x3f8/0x400 [my_device_driver]) from [bf11a8f8] (third_function+0x2b4/0x3dc [my_device_driver])” 进一步说明 third_function 函数调用了 another_function 函数。通过这样的调用链分析,我们可以从出错的函数开始,逐步回溯到它的调用者,从而找出错误是从哪里开始引入的。
8.3解决方法
(1)定位问题代码:根据前面的分析,我们将重点放在 my_device_driver 模块的 device_io_function 函数中。通过查看该函数的源代码,发现是在对一个设备指针进行操作前,没有进行有效的非空检查,导致空指针解引用。
(2)修改代码:在 device_io_function 函数中,对设备指针添加非空检查代码,如下:
int device_io_function(struct device *dev)
{
if (dev == NULL) {
printk(KERN_ERR "Device pointer is NULL\n");
return -EINVAL;
}
// 原有的设备操作代码
}
(3)重新编译和测试:修改代码后,重新编译 my_device_driver 模块,并将其加载到内核中进行测试。通过观察系统运行状态,检查是否还会出现 Oops 错误。同时,使用一些测试工具对设备的功能进行全面测试,确保设备能够正常工作。经过测试,系统不再出现 Oops 错误,设备也能稳定运行,说明问题得到了解决。
九、附录:内存调试工具
9.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 中的代码将分配两个 512 字节的内存块,然后指向第一个内存块的指针被设定为指向第二个内存块。结果,第二个内存块的地址丢失,从而产生了内存泄漏。现在我们编译清单 1 的 memwatch.c。下面是一个 makefile 示例:test1
gcc -DMEMWATCH -DMW_STDIO test1.c memwatch
c -o test1
当您运行 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 : 512
MEMWATCH 为您显示真正导致问题的行。如果您释放一个已经释放过的指针,它会告诉您。对于没有释放的内存也一样。日志结尾部分显示统计信息,包括泄漏了多少内存,使用了多少内存,以及总共分配了多少内存。
9.2YAMD
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
清单 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.
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);
}
您可以使用下面的命令来启动 YAMD:
./run-yamd /usr/src/test/test2/test2
清单 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.
MEMWATCH 和 YAMD 都是很有用的调试工具,它们的使用方法有所不同。对于 MEMWATCH,您需要添加包含文件 memwatch.h 并打开两个编译时间标记。对于链接(link)语句,YAMD 只需要 -g 选项。
9.3Electric Fence
多数 Linux 分发版包含一个 Electric Fence 包,不过您也可以选择下载它。Electric Fence 是一个由 Bruce Perens 编写的 malloc () 调试库。它就在您分配内存后分配受保护的内存。如果存在 fencepost 错误(超过数组末尾运行),程序就会产生保护错误,并立即结束。通过结合 Electric Fence 和 gdb,您可以精确地跟踪到哪一行试图访问受保护内存。ElectricFence 的另一个功能就是能够检测内存泄漏。
9.4strace
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)
= ?
清单 6 显示 ioctl 调用导致用来格式化分区的 mkfs 程序失败。ioctl BLKGETSIZE64 失败。( BLKGET-SIZE64 在调用 ioctl 的源代码中定义。) BLKGETSIZE64 ioctl 将被添加到 Linux 中所有的设备,而在这里,逻辑卷管理器还不支持它。因此,如果 BLKGETSIZE64 ioctl 调用失败,mkfs 代码将改为调用较早的 ioctl 调用;这使得 mkfs 适用于逻辑卷管理器。