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架构的弱一致性模型允许写缓冲合并和乱序提交,导致以下问题:
- 写缓冲延迟:CPU核心将
desc->buf_addr写入自己的写缓冲,但尚未刷入主存;此时desc->flags的写操作可能先到达内存总线。 - 多观察者不一致:DMA控制器作为独立的总线主设备,看到的写顺序与CPU核心看到的顺序不同。
- Store Buffer合并:CPU可能将多个相邻的写操作合并为一个总线事务,改变中间状态的可见性。
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描述符同步的头号敌人。编译器重排序和CPU乱序执行分别作用于编译时和运行时,必须使用编译器屏障(barrier())和CPU屏障(dma_wmb()/dma_rmb())组合防御。在弱一致性架构(ARM/PowerPC/RISC-V)上,CPU屏障是强制性的;即使在x86上,编译器屏障也不可省略。