☰
AArch64内存屏障:从barrier.h到dmb/ldar
2026/10/26 22:09:41 网站建设 项目流程

同一份单生产者单消费者的环形队列代码,在 x86 服务器上跑了两年都没出过岔子,换到一块 AArch64 开发板上,第三天开始零星丢数据:生产者明明已经把tail索引更新了,消费者读到的tail是新的,对应槽位里的数据却是上一轮的旧值。加了一句barrier()没救,加了个udelay(1)反而"好了"——这种"靠延迟掩盖乱序"的修法,恰恰说明代码里缺的不是时间,而是dmb。在 AArch64 上写并发代码,内存屏障由架构的内存模型强制要求,不是"性能优化选项"。

这篇内容拆的是 Linux 内核在 aarch64 下对内存屏障的定义:arch/arm64/include/asm/barrier.h里那几个宏到底展开成什么指令,smp_mb()和mb()为什么长得不一样,dmb ish与dmb ishld的区别值不值得关心,ldar/stlr又是怎么把 acquire/release 语义落到单条指令上的。面向的是写过驱动、调过并发、被"偶发丢包"折磨过的人,也适合刚接手 AArch64 平台移植、想搞清楚"为什么 x86 上能跑"的朋友。下面所有内容都基于内核源码和架构手册的常见实现,具体行号以你手上的代码树为准。

1. AArch64 的内存模型决定了屏障不是可选项

1.1 一个只在换平台后才冒出来的乱序现象

先还原开头那个场景。共享内存里放一个环形队列,生产者做三步:写数据到buf[i],写buf[i].len,最后WRITE_ONCE(tail, i + 1)。消费者反过来:读到tail变了,就去读buf[i]。在 x86 上这段代码通常能跑,因为 x86 是 TSO 模型,除了 store 后面跟 load 这一种情况,其余访问顺序都被硬件保住了——store-store 是有序的,load-load 也是有序的,所以你"先写数据再写索引"这件事在 x86 上几乎白送。

AArch64 不吃这一套。ARMv8 是典型的弱内存序模型,普通 Normal 内存访问在满足条件时可以被硬件重排、合并、提前提交。生产者那条WRITE_ONCE(tail, ...)完全可能先于前面的buf[i]写入对其它核可见。消费者看到新tail就放心地去读数据,读到的是旧的。这里缺的东西非常明确:生产者需要一个 store-store 屏障确保数据写在对tail的写之前可见,消费者需要一个 load-load(更准确地说是 acquire)屏障确保读到tail之后才去读buf[i]。

把udelay(1)塞进去"就好了",本质上是给硬件留了足够时间让写缓冲排空,是用概率换正确性。负载一上来、cache 竞争一激烈,延迟就不够了。我在实际项目里见过把udelay从 1 调到 10、再到 100 的"优化过程",最后系统在高温降频时又挂了。这种坑只有一次和无数次。

1.2 弱内存序到底弱在哪儿:四类顺序关系里哪些不保证

讨论屏障之前得先把"顺序"这件事拆开。两个内存访问 A 和 B(A 在程序序中先于 B),组合起来有四类:load-load、load-store、store-store、store-load。不同架构对这几类的保证程度完全不同,这就是为什么同一段代码在不同平台上表现不一致。

x86 的 TSO 强在只放开 store-load 一类,其它三类硬件保底,所以写 x86 并发代码的人对屏障的直觉普遍偏弱。ARMv8 的普通内存访问默认四类都可能乱序,只有几种情况是例外:对同一地址的访问由一致性序(coherence order)保证;地址依赖的 load 链(A 加载出地址,B 用这个地址去加载)保证有序;显式的 acquire/release 或者屏障指令能建立顺序。除此之外,硬件没有义务配合你。

这里有个容易被忽略的点:控制依赖在 ARMv8 上不保证有序。也就是if (READ_ONCE(flag)) x = READ_ONCE(data);这种写法,硬件可以先投机地把data读上来,再看flag。所以凡是靠"我先判断条件再去读数据"来保证正确性的代码,在 AArch64 上都必须补一个 acquire 语义的屏障。这一条坑过的人比想象中多,因为在很多资料里"控制依赖"被默认当成安全的。

1.3 Linux 的屏障抽象层:AArch64 在哪个文件里给出定义

内核没有让驱动直接写dmb,而是给了一套分层的宏。理解这套分层是读懂源码的前提,因为同一个名字在不同配置下展开结果完全不同。

  • include/linux/compiler.h:barrier()、READ_ONCE()、WRITE_ONCE(),这一层只管编译器,barrier()展开成asm volatile("" : : : "memory"),对 CPU 毫无约束。
  • include/asm-generic/barrier.h:smp_mb()、smp_rmb()这些通用包装,负责在!CONFIG_SMP时退化、在CONFIG_SMP时调度到架构实现的__smp_mb()。
  • arch/arm64/include/asm/barrier.h:真正给出指令的地方,__smp_mb()、__smp_rmb()、__smp_wmb()、mb()、rmb()、wmb()、isb()都在这。
  • arch/arm64/include/asm/io.h:readl()/writel()里挂的 I/O 屏障。
  • arch/arm64/include/asm/cmpxchg.h、atomic_ll_sc.h、atomic_lse.h:原子操作自带的顺序,以及smp_mb__before_atomic()之类的补丁点。

分层带来的一个直接后果是:你在驱动里写的smp_mb(),在单核配置下什么指令都不会生成,只剩一个编译屏障。所以不要拿单核配置的编译产物去判断多核行为,反汇编必须在CONFIG_SMP=y的配置下看。

2. barrier.h 展开之后的真实指令

2.1 编译屏障挡得住编译器,挡不住乱序执行

barrier()的定义在多版本内核里都是这一句:

#define barrier() __asm__ __volatile__("": : :"memory")

它做两件事:volatile让编译器不要把这个空的 asm 优化掉,"memory"clobber 告诉编译器"这条语句前后,所有内存内容都可能被改了",于是编译器不敢把全局变量、指针解引用的读写跨过这条线重排,也不敢把多次读合并成一次。它完全不生成任何机器指令,对 CPU 的乱序执行、写缓冲、多核可见性没有任何影响。

这就是新手最常踩的第一个坑:以为加了barrier()就有了内存屏障。实际运行中,编译器确实不再乱动了,但 CPU 该重排还重排。反汇编里barrier()的位置干干净净,一条指令都没有——这本身就是最好的自检信号:如果你的目的是跨核可见性,而反汇编里屏障位置没有任何指令,那大概率写错了。

READ_ONCE()/WRITE_ONCE()同理,它们解决的是编译器层面的单次访问问题(防止撕裂读、防止编译器假定值不变、防止合并),在 AArch64 上展开成普通的ldr/str,不带任何顺序保证。想让WRITE_ONCE之后的写先于它对别的核可见,还得自己加smp_wmb()或者改用smp_store_release()。

2.2 smp_mb() 落在 dmb ish 上

arch/arm64/include/asm/barrier.h里,多核场景的核心三兄弟大致长这样:

#define __smp_mb() asm volatile("dmb ish" ::: "memory") #define __smp_rmb() asm volatile("dmb ishld" ::: "memory") #define __smp_wmb() asm volatile("dmb ishst" ::: "memory")

dmb是 Data Memory Barrier,它不阻塞流水线、不等待访存完成,只做一件事:保证这条指令之前的所有相关内存访问,在它之后的相关内存访问之前被观察到。ish是 Inner Shareable 域,覆盖同一一致性域内的所有 PE(通常就是一个 SoC 里所有 CPU 核,包括超线程意义上的兄弟核)。

smp_mb()是最强的 SMP 屏障,四条顺序关系全包。它贵,但不是贵在"等待",而是贵在它约束了硬件的重排自由度,让乱序窗口收窄。在高频交易、DPDK 这类极致性能场景里,能不用就不用;但在绝大多数驱动代码里,一次dmb ish的代价远小于一次数据损坏。

值得一提的是,现代内核的smp_mb()并不是直接展开成上面那一条。在include/asm-generic/barrier.h里它是do { kcsan_mb(); __smp_mb(); } while (0),前面那个是 KCSAN(Kernel Concurrency Sanitizer)的钩子,不开 KCSAN 时展开为空。所以反汇编里最终只剩一条dmb ish,符合预期。

2.3 mb() 为什么是 dsb sy 而不是 dmb sy

smp_mb()和mb()的区别,是很多人的知识盲区。在 AArch64 上:

#define mb() do { kcsan_mb(); dsb(sy); } while (0) #define rmb() do { kcsan_rmb(); dsb(sy); } while (0) #define wmb() do { kcsan_wmb(); dsb(sy); } while (0)

三个都用dsb sy。dsb是 Data Synchronization Barrier,它除了排序,还会等待之前的显式内存访问真正完成(到达一致性点,或者对于 Device 内存到达外设)。dmb只排序不等待,写缓冲里没排空的写它不管。

这个差别在 MMIO 场景下就是生死线。你给网卡寄存器写完描述符地址,紧接着敲一下门铃寄存器,如果用的是dmb,描述符的写可能还在写缓冲里,门铃已经打出去了,DMA 引擎读到的是旧描述符。dsb会确保描述符写真正到达设备侧,门铃才是有效的。所以mb()/rmb()/wmb()这三个不带smp_前缀的"全系统"屏障,语义上是对着 I/O 和 DMA 设计的,用dsb sy是对的。

域的选择也说明了这一点:sy是 Full System,覆盖所有 PE 和所有观察者,包括非一致性设备。而smp_mb()的ish只覆盖 inner shareable,够用于核间同步,但管不到挂在系统总线上的一致性域之外的东西。

2.4 isb 的位置:指令流同步,不是数据屏障

isb()展开成isb指令,它是 Instruction Synchronization Barrier,作用对象是流水线和取指,和数据屏障不在一个维度上。典型用途有两个:一是修改系统寄存器后(比如TTBR、SCTLR、TCR),要保证后续指令按新配置取指执行;二是在自修改代码或者flush_icache_range()之后,保证新指令被取到。

新手常见的误用是把isb当数据屏障使,觉得"它比 dmb 更狠"。实际上isb对 Normal 内存的访问顺序几乎不起作用,该乱序还是乱序。另一个常见误用是反过来——改了SCTLR却忘了isb,然后在下一个函数里踩到莫名其妙的异常。我在做启动代码适配时就在这里耗过大半天:页表刚切换,紧接着的几条指令用的还是旧映射,加一条isb立刻正常。

宏 / 指令AArch64 展开保序范围典型用途
barrier()空 asm + memory clobber仅编译器防止编译器合并、重排
smp_rmb()dmb ishldload-load(含 load-store)读索引后读数据
smp_wmb()dmb ishststore-store写数据后写索引
smp_mb()dmb ish全序,inner shareable核间通用同步
mb()/rmb()/wmb()dsb sy全序,系统域且等待完成MMIO、DMA 前后
isb()isb指令流与系统寄存器可见性改系统寄存器、刷 icache
smp_load_acquire()ldar系列该 load 之后的所有访问读标志位后读数据
smp_store_release()stlr系列该 store 之前的所有访问写数据后发索引

3. 读写屏障在 AArch64 上的强度选择

3.1 smp_rmb() 与 dmb ishld

smp_rmb()的语义需求只有一条:保证它之前的 load 在它之后的 load 之前被观察到。用一条完整的dmb ish当然正确,但那是杀鸡用牛刀——dmb ish连 store-store 和 store-load 都一起约束了,在访存密集的路径上白白收窄了硬件的调度空间。

ARMv8 提供了带类型的 DMB,dmb ishld就是"只对 load 生效"的变体,它提供 load-load 和 load-store 的顺序,但不碰 store-store。对于smp_rmb()需要的 load-load,它完全够用,而且约束面更窄。较新的内核里__smp_rmb()已经改成dmb ishld;早期版本统一写dmb ish。你手上的树到底是哪种,grep -n '__smp_rmb' arch/arm64/include/asm/barrier.h一眼就能确认,别凭记忆写代码。

这里要提醒一句:smp_rmb()只保证读的顺序,它不保证你读到的值是新的。想让"读到 flag 为真"这件事本身带可见性保证,该用smp_load_acquire(),而不是READ_ONCE()加smp_rmb()。两者在语义上接近,但后者在代码审查里容易被质疑"你到底是想排序还是想同步"。

3.2 smp_wmb() 与 dmb ishst

smp_wmb()的需求只有 store-store:之前写的所有东西,必须在之后那次写之前可见。典型的写法是"数据写完了,再更新索引"。对应的指令是dmb ishst,只约束 store 之间的顺序,不管 load。

注意一个反直觉的地方:smp_wmb()不保证你之前的 store 已经完成,它只保证顺序。如果你的下一步是敲 MMIO 门铃,中间需要的是wmb()(即dsb sy),而不是smp_wmb()。这两个宏只差几个字符,语义差了一个"等待完成"。我见过把wmb()写成smp_wmb()的驱动,在轻载下(写缓冲有空位)一切正常,压力一大就开始间歇性 DMA 描述符错乱,排查了两周才定位到这一处。

另外smp_wmb()不阻止编译器重排,它自带"memory"clobber,所以通常不需要额外加barrier()。但如果你在两次写之间夹了函数调用,并且这个函数被内联后编译器又做了别的优化,看反汇编确认屏障位置是否还在你期望的地方,是个成本很低的习惯。

3.3 为什么不能随手全用 dmb ish

"全部用最强的dmb ish不就行了"这个想法很自然,但在 AArch64 上有两个代价。第一是架构层面的:dmb ish的约束面覆盖四类顺序,硬件为了满足它,必须收窄乱序窗口,在高并发路径上累积起来是可观的开销。第二是更隐蔽的——用强屏障会掩盖代码本身的逻辑错误。

举个实际例子。一段代码本该用smp_store_release()表达"发布"语义,开发者图省事写成WRITE_ONCE(data, v); smp_mb(); WRITE_ONCE(flag, 1);。功能上没错,但读侧如果没有对应的 acquire,正确性依然不成立。全用强屏障的结果是:x86 上测不出问题,AArch64 小压力测不出问题,量产之后在特定负载下偶发。这时候再回头去看代码,每个屏障都"看起来很强",反而失去了"这里为什么需要屏障"的线索。

我的经验是:每个屏障旁边写一行注释,说明它防的是哪一类重排、配对的读侧在哪。这行注释不是给别人看的,是给半年后的自己看的。凡是写不出这行注释的地方,十有八九是屏障放错了位置,或者根本不需要。

4. acquire/release 是 AArch64 的"原生"屏障

4.1 smp_load_acquire 对应 ldar

ARMv8 最舒服的一点是,acquire/release 语义有专门指令,不用靠"普通访存 + 屏障"两条指令拼。smp_load_acquire(&flag)在 AArch64 上直接展开成ldar:

/* u32 v = smp_load_acquire(&flag); */ ldar w1, [x0]

ldar的语义是:这条 load 之后的所有内存访问,都不能被重排到它前面。也就是说,只要你读到了flag的新值,后面读data一定读到对应的新值。这正好对上"发布-订阅"模式,而且比ldr+dmb ishld少一条指令,硬件实现上也更高效。

__smp_load_acquire()在 arm64 头文件里按宽度分了 1/2/4/8 字节,分别对应ldarb/ldarh/ldar/ldar(8 字节用ldar x)。这意味着你不能拿它去做非对齐或者超大块的读,编译器会用compiletime_assert_atomic_type拦住你。这是好事,非原子的 acquire 本来就没有意义。

顺带说一个常被问到的点:smp_load_acquire()之后的访问一定是新的吗?严格说,它保证的是顺序,配合硬件的缓存一致性协议(MESI 类),你读到新 flag 意味着对应 cacheline 已经被作废并重新取过,所以后续读同一份数据的 cacheline 也会拿到最新。这个链条在 AArch64 上成立,前提是数据走的是 Normal Cacheable 内存,而不是 Device 内存。

4.2 smp_store_release 对应 stlr

对称地,smp_store_release(&flag, 1)展开成stlr:

/* smp_store_release(&flag, 1); */ stlr w1, [x0]

stlr保证这条 store 之前的所有内存访问,都不会被重排到它后面。放到环形队列里,就是"数据写完、len写完,才轮到tail更新",一行指令解决。比smp_wmb()加普通写少一条指令,而且语义更清晰。

实际写驱动的时候,我倾向于把发布路径统统改成 release/acquire 组合,只在需要"多个 store 之间互相保序"或者"循环等待"这类场景才动smp_mb()/smp_rmb()。原因很简单:release/acquire 把语义绑在了那次访问上,读代码的人看到smp_store_release就知道这里有发布动作,看到smp_mb()还得往上往下翻,猜它到底在配哪一段。

一个细节:smp_store_release()只对这一次 store提供 release 语义,它不保证之后的 store 有序。所以"发完索引还要再写一个统计计数器"这种场景,那个计数器没有被 release 覆盖,需要另外处理。这类边角在代码评审里特别容易漏。

4.3 自旋锁与 LL/SC 里的 ldaxr/stlxr

内核的spin_lock()在 AArch64 上不是靠dmb堆出来的,而是靠 LL/SC(Load-Linked/Store-Conditional)指令自带的语义:

/* 简化的 arch_spin_lock 竞争路径 */ ldaxr w1, [x0] /* load-acquire exclusive */ cbnz w1, 1f stxr w2, w3, [x0] /* store exclusive */ cbnz w2, 1b

ldaxr里那个a就是 acquire:拿到锁之后,临界区里的读写不会被重排到加锁之前。解锁路径用stlrh(store-release),保证临界区里的写都在解锁之前可见。所以你在spin_lock()保护的临界区里通常不需要额外加smp_mb(),锁本身已经给了。多加了不但浪费,还会让后来的人误以为锁不够用。

stxr失败要重试,这个循环本身的语义是"独占监视器",和内存序是两件事。有人把stxr重试失败理解成"内存序问题",方向就跑偏了。

4.4 LSE 原子指令进来之后屏障怎么变

ARMv8.1 引入的 LSE 原子指令(ldadd、swp、cas等)带.a/.l/.al后缀,天然支持 acquire/release。内核在CONFIG_ARM64_USE_LSE_ATOMICS打开时会走atomic_lse.h的路径,atomic_inc()这类操作用ldaddal一条搞定,不再需要 LL/SC 循环,也不需要在外面补屏障。

这带来一个实际影响:同一个smp_mb__before_atomic(),在不同配置下的必要性不一样。LSE 路径的原子操作本身带全序(al后缀),LL/SC 路径的原子操作在循环成功之后语义上也足够,所以内核在 arm64 上对__smp_mb__before_atomic()的处理相对宽松。但具体定义以你手上的树为准——grep -rn '__smp_mb__before_atomic' arch/arm64/include/确认,如果没有定义,那就是走 asm-generic 的默认实现(展开成smp_mb()),别想当然认为它是空的。

移植老驱动的时候这一点特别容易出问题:代码在老的 LL/SC 平台上写的是"原子操作 + 显式 smp_mb()",换到 LSE 平台上多了一条屏障,功能正确但性能下降;反过来如果老代码依赖了某个隐式顺序,换平台后顺序没了,就是功能问题。这类问题只能靠读代码加反汇编,没有捷径。

5. 共享域的选择:DMA、MMIO 与一致性

5.1 SY、OSH、ISH、NSH 各自覆盖谁

dmb后面的域参数不是装饰,选错了屏障就白加。四个域从大到小:

  • sy(Full System):所有 PE、所有观察者,包括不一致的设备。
  • osh(Outer Shareable):outer shareable 域内的 PE 和观察者,通常覆盖同一 cluster 之外的共享域,也常被用来对齐 DMA 设备。
  • ish(Inner Shareable):inner shareable 域内的 PE,一般就是 SoC 里所有 CPU 核。
  • nsh(Non-shareable):只对单个 PE 有效,多核场景基本用不上。

核间同步用ish是标准做法,因为 Linux 在启动时会通过SMP初始化把参与调度的 CPU 都纳入同一个 inner shareable 域。但如果你的同步对象涉及 DMA 设备、或者跨 cluster 的加速器,ish就不够了,得上osh甚至sy。

这里有个隐蔽的坑:某些平台上,如果设备树或者固件没有正确配置 CPU 的 shareability 属性,实际生效的域可能和你以为的不一样。表现形式是"屏障看起来加了但没用",用dmesg | grep -i shareab之类的手段确认平台配置,比盲猜快。

5.2 readl/writel 里挂的屏障

内核给 MMIO 访问做了"带屏障"和"不带屏障"两套 API。readl()/writel()是带屏障的,readl_relaxed()/writel_relaxed()是不带的。区分它们的目的是:一串连续的寄存器读写,中间不需要每次都加屏障,只要在整串的开头或结尾加一次就够了。

在arch/arm64/include/asm/io.h里,readl()会在真正读之前或之后调用__iormb(),writel()会在写之前调用__iowmb()。多数版本里这两个宏落到dma_rmb()和dma_wmb(),也就是dmb oshld/dmb oshst。用osh而不是ish,是因为 MMIO 的观察者往往是设备,不在 inner shareable 域里。

实操建议:寄存器初始化那种连续几十个写的场景,用writel_relaxed()写完,最后在关键的那一次(比如使能位)之前补一个wmb(),比每个都writel()快得多。我在一个 PCIe 设备的初始化路径上做过这个改动,初始化时间从毫秒级降到百微秒级,功能完全正常。前提是你得清楚哪些写之间存在顺序依赖,把wmb()放对位置。

5.3 dma_rmb/dma_wmb 与 osh

DMA 场景有一套单独的屏障:

#define __dma_mb() dmb(osh) #define __dma_rmb() dmb(oshld) #define __dma_wmb() dmb(oshst)

它们用osh域,专门对着"CPU 和 DMA 引擎之间的一致性内存"设计。典型的用法是:CPU 填好描述符,dma_wmb(),然后写门铃寄存器。注意这里只是排序,不保证描述符真正到达内存——如果设备对时序要求严格,门铃之前还需要wmb()(dsb sy)或者读一次寄存器做 flush。这两个屏障经常需要配对使用,只用dma_wmb()在多数场景够用,但遇到"设备读到的描述符字段是旧的"这种问题,就得往上加。

另外一个常被忽略的点:dma_wmb()只对一致性内存(dma_alloc_coherent分配的、cacheable 且硬件维护一致性的内存)有意义。如果你的缓冲区是 streaming 映射,中间还夹着dma_sync_single_for_device(),那 cache 维护操作本身已经包含了必要的顺序,再叠一个屏障可能是多余的。这块的判断依据是 DMA API 文档里对 ownership 转移的描述,别凭感觉加。

6. 把屏障写对:驱动里最常见的几种错误写法

6.1 只用 barrier() 就以为搞定了核间顺序

这是出现频率最高的错误,没有之一。代码长这样:

WRITE_ONCE(desc->data, v); barrier(); WRITE_ONCE(desc->ready, 1);

作者的想法是"我加了屏障,编译器不会乱动"。确实,编译器不会乱动,但 CPU 会。AArch64 上这两条str之间没有任何硬件约束,desc->ready的写完全可能先于desc->data的写被别的核看到。

改法很简单,把barrier()换成smp_wmb(),读侧配一个smp_rmb()。或者更符合现代内核风格的写法,用 release/acquire:

WRITE_ONCE(desc->data, v); smp_store_release(&desc->ready, 1); /* 读侧 */ if (smp_load_acquire(&desc->ready)) v = READ_ONCE(desc->data);

这个改法的额外好处是语义自解释:看到 release/acquire 就知道这是一对发布-订阅,不用去猜中间那个barrier()到底想干嘛。

6.2 循环等标志位时漏掉 acquire

轮询等待的代码极其常见:

while (!READ_ONCE(dev->done)) cpu_relax(); val = READ_ONCE(dev->result);

问题在于:READ_ONCE(dev->done)只是保证编译器不优化这次读,它不建立任何顺序。在 AArch64 上,dev->result的读完全可以被提前到循环之前,于是你等到done变真了,读到的result却是旧的。

正确写法有两种。用smp_cond_load_acquire():

smp_cond_load_acquire(&dev->done, VAL); val = READ_ONCE(dev->result);

或者手写循环加smp_rmb():

while (!READ_ONCE(dev->done)) cpu_relax(); smp_rmb(); val = READ_ONCE(dev->result);

第一种更好,因为smp_cond_load_acquire()

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询