当 Ldaxr 失灵时:AArch64 上的独占访问与缓存能力

Hacker News Top 新闻

摘要

作者在为 AArch64 从零编写自制操作系统 floss 的过程中,深入分析了 ldaxr/stxr 独占内存访问指令在 QEMU 上运行正常、却在真实硬件上触发异常的问题,探讨了自旋锁、异常处理、虚拟内存、内存类型与缓存属性之间的复杂交互。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/11 12:40

# 当 ldaxr 失灵时:AArch64 上的独占访问与缓存性 来源:https://joelsiks.com/posts/aarch64-exclusive-access-cacheability/ 2026-10-10 ## 引言 自从我上一篇关于 AArch64 中断和通用中断控制器的文章(https://joelsiks.com/posts/aarch64-interrupts)以来,我已经开发 floss(https://github.com/joelsiks/floss)好几个月了——这是我为 AArch64 从零开始编写的操作系统(OS),目前实际上只是一个内核。目前,floss 面向 QEMU 的 virt 和 Raspberry Pi 4(rpi4)板型,以及真实的 Raspberry Pi 5(rpi5)硬件。在真实硬件上测试非常有收获,也让这个项目变得更加有趣。然而,正如我们在本文中将详细讨论的那样,真实硬件暴露出了 QEMU 不一定能够展现的全新故障维度和行为模式。 本文将讲述我实现基本自旋锁的过程,以及弄清楚为什么在真实硬件上执行加载独占指令会触发异常、而在 QEMU 中却不会。我们将涉及一系列引人入胜的领域,包括:自旋锁、异常处理、虚拟内存、内存类型、内存属性、缓存性、QEMU 与真实硬件之间的差异,以及最重要的——独占内存操作的工作原理。 ## 实现自旋锁 当我开始启用并启动 CPU 上的次级核心时,正是引入某种互斥机制的好时机。举一个简单的例子,每个核心在初始化路径中打印自己的 ID,例如"Running core 0"。如果没有互斥机制,各核心会相互竞争,导致输出交错,产生如下所示的混乱输出。为了获得可读的输出,我们需要用某种互斥机制来保护打印操作,使得同一时间只有一个核心进行打印。 ``` Running core 0 RRRununing cournnien ng 2cni onrge 1co re 3 ``` 为了热身锁和同步方面的知识,我阅读并回顾了 Arm 的《Implementation Software Synchronization Primitives in A64》(https://support.arm.com/documentation/110478/0100),以及我最喜欢的操作系统书籍《Operating Systems: Three Easy Pieces》(https://pages.cs.wisc.edu/~remzi/OSTEP/)。作为一个良好的起点,我决定实现一个自旋锁——其最简单的形式极其直接。我还决定用手写汇编来实现自旋锁,这比用 C/C++ 编写要有趣得多。 我的基础版本使用加载独占和存储独占指令,它们协同工作以实现原子性。其思想是:自 `ldx[a]r` 指令执行以来,如果没有观察到对同一内存位置的其他写入,`stxr` 指令才会成功。 ```cpp void SpinLock::lock() { uint32_t lock_read; uint32_t store_result; asm volatile( "1: \t\n\ ldaxr %w0, [%2] \t\n\ cbnz %w0, 1b \t\n\ mov %w0, #1 \t\n\ stxr %w1, %w0, [%2] \t\n\ cbnz %w1, 1b \t\n\ " : "=&r"(lock_read), "=&r"(store_result) // outputs : "r"(&_lock) // input : "memory" // clobber ); } void SpinLock::unlock() { asm volatile( "stlr wzr, [%0]" : // no output : "r"(&_lock) // input : "memory" // clobber ); } ``` 该锁使用一个 32 位的锁字(因此使用 `w` 寄存器),其中 0 表示未锁定,1 表示已锁定。为了保证正确性,加载独占指令带有获取语义(`ldaxr` 中的 "a"),这保证了加载之后的所有内存访问不会被重排到它之前。类似地,解锁路径中的存储带有释放语义(`stlr` 中的 "l"),这保证了存储之前的所有内存访问不会被重排到它之后。 锁通常从三个维度进行评估:*正确性*、*公平性*和*性能*。我们稍后将对正确性进行经验性验证,而内存排序我们已经考虑过了。公平性暂时不考虑,对于这个基础版本来说是可以接受的,但可能会出现某个核心被饿死、始终无法获取锁的情况,如果其他核心总能先获取到锁的话。性能也不是太好,因为各核心会花费时间周期忙等待以获取锁。 解决性能问题的一种方法是使用 Arm 的 `wfe`(等待事件)和 `sev`(发送事件)指令对,它使核心进入低功耗状态等待事件,并唤醒所有正在等待的核心。这是未来的工作。 ## 验证 我最初在产生混乱输出的路径中测试了自旋锁(见[#实现自旋锁](https://joelsiks.com/posts/aarch64-exclusive-access-cacheability/#implementing_spin_lock)),以便在多核心启动过程中获得可读的输出。在 QEMU 的 virt 和 rpi4 板型上,一切似乎都运行正常。为了保险起见,我重新运行了程序数千次,从未观察到混乱的输出。 ```cpp // SpinLockGuard 是一个 RAII 对象,在构造时加锁,在析构时解锁 { SpinLockGuard guard(&_print_lock); kprintf("Running core %d\n", cpu_id); } ``` 然而,当我在 rpi5 上运行相同的代码时,遇到了一个神秘的异常。以下输出来自我的自定义异常处理器,我们可以看到一个 Data Abort 异常,其异常类别(EC)值为 37(0x25)。根据 Arm 的"Types of exception"(https://support.arm.com/documentation/den0042/0100/Exceptions-and-Interrupts/Types-of-exception):*"Data abort 异常是由加载或存储指令引发的。"* ``` Got an exception (from 0x84474) - Syndrome is 0x96000410 - EC is 37 (Data Abort) Register dump: x0: 0x2bcf88 ... x30: 0x85554 ``` 仔细查看 CPU 在异常发生时正在执行的指令(0x84474),我们可以看到它正好在 `ldaxr` 指令上。这与我们得到的 Data Abort 值相吻合,所以一定是 `ldaxr` 的内存访问出了问题。作为一个合理性检查,将加载独占和存储独占替换为"普通的"加载获取指令(`ldar`)和普通存储指令(`str`)后,问题就消失了,不再抛出异常。这告诉我有什么东西阻止了独占操作的正常工作。 ``` 0000000000084470 <_ZN13SpinLockGuardC1EP8SpinLock>: 84470: f9000001 str x1, [x0] 84474: 885ffc20 ldaxr w0, [x1] <-- CPU 停在这里 84478: 35ffffe0 cbnz w0, 84474 8447c: 52800020 mov w0, #0x1 84480: 88027c20 stxr w2, w0, [x1] 84484: 35ffff82 cbnz w2, 84474 84488: d65f03c0 ret ``` ## 追根溯源 幸运的是,因为我之前阅读过《Implementation Software Synchronization Primitives in A64》(https://support.arm.com/documentation/110478/0100),我记得有一个专门关于[内存属性](https://support.arm.com/documentation/110478/0100/AArch64-Atomic-Instructions/The-memory-attribute-of-address-which-is-atomic-accessed)的章节。该章节强调了架构上保证原子指令具有原子性的内存属性。它们是: - Inner Shareable、Inner Write-Back、Outer Write-Back 的 Normal 内存,具有读分配提示和写分配提示且非瞬态(not transient) - Outer Shareable、Inner Write-Back、Outer Write-Back 的 Normal 内存,具有读分配提示和写分配提示且非瞬态(not transient) 该页面还列出了一些可能不支持原子指令的内存类型。其中最值得注意的是: - Device 内存、Non-cacheable 内存,或被视为 Non-cacheable 的内存,在支持硬件缓存一致性的实现中 ## 内存类型 所有内存的默认内存类型是 Device 内存。但我在 floss 中很早就设置了虚拟内存并进行了映射,将所有 RAM 区域关联到"Normal"内存类型,以便能够执行非对齐访问(例如,从一个不是 8 字节对齐的地址进行 8 字节加载)。这是因为 Device 内存要求对齐检查。我是在从 QEMU v8.2.2 升级到 v11.1.50 时意识到这一点的,这次升级包含了一个(不太新的)[补丁](https://gitlab.com/qemu-project/qemu/-/commit/59754f85ed35cbd5f4bf2663ca2136c78d5b2413),为设备内存添加了对齐检查,引发了一个相当恼人的异常,也花了一些时间来调试。 Normal 内存是一个相当模糊的术语,但通常是指所有非 Device 的内存类型。Normal 内存通常可以根据属性进行缓存、推测执行和重排序。通过内存映射 IO(MMIO)访问的 Device 内存通常不能像 RAM 那样进行推测执行或缓存。 所有类型的内存都由一组属性定义,这些属性可以针对每个虚拟内存映射单独配置。 ## 内存属性 内存属性通过内存属性间接寄存器(Memory Attribute Indirection Register,EL1 对应 `MAIR_EL1`)配置。这是一个 64 位寄存器,被划分为 8 组不同的属性,每组 8 位。指向物理内存块(即 [Block 或 Page 描述符](https://support.arm.com/documentation/102376/0200/Describing-memory-in-AArch64))的转换表(Linux 术语中称为页表)条目中有三位引用 MAIR 寄存器中的索引,表示该特定内存映射的属性。此外,这样的条目中还有配置执行权限、访问权限、可共享性等的位。 与本文相关,特别是与独占内存操作相关的,是可共享性配置,它可以设置为以下之一:*Non-Shareable*、*Inner-Shareable* 或 *Outer-Shareable*。粗略地说,可共享性决定了内存操作应在哪个域内、对哪些观察者被观测为在有限时间内完成,而无需使用显式的缓存维护操作。这个话题相当复杂,更多信息请参考 Arm ARM 的 B 2.7.1 章节(https://support.arm.com/architectures/a-profile-architecture)。在 floss 中,Normal 内存类型被配置为 Inner-Shareable,这正是 Arm [期望](https://support.arm.com/documentation/102376/0200/Cacheability-and-shareability-attributes)操作系统采用的配置。 目前,floss 只配置了两组内存属性,一组用于 Device 内存,一组用于 Normal 内存。让我们忽略 Device 的具体细节,专注于 Normal 内存。 - **Device**:`0b00000000`,Device-nGnRnE 内存 - **Normal**:`0b11111111`,Normal 内存,Outer Write-Back、Inner Write-Back,具有读分配提示和写分配提示且非瞬态 Write-Back 属性意味着写入应先写入缓存,只有当缓存行被淘汰时才刷写到内存。总而言之,这意味着我为 Normal 内存配置的内存属性对缓存有*完全*支持。这也完全符合原子指令保持原子性的架构要求。 Write-Back 的替代方案是 Write-Through,它会立即同时写入缓存和内存,而无需等待缓存行被淘汰。 ## 全局与局部缓存性作用域 如我们所得出的结论,Normal 内存类型的内存属性确实具有可缓存的 Inner 和 Outer Write-Back 属性。即便如此,就目前我们掌握的所有细节来看,执行加载独占指令时抛出异常的唯一合理解释就是缓存性。 在我的 LLM 的帮助下,我发现在系统控制寄存器(`SCTLR`,https://support.arm.com/documentation/100442/0100/register-descriptions/aarch32-system-registers/sctlr--system-control-register)中有一个全局设置:"C" 位,它控制 EL1 和 EL0 的数据访问缓存性(floss 运行在 EL1)。查询该位后,我发现它在所有核心上都是默认值 0,这禁用了所有核心的数据缓存。将该位设置为 1 后,rpi5 上的异常就消失了。太好了! 阅读 Cortex-A76 CPU(rpi5 的 CPU)的 [L1 内存系统和缓存行为](https://support.arm.com/documentation/100798/0401/L1-memory-system/Cache-behavior/Data-cache-disabled-behavior?lang=en)文档后,我发现了一些很有揭示性的信息: - 当数据缓存被禁用时,指令和操作受到如下影响:- 所有对可缓存内存的加载和存储指令都被视为 Non-cacheable,并且与本核心及集群中其他核心的缓存不一致。软件必须考虑到这一点。 ## 缓存性 我们已经弄清楚了为什么 rpi5 上会抛出异常,因为 `SCTLR.C` 值为 0 会将内存有效地视为 Non-cacheable。有趣的是,如果我通过将 `SCTLR.C` 设置为 1 来启用每个核心的全局缓存性设置,但将 MAIR 属性更改为没有启用 Write-Back 的值,同样的异常仍然会出现。我对 `ldaxr` 引用的内存进行了不同 MAIR 配置的小测试,以下是 rpi5 上执行的结果。 | 位模式 | 描述 | 是否抛出异常 | |--------|------|-------------| | `0b00110011` | Normal 内存,Outer/Inner Write-Through Transient | 是 | | `0b01000100` | Normal 内存,Outer/Inner Non-cacheable | 是 | | `0b01110111` | Normal 内存,Outer/Inner Write-Back Transient | **否** | | `0b10111011` | Normal 内存,Outer/Inner Write-Through Non-transient | 是 | | `0b11111111` | Normal 内存,Outer/Inner Write-Back Non-transient | **否** | 从这个实验来看,加载独占指令在没有 Write-Back 的情况下似乎无法工作。事实上,Cortex-A76 CPU 的[内存属性](https://support.arm.com/documentation/100798/0401/Memory-Management-Unit/Specific-behaviors-on-aborts-and-memory-attributes/Memory-attributes?lang=en)文档指出:*"... 一个页面只有在 Inner 和 Outer 内存属性都是 Write-Back 时才是可缓存的。在所有其他情况下,所有页面都会降级为 Non-cacheable Normal 内存。"*这意味着尽管 Write-Through 在技术上意味着"写入缓存",但 Cortex-A76 CPU 实际上并不会缓存这种类型的内存。 由此看来,加载独占操作只有在缓存性被启用的情况下才能顺利完成而不抛出异常——既需要通过 `SCTLR.C` 设置为 1 来全局启用,也需要翻译表条目设置 Write-Back 属性来局部启用。 有点令人沮丧的是我没能在这个问题在 QEMU 中捕获到。QEMU 尚不支持 rpi5 板型,实际的 rpi5 硬件与现有的 rpi4 QEMU 板型之间可能存在一些差异。不过,在翻阅 QEMU 源码时,我发现缓存性根本没有被建模(参见提交 [fa2ef212df](https://gitlab.com/qemu-project/qemu/-/commit/fa2ef212df) 和 [b0fe242751](https://gitlab.com/qemu-project/qemu/-/commit/b0fe242751) 中的参考),这一点得到了进一步证实——`SCTLR_C` 常量在[任何地方](https://gitlab.com/search?search=SCTLR_C&nav_source=navbar&project_id=11167699&group_id=3038080&search_code=true&repository_ref=v11.1.2)都没有被引用。所以,即使它在 QEMU 中运行良好,也不要假设一切都没问题,直到你在真实环境中测试过。 ## 独占监视器 为了通过加载独占和存储独占指令实现原子性,会使用一个或多个独占监视器。每个核心都有自己的本地监视器,所有核心都可以访问一个全局监视器。每个监视器有两种可能的状态:*open* 和 *exclusive*。当执行加载独占指令时,监视器被转移到 exclusive 状态,并记录加载的地址。当执行存储独占指令时,会检查监视器是否都处于 exclusive 状态且地址匹配,如果是则操作成功。 更准确地说,存储在独占监视器中的地址只是上位地址...

相似文章

NX位不仅仅关乎安全

Lobsters Hottest

一位开发者描述了在postmarketOS的ARM64裸机虚拟机管理程序中调试一个复杂bug的过程,涉及指令缓存一致性问题和与NX位相关的硬件特定行为。

x86 模拟的难题

Hacker News Top

本文讨论在 ARM 的弱序内存模型上模拟 x86-TSO 内存模型的挑战,涵盖原子指令、分裂锁和未缓存内存等问题,并探讨潜在解决方案。

Linux Lockdown 与休眠功能(又见)

Lobsters Hottest

Matthew Garrett 解释了 Linux Lockdown LSM 与休眠功能之间的冲突,以及 UEFI Secure Boot 如何催生了内核与 root 权限之间的强边界。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。