内存映射驱动开发:DMA缓冲区共享与性能优化 - 第4节
4.1 DMA描述符结构设计
DMA描述符是硬件与软件之间传输控制信息和数据地址的核心数据结构。每个描述符通常对应一个DMA传输单元(如一个数据包或一个缓冲区块)。设计合理的描述符结构是构建高效环形缓冲区的基础。
4.1.1 描述符核心字段
一个典型的DMA描述符包含以下关键字段,这些字段在硬件和软件之间共享:
| 字段 | 宽度(位) | 描述 |
|---|---|---|
| 缓冲区地址 | 32/64 | 数据缓冲区的物理地址(或IOVA) |
| 缓冲区长度 | 16 | 本次传输的字节数 |
| 控制标志 | 16 | 包含中断使能、链式标记、方向等控制位 |
| 状态字段 | 32 | 硬件写入,表示传输完成状态、错误码等 |
| 下一个描述符指针 | 32/64 | 链式模式下指向下一个描述符的物理地址 |
| 用户私有数据 | 32/64 | 软件保留字段,用于回调或上下文关联 |
设计要点:描述符必须对齐到缓存行边界(通常64字节),以避免伪共享(False Sharing)问题。控制标志和状态字段应放置在不同的32位字中,确保硬件写入状态时不会与软件修改控制标志产生竞争。
4.1.2 描述符结构示例(C语言)
/* 64位平台下的DMA描述符,总大小32字节 */
struct dma_descriptor {
__le64 buf_addr; /* 缓冲区物理地址 */
__le16 buf_len; /* 缓冲区长度 */
__le16 flags; /* 控制标志 */
__le32 status; /* 硬件写入的状态 */
__le64 next_desc; /* 链式下一个描述符地址 */
__le64 priv; /* 软件私有数据 */
} __attribute__((packed, aligned(64)));
/* 控制标志位定义 */
#define DESC_FLAG_OWN BIT(0) /* 所有权位:1=硬件,0=软件 */
#define DESC_FLAG_INT BIT(1) /* 传输完成后产生中断 */
#define DESC_FLAG_CHAIN BIT(2) /* 链式模式使能 */
#define DESC_FLAG_DIR_WR BIT(3) /* 方向:1=设备写内存,0=内存写设备 */
/* 状态位定义 */
#define DESC_STAT_DONE BIT(0) /* 传输完成 */
#define DESC_STAT_ERROR BIT(1) /* 传输错误 */
#define DESC_STAT_LENGTH GENMASK(31, 16) /* 实际传输长度 */
4.1.3 描述符所有权模型
描述符的所有权(Ownership)是同步的核心机制。通过一个所有权位(OWN bit)来标记当前描述符归硬件还是软件管理:
- OWN=1(硬件所有):硬件可以读取描述符并执行DMA传输,软件不得修改。
- OWN=0(软件所有):软件可以填充或回收描述符,硬件在完成传输后清除OWN位。
注意事项:所有权位的修改必须使用内存屏障(DMA屏障)来保证顺序。在x86上通常使用wmb()/rmb(),在ARM上需使用dma_wmb()/dma_rmb()。硬件写入OWN位时,必须确保所有数据已经写入内存(PCIe的强排序模型可简化此过程)。
4.2 环形缓冲区实现
环形缓冲区(Ring Buffer)是DMA描述符管理中最常用的数据结构。它将描述符组织成一个固定大小的循环队列,通过头指针(生产者)和尾指针(消费者)实现无锁或低锁的数据交换。
4.2.1 环形缓冲区结构
一个典型的DMA环形缓冲区包含以下组件:
- 描述符数组:连续内存中的描述符列表,大小通常为2的幂次。
- 生产者索引(head):软件写入新描述符的位置。
- 消费者索引(tail):硬件已处理完成的描述符位置。
- 硬件寄存器:设备内部的head/tail寄存器,用于硬件同步。
| 组件 | 所有者 | 作用 |
|---|---|---|
| 描述符数组 | 共享(软件分配) | 存储所有待处理/已完成的描述符 |
| 软件head | 软件 | 指向下一个可用的空闲描述符槽位 |
| 硬件tail | 硬件 | 硬件当前正在处理或已完成的描述符位置 |
| 硬件head寄存器 | 硬件(软件写入) | 通知硬件新的描述符已就绪 |
| 软件tail寄存器 | 软件(硬件写入) | 硬件通知软件已完成处理的描述符位置 |
4.2.2 环形缓冲区初始化
/* 环形缓冲区结构体 */
struct dma_ring {
struct dma_descriptor *desc; /* 描述符数组,DMA可访问 */
dma_addr_t desc_dma; /* 描述符数组的DMA地址 */
u32 size; /* 描述符数量(2的幂) */
u32 mask; /* size - 1,用于快速取模 */
u32 head; /* 软件生产者索引 */
u32 tail; /* 软件消费者索引(硬件更新) */
spinlock_t lock; /* 保护head/tail的锁 */
};
/* 初始化环形缓冲区 */
int dma_ring_init(struct dma_ring *ring, u32 num_descs)
{
/* 确保描述符数量为2的幂 */
if (!is_power_of_2(num_descs))
return -EINVAL;
ring->size = num_descs;
ring->mask = num_descs - 1;
/* 分配DMA一致内存 */
ring->desc = dma_alloc_coherent(dev,
num_descs * sizeof(struct dma_descriptor),
&ring->desc_dma, GFP_KERNEL);
if (!ring->desc)
return -ENOMEM;
memset(ring->desc, 0, num_descs * sizeof(struct dma_descriptor));
ring->head = 0;
ring->tail = 0;
spin_lock_init(&ring->lock);
/* 通知硬件初始的head/tail位置 */
writel(0, dev->regs + REG_DMA_HEAD);
writel(0, dev->regs + REG_DMA_TAIL);
return 0;
}
4.2.3 描述符提交与回收流程
软件提交描述符和硬件回收描述符的典型流程如下:
- 软件提交:软件获取空闲描述符,填充缓冲区地址、长度和标志,设置OWN=1,然后更新head索引。
- 通知硬件:软件将新的head值写入硬件寄存器,触发DMA引擎开始处理。
- 硬件处理:硬件从tail位置开始读取描述符,执行DMA传输,完成后清除OWN位并更新状态。
- 硬件通知:硬件更新tail寄存器(或通过中断),软件感知到已完成描述符。
- 软件回收:软件从tail到head之间扫描OWN=0的描述符,处理数据后回收。
性能关键点:描述符提交和回收应尽量批量进行。每次提交多个描述符可以减少寄存器写操作的次数;每次回收多个描述符可以减少中断处理的开销。建议在中断处理中一次性回收所有已完成的描述符,而不是逐个处理。
4.3 硬件与软件同步机制
DMA环形缓冲区的核心挑战在于硬件和软件之间高效、正确地同步。同步机制需要解决三个关键问题:描述符所有权交接、内存序保证、以及中断与轮询的平衡。
4.3.1 基于所有权的同步
所有权位(OWN)是最基本的同步手段。硬件和软件通过原子操作修改OWN位来交接描述符的控制权:
/* 软件提交描述符 */
void dma_ring_submit(struct dma_ring *ring, struct dma_descriptor *desc)
{
u32 idx = ring->head & ring->mask;
struct dma_descriptor *slot = &ring->desc[idx];
/* 填充描述符字段 */
slot->buf_addr = desc->buf_addr;
slot->buf_len = desc->buf_len;
slot->flags = desc->flags | DESC_FLAG_OWN; /* 设置所有权位 */
/* DMA屏障:确保描述符写入完成后再更新head */
dma_wmb();
/* 更新head索引 */
ring->head++;
/* 通知硬件新的head位置 */
writel(ring->head, dev->regs + REG_DMA_HEAD);
}
/* 软件回收描述符(在中断或轮询中调用) */
int dma_ring_reclaim(struct dma_ring *ring, u32 budget)
{
u32 reclaimed = 0;
u32 tail = readl(dev->regs + REG_DMA_TAIL); /* 读取硬件更新的tail */
while (ring->tail != tail && reclaimed < budget) {
u32 idx = ring->tail & ring->mask;
struct dma_descriptor *slot = &ring->desc[idx];
/* DMA屏障:确保读取状态前,硬件写入已完成 */
dma_rmb();
if (slot->status & DESC_STAT_ERROR) {
/* 处理错误描述符 */
handle_dma_error(slot);
} else {
/* 处理正常完成的描述符 */
process_completed_buffer(slot);
}
/* 清除描述符,准备给软件重新使用 */
slot->flags = 0;
slot->status = 0;
ring->tail++;
reclaimed++;
}
return reclaimed;
}
4.3.2 内存序与屏障
DMA操作涉及多个内存域(CPU缓存、设备内存、PCIe桥),内存序保证至关重要:
| 场景 | 需要的屏障 | 说明 |
|---|---|---|
| 软件写入描述符后通知硬件 | dma_wmb() |
确保描述符数据在head更新前对设备可见 |
| 硬件写入状态后通知软件 | dma_rmb() |
确保状态读取在tail读取之后(或使用一致性内存) |
| 软件修改描述符后重新提交 | dma_wmb() |
确保OWN位清除和字段填充的顺序 |
| 中断处理中读取描述符 | dma_rmb() 或 mb() |
确保中断状态寄存器读取与描述符读取的顺序 |
优化技巧:如果使用DMA一致性内存(dma_alloc_coherent),硬件和软件看到的内存视图是一致的,不需要额外的屏障。但一致性内存的分配和同步开销较大,适合描述符数量较少的场景。对于高性能场景,建议使用流式DMA映射并配合显式屏障。
4.3.3 中断与轮询策略
硬件通知软件描述符完成的方式直接影响系统吞吐量和延迟:
- 中断模式:每个描述符完成或每批描述符完成后触发中断。延迟低,但中断开销在高吞吐场景下可能成为瓶颈。
- 轮询模式:软件定期检查tail寄存器或描述符状态。吞吐量高,但CPU占用率高。
- 混合模式(NAPI):初始使用中断,在中断处理中切换到轮询模式,处理完一批后再重新开启中断。网络驱动中的NAPI是典型实现。
/* NAPI风格的DMA完成处理 */
static int dma_poll(struct napi_struct *napi, int budget)
{
struct dma_ring *ring = container_of(napi, struct dma_ring, napi);
int work_done = 0;
/* 轮询回收描述符 */
work_done = dma_ring_reclaim(ring, budget);
if (work_done < budget) {
/* 没有更多工作,关闭轮询,重新开启中断 */
napi_complete_done(napi, work_done);
enable_irq(ring->irq);
}
return work_done;
}
/* 中断处理函数 */
irqreturn_t dma_interrupt(int irq, void *dev_id)
{
struct dma_ring *ring = dev_id;
/* 禁用中断,切换到轮询模式 */
disable_irq_nosync(irq);
napi_schedule(&ring->napi);
return IRQ_HANDLED;
}
4.3.4 同步机制对比
| 同步方式 | 延迟 | 吞吐量 | CPU占用 | 适用场景 |
|---|---|---|---|---|
| 中断(每描述符) | 低 | 低 | 中 | 低速设备、延迟敏感 |
| 中断(批量) | 中 | 中 | 低 | 通用场景 |
| 轮询 | 高 | 高 | 高 | 高速设备、吞吐优先 |
| NAPI混合 | 低-中 | 高 | 中 | 网络设备、高性能存储 |
| 硬件时间戳触发 | 可预测 | 高 | 低 | 实时系统、确定性需求 |
4.3.5 常见同步问题与解决方案
问题1:描述符覆盖(Overrun)
当软件提交速度超过硬件处理速度时,head可能追上tail,导致未处理的描述符被覆盖。
解决方案:在提交前检查环形缓冲区剩余空间,保留至少一个槽位作为“警戒线”。
当软件提交速度超过硬件处理速度时,head可能追上tail,导致未处理的描述符被覆盖。
解决方案:在提交前检查环形缓冲区剩余空间,保留至少一个槽位作为“警戒线”。
if (ring->head - ring->tail >= ring->size - 1) return -EBUSY;
问题2:中断丢失(Missed Interrupt)
在高速传输中,硬件可能在软件处理上一个中断时已经完成了多个描述符,导致中断合并或丢失。
解决方案:在中断处理中轮询所有已完成的描述符,而不是仅处理一个。使用NAPI机制自动在中断和轮询之间切换。
在高速传输中,硬件可能在软件处理上一个中断时已经完成了多个描述符,导致中断合并或丢失。
解决方案:在中断处理中轮询所有已完成的描述符,而不是仅处理一个。使用NAPI机制自动在中断和轮询之间切换。
问题3:缓存一致性问题
软件修改描述符后,硬件可能读取到缓存中的旧数据(特别是在非一致性架构如ARM上)。
解决方案:使用dma_wmb()确保写入顺序,或使用dma_alloc_coherent分配一致性内存。对于流式映射,在提交前调用dma_sync_single_for_device()。
软件修改描述符后,硬件可能读取到缓存中的旧数据(特别是在非一致性架构如ARM上)。
解决方案:使用dma_wmb()确保写入顺序,或使用dma_alloc_coherent分配一致性内存。对于流式映射,在提交前调用dma_sync_single_for_device()。
4.3.6 性能优化建议
- 批量提交:累积多个描述符后一次性更新head寄存器,减少PCIe写事务次数。
- 预取描述符:软件在回收描述符时,使用prefetch指令提前加载下一个描述符到缓存。
- 缓存行对齐:描述符数组按缓存行对齐,避免不同描述符之间的伪共享。
- 减少中断频率:通过描述符的INT标志控制中断产生频率,例如每N个描述符产生一次中断。
- 使用写合并:在x86上,对head寄存器的写入可以使用写合并(Write Combining)内存类型,减少总线事务。
实战案例:在某NVMe驱动中