プログラミング
ldaxrが機能しない場合: AArch64における排他的アクセスとキャッシュ可能性
When Ldaxr Doesn't Work: Exclusive Accesses and Cacheability on AArch64 (joelsiks.com)
要約
著者は、AArch64アーキテクチャ上でゼロからオペレーティングシステム(OS)カーネルを開発する過程で、スピンロックの実装中に発生した問題について解説しています。QEMUエミュレータでは問題なく動作したロード排他(ldaxr)命令が、Raspberry Pi 5のような実ハードウェアでデータアボート例外を引き起こしました。この原因を調査した結果、メモリタイプと属性、特にキャッシュ可能性の設定が、排他的メモリ操作の動作に影響を与えることが判明しました。
全文翻訳
2026-10-10 ldaxrが機能しない場合: AArch64における排他的アクセスとキャッシュ可能性
はじめに
私は今、数ヶ月間、AArch64用の独自のオペレーティングシステム(OS)であるflossに取り組んでいます。これは、AArch64における割り込みと汎用割り込みコントローラーに関する前回の投稿以来、今のところカーネルにすぎません。
現在、flossはQEMUのvirtおよびRaspberry Pi 4(rpi4)ボード、そして実際のRaspberry Pi 5(rpi5)ハードウェアをターゲットにしています。
実ハードウェアでのテストはやりがいがあり、プロジェクトをエキサイティングなものにしています。
しかし、この投稿で詳細にカバーするように、実ハードウェアはQEMUが必ずしも明らかにするわけではない、新たな次元の潜在的な障害/動作を明らかにします。
この投稿では、基本的なスピンロックの実装と、ロード排他を実行すると実ハードウェアで例外が発生するがQEMUでは発生しない理由の解明についてカバーします。
スピンロック、例外処理、仮想メモリ、メモリタイプ、メモリ属性、キャッシュ可能性、QEMUと実ハードウェアの違い、そして最も注目すべきは、排他的メモリ操作の仕組みといったエキサイティングな分野の組み合わせをカバーします。
スピンロックの実装
CPUのセカンダリコアを有効化して起動する段階に達したとき、何らかの形の相互排他を実現するのに良い時期でした。
些細な例として、各コアはセットアップパスで自身のIDを出力します。例えば、「Running core 0」です。
相互排他なしでは、コアは互いに競合し、出力がインターリーブされて、下のスニペットに示すようなガタガタのメッセージになります。
読みやすい出力を得るために、スピンロックのような相互排他でプリントを保護したいのです。これにより、一度に1つのコアだけが出力できるようにします。
Running core 0 RRRununing cournnien ng 2cni onrge 1co re 3
ロックと同期のウォームアップとして、Armの「Implementation Software Synchronization Primitives in A64」と、私のお気に入りのOS本「Operating Systems: Three Easy Pieces」を読み、要約しました。
良い出発点として、スピンロックを実装することにしました。これは、その最も単純な形式では非常に簡単です。
また、C/C++で書くよりもずっと楽しいので、アセンブリでスピンロックを手書きで書くことも良い練習になると判断しました。
私の基本的なバージョンは、ロード排他とストア排他命令を使用しており、これらは協調してアトミック性を達成します。
アイデアは、ldx[a]r命令が実行されてから同じメモリ位置への他の書き込みが観測されなかった場合にのみ、stxr命令が成功するというものです。
void SpinLock::lock() {
uint32_t lock_read;
uint32_t store_result;
asm volatile(
"1: \t\n\t ldaxr %w0, [%2] \t\n\t cbnz %w0, 1b \t\n\t mov %w0, #1 \t\n\t stxr %w1, %w0, [%2] \t\n\t cbnz %w1, 1b \t\n\t "
: "=&r"(lock_read), "=&r"(store_result) // 出力
: "r"(&_lock) // 入力
: "memory" // クローバー
);
}
void SpinLock::unlock() {
asm volatile(
"stlr wzr, [%0]"
: // 出力なし
: "r"(&_lock) // 入力
: "memory" // クローバー
);
}
ロックは32ビットのロッキングワード(したがってwレジスタの使用)を使用しており、0はロック解除、1はロック済みを示します。
正しさのために、ロード排他はアクレア(acquire)セマンティクス(ldaxrの「a」)でマークされており、ロード後のすべてのメモリアクセスがそれより前に再順序付けされないことを保証します。
同様に、アンロックパスのストアはリリース(release)セマンティクス(stlrの「l」)でマークされており、ストア前のすべてのメモリアクセスがそれより後に再順序付けされないことを保証します。
ロックは通常3つのカテゴリで評価されます:正しさ、公平性、パフォーマンス。
正しさは後で経験的に検証し、メモリ順序についてはすでに考慮しました。
公平性は不可能ですが、この基本的なバージョンでは問題ありません。しかし、他のコアが常に先にロックを取得できる場合、あるコアがスターベーションを起こして決してロックを取得できない可能性があります。
パフォーマンスはあまり良くありません。コアはロックを取得するためにビジーウェイトでサイクルを消費します。
パフォーマンスの問題に対処するアプローチの1つは、Armのwfe(wait for event)とsev(send event)のペアを使用することです。これはコアを低電力状態にし、イベントを待機させ、待機中のすべてのコアをウェイクアップさせます。
それは将来の作業のためのものです。
検証
最初に、スピンロックを、複数のコアの起動中に読みやすい出力を得るために、「スピンロックの実装」で示したガタガタの出力が発生するパスでテストしました。
QEMUのvirtおよびrpi4ボードでは、すべて問題なく動作しているように見えました。
念のため、プログラムを数千回実行しましたが、ガタガタの出力は一度も観察されませんでした。
// SpinLockGuardは、構築中にロックし、破壊中にアンロックするRAIIオブジェクトです
{
SpinLockGuard guard(&_print_lock);
kprintf("Running core %d\n", cpu_id);
}
しかし、私のrpi5で同じコードを実行したところ、奇妙な例外が発生しました。
下の出力はカスタム例外ハンドラからのもので、例外クラス(EC)値が37(0x25)のデータアボート例外が見られます。
Armの「例外の種類」によると、「データアボート例外は、ロードまたはストア命令の結果として発生します」。
例外が発生しました(0x84474から) - シンドロームは0x96000410 - ECは37(データアボート)
レジスタダンプ:
x0: 0x2bcf88 ... x30: 0x85554
例外が発生したときのCPUが実行していた命令(0x84474)を詳しく見ると、それがldaxr命令の直前であることがわかります。
これは、データアボートの値とも一致するため、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」を読んでいたので、メモリ属性に関する特定のセクションがあったことを覚えています。
このセクションは、アトミック命令がアトミックであることがアーキテクチャ的に保証されているメモリ属性を強調しています。
これらは次のとおりです。
Inner Shareable, Inner Write-Back, Outer Write-Back Normal memory with Read allocation hints and Write allocation hints and not transient
Outer Shareable, Inner Write-Back, Outer Write-Back Normal memory with Read allocation hints and Write allocation hints and not transient
このページには、アトミック命令をサポートしていない可能性のあるメモリタイプもリストされています。
最も注目すべき箇条書きは次のとおりです。
Device, Non-cacheable memory, or memory that is treated as Non-cacheable, in an implementation that does support hardware cache coherency
メモリタイプ
すべてのメモリのデフォルトのメモリタイプはDeviceメモリです。
しかし、私はflossの非常に早い段階で仮想メモリを設定およびマップし、すべてのRAM領域を「Normal」メモリタイプに関連付けています。これにより、アラインメントされていないアクセスを実行できます(例:8バイトアラインメントされていないアドレスからの8バイトロード)。
これは、Deviceメモリはアラインメントチェックを必要とするためです。
QEMU v8.2.2からv11.1.50へのアップグレード中にこれに気づきました。これは、デバイスメモリにアラインメントチェックを追加する(それほど最近ではない)パッチが含まれており、デバッグにかなりの時間がかかった、非常に腹立たしい例外を引き起こしました。
Normalメモリはかなり曖昧な用語ですが、一般的にはDeviceではないすべての種類のメモリです。
Normalメモリは、属性に応じて、一般的にキャッシュ、投機的実行、再順序付けが可能です。
メモリマップドI/O(MMIO)を介してアクセスされるDeviceメモリは、RAMのように一般的に投機的実行またはキャッシュできません。
すべてのメモリタイプは一連の属性によって定義されており、これらは個々の仮想メモリマッピングに対して構成できます。
メモリ属性
メモリ属性は、Memory Attribute Indirection Register(MAIR_EL1 for EL1)を介して構成されます。
これは64ビットレジスタで、8ビットの8つの異なる属性セットに分割されています。
翻訳テーブル(Linux用語ではページテーブル)のエントリは、物理ブロックを指すときに、これらの属性セットのいずれかを参照します。