ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ARM独占访问指令演进与实战:从LL/SC到LSE的原子操作迁移指南

ARM独占访问指令演进与实战:从LL/SC到LSE的原子操作迁移指南 1. 先把问题拆开看独占访问到底在解决什么ARM架构上的独占访问Exclusive Access机制说白了就是解决多个执行单元同时改同一个内存位置这件事。它看起来只是一对指令的名字实际上背后牵扯到内存一致性、缓存监控、编译器和操作系统调度是一条从 ARMv5 一直延伸到 ARMv9 的技术脉络。我这几年做过不少从 x86 往 ARM 迁移的活儿也在 ARM 上写过自旋锁和无锁队列踩过的坑基本都集中在这条脉络上。这篇文章不打算写成手册而是想把 ARM 独占访问为什么这么设计、每一代加了什么、写代码时哪一步会翻车讲透适合已经会用atomic但对底层一知半解的开发者也适合准备做交叉编译和跨架构移植的同学。先说结论性的判断ARM 走的是 LL/SCLoad-Linked / Store-Conditional路线而 x86 走的是 CASCompare-And-Swap路线。这个选择差异不是历史偶然而是两套设计哲学的直接结果它会影响到你写出来的每一行原子代码长什么样、性能瓶颈出现在哪里。理解了这一点后面所有的指令演进、坑点、调优手段就都串起来了。1.1 一条读-改-写为什么天生不安全从最简单的场景开始。假设有两个变量共享的计数器counter两个核同时执行counter。这一行 C 代码在汇编层面至少是三件事把值从内存装载到寄存器load在寄存器里加一add把结果写回内存store。这三步里任何一步之间都可能被另一个核插进来。举个数两个核各自读到counter 5各自加一得到 6各自写回最终内存里是 6 而不是 7。少掉的那一次增量永远不会自己回来。这类问题在单核时代不太常见因为一次上下文切换发生在指令边界但多核时代两个核是物理并行跑的中间没有任何天然的互斥点。最原始的解法是关中断或者加锁。关中断只对单核有效多核没有任何作用加锁需要锁本身也是原子的于是陷入鸡生蛋——你要实现原子操作先得有一个原子操作。所以真正提供原子性的是硬件处理器必须提供某种要么整条做完、要么完全不留下痕迹的读-改-写能力。ARM 给出的答案就是独占访问。1.2 LL/SC 与 CAS 的路线之争硬件实现原子读-改-写有两条主流路线。一条是 CAS给一条指令语义是如果内存里的值等于我给的期望值就换成新值同时告诉我原本是什么。x86 的lock cmpxchg就是典型一条指令就把比较和交换做完中间不被打断。好处是语义直观、单指令完成坏处是遇到 ABA 问题和几乎总能成功的假象以及无法天然扩展到任意复杂操作。另一条是 LL/SC拆成两条指令。第一条Load-Exclusive把地址读进寄存器同时在这个地址上打一个独占标记第二条Store-Exclusive只有在标记还在的情况下才真正写入并返回一个状态码表示成功或失败。如果中途有别人碰过这块地址标记被清除写入静默失败返回失败码。ARM 从 ARMv6 开始坚定选了 LL/SC。为什么因为 LL/SC 对复杂操作的扩展性好得多。CAS 只能对单个字做比较交换如果一个数据结构需要原子地改多个字段比如链表节点的next和versionCAS 就得拆成一堆小 CAS 并处理中间态复杂度飙升。而 LL/SC 的语义是从读到写之间没有任何人动过这个地址天然适配读一堆东西、算一算、写一堆东西这种模式。代价是必须放在循环里失败就重试也就是所谓的自旋重试结构。还有一层实际考虑CAS 指令通常要求对单个内存位置操作会对缓存和一致性协议提出特殊要求而 LL/SC 的监控粒度是保留粒度Exclusive Reservation Granule可以做得更松散硬件实现自由度更大。ARM 把这个自由度留给了各家 IP 设计者代价就是你必须接受伪失败这个现实。1.3 独占监视器真正的裁决者很多讲独占访问的资料只讲指令不讲监视器结果就是代码能跑但看不懂现象。真正管事的角色叫独占监视器Exclusive Monitor。ARM 把它分成两层。一层是本地监视器Local Monitor每个 PE 自己有一份管的是 Non-shareable 内存的独占标记。另一层是全局监视器Global Monitor放在一致性域里可以理解为互连总线或一致性点附近管 Shareable 内存。你的指令落在哪一层取决于这个地址被映射成了什么内存属性——这是关键前提独占访问只对 Normal Memory 有意义Device 类型的内存上做独占访问是未定义行为。监视器的状态很轻量本质上是某个 PE 是否在某个地址范围内持有标记这样一个记录粒度就是保留粒度。清除标记的触发条件大致有三类另一个 PE 写入了同一粒度内的任意地址同一个 PE 对同一粒度内的其他地址发起了新的独占访问以及异常、上下文切换之类的架构事件。这三条里第一条和第二条是导致看起来毫无竞争却一直失败的罪魁祸首后面还会展开讲。还有一个容易被忽略的细节独占访问是成对的。STXR必须和之前的LDXR在同一个地址、同样的访问尺寸上匹配不匹配的配对直接失败。所以你不能LDXR读一个字节然后STXR写一个字——即使地址一样也不行。同时读写之间的访问尺寸、对齐、内存属性也要一致这些条件在执行前就应该在代码里固定下来不要依赖运气。2. ARM 独占访问指令的演进路线把时间线铺开看ARM 在这块的动作其实很有节奏先用 SWP 顶上再用 LDREX/STREX 打开局面AArch64 统一了命名并引入内存序后缀ARMv8.1 的 LSE 把重试循环塞进硬件之后 ARMv8.3、ARMv8.4 再补内存序和非对齐的短板。每一代都在解决上一代暴露出来的具体问题。2.1 ARMv5 的 SWP能用但不够用ARMv5 时代只有 SWP 和 SWPB 两条指令做的是原子交换把寄存器的值和内存的值互换。这是最原始的原子原语语义上足够实现简单的信号量和锁——一个经典的实现就是用一个内存字表示锁状态0 表示空闲、1 表示占用加锁就是不断用 SWP 往里面换 1看看换回来的旧值是不是 0。问题很明显。第一它只有交换语义没有比较语义实现 CAS 得绕着走效率低。第二SWP 在总线上的行为相当重很多实现里它会把整条总线占住一段时间多核争抢时性能很差。第三ARMv6 之后的架构文档里它已经被标记为弃用到 AArch64ARMv8.0直接被删掉了。所以现在看到 SWP基本可以判断这是老代码。有意思的是ARMv8.1 的 LSE 又把 SWP 加回来了但此 SWP 非彼 SWP——新的 SWP 属于原子指令家族语义、性能、内存序都完全不一样。这个删掉再加回来的动作恰好说明了原子操作的实现思路在十几年里发生了根本转变。2.2 ARMv6/v7LDREX/STREX 家族补齐尺寸ARMv6 引入 LDREX/STREX这是 ARM 独占访问的正式起点。基本形态是LDREX Rt, [Rn]读取一个字并打标记STREX Rd, Rt, [Rn]尝试写入并把状态码放到Rd0 成功1 失败。写代码时最容易犯的错误就是忘了检查Rd直接认为写成功了——ARM 不会给你异常它只是静默地不写你必须在循环里判断。ARMv7 做了两个重要补充。一个是补齐数据尺寸加了LDREXB/LDREXH/LDREXD和对应的STREXB/STREXH/STREXD分别对应字节、半字和双字。另一个是明确把 LDREX 系列的语义和内存类型、缓存属性绑在一起同时给出 CLREX 指令用于显式清除本地监视器。关于LDREXD有个具体的坑值得记一笔在 AArch32 状态下它的目标寄存器必须是偶数编号的寄存器对也就是Rt必须是偶数数据放在Rt:Rt1里奇数编号会触发未定义指令。这个规则在 AArch64 的LDXP上就取消了限制两个目标寄存器只需要互不相同就行。移植代码的时候如果手写汇编这个细节不注意会在 32 位平台上炸掉。还有一点ARMv7 时代推荐在异常处理和上下文切换时执行CLREX因为独占监视器的状态不一定随上下文保存恢复。如果不做这件事一个线程被打断后它残留的独占标记可能被另一个线程继承导致一系列诡异的失败或误成功。现代操作系统内核在这块都处理好了但如果你写的是裸机程序或者 RTOS这一条必须自己负责。2.3 ARMv8 AArch64LDXR/STXR 与内存序后缀AArch64 把指令重命名成 LDXR/STXR同时引入了非常关键的一组后缀LDAXRLoad-Acquire Exclusive、STLXRStore-Release Exclusive、LDAXP、STLXP。无后缀的是 Relaxed 语义带 A 的表示 acquire带 L 的表示 release。这组后缀的意义在于它把内存序直接写进了指令而不是靠外挂一个DMB屏障。以前在 ARMv7 上写一个标准的自旋锁你必须DMBLDREX 判断 STREXDMB两条全屏障把前后都拦住开销不小。到了 AArch64加锁路径用LDAXR/STLXR就够语义上等价于 acquire/release硬件能做更细的优化。配对的LDXP/STXP对应 128 位访问用两个 X 寄存器拼起来这是 AArch32 的LDREXD/STREXD在 64 位下的自然延伸。用途主要在需要原子地操作指针加计数器的场景比如一些带版本号的指针结构。不过要注意AArch64 里依然没有单独的 128 位 CAS 指令——直到 ARMv8.1 的CASP出现。所以在 ARMv8.0 上想实现 128 位比较交换得自己用 LDXP/STXP 写循环效率和正确性都得自己保证。2.4 ARMv8.1 LSE把重试循环塞进硬件ARMv8.1 引入的 LSELarge System Extensions是这条线上最大的一次转折。它一次性带来了一整个原子指令家族最有代表性的几条指令语义典型用途LDADD原子加并返回旧值引用计数递增、统计累加LDCLR/LDSET原子清位 / 置位位图、标志管理LDSMAX/LDSMIN有符号取最大 / 最小水位、阈值维护LDUMAX/LDUMIN无符号取最大 / 最小同上无符号场景SWP原子交换并返回旧值简单锁、句柄交换CAS/CASP比较交换CASP 为 128 位版无锁结构、指针更新同时还有一组不带返回值的STADD、STCLR、STSET之类适合只改不关心旧值的情况。后缀规则也更丰富了A表示 acquire、L表示 release、AL表示 acquire-release比如LDADDAL就是带完整 acquire-release 语义的原子加。这些指令的价值在哪在于它把循环重试这件事消灭了。以前一个原子加要写成LDXRADDSTXR的循环硬件在每次失败后都要把控制权交回给软件重新读一遍中间还夹着分支预测、流水线冲刷。LSE 把整个读-改-写做成一条指令在互连层面用更高效的方式实现多核高竞争下性能差距可以有数倍。实际做迁移的时候你不需要手写这些指令。GCC 和 Clang 会根据-march决定用哪种形式编译到armv8-a时生成 LDXR/STXR 循环编译到armv8.1-a时生成 LSE 指令。这就是为什么同样一段__atomic代码换一个-march跑出来性能完全不一样。2.5 ARMv8.3 之后的补丁与 ARMv9 的走向ARMv8.1 之后还有几个后续扩展值得知道。ARMv8.3 引入了 FEAT_LRCPC带来LDAPR这类 RCpcRelease Consistency, processor consistent语义的 acquire load。传统的LDAR是 RCsc 语义acquire 的约束更强LDAPR允许更弱的排序只在真正需要的地方保留依赖关系。对于无锁数据结构里那种读到指针后靠数据依赖去访问对象的场景LDAPR能省掉一些不必要的屏障开销。ARMv8.4 的 FEAT_LSE2 放宽了原子指令的对齐要求把一些单寄存器的原子访问扩展到了非对齐地址上。对齐问题在跨架构移植里非常常见x86 对非对齐访问很宽容ARM 传统上则要求严格对齐LSE2 在一定程度上缓解了这个矛盾但注意它只覆盖一部分场景别指望所有原子操作都能非对齐。再往后ARMv9 的动作主要集中在内存模型细化和内存标记MTE等安全特性上。独占访问本身的语义没有颠覆性变化仍然是 LSE 原子家族主打重试型 LL/SC 依然保留用于需要跨多条指令保持独占的场景。可以说 ARM 已经形成了LSE 原子指令为主、LL/SC 循环为辅的双轨格局。3. 动手写从 C11 原子到内联汇编光看指令表容易产生错觉觉得知道了指令名就会用了。实际写的时候从 C 代码到最终指令之间隔着编译器、-march选项、运行库三层每一层都可能给你惊喜。这一节从可以编译运行的最小例子开始逐步把中间过程展开。3.1 先看编译器生成什么最省事的做法是用 C11 的stdatomic.h。写一个最基础的比较交换#include stdatomic.h #include stdbool.h #include stdint.h bool cas32(_Atomic uint32_t *p, uint32_t expected, uint32_t desired) { return atomic_compare_exchange_strong(p, expected, desired); }然后用交叉工具链编译到汇编aarch64-linux-gnu-gcc -O2 -marcharmv8-a -S cas.c -o cas.s在armv8-a目标上你会看到类似这样的结构不同工具链版本细节可能不同以你本机输出为准cas32: ldaxr w3, [x0] cmp w3, w1 b.ne .Lfail stlxr w4, w2, [x0] cbnz w4, cas32 mov w0, #1 ret .Lfail: str w3, [x1] mov w0, #0 ret能看出几个特征。第一ldaxr和stlxr带了 A/L 后缀因为 C11 的默认比较交换是 acquire-release 语义。第二stlxr失败后跳回函数开头重新读这就是重试循环。第三失败时把实际读到的值写回expected指针这是 C11 比较交换的约定。把-march换成armv8.1-a再看aarch64-linux-gnu-gcc -O2 -marcharmv8.1-a -S cas.c -o cas_lse.s汇编会明显变短核心变成一条casal之类的指令循环消失了。这个对比非常直观也是我在做性能优化时最先看的地方——打开反汇编如果发现高竞争路径上还是一堆ldaxr/stlxr循环基本可以确定这块有优化空间。3.2 手写 CAS 与自旋锁知道编译器会生成什么之后手写内联汇编就不容易出错了。一个经过验证的 32 位 CAS 写法长这样static inline int cas32(volatile uint32_t *p, uint32_t expected, uint32_t desired) { uint32_t old, status; __asm__ __volatile__( 1: ldaxr %w[old], [%[p]]\n cmp %w[old], %w[exp]\n b.ne 2f\n stlxr %w[st], %w[des], [%[p]]\n cbnz %w[st], 1b\n 2:\n : [old] r(old), [st] r(status) : [p] r(p), [exp] r(expected), [des] r(desired) : cc, memory); return old expected; }这里有几点必须说清楚否则很容易写出看似正确实则危险的版本。r里的表示这个输出寄存器是 early-clobber也就是在输入还没读完之前就可能被写。独占访问指令是读-判断-写三步中间的寄存器必须保证不被复用。省掉在某些优化级别下编译器会把st分配到和p相同的寄存器结果就是地址被覆盖程序跳到一个随机地址上。这个 bug 我在早期版本里确实遇到过现象是偶发崩溃排查了很久。约束里的memory是必须的。它告诉编译器这段汇编对内存有副作用不允许把前后相关的访存操作重排过来。没有它编译器可能把你精心安排的顺序打乱。标号1:和2:是局部标号多次内联展开时不会冲突。用普通的具名标号会在同一函数里重复定义链接时报错。这一点在头文件里定义内联函数时尤其重要。在这基础上写自旋锁就简单了typedef struct { uint32_t locked; } spinlock_t; static inline void spin_lock(spinlock_t *l) { uint32_t expected; for (;;) { expected 0; if (cas32(l-locked, 0, 1)) break; __asm__ __volatile__(wfe ::: memory); } } static inline void spin_unlock(spinlock_t *l) { __asm__ __volatile__(stlr %w0, [%1] :: r(0u), r(l-locked) : memory); __asm__ __volatile__(sev ::: memory); }wfeWait For Event和sevSend Event这对指令是省电的关键。纯自旋会让核心跑满功耗、抢总线带宽wfe让核心在检测到事件前进入低功耗状态解锁方用sev广播事件唤醒。ARMv8.1 之后LSE 原子指令在架构上被定义为会发出事件所以在这类平台上等待方不需要额外依赖sev也能被唤醒这算是一个隐藏的性能红利。3.3 该不该强制上 LSE这里有个现实问题你的程序可能同时要跑在支持 LSE 和不支持 LSE 的芯片上。如果编译时硬编成armv8.1-a在不支持 LSE 的老芯片上会直接触发非法指令异常。早期的做法是编译两份代码或者运行时检测很麻烦。GCC 10 和 Clang 12 之后引入了-moutline-atomics思路是生成一段运行时判断的桩代码程序启动时检查AT_HWCAP里有没有HWCAP_ATOMICS位有就走 LSE 路径没有就走 LL/SC 路径。这个选项目前在新版本工具链上基本是默认开启的你可以先确认一下自己的编译配置。手动检测也很简单在 Linux 上直接看grep -o atomics /proc/cpuinfo | head -1Features字段里出现atomics就说明这颗芯片支持 LSE。用getauxval(AT_HWCAP)在代码里判断也是同样的原理HWCAP_ATOMICS这个位对应着 ARMv8.1 的原子扩展。我的一般建议是如果是新项目、目标平台明确是近几年的服务器芯片直接-marcharmv8.1-a甚至更高省心又快如果是发行版打包、要覆盖老设备保持armv8-a加-moutline-atomics让运行库替你选。最忌讳的是为了性能强行编高版本结果在客户的旧机器上启动就崩。3.4 交叉编译与验证环境要在 x86 的开发机上验证 ARM 的原子代码标准做法是装交叉工具链sudo apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu aarch64-linux-gnu-gcc --version如果只是想看生成的指令不需要运行-S输出汇编就够了。要真正跑起来最方便的是用静态编译加模拟器aarch64-linux-gnu-gcc -static -O2 -marcharmv8.1-a -o demo demo.c qemu-aarch64 ./demo用 QEMU 用户态模拟验证逻辑是够的但要注意它无法复现真实硬件的缓存一致性行为和保留粒度问题——那些只有在多核真机上才暴露。真机验证建议用taskset把线程绑到不同物理核上避免调度器把两个线程塞进同一个核那样根本测不出竞争问题taskset -c 0,1 ./demo这一步非常关键。我见过不止一次本地测不出问题、上线就崩的案例最后发现是测试机上线程被调度到同一核独占访问永远不冲突。4. 实测踩过的坑与排查手段前面讲的是机制和写法这一节讲真机上会遇到什么。独占访问的问题有个共同特点现象诡异、复现困难、看起来像玄学。但只要理解了监视器的行为绝大部分都能解释清楚。4.1 伪失败与保留粒度伪失败spurious failure是 LL/SC 路线上最反直觉的一点即使没有任何其他核和你竞争STXR也可能失败。原因是架构只保证如果在读写之间没有任何影响该地址的访问那么可以成功反过来说就是允许在无竞争情况下失败。所以你的重试循环不是可选项没有循环的独占访问代码一定是错的。更麻烦的是保留粒度。架构规定保留粒度是实现定义的最小 4 字节上不封顶实际芯片上常见的是 16 字节、64 字节甚至更大跟着缓存行走。这意味着你对地址 A 做独占访问另一个核对同一粒度范围内的地址 B 做写操作也会清掉你的标记。举一个我实际遇到的问题在一个结构体里放了两个原子变量stat_rx和stat_tx分别由两个线程高频更新。它们相距只有 8 字节落在同一个保留粒度里结果两个线程的原子操作互相踢标记STXR失败率高得离谱吞吐量只有预期的一小半。解决办法是把这两个变量按 64 字节对齐隔开struct stats { _Alignas(64) _Atomic uint64_t stat_rx; _Alignas(64) _Atomic uint64_t stat_tx; };改完之后成功率立刻上来了。这条经验对所有高性能计数器都适用高频写入的原子变量不要挤在一起按缓存行大小对齐隔开。这个成本和收益比非常高一行_Alignas就能解决。同理操作系统内核里的自旋锁通常也是按缓存行对齐的原理一样。如果你在写自己的锁实现别忘了这一条。4.2 内存序与屏障独占访问指令本身只管地址有没有被人动过不管其他内存的可见性顺序。要保证顺序得靠 acquire/release 语义或者显式的屏障指令。ARM 上的屏障主要有三种DMB数据内存屏障保证屏障前后的访存顺序在其他观察者看来是一致的、DSB数据同步屏障更强等所有访存真正完成、ISB指令同步屏障用于流水线刷新比如修改系统寄存器之后。日常写锁用的基本是DMB ISH或者干脆用带 A/L 后缀的独占访问指令省掉屏障。编译器层面的重排同样要防。C11 的atomic系列自带内存序参数用对了编译器就不会乱动手写内联汇编则必须加memoryclobber。这两条都不能省。一个比较隐蔽的坑是把 acquire 和 release 用反了。加锁路径要 acquire保证拿到锁之后能看到锁持有者之前的所有写入解锁路径要 release保证自己之前的写入对后来者可见。如果顺序搞反代码在强一致性的机器上可能蒙混过关换到弱一致性的 ARM 上就会读到过期的数据。这类 bug 在 x86 迁 ARM 的场景里特别常见因为 x86 的 TSO 模型太宽容很多错误写法在 x86 上根本不暴露。4.3 内存属性、跨页与对齐独占访问对内存属性有硬性要求只对 Normal Memory 有效。如果你对映射成 Device 类型的内存比如 MMIO 寄存器区做LDXR/STXR行为是未定义的通常表现为永远失败或者直接异常。要访问设备寄存器应该用专门的 MMIO 访问函数而不是原子操作。对齐要求同样是硬性规定。ARMv8 的独占访问要求地址按访问尺寸对齐跨页的独占访问也是不允许的。写代码时如果对指针做偏移运算要确认偏移量不会让访问越界到相邻页否则在高并发下会出现偶发失败很难定位。还有一个边角情况只读页上的独占访问。STXR需要写权限如果页是只读的写入会触发访问异常。这在共享内存或者文件映射的场景下要注意确保页属性是读写。4.4 定位手段反汇编、perf 与硬件调试遇到独占访问相关的性能问题时我的排查顺序大致是这样。第一步反汇编确认实际指令。用objdump -d看关键路径确认是 LDXR/STXR 循环还是 LSE 指令确认内存序后缀对不对。这一步能排除掉大部分以为编了实际没编的误会。aarch64-linux-gnu-objdump -d --no-show-raw-insn target | grep -A5 -B5 ldxr\|ldaxr\|casal\|ldadd第二步用perf看执行分布。perf stat关注bus_cycles、stall相关的事件perf recordperf report看热点是不是集中在自旋循环上。如果某个自旋锁的占比异常高八成是竞争激烈或者伪失败。第三步如果怀疑是保留粒度的问题做对比实验把原子变量按不同对齐方式摆放跑同样的负载看吞吐变化。这个实验成本低、结论清晰我一般会先做。第四步实在找不到原因就上硬件调试。ARM 的 CoreSight 体系支持 external debug配合 trace 能看到指令级的执行流。如果用开发板注意确认调试口的连接方式有些板子默认关闭了外部调试需要改配置。这一步成本比较高一般留到最后。4.5 常见问题速查表把上面这些经验整理成表方便对号入座。现象可能原因处理办法STXR 一直失败没有重试循环或配对地址/尺寸不匹配加循环核对读写地址与尺寸完全一致无竞争也失败伪失败属于架构允许行为接受失败并重试不要试图消除高并发下吞吐骤降两个原子变量在同一保留粒度内按 64 字节对齐隔开老芯片上非法指令编译目标高于芯片支持的架构版本降-march或启用-moutline-atomics偶发读到过期数据内存序缺失或 acquire/release 用反补 acquire/release检查加解路径MMIO 地址上访问异常对 Device 内存用了独占访问改用专用 MMIO 访问函数上下文切换后行为异常独占标记未清除在切换路径加 CLREX单机测试通过、多机失败线程被调度到同一核用 taskset 绑核或在真机多核上测5. 迁移与验证清单如果你手上正好有一个从 x86 迁到 ARM 的项目或者准备把某个.so搬到 ARM 平台下面这些点值得逐条过一遍。这是我做完几次迁移之后总结出来的按重要性从高到低排。5.1 x86 迁到 ARM 时原子代码的差异点第一个差异是内存模型。x86 是 TSO读写顺序基本有保证ARM 是弱一致性模型需要显式的 acquire/release 或屏障。x86 上一些依赖硬件顺序的写法搬到 ARM 上必须补内存序。这是最容易出事的一条而且往往在压力测试下才暴露。第二个差异是原子操作的实现形态。x86 是一条lock前缀指令ARM 是重试循环或者在 LSE 上是一条指令。这导致同样的atomic_fetch_add在两边性能特征完全不同尤其是高竞争场景。迁完之后建议重新做一遍性能基准不要拿 x86 的数据当预期。第三个差异是对齐要求。ARM 对访问对齐的要求比 x86 严格把紧凑结构体里塞的原子变量搬到 ARM 上可能直接触发未对齐异常。检查所有原子变量的地址对齐必要时补 padding。第四个差异是编译器内建的行为。不同工具链对__atomic系列生成的代码可能不一样尤其是老版本的 ARM 编译器比如 armcc 5.06 那类对 C11 原子的支持程度有限很多时候只能靠内联汇编或者编译器特定的 intrinsic。老项目迁移时这一块要提前评估工作量。第五个差异是库依赖。如果用到了第三方库确认它有没有 ARM 版本或者源码能不能交叉编译。有些库内部用了平台相关的原子操作实现换架构之后需要重新配置。5.2 上线前的自检项把下面这些做成 checklist每次迁移都用一遍。反汇编确认关键路径上的原子操作形态符合预期内存序后缀正确。确认编译选项里的架构版本和实际硬件匹配或者启用了运行时探测。所有高频原子变量按缓存行对齐确认没有落在同一保留粒度里。测试时用taskset把竞争线程绑到不同物理核确认能触发真实竞争。在目标真机上跑一遍长时间压力测试至少覆盖真实的核数和负载特征。检查异常处理和上下文切换路径有没有CLREX特别是在裸机或 RTOS 场景。确认没有对 Device 类型内存做独占访问。记录一份基准数据方便后续回归对比。做完这些大部分独占访问相关的问题应该都能提前拦住。最后分享一个我个人觉得挺有用的小技巧。如果你的目标平台同时存在支持和不支持 LSE 的机型除了依赖-moutline-atomics还可以在代码里加一个启动时的探测把HWCAP_ATOMICS的结果打印到日志里。这样一旦线上出现性能异常你第一眼就能确认是不是跑到了 LL/SC 路径上省掉一轮远程排查。这个日志成本几乎为零但能省下很多时间。另外提一句这套东西的后续扩展方向也很清楚如果你的场景涉及 128 位原子操作比如带版本号的指针在 ARMv8.0 上只能用 LDXP/STXP 自己写循环而到了支持 LSE 的平台就有CASP可用代码可以简化不少。迁移的时候把这块单独拎出来评估往往能省下可观的复杂度。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进