4、内存管理与DMA的雷区:内存泄漏、DMA缓冲区对齐、Cache一致性、内存碎片化

内存管理,说白了就是驱动和硬件之间的“物流系统”。你想想看,数据要从外设搬到系统内存,再从内存搬回去,中间但凡出一点岔子——泄漏、对齐错误、Cache不同步、碎片化——轻则性能暴跌,重则系统直接挂掉。我这些年调试过的驱动,至少有一半的稳定性问题都出在这四个坑里。

嗯,咱们一个一个来拆。

4.1 内存泄漏:看不见的“黑洞”

内存泄漏在驱动里比在应用层更致命。应用层泄漏,进程退出时系统会回收;驱动泄漏?不重启机器,那块内存就永远丢了。

典型场景:每次IO操作都分配一块DMA缓冲区,操作完成后忘了释放。或者中断处理函数里分配了内存,但异常分支没走到free。

核心原则:谁分配,谁释放;分配点和释放点要成对出现,最好写在同一个函数层级里。

我个人习惯在代码里用“资源获取即初始化”的思路。分配内存时,立刻在下一行写好释放的goto标签或者cleanup块。举个例子:

static int my_dev_io(struct my_dev *dev, size_t size)
{
    void *buf = NULL;
    int ret = 0;

    buf = dma_alloc_coherent(dev->dev, size, &dev->dma_handle, GFP_KERNEL);
    if (!buf)
        return -ENOMEM;

    // 业务逻辑...
    if (some_error_condition) {
        ret = -EIO;
        goto err_free_buf;  // 异常路径统一释放
    }

    // 正常路径
    dev->buf = buf;
    return 0;

err_free_buf:
    dma_free_coherent(dev->dev, size, buf, dev->dma_handle);
    return ret;
}

小技巧:在模块卸载函数里加一个全局计数器,记录当前分配的缓冲区数量。如果卸载时计数器不为零,打印警告。这个习惯帮我抓到了至少三次泄漏。

4.2 DMA缓冲区对齐:硬件有它的脾气

DMA控制器不是软件,它没那么多灵活性。很多硬件要求缓冲区起始地址必须对齐到某个边界——比如32字节、64字节、甚至1KB。不对齐?数据传输直接错位,或者硬件直接报错。

我在项目中遇到过一块网卡,文档里写着“缓冲区必须16字节对齐”。我偷懒用了kmalloc,结果在某个平台上偶尔丢包。查了两天才发现,kmalloc在大多数情况下返回的地址是8字节对齐的,但16字节对齐并不保证。

解决方案:

  • 使用dma_alloc_coherent:它保证返回的地址是cacheline对齐的,通常也满足DMA对齐要求。
  • 使用dma_pool:如果你需要大量固定大小的DMA缓冲区,dma_pool可以保证对齐,还能减少碎片。
  • 手动对齐:如果必须用kmalloc,可以多分配一些空间,然后手动对齐到目标边界。
分配方式 对齐保证 适用场景
kmalloc 至少8字节(架构相关) 小数据、非DMA场景
dma_alloc_coherent Cacheline对齐(通常64字节) DMA传输、一致性映射
dma_pool 自定义对齐 大量固定大小DMA缓冲区
__get_free_pages 页对齐(4KB) 大块连续内存

警告:不要假设kmalloc返回的地址能满足DMA对齐要求。除非你明确知道硬件只要求8字节对齐,否则请使用专用的DMA分配接口。

4.3 Cache一致性:CPU和DMA的“信息差”

这是DMA驱动里最隐蔽的坑。CPU有Cache,DMA直接读写内存。如果CPU修改了某个缓冲区,但数据还留在Cache里没写回内存,DMA读到的就是旧数据。反过来,DMA写入了新数据,但CPU的Cache里还存着旧副本,CPU读到的也是错的。

为什么会这样?因为Cache对CPU是透明的,但对DMA不是。DMA控制器看不到Cache,它只认物理内存地址。

解决办法:

  • 一致性DMA映射(dma_alloc_coherent):分配时就把Cache和内存同步好,之后CPU和DMA看到的永远是一致的数据。代价是性能稍低,因为每次访问都可能穿透Cache。
  • 流式DMA映射(dma_map_single / dma_unmap_single):在DMA传输前手动刷Cache,传输完成后手动无效化Cache。性能更好,但需要开发者精确控制同步点。

我记得有一次调试一个视频采集驱动,画面时不时出现撕裂。查到最后发现是DMA写入了新帧,但CPU读取时Cache里还是旧帧。加上dma_sync_single_for_cpu调用后,问题立刻消失。

经验之谈:如果你不确定用哪种映射方式,优先用一致性映射。等驱动稳定了,再根据性能需求考虑是否切换到流式映射。

4.4 内存碎片化:长时间运行的“慢性病”

内存碎片化在驱动里比在应用层更棘手。应用层有虚拟内存,碎片化大不了多占点页表。驱动里呢?DMA需要物理连续内存。系统跑个几天几周后,物理内存被切成无数小块,你申请一个稍大的连续区域就可能失败。

我曾经维护过一个网络驱动,客户反馈设备运行一周后吞吐量骤降。查日志发现是dma_alloc_coherent频繁失败,驱动退回到了PIO模式。根本原因就是内存碎片化。

应对策略:

  • 预分配:在驱动初始化时就分配好所有DMA缓冲区,运行期间不再动态分配。这是最彻底的办法。
  • 使用内存池:dma_pool或kmem_cache可以复用固定大小的缓冲区,减少碎片。
  • 使用CMA(Contiguous Memory Allocator):内核专门为DMA预留的连续内存区域,可以在系统启动时通过cmdline指定大小。
  • 监控碎片化程度:通过/proc/buddyinfo观察内存碎片情况,设置阈值告警。

实用建议:在驱动里加一个定时器,每隔一段时间检查dma_alloc_coherent的成功率。如果失败次数超过阈值,打印告警并尝试触发内存压缩(echo 1 > /proc/sys/vm/compact_memory)。

4.5 知识体系总览

下面这张图把四个雷区的关系串起来了。你可以把它当作调试时的检查清单:

内存管理与DMA四大雷区 DMA内存管理 内存泄漏 DMA对齐 Cache一致性 内存碎片化 症状:内存只增不减 模块卸载后仍有残留 症状:数据错位 硬件报错或丢包 症状:画面撕裂 数据不一致 症状:分配失败 性能逐渐下降 通用解决方案 预分配缓冲区 | 使用dma_pool/kmem_cache | 启用CMA 一致性映射优先 | 流式映射注意同步点 | 监控碎片化程度 分配释放成对出现 | 异常路径统一清理

4.6 避坑指南:我曾经踩过的坑

最后分享几个真实案例,希望能帮你少走弯路:

  • 案例一:我曾经在中断处理函数里调用了dma_alloc_coherent。中断上下文不能睡眠,而dma_alloc_coherent可能会触发页面回收导致睡眠。结果系统随机死锁。后来改成在中断上半部只做标记,下半部用工作队列来分配内存。
  • 案例二:有一次我忘了在DMA传输完成后调用dma_unmap_single。驱动卸载时dma_debug报了一堆“DMA API: device driver tries to free DMA memory it has not allocated”的错误。从那以后我每次写DMA代码,都会在分配后立刻写好对应的释放调用。
  • 案例三:一个音频驱动在播放24小时后有杂音。查到最后是dma_pool里的缓冲区被反复分配释放,产生了内部碎片。改用预分配固定数量的缓冲区后,问题解决。

最后提醒:内存和DMA的问题往往不会立刻暴露。它们像定时炸弹,可能在设备运行几天甚至几周后才引爆。所以,在驱动开发阶段就做好压力测试和长时间稳定性测试,比事后排查要高效得多。