63
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C言語でメモリマップド I/O を扱うときに注意すること - volatile 修飾子とメモリバリアの役割

Last updated at Posted at 2026-01-30

はじめに

CPU が I/O デバイスのレジスタをアクセスする際は、物理メモリ空間にマッピングされたアドレスを介して行われるのが一般的です。これをメモリマップド I/O と言います。

CPU がメモリ空間にアクセスする際の用途は文字通り記録(メモリ)するためです。しかし、それ以外にも他者(他プロセス、他プロセッサー、他デバイス等)に何かを伝えるための伝達手段という用途があります。

頻度的には記録(メモリ)としての用途の方が圧倒的に多いため、例えば次の例に示すように、コンピューターは主にメモリアクセスを効率的に行うように技術進化してきました。

  • コンパイラの進化
    • 不要なメモリアクセスの削除
    • アクセス順序の最適化
  • プロセッサの進化
    • キャッシュシステム
    • ストアバッファ(ポスティッドライト、ライトコンバイン)
    • アウトオブオーダー実行

上記の技術の何れも、対象が記録(メモリ)であることを前提にして、なるべく効率的にメモリにアクセスするようになっています。

しかし、メモリアクセスの目的が他者に何かを伝えるための伝達手段という点から考えると、上記の例であげた技術はすべて「余計なお世話」になります。勝手に省略されても、勝手に遅延されても、勝手に順序を入れ替えられても、伝達手段としては不都合な結果になります。

そこで C 言語には、メモリアクセスの目的が他者に何かを伝えるための伝達手段の場合にも対処できるような仕組みが用意されています。この記事では、そのうちの volatile 修飾子 と atomic_thread_fence 関数 について説明します。

volatile 修飾子

例えば次のようなメモリマップド I/O にアクセスするソースコードがあったとします。

mmapio-sample-1.c
#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 を書き込む
}

このソースコードの意図は次のようになります。

  1. コントロールレジスタに 0x01 を書き込む
  2. ステータスレジスタを読んで値が 0 ならば、0 以外になるまで、コントロールレジスタに 0x01 を書き込む
  3. コントロールレジスタに 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] <= 0
  • 8: ret # return
  • a: j a <.L3> # goto a:

みての通り、「1. コントロールレジスタに 0x01 を書き込む」は削除され、「2. ステータスレジスタを読んで値が 0 ならば、0 以外になるまで、コントロールレジスタに 0x01 を書き込む」が 「2. ステータスレジスタを読んで値が 0 ならば無限ループに突入」に変更されています。

これらは何れも、記憶が不揮発性である(プロセッサが意図的に変更を加えなければ値は変化しない)ことを前提にして、コンパイラがメモリアクセスを最適化した結果です。

コンパイラがこの最適化を行わないようにするには、次のようにポインタに揮発性修飾子 volatile を追加します。

mmapio-sample-2.c
#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 <= 0x01
  • 2: sw a4,0(a0) # a0[0] <= a4
  • 4: 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] <= zero
  • c: ret # return

ポインタに揮発性である(値がプロセッサの動作に関係なく勝手に変化している可能性がある)ことを指示する修飾子 volatile を追加することで、メモリアクセスの最適化を抑制しています。

残念ながら、volatile 修飾子は、コンパイラに対してはその volatile オブジェクトへのメモリアクセスの最適化を抑制する効果はありますが、メモリアクセスの順序を保証したり、プロセッサがメモリアクセスを最適化するのを抑制することはできません。メモリアクセスの順序を保証したり、プロセッサによるメモリアクセスの最適化を抑制するには次節で紹介する atomic_thread_fence 関数を併用する必要があります。

atomic_thread_fence 関数

近年のプロセッサの技術進化によって、アーキテクチャによってはプロセッサが勝手にメモリアクセスを入れ替えたり、遅延したり、削除したりすることがあります。プロセッサに対して、メモリアクセスの最適化を抑制するにはコンパイラが適切なメモリバリア命令を生成する必要があります。

atomic_thread_fence 関数は、C11 規格で定義されました。形は完全に関数で使いかたも「関数呼び出し」ですが、実装はマクロ、インライン関数、ビルトイン関数、直接フェンス命令に展開のどれでもよいとされています。

例えば次のような atomic_thread_fence 関数を使ったメモリマップド I/O にアクセスするソースコードがあったとします。

mmapio-sample-3.c
#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 言語でメモリバリアするときはてっきりインラインアセンブラを使って命令を直接書くもんだと思ってたんだけど、今ではちゃんと抽象化されてるんだな~。私の知識が古かったので、忘備録として残しておきます。

63
46
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?