跳转至

一颗 CPU 的原子指令,一个打包死循环:LA664 丢失更新事件始末

本文同步发布到本人的知乎

English version

太长不看版本

2026 年 2 月,王邈在龙架构服务器上给 Debian 打包 normaliz 时遇到一件怪事:这个数学软件的自带测试总是超时,现象是卡在死循环里出不来。顺着代码调查,问题指向一个很常规的操作:OpenMP 的 #pragma omp atomic 对共享变量进行累加。循环的退出条件要求累加后的值等于某个数,而累加的结果总是少于这个数,就导致了死循环。由于程序太大、代码又很复杂,始终没能把问题缩减成一个人类能看懂的最小例子,这件事就被搁置了。

半年后的 8 月,王邈再次找到我,想把它重新捡起来。这回我们换了个做法:不再由人来定位问题,而是让 AI 去找最小复现,人在这个过程中负责指挥 AI 调查的方向。大概两天后,我们拿到一个稳定的复现程序,才发现事情的根源是:CPU 的原子加法指令,居然偶尔会不原子。这意味着我们找到了 CPU 的一个新 erratum,而龙芯得知这件事后,仅仅过了两周,就找到了几乎没有性能损失的修复方法,并给我们提供了测试固件。我们确认了测试固件可以解决问题,并且龙芯告诉我们,该测试固件预计在国庆(10 月 1 日)之前发布,届时读者将可以升级固件以修复该问题。

下面,让我们把整个事件的来龙去脉娓娓道来。

缘起

loong13 是社区维护的一个 Debian 13 稳定版在龙架构上的移植,王邈是维护者之一。在编译打包过程中,发现 normaliz 的自带测试会卡在循环里无法退出,导致打包超时。当时并没有立即找到问题的根源,只好先跳过这个软件包。但由于有一些其它软件包依赖 normaliz,不能一直跳过,2 月份的时候,我们开始集中精力排查这个问题。此前在构建其它软件包的过程中,我们发现过代码中暗藏的竞态条件或内存序问题,这类问题更容易在采用弱内存序的龙架构上暴露出来。因此,一开始我们推测,原因可能是该软件也存在类似的问题。但是在排查开始后,不出意外就出了意外。

第一轮排查

第一轮排查从 normaliz 的源码开始。normaliz 使用 OpenMP 并行处理数据点,出问题的代码片段概括起来如下所示:

func (std::list<std::vector<int>> LatticePoints) {

  size_t nr_to_match = LatticePoints.size(); // 输入大小
  size_t nr_points_matched = 0; // 维护已经处理了的点的数目

  while (true) {
    size_t nr_points_done_in_this_round = 0; // 维护本轮处理了的点的数目

    #pragma omp parallel
    {
      auto P = LatticePoints.begin(); // 线程私有的 List 指针
      size_t ppos = 0; // 线程私有的 List 指针位置
      #pragma omp for
      for (ppp = 0...nr_to_match){

        if (skip_remaining) { // 特定情况下会设置 skip_remaining,从而跳过未处理的点
          continue;
        }

        // 根据 ppos 和 ppp 的差值,将 P 挪动至 ppp 所指位置
        // 并维护 ppos

        if ((*P)[0] == 0) { // 表示处理过了
          continue;
        }

        #pragma omp atomic
        nr_points_matched++;
        #pragma omp atomic
        nr_points_done_in_this_round++;

        // 处理 P 所指对象
        (*P)[0] = 0;
      }
    }

    // 一直没能执行这个 break
    if (nr_points_matched == nr_to_match)
      break;
  }

}

这段代码的大意是:对于给定的 LatticePoints 列表,程序会并行地处理每一个点。在处理每一个数据点时,有些数据点有可能会被暂时跳过,需要反复遍历,直到所有的数据点都被处理完毕为止。具体到这段代码,其中 nr_to_match 表示总的数据点数,nr_points_matched 表示已经处理完的数据点数,nr_points_done_in_this_round 表示本轮处理的数据点数。循环终止的条件是 nr_points_matched 等于 nr_to_match,即所有的数据点都被处理。而导致死循环的直接原因是,nr_points_matched 永远达不到 nr_to_match,从而导致循环无法终止。使用 gdb 调试可以发现,在出现这种情况时,整个 LatticePoints 列表中的所有点都被标记为处理过了,因此 nr_points_matched 不再增加,但循环的退出条件始终不满足,于是就一直循环下去。那么问题就变成了:为什么计数器 nr_points_matched 的值和实际处理的数据点数不一致。根据代码,nr_points_matched 每一轮的增量会与 nr_points_done_in_this_round 相等,因为它们总是一起原子加一。但实际输出却并非如此:两个计数器的值会有些许差别,而且差距不稳定,每次运行得到的结果可能都不相同。

首先排除的是内存序问题:这段代码并不依赖原子变量来同步其它变量,也就是说,它自始至终操作和读取的都是原子变量本身,所以从代码的角度看,逻辑上是正确的。其次怀疑的是 OpenMP 的实现是否有问题:用 #pragma omp atomic 标注的原子操作是否真的保证了原子性。从反汇编结果可以看到,编译器将这些原子操作生成了 LoongArch64 的 amadd.d 指令,符合预期。为了排查这一点,我们另外设置两个 std::atomic 计数器作为对照,与原来的两个计数器同时使用,观察结果是否一致。结果发现,四个计数器的值(按每轮的增量计算)理应一致,但实际上却呈现出随机的差异。这暗示着,原子加指令会在特定情况下丢失更新。

但是,用简单的原子加程序测试原子加指令的原子性,并不能复现丢失问题。为了找到最小的复现样例,我们把前述 normaliz 的处理逻辑简化为一个类似的测试程序,同样不能复现这个问题。所以只好在 normaliz 实际运行的代码中,不断注释掉其中的运算步骤,试图找出触发原子加丢失的最小条件。一个诡异的现象是,在注释掉大部分的运算步骤后,问题依然存在。由于程序太复杂,最终还是没能找到一个能够稳定复现原子加丢失的最小代码片段。

第二轮排查

6 个月后,问题依然没有得到解决。随着 LoongLeak/LoongBleed 漏洞的公开,normaliz 中原子加丢失的问题也重新回到我们的视线。这次,我们试图使用 AI 来协助排查。具体使用 AI 的方法是:首先向 AI 指出 normaliz 的上述代码存在死循环问题,要求 AI 确认并复现,然后找出可能原因。在第一轮对话中,AI 注意到了出问题的循环,但并没有得出原子加指令存在问题的结论。此后,我们又向 AI 提示该问题只在 LoongArch 上存在、在其它架构上不存在,AI 依然没能给出明确的结论。最后,我们直接告诉 AI 已经定位到原子加存在问题这一事实,并要求它复现并给出最小复现样例。这轮对话中,AI 最终把目光投向处理函数中的一段 memcpy 调用,而这正是第一轮排查中被忽视的部分:memcpy 的实现位于 glibc 中,glibc 会根据当前可用的硬件特性选择最优实现;如果硬件支持向量指令集(龙架构上是 LSX/LASX),glibc 的 memcpy 就会使用相应的向量指令加速内存搬运。而正是这些向量化的内存搬运,触发了 LoongArch64 上原子加指令丢失的问题。两天后,AI 给出了一个稳定复现问题的最小程序。

问题面的扩大

发现原子加指令会出现丢失更新的问题后,我们产生了新的疑问:其一,只有原子加存在问题,还是其它原子指令也存在问题;其二,其它的内存操作是否也会触发类似的问题。

对于问题一,我们首先排查了 CAS 指令,因为它可用于实现原子加法,很容易判断是否出现问题。结果发现,CAS 指令在相同条件下也存在问题。对于其它原子指令,例如原子交换、原子最大、原子最小、原子按位与、原子按位或等,由于即使发生丢失,也不容易从结果上判断出来,因此一开始并没有验证。例如,原子取最大值的过程中如果发生丢失,那么只要更新最大值的那次操作没有丢失,结果就是正确的。

经过深入思考,我们最终找到了验证方案:为了检查这类指令是否发生丢失,改为把每次操作的结果都记录下来,事后再做校验。以原子取最大值为例,如果并行地对 1 到 n 这 n 个数进行原子取最大值操作,那么最终的结果应该是 n。每次原子取最大值时,修改内存的同时,还会返回旧的最大值。对于输入为 k 的操作,如果返回的旧值小于 k,则说明此次操作更新了最大值。原子性保证了:所有更新了最大值的操作,其返回值不会出现重复。如果出现了重复,则说明发生了原子指令更新的丢失。经过验证发现,这些原子指令在相同条件下,都会发生丢失。

对于问题二,我们测试发现,只有 LASX 的向量化读内存操作(xvld)会触发原子指令丢失的问题;而正常的标量读取和 LSX 的向量化读内存操作(vld)则不会触发该问题。后来,Rong "Mantle" Bao 独立发现,当原子变量所在的内存地址与被读取的内存地址存在特定的位置关系时,正常的标量读取也可能触发原子指令丢失,意味着即使没有使用 LASX 也可能出现问题,只是概率更小。这些复杂的触发条件,解释了为什么这个问题一直没有被找到。

具体结论

在介绍具体结论之前,首先介绍关于此问题的背景知识:龙芯的 3C6000/S 和 3A6000 用的是 LA664 核心,其指令集是 LoongArch64,带有 SIMD 扩展;其中,128 位的 SIMD 扩展叫 LSX,256 位的叫 LASX。较早的 LA464 核心(如 3A5000)没有这个问题。LASX 中存在读取内存指令 xvld,一次可以读取 32 字节到向量寄存器中。龙芯的原子指令可以概括为:am<OP>[_db].<width>,其中 <OP> 表示具体的原子操作,如 amaddamcasamswapammaxamxoramandamor 等;[_db] 表示是否带有 data barrier(db)即数据屏障;而 <width> 表示操作的数据宽度,如 .d 表示 64 位,.w 表示 32 位。

总结实验结果,复现原子操作丢失需要同时满足以下三个条件:

  • 线程运行在不同的物理核上(即不能是同一个物理核上的两个 SMT 逻辑核);
  • 这些线程对同一地址进行不带数据屏障的原子操作;
  • 其中至少一个线程在原子操作之间穿插内存读取操作。

其中,内存读取操作可以是 LASX 的向量化读内存操作 xvld;当被读取的内存地址与原子操作的内存地址满足特定的位置关系时,内存读取操作也可以是普通的标量读取。

复现和测量

最小的复现方法,来自于 AI 对 normaliz 代码的简化。normaliz 每个数据点大小是 2208 字节,即是 276 个 uint64_t。两条线程各自分片遍历这些点,对每个点先做一次向量化的内存搬运(memcpy),再对三个共享计数器各做一次 relaxed 原子加(对应不带数据屏障的 amadd 指令)。一轮结束后,检查三个计数器是否相等,不相等就说明丢失了更新。

在 3C6000/S 上,用两个不同物理核(比如 CPU0 和 CPU2)、每个点 2208 字节、每次试验 200 轮、共 30 次试验,得到如下结果:

原子操作 双方都做 LASX 拷贝 双方都做 LASX 读 单侧 LASX 拷贝
amadd.d 67% 100% 53%
amadd.w 73% 100% 67%
amcas.d 77% 97% 17%
amcas_db.d 0% 0% 0%
ammax.d 43% 100% 50%
amswap.d 53% 100% 53%

表里的百分比是“30 次试验中失败的试验比例”。可以看到“双方都做 LASX 读”最容易触发问题,触发概率几乎到 100%;带 db 的 amcas_db.d 在同样条件下总是 0%。

插曲:为什么 AOSC 后来复现不了了

在复现这个问题的过程中,有一个小插曲,也是整件事里最有意思的地方。在 2 月刚发现这个问题时,AOSC OS 和 Debian 都能复现 normaliz 的死循环问题。但是到了 8 月重新排查时,AOSC 那边却怎么都复现不出来,而 Debian 照旧。当时我们并不知道为什么会出现这种不一致,只是继续在 Debian 上做实验。

后来,随着 AI 定位到问题出在 memcpy,我们才明白了其中的原因。AOSC 在 2 月份之后发布的 Core 13 版本在 glibc 里错误地关掉了 --enable-multi-arch 选项,于是系统 memcpy 不再走 LASX 加速路径;而 Debian 的 glibc 正常启用向量加速,因此 memcpy 会使用 LASX。2 月份的时候,AOSC 和 Debian 都使用 LASX 加速路径;而到了 8 月,AOSC 的 memcpy 不再使用 LASX 加速,自然就触发不了。在 Debian 上,用 GLIBC_TUNABLES=glibc.cpu.hwcaps=-LASX 关掉 LASX 加速,问题也不再触发。

所以等 AOSC 发布 Core 14 版本,重新打开 --enable-multi-arch 选项后,问题也会重新复现。考虑到 memcpy 很常用,受影响的程序可能比我们想象的还要多,只是因为触发概率较小,难以发现。

影响面分析

存在问题的原子指令中,ammaxammin 等指令由于目前 C 标准中没有对应的原子操作接口,所以很难被编译器生成出来;而 amcas 属于 LoongArch64 v1.1 的新增指令,因此在默认情况下也不会被编译器生成。可能存在较大影响的是 amadd,即原子加指令。该指令通常会被用于引用计数:如果引用计数的增加操作丢失,计数就会小于实际引用数,可能导致对象被提前释放,从而引发 use-after-free 或双重释放等内存安全问题。而触发的另一个条件,即向量化内存读取,则很容易被 memcpy 之类的函数触发。我们发现,在 Rust 标准库中,std::sync::Arc 的引用计数、std::sync::mpscSender 克隆也都采用了不带数据屏障的 amadd 指令。我们构造了 safe Rust 程序,使用 Arc 或者 mpsc 都能让程序崩溃,表现为 SIGABRT 或 glibc 报告堆被破坏,这意味着出现了 use-after-free。

但是,该问题很难被用于安全攻击。因为要触发该问题,两个线程必须在同一个对象的同一个计数器上并发执行 relaxed 原子操作,而且至少有一方在执行向量化内存读取。这种触发条件意味着潜在攻击者与受害者必须处于同一进程内,无法跨越进程隔离,因此很难被单方面利用。

规避方法

一般而言,由于在高级语言代码中,开发者对编译器产生的原子指令并无控制能力,因此对应用软件的开发者而言,并无直接的规避手段。在软件层面的规避方法,主要通过编译器实现,即编译器不再生成不带数据屏障的原子指令(am<OP>.*),而是生成带有数据屏障的原子指令(am<OP>_db.*),或者使用 LL/SC 循环来实现原子操作。但对于目前已经存在的二进制程序而言,则需要完全重新编译才能应用这些规避方法。因此,从软件层面上规避的代价很高昂。

修复

向龙芯反馈后,修复来得很快:2026 年 8 月 26 日我们发邮件把问题反馈给龙芯,仅仅两周后的 2026 年 9 月 9 日就拿到了测试固件,3A6000 和 3C6000/S 实测都恢复了正常。龙芯告诉我们,这个固件预计会在国庆节(10 月 1 日)之前发布。

修复方式是将 MCSR24 的 bit 13 置为 1。MCSR24 是一个内部的 CSR,手册中并未说明其功能。设置这个 bit 之后,丢失更新的问题不再出现。经测试,性能损失很小:单核性能没有受到影响,多核性能只是略微下降。

如果受影响用户暂时不能更新固件,也可以选择在 Linux 内核里直接写入该 bit,这样等效于完成固件修复,不需要等待主板固件更新。

总结

回过头来看,这个故事起源于一个纯软件的问题:打包时出现的死循环、偶尔算错的计数器。到最后却发现是 CPU 里的一条原子加指令,它并不原子。从发现问题到找出原因前后跨越了半年时间,其中真正有效的推进只在两天里完成,AI 和人都有不可或缺的作用。剩下的时间就是确认问题、找到触发条件、扩大测试面,等待固件修复。

事实上,类似的 erratum 在各家厂商的 CPU 中都很常见。感兴趣的读者可以翻阅 ARM 公版核的 Software Developer Errata Notice,其中不少涉及访存指令或原子指令,个别严重的甚至会导致 CPU 死锁;但真正影响到用户使用体验的其实非常少。这类问题,与其让它在未来的某一天于某个复杂系统中以一次不稳定的报错突然冒出来,不如尽早定位到具体原因并加以修复。

致谢

这项工作由王邈发起并主导,我负责复现和报告整理。在我们告知 amcas 也存在问题之后,Rong "Mantle" Bao 又发现了类似的 CPU 问题,并一并得到了修复。感谢龙芯芯片研发部和开发者社区运营部等部门在报告和修复过程中高效而专业的表现!

评论 / Comments