メモリマップドI/O
ペリフェラルを普通のポインタで叩ける理由と、volatile を外すと動かなくなる仕組みが原理から腑に落ちる。レジスタのビット操作と読み書き順序の落とし穴まで、組込みで刺さる勘所を押さえられる。
- 1.MMIO はペリフェラルのレジスタを物理アドレス空間に配置し、CPU のロード/ストア命令でそのまま読み書きする方式。専用 I/O 命令を持つポートマップドI/O と対になる。
- 2.レジスタ変数には volatile が必須。付けないとコンパイラが「値は変わらない」と誤認し、読み出しの省略・書き込みの合体・順序入れ替えでハードを取りこぼす。
- 3.レジスタは読むだけ・書くだけで副作用を起こすものがある(read-to-clear、write-1-to-clear など)。ビット操作の read-modify-write と実行順序を常に意識する。
ペリフェラルを「メモリのように」触る
マイコンで LED を光らせたり UART で文字を送ったりするとき、C 言語では特定のアドレスにポインタで値を書き込みます。この「ポインタに代入したらハードが動く」という感覚こそがメモリマップドI/O(MMIO)の本質です。CPU から見ると、GPIO やタイマ、UART といったペリフェラルの制御レジスタは、RAM のバイト列とまったく同じアドレス空間の中に並んでいます。CPU は RAM 用の特別な回路を持たず、同じロード/ストア命令(LDR / STR など)でメモリもペリフェラルも区別なくアクセスします。
区別しているのはバス側です。アドレスデコーダが、CPU の出したアドレスを見て「これは RAM 領域」「これはペリフェラル領域」と判定し、対応するデバイスの選択信号(チップセレクト)をアサートします。つまりアドレス空間という一枚の地図の上に、RAM・Flash・各ペリフェラルが縄張りを分け合って配置されているわけです。この配置はメモリマップとしてデータシートに必ず載っています。
| 方式 | アクセス手段 | アドレス空間 | 代表アーキ |
|---|---|---|---|
| メモリマップドI/O | 通常のロード/ストア命令 | メモリと同一空間を共有 | ARM Cortex-M・RISC-V・多くのRISC |
| ポートマップドI/O | 専用のI/O命令(IN/OUT) | メモリとは別のI/O空間 | x86(IN/OUT命令) |
MMIO の利点は、メモリを扱うあらゆる命令・アドレッシング・ポインタ演算がそのままペリフェラルにも使えることです。専用命令を覚える必要がなく、C のポインタで自然に書けます。代償として、本来 RAM に使えるアドレス空間の一部をペリフェラルに割く必要がありますが、32 ビットの広大な空間を持つ現代のマイコンではほぼ問題になりません。
レジスタをアドレスとして表現する
C でレジスタを叩く定石は、レジスタの物理アドレスを整数リテラルとして与え、それを「volatile なポインタ」にキャストして参照外しすることです。ARM Cortex-M の GPIO 出力データレジスタを例にすると次のようになります。
/* レジスタのアドレスを volatile ポインタとして定義 */
#define GPIOA_ODR (*(volatile uint32_t *)0x48000014u)
/* PA5 を High にする(ビット5を立てる) */
GPIOA_ODR |= (1u << 5);
(volatile uint32_t *)0x48000014u は「アドレス 0x48000014 を指す、32 ビット幅の volatile なポインタ」で、先頭の * で参照外しして左辺値にしています。ベンダ提供のヘッダ(CMSIS など)では、ペリフェラルを構造体として定義し、各レジスタをメンバとしてオフセット順に並べる方式が一般的です。構造体の先頭アドレスをベースに置けば、GPIOA->ODR のようにフィールドアクセスでき、レイアウトとオフセット計算をコンパイラに任せられます。
多くのペリフェラルはアクセス幅に制約があります。32 ビットレジスタを 8 ビット単位で書くと、無視される・エラーになる・意図しない部分書き込みになる、のいずれかが起こり得ます。ポインタの型(uint32_t か uint16_t か)はデータシートのアクセス幅に厳密に合わせ、アドレスも幅の境界にアラインさせます。構造体でまとめる場合はパディングが入らないよう配置を確認します。
なぜ volatile が必須なのか
MMIO で最も事故が多いのが volatile の付け忘れです。コンパイラは通常、「同じアドレスを連続で読んでも値は変わらない」「書いた直後に上書きするなら最初の書き込みは無駄」といった前提で最適化します。この前提は普通のメモリでは正しいのですが、ペリフェラルレジスタではハードウェアが裏で値を書き換えるため成り立ちません。volatile は「このアクセスは副作用を持つので、省略・合体・並べ替えをするな」とコンパイラに宣言する修飾子です。
/* NG: volatile なし。ハードのフラグを待つループが消える危険 */
uint32_t *status = (uint32_t *)0x40011000u;
while ((*status & FLAG) == 0) { } /* *status を1回読んだ値を使い回し
→ 永久ループにコンパイルされ得る */
/* OK: volatile あり。毎回ハードから読み直す */
volatile uint32_t *status = (volatile uint32_t *)0x40011000u;
while ((*status & FLAG) == 0) { } /* ループのたびに実メモリを読む */
volatile を外すと、コンパイラは「ループ内で *status は変化しない」と判断してループ外に1回だけ読み出しを追い出し(ループ不変式の巻き上げ)、無限ループを生成し得ます。逆に連続書き込みでは、最終値だけを書く最適化により中間の書き込みが消えることがあります。レジスタを指すポインタには例外なく volatile を付けるのが鉄則です。
volatile が保証するのは「そのアクセスを省略・合体しない」ことと「volatile アクセス同士のプログラム順を守る」ことだけです。複数コアやDMAとの排他制御にはならず、CPU やバスによるメモリアクセスの並べ替え(メモリオーダリング)まで抑える力もありません。マルチコアや厳密な順序が要る場面では、volatile に加えてメモリバリア命令(DMB / DSB など)や適切な同期プリミティブを併用します。
ビット操作 ── read-modify-write の罠
レジスタの1ビットだけを変えたいとき、REG |= (1u << n) のように書きます。これは1命令に見えて、実際は読み出し → 該当ビットを変更 → 書き戻しの3ステップ(read-modify-write, RMW)に展開されます。ここに落とし穴が2つあります。
REG |= (1u << 3); が展開される様子
1. tmp = REG ← レジスタ全体を読む
2. tmp = tmp | 0x08 ← ビット3を立てる
3. REG = tmp ← 全体を書き戻す
問題1: 1と3の間に割り込みが同じレジスタの別ビットを
変えると、その変更が3の書き戻しで消える(競合)
問題2: 1で読んだ値に「副作用で1になっていたビット」が
含まれると、3の書き戻しで意図せずそのビットを操作
第一に、RMW の途中で割り込みや DMA が同じレジスタの別ビットを操作すると、書き戻しでその変更が上書きされて失われます(ロストアップデート)。第二に、読んだ時点で他のビットに副作用のあるフラグが立っていると、書き戻しでそれを誤って触ってしまいます。こうした事故を避けるため、近年のマイコンは RMW を不要にするハードウェア機構を備えています。
| 機構 | 仕組み | 利点 |
|---|---|---|
| セット/クリア専用レジスタ | BSRR 等。書いたビットだけ1に(0に)し、他は不変 | 読み出し不要でアトミックにビット操作 |
| ビットバンド | 各ビットを別アドレスに写像し、1語アクセスで1ビット操作 | Cortex-M の一部で単一ビットをアトミックに |
| write-1-to-clear | 1を書いたビットだけクリア、0のビットは不変 | フラグクリア時に他ビットを読まず済む |
たとえば STM32 の GPIO では、出力データレジスタ ODR に RMW するかわりに、セット/リセット専用の BSRR に「立てたいビット」だけを書きます。BSRR は書き込み専用で、書いたビットだけを操作し他は一切変えないため、読み出しも割り込み禁止も要らずアトミックにビットを立てられます。
読み書きの順序と副作用
MMIO のレジスタは、値を保持するだけの箱ではありません。アクセスそのものが動作の引き金になるものが多くあります。代表的なのが、読み出すとフラグが自動的にクリアされる read-to-clear レジスタや、データレジスタを読むと次のデータ受信が許可される FIFO です。この副作用を知らずにデバッガでレジスタを覗くと、それだけで状態が変わってしまうことすらあります。
UART 受信では「まず受信完了フラグ(RXNE 等)を読んで確認し、次にデータレジスタを読む」という順序が仕様で決まっていることがあります。データを読むとフラグが自動クリアされる設計だと、順序を逆にしたり片方を省いたりすると、フラグを取りこぼしたりデータを二重に消費したりします。データシートの「レジスタアクセス手順」は必ず記載どおりに守ります。
さらに、書き込みが CPU から見て「完了」してもハードに反映されるまでには遅延があります。バスにはライトバッファが挟まることがあり、STR を出した直後に CPU は次の命令へ進みますが、実際の書き込みはバス上で後から完了します。周辺回路の設定を書いた直後にその効果を前提とする処理へ移る場合、書き込みが確実に届いたことを保証するために、対象レジスタを一度読み返す(リードバック)か、メモリバリアで順序を固定します。
「なぜレジスタ変数に volatile を付けるか」には『コンパイラの最適化による読み書きの省略・合体・並べ替えを禁止し、毎回ハードへ実アクセスさせるため』と答えます。「volatile だけで並行アクセスは安全か」には『いいえ。アトミック性もメモリバリアも保証しないため、割り込み・DMA・マルチコアとの競合には別途 BSRR 等のアトミック機構やバリア命令が要る』が正解です。MMIO とポートマップドI/O の違い(命令とアドレス空間)も定番です。
まとめ
- MMIO はペリフェラルレジスタをメモリと同じアドレス空間に配置し、通常のロード/ストア命令で読み書きする方式。専用 I/O 命令を使うポートマップドI/O と対をなす。CPU は両者を区別せず、バスのアドレスデコーダが振り分ける。
- レジスタは物理アドレスを
volatileポインタにキャストして表現する。volatileを外すとコンパイラが読み出しの巻き上げ・書き込みの合体・並べ替えを行い、ハードの状態を取りこぼす。 - 単一ビット変更は read-modify-write に展開されるため、割り込みや DMA との競合と副作用ビットの巻き添えに注意する。BSRR などのセット/クリア専用レジスタやビットバンドでアトミックに操作するのが安全。
- レジスタはアクセス自体が副作用(read-to-clear、write-1-to-clear、FIFO の消費など)を持つことがあり、読み書きの順序が仕様で決まっている。書き込みの反映遅延にはリードバックやメモリバリアで対処する。
組込み・IoT Article
メモリマップドI/Oを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
組込み
比較で見る軸
難易度: advanced / カテゴリ: 組込み・IoT / タグ数: 6
導入後に効く点
レジスタ変数には volatile が必須。付けないとコンパイラが「値は変わらない」と誤認し、読み出しの省略・書き込みの合体・順序入れ替えでハードを取りこぼす。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- 組込み・IoT
- タグ数
- 6
判断チェックリスト
- 自社の用途が「組込み / MMIO」に近いか確認する。
- 強みである「MMIO はペリフェラルのレジスタを物理アドレス空間に配置し、CPU のロード/ストア命令でそのまま読み書きする方式。専用 I/O 命令を持つポートマップドI/O と対になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。