资讯详情

资讯详情

AArch64内存屏障:从barrier.h到dmb/ldar

同一份单生产者单消费者的环形队列代码在 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 和 BA 在程序序中先于 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.hbarrier()、READ_ONCE()、WRITE_ONCE()这一层只管编译器barrier()展开成asm volatile( : : : memory)对 CPU 毫无约束。include/asm-generic/barrier.hsmp_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.hreadl()/writel()里挂的 I/O 屏障。arch/arm64/include/asm/cmpxchg.h、atomic_ll_sc.h、atomic_lse.h原子操作自带的顺序以及smp_mb__before_atomic()之类的补丁点。分层带来的一个直接后果是你在驱动里写的smp_mb()在单核配置下什么指令都不会生成只剩一个编译屏障。所以不要拿单核配置的编译产物去判断多核行为反汇编必须在CONFIG_SMPy的配置下看。2. barrier.h 展开之后的真实指令2.1 编译屏障挡得住编译器挡不住乱序执行barrier()的定义在多版本内核里都是这一句#define barrier() __asm__ __volatile__(: : :memory)它做两件事volatile让编译器不要把这个空的 asm 优化掉memoryclobber 告诉编译器这条语句前后所有内存内容都可能被改了于是编译器不敢把全局变量、指针解引用的读写跨过这条线重排也不敢把多次读合并成一次。它完全不生成任何机器指令对 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)前面那个是 KCSANKernel Concurrency Sanitizer的钩子不开 KCSAN 时展开为空。所以反汇编里最终只剩一条dmb ish符合预期。2.3 mb() 为什么是 dsb sy 而不是 dmb sysmp_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指令流与系统寄存器可见性改系统寄存器、刷 icachesmp_load_acquire()ldar系列该 load 之后的所有访问读标志位后读数据smp_store_release()stlr系列该 store 之前的所有访问写数据后发索引3. 读写屏障在 AArch64 上的强度选择3.1 smp_rmb() 与 dmb ishldsmp_rmb()的语义需求只有一条保证它之前的 load 在它之后的 load 之前被观察到。用一条完整的dmb ish当然正确但那是杀鸡用牛刀——dmb ish连 store-store 和 store-load 都一起约束了在访存密集的路径上白白收窄了硬件的调度空间。ARMv8 提供了带类型的 DMBdmb 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 ishstsmp_wmb()的需求只有 store-store之前写的所有东西必须在之后那次写之前可见。典型的写法是数据写完了再更新索引。对应的指令是dmb ishst只约束 store 之间的顺序不管 load。注意一个反直觉的地方smp_wmb()不保证你之前的 store 已经完成它只保证顺序。如果你的下一步是敲 MMIO 门铃中间需要的是wmb()即dsb sy而不是smp_wmb()。这两个宏只差几个字符语义差了一个等待完成。我见过把wmb()写成smp_wmb()的驱动在轻载下写缓冲有空位一切正常压力一大就开始间歇性 DMA 描述符错乱排查了两周才定位到这一处。另外smp_wmb()不阻止编译器重排它自带memoryclobber所以通常不需要额外加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 对应 ldarARMv8 最舒服的一点是acquire/release 语义有专门指令不用靠普通访存 屏障两条指令拼。smp_load_acquire(flag)在 AArch64 上直接展开成ldar/* u32 v smp_load_acquire(flag); */ ldar w1, [x0]ldar的语义是这条 load 之后的所有内存访问都不能被重排到它前面。也就是说只要你读到了flag的新值后面读data一定读到对应的新值。这正好对上发布-订阅模式而且比ldrdmb ishld少一条指令硬件实现上也更高效。__smp_load_acquire()在 arm64 头文件里按宽度分了 1/2/4/8 字节分别对应ldarb/ldarh/ldar/ldar8 字节用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/SCLoad-Linked/Store-Conditional指令自带的语义/* 简化的 arch_spin_lock 竞争路径 */ ldaxr w1, [x0] /* load-acquire exclusive */ cbnz w1, 1f stxr w2, w3, [x0] /* store exclusive */ cbnz w2, 1bldaxr里那个a就是 acquire拿到锁之后临界区里的读写不会被重排到加锁之前。解锁路径用stlrhstore-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后面的域参数不是装饰选错了屏障就白加。四个域从大到小syFull System所有 PE、所有观察者包括不一致的设备。oshOuter Shareableouter shareable 域内的 PE 和观察者通常覆盖同一 cluster 之外的共享域也常被用来对齐 DMA 设备。ishInner Shareableinner shareable 域内的 PE一般就是 SoC 里所有 CPU 核。nshNon-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 与 oshDMA 场景有一套单独的屏障#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/acquireWRITE_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()
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →