はじめに
組み込みの現場でCを書き始めると、見慣れないお作法に出くわすことがあります。
typedef unsigned char UB;
volatile int x;
「なんでわざわざ unsigned char に別名をつけるの?」「volatile って何?」と思った方は多いのではないでしょうか。
これらは一言で言うと「コンパイラに任せない」ための宣言です。アプリ開発なら int でだいたい動きますが、組み込みではハードウェアが「8ビット幅でなければダメ」「キャッシュされては困る」という制約を直接課してきます。その制約に応えるのがこの二つのテクニックで、セットで理解すると腑に落ちやすいです。
typedefによる型宣言 ― ビット幅を名前で縛る
Cの標準型である int や char は、処理系(コンパイラやCPUアーキテクチャ)によってビット幅が変わります。たとえば int は16ビットのこともあれば32ビットのこともある。組み込みでは「このフラグは絶対8ビット」「このカウンタは符号なし16ビット」といった要件がハードウェア仕様から決まることが多く、型のビット幅があいまいなままだと思わぬバグの原因になります。
そこで使うのが typedef です。
typedef signed char B; /* 符号付き8ビット整数 */
typedef unsigned char UB; /* 符号なし8ビット整数 */
typedef unsigned short UH; /* 符号なし16ビット整数 */
typedef unsigned long UW; /* 符号なし32ビット整数(H8環境でlong=32bit) */
こうしておくと、コード上に UB と書いた時点で「符号なし8ビット」という意図が明示されます。移植時にビット幅が変わる場合も、typedef の定義を直すだけで済むので変更箇所が一か所に集約できます。
現代のプロジェクトでは stdint.h の uint8_t / uint32_t などが使えるケースも増えています。unsigned long のビット幅はプラットフォーム依存(LP64環境では64ビット)なので、ビット幅を確実に縛りたい場合は stdint.h の型を使う方が安全です。TOPPERS/JSPのような古いRTOSや stdint.h が使えない環境では今でもこのパターンが現役です。プロジェクトの規約に従いつつ、「ビット幅を名前で縛る」という意図は変わらないので、どちらの書き方であっても考え方は同じです。
volatile修飾子 ― コンパイラの最適化が引き起こす罠
volatile は、変数宣言に付けることでコンパイラの最適化を抑止する修飾子です。
「最適化を抑止する」と聞くと損をしているように聞こえますが、組み込みでは必要な場面があります。具体的には次の二つのケースです。
ハードウェアのレジスタを変数としてアクセスする場合
マイコンのI/Oポートやレジスタは、メモリ上の特定アドレスに割り当てられています(メモリマップドI/O)。コンパイラから見るとただのメモリ読み書きに見えるため、「同じアドレスを2回読んでも値は変わらないはず」と判断して2回目の読み込みを省略してしまうことがあります。しかしレジスタの値はハードウェアの状態によって変化するので、これは困ります。
複数のタスクや割り込みハンドラから共有される変数
あるタスクが変数を書き換えても、別のタスクや割り込みハンドラからの変更をコンパイラが検知できず、コンパイラがレジスタに保持した古い値を使い続けることがあります。
どちらも volatile を付けることで「毎回必ずメモリから読み書きしてください」とコンパイラに伝えられます。
volatile int x; /* 複数タスクから共有される変数 */
ただし volatile は万能ではありません。アトミック性は保証されないので、排他制御が必要な場面では別途ミューテックスやディスエーブル区間の設計が必要です。「最適化を抑止する」と「スレッドセーフにする」は別の話です。ここは混同しやすいポイントなので注意してください。
二つをセットで使う ― レジスタアクセスの定石パターン
typedef と volatile が組み合わさるのが、レジスタアクセスのコードです。
まずレジスタのアドレスをマクロで定義します。
#define H8P4DR 0xffffd3 /* ポート4のアドレス */
このアドレスに対して読み書きするとき、こう書きます。
/* 読み込み */
UB p4 = *((volatile UB *)H8P4DR);
/* 書き込み */
*((volatile UB *)H8P4DR) = 0x80;
少し見慣れない書き方ですが、分解すると単純です。
-
(volatile UB *)H8P4DR― アドレス値をポインタにキャスト。UB(符号なし8ビット)で読み書きする、volatile(最適化しない)というセットの指定 -
*(...)― そのアドレスの値を読む/書く
UB によって「8ビット幅でアクセスする」という意図を明示し、volatile によって「毎回必ずレジスタを読み書きする」ことを保証しています。この二つが揃って初めて意図通りのレジスタアクセスになります。
実際のコードではこのキャストを毎回書くのは冗長なので、引数付きマクロやインライン関数でラップするのが一般的です。
#define sil_reb_mem(mem) (*((volatile UB *)(mem)))
#define sil_wrb_mem(mem, data) (*((volatile UB *)(mem)) = (data))
/* 使う側はこう書ける */
UB p4 = sil_reb_mem(H8P4DR);
sil_wrb_mem(H8P4DR, p4);
このラップのパターンは次回(マクロ編)で詳しく扱います。
実務での注意点・よくある落とし穴
volatileの付け忘れ
最適化レベルが低い開発中は症状が出ず、リリースビルドで最適化を上げた途端に動かなくなる、というのが典型的なパターンです。デバッグが非常に困難になるので、レジスタアクセスや共有変数には最初から付ける習慣をつけておくのが無難です。
volatileを付ければ安全という誤解
前節でも触れましたが、volatileはアトミック性を保証しません。複数タスクから読み書きする変数に volatile を付けても、割り込みのタイミングによっては中途半端な値を読む可能性があります。volatile はあくまで「最適化の抑止」であり、排他制御の代わりにはなりません。
型の幅とアクセス幅のミスマッチ
UB(8ビット)で定義したレジスタに対して UW(32ビット)でアクセスしてしまうと、隣接するレジスタを意図せず書き換えることがあります。ハードウェア仕様書のアクセス幅の指定は必ず確認してください。
まとめ
typedef と volatile は、どちらも「コンパイラの判断に任せず、開発者の意図をコードで明示する」ためのテクニックです。
-
typedef― ビット幅を名前で縛り、移植性と可読性を上げる -
volatile― 最適化を抑止し、レジスタや共有変数を正しく読み書きする - 二つをセットで使うことで、レジスタアクセスの定石パターンが完成する
次回はマクロとインライン関数を取り上げます。今回の最後に登場した sil_reb_mem のようなラップパターンも、そこで詳しく見ていきます。