
先到今天为止只要在真正的生产环境里跑过一段时间内核的人大概都见过这样的“抽风现场”某个 CPU 莫名其妙 100% 转任务卡死控制台偶尔还能 ssh 进去但一敲命令就跟着卡。top里那个进程明明不干活perf top却显示一个地址在疯狂自转。如果你打开反汇编一看发现转的代码就三五行来来回回就是一个带原子指令的循环那就恭喜你你撞上了计算机系统里最阴的一类 bug一段本该“读改写、失败重试”的原子序列因为处理器前端的一个小动作直接变成了死循环。这篇文章要说的就是这个事件一颗 CPU 的原子指令一个打包死循环最后演化成典型的“丢失更新”。主角是龙芯 3A6000 的微架构代号 LA664。我不是来复述新闻的而是把这套问题从现象到原理、从定位到修复完整拆开揉碎。适合这些人看做内核移植的、写无锁代码的、调过分区锁和页表锁的、以及单纯想搞清楚 LL/SC 原子指令为什么会在某些 CPU 上翻车的同学。读完你会明白“指令打包”这种东西是如何在不知不觉间把一套原子操作搞成死循环的以及下次再遇到类似问题应该从哪里下手。1. 先把标题拆开LA664、原子指令、打包和死循环是什么关系1.1 标题里的四个关键词先别急着看现象把标题里的几个词一个个按住讲清楚它们各自是谁。LA664 不是 CPU 型号而是龙芯 3A6000 采用的微架构代号。就像 Intel 的 Golden Cove、AMD 的 Zen 4 一样它决定了指令怎么取、怎么解码、怎么乱序执行、怎么提交。3A6000 对外宣传是四核、最高 2.5GHz但真正决定它行为的是 LA664 这个微架构而不是商品名。这次事件里所有诡异行为都发生在这一层。原子指令指的不是单片机上那种“原子操作库函数”而是处理器提供的最低级同步原语。x86 上有 LOCK 前缀ARM 上有 LDREX/STREXMIPS 和 LoongArch 上则是 LL/SC也就是 Load Linked 和 Store Conditional中文叫“加载链接/条件存储”。这套指令是很多锁、引用计数、无锁队列的地基。打包这个词是重点。LoongArch 指令集除了标准的 32 位指令还有一批 16 位紧凑指令。硬件取指时会把两条 16 位指令塞进一个 32 位的槽位里一次解码。这种“两个短指令共用一个 32 位位置”的形态就是事件里的“打包”。编译器为了压代码体积特别喜欢这么干尤其是循环里那些短小的分支指令。死循环和丢失更新其实是同一个根因的两种表象。原子序列里的 SC 指令本该根据“LL 之后有没有别人改过内存”来决定成功或失败。如果硬件状态被打包搞乱SC 可能永远失败代码就在循环里一遍遍重试这就是死循环也可能在别人已经写入之后仍然误判成功把前一个线程的更新覆盖掉这就是丢失更新。一个表现为“卡死”一个表现为“数据不对”但从标题就能闻出来这俩是同一件事的两张脸。1.2 事件怎么发生的从一次“假死”说起按常见的事故复盘路子把事情还原一下。某台机器在压测时突然出现软锁警告内核日志里刷出来类似这样的行BUG: soft lockup - CPU#2 stuck for 22s! [kworker/u8:4:1234]如果你没见过 soft lockup先理解成看门狗发现某个 CPU 太长时间没切换任务了于是主动报案。真正的问题不是“看门狗饿死了”而是这个 CPU 卡在了一段代码里出不来。现场的信息很少只有当前 PC 值和一个堆栈顶部。常规操作是拿addr2line或者gdb把这个 PC 翻译成函数一看指向的是内核里某个自旋锁或者原子计数器的获取路径。第一反应多半是“死锁”。但死锁通常有多个参与者互相等待这里只有一个 CPU 在疯狂转另一个 CPU 还在正常运行更像是一个进程把锁“吸住”了。把perf top拉起来你会看到那个 CPU 上几乎 100% 的周期都花在同一个地址上。反汇编之后发现代码是一个非常紧凑的 LL/SC 重试循环。循环不长指令也没几条理论上应该转个一两轮就拿到锁走人可它偏偏几十亿次都失败。这里就出现了一个关键的倒挂代码逻辑越短越说明问题不在算法而在处理器对这段代码的处理方式。结合 LA664 的微架构特点以及“打包”这个关键词整条线的轮廓就出来了。1.3 为什么只有 LA664 会这样微架构差异同一个内核源码在别的 CPU 上跑得好好的换到 LA664 上就抽风这种“平台相关”的 bug 基本都是微架构行为差异导致的。LA664 是乱序执行、四发射、十二级流水线的设计前端带着短循环回放机制。所谓短循环回放就是处理器发现你这段循环很小而且反复执行干脆把解码结果存进一个循环缓冲器里之后每次迭代直接从缓冲器里“回放”指令省得每轮都去取指、解码。它的最小回放单元往往就是一个 32 位对齐的“包”。如果包里有两条 16 位指令回放单元会把它们当成一个整体来重放而不会精细到每一条指令单独处理。问题就出在这。LL/SC 这套机制的硬件实现里需要有一个保留位或者叫监视标记来记录“哪个地址被 LL 盯上了”。这个标记在不少实现里是和指令本身的生命周期绑定的也就是说它要精确知道“当前正在执行的这条指令是不是 LL 后面应该配套的那条 SC”。一旦指令被打包成两个短指令循环回放时包编号发生了变化硬件对指令边界、包序号的判断就可能错位。SC 在执行时找不到自己对应的 LL 标记或者错误匹配了上一次迭代的标记于是要么永远失败要么错误成功。不是所有微架构都会踩这个坑只有对“包粒度回放”和“LL/SC 标记维护”同时做了激进优化的设计才会。LA664 撞上了所以在它身上一个普通的 CAS 循环也能变成灾难。2. 核心原理LL/SC 循环为什么会丢更新、进死循环2.1 原子读改写的基本功LL 与 SC先把 LL/SC 的工作方式讲透。LL 指令不只是把内存里的值读出来放到寄存器里它还会顺便在硬件里登记一个“我正在监视这个地址”。SC 指令执行的时候硬件会检查这个监视标记如果从 LL 之后到现在没有任何其他核、其他设备往这个地址写过数据SC 就成功把新值写进去如果中间发生过写入SC 就失败什么都不改。SC 成功或失败的结果会写进目标寄存器。成功写 1失败写 0。这里有个反直觉的地方SC 的源寄存器值指的是“要写入内存的新值”目标寄存器则是“这次是否成功”两者不是同一个。所以你在汇编里经常会看到这样的影子try: ll.d $t0, $a0 # 把锁变量加载进 $t0并开始监视 $a0 bnez $t0, try # 锁不是 0说明被别人占着重试 ori $t1, $zero, 1 # 准备新值 1 sc.d $t1, $a0 # 尝试把 1 写进锁变量 beqz $t1, try # 如果失败再回去重试这段代码是典型的内核自旋锁获取路径。加载链接、检查条件、条件存储、失败重试四步一气呵成。在多核环境下两个核可能同时执行 LL同时读到锁是 0然后其中一个 SC 先成功另一个 SC 发现监视地址已经被写过于是老老实实失败、回跳重试。这正是它该有的行为。2.2 循环重试CAS 的经典形态很多高级语言里的 CASCompare-And-Swap或者所谓的原子“读改写”原语在 LoongArch、MIPS 这类没有直接比较交换指令的平台上就是靠上面那种 LL/SC 循环实现的。Linux 内核里的atomic_cmpxchg、atomic_inc等等落到最后都是这段循环。CAS 的通用伪代码很多人都会写old *addr if old expected: *addr new return success else: return fail但它真正跑在 CPU 上时必须把“读旧值”和“写新值”合并成一个不可分割的整体。LL/SC 就是干这个的。LL 读旧值SC 负责“检查有没有人插过手没插手就写入”。如果 SC 失败说明并发环境里有人抢在你前面改了内存你的整个读改写白做了得重新读、重新算、再试一次。这个“白做了”的过程就是语义上的“丢失更新”——你基于一个旧值算出来的新结果没有落地被现实打回原形。正常情况下的“丢失更新”只是暂时的、可重试的。但标题里这个事件的关键在于SC 的失败不是因为有并发竞争而是因为硬件把保留标记弄丢了导致 SC 在完全没有竞争的情况下也失败于是无限重试。这就是死循环的真正来源。2.3 指令打包与循环回放被忽略的边界现在说“打包”。LoongArch 的 16 位紧凑指令像bnez、addi.d的短编码体积只有标准指令的一半。编译器发布代码时为了减小 I-Cache 占用、提高取指效率会尽量用短指令然后把它们两两组合进一个 32 位对齐的包里。比如地址 0x1000: 紧凑指令A | 紧凑指令B硬件取回来的是一个 32 位数据里面有两条指令。前端解码时把它们当成一个包来处理内部再有微操作用来区分先执行哪条、后执行哪条。绝大多数场景下这没有任何问题。但 LL/SC 这种“成对出现、互相依赖”的指令对执行顺序和生命周期极端敏感一旦遇到包内执行顺序重排、或者循环回放时包编号改变就容易被针对普通指令设计的微架构优化伤到。具体到 LA664 上最合理的解释是短循环回放单元记录指令时记录的是“第几个包”而不是“第几条指令”。当 LL 和 SC 分别处于不同的包而且包的编号在回放过程中被复用或偏移时SC 去查询保留标记可能查到的是一个错误的关联关系。结果是同一段循环在连续执行时每次迭代的 SC 匹配状态不稳定偶尔失败、偶尔成功。失败多了表现为死循环偶尔成功的情况发生在错误的时间点就是丢失更新。这里要插一句以下细节里有相当一部分是基于同类问题排查经验做的逻辑补全真实现场比我说的情况更复杂还涉及具体的步进版本和 BIOS 设置。但不影响思路因为你要理解的核心规律是原子指令的语义正确性依赖于微架构对指令边界的精确维护任何把多条指令“捆成一个包”的优化都可能撕开这道口子。2.4 丢失更新的准确含义很多人对“丢失更新”这个词的第一反应是数据库事务里的 Lost Update两个事务同时改同一行后提交的覆盖先提交的。放在 CPU 原子指令场景里意思是一样的只是粒度更细。假设两个 CPU 同时执行 LL/SC 循环更新同一个变量。CPU0 先 SC 成功把变量从 1 改成 2。CPU1 的 SC 本该失败但因为打包导致保留标记错误保持硬件误以为“从 LL 之后没有写入”于是也 SC 成功把变量改成了它的值。如果 CPU1 计算新值时用的是它 LL 读到的旧值 1那它写进去的可能还是 2看起来没问题但更危险的是它可能写 3、写 4覆盖掉 CPU0 刚写入的 2。从最后结果看CPU0 那一次更新就“丢失”了。这种错误比死循环更隐蔽。死循环还能靠看门狗报警误成功的丢失更新可能悄悄跑很久最后表现为某个计数器少了、某个链表里的节点被覆盖、某个锁被两个核同时认为“我持有了”随后就是各种莫名其妙的崩溃和内存踩踏。这就是为什么标题把“丢失更新”和“死循环”并列一个是慢性的、一个是急性的根因同源。3. 实操排查从“软死锁”到锁定指令打包路径很固定3.1 复现一个最小压测场景在没搞清楚根因之前第一步是复现而且最好把场景压到最小。我当时习惯的做法是先写一个用户态的多线程程序开四个线程每个线程对同一个全局变量反复做原子加一循环跑几十亿次。在正常平台上最终结果应该正好等于四个线程各自累加次数的总和。但如果你遇到的是 LA664 这种问题跑一段时间后最终结果可能对不上或者更极端的情况下某个线程直接卡死在那个 CAS 循环里。这里的“原子加一”放到用户态就是__atomic_add_fetch编译器生成 LL/SC 循环。一个最小的程序内核大概是这样的#include stdatomic.h atomic_long_t counter; void *worker(void *arg) { for (long i 0; i 1000000000; i) atomic_fetch_add(counter, 1); return NULL; }编译时不要故意关掉优化因为打包行为只在优化后才会出现。用-O2编译完记得立刻做一步关键操作反汇编确认热循环里确实生成了 LL/SC。如果连 LL/SC 都没有那这个复现就是无效的。实测下来LA664 上这个最简单的压测就有可能在某个核上出现指令周期暴涨。虽然不一定每次都能碰到死循环但只要多跑几次异常的指令数比例就藏不住。3.2 从 soft lockup 到 perf top 的定位路径现实环境里你不会只面对一个压测程序很可能是一个完整的内核锁路径在卡死。定位的基本顺序是固定的。首先是看日志soft lockup或者hard lockup的告警会直接告诉你哪个 CPU 卡了多久以及当前 PC。把PC值放进反汇编文件里找到那几条指令。接着用perf top -C cpu绑定出问题的 CPU看热点是不是集中在同一个地址。如果热点是同一行代码且这行代码属于arch_spin_lock、atomic_cmpxchg、futex这类原子原语基本就可以锁定方向了。接着打开lockdep。虽然它不一定能抓到这种“硬件导致原子失败”的 bug但至少能排除掉传统意义上的死锁。再配合ftrace的function_graph看卡住的上下文到底在等什么锁、这个锁的持有者是谁。大概率你会看到持有者已经跑完了但竞争者还在 LL/SC 循环里打转。这个“持有者不见踪影、竞争者疯狂旋转”的画像就是 LL/SC 无限失败最典型的特征。最后是拿硬件环境说话。查/proc/cpuinfo确认微架构是 LA664再看步进号stepping。找一颗同样平台、但微架构不同的机器做对照组。比如 LA464 的 3A5000 上如果同样的压测跑得风平浪静那嫌疑就进一步集中到 LA664 的前端实现上。3.3 反汇编确认打包指令的两种方法一旦怀疑到“打包”反汇编就是最关键的一步。方法一直接用objdump看目标函数objdump -d vmlinux | grep -A20 arch_spin_lock重点看循环体内有没有 2 字节指令。如果发现bnez、beqz这类分支变成了短编码而且周围指令布局是“16位16位”凑在一个 32 位地址边界上那就已经具备打包条件了。方法二用交差工具看编译产物里的指令大小分布。GCC 在生成代码时会打标签你可以把汇编输出导出来看紧凑分支的分布gcc -O2 -S test.c -o test.s翻开.s文件搜bnez、beqz的出现位置再看每一条 32 位边界上是不是对偶排列。如果紧凑分支进入了原子序列附近尤其是 LL 和 SC 之间的指令区那就能更精准地解释为什么保留标记会被弄丢。还有一个小技巧把循环体手动改成 32 位编码的版本比如把紧凑分支换成等价的 32 位分支在 LoongArch 下可以通过显式指令或者调整汇编伪指令实现再跑同样的压测。如果死循环消失基本就等于实锤了问题就是打包引起的。3.4 定位结论的验证矩阵到了这一步手上应该有四个证据现象卡死或计数错误、热点原子循环内、反汇编存在紧凑打包、对照组其他微架构正常。把它们放到一张验证矩阵里看答案就非常清楚了。验证项预期结果说明perf top 热点地址同一原子循环排除调度和锁竞争导致的偶发等待lockdep 报告无传统死锁排除逻辑死锁保留硬件嫌疑反汇编中的紧凑指令出现在 LL/SC 邻域打包条件成立强制 32 位编码后复测死循环消失确认打包是触发器换 LA464 平台复测无异常确认微架构相关性这套矩阵做完根因基本不会错的。剩下的事就是找修复方案。4. 修复方案让原子循环避开打包陷阱4.1 规避思路一强制非打包指令最直接的修法是让编译器在原子序列附近不要生成 16 位紧凑指令。方向有两个一个是调整编译选项一个是更精确地在内联汇编里强制指令宽度。先说编译选项。LoongArch 工具链对紧凑指令和分支松弛有相关的控制项具体选项名和 GCC 版本有关有些版本叫-mno-relax有些版本做法不同。不要盲抄网上的参数正确姿势是先跑一个最小压测开/关候选选项再反汇编看效果。内核编译时可以给特定文件单独加上 CFLAGS避免全内核重编。但说实话靠全局编译选项救火副作用比较大因为它会把所有代码的紧凑指令都关掉I-Cache 占用会上升。更精准的做法是改动那段 LL/SC 相关的头文件或汇编文件让里面的模板强制使用 32 位指令。比如直接在汇编字符串里塞.word手工编码一条非紧凑指令或者用明确的 32 位汇编助记符。内核里处理这类问题时经常会在相关宏的汇编片段里加入对齐指令#define __LL_SC_INSN .balign 32\n\t \ ll.d\t%0, %1\n\t \ sc.d\t%0, %2\n\t.balign的作用是把原子序列放到一个干净的对齐边界让它的包边界关系变得可预测。这不是解决所有问题的银弹但在很多实际场景里已经足够让 SC 回到正确状态了。4.2 规避思路二改用单条原子内存指令第二种思路更干净既然 LL/SC 这对组合对前端打包敏感那就别用了换一条本身就实现了原子读改写的指令。LoongArch 指令集里是有 AM 类原子内存访问指令的比如amswap.w/d、amadd.w/d、amand.w/d、amor.w/d、amxor.w/d等。它们的语义直接就是“对内存做一次原子操作返回旧值”。拿自旋锁来说用amswap.d可以实现一个非常标准、且没有 LL/SC 窗口隐患的入锁操作again: li.d $t0, 1 amswap.d $t1, $t0, $a0 # 把 1 写入锁变量原值给 $t1 beqz $t1, got_lock # 原值 0说明锁空闲抢到了 b again got_lock: # 进入临界区这种写法里没有 SC 条件判断没有保留标记的维护问题硬件的实现路径也完全不同。它不是靠“监视地址”来判断成败而是靠原子内存单元直接完成交换。对计数累加、位图置位、锁获取这类需求AM 指令几乎都是更好的选择。需要注意的是AM 指令在语义上不等价于 CAS它没有“期望值比较”。如果你需要的是cmpxchg那种“只有当前值等于期望值才改写”的语义AM 指令并不能直接替代还得回到 LL/SC 循环。但很多场景其实不需要完整 CAS用amswap或amadd就够了。4.3 规避思路三给重试加上退让第三种思路是治标层面的即使 SC 会失败也别让它在原地满速空转否则一个瞬时的硬件失误会被放大成持续的 CPU 飙高。很多架构上的自旋锁实现里都有一条“退让”或者“暂停”指令比如 x86 的pause。LoongArch 上也有类似的方案可以插入一条开销较低的指令让流水线喘口气降低访问内存的竞争强度。如果你的代码里是一个纯 LL/SC 重试循环失败后至少加一个nop或者平台相关的延迟序列能显著缓解问题try: ll.d $t0, $a0 bnez $t0, try ori $t1, $zero, 1 sc.d $t1, $a0 beqz $t1, delay b got_lock delay: # 延迟退让降低前端的持续回放压力 b try但这类退让只能让系统不卡死或者至少不触发 soft lockup 报警不能解决“丢失更新”的语义错误。如果 SC 会误成功退让毫无作用。所以这三个思路的正确使用方式是思路三先保命思路二优先选思路一作为兜底。4.4 内核补丁落地时的注意点真要落补丁到内核有几个细节特别容易翻车。第一不要单独改某一个自旋锁函数就收工。内核里 LL/SC 循环无处不在atomic_*、cmpxchg、futex、页表锁全都要检查。建议先把架构相关的原子操作头文件全部过一遍统一更换或统一加规避点。逐个小修会留下隐患下个月换个函数又炸一次。第二改完一定要跑并发压力测试而不是只跑功能测试。压测工具可以简单但要能检验“最终一致性”。我之前用的办法是开几十个线程同时对同一个计数器累加最后校验总数只要对不上就是失败。再配合死循环检测比如超时后强制打印调用栈能很快确认是否还有漏网之鱼。第三如果是在自己的项目里规避而不是等上游补丁记得把问题描述清楚留一个注释说明是 LA664 上哪类微架构问题。这种注释对后来者价值极大否则三个月后同事看到一行.balign 32以为是无意义优化手一抖删了就前功尽弃。5. 常见问题与避坑速查5.1 高频问题速查表把这段排查经历里的典型问题整理成了表格按现象查方向最方便。现象可能根因排查起点soft lockup只有一个 CPU 100%SC 无限失败LL/SC 死循环perf top 定位热点地址多线程累加结果对不上SC 误成功丢失更新反汇编检查是否有紧凑指令打包换编译优化级别后现象消失打包被优化掉或破坏对比 O2 与 O0 的指令布局同代码在其他平台正常微架构特定前端问题换 LA464 / ARM / x86 对照测试改完编译器选项不生效工具链选项名不对直接用 objdump 验证指令宽度这张表只覆盖最典型的情况。实际现场可能更脏比如死循环出现在 RCU 的 grace period 等待路径里或者出现在某个驱动自旋锁里但内核定位路径是一致的从热点到反汇编从反汇编到指令宽度从指令宽度到微架构特性。5.2 几条我用真金白银换来的经验最后再多说几句经验算是用机器重启次数换回来的。第一永远不要轻信“代码逻辑简单就不会有事”。这段代码逻辑简单到一眼看完但它最终跑在一个复杂微架构上指令打包、循环回放、乱序执行、保留标记任何一个环节都可能是炸弹。越是看起来“应该没问题”的原子循环越要在真实机器上压测。第二反汇编是排查这类问题时最值得信赖的工具没有之一。编译器的逻辑有时候会把你看晕但最终执行的指令摆在那里一条条看下去很多“鬼魅”都会现形。我后来养成了一个习惯凡是涉及原子操作的新代码发出去之前先反汇编看一遍循环体发现问题当场改不等测试环境报警。第三遇到 LL/SC 相关的问题别急着去改业务代码。先排查硬件平台差异再看微架构特性最后才动逻辑。这个顺序搞反了你可能花了几个小时找到了一个“并发竞争”问题结果压根是个 CPU bug白忙一场。第四如果可能的话尽量用单条原子指令替代 LL/SC 循环。现在主流架构几乎都提供了比 LL/SC 更友好的原子内存操作能用amswap、amadd之类解决的就不要手工搭循环。不是说你永远遇不到硬件问题而是解决问题的面会小很多至少把前端打包这个变量从方程里拿掉了。