4. 内存重排序问题:编译器重排序与CPU乱序执行对DMA描述符的影响

在DMA描述符同步中,内存重排序是导致数据不一致的最隐蔽原因之一。即使驱动代码在逻辑上正确,编译器或CPU对内存访问顺序的重新排列,也可能让描述符的所有权标志位与数据载荷的更新顺序颠倒,导致硬件读取到不完整的描述符。

核心矛盾:驱动期望“先写数据,再写标志位”,但编译器或CPU可能实际执行成“先写标志位,再写数据”,从而让DMA控制器拿到错误的描述符。

4.1 内存重排序的两大来源

重排序来源 触发条件 影响范围 典型场景
编译器重排序 编译器优化(-O2/-O3)时,认为两个无关内存操作可以交换顺序 单线程编译单元内 描述符字段赋值被重新排列
CPU乱序执行 现代CPU(ARM/PowerPC/x86)的写缓冲与乱序流水线 多核/多观察者视角 CPU核心与DMA控制器看到的写顺序不一致
关键认知:编译器重排序是编译时问题,CPU乱序是运行时问题。两者都需要通过不同的屏障指令来抑制。

4.2 编译器重排序对DMA描述符的影响

考虑以下典型的描述符提交代码(未加屏障):

/* 驱动代码:提交一个TX描述符 */
desc->buf_addr   = cpu_to_le32(dma_addr);   // 写1:数据缓冲区地址
desc->buf_len    = cpu_to_le32(pkt_len);     // 写2:数据长度
desc->flags      = cpu_to_le32(OWN_BIT);     // 写3:所有权标志位(最后写)

编译器在开启优化后,可能将desc->flags的赋值提前到desc->buf_addr之前,因为从编译器视角看,这三个写操作之间没有数据依赖关系。结果:DMA控制器看到OWN_BIT置位后,立即读取buf_addr,但此时buf_addr还是旧值(或未初始化值),导致DMA传输到错误的内存地址。

避坑指南:不要依赖代码书写顺序!编译器优化会打乱所有“看起来无关”的赋值。必须使用barrier()或WRITE_ONCE()来阻止编译器重排序。

4.3 CPU乱序执行对DMA描述符的影响

即使编译器生成了正确的顺序,CPU在运行时也可能对写操作进行重排序。ARM架构的弱一致性模型允许写缓冲合并和乱序提交,导致以下问题:

  1. 写缓冲延迟:CPU核心将desc->buf_addr写入自己的写缓冲,但尚未刷入主存;此时desc->flags的写操作可能先到达内存总线。
  2. 多观察者不一致:DMA控制器作为独立的总线主设备,看到的写顺序与CPU核心看到的顺序不同。
  3. Store Buffer合并:CPU可能将多个相邻的写操作合并为一个总线事务,改变中间状态的可见性。
CPU乱序执行导致DMA描述符同步失败 驱动期望的写顺序 ① 写 desc->buf_addr ② 写 desc->buf_len ③ 写 desc->flags (OWN=1) CPU乱序后的实际顺序 ① 写 desc->flags (OWN=1) ← 提前! ② 写 desc->buf_addr (延迟) ③ 写 desc->buf_len (延迟) 重排序 DMA控制器看到OWN=1后立即读取描述符 但buf_addr和buf_len尚未写入主存 → 传输到错误地址 DMA控制器:读到不完整的描述符

4.4 不同架构的重排序行为对比

架构 内存模型 写-写重排序 写-读重排序 对DMA描述符的影响程度
x86/AMD64 强一致性(TSO) 不允许 允许(StoreLoad) 低:仅需StoreLoad屏障
ARMv7/v8 弱一致性 允许 允许 高:需要DMB/DSB全面屏障
PowerPC 弱一致性 允许 允许 高:需要sync/lwsync
RISC-V 弱一致性(RVWMO) 允许 允许 高:需要fence指令
经验法则:在ARM/PowerPC等弱一致性架构上,每次写描述符数据后、写所有权标志位之前,都必须插入写屏障(wmb()或dma_wmb())。x86上虽然硬件保证写-写顺序,但编译器重排序仍需防范。

4.5 编译器屏障 vs CPU屏障:分工与协作

解决内存重排序需要两层屏障协同工作:

屏障类型 作用对象 Linux API 开销 适用阶段
编译器屏障 编译器优化 barrier() 零(仅阻止优化) 编译时
CPU写屏障 CPU写缓冲/乱序 wmb() / dma_wmb() 中等(刷写缓冲) 运行时
CPU全屏障 读写双向 mb() / dma_mb() 高(等待所有缓冲) 运行时
/* 正确的描述符提交模式:双重屏障 */
/* 步骤1:写描述符数据 */
desc->buf_addr = cpu_to_le32(dma_addr);
desc->buf_len  = cpu_to_le32(pkt_len);

/* 步骤2:编译器屏障 + CPU写屏障 */
barrier();          // 阻止编译器重排序
dma_wmb();          // 确保数据写操作在标志位之前被DMA控制器看到

/* 步骤3:写所有权标志位 */
desc->flags = cpu_to_le32(OWN_BIT);
常见错误:只使用barrier()而不使用dma_wmb()。在ARM上,barrier()只阻止编译器重排序,但CPU仍可能乱序执行。反之,只使用dma_wmb()而不使用barrier(),编译器可能在编译时就把标志位赋值提前。

4.6 描述符回收时的读重排序问题

当DMA控制器完成传输并清空所有权标志位后,驱动读取描述符时同样面临重排序风险:

/* 驱动轮询等待DMA完成 */
while (le32_to_cpu(desc->flags) & OWN_BIT) {
    cpu_relax();
}

/* 此时驱动认为DMA已完成,读取结果 */
/* 问题:CPU可能乱序读取desc->status,读到旧值! */
if (le32_to_cpu(desc->status) & ERROR_BIT) {
    /* 错误处理 */
}

正确的做法是在读取标志位之后、读取数据之前插入读屏障:

/* 正确的描述符回收模式 */
while (le32_to_cpu(desc->flags) & OWN_BIT) {
    cpu_relax();
}

/* 读屏障:确保flags的读取完成后再读取其他字段 */
dma_rmb();

/* 安全读取结果 */
if (le32_to_cpu(desc->status) & ERROR_BIT) {
    /* 错误处理 */
}

4.7 总结:内存重排序的防御策略

DMA描述符同步:内存重排序防御策略 描述符提交路径(驱动→DMA) 写数据字段 dma_wmb() 写OWN标志位 描述符回收路径(DMA→驱动) 读OWN标志位 dma_rmb() 读结果字段 防御策略三原则 ① 提交时:先写数据 → dma_wmb() → 写OWN标志位 ② 回收时:先读OWN标志位 → dma_rmb() → 读结果数据 ③ 始终使用 dma_wmb()/dma_rmb() 而非通用 mb() 以降低开销
核心要点:内存重排序是DMA描述符同步的头号敌人。编译器重排序和CPU乱序执行分别作用于编译时和运行时,必须使用编译器屏障(barrier())和CPU屏障(dma_wmb()/dma_rmb())组合防御。在弱一致性架构(ARM/PowerPC/RISC-V)上,CPU屏障是强制性的;即使在x86上,编译器屏障也不可省略。