Linux内核DMA缓存处理的“真相“-实战
dma_alloc_coherent和dma_map_single到底该选哪个,问题出在哪?
因为官方文档和源码分析都回避了最核心的问题:这两种方式到底有什么区别?什么时候该用哪个?
第一章:两种DMA方式的"基因"差异
1.1 从CPU视角看内存属性
在ARM64架构中,页表项有4种内存属性(在arch/arm64/include/asm/memory.h中定义):
/* * Memory types available. * * 这4种属性决定了CPU访问内存时的行为 * 直接决定了你的DMA驱动会不会"抽风" */ #define MT_NORMAL 0 // 正常缓存:CPU读写都经过cache,性能最好 #define MT_NORMAL_NC 1 // 非缓存:CPU读写直接访问内存,性能最差 #define MT_NORMAL_WT 2 // 写通:写直达内存,读可能缓存 #define MT_DEVICE_nGnRnE 3 // 设备内存:严格顺序,用于寄存器 // 在arch/arm64/mm/proc.S中设置MAIR寄存器 #define MAIR(attr, mt) ((attr) << ((mt) * 8)) /* * 最终设置: * mair_el1 = 0x00ff044c00000000 * 其中: * - MT_NORMAL: 0xff (正常缓存,读分配写回) * - MT_NORMAL_NC: 0x44 (非缓存) * - MT_DEVICE: 0x00 (设备内存) */
真相来了: dma_alloc_coherent()分配的内存,页表属性被设置为MT_NORMAL_NC——非缓存。这意味着CPU每次读写都直接操作物理内存。
1.2 用实验证明差异
写个小模块验证:
// test_dma_attrs.c - 测试两种DMA方式的内存属性
#include <linux/module.h>
#include <linux/dma-mapping.h>
#include <linux/slab.h>
static struct device *test_dev;
static void *coherent_virt;
static dma_addr_t coherent_dma;
static void *stream_virt;
static dma_addr_t stream_dma;
static int __init test_init(void)
{
int ret;
u32 *ptr;
u32 val;
unsigned long flags;
// 创建虚拟设备
test_dev = root_device_register("dma_test");
if (IS_ERR(test_dev))
return PTR_ERR(test_dev);
// 设置DMA掩码(告诉内核设备能访问多少位地址)
ret = dma_set_mask_and_coherent(test_dev, DMA_BIT_MASK(32));
if (ret) {
printk("Failed to set DMA mask\n");
return ret;
}
// 方式1:一致性DMA
coherent_virt = dma_alloc_coherent(test_dev, PAGE_SIZE,
&coherent_dma, GFP_KERNEL);
printk("Coherent: virt=%p, dma=%pad\n",
coherent_virt, &coherent_dma);
// 写入数据(会直接到内存,不经过cache)
ptr = coherent_virt;
*ptr = 0xdeadbeef;
// 读取数据(直接从内存读)
val = *ptr;
printk("Coherent read: 0x%x\n", val);
// 方式2:流式DMA(先分配普通内存)
stream_virt = kmalloc(PAGE_SIZE, GFP_KERNEL);
// 映射为流式DMA
stream_dma = dma_map_single(test_dev, stream_virt,
PAGE_SIZE, DMA_BIDIRECTIONAL);
printk("Stream: virt=%p, dma=%pad\n",
stream_virt, &stream_dma);
// 写入数据(会写入cache,还没到内存)
ptr = stream_virt;
*ptr = 0xdeadc0de;
// 重要:告诉设备数据准备好了
dma_sync_single_for_device(test_dev, stream_dma,
PAGE_SIZE, DMA_TO_DEVICE);
// 现在数据在内存中
return 0;
}
// 编译后加载,查看/proc/iomem和printk输出
在ARM64平台上运行,会发现:
-
一致性内存:虚拟地址对应的页属性是"非缓存",写入后立即在内存中可见
-
流式内存:虚拟地址对应的页属性是"正常缓存",写入后在cache中,需要显式冲刷
1.3 为什么要有两种方式?——性能与便利的权衡
/* * 一致性DMA (dma_alloc_coherent) * * 优点: * - 简单:不需要考虑cache同步 * - 安全:永远不会有一致性问题 * - 适合:描述符环、状态寄存器等频繁访问的小块内存 * * 缺点: * - 慢:CPU每次读写都是直接访问内存(慢几十到几百倍) * - 例如:在Cortex-A53上,L1 cache命中约2ns,DDR访问约100ns * * 形象比喻:就像住酒店,每次都要下楼取东西,虽然麻烦但肯定准确 */ /* * 流式DMA (dma_map_single) * * 优点: * - 快:CPU可以享受cache加速 * - 适合:大数据块传输(网络包、音视频数据) * * 缺点: * - 复杂:必须在正确的时间点做sync * - 危险:sync错了数据就乱了 * * 形象比喻:就像在家,东西随手放桌上(cache),但要收拾(sync)后才能给客人 */
第二章:需要注意的关键——缓存对齐
2.1 缓存行的"魔法"
这是90%的DMA驱动bug的来源。看代码:
// 错误示例 - 网上90%的示例代码都这么写
static int my_dma_transfer_bad(struct device *dev, char *buf, int len)
{
dma_addr_t dma_handle;
// 假设buf是kmalloc分配的,偏移可能是任意值
dma_handle = dma_map_single(dev, buf, len, DMA_TO_DEVICE);
if (dma_mapping_error(dev, dma_handle))
return -EIO;
// 启动DMA...
start_dma(dma_handle, len);
return 0;
}
// 问题在哪?看ARM64的cache维护代码:
static void __dma_map_area(void *virt, size_t size, int dir)
{
unsigned long start = (unsigned long)virt;
unsigned long end = start + size;
// 关键!cache操作是以缓存行为单位的
// ARM64通常有64字节的缓存行
start = start & ~(cache_line_size() - 1); // 向下对齐到缓存行边界
end = ALIGN(end, cache_line_size()); // 向上对齐到缓存行边界
// 现在操作的范围比原来的大!
__flush_dcache_area(start, end - start);
}
/*
* 悲剧发生了:
* 假设buf = 0x1004, len = 8(两个int)
* 缓存行64字节,对齐后:
* start = 0x1000, end = 0x1040
* 你只修改了8字节,但冲刷了64字节!
* 如果这64字节里有其他重要的数据(比如其他线程的变量),
* 它们也被写回内存了——可能覆盖了正确值
*/
2.2 正确做法:缓冲区对齐
// 正确示例 - DMA缓冲区必须缓存行对齐
#define DMA_BUFFER_SIZE 2048
struct my_device {
void *rx_buf ____cacheline_aligned; // 告诉编译器对齐到缓存行
dma_addr_t rx_dma;
};
static int my_dma_init(struct my_device *dev)
{
// 方法1:使用dma_alloc_coherent(自动对齐到缓存行)
dev->rx_buf = dma_alloc_coherent(dev->dev, DMA_BUFFER_SIZE,
&dev->rx_dma, GFP_KERNEL);
// 方法2:kmalloc时指定对齐
dev->rx_buf = kmalloc(DMA_BUFFER_SIZE, GFP_KERNEL | __GFP_DMA);
// 但还要确保起始地址对齐
BUG_ON((unsigned long)dev->rx_buf & (cache_line_size() - 1));
return 0;
}
真相: 不是随便一块内存都能做DMA的,必须满足:
-
物理地址连续(对大多数DMA控制器)
-
对齐到缓存行(对cache一致性的要求)
-
在设备的DMA地址范围内(不能超过dma_mask)
第三章:会踩的坑——dma_sync的时机
3.1 所有权移交模型
DMA子系统其实实现了一个"所有权"模型:
/*
* DMA内存的"所有权"在两个角色间切换:
*
* CPU Owner Device Owner
* │ │
* │ dma_map_single/ │
* │ dma_sync_for_device │
* │ ──────────────────────→ │
* │ │
* │ DMA传输中 │
* │ │
* │ dma_sync_for_cpu/ │
* │ dma_unmap_single │
* │ ←────────────────────── │
* │ │
*/
// 典型的数据包处理循环
static void my_packet_handler(struct my_device *dev)
{
// 场景1:准备数据给设备
prepare_packet(dev->tx_buf); // CPU写缓冲区
dma_sync_single_for_device(dev->dev, // 移交所有权给设备
dev->tx_dma, PACKET_SIZE,
DMA_TO_DEVICE);
start_tx_dma(dev); // 设备读取数据
// 场景2:处理接收的数据
wait_for_rx_complete(dev);
dma_sync_single_for_cpu(dev->dev, // 收回所有权给CPU
dev->rx_dma, PACKET_SIZE,
DMA_FROM_DEVICE);
process_packet(dev->rx_buf); // CPU读取数据
// 场景3:再次给设备用(循环缓冲区)
// 注意:所有权必须完整切换,不能偷懒!
}
3.2 bug案例
这是调试一个网络驱动时遇到的bug:
// 有bug的代码 - 看起来没错,但在压力测试下会崩溃
static void net_tx_complete(void *data)
{
struct my_netdev *ndev = data;
// DMA完成了,可以重用缓冲区了
dma_unmap_single(ndev->dev, ndev->tx_dma,
ndev->tx_len, DMA_TO_DEVICE);
// 准备下一个数据包
ndev->tx_len = build_next_packet(ndev->tx_buf);
ndev->tx_dma = dma_map_single(ndev->dev, ndev->tx_buf,
ndev->tx_len, DMA_TO_DEVICE);
start_tx(ndev);
}
// 崩溃时的错误信息:
// [ 123.456789] DMA-API: device driver tries to map DMA buffer that has not been unmapped
// [ 123.456790] ------------[ cut here ]------------
// [ 123.456791] WARNING: CPU: 2 PID: 0 at lib/dma-debug.c:1088 check_unmap+0x4a0/0x9c8
/*
* 问题分析:
* dma_unmap_single和dma_map_single之间没有同步!
* 如果build_next_packet修改了tx_buf的内容,
* 这些修改可能在cache中,还没到内存。
* 当dma_map_single后立即start_tx,
* 设备可能读到旧数据。
*
* 正确做法:
*/
static void net_tx_complete_fixed(void *data)
{
struct my_netdev *ndev = data;
// 1. 先同步CPU读取(虽然我们用DMA_TO_DEVICE,但可能缓存中还有数据)
dma_sync_single_for_cpu(ndev->dev, ndev->tx_dma,
ndev->tx_len, DMA_TO_DEVICE);
// 2. 构建新数据(修改缓存中的内容)
ndev->tx_len = build_next_packet(ndev->tx_buf);
// 3. 同步给设备
dma_sync_single_for_device(ndev->dev, ndev->tx_dma,
ndev->tx_len, DMA_TO_DEVICE);
// 4. 启动DMA
start_tx(ndev);
}
第四章:DMA调试的"三板斧"
4.1 第一板斧:开启DMA_API_DEBUG
这是第一道防线:
# 编译内核时开启 make menuconfig # Kernel hacking -> Memory Debugging -> DMA API Debug # 或者直接在命令行加参数 dma_debug=1 dma_debug_entries=65536 # 运行时控制 echo 1 > /sys/module/dma_debug/parameters/debug_driver
开启后,任何DMA API的误用都会被记录:
// 常见错误及提示 [ 1.234567] DMA-API: device driver maps memory from stack [addr=ffff800010203040] // 解释:不能对栈内存做DMA,因为栈可能被回收 [ 2.345678] DMA-API: device driver tries to free DMA memory it has not allocated // 解释:dma_unmap了没有dma_map过的地址 [ 3.456789] DMA-API: device driver maps memory with invalid direction // 解释:方向参数错误 [ 4.567890] DMA-API: device driver syncs DMA memory with different direction // 解释:sync时用的方向和map时不一致
4.2 第二板斧:使用IOMMU/DMA重映射
如果硬件支持IOMMU(如ARM SMMU),可以强制启用:
// 在内核命令行添加 iommu.passthrough=0 arm-smmu.disable_bypass=0 /* * 好处: * 1. IOMMU会检查每次DMA访问,越界访问会被阻止(生成错误报告) * 2. 不需要物理连续内存(IOMMU帮你做映射) * 3. 可以通过iommu=debug查看详细日志 */
4.3 第三板斧:写压力测试脚本
这是最有效的:模拟真实负载
#!/bin/bash # dma_stress_test.sh # 测试DMA驱动在高并发下的表现 DEVICE="/dev/my_uart" ITERATIONS=10000 THREADS=4 # 测试1:多线程并发读写 for i in $(seq 1 $THREADS); do ( for j in $(seq 1 $ITERATIONS); do # 写随机数据 dd if=/dev/urandom of=$DEVICE bs=1024 count=1 2>/dev/null # 读回 dd if=$DEVICE of=/dev/null bs=1024 count=1 2>/dev/null done ) & done wait # 测试2:混合大小 for size in 64 128 256 512 1024 2048 4096; do echo "Testing size $size" dd if=/dev/zero of=$DEVICE bs=$size count=1000 done # 测试3:边界测试 echo "Boundary test: start at odd address" ./test_odd_addr $DEVICE # 测试非对齐缓冲区 echo "Boundary test: cross page boundary" ./test_cross_page $DEVICE # 测试跨页缓冲区
第五章:资深工程师的DMA设计决策树
5.1 如何选择DMA方式?
/*
* 当你站在代码面前,问自己这5个问题:
*/
// Q1: 缓冲区大小?
if (size < 64) {
// 小数据,不值得DMA开销,用PIO
goto use_pio;
}
// Q2: 访问频率?
if (access_frequency > 1000_per_second) {
// 高频访问,必须用cacheable内存(流式DMA)
goto streaming_dma;
}
// Q3: 是否需要同步?
if (need_hardware_coherency) {
// 硬件保证一致性(如ACP端口),直接用dma_alloc_coherent
goto coherent_dma;
}
// Q4: 设备是否有地址限制?
if (dev->dma_mask < DMA_BIT_MASK(32)) {
// 只能访问低端内存,可能需要swiotlb
check_swiotlb();
}
// Q5: 是否可能跨页?
if (size > PAGE_SIZE || !is_phys_contiguous) {
// 用SG-DMA(scatter-gather)
goto sg_dma;
}
/*
* 最终决策表:
*
* ┌─────────────────┬───────────────┬───────────────┐
* │ 场景 │ 一致性DMA │ 流式DMA │
* ├─────────────────┼───────────────┼───────────────┤
* │ 描述符环 │ √ │ │
* │ 寄存器状态 │ √ │ │
* │ 网络包缓冲 │ │ √ │
* │ 音频缓冲 │ │ √ │
* │ 视频帧缓冲 │ △ │ √ │
* │ DMA缓冲区池 │ √ │ │
* └─────────────────┴───────────────┴───────────────┘
* △: 取决于是否频繁CPU访问
*/
5.2 真实项目中的配置示例
4G模块驱动:
// 4G模块的USB转串口驱动(简化版)
struct modem_dma {
/*
* 控制通道:使用一致性DMA
* 因为要频繁读写状态寄存器,且数据量小
*/
void *ctrl_virt;
dma_addr_t ctrl_dma;
/*
* 数据通道:使用流式DMA + SG列表
* 因为要传输大块数据,且可能跨页
*/
struct scatterlist *data_sg;
int sg_nents;
/*
* 紧急缓冲区:当DMA失败时回退到PIO
* 可靠性设计
*/
u8 fallback_buf[2048] ____cacheline_aligned;
};
static int modem_probe(struct platform_device *pdev)
{
struct modem_dma *mdma;
// 1. 设置DMA掩码
dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32));
// 2. 分配一致性内存(控制通道)
mdma->ctrl_virt = dma_alloc_coherent(dev, 1024,
&mdma->ctrl_dma,
GFP_KERNEL | GFP_DMA);
// 3. 预分配SG列表(数据通道)
mdma->sg_nents = 32;
mdma->data_sg = kmalloc_array(mdma->sg_nents,
sizeof(struct scatterlist),
GFP_KERNEL);
sg_init_table(mdma->data_sg, mdma->sg_nents);
// 4. 注册DMA通道
mdma->rx_chan = dma_request_slave_channel(dev, "rx");
mdma->tx_chan = dma_request_slave_channel(dev, "tx");
// 5. 设置DMA回调
dma_async_issue_pending(mdma->rx_chan);
return 0;
}
第六章:DMA与内存屏障
6.1 为什么需要屏障?
看这个例子:
// 描述符环的典型处理
struct desc_ring {
u32 buffer_addr;
u32 length;
u32 flags;
u32 status; // 硬件写入
};
static void process_completed_descs(struct desc_ring *ring, int cnt)
{
for (i = 0; i < cnt; i++) {
// 这里有个隐藏的bug
if (ring[i].status & DESC_COMPLETE) {
// 处理完成的数据
process_data(ring[i].buffer_addr);
}
}
}
/*
* 问题:编译器和CPU可能重排指令!
* 编译器可能为了优化,先读取status,再读取buffer_addr
* 但硬件写入的顺序是:先写buffer_addr,再写status
*
* 如果status先被读到(CPU推测执行),但buffer_addr还没写到内存
* 就会处理错误的数据
*/
6.2 正确使用DMA屏障
static void process_completed_descs_fixed(struct desc_ring *ring, int cnt)
{
u32 status;
u32 addr;
for (i = 0; i < cnt; i++) {
// 确保顺序
status = READ_ONCE(ring[i].status); // 防止编译器优化
if (status & DESC_COMPLETE) {
/*
* DMA屏障:
* dma_rmb() 确保在读取buffer_addr之前,
* status的读取已经完成,且所有DMA写入对CPU可见
*/
dma_rmb();
addr = READ_ONCE(ring[i].buffer_addr);
process_data(addr);
// 清理状态
WRITE_ONCE(ring[i].status, 0);
}
}
}
/*
* ARM64中dma_rmb()的实现:
* #define dma_rmb() dsb(ishld)
*
* dsb(ishld):数据同步屏障,确保所有之前的加载指令完成
*/
终章:
7.1 新手最容易犯的3个错误
-
忘记同步:在CPU和DMA之间切换所有权时忘记sync
-
错误的对齐:缓冲区不是缓存行对齐
-
忽略dma_mask:设备只能访问32位地址,却给了64位地址
7.2 调试DMA驱动的标准流程
# 阶段1:基本验证 insmod my_dma.ko dmesg | grep DMA cat /proc/iomem | grep my_dma # 阶段2:开启调试 echo 1 > /sys/module/dma_debug/parameters/enable echo 8 > /proc/sys/kernel/printk # 阶段3:压力测试 ./dma_stress_test.sh 2>&1 | tee test.log # 阶段4:分析错误 grep "DMA-API" /var/log/kern.log grep "Call Trace" /var/log/kern.log # 阶段5:如果崩溃,分析vmcore crash vmlinux vmcore bt # 查看调用栈 log # 查看最后的内核消息
7.3 最后
写DMA驱动就像走钢丝:
-
dma_alloc_coherent是安全网,但慢 -
dma_map_single是快速通道,但容易摔 -
dma_debug是你的安全带,永远别拆
注意:DMA驱动里,能跑通的代码不一定对,压力测试不崩溃才可能是对的。
-
先用
dma_alloc_coherent把功能调通 -
确认所有逻辑正确后,再优化到流式DMA
-
每一步优化都要经过压力测试
-
永远保留回退路径
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)