はじめに
CPU が I/O デバイスのレジスタをアクセスする際は、物理メモリ空間にマッピングされたアドレスを介して行われるのが一般的です。これをメモリマップド I/O と言います。
CPU がメモリ空間にアクセスする際の用途は文字通り記録(メモリ)するためです。しかし、それ以外にも他者(他プロセス、他プロセッサー、他デバイス等)に何かを伝えるための伝達手段という用途があります。
頻度的には記録(メモリ)としての用途の方が圧倒的に多いため、例えば次の例に示すように、コンピューターは主にメモリアクセスを効率的に行うように技術進化してきました。
- コンパイラの進化
- 不要なメモリアクセスの削除
- アクセス順序の最適化
- プロセッサの進化
- キャッシュシステム
- ストアバッファ(ポスティッドライト、ライトコンバイン)
- アウトオブオーダー実行
上記の技術の何れも、対象が記録(メモリ)であることを前提にして、なるべく効率的にメモリにアクセスするようになっています。
しかし、メモリアクセスの目的が他者に何かを伝えるための伝達手段という点から考えると、上記の例であげた技術はすべて「余計なお世話」になります。勝手に省略されても、勝手に遅延されても、勝手に順序を入れ替えられても、伝達手段としては不都合な結果になります。
そこで C 言語には、メモリアクセスの目的が他者に何かを伝えるための伝達手段の場合にも対処できるような仕組みが用意されています。この記事では、そのうちの volatile 修飾子 と atomic_thread_fence 関数 について説明します。
volatile 修飾子
例えば次のようなメモリマップド I/O にアクセスするソースコードがあったとします。
#include <stdint.h>
void sample_1(uint32_t* addr)
{
uint32_t* ctrl_addr = &(addr[0]);
uint32_t* stat_addr = &(addr[1]);
uint32_t status;
*ctrl_addr = 0x00000001; // 1. コントロールレジスタに 0x01 を書き込む
while ((status = *stat_addr) == 0) // 2. ステータスレジスタを読んで値が 0 ならば、0 以外になるまで
*ctrl_addr = 0x00000001; // コントロールレジスタに 0x01 を書き込む
*ctrl_addr = 0x00000000; // 3. コントロールレジスタに 0x00 を書き込む
}
このソースコードの意図は次のようになります。
- コントロールレジスタに 0x01 を書き込む
- ステータスレジスタを読んで値が 0 ならば、0 以外になるまで、コントロールレジスタに 0x01 を書き込む
- コントロールレジスタに 0x00 を書き込む
しかし、残念ながら上記のソースコードは意図通りに動きません。このソースコードを riscv64-unknown-linux-gnu-gcc でコンパイルした結果を逆アセンブラしてみるとわかります。
shell$ riscv64-unknown-linux-gnu-gcc -O2 -c mmapio-sample-1.c -o mmapio-sample-1-riscv.o
shell$ riscv64-unknown-linux-gnu-objdump -d mmapio-sample-1-riscv.o
mmapio-sample-1-riscv.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <sample_1>:
0: 415c lw a5,4(a0)
2: c781 beqz a5,a <.L3>
4: 00052023 sw zero,0(a0)
8: 8082 ret
000000000000000a <.L3>:
a: a001 j a <.L3>
逆アセンブラしたコードを解説すると次のようになります。
0: lw a5,4(a0) # a5 <= a0[4]2: beqz a5,a <.L3> # if a5 equal zero then goto a:4: sw zero,0(a0) # a0[0] <= 08: ret # returna: j a <.L3> # goto a:
みての通り、「1. コントロールレジスタに 0x01 を書き込む」は削除され、「2. ステータスレジスタを読んで値が 0 ならば、0 以外になるまで、コントロールレジスタに 0x01 を書き込む」が 「2. ステータスレジスタを読んで値が 0 ならば無限ループに突入」に変更されています。
これらは何れも、記憶が不揮発性である(プロセッサが意図的に変更を加えなければ値は変化しない)ことを前提にして、コンパイラがメモリアクセスを最適化した結果です。
コンパイラがこの最適化を行わないようにするには、次のようにポインタに揮発性修飾子 volatile を追加します。
#include <stdint.h>
void sample_2(uint32_t* addr)
{
volatile uint32_t* ctrl_addr = &(addr[0]);
volatile uint32_t* stat_addr = &(addr[1]);
uint32_t status;
*ctrl_addr = 0x00000001; // 1. コントロールレジスタに 0x01 を書き込む
while ((status = *stat_addr) == 0) // 2. ステータスレジスタを読んで値が 0 ならば、0 以外になるまで
*ctrl_addr = 0x00000001; // コントロールレジスタに 0x01 を書き込む
*ctrl_addr = 0x00000000; // 3. コントロールレジスタに 0x00 を書き込む
}
shell$ riscv64-unknown-linux-gnu-gcc -O2 -c mmapio-sample-2.c -o mmapio-sample-2-riscv.o
shell$ riscv64-unknown-linux-gnu-objdump -d mmapio-sample-2-riscv.o
mmapio-sample-2-riscv.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <sample_2>:
0: 4705 li a4,1
0000000000000002 <.L8>:
2: c118 sw a4,0(a0)
4: 415c lw a5,4(a0)
6: fff5 beqz a5,2 <.L8>
8: 00052023 sw zero,0(a0)
c: 8082 ret
逆アセンブラしたコードを解説すると次のようになります。
0: li a4,1 # a4 <= 0x012: sw a4,0(a0) # a0[0] <= a44: lw a5,4(a0) # a5 <= a0[4]6: beqz a5,2 <.L8> # if a5 equal zero then goto 2:8: sw zero,0(a0) # a0[0] <= zeroc: ret # return
ポインタに揮発性である(値がプロセッサの動作に関係なく勝手に変化している可能性がある)ことを指示する修飾子 volatile を追加することで、メモリアクセスの最適化を抑制しています。
残念ながら、volatile 修飾子は、コンパイラに対してはその volatile オブジェクトへのメモリアクセスの最適化を抑制する効果はありますが、メモリアクセスの順序を保証したり、プロセッサがメモリアクセスを最適化するのを抑制することはできません。メモリアクセスの順序を保証したり、プロセッサによるメモリアクセスの最適化を抑制するには次節で紹介する atomic_thread_fence 関数を併用する必要があります。
atomic_thread_fence 関数
近年のプロセッサの技術進化によって、アーキテクチャによってはプロセッサが勝手にメモリアクセスを入れ替えたり、遅延したり、削除したりすることがあります。プロセッサに対して、メモリアクセスの最適化を抑制するにはコンパイラが適切なメモリバリア命令を生成する必要があります。
atomic_thread_fence 関数は、C11 規格で定義されました。形は完全に関数で使いかたも「関数呼び出し」ですが、実装はマクロ、インライン関数、ビルトイン関数、直接フェンス命令に展開のどれでもよいとされています。
例えば次のような atomic_thread_fence 関数を使ったメモリマップド I/O にアクセスするソースコードがあったとします。
#include <stdint.h>
#include <stdatomic.h>
void sample_3(uint32_t* addr)
{
volatile uint32_t* ctrl_addr = &(addr[0]);
volatile uint32_t* stat_addr = &(addr[1]);
*ctrl_addr = 0x00000001; // 1. コントロールレジスタに 0x01 を書き込む
atomic_thread_fence(memory_order_seq_cst); // 2. アクセス順序が逆転して観測されることを防ぐ
while (1) { //
uint32_t status = *stat_addr; // 3. ステータスレジスタを読む
atomic_thread_fence(memory_order_acquire);// 4. アクセス順序が逆転して観測されることを防ぐ
if (status != 0) break; // 5. ステータスレジスタの値が0 以外ならばループ脱出
*ctrl_addr = 0x00000001; // 6. コントロールレジスタに 0x01 を書き込む
atomic_thread_fence(memory_order_seq_cst);// 7. アクセス順序が逆転して観測されることを防ぐ
}
*ctrl_addr = 0x00000000; // 8. コントロールレジスタに 0x00 を書き込む
atomic_thread_fence(memory_order_seq_cst); // 9. アクセス順序が逆転して観測されることを防ぐ
}
RISC-V の場合
前述のソースコードを riscv64-unknown-linux-gnu-gcc-14.2.0 でコンパイルした結果を逆アセンブラしてみると次のようになります。
shell$ riscv64-unknown-linux-gnu-gcc -O2 -c mmapio-sample-3.c -o mmapio-sample-3-riscv.o
shell$ riscv64-unknown-linux-gnu-objdump -d mmapio-sample-3-riscv.o
mmapio-sample-3-riscv.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <sample_3>:
0: 4705 li a4,1
0000000000000002 <.L4>:
2: c118 sw a4,0(a0)
4: 0330000f fence rw,rw
8: 415c lw a5,4(a0)
a: 0230000f fence r,rw
e: 2781 sext.w a5,a5
10: dbed beqz a5,2 <.L4>
12: 00052023 sw zero,0(a0)
16: 0330000f fence rw,rw
1a: 8082 ret
RISC-V プロセッサの fence 命令は、(指定したアクセス種別の)メモリアクセスの順序を保証する命令です。つまり、この命令以降の(第2引数で指定した種別の)メモリアクセスが、この命令以前の(第1引数で指定した種別の)メモリアクセスよりも前に観測されることを防ぎます。
AArch64(arm64) の場合
前述のソースコードを aarch64-linux-gnu-gcc-13 でコンパイルした結果を逆アセンブラしてみると次のようになります。
shell$ aarch64-linux-gnu-gcc -O2 -c mmapio-sample-3.c -o mmapio-sample-3-aarc64.o
shell$ aarch64-linux-gnu-objdump -d mmapio-sample-3-aarc64.o
mmapio-sample-3-aarc64.o: file format elf64-littleaarch64
Disassembly of section .text:
0000000000000000 <sample_3>:
0: 52800022 mov w2, #0x1 // #1
4: d503201f nop
8: b9000002 str w2, [x0]
c: d5033bbf dmb ish
10: b9400401 ldr w1, [x0, #4]
14: d50339bf dmb ishld
18: 34ffff81 cbz w1, 8 <sample_3+0x8>
1c: b900001f str wzr, [x0]
20: d5033bbf dmb ish
24: d65f03c0 ret
AArch64 の dmb(Data Memory Barrier)命令は、指定した範囲のメモリアクセスについて、その順序を保証する命令です。引数にはアクセスの対象範囲(shareability)と、順序保証の対象となるアクセス種別を指定します。なお、ish は Inner Shareable ドメインに対する load および store の順序を保証し、ishld は Inner Shareable ドメインに対する load アクセスのみの順序を保証します。
AArch32(arm32) の場合
前述のソースコードを arm-linux-gnueabihf-gcc-13 でコンパイルした結果を逆アセンブラしてみると次のようになります。
shell$ arm-linux-gnueabihf-gcc -O2 -c mmapio-sample-3.c -o mmapio-sample-3-arm.o
shell$ arm-linux-gnueabihf-objdump -d mmapio-sample-3-arm.o
mmapio-sample-3-arm.o: file format elf32-littlearm
Disassembly of section .text:
00000000 <sample_3>:
0: 2201 movs r2, #1
2: 6002 str r2, [r0, #0]
4: f3bf 8f5b dmb ish
8: 6843 ldr r3, [r0, #4]
a: f3bf 8f5b dmb ish
e: 2b00 cmp r3, #0
10: d0f7 beq.n 2 <sample_3+0x2>
12: 2300 movs r3, #0
14: 6003 str r3, [r0, #0]
16: f3bf 8f5b dmb ish
1a: 4770 bx lr
AArch32 の dmb(Data Memory Barrier)命令は、指定した範囲のメモリアクセスについて、その順序が他から先に観測されることを保証する命令です。引数にはアクセスの対象範囲(shareability)と、順序保証の対象となるアクセス種別を指定します。なお、ish は Inner Shareable ドメインを指定し、アクセス種別を省略した場合は load および store の順序を保証します。
x86(64bit) の場合
前述のソースコードを x86_64-linux-gnu-gcc-13 でコンパイルした結果を逆アセンブラしてみると次のようになります。
shell$ x86_64-linux-gnu-gcc -O2 -c mmapio-sample-3.c -o mmapio-sample-3-x86-64.o
shell$ x86_64-linux-gnu-objdump -d mmapio-sample-3-x86-64.o
mmapio-sample-3-x86-64.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <sample_3>:
0: f3 0f 1e fa endbr64
4: 0f 1f 40 00 nopl 0x0(%rax)
8: c7 07 01 00 00 00 movl $0x1,(%rdi)
e: f0 48 83 0c 24 00 lock orq $0x0,(%rsp)
14: 8b 47 04 mov 0x4(%rdi),%eax
17: 85 c0 test %eax,%eax
19: 74 ed je 8 <sample_3+0x8>
1b: c7 07 00 00 00 00 movl $0x0,(%rdi)
21: f0 48 83 0c 24 00 lock orq $0x0,(%rsp)
27: c3 ret
lock orq $0x0,(%rsp) がメモリアクセス順序を強制する命令になります。
この命令を少し解説すると、orq $0x0,(%rsp) はスタック上の 8 バイトの値と 0 をビット OR しますが、結果は元の値と同じであり、実質的には no-op と同等です。これに lock プレフィックスを付けることで、対象となるキャッシュラインに対して排他的な所有権が取得され、その過程で他コアとのメモリ可視順序が強制されます。その結果、x86 ではこの命令はフルメモリバリアとして振る舞います。
補足
各 CPU のメモリバリア命令の説明で「観測」や「可視」といった表現が使われるのは、CPU 内部の実行順ではなく、他の CPU やデバイスなどの外部エージェントから見たメモリアクセスの順序を定義しているためです。これは、CPU を外部から観測した際に、メモリアクセスがこの順番で見えることを意味します。なお、実際にどの段階(キャッシュ、メモリ、I/O)まで反映されるかはシステムや実装に依存します。
おまけ
C 言語でメモリバリアするときはてっきりインラインアセンブラを使って命令を直接書くもんだと思ってたんだけど、今ではちゃんと抽象化されてるんだな~。私の知識が古かったので、忘備録として残しておきます。