ARM内存排序与屏障指令

从弱排序模型的基础原理到 DSB/DMB/ISB 三条屏障指令的精确语义,完整覆盖 ARMv7-A 内存访问顺序的控制机制与典型应用场景。

1. 弱排序模型

1.1 旧式 ARM 的顺序执行

旧式 ARM 实现(如 ARM7TDMI)严格按程序顺序执行指令:每条指令完整执行后,下一条才启动。编译生成的二进制和源码在执行语义上完全一致。

Cortex-A 系列不再遵守这种简单约定。核心运行速度远高于外部内存——为了隐藏访存延迟,ARM 引入了缓存和写缓冲器。性能优化的代价是访问重排序。

ARM 文档中的标准示例:

1
2
3
4
5
6
7
8
9
程序顺序:
STR R12, [R1] @ Access 1 — 进入写缓冲器
LDR R0, [SP], #4 @ Access 2 — 缓存缺失,触发 linefill
LDR R2, [R3, #8] @ Access 3 — 缓存命中

实际执行时间线:
Access 3(命中)最先完成
Access 2 的 linefill 在后台进行
Access 1 的写缓冲器写入最后才真正完成

三条指令的完成顺序是 3→2→1,与程序序完全相反。hit-under-miss 机制让命中的 Access 3 在更早但缺失的 Access 2 之前完成——即使 Access 3 在程序顺序中更晚到达。

ARMv7-A 正式采用弱排序模型(weakly-ordered model):load/store 指令的程序顺序不强制等于它们在硬件总线上的可见顺序。这一模型仅受数据依赖显式屏障指令约束。在单核系统中,硬件自动处理数据依赖和读后写冒险,保证语义正确;多核共享内存场景才是需要手动插入屏障的真正战场。

架构不保证指令预取和页表遍历的排序——排序规则仅适用于显式内存访问(由 load/store 指令引起)。

1.2 无屏障多核代码的四种合法结果

考虑两核各自执行两条指令的最简情况(Core A 写 Addr1 然后读 Addr2,Core B 写 Addr2 然后读 Addr1):

1
2
3
@ Core A:                  @ Core B:
STR R0, [Addr1] STR R2, [Addr2]
LDR R1, [Addr2] LDR R3, [Addr1]

由于 Addr1 和 Addr2 互相独立,且全为 Normal 内存,两核均无义务保证 load 在 store 之后执行,也无义务关心对端核心的操作顺序。因此这段代码存在四种合法的执行结果

  1. Core A 读到旧值,Core B 读到旧值
  2. Core A 读到旧值,Core B 读到新值
  3. Core A 读到新值,Core B 读到旧值
  4. Core A 读到新值,Core B 读到新值

更进一步,若引入第三个 Core C,C 完全可以观察到与 A 和 B 都不同的写顺序——A 和 B 各自看到旧值,但 C 看到新值,在弱排序模型下是合法的。这清楚地说明了:当多核之间有数据依赖时,必须显式插入内存屏障。

强排序的诉求不是指令流水线的事情,它首先由内存类型决定。下面从 Strongly-ordered、Device 和 Normal 三种内存的行为差异开始,看排序在不借助屏障时能提供什么保证。


2. 三种内存类型与排序保证

ARMv7-A 定义了三种互斥的内存类型,由页表描述符的 TEX/C/B 位编码。

2.1 排序保证矩阵

A1 → A2(程序序) A2=Normal A2=Device A2=Strongly-ordered
A1=Normal 不保证 不保证 不保证
A1=Device 不保证 程序顺序 程序顺序
A1=Strongly-ordered 不保证 程序顺序 程序顺序

核心结论:只有双方都是非 Normal 时,才保证程序顺序。任意一方是 Normal,排序都不保证。核心可以将 Normal 访问重排到 Strongly-ordered 或 Device 访问的任意一侧。

实例:将 UART TX 寄存器映射为 Device 内存。写入 STR R0, [UART_TX] 后,若下一条是另一 Device 区域的写,硬件保证两写在总线上按程序顺序出现;但若下一条是 Normal 内存的 LDR,则该 LDR 可能在 STR 写入之前完成——若 DMA 引擎同时读取 UART_TX,后果不可预测。

2.2 Strongly-ordered 与 Device 的行为约束

属性 Strongly-ordered Device
访问数量/大小保持
原子性(不会中途打断)
永不缓存
无推测性访问
禁止非对齐访问
写操作何时视为完成? 数据到达目标外设/内存才完成 可以在到达目标前提前完成

Strongly-ordered 和 Device 在排序规则上完全一致——两者的唯一区别在于写操作的完成点。Strongly-ordered 必须等待数据到达目标外设或内存才视为完成,而 Device 允许提前完成。这使得 Strongly-ordered 更慢,仅在绝对需要精确时序的场合使用。系统外设几乎总是映射为 Device 内存。

Device 内存的程序顺序保证有一个重要限定:仅适用于同一外设或同一内存块内的访问。块的大小由具体实现定义,但最小为 1KB。对两个分别位于不同 1KB 块中的 Device 外设的访问,即使它们在程序序中前后排列,也不保证在总线上的完成顺序。

Device 类型支持 Shareable 属性。在 ARMv6 上,这曾用于选择专用外设端口(peripheral private port),但 ARMv7 上该用法已废弃。

2.3 Normal 内存的行为特征

Normal 内存具备最宽松的约束和最激进的优化:

行为 含义
可重复读取 核心可多次读同一地址
可推测访问 可预取额外内存位置(无副作用时)。但核心不会执行推测性写入
可合并写入 多个字节写可合并为一个双字写
允许非对齐 非对齐 load/store 不触发异常

Normal 内存通过 inner/outer 两级缓存属性区分 L1 和 L2/L3 的缓存策略。Inner 总是指最内层缓存(必定包含 L1)。Outer 缓存因实现而异——例如 Cortex-A9 + L2C-310 系统中 L2C-310 是 outer cache,而 Cortex-A8 的集成 L2 既可配置为 inner 也可为 outer。

Shareability 属性决定了缓存一致性域:

  • Non-Shareable:仅本核使用。多核场景下软件自行维护一致性
  • Inner Shareable:同簇内硬件保证一致性(如 A15 簇内四核)
  • Outer Shareable:跨簇一致性(如 A15 簇 + A7 簇 + GPU)。Outer 域可包含多个 Inner 域,但一个 Inner 域只能属于一个 Outer 域

内存类型靠页表声明,这些静态属性只能约束默认行为。当多核之间需要精确的访问顺序时,需要下一节的主角:DSB、DMB 和 ISB 三条屏障指令。


3. 内存屏障指令:DSB、DMB、ISB

ARM 内存屏障在其他架构中常被称为 memory fence。ARM 提供三条指令,在 User 和 Privileged 模式下均可使用。值得注意的历史背景:在较旧的 ARM 架构中,内存屏障通过 CP15 操作实现(仅 ARM 代码可用),该方式现已废弃——虽然出于兼容性保留,但新代码必须使用 DMB/DSB/ISB 指令。

3.1 三条屏障的核心区分

指令 全称 行为 典型场景
DMB Data Memory Barrier 保证 DMB 前的所有显式内存访问在 DMB 后的任何显式内存访问之前被所有观察者看到 多核数据通信(生产者写好数据→DMB→写 flag)
DSB Data Synchronization Barrier DMB 的全部效果 + 阻止后续指令执行直到所有显式内存访问完成 写外设寄存器后需等待外设响应
ISB Instruction Synchronization Barrier 冲刷流水线和预取缓冲,后续指令从缓存/内存重新取指 CP15 修改、ASID/TLB 变更后

区分标准:DMB 只影响内存访问的可见顺序,不阻塞指令流。DSB 额外阻塞指令执行直到内存操作完成。ISB 处理指令流的一致性问题,不涉及数据侧。

3.2 DMB/DSB 的域选项

选项 含义 范围
SY 全系统屏障(默认) 所有观察者
ST 仅等待 store 完成
ISH Inner Shareable 域 同簇内所有核心
ISHST Inner Shareable + 仅 store
NSH Non-Shareable 域(仅本核)
OSH Outer Shareable 域 跨簇(含 GPU/DMA)
OSHST Outer Shareable + 仅 store

四核 Cortex-A9 簇构成一个 Inner Shareable 域,DMB ISH 保证簇内四核看到屏障两侧的访问顺序一致;但 DMA 控制器和 DSP 不在 ISH 域内,不享受此保证。跨域可见时应使用 DMB SYDMB OSH

3.3 应用实例一:多核消息传递

两核通过 flag 通信的场景——Core A 写入消息后置 flag,Core B 轮询 flag 后读取消息:

1
2
3
4
5
6
7
8
9
10
11
12
@ Core A: 写入消息,然后置 flag
STR R0, [Msg] @ 写消息数据
DMB @ 保证 Msg 在 Flag 之前被所有观察者看到
STR R1, [Flag] @ 设置标志位

@ Core B: 轮询 flag,读到后读取消息
Poll_loop:
LDR R1, [Flag] @ 读标志
CMP R1, #0
BEQ Poll_loop
DMB @ 保证 Flag 读在 Msg 读之前完成
LDR R0, [Msg] @ 读消息数据

两条不可省略的原则:生产者需要 DMB 在两次 store 之间保证写顺序;消费者需要 DMB 在 flag 读和消息读之间保证不拿到旧数据。缺少任意一个屏障都构成竞态。

3.4 应用实例二:避免外设死锁

写外设寄存器后轮询确认的代码:

1
2
3
4
5
6
STR  R0, [Addr]       @ 向外设寄存器写入命令
DSB @ 强制写入完成,而非停留在写缓冲器
Poll_loop:
LDR R1, [Flag] @ 轮询外设状态
CMP R1, #0
BEQ Poll_loop

若没有 DSB,写入可能永远停留在写缓冲器中——而核心轮询 flag 的总线流量可能使写缓冲器一直不排空。这是弱排序模型中最容易遗漏的死锁场景。解决方案是 DSB 强制冲刷写缓冲器。

需要注意的是:实现了多处理器扩展(Multiprocessing Extensions)的核心被要求写缓冲器在有限时间内必须排空——此时 DSB 在技术上是可选的,但不依赖这一硬件假设是最安全的实践。

3.5 应用实例三:WFE/SEV 自旋锁

WFE(Wait For Event)和 WFI(Wait For Interrupt)使核心暂停执行并进入低功耗状态。在进入低功耗前必须插入 DSB,确保之前的所有内存访问已被其他核心观察到。

在多处理器系统中,WFESEV(Send Event)配对使用以降低自旋锁的功耗——等待锁的核心通过 WFE 休眠,持锁核心释放锁后通过 SEV 唤醒等待者:

1
2
3
4
@ Core A: 释放锁
STR R0, [lock] @ 清除锁变量
DSB @ 保证锁清除对其他核心可见后,再发送事件
SEV @ 唤醒等待的核心

这里必须用 DSB 而非 DMB:DMB 只影响内存访问的可见顺序,但不保证 SEV 指令在内存写入被其他核心观察到之后才执行。被唤醒的核心可能仍读到旧锁值,导致两个核心同时认为自己持锁。

3.6 Linux 内核中的屏障映射

硬件内存屏障和编译屏障是两种独立的机制。编译屏障阻止的是编译器在优化阶段将内存访问跨屏障重排,对 CPU 硬件的乱序执行完全无影响。GCC 的实现:

1
asm volatile("" ::: "memory");    // 编译器屏障:禁止跨越此语句重排访存

ARM RVCT 提供等价的 __schedule_barrier() 内建函数。

ARM Linux 中的屏障映射关系(编译选项含 Cortex-A SMP 支持):

Linux 函数 效果 对应 ARM 指令
barrier() 仅编译屏障(阻止编译器重排) 无硬件指令
rmb() 读屏障 DMB
wmb() 写屏障 DMB ST
mb() 全内存屏障 DMB
smp_mb() SMP 全屏障(CONFIG_SMP 时编译为 DMB ISH) DMB ISH
smp_rmb() SMP 读屏障 DMB ISH
smp_wmb() SMP 写屏障 DMB ISHST

在未启用 CONFIG_SMP 的内核中,smp_*() 系列仅展开为 barrier() 编译屏障——不产生任何硬件指令。Linux 所有锁原语已经包含必要的屏障,设备驱动和内核模块开发者通常无需显式调用,但理解这些函数的存在是为了阅读现有代码和调试罕见竞态。


4. 缓存一致性与编译器重排

4.1 DMA 场景的缓存维护

在内存排序的上下文中重新审视缓存一致性问题——两个方向的数据不一致:

  • 核心到 DMA(核心写,DMA 读):DMA 启动前必须 Clean 数据缓存中的 Dirty 行,因为写缓冲器中的待完成写入和缓存中的 Dirty 数据都对 DMA 不可见
  • DMA 到核心(核心读,DMA 写):DMA 完成后必须 Invalidate 缓存中被 DMA 更新的地址范围;此外,由于所有 ARMv7-A 处理器均可做推测性内存访问,DMA 传输之后也需要做 invalidate——不能假设”核心刚还没读到”就意味着不持旧数据

4.2 自修改代码与 JIT 编译

Boot 代码、内核模块加载器和 JIT 编译器需要拷贝或修改代码。标准序列:Clean 数据缓存(确保新指令码到达主存)→ Invalidate 指令缓存(确保不再取到旧指令)→ ISB(冲刷流水线,保证后续取指看到新代码)。指令缓存和数据缓存之间没有硬件一致性维护机制,这三个步骤缺一不可。

4.3 volatile 不能替代内存屏障

这是最容易被误用的 C 语言特性:

  • volatile 仅保证编译器不优化掉该变量访问,且 volatile 访问之间按程序顺序执行
  • volatileCPU 硬件的重排、写缓冲合并、推测性访存完全没有约束力
  • C 标准不涉及多核系统——volatile 在多核环境中不提供任何排序保证

volatile 不能用于实现互斥锁。互斥锁只能用硬件屏障实现。Linux 中设备驱动访问 MMIO 使用 volatile 是正确的(它阻止编译器优化外设寄存器读写),但多核数据共享依赖 smp_mb()


5. 总结

  1. ARMv7-A 是弱排序模型。load/store 的程序顺序不等于硬件执行顺序。缓存命中可超越缺失先行完成,写缓冲器可让 store 在后续 load 之后才落地。这是性能优化,不是 bug。排序规则仅适用于显式内存访问——不覆盖指令预取和页表遍历。

  2. 三种内存类型的排序保证只作用于非 Normal 访问之间。Normal 内存的访问可在任意时刻被重排到 Device 或 Strongly-ordered 访问的任意一侧。Device 内存的程序序保证仅限于同一 1KB 块内的访问。

  3. DMB 只约束内存操作的可见顺序,不阻塞指令流。DSB = DMB + 阻塞后续指令直到内存操作完成。ISB 冲刷流水线,不处理数据侧。典型组合:数据操作后 DSB → 改 CP15 → ISB。旧式 CP15 实现的屏障已废弃,新代码必须使用这三条指令。

  4. 多核 flag 通信需要两侧 DMB——生产者 store 之间放 DMB 保证写顺序,消费者 load 之间放 DMB 保证读顺序。少一个就是竞态。

  5. 写外设后轮询必须用 DSB,不是 DMB。写缓冲器可能导致写入永远不排空。在多处理器扩展实现的核心上 DSB 技术上可选,但不依赖硬件假设是最安全的实践。

  6. WFE/SEV 自旋锁释放路径的关键是 DSB(不是 DMB)。DMB 不保证 SEV 执行前内存写入对其他核心可见——被唤醒的核可能读到旧锁值,导致两个核心同时认为自己持锁。

  7. volatile 是编译器工具,不是 CPU 工具。它能阻止编译器优化掉 MMIO 访问,但对硬件乱序、写合并、推测读取无能为力。多核数据共享的唯一正确方案是硬件屏障指令——且 Linux 的锁原语已经内置了所需屏障。