本节摘要:ARM 是弱内存序架构:每个核心看自己的访存顺序不变,但看别的核心的顺序可能"重排"。本节用经典的双核消息传递实验演示"数据已写、标志已置,对方却读到旧数据"的撕扯现场,再引入
dmb屏障修复,最后给出dmb、dsb、isb三兄弟的分工表。
上一节解决的是"怎么搬",这一节解决"搬完之后别人看到什么"。这是 ARM 与 x86 差异最大的一块,也是从 x86 转来的工程师栽得最狠的一块——x86 是强序架构,很多不加屏障的代码在 x86 上碰巧正确,搬到 ARM 上就开始"随机"出鬼。本节先讲直觉,再做实验,实验里你会亲眼看到重排发生。
场景极简单:核心 0 往数据区写一个值,然后置标志;核心 1 轮询标志,看到置位后读数据区。人类直觉里,核心 1 读到的数据必然是新值——先写的数据嘛。可是在 ARM 上不加屏障时,这个直觉不成立。两个核心的观察代码(简化到最小):
// 核心0:写数据,再置标志(错误版:无屏障) str x1, [x0] // 数据区写入新值 mov w2, #1 str w2, [x3] // 标志置 1 // 核心1:轮询标志,读到置位后读数据(错误版:无屏障) poll: ldr w2, [x3] cbz w2, poll // 标志还是 0 就继续等 ldr x1, [x0] // 满怀信心地读数据——可能读到旧值
跑足够多次,核心 1 会以小概率读到旧数据。原因藏在两处:核心 0 侧,两条 str 在离开核心之前的完成顺序没有承诺——写缓冲可能让标志先于数据对其他核心可见;核心 1 侧,两条 ldr 之间也没有顺序承诺——推测执行可能让后面的读先于前面的读生效。单核视角各自"没毛病":核心 0 看自己写的顺序没变,核心 1 看自己读的顺序也没变;毛病出在跨核心的观察上。这正是弱序的定义:单核程序顺序保持,跨核观察顺序放开。
在两条 str 之间插一句 dmb ish(数据内存屏障,内共享域),核心 0 侧的顺序就焊死了——屏障之前的访存全部"对外可见"之后,屏障之后的访存才许出发。核心 1 读到标志后同样需要一条 dmb ish 再读数据,堵住自己侧的推测。修复后的代码只多两条指令,撕扯现场消失。
工程上的难点从来不是"加不加屏障",而是加在哪。三条实用判断规则:规则一,锁的加与解锁处天然成对需要屏障(互斥库替你做了,手写自旋锁必须自己做);规则二,"先数据后标志"这类发布模式,屏障加在两者之间,两核各一条;规则三,裸机配置外设时用更重的 dsb(完成同步屏障,等所有访存真正完成而非仅可见),改完系统寄存器后用 isb(指令屏障,冲掉流水线里已取的旧指令)。三兄弟分工表:
| 指令 | 拦什么 | 典型位置 |
|---|---|---|
dmb ish |
保证屏障前后的访存可见顺序 | 消息传递、自旋锁、发布模式 |
dsb sy |
等屏障前访存全部完成才继续 | 关缓存、清 TLB 后、外设配置 |
isb |
冲掉流水线中已取指令,重新取指 | 改系统寄存器(如页表基址)之后 |
展开说说那个"碰巧正确"。x86 的内存模型保证"每个核心看别人的写按程序序出现"(TSO),所以上面那个实验在 x86 上不加屏障也几乎安全——唯二需要屏障的场景是"先写后读"的跨核传递,而那种模式被编译器的 volatile 与原子库包圆了。于是 x86 工程师形成了"加锁就够了"的肌肉记忆。搬到 ARM 后,写与写之间也需要屏障了,旧直觉直接漏风。我们的建议是把弱序当成默认假设:任何"一个核写数据、另一个核依据标志读数据"的模式,无论平台,都显式写屏障——在 x86 上它是免费的空操作,在 ARM 上它是保命的。
实验收尾:在 QEMU 双核环境里把错误版循环跑上百万次计数撕扯次数,再插屏障对比——你多半会看到错误版偶发失败、修复版零失败。亲手跑过一次,你对"内存序"的敬畏就不再是背出来的,而是被烫出来的。
dmb 是"全量屏障"——把屏障前后的所有访存都管起来,代价是多等一拍。现代 AArch64 提供更精细的工具:获取与释放语义的加载存储。stlr w2, [x3] 等价于"这次 store 自带释放语义(之前的写都对它可见)",ldar w2, [x3] 等价于"这次 load 自带获取语义(之后的读不会提前)"。用它们改写撕扯实验,屏障指令直接消失:
// 核心0:数据写入后,用带释放语义的 store 置标志 str x1, [x0] stlr w2, [x3] // 替代 dmb ish 加 str 的组合 // 核心1:用带获取语义的 load 读标志 poll: ldar w2, [x3] cbz w2, poll ldr x1, [x0] // 获取语义保证这条读不会提前
两条专属指令比两条屏障更省——它们只约束"这一对访存"的顺序,不像 dmb 那样全线管制。自旋锁与无锁队列的内核实现里,ldar 加 stlr 已是标配。读现代内核源码的反汇编时看到它们,就明白这是"细粒度屏障"的产物。边界:语义成对才有意义——发布方用释放、订阅方用获取,只配一边等于没配;而多字节复合操作(如原子交换)仍需 ldxr 加 stxr 独占对或原子指令家族,那是另一套工具。
dmb 修序:管可见顺序,消息传递两端各一条,位置在数据与标志之间。dsb 等完成、isb 冲流水线:外设与系统寄存器场景的重型屏障。单核的语法与多核的秩序都有了。最后一站升到系统视角:虚拟地址如何变成物理地址,下一节见。