【Linux内核模块】调试技巧

内核模块开发最让人头疼的不是写代码,而是调试 —— 代码编译通过了,加载后却要么没反应,要么直接让系统崩溃。这就像在黑屋子里修机器,看不见摸不着。其实内核调试有一套成熟的工具箱,掌握这些工具和技巧,就能给内核装个监控监控仪,让问题无所遁形。


目录

一、调试前的安全须知:别让系统崩溃

二、最基础也最常用:printk 打印日志

2.1 printk 的基本用法

2.2 控制日志输出

2.3 printk 的高级技巧

三、内核 Oops 分析:系统崩溃时的现场照片

3.1 认识 Oops 信息

3.2 定位 Oops 错误位置

3.3 常见 Oops 错误及原因

四、动态调试:按需开启的监控摄像头

五、内核调试器 kgdb:像 gdb 一样调试内核

5.1 搭建 kgdb 环境

5.2 使用 kgdb 调试模块 

5.3 kgdb 的优缺点

六、内存调试工具:检测内存泄漏和越界

6.1 kmemleak:检测内存泄漏

6.2 KASAN:检测内存越界

七、用户态调试工具:从外部观察模块行为

八、调试方法论:解决问题的步骤


一、调试前的安全须知:别让系统崩溃

内核模块调试有个特点:一旦出错可能直接导致系统死机,所以安全措施必须做好。就像拆弹专家要穿防爆服,咱们调试内核也得有防护措施。

1. 必备的调试环境

  • 虚拟机优先:90% 的内核调试应该在虚拟机里进行(推荐 VirtualBox 或 VMware),死机了重启就行
  • 多终端连接:用 SSH 或串口连接虚拟机,即使图形界面卡死,还能通过终端查看日志
  • 快照备份:调试前给虚拟机拍快照,搞崩了能快速恢复(血的教训!)

2. 调试的三不原则

  • 不要在生产环境调试新模块
  • 不要加载来源不明的模块
  • 调试时不要运行重要程序

二、最基础也最常用:printk 打印日志

如果只能选一个调试工具,那一定是printk。它就像医生用的听诊器,简单直接却能解决大部分问题。

2.1 printk 的基本用法

和用户态的printf类似,但多了个日志级别参数:

printk(KERN_INFO "模块初始化成功,当前状态: %d\n", status);

日志级别决定了消息是否显示以及存到哪里,常用的有:

  • KERN_EMERG:紧急情况(系统崩溃前消息)
  • KERN_ALERT:必须立即处理
  • KERN_CRIT:严重错误
  • KERN_ERR:错误信息
  • KERN_WARNING:警告信息
  • KERN_NOTICE:正常但重要的信息
  • KERN_INFO:普通信息(最常用)
  • KERN_DEBUG:调试信息(默认不显示)

2.2 控制日志输出

默认情况下,级别高于KERN_WARNING的消息才会显示到控制台。可以通过dmesg命令查看所有日志: 

dmesg | tail  # 查看最新的10条日志
dmesg -w      # 实时监控日志输出

临时调整日志级别(数值越小级别越高):

sudo echo 7 > /proc/sys/kernel/printk  # 显示所有级别日志(调试时用)

2.3 printk 的高级技巧

  • 添加模块名和函数名:方便定位日志来源 

printk(KERN_INFO "[MY_MODULE] %s: 设备已打开\n", __func__);

__func__是编译器内置宏,会自动替换为当前函数名

  • 条件编译调试信息:只在调试模式输出详细日志 

#ifdef DEBUG
#define DBG_PRINT(fmt, args...) printk(KERN_DEBUG "[DBG] %s: " fmt, __func__, ##args)
#else
#define DBG_PRINT(fmt, args...)
#endif

// 使用
DBG_PRINT("缓冲区大小: %d\n", buf_size);

编译时添加-DDEBUG参数启用调试日志

  • 避免日志刷屏:高频操作中限制日志输出

static int log_counter = 0;
if (log_counter % 1000 == 0) {  // 每1000次打印一次
    printk(KERN_INFO "已处理 %d 个请求\n", log_counter);
}
log_counter++;

三、内核 Oops 分析:系统崩溃时的现场照片

当模块代码有严重错误(如空指针访问),内核会产生 Oops 信息,这相当于系统崩溃时的现场照片,包含大量调试线索。

3.1 认识 Oops 信息

典型的 Oops 信息长这样: 

BUG: unable to handle kernel NULL pointer dereference at 0000000000000010
IP: [] my_module_write+0x16/0x50 [my_module]
PGD 80000001f8e7067 PUD 1f8e71067 PMD 0 
Oops: 0002 [#1] SMP PTI
CPU: 1 PID: 1234 Comm: insmod Tainted: G        W  OE     5.4.0-100-generic #101-Ubuntu
Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/29/2019
RIP: 0010:my_module_write+0x16/0x50 [my_module]
...
Call Trace:
 
 SyS_write+0x5f/0xe0
 do_syscall_64+0x57/0x190
 entry_SYSCALL_64_after_hwframe+0x44/0xa9
...
  • NULL pointer dereference:空指针引用错误
  • my_module_write+0x16/0x50:错误发生在my_module_write函数,偏移 0x16 处
  • Call Trace:函数调用栈,显示错误发生前的调用路径

3.2 定位 Oops 错误位置

addr2line工具将内存地址转换为代码行号: 

addr2line -e my_module.ko 0x16

会输出类似/home/user/my_module.c:42的结果,直接定位到出错的代码行。

3.3 常见 Oops 错误及原因

  • NULL pointer dereference:访问空指针(最常见)
  • use-after-free:使用已释放的内存
  • stack overflow:栈溢出
  • invalid opcode:非法指令(通常是汇编错误)

四、动态调试:按需开启的监控摄像头

内核的动态调试(Dynamic Debug)机制可以像开关灯一样控制特定代码的日志输出,不用重新编译模块。

1. 开启动态调试支持

首先确认内核支持动态调试(大部分发行版默认支持): 

grep CONFIG_DYNAMIC_DEBUG /boot/config-$(uname -r)

如果输出CONFIG_DYNAMIC_DEBUG=y,说明支持。

2. 动态调试的基本用法

通过/sys/kernel/debug/dynamic_debug/control文件控制日志输出: 

# 先挂载debugfs
sudo mount -t debugfs none /sys/kernel/debug

# 显示my_module.c中所有函数的调试信息
sudo echo 'file my_module.c +p' > /sys/kernel/debug/dynamic_debug/control

# 只显示特定函数的调试信息
sudo echo 'func my_module_write +p' > /sys/kernel/debug/dynamic_debug/control

# 关闭调试信息
sudo echo 'file my_module.c -p' > /sys/kernel/debug/dynamic_debug/control

3. 在代码中使用动态调试

在代码中用pr_debugdev_dbg代替printk(KERN_DEBUG): 

pr_debug("数据长度: %d\n", data_len);  // 动态调试支持的打印函数

这些函数默认不输出日志,只有通过动态调试开关启用后才会输出。

五、内核调试器 kgdb:像 gdb 一样调试内核

如果 printk 和 Oops 分析还不够,就需要kgdb—— 内核版的 gdb 调试器,支持断点、单步执行等高级调试功能。

5.1 搭建 kgdb 环境

kgdb 需要两台机器(或虚拟机)通过串口连接:

  • 目标机:运行待调试的内核和模块
  • 主机:运行 gdb,通过串口控制目标机

配置步骤(以虚拟机为例):

  1. 给目标虚拟机添加一个串口设备(如 /dev/ttyS0)
  2. 目标机内核启动参数添加:kgdboc=ttyS0,115200 kgdbwait(启动时等待调试连接)
  3. 主机通过screen连接串口:screen /dev/ttyS0 115200

5.2 使用 kgdb 调试模块 

# 在主机上启动gdb
gdb ./vmlinux  # vmlinux是带调试信息的内核镜像

# 连接目标机
(gdb) target remote /dev/ttyS0

# 设置断点(模块加载后)
(gdb) break my_module_init

# 查看变量
(gdb) print buffer_size

# 单步执行
(gdb) step

# 继续执行
(gdb) continue

5.3 kgdb 的优缺点

  • 优点:可以像调试用户态程序一样单步调试内核代码
  • 缺点:配置复杂,需要两台机器,调试过程会暂停整个系统

六、内存调试工具:检测内存泄漏和越界

内核模块最容易出内存问题,这些问题隐蔽性强,需要专门工具检测。

6.1 kmemleak:检测内存泄漏

kmemleak 可以跟踪内核内存分配,发现未释放的内存:

启用 kmemleak:

# 挂载debugfs
sudo mount -t debugfs none /sys/kernel/debug

# 手动触发内存泄漏检查
sudo echo scan > /sys/kernel/debug/kmemleak

# 查看内存泄漏报告
sudo cat /sys/kernel/debug/kmemleak

典型的内存泄漏报告:

unreferenced object 0xffff888123456780 (size 128):
  comm "insmod", pid 1234, jiffies 4567890 (age 30.000s)
  hex dump (first 32 bytes):
    00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f  ................
    10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f  ................
  backtrace:
    [] my_module_init+0x20/0x100 [my_module]
    [] do_one_initcall+0x50/0x220
    ...

报告会显示泄漏内存的地址、大小、分配位置,帮助定位问题。

6.2 KASAN:检测内存越界

KASAN(Kernel Address Sanitizer)能检测数组越界、使用已释放内存等错误,但需要使用带 KASAN 支持的内核:

# 查看内核是否支持KASAN
grep CONFIG_KASAN /boot/config-$(uname -r)

当检测到内存错误时,会输出详细报告:

==================================================================
BUG: KASAN: out-of-bounds in my_module_write+0x30/0x50 [my_module]
Write of size 4 at addr ffff88812345678c by task insmod/1234

CPU: 1 PID: 1234 Comm: insmod Tainted: G        W  OE     5.4.0-100-generic #101-Ubuntu
Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/29/2019
Call Trace:
 
 __dump_stack+0x70/0xa0
 ...
Allocated by task 1234:
 my_module_init+0x20/0x100 [my_module]
 do_one_initcall+0x50/0x220
 ...
==================================================================

七、用户态调试工具:从外部观察模块行为

除了内核态工具,还有一些用户态工具可以帮助观察模块的行为。

1. lsmod 和 modinfo:查看模块信息 

lsmod  # 查看所有加载的模块及使用计数
lsmod | grep my_module  # 查看特定模块
modinfo my_module.ko  # 查看模块详细信息(版本、作者、依赖等)

2. proc 和 sys 文件系统:模块状态接口

在模块中创建 proc 或 sys 接口,暴露内部状态:

创建 proc 文件示例

#include 
#include 

static int my_proc_show(struct seq_file *m, void *v) {
    seq_printf(m, "当前连接数: %d\n", conn_count);
    seq_printf(m, "缓冲区使用率: %d%%\n", buf_usage);
    return 0;
}

static int my_proc_open(struct inode *inode, struct file *file) {
    return single_open(file, my_proc_show, NULL);
}

static const struct file_operations my_proc_fops = {
    .owner = THIS_MODULE,
    .open = my_proc_open,
    .read = seq_read,
    .llseek = seq_lseek,
    .release = single_release,
};

// 在初始化函数中创建
proc_create("my_module_stats", 0, NULL, &my_proc_fops);

用户态查看:

cat /proc/my_module_stats

3. perf:性能分析工具

perf可以分析模块的性能瓶颈: 

# 记录模块的函数调用情况
sudo perf record -g -e 'module:my_module:*' sleep 10

# 查看报告
sudo perf report

八、调试方法论:解决问题的步骤

掌握工具后,更重要的是形成一套调试思路。遇到问题时可以按这个步骤排查:

①复现问题:明确触发条件,确保问题可重复

②缩小范围:通过注释代码或添加日志,定位问题所在的大致范围

③针对性调试

  • 功能问题:用 printk 打印关键变量值
  • 崩溃问题:分析 Oops 信息
  • 内存问题:用 kmemleak 和 KASAN 检测
  • 性能问题:用 perf 分析

④验证修复:确认问题解决,且没有引入新问题

调试 checklist

  •  模块是否正确加载?(lsmod 检查)
  •  有没有 Oops 信息?(dmesg 查看)
  •  关键变量的值是否符合预期?(printk 输出)
  •  内存分配和释放是否配对?(检查 kmalloc 和 kfree)
  •  函数返回值是否正确处理?(是否检查错误码)

内核模块调试确实有难度,但只要掌握了正确的工具和方法,大部分问题都能解决。记住:

  1. 从简单工具开始:先用 printk 和 dmesg 解决 80% 的问题
  2. 善用系统提供的调试机制:动态调试、kmemleak 等内核自带工具
  3. 复杂问题才需要 kgdb:简单问题用高级工具反而效率低
  4. 安全第一:始终在虚拟机中调试,做好快照备份

调试能力是区分内核开发者水平的关键指标。刚开始可能会觉得挫败,但每解决一个调试难题,你的内核开发水平就会上一个台阶。就像医生通过不断积累病例提高诊断能力,内核开发者也是在一次次调试中成长的。


你可能感兴趣的:(#,嵌入式Linux驱动开发实战,linux)