第三章 驱动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类型与调试策略 空指针异常 内存泄漏 竞态条件 栈溢出 症状 HardFault / Oops 系统直接崩溃 症状 内存持续增长 最终 OOM 症状 随机崩溃 / 数据错乱 复现概率低 症状 函数返回异常 跳转到随机地址 调试手段 空指针 KASAN / 判空检查 addr2line 内存泄漏 kmemleak slabtop 竞态条件 lockdep / KCSAN ftrace 栈溢出 STACKTRACE VmStk 监控

这四类 Bug,说白了就是驱动开发者的“四大天王”。每个都够你喝一壶的。但好消息是,它们都有成熟的检测工具和调试方法。你只要记住:空指针要判空,内存要配对,共享要加锁,栈上别放大数组。做到这四点,80% 的驱动 Bug 都能在开发阶段被扼杀在摇篮里。