第三章 驱动Bug定位:空指针异常、内存泄漏、竞态条件、栈溢出
驱动开发里,Bug 分两种。一种是你一眼能看出来的,比如语法错误。另一种,就是今天我们要聊的这四类——它们藏得深,跑起来才露馅。我做了十几年嵌入式,可以说这四类 Bug 占了驱动调试工作量的八成以上。
咱们一个一个来拆。
3.1 空指针异常:最常见的“隐形杀手”
空指针在驱动里太常见了。说白了,就是你拿了个 NULL 指针当有效地址去读写。在 MCU 上,这往往直接触发 HardFault。在 Linux 内核里,就是 Oops 或者 panic。
核心原因:指针在使用前未判空,或者资源被释放后指针未置 NULL。
我遇到过最典型的一个案例:一个 SPI 驱动,在 probe 函数里申请了设备结构体,但某个回调函数在设备移除后还被调用。结果呢?结构体已经被 kfree 了,指针还留着。一访问,系统直接挂掉。
避坑指南:我曾经在调试一个 USB 主机驱动时,花了整整两天才找到问题——一个中断处理函数里用了 dev_get_drvdata(),但设备已经在另一个 CPU 核上被移除了。从那以后,我养成了一个习惯:
- 每次使用指针前,先判空
- 释放资源后,立即把指针置 NULL
- 中断上下文里尽量少用全局指针
// 错误示例
void my_drv_irq_handler(void *data) {
struct my_dev *dev = (struct my_dev *)data;
dev->status = 1; // 如果 data 是 NULL,直接崩溃
}
// 正确做法
void my_drv_irq_handler(void *data) {
if (!data) {
pr_err("my_drv: NULL pointer in IRQ handler\n");
return;
}
struct my_dev *dev = (struct my_dev *)data;
dev->status = 1;
}
3.2 内存泄漏:温水煮青蛙
内存泄漏不会立刻让你崩溃,但系统跑着跑着就慢了,最后 OOM 挂掉。驱动里泄漏的原因,无非就是申请了没释放,或者释放路径没覆盖全。
| 泄漏类型 | 典型场景 | 检测手段 |
|---|---|---|
| kmalloc 未 kfree | 每次 IOCTL 都申请缓冲区,但只在成功路径释放 | kmemleak、slabinfo |
| DMA 缓冲区未释放 | dma_alloc_coherent 后没有 dma_free_coherent | /proc/meminfo、dma_debug |
| 引用计数泄漏 | kobject_get 后没有 kobject_put | debugfs 查看 refcount |
| URB 未释放 | USB 驱动中 usb_alloc_urb 后忘记 usb_free_urb | usbmon、usb_debug |
我记得有一次调试一个网卡驱动,客户反映设备运行 72 小时后网络就断了。我上去一看,是每个数据包都申请了一个 skb,但只在发送成功时释放。如果网络拥堵,发送队列满了,skb 就永远留在那里。嗯,这就是典型的“成功路径覆盖了,异常路径忘了”。
我的习惯:每次写申请资源的代码,先写释放代码。这叫“资源逆序释放法”。你想想看,如果先写 free,你就不会忘记它。
3.3 竞态条件:多核时代的噩梦
竞态条件,说白了就是多个执行流同时访问共享资源,结果互相踩踏。单核时代靠关中断就能解决,现在多核、SMP、抢占式内核,情况复杂多了。
驱动里最常见的竞态场景:
- 中断 vs 进程上下文:中断处理函数和普通进程同时访问同一个全局变量
- 多核同时访问:两个 CPU 核同时执行同一个驱动的同一个函数
- 抢占导致:进程 A 正在修改链表,被进程 B 抢占,B 也来修改同一个链表
我遇到过最隐蔽的一个竞态 Bug:一个 I2C 驱动,在读写操作之间没有加锁。结果两个线程同时操作同一个从设备,控制寄存器被写乱了,设备直接死锁。这个 Bug 复现概率只有 1/10000,跑压力测试跑了三天才抓到。
注意:不要以为你的驱动只在单核上跑就没事。Linux 内核的抢占机制,在单核上也能制造出竞态条件。
// 竞态示例:不加锁的共享变量
static int shared_flag = 0;
void thread_a(void) {
if (shared_flag == 0) {
// 这里可能被 thread_b 抢占
shared_flag = 1;
do_something();
}
}
void thread_b(void) {
if (shared_flag == 0) {
shared_flag = 1;
do_something_else();
}
}
// 正确做法:使用 spinlock
static DEFINE_SPINLOCK(flag_lock);
static int shared_flag = 0;
void thread_a(void) {
unsigned long flags;
spin_lock_irqsave(&flag_lock, flags);
if (shared_flag == 0) {
shared_flag = 1;
spin_unlock_irqrestore(&flag_lock, flags);
do_something();
} else {
spin_unlock_irqrestore(&flag_lock, flags);
}
}
3.4 栈溢出:无声的崩溃
栈溢出在驱动里比你想的更常见。内核栈通常只有 4KB 到 16KB(取决于架构和配置),比用户态栈小得多。你在函数里声明一个大的局部数组,或者递归调用太深,栈就爆了。
栈溢出的后果很严重:它会静默地覆盖相邻内存,可能是任务控制块,可能是其他局部变量。表现出来的症状千奇百怪——函数返回地址被篡改,系统跳转到随机地址执行。
我个人的经验:有一次调试一个 SD 卡驱动,发现写入大文件时系统随机崩溃。查了三天,最后发现是一个函数里声明了 char buf[4096] 的局部数组。内核栈总共才 8KB,这一个数组就占了一半。加上函数调用链上的其他栈帧,刚好溢出。
| 架构 | 默认内核栈大小 | 建议局部数组上限 |
|---|---|---|
| ARM32 | 8KB | 1KB |
| ARM64 | 16KB | 2KB |
| x86_64 | 16KB | 2KB |
| RISC-V | 8KB | 1KB |
检测方法:内核编译时开启 CONFIG_DEBUG_STACKOVERFLOW 和 CONFIG_DEBUG_STACK_USAGE。运行时查看 /proc/self/status 里的 VmStk 字段。如果栈使用率超过 70%,就要警惕了。
怎么避免?说白了就几条:
- 大缓冲区用 kmalloc 动态申请,别放栈上
- 递归调用深度控制在 3 层以内
- 中断处理函数里尽量少调用其他函数
- 定期检查栈使用量
这四类 Bug,说白了就是驱动开发者的“四大天王”。每个都够你喝一壶的。但好消息是,它们都有成熟的检测工具和调试方法。你只要记住:空指针要判空,内存要配对,共享要加锁,栈上别放大数组。做到这四点,80% 的驱动 Bug 都能在开发阶段被扼杀在摇篮里。