文章目录
- [1. 引言](#1. 引言)
- [2. 调试工具箱](#2. 调试工具箱)
- [3. 基础日志](#3. 基础日志)
-
- [3.1 规范化输出](#3.1 规范化输出)
- [3.2 频控打印](#3.2 频控打印)
- [3.3 动态开关](#3.3 动态开关)
-
- [3.3.1 开启与关闭](#3.3.1 开启与关闭)
- [3.3.2 加载时开启](#3.3.2 加载时开启)
- [3.4 数据缓冲区打印](#3.4 数据缓冲区打印)
- [3.5 断言](#3.5 断言)
- [4. debugfs](#4. debugfs)
-
- [4.1 代码示意](#4.1 代码示意)
- [4.2 实时监控](#4.2 实时监控)
- [5. trace_printk](#5. trace_printk)
-
- [5.1 架构对比](#5.1 架构对比)
- [5.2 使用方法](#5.2 使用方法)
- [6. devmem](#6. devmem)
- [7. Oops](#7. Oops)
-
- [7.1 解读 Oops](#7.1 解读 Oops)
- [7.2 快速定位](#7.2 快速定位)
-
- [7.2.1 decode_stacktrace.sh](#7.2.1 decode_stacktrace.sh)
- [7.2.2 faddr2line](#7.2.2 faddr2line)
- [7.3 手动定位](#7.3 手动定位)
- [8. KASAN](#8. KASAN)
- [9. 总结](#9. 总结)
1. 引言
驱动开发的真正挑战往往不在于编写代码,而在于调试(Debugging)。
在用户态程序开发中,拥有 GDB、IDE 断点和 printf 等简单方便的调试工具;但在内核态开发中,环境变得严苛且不透明。以下是内核驱动开发中常见的一些问题:
- 模块加载失败 :
insmod报错,但是不知错在哪里。 - Probe 未触发:确认代码写的没问题,但是驱动入口函数就是不执行。
- 数据异常 :I2C/SPI 读回来的数据全是
0xFF或乱码。 - 中断怪象:IRQ 触发了,但执行结果与期望不符。
- 内存踩踏:写越界导致随机崩溃,且崩溃点往往不在案发现场。
- 系统崩溃:一个空指针解引用,整个系统直接崩溃。
内核态程序运行时,无法随意暂停系统。因此,我们需要构建一套从日志记录到实时追踪,再到现场分析的完整工具箱。本文将系统梳理 Linux 内核驱动开发中最高频、最实用的调试接口与方法。
2. 调试工具箱
在动手之前,根据问题现象选择合适的工具,能事半功倍。
逻辑/流程追踪
缓冲区数据打印
异常检测/断言
内部状态/变量查看
性能/时序/中断分析
硬件寄存器确认
系统崩溃/Oops
内存越界/踩踏检测
驱动调试需求
工具选择
printk / dynamic_debug
print_hex_dump
WARN_ON / BUG_ON
debugfs
trace_printk
devmem / devmem2
faddr2line / decode_stacktrace.sh
KASAN
3. 基础日志
内核打印是第一道防线,但滥用 printk 会导致日志洪水,养成良好的打印习惯,能让调试事半功倍。同时,学会使用断言能提前暴露问题。
3.1 规范化输出
在驱动代码中,建议放弃裸写 printk,改用内核推荐的等级宏,这有助于利用内核日志等级过滤无关信息。为了让日志统一带上驱动名称,可以在文件最开头定义 pr_fmt。
c
/* 放在 .c 文件开头,自动给所有日志加上前缀 */
#define pr_fmt(fmt) "MyDriver: " fmt
/* 自带日志级别,且易于搜索 */
pr_info("module loaded\n");
pr_err("i2c timeout (Addr: 0x%02x)\n", addr);
/* 使用 %pS 可以打印函数名,适合调试回调函数 */
pr_info("callback function is %pS\n", callback_func);
/* 调试专用,平时不打印,开启 DEBUG 宏后才生效 */
pr_debug("count = %d\n", count);
实用技巧 :查看日志时,可根据需要选用 dmesg -w(实时跟随模式)和 dmesg -C(清空缓冲区),提高阅读效率。
3.2 频控打印
在中断处理函数或高频循环中打印日志会导致系统卡顿甚至挂死。这时候推荐使用 _ratelimited 后缀的打印宏。
c
/* 每秒最多打印默认次数,通常是 5 秒 10 次,具体阈值由内核实现决定(不同内核版本可能不同) */
if (status_error)
pr_err_ratelimited("hardware error: 0x%x\n", status);
3.3 动态开关
如果需要临时开启 pr_debug 级别的日志,可以使用内核的动态调试功能(需开启 CONFIG_DYNAMIC_DEBUG 宏控)。代码中保留 pr_debug,默认不打印,运行时通过命令精准开启,无需重新编译内核。
3.3.1 开启与关闭
通过 debugfs 接口动态控制:
bash
# 挂载 debugfs (通常系统会自动挂载)
sudo mount -t debugfs none /sys/kernel/debug
# 开启,指定文件名 (+p)
sudo sh -c 'echo "file my_driver.c +p" > /sys/kernel/debug/dynamic_debug/control'
# 开启,指定行号
sudo sh -c 'echo "file my_driver.c line 123 +p" > /sys/kernel/debug/dynamic_debug/control'
# 关闭,指定文件名 (-p)
sudo sh -c 'echo "file my_driver.c -p" > /sys/kernel/debug/dynamic_debug/control'
3.3.2 加载时开启
如果驱动在加载瞬间就报错,来不及通过 debugfs 接口写入参数时,可以使用模块参数:
bash
# 整个模块
sudo modprobe my_driver dyndbg="module my_driver +p"
# 指定文件
sudo modprobe my_driver dyndbg="file my_driver.c +p"
3.4 数据缓冲区打印
驱动开发常涉及协议调试(如 SPI/I2C 数据包),不要自己写 for 循环打印,内核提供了标准接口 print_hex_dump。
c
/* 打印一段内存 buffer
* KERN_DEBUG: 日志等级
* "RX_DATA: ": 前缀字符串
* DUMP_PREFIX_OFFSET: 显示偏移量
* 16: 每行显示 16 字节
* 1: 每个数据单元为 1 字节
* buf: 数据指针
* len: 数据长度
* true: 是否显示 ASCII 字符
* 打印效果:RX_DATA: 00000000: 1a 2b 3c 4d ... .+<M
*/
print_hex_dump(KERN_DEBUG, "RX_DATA: ", DUMP_PREFIX_OFFSET,
16, 1, buf, len, true);
3.5 断言
在关键路径上检查条件是防御性编程的基础。
WARN_ON(condition):如果条件为真,打印堆栈信息,程序继续运行。适用于不应该发生,但发生了也能勉强运行的场景。BUG_ON(condition):如果条件为真,打印堆栈信息,并引发 Kernel Panic(系统崩溃)。仅适用于如果继续运行会破坏核心数据结构或造成硬件损坏的极端场景。
注意 :驱动开发中应尽量使用 WARN_ON 并配合错误码返回(如 return -EINVAL),慎用 BUG_ON,因为它会导致整个系统不可用。
4. debugfs
虚拟文件系统中,sysfs 用于建立规范的标准设备模型,procfs 用于观测系统运行信息。如果你只是想查看驱动内部的变量(如寄存器值、统计计数、状态机状态等),更加自由的 debugfs 是最佳选择。
4.1 代码示意
c
#include <linux/debugfs.h>
static struct dentry *dbg_dir;
static u32 irq_counter = 0;
static u64 last_timestamp = 0;
static int __init my_driver_init(void)
{
// 1. 创建目录 /sys/kernel/debug/my_driver/
dbg_dir = debugfs_create_dir("my_driver", NULL);
if (!dbg_dir)
return -ENOMEM;
// 2. 创建文件,将变量暴露给用户空间(只读)
// u32 打印的是十进制,x64 打印的是十六进制
debugfs_create_u32("irq_count", 0444, dbg_dir, &irq_counter);
debugfs_create_x64("last_ts", 0444, dbg_dir, &last_timestamp);
return 0;
}
static void __exit my_driver_exit(void)
{
// 递归删除目录及其下文件
debugfs_remove_recursive(dbg_dir);
}
4.2 实时监控
在用户态配合 watch 命令,形成简易仪表盘:
bash
# 每 0.5 秒刷新一次数据
sudo watch -n 0.5 cat /sys/kernel/debug/my_driver/irq_count
5. trace_printk
当你在调试中断延迟、调度时序或原子上下文问题时,普通的 printk 可能会因为 I/O 慢而改变时序,甚至掩盖 Bug(Heisenbug)。此时,我们需要 ftrace 子系统提供的 trace_printk,它的特点是只把日志写入内存缓冲区,不走虚拟终端、伪终端或串口等 TTY I/O 接口,速度极快,可在中断上下文放心使用。
5.1 架构对比
ftrace RingBuffer 控制台 printk RingBuffer 驱动代码 ftrace RingBuffer 控制台 printk RingBuffer 驱动代码 普通 printk (慢,可能阻塞) trace_printk (极快,无阻塞) 用户想看时才读取 写入日志 格式化输出 (耗时!) 仅写入内存缓冲区
5.2 使用方法
代码插桩:
c
/* 极其轻量,可用于中断上下文,几乎不影响时序 */
trace_printk("ISR enter: status=0x%x\n", reg_val);
查看记录:
bash
# 1. 开启追踪 (确认内核已开启 CONFIG_FTRACE)
sudo sh -c 'echo 1 > /sys/kernel/debug/tracing/tracing_on'
# ... 运行你的程序 ...
# 2. 读取追踪缓冲区
sudo cat /sys/kernel/debug/tracing/trace
# 3. 关闭追踪
sudo sh -c 'echo 0 > /sys/kernel/debug/tracing/tracing_on'
说明 :trace_printk 主要开销来自缓冲区写入,关闭后几乎没有开销。建议仅在调试时启用,避免在生产环境影响性能。
6. devmem
有时候检查代码没有问题,但是硬件寄存器没写进去,或者想直接读硬件状态。这时候我们不需要反复修改读写寄存器的代码,可以直接用内存地址读写工具 devmem2。devmem 是 BusyBox 提供的简化版工具,devmem2 是独立实现的完整工具,两者功能类似,这里以 devmem2 为例说明。
直接通过物理地址读写寄存器可能导致硬件状态异常或系统崩溃,请注意在操作前确认地址正确性。
bash
# 安装 devmem2,以 Debian 系发行版为例
sudo apt install devmem2
# 读取指定物理地址的值,4 字节长度
sudo devmem2 0x00200000 w
# 向指定物理地址写值,1 字节长度
sudo devmem2 0x00200000 b 0x1
读写操作日志如下所示:
bash
$ sudo devmem2 0x9c0000000 b
/dev/mem opened.
Memory mapped at address 0xffff89399000.
Value at address 0xC0000000 (0xffff89399000): 0xFF
$ sudo devmem2 0x9c0000000 b 0x1
/dev/mem opened.
Memory mapped at address 0xffffa7625000.
Value at address 0xC0000000 (0xffffa7625000): 0xFF
Written 0x1; readback 0xFF
7. Oops
当发生空指针引用时,内核会打印 Oops 信息。调试内核或内核模块时分别需要加上 CONFIG_DEBUG_INFO=y 或 EXTRA_CFLAGS += -g 打开调试信息。
检查文件中是否包含调试相关的段,如果没有 debug 相关输出,说明没有打开调试信息:
bash
$ aarch64-linux-gnu-objdump -h hello_device.ko | grep debug
20 .debug_info 00000750 0000000000000000 0000000000000000 00000640 2**0
21 .debug_abbrev 0000017b 0000000000000000 0000000000000000 00000d90 2**0
22 .debug_aranges 00000040 0000000000000000 0000000000000000 00000f0b 2**0
23 .debug_rnglists 00000021 0000000000000000 0000000000000000 00000f4b 2**0
24 .debug_line 00000108 0000000000000000 0000000000000000 00000f6c 2**0
25 .debug_str 00000fd6 0000000000000000 0000000000000000 00001074 2**0
26 .debug_line_str 000001f9 0000000000000000 0000000000000000 0000204a 2**0
27 .debug_frame 00000060 0000000000000000 0000000000000000 00002248 2**3
7.1 解读 Oops
Oops 日志中最关键的是 PC 指针和 Call Trace:
bash
[27580.005612] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
[27580.006488] Mem abort info:
[27580.006872] ESR = 0x0000000096000044
[27580.007251] EC = 0x25: DABT (current EL), IL = 32 bits
[27580.007772] SET = 0, FnV = 0
[27580.008085] EA = 0, S1PTW = 0
[27580.008409] FSC = 0x04: level 0 translation fault
[27580.008891] Data abort info:
[27580.009187] ISV = 0, ISS = 0x00000044
[27580.009572] CM = 0, WnR = 1
[27580.009875] user pgtable: 4k pages, 48-bit VAs, pgdp=00000004f1c40000
[27580.010552] [0000000000000000] pgd=0000000000000000, p4d=0000000000000000
[27580.011236] Internal error: Oops: 0000000096000044 [#1] SMP
[27580.011778] Modules linked in: hello_device(O+) led_class_multicolor btsdio brcmfmac brcmutil panfrost pwm_fan rk805_pwrkey drm_shmem_helper gpu_sched zram zsmalloc binfmt_misc sch_fq_codel fuse dm_mod nfnetlink ip_tables ipv6 nvmem_rockchip_otp yt6801 rockchip_cpuinfo uio_pdrv_genirq uio [last unloaded: hello_device(O)]
[27580.014673] CPU: 1 PID: 3512 Comm: insmod Tainted: G O 6.1.75-vendor-rk35xx #1
[27580.015501] Hardware name: Orange Pi 5 Pro (DT)
[27580.015952] pstate: 60400009 (nZCv daif +PAN -UAO -TCO -DIT -SSBS BTYPE=--)
[27580.016631] pc : hello_init+0x24/0x1000 [hello_device]
[27580.017171] lr : hello_init+0x20/0x1000 [hello_device]
[27580.017700] sp : ffff800011233af0
[27580.018038] x29: ffff800011233af0 x28: ffff80000a29a990 x27: 0000000000000000
[27580.018753] x26: ffff800011233cb0 x25: 0000000000000000 x24: 0000000000000000
[27580.019465] x23: 0000000000000000 x22: 0000000000000000 x21: ffff800001364058
[27580.020174] x20: ffff80000a4ddfe0 x19: ffff800001252000 x18: 0000000000000000
[27580.020884] x17: 0000000000000000 x16: 0000000000000000 x15: 0000000000000000
[27580.021593] x14: 0000000000000000 x13: 0000000000000000 x12: 0000000000000000
[27580.022303] x11: 0000000000000000 x10: 0000000000000000 x9 : ffff8000081a06d4
[27580.023013] x8 : 00000db8c200000c x7 : 21646c726f57206f x6 : 6f57206f6c6c6548
[27580.023722] x5 : 0000000000000000 x4 : 0000000000000000 x3 : 0000000000000000
[27580.024431] x2 : 0000000000000000 x1 : ffff0004f08cadc0 x0 : 0000000000000000
[27580.025143] Call trace:
[27580.025399] hello_init+0x24/0x1000 [hello_device]
[27580.025909] do_one_initcall+0x94/0x1e4
[27580.026323] do_init_module+0x58/0x1e0
[27580.026724] load_module+0x1850/0x1918
[27580.027117] __do_sys_finit_module+0xf8/0x118
[27580.027563] __arm64_sys_finit_module+0x24/0x30
[27580.028032] invoke_syscall+0x8c/0x128
[27580.028425] el0_svc_common.constprop.0+0xd8/0x128
[27580.028911] do_el0_svc+0xac/0xbc
[27580.029261] el0_svc+0x2c/0x54
[27580.029590] el0t_64_sync_handler+0xac/0x13c
[27580.030026] el0t_64_sync+0x19c/0x1a0
[27580.030402]
PC: 0xffff800001252024:
[27580.030892] 1e24 ******** ******** ******** ******** ******** ******** ******** ********
[27580.031771] 1e44 ******** ******** ******** ******** ******** ******** ******** ********
7.2 快速定位
7.2.1 decode_stacktrace.sh
Linux 内核源码树提供了一个强大的脚本 scripts/decode_stacktrace.sh,它可以自动加载 vmlinux 和 ko 文件,把 Oops 日志里的十六进制直接翻译成代码行号。如果 .ko 文件没有开启调试信息,执行脚本会报错 WARNING! Modules path isn't set, but is needed to parse this symbol。
bash
# 语法:将 dmesg 输出通过管道传给脚本
# 需要:带调试符号的 vmlinux,以及模块(.ko)所在目录
sudo dmesg | ./scripts/decode_stacktrace.sh vmlinux auto /home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/
解析后偏移量信息被转换成了文件名:行号,参见如下日志:
bash
[ 114.248142] pc : hello_init (/home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/hello_device.c:9) hello_device
[ 114.248635] lr : hello_init (/home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/hello_device.c:9) hello_device
[ 114.249118] sp : ffff800011263af0
[ 114.249431] x29: ffff800011263af0 x28: ffff80000a29a990 x27: 0000000000000000
[ 114.256038] Call trace:
[ 114.256270] hello_init (/home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/hello_device.c:9) hello_device
7.2.2 faddr2line
内核源码提供了一个脚本工具 faddr2line,它支持 函数+偏移 语法,省去了手动查 nm 和计算的过程,这是目前内核开发者最常用的方式。
bash
# 格式:faddr2line <ko文件> <函数名+偏移>
./scripts/faddr2line hello_device.ko hello_init+0x24
执行结果如下,偏移地址是 0x24,函数总长度是 0x34:
bash
hello_init+0x24/0x34:
hello_init at /home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/hello_device.c:9
7.3 手动定位
如果你无法使用自动化脚本,可以手动计算偏移量。这里假设 Oops 显示 hello_init+0x24/0x1000,表示函数内偏移为 0x24,总长度为 0x1000(按页对齐)。
1. 获取函数的起始地址
使用 nm 工具从 .ko 文件中提取符号的基地址:
bash
$ nm hello_device.ko | grep hello_init
0000000000000000 t hello_init
2. 计算绝对偏移量
将 nm 查到的基地址(0x0000000000000000)与 Oops 中的偏移量(0x24)相加,即绝对偏移量为 0x24。
3. 使用 addr2line 定位源码行号
如果没有开启调试信息,.ko 文件中不会包含源码行号映射表,行号信息就会变成问号。
bash
# -f: 显示函数名, -e: 指定文件
addr2line -f -e hello_device.ko 0x24
定位结果如下所示,可以看到函数名是 hello_init,代码行号是第 9 行:
bash
hello_init
/home/dump_linux/learning_linux_kernel_driver_from_scratch/hello_device/hello_device.c:9
8. KASAN
如果你的驱动遇到莫名其妙的随机崩溃,或者写了一个变量却改变了另一个无关变量的值,大概率是内存越界或释放后使用。这种情况是最难调试的 Bug,因为崩溃点通常不是案发现场。
解决方案是开启 KASAN (Kernel Address Sanitizer),需要在内核配置时打开配置选项,这里仅供参考,相关配置项较多,具体请根据需要调整:
bash
CONFIG_KASAN=y
CONFIG_KASAN_GENERIC=y
CONFIG_HAVE_ARCH_KASAN=y
重新编译内核后,KASAN 会自动监控所有内存访问,在每次内存访问时进行检查。一旦越界,它会立刻打印报错,并指出:
- 谁在非法访问(函数、行号)。
- 这块内存是谁分配的(分配时的堆栈)。
- 这块内存是谁释放的(释放后使用)。
典型 KASAN 报告如下所示:
bash
BUG: KASAN: slab-out-of-bounds in kmalloc_oob_right+0xa8/0xbc [kasan_test]
Write of size 1 at addr ffff8801f44ec37b by task insmod/2760
CPU: 1 PID: 2760 Comm: insmod Not tainted 4.19.0-rc3+ #698
Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.10.2-1 04/01/2014
Call Trace:
dump_stack+0x94/0xd8
print_address_description+0x73/0x280
kasan_report+0x144/0x187
__asan_report_store1_noabort+0x17/0x20
kmalloc_oob_right+0xa8/0xbc [kasan_test]
kmalloc_tests_init+0x16/0x700 [kasan_test]
do_one_initcall+0xa5/0x3ae
do_init_module+0x1b6/0x547
load_module+0x75df/0x8070
__do_sys_init_module+0x1c6/0x200
__x64_sys_init_module+0x6e/0xb0
do_syscall_64+0x9f/0x2c0
entry_SYSCALL_64_after_hwframe+0x44/0xa9
RIP: 0033:0x7f96443109da
RSP: 002b:00007ffcf0b51b08 EFLAGS: 00000202 ORIG_RAX: 00000000000000af
RAX: ffffffffffffffda RBX: 000055dc3ee521a0 RCX: 00007f96443109da
RDX: 00007f96445cff88 RSI: 0000000000057a50 RDI: 00007f9644992000
RBP: 000055dc3ee510b0 R08: 0000000000000003 R09: 0000000000000000
R10: 00007f964430cd0a R11: 0000000000000202 R12: 00007f96445cff88
R13: 000055dc3ee51090 R14: 0000000000000000 R15: 0000000000000000
Allocated by task 2760:
save_stack+0x43/0xd0
kasan_kmalloc+0xa7/0xd0
kmem_cache_alloc_trace+0xe1/0x1b0
kmalloc_oob_right+0x56/0xbc [kasan_test]
kmalloc_tests_init+0x16/0x700 [kasan_test]
do_one_initcall+0xa5/0x3ae
do_init_module+0x1b6/0x547
load_module+0x75df/0x8070
__do_sys_init_module+0x1c6/0x200
__x64_sys_init_module+0x6e/0xb0
do_syscall_64+0x9f/0x2c0
entry_SYSCALL_64_after_hwframe+0x44/0xa9
Freed by task 815:
save_stack+0x43/0xd0
__kasan_slab_free+0x135/0x190
kasan_slab_free+0xe/0x10
kfree+0x93/0x1a0
umh_complete+0x6a/0xa0
call_usermodehelper_exec_async+0x4c3/0x640
ret_from_fork+0x35/0x40
The buggy address belongs to the object at ffff8801f44ec300
which belongs to the cache kmalloc-128 of size 128
The buggy address is located 123 bytes inside of
128-byte region [ffff8801f44ec300, ffff8801f44ec380)
The buggy address belongs to the page:
page:ffffea0007d13b00 count:1 mapcount:0 mapping:ffff8801f7001640 index:0x0
flags: 0x200000000000100(slab)
raw: 0200000000000100 ffffea0007d11dc0 0000001a0000001a ffff8801f7001640
raw: 0000000000000000 0000000080150015 00000001ffffffff 0000000000000000
page dumped because: kasan: bad access detected
Memory state around the buggy address:
ffff8801f44ec200: fc fc fc fc fc fc fc fc fb fb fb fb fb fb fb fb
ffff8801f44ec280: fb fb fb fb fb fb fb fb fc fc fc fc fc fc fc fc
>ffff8801f44ec300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03
^
ffff8801f44ec380: fc fc fc fc fc fc fc fc fb fb fb fb fb fb fb fb
ffff8801f44ec400: fb fb fb fb fb fb fb fb fc fc fc fc fc fc fc fc
9. 总结
| 场景 | 推荐工具 | 核心优势 |
|---|---|---|
| 日常逻辑验证 | pr_info / pr_debug / pr_fmt |
简单直接,配合 dynamic_debug 灵活开关 |
| 数据包分析 | print_hex_dump |
格式化输出十六进制 Buffer,美观易读 |
| 防御性检查 | WARN_ON |
暴露逻辑错误但不至于让系统崩溃 |
| 查看内部状态 | debugfs |
干净,不污染 dmesg,支持按需读取 |
| 中断/时序分析 | trace_printk |
极低开销,不影响系统实时性 |
| 硬件物理验证 | devmem / devmem2 |
绕过驱动,直接读写物理寄存器 |
| 内核崩溃 | faddr2line / decode_stacktrace.sh |
将晦涩的内存地址转换为具体的代码行号 |
| 内存越界/踩踏 | KASAN |
捕捉非法内存 Bug 的神器 |
调试驱动是驱动开发的必备技能,调试不能靠运气,得靠基于证据的推理。