volatileの正しい使い方

C言語の型修飾子であるvolatileは、組み込みソフトウェアで頻繁に使われます。しかし、「最適化を止めるための指定」とだけ覚えていると、誤った使い方につながります。

volatileが必要になるのは、ハードウェアや割り込み処理など、通常のプログラムの制御フローとは別の要因で値が変化する場合です。この記事では、C言語におけるvolatileの意味や最適化によって処理が意図したとおりに動かなくなる理由、正しいコードの書き方と注意点について解説します。

volatileの正しい使い方

最適化でコードが消える理由

volatileの要点を4つに分けて示した図。1.volatileとは … CPU・コンパイラ・メモリの関係図で、割り込みやハードウェアが値を変えうることをコンパイラに伝える指定であること。2.なぜ必要? … volatileなしのwhile (flag == 0)は再読み取りが省略されることがあり、volatileありなら必要な読み取りが残ること。3.使う場面 … ハードウェアレジスタ、割り込みフラグ、DMA更新データ(DMAはvolatileだけでは不十分な場合もある)。4.できないこと … 排他制御はしない、アトミック性は保証しない、同期そのものではない(必要に応じてミューテックスやアトミック操作を使う)。要点は、volatileは「アクセスの省略を防ぐ」ための指定であり、最適化全体を止めるものでも排他制御や同期を保証するものでもない、という点。

C言語のvolatileは型修飾子である

volatileは、プログラムの通常の制御フローからは予測できないタイミングで値が変化するオブジェクトに指定する型修飾子です。厳密には、volatile は「volatile 修飾型の lvalue を通じた読み書き」を最適化で消せない(観測可能な副作用として扱う)ようにします。

一般的な変数は、ソースコード上の代入によって値が変わります。一方、組み込みソフトウェアでは、周辺機器、割り込み処理、DMAが実行中の処理とは別に値を更新します。

このようなオブジェクトにvolatileを指定すると、コンパイラは処理系の仕様に従い、volatileオブジェクトへのメモリの読み書きが最適化によって省略されないよう扱います。

volatileが必要になる理由

コンパイラには、プログラムの実行速度やコードサイズを改善するための最適化機能があります。

たとえば、同じ変数を何度も読み取る処理がある場合、コンパイラは値が変化しないと判断し、最初に読み取った値をCPUのレジスタに保持することがあります。その後はメモリを読み直さず、レジスタ上の値を使い続けます。

通常の変数であれば問題ないでしょう。しかし、割り込み処理やハードウェアがメモリ上の値を変更する場合、コンパイラがレジスタに保持した古い値を使い続けると、変更を検知できません。

volatileは、コンパイラが「値は変化しない」と仮定しておこなうこのような最適化と、実際に外部要因で値が変化する状況の差を埋めるために使用します。

最適化を完全に止めるものではない

volatileは、関数やプログラム全体の最適化を無効にする指定ではありません。

主に影響するのは、volatileで修飾したオブジェクトへのメモリの読み書きです。それ以外の処理は、プログラムの動作を変えない範囲で引き続き最適化されます。

したがって、「最適化されたくない処理にはすべてvolatileを付ける」という使い方は適切ではありません。外部要因で値が変化する対象を見極め、必要な範囲に限定して使用することが重要です。volatile経由の読み書きを最適化で削除されないようにはできますが、volatileでは次の点を保証できません。

  • 原子性
  • 排他
  • スレッド間同期
  • メモリ順序
  • キャッシュ整合

最適化で「読み書き」が消えるのはなぜ?

コンパイラは、実行結果に影響しない命令(メモリアクセス)を削除したり、メモリアクセスを省略してレジスタ上の値を再利用したりします。そのため、ソースコードに書いた処理が、コンパイル後には残らない場合があります。

ポーリング処理が動かなくなる例

次のコードは、割り込み処理などによってflagが1になるまで待機する処理です。

int flag = 0;
while (flag == 0) {/* flagが1になるまで待機 */}

ソースコード上ではループ内でflagが変更されていないため、コンパイラが「値は変更されない」と判断し、メモリからの再読み取りを省略する場合があります。その結果、割り込み処理でflagが1に変更されても検知できず、ループを抜けられません。なお、割り込み処理と変数を共有する場合、volatileは再読み出しを保証するために使います。原子性や排他は別途(割り込み禁止、atomic、RTOS 同期など)設計してください。

volatileでアクセスを残す

flagを次のように宣言すると、コンパイラに対して、外部要因により値が変化する可能性があることを伝えられます。

volatile int flag = 0;

このvolatile指定により、ポーリング処理ではflag の読み出し(ロード)が省略されないようになります。

ただし、volatileを付ければ常に最新の値が保証される、と単純に考えるべきではありません。マルチコアや DMA を使用する環境では、CPU のキャッシュ整合性や他コアへの可視性・順序(メモリバリア)の保証が別途必要です。なお、volatile はメモリバリアの代替ではなく、volatile 以外の通常メモリアクセスの順序を保証しません。

volatileが必要になる場面

volatileは、実行中のコードとは別の要因で値が変化するデータに使用します。組み込みソフトウェアでは、主にハードウェアレジスタや割り込み処理と共有する変数で利用されます。

ハードウェアレジスタ

マイコンの周辺機器は、メモリ上の特定アドレスに割り当てられたレジスタを通じて制御します。ステータスレジスタの値は、通信完了や受信データの有無などに応じてハードウェア側で変化する仕組みです。また、制御レジスタでは、値の書き込み自体が周辺機器の起動や停止につながります。こうした読み書きが最適化によって省略されないよう、volatile修飾したポインタなどを介してアクセスします。

割り込みやDMAで更新される変数

割り込み処理が更新し、メイン処理が監視するフラグにもvolatileを使用します。たとえば、通信完了割り込みでフラグを1にし、メイン処理で状態を確認する場合です。volatileを指定しないと、コンパイラがフラグの再読み取りを省略する可能性があります。

また、DMAが更新するフラグやディスクリプタなども、用途に応じてvolatileを使用する場合があります。DMAでは、バッファ本体よりも完了フラグやディスクリプタの参照でvolatileが必要になることが多いです。ただし、DMAバッファにvolatileを指定するだけでは、CPUキャッシュとの整合性は保証されません。キャッシュのクリーンや無効化、メモリバリアなど、使用するマイコンやCPUの仕様に従った処理も必要です。

volatileでは解決できないこと

volatileはアクセスの省略を防ぐ指定であり、排他制御やタスク間同期、原子性、キャッシュの一貫性は保証しません。

同時アクセスは防げない

割り込み処理とメイン処理、または複数のタスクが同じデータを更新する場合、volatileで修飾しても競合は防げません。

たとえば、メイン処理と割り込み処理が同じカウンタを更新すると、一方の更新結果が失われる場合があります。また、複数のタスクが同時に更新すれば、片方の変更が失われることもあるでしょう。

割り込み処理とメイン処理で共有する場合は、必要に応じて割り込み禁止や割り込みサービスルーチン(ISR)対応の同期機能を使用します。複数のタスク間ではミューテックス、セマフォ、メッセージキューなどでアクセスを制御します。

アトミック操作にはならない

volatileを指定しても、処理が不可分になるわけではありません。

たとえば、次の処理はC言語のソースコード上では1行ですが、機械語にコンパイルされると読み取り、加算、書き込みという複数の命令に分かれる可能性があります。

volatile int counter;
counter++;

この途中で割り込みや別タスクの処理が入ると、更新結果が失われることがあります。単純な読み書きがハードウェア上で原子的に実行される場合もありますが、その性質をvolatile自体が保証しているわけではないことに留意が必要です。

原子性が必要な場合は、C11 の _Atomic 型指定子(<stdatomic.h>)やミューテックスなどの排他制御を検討します。

volatileの正しい書き方

volatileは、必要な対象だけに使用することが重要です。また、変数、ポインタの参照先、ポインタ自体のどこを修飾しているかを区別する必要があります。

変数と参照先への指定

割り込み処理などによって変数の値が変化する場合は、次のように宣言します。

volatile int flag;

ハードウェアレジスタを指すポインタでは、参照先の型にvolatileを指定します。

volatile uint32_t *reg; // *reg(参照先)が volatile
uint32_t * volatile reg; // reg(ポインタ変数)が volatile
const volatile uint32_t *status_reg; // 参照先は読み取り専用かつ volatile

最初の宣言(volatile uint32_t *reg)では、regが指しているuint32_t型のデータがvolatileです。ポインタ変数reg自体がvolatileという意味ではありません。

ポインタ自体をvolatileにする場合は、上記2行目のように「uint32_t * volatile reg;」と記述します。

両者は意味が異なるため、volatileを置く位置に注意が必要です。

constとの組み合わせ

ハードウェアによって値が変化するものの、ソフトウェアからは書き込まないステータスレジスタには、constとvolatileを組み合わせて使用できます。

const volatile uint32_t *status_reg;

この宣言では、参照先の値はハードウェアによって変化するためvolatileですが、このポインタを通じてソフトウェアから書き換えることはできません。ただし、別の書き込み可能なポインタからの変更まで禁止するものではありません。volatileは便利な指定ですが、排他制御や同期を代替するものではありません。どの処理が値を変更し、どの処理がアクセスするのかを整理した上で、必要な対象に限定して使用することが重要です。

組み込みソフトの世界 トップへ戻る