C言語 volatile
キーワード徹底解説:初心者から理解する最適化の落とし穴と賢い回避法
はじめに:なぜ volatile
が必要なのか? コンパイラ最適化との出会い
C言語の学習を進めているあなたは、おそらく変数の宣言や関数の呼び出し、ポインタの操作など、基本的な文法や概念を理解していることでしょう。プログラムを書くことは、コンピュータに「こう動いてほしい」という命令を与えることです。そして、私たちが書いたC言語のソースコードを、コンピュータが理解できる機械語に翻訳してくれるのが「コンパイラ」です。
コンパイラは単にソースコードを機械語に変換するだけでなく、プログラムの実行速度を速くしたり、使用するメモリ量を減らしたりするために、様々な「最適化」を行います。この最適化は通常、非常に役立ちます。例えば、同じ計算を何度も繰り返している箇所を効率化したり、全く使われていないコードを削除したりすることで、プログラムはより高速に、より効率的に動作するようになります。
しかし、このコンパイラの賢い最適化が、予期せぬ問題を引き起こす場合があります。それは、プログラムの実行中に「プログラムの命令以外」の要因によって変数の値が変更される可能性がある場合です。このような状況では、コンパイラが良かれと思って行った最適化が、プログラムの正しい動作を妨げてしまうことがあるのです。
ここで登場するのが、C言語の volatile
キーワードです。このキーワードは、コンパイラに対して「この変数は、あなたの知らないところで値が変わるかもしれないよ。だから、最適化するときはこの点に十分注意してね」と警告するためのものです。volatile
は、主に以下のような特殊な状況で不可欠となります。
- ハードウェアレジスタへのアクセス: マイコンなどの組み込みシステムでは、特定のメモリ番地がハードウェアの制御や状態を示す「レジスタ」として機能します。これらのレジスタの値は、外部のイベント(センサーからの入力、タイマーの経過など)によってプログラムの実行とは独立に変更されることがあります。
- 割り込みサービスルーチン (ISR) とメインプログラム間での変数共有: プログラムの実行中に発生する「割り込み」に対応するための特別な関数(ISR)の中で変数が変更され、その変数をメインプログラムも参照する場合。
- マルチスレッド/マルチプロセス環境での変数共有: 複数の処理の流れ(スレッドやプロセス)が同じメモリ上の変数を同時に参照・変更する場合(ただし、これについては
volatile
だけでは不十分であることが多く、より高度な同期メカニズムが必要です)。
これらの状況では、コンパイラが「この変数の値は、この前の命令で読み込んだ(または書き込んだ)値と同じはずだ」と決めつけて最適化を行うと、実際には外部要因によって値が変更されているにも関わらず、古い値を使ってしまったり、本来行うべきメモリへのアクセスを省略してしまったりする可能性があります。
この記事では、まずコンパイラの最適化がどのように行われるのか、それがどのように問題を引き起こす可能性があるのかを詳しく見ていきます。そして、volatile
キーワードがその問題をどのように解決するのか、その正しい使い方や注意点、さらには volatile
だけでは解決できない問題について、初心者の方にも分かりやすいように丁寧に解説していきます。約5000語のボリュームで、volatile
に関するあなたの疑問を徹底的に解消することを目指します。
第1部:コンパイラの最適化とは? なぜそれが問題を引き起こす可能性があるのか?
volatile
の必要性を理解するためには、まずコンパイラの最適化についてもう少し詳しく知る必要があります。コンパイラの最適化は、私たちの書いたソースコードを、より効率的な機械語に変換するプロセスです。これは、プログラムの実行速度向上やリソース(メモリ、CPU時間)の節約に大きく貢献します。
コンパイラが行う最適化は多岐にわたりますが、volatile
に特に関連する代表的なものをいくつか見てみましょう。
1. レジスタ割り当て (Register Allocation)
コンピュータのCPU内には「レジスタ」と呼ばれる非常に高速な記憶領域があります。メモリ(RAM)に比べて容量は小さいですが、アクセス速度は段違いに速いです。コンパイラは、プログラム中で頻繁に使用される変数を、メモリ上ではなくレジスタに割り当てようとします。これにより、変数へのアクセスが高速化されます。
例えば、以下のようなコードがあったとします。
c
int count = 0;
// ... 何らかの処理 ...
count = count + 1;
// ... 別の処理 ...
if (count > 100) {
// ...
}
コンパイラは count
変数をレジスタに割り当てることがあります。この場合、count = count + 1;
の処理は、メモリから count
の値を読み出して、レジスタに格納し、レジスタ内の値を1増やし、再びレジスタに書き込む(メモリへの書き込みは後回しにするか、必要ないと思えば省略する)という形で行われるかもしれません。if (count > 100)
の比較も、メモリではなくレジスタ内の値を使って行われます。
問題は、もし count
変数の値が、コンパイラが認識していない外部要因(例えば、割り込みサービスルーチンや別のスレッド)によって、メモリ上で直接変更された場合です。コンパイラが count
をレジスタに割り当てていると、メインプログラムはレジスタ内の古い値を使い続けてしまい、メモリ上の新しい値の変化に気づくことができません。これにより、プログラムは誤った判断を下したり、予期しない動作をしたりする可能性があります。
2. 共通部分式の削除 (Common Subexpression Elimination)
同じ計算式が複数回出現する場合、コンパイラはその計算結果を一時的に保持しておき、次に出てきたときには計算を省略してその結果を再利用することがあります。
c
int a = x * y + z;
int b = w + x * y; // x * y は上で計算済み
この例では、x * y
という計算が2回出てきます。コンパイラは x * y
の結果を一度計算してレジスタや一時メモリに保持し、2回目の x * y
の計算を省略して、保持しておいた結果を利用します。これは計算量の削減につながります。
3. ループ不変式の移動 (Loop-Invariant Code Motion)
ループの中で、ループの反復とは無関係に常に同じ値を計算している式がある場合、コンパイラはその計算をループの外に移動させます。
c
for (int i = 0; i < 100; ++i) {
int value = x * y; // x と y がループ内で変化しない場合
// ... value を使った処理 ...
}
x * y
の結果がループの各反復で同じであれば、コンパイラは x * y
の計算をループの前に一度だけ行い、その結果をループの中で利用するようにコードを変更します。
c
int loop_invariant_value = x * y;
for (int i = 0; i < 100; ++i) {
int value = loop_invariant_value;
// ... value を使った処理 ...
}
4. デッドコード削除 (Dead Code Elimination)
プログラムの実行結果に全く影響を与えないコードや変数(「死んだコード」や「死んだ変数」と呼ばれます)は、コンパイラによって削除されます。
c
int unused_variable = 123; // この変数が後で全く使われない場合
int result = a + b;
// ... result を使った処理 ...
unused_variable
がどこでも参照も変更もされない場合、コンパイラはこの変数の宣言や代入処理を削除します。
5. ストアの省略・再順序付け (Store Elimination/Reordering)
コンパイラは、メモリへの書き込み(ストア)操作を最適化することがあります。
-
ストアの省略: 同じ変数に対して短期間に複数回書き込みが行われる場合、最後の書き込み以外の途中の書き込みを省略することがあります。コンパイラは「どうせ最後にこの値になるなら、途中経過をメモリに書き出す必要はない」と判断する可能性があります。
c
int status;
status = 1;
status = 2; // status=1 の書き込みは省略されるかも
status = 3; // 最終的に status=3 がメモリに書き込まれる -
ストアの再順序付け: 複数の変数への書き込みの順序を入れ替えることがあります。コンパイラは、プログラムの「観測可能な振る舞い」が変わらない限り、効率のために命令の実行順序(特にメモリ操作)を変更することがあります。
c
int a = 1;
int b = 2;コンパイラはまず
b = 2;
を実行し、その後にa = 1;
を実行するように機械語を生成するかもしれません。単一スレッドのプログラムであれば問題ありませんが、複数のスレッドやハードウェアがこれらの変数を見ている場合、この順序の違いが問題になることがあります。
最適化が引き起こす具体的な問題例:ポーリングループ
最も典型的な問題例として、「ポーリング(Polling)」を使った待ち合わせ処理を考えましょう。これは、特定の条件が満たされるまで、変数の値を繰り返しチェックし続けるループのことです。組み込みシステムでは、ハードウェアからの応答を待つ際によく使われます。
例えば、外部デバイスが処理を完了したことを示すフラグ変数 is_ready
があるとします。デバイスが処理を開始する前に is_ready
を 0 に設定し、処理が完了したらデバイス自身(または関連するハードウェア)がこのメモリ上の値を 1 に変更するとします。メインプログラムは、デバイスが準備完了になるまで以下のようなループで待ちます。
“`c
int is_ready = 0; // デバイスからのフラグ (メモリ上の特定アドレスにあると仮定)
// デバイスに処理開始を指示 …
// デバイスの準備完了を待つ
while (is_ready == 0) {
// 何もしない (ビジーループ)
}
// デバイスが準備完了したので、次の処理へ進む
“`
このコードを見たコンパイラは、以下のように推論する可能性があります。
is_ready
の初期値は0
である。while (is_ready == 0)
ループに入る。- ループの中で
is_ready
の値はプログラム自身によって変更されていない。 - したがって、ループの条件
is_ready == 0
は常に真のままである。 - これは無限ループである。この無限ループはプログラムの他の部分に影響を与えない。
- よし、この無限ループは意味がないから削除しよう! または、ループ条件の評価を一度だけ行って、常に真と判断し、以降の評価を省略しよう!
このような最適化が行われると、たとえ外部デバイスがメモリ上の is_ready
の値を 1 に変更したとしても、コンパイラが生成した機械語は is_ready
の値をメモリから再読込しないため、ループ条件は永遠に 0 == 0
と評価され続け、プログラムは無限ループから抜け出せなくなります。
あるいは、コンパイラが is_ready
をレジスタに割り当てた場合も同様です。初期値 0
がレジスタにロードされ、ループ内の比較は常にレジスタ内の 0
と行われるため、メモリ上の値が 1 に変わっても気づきません。
これが、コンパイラの最適化が意図しない動作を引き起こす典型的な例です。コンパイラはプログラムコードだけを見て「賢く」振る舞いますが、プログラムの実行環境にはコンパイラが知らない「外部要因」が存在する場合があるのです。
第2部:volatile
キーワードの導入と役割
さて、このような最適化による問題を回避するために、C言語には volatile
というキーワードが用意されています。
volatile
は型修飾子(type specifier)の一つです。変数宣言の際に、型名の前または後ろに付け加えます。
volatile
の文法:
c
volatile int my_variable;
int volatile another_variable; // どちらも同じ意味
通常、volatile
は型名の前に書かれることが多いです。
volatile
の役割の核心:
volatile
キーワードを付けられた変数は、コンパイラに対して「この変数の値は、いつ、どのように変化するか予測不能である」ことを伝えます。これにより、コンパイラはその変数に対する最適化を抑制します。具体的には、volatile
変数へのアクセス(読み込みまたは書き込み)が必要になった場合、コンパイラは必ずメモリ上のその変数がある場所へアクセスするような機械語を生成します。レジスタに値をキャッシュしたり、連続する読み込みを一度にまとめたり、書き込みを省略したり、読み書きの順序を入れ替えたりといった最適化は抑制されます。
つまり、volatile
はコンパイラに対して以下のことを保証させます。
- 読み込み:
volatile
変数の値を読み込む必要があるとき、コンパイラは必ずメモリからその時点の値を読み込む機械語を生成します。前に同じ変数を読み込んだ時の値を再利用したり、レジスタにキャッシュされた値を使ったりといった最適化は行われません。 - 書き込み:
volatile
変数に値を書き込む必要があるとき、コンパイラは必ずメモリ上のその変数がある場所へ値を書き込む機械語を生成します。書き込みを省略したり、他の書き込みと順序を入れ替えたりといった最適化は抑制されます。
volatile
を付けることで、先ほどのポーリングループの例は正しく動作するようになります。
“`c
volatile int is_ready = 0; // ここに volatile を追加
// デバイスに処理開始を指示 …
// デバイスの準備完了を待つ
while (is_ready == 0) {
// 何もしない (ビジーループ)
// volatile が付いているので、ループの各反復で
// is_ready の値がメモリから再読込される
}
// デバイスが準備完了したので、次の処理へ進む
“`
is_ready
に volatile
が付いているため、コンパイラは while (is_ready == 0)
の条件を評価するたびに、必ずメモリ上の is_ready
があるアドレスから現在の値を読み込みます。外部デバイスがその値を 1 に変更すれば、次のループ条件の評価で is_ready
は 1 と読み込まれ、ループ条件 1 == 0
は偽となり、プログラムはループを抜けて次の処理に進むことができます。
volatile
は「メモリへの直接アクセスを強制する」修飾子だと理解するのが、初心者にとっては一番分かりやすいでしょう。コンパイラが勝手に判断してアクセスを省略したり、古い値を使ったりするのを防ぎます。
第3部:volatile
が必要な具体的なケースを深掘り
volatile
は特定の、メモリへの非同期的な変更が起こりうる状況で使用されます。ここでは、その代表的なケースを詳しく見ていきます。
ケース1: ハードウェアレジスタへのアクセス
組み込みシステムや低レベルプログラミングでは、特定のメモリ番地が単なるデータ記憶領域ではなく、ハードウェアの制御や状態を示す「レジスタ」として機能します。例えば、UART(シリアル通信)のデータ送信レジスタにデータを書き込むと、そのデータは外部に送信されます。ステータスレジスタを読み込むと、外部デバイスの状態(例えば、データ受信バッファが空か満杯か)を知ることができます。
これらのレジスタの値は、プログラムの実行とは独立に、ハードウェアによって変更されます。
- ステータスレジスタ: 外部イベント(データ受信完了、タイマー満了、ボタン押下など)によってレジスタの特定ビットがセットされたりクリアされたりします。
- データレジスタ: 外部からデータが受信されると、受信データレジスタの値が更新されます。
- コントロールレジスタ: プログラムがこのレジスタに値を書き込むと、ハードウェアの動作モードが切り替わったり、特定のアクション(例えば、データ送信開始)がトリガーされたりします。
これらのレジスタにアクセスする変数を volatile
にしないと、以下のような問題が起こりえます。
- ステータスフラグの読み飛ばし: ステータスレジスタの特定のビット(例えば「データ受信完了」フラグ)が立つまでポーリングで待つ場合、
volatile
がないとコンパイラがフラグの読み込みを省略したり、レジスタにキャッシュした古い値を使ったりして、フラグの変化を見落とす可能性があります。 - 書き込みの省略/遅延: コントロールレジスタに連続して設定値を書き込む場合、コンパイラが途中の書き込みを省略したり、実際の書き込みタイミングを遅延させたりすると、ハードウェアが期待通りに設定されなかったり、予期しないタイミングで動作したりする可能性があります。
例:簡単な I/O ポート制御 (疑似コード)
“`c
// マイコンのGPIOポートAのデータレジスタのアドレスを指すポインタ
define GPIOA_DATA_ADDR (0x40020000 + 0x04) // 仮のアドレス
volatile unsigned int GPIOA_DATA = (volatile unsigned int)GPIOA_DATA_ADDR;
// GPIOポートAの方向レジスタのアドレスを指すポインタ
define GPIOA_DIR_ADDR (0x40020000 + 0x00) // 仮のアドレス
volatile unsigned int GPIOA_DIR = (volatile unsigned int)GPIOA_DIR_ADDR;
void setup_gpioa() {
// ポートAをすべて出力に設定 (DIRレジスタに 0xFFFFFFFF を書き込むと仮定)
*GPIOA_DIR = 0xFFFFFFFF; // ここで volatile ポインタへの書き込み
}
void write_gpioa(unsigned int value) {
// ポートAに値を出力 (DATAレジスタに書き込む)
*GPIOA_DATA = value; // ここで volatile ポインタへの書き込み
}
unsigned int read_gpioa() {
// ポートAの現在の状態を読み込む
return *GPIOA_DATA; // ここで volatile ポインタからの読み込み
}
// 使用例
int main() {
setup_gpioa();
write_gpioa(0x5555AAAA); // 値を書き込む
unsigned int state = read_gpioa(); // 現在の状態を読み込む
// … state を使う …
return 0;
}
“`
この例では、ハードウェアレジスタを指すポインタを volatile unsigned int*
と宣言しています。これは「unsigned int
型のデータへのポインタであり、そのポインタが指すデータ(つまりレジスタの値)が volatile
である」という意味です。これにより、*GPIOA_DIR = ...;
, *GPIOA_DATA = ...;
, return *GPIOA_DATA;
といった操作は、コンパイラによって省略されたり順序を変えられたりすることなく、必ず指定されたメモリアドレスへの読み書きとして機械語に変換されます。
ハードウェアレジスタへのアクセスは、volatile
の最も一般的で典型的なユースケースです。
ケース2: 割り込みサービスルーチン (ISR) とメインプログラム間での変数共有
組み込みシステムでは、外部イベント(タイマー、通信受信、ボタン押下など)が発生すると、現在のプログラムの実行が一時中断され、「割り込みサービスルーチン(ISR)」と呼ばれる特別な関数が実行されます。ISRは、割り込みの原因となったイベントに応じた処理を行い、処理が完了すると中断された元のプログラムに戻ります。
メインプログラムとISRは、非同期に実行されます。メインプログラムがいつ割り込まれるか、ISRがいつ実行を終えるかは、外部イベントの発生タイミングに依存します。
もし、メインプログラムとISRの両方からアクセスされる変数がある場合、この変数に volatile
を付ける必要があります。なぜなら、ISRによる変数の変更は、メインプログラムの観点からは「予測不能なタイミング」で起こるからです。
例:ISR によるフラグ変更
“`c
// 割り込みハンドラ内でセットされるフラグ
volatile int event_flag = 0; // volatile を付ける!
// … メインプログラム …
// メインプログラムのどこかで、フラグが立つまで待つループ
void wait_for_event() {
while (event_flag == 0) {
// 何もしないか、省電力モードに入るなど
}
// フラグが立った!イベント発生!
event_flag = 0; // フラグをクリアして次のイベントに備える
}
// … 割り込みサービスルーチン (ISR) …
// (例: タイマー割り込みハンドラ)
void timer_isr() {
// 割り込みが発生した!フラグをセットする
event_flag = 1; // volatile 変数への書き込み
// … その他のISR処理 …
}
// main 関数や初期化処理
int main() {
// タイマー設定、割り込み有効化など …
while (1) { // メインループ
wait_for_event(); // イベント待ち
// イベント発生後の処理 ...
}
return 0;
}
“`
この例で event_flag
に volatile
が付いていないと、メインプログラムの wait_for_event
関数内の while (event_flag == 0)
ループは、先ほどのポーリングループの例と同様に最適化されてしまう可能性があります。コンパイラは event_flag
の初期値 0
を見て、「このループ内で event_flag
はプログラムによって変更されないから、条件は常に真だ」と判断し、ループ条件の再評価に必要なメモリからの読み込みを省略してしまうかもしれません。
しかし、実際にはISRが非同期に実行され、event_flag
を 1 に変更する可能性があります。volatile
を付けることで、コンパイラは while (event_flag == 0)
の条件を評価するたびに必ず event_flag
のメモリ上の値を読み込むため、ISRによって値が変更されたことを検知し、ループから抜け出すことができます。
ISRとメインプログラム間で共有される変数は、必ず volatile
で修飾する必要があります。これには、フラグ変数だけでなく、カウント、ステータス、データバッファなどの変数も含まれます。
ケース3: マルチスレッド/マルチプロセス環境での変数共有 (注意が必要!)
複数のスレッドやプロセスが同じメモリ空間を共有し、同じ変数にアクセスする場合も、外部要因(別のスレッド/プロセス)による非同期的な変数変更が発生します。理論的には、このような共有変数も volatile
で修飾する必要があるように思えます。
“`c
// 複数のスレッドからアクセスされる共有変数 (注意が必要な例)
volatile int shared_data = 0;
// スレッド1: shared_data をインクリメント
void thread1_func(void arg) {
for (int i = 0; i < 100000; ++i) {
shared_data++; // volatile 変数へのアクセス
}
return NULL;
}
// スレッド2: shared_data をインクリメント
void thread2_func(void arg) {
for (int i = 0; i < 100000; ++i) {
shared_data++; // volatile 変数へのアクセス
}
return NULL;
}
// main 関数でスレッドを生成・実行
// (スレッドライブラリの使用を仮定)
“`
この例では、shared_data
に volatile
が付いています。これにより、各スレッド内での shared_data++
という操作が、コンパイラによってレジスタにキャッシュされた値を使って最適化されるのではなく、常にメモリ上の shared_data
に対して行われることが保証されます。
しかし、ここで volatile
だけではマルチスレッドの問題を完全に解決できないという重要な点があります。
shared_data++
という操作は、C言語のソースコード上は1つの文ですが、機械語レベルでは通常以下の3つのステップに分解されます。
1. shared_data
の値をメモリから読み込む。
2. 読み込んだ値を1増やす。
3. 新しい値をメモリの shared_data
の場所に書き込む。
これらのステップは、volatile
が付いていてもアトミック(不可分)ではありません。つまり、スレッド1がステップ1で値を読み込んだ直後に、スレッド2が全く同じ3ステップを実行し、その後にスレッド1がステップ2と3を実行するということが起こりえます。
もし shared_data
が 0 の状態で、スレッド1が読み込みを行い(値は0)、その直後にスレッド2も読み込みを行い(値は0)、スレッド2がインクリメントして書き込みを行い(shared_data
は 1 になる)、その後でスレッド1がインクリメントして書き込みを行うと(shared_data
は 1 になる)、期待される結果(2)ではなく 1 になってしまいます。
volatile
は「コンパイラの最適化を抑制し、メモリへのアクセスを強制する」だけであり、「複数のスレッドからの同時アクセスを調停したり、操作のアトミック性を保証したりする」機能はありません。
マルチスレッド環境で共有される変数に対しては、通常、volatile
よりも同期メカニズム(例えば、ミューテックス、セマフォ、またはC++11以降の std::atomic
型など)を使用する必要があります。これらのメカニズムは、複数のスレッドが共有データにアクセスする際に、一貫性と安全性を保証するためのものです。
volatile
は、マルチスレッド環境において限定的な役割を持つことはありますが(例えば、コンパイラの最適化による問題を防ぐという点では)、排他制御やアトミック操作が必要な場面で volatile
だけで済ませるのは間違いです。この点は、特に初心者の方が混同しやすいポイントなので十分注意が必要です。
第4部:volatile
の「読み取り」と「書き込み」
volatile
修飾された変数へのアクセスは、すべて重要なイベントとして扱われます。具体的には、volatile
変数に対する「読み込み」操作と「書き込み」操作は、それぞれ独立した観測点となります。
コンパイラは、volatile
変数に対する読み込みまたは書き込み操作を、ソースコードに書かれた通りの順序で、かつ省略することなく実行するように機械語を生成しようとします。
“`c
volatile int status_reg;
volatile int data_reg;
// …
status_reg = 0; // volatile 書き込み
data_reg = 10; // volatile 書き込み
int s1 = status_reg; // volatile 読み込み
int d1 = data_reg; // volatile 読み込み
int s2 = status_reg; // volatile 読み込み (たとえ直前と同じ変数でも再読み込みが強制される)
“`
このコード片において、コンパイラは以下の順序とアクセスを基本的に維持します(厳密なメモリバリアの議論は後述)。
status_reg
への書き込み (0)data_reg
への書き込み (10)status_reg
からの読み込み (結果がs1
に入る)data_reg
からの読み込み (結果がd1
に入る)status_reg
からの読み込み (結果がs2
に入る)
特に注目すべきは、5番目の status_reg
の読み込みです。たとえ直前の4番目の操作で status_reg
を読み込んでいたとしても、volatile
が付いているため、コンパイラは再度メモリから status_reg
の値を読み込む機械語を生成します。これは、その直前の短い時間の間でも、外部要因によって status_reg
の値が変更された可能性があるためです。
同様に、連続する書き込みも省略されません。
“`c
volatile int control_reg;
control_reg = 1; // デバイス設定1を有効化
control_reg = 2; // デバイス設定2を有効化
control_reg = 3; // デバイス設定3を有効化
“`
volatile
が付いている場合、コンパイラは control_reg
への 1
, 2
, 3
という3回の書き込みをすべて、記述された順序で実行する機械語を生成します。これにより、ハードウェアに対して意図した通りの設定シーケンスを確実に実行させることができます。volatile
がないと、コンパイラは最初の2つの書き込みを省略して、最後の control_reg = 3;
だけをメモリに書き込むように最適化してしまう可能性があります。
このように、volatile
修飾された変数へのアクセスは、「必ずその場所へ、その時点の値を読み書きせよ」というコンパイラへの強い指示となります。
第5部:volatile
のスコープ:ポインタ、構造体、共用体
volatile
は単一の変数だけでなく、ポインタ、構造体、共用体にも適用できます。しかし、その適用方法によって意味が異なります。
1. ポインタへの volatile
の適用
ポインタ宣言に volatile
を付ける場合、2つの異なる意味があります。
-
ポインタが指すデータが
volatile
:volatile int *ptr;
これは、「ptr
はint
型のデータへのポインタであり、そのポインタが指しているint
型のデータがvolatile
である」という意味です。ポインタ変数ptr
自体はvolatile
ではありません。つまり、ptr
の値(どのアドレスを指しているか)はコンパイラによって最適化される可能性がありますが、*ptr
を通じたメモリへのアクセスはvolatile
として扱われます。これは、ハードウェアレジスタを指すポインタの宣言でよく使われる形式です。“`c
volatile int *hardware_reg_ptr; // ポインタが指すデータが volatileint ordinary_var = 10;
hardware_reg_ptr = &ordinary_var; // ポインタの値自体は変更可能(最適化の対象)// hardware_reg_ptr へのアクセスは volatile 扱い
int value = hardware_reg_ptr; // メモリから必ず読み込む
*hardware_reg_ptr = value + 1; // メモリへ必ず書き込む
“` -
ポインタ変数自体が
volatile
:int *volatile ptr;
これは、「ptr
はint
型のデータへのポインタであり、そのポインタ変数ptr
自体(つまり、ptr
が保持しているメモリアドレスの値)がvolatile
である」という意味です。ptr
が指すデータはvolatile
ではありません。ポインタ変数ptr
の値が、コンパイラが予測しない要因によって変更される可能性がある場合にこれを使用します。例えば、ISRがポインタ変数の値を変更する場合などです。“`c
int *volatile changing_ptr; // ポインタ変数自体が volatileint data1 = 100;
int data2 = 200;changing_ptr = &data1; // ポインタ変数 changing_ptr への書き込み (volatile 扱い)
// changing_ptr へのアクセスは volatile ではない通常のアクセスとして扱われる
// (コンパイラが最適化する可能性あり)
int val1 = changing_ptr; // data1 を読み込む (最適化されるかも)// 外部要因 (例: ISR) によって changing_ptr が &data2 に変更されたとする
changing_ptr = &data2; // ポインタ変数 changing_ptr への書き込み (volatile 扱い)
int val2 = *changing_ptr; // data2 を読み込む (最適化されるかも)
“` -
ポインタが指すデータも、ポインタ変数自体も
volatile
:volatile int *volatile ptr;
これは、上記の2つの意味を組み合わせたものです。ポインタ変数ptr
自体もvolatile
であり、ptr
が指すデータもvolatile
です。“`c
volatile int *volatile fully_volatile_ptr;volatile int vol_data1 = 100; // 指されるデータも volatile にしておくのが典型的
volatile int vol_data2 = 200;fully_volatile_ptr = &vol_data1; // ポインタ変数への書き込み (volatile)
// fully_volatile_ptr へのアクセスも volatile 扱い
int val1 = fully_volatile_ptr; // vol_data1 を読み込み (volatile)// 外部要因 (例: ISR) によって fully_volatile_ptr が &vol_data2 に変更されたとする
fully_volatile_ptr = &vol_data2; // ポインタ変数への書き込み (volatile)
int val2 = *fully_volatile_ptr; // vol_data2 を読み込み (volatile)
“`
ポインタを扱う際は、volatile
をどこに付けるかで意味が全く異なるため、注意が必要です。ハードウェアレジスタを扱う場合は、通常「ポインタが指すデータが volatile
」 (volatile int *ptr;
) の形式を使用します。
2. 構造体と共用体への volatile
の適用
構造体や共用体の変数全体を volatile
で修飾することもできます。
“`c
typedef struct {
int field1;
char field2;
} my_struct_t;
volatile my_struct_t status_block; // 構造体全体が volatile
typedef union {
int ival;
float fval;
} my_union_t;
volatile my_union_t data_register; // 共用体全体が volatile
“`
構造体や共用体全体を volatile
で修飾した場合、その構造体/共用体のすべてのメンバーが volatile
として扱われます。つまり、status_block.field1
や status_block.field2
へのアクセス、または data_register.ival
や data_register.fval
へのアクセスは、すべて個別に volatile
アクセスとして扱われ、コンパイラによる最適化が抑制されます。
“`c
// volatile 構造体へのアクセス例
status_block.field1 = 123; // field1 への volatile 書き込み
char c = status_block.field2; // field2 からの volatile 読み込み
// volatile 共用体へのアクセス例
data_register.ival = 456; // ival への volatile 書き込み
float f = data_register.fval; // fval からの volatile 読み込み
“`
特定のメンバーだけを volatile
にしたい場合は、構造体/共用体の定義内でそのメンバーに volatile
を付けます。
“`c
typedef struct {
int regular_field;
volatile int volatile_field; // このメンバーだけ volatile
} mixed_struct_t;
mixed_struct_t device_status;
device_status.regular_field = 10; // 通常の最適化可能な書き込み
int r = device_status.regular_field; // 通常の最適化可能な読み込み
device_status.volatile_field = 20; // volatile 書き込み
int v = device_status.volatile_field; // volatile 読み込み
“`
ハードウェアレジスタが構造体としてマップされている場合など、特定のレジスタ(メンバー)だけが外部から変更される可能性がある場合に、この方法が役立ちます。
第6部:volatile
が解決しない問題 (ここが重要!)
volatile
はコンパイラの最適化によって引き起こされる特定の問題を解決するための強力なツールですが、万能薬ではありません。特に、複数の実行主体(CPUコア、スレッド、ISRなど)が同じメモリ領域にアクセスする並行処理の文脈では、volatile
だけでは不十分なことがほとんどです。
volatile
が解決しない、または解決しようとしない主な問題は以下の通りです。
1. アトミック性 (Atomicity)
前述のマルチスレッドの例で触れたように、volatile
は操作のアトミック性(不可分性)を保証しません。アトミックな操作とは、その操作が実行されている途中で他の実行主体に割り込まれることなく、一連の処理全体が一気に完了することを意味します。
例えば、32ビットの整数変数に対する操作を考えます。この変数がメモリ上で複数バイトにまたがっている場合、その変数への「読み込み」や「書き込み」自体が、CPUのアーキテクチャによっては複数回のメモリ操作に分解されることがあります。
“`c
volatile long_long_int large_counter; // 64ビット変数と仮定
// スレッド1
large_counter = 123456789012345; // 64ビット書き込み
// スレッド2
long_long_int value = large_counter; // 64ビット読み込み
“`
たとえ volatile
が付いていても、64ビットの書き込みや読み込みがCPUによっては上位32ビットと下位32ビットに分けて行われる場合、スレッド1が上位32ビットを書き込んだ後にスレッド2が値を読み込み、その後にスレッド1が下位32ビットを書き込む、といった状況が起こりえます。この場合、スレッド2は「新しい上位32ビット + 古い下位32ビット」という、整合性のない値を読み込んでしまう可能性があります。
volatile
はコンパイラ最適化を防ぐだけなので、このようなハードウェアレベルでの操作の分解や、分解された操作の途中に別の実行主体が割り込むことを防ぐことはできません。アトミック性を保証するには、CPUの特殊な命令(アトミック操作命令)を利用するか、ソフトウェア的なロック(ミューテックスなど)によって排他制御を行う必要があります。
C++11以降では、std::atomic
という機能が追加され、特定の型に対するアトミックな操作を比較的容易に実現できるようになりました。C言語自体には標準でこのような高レベルのアトミック機能はありませんが、多くの場合、特定のアーキテクチャ向けの組み込み関数(intrinsic functions)や、POSIXスレッドライブラリ (pthreads
) のようなOSやライブラリが提供するアトミック操作や同期プリミティブを使用します。
結論として、アトミック性が必要な場合は volatile
だけでは不十分です。
2. メモリバリア (Memory Barrier / Memory Fence)
コンパイラは、プログラムの「観測可能な振る舞い」が変わらない範囲で、命令の実行順序を入れ替えることがあります(前述のストアの再順序付けなど)。これはコンパイラによる最適化ですが、さらにCPU自身も、パイプライン処理やキャッシュの効率を高めるために、実際にメモリへアクセスする順番をプログラムの命令順と異なる順序で行うことがあります。これは「アウトオブオーダー実行」や「メモリの順序付け (memory ordering)」の問題と呼ばれます。
volatile
は、volatile
変数へのアクセスに関してはコンパイラによる順序変更や省略を抑制しますが、異なる volatile
変数間のアクセス順序や、volatile
変数とそうでない変数との間のアクセス順序、さらにはCPUによるハードウェアレベルでの順序変更を完全に制御できるわけではありません。
“`c
volatile int flag = 0;
int data = 0;
// スレッド1
data = 123; // (A)
flag = 1; // (B) volatile 書き込み
// スレッド2
while (flag == 0) {
// 待つ
}
// flag が 1 になった!
int value = data; // (C)
“`
スレッド1はまず data
に値を書き込み、それから flag
を 1 にセットするという意図でコーディングしています。スレッド2は flag
が 1 になったのを見て、data
を読み込むという意図です。期待としては、スレッド2が flag
が 1 になったのを確認して data
を読み込むときには、スレッド1が書き込んだ新しい値 (123) が読み込まれるはずです。
しかし、もしコンパイラやCPUが (A) と (B) の順序を入れ替え、先に flag = 1;
を実行し、その後に data = 123;
を実行したとします。この場合、スレッド2は flag
が 1 になったのを検知してループを抜けたにも関わらず、data
の読み込み (C) が、スレッド1による data = 123;
の書き込みよりも先に実行されてしまい、data
の古い値 (0) を読み込んでしまう可能性があります。
volatile
は (B) の flag
への書き込みと (C) の flag
(ループ条件) および data
への読み込みが、それぞれのステップでメモリへアクセスすることを保証しますが、(A) と (B) の間の順序保証や、(B) と (C) の間の、特に異なる変数 (flag
と data
) に対する読み書き順序保証は、volatile
の標準的な機能範囲を超えます。(ただし、特定のコンパイラやアーキテクチャにおいては、volatile
アクセスが弱いメモリバリアとして機能することもありますが、これは移植性のある挙動ではありません。)
このような順序の問題を解決するには、メモリバリア(Memory Barrier)またはメモリフェンス(Memory Fence)と呼ばれる特殊な命令や関数が必要です。メモリバリアは、そのバリアより前に記述されたメモリ操作がすべて完了してから、バリアより後に記述されたメモリ操作が開始されることを保証します。これにより、コンパイラやCPUによる命令の再順序付けを制御できます。
メモリバリアはCPUアーキテクチャに依存する低レベルな機能であり、C言語の標準には直接的なメモリバリア関数はありません。(C++11以降の std::atomic
は、内部でメモリバリア機能を組み合わせて提供しています。)低レベル開発では、プラットフォーム固有の組み込み関数やアセンブリ言語を使用することが一般的です。
結論として、複数の変数間のメモリ操作順序を厳密に制御する必要がある場合は、volatile
だけでは不十分であり、メモリバリアが必要になる可能性があります。
3. キャッシュコヒーレンシ (Cache Coherency)
最近のCPUは、高速なキャッシュメモリを持っています。複数のCPUコアを持つシステムでは、各コアが独自のキャッシュを持つことが一般的です。あるコアがキャッシュに変数の値を読み込んだ後、別のコアが主メモリ上の同じ変数の値を変更しても、元のコアのキャッシュは古い値を持ったままになる可能性があります。これを「キャッシュコヒーレンシの問題」と呼びます。
volatile
はコンパイラに対して「メモリにアクセスせよ」と指示しますが、それがCPUのキャッシュをバイパスして直接主メモリにアクセスすることを保証するわけではありません(多くのアーキテクチャでは、volatile
アクセスはキャッシュを考慮したアクセスになります)。また、あるコアがキャッシュに行った変更が、すぐに他のコアや主メモリに反映される(キャッシュのフラッシュや無効化)ことを保証するわけでもありません。
キャッシュコヒーレンシの問題は、通常、ハードウェア(キャッシュコヒーレンシプロトコル)によって自動的に解決されるか、あるいはOSやランタイムシステムが提供する高レベルな同期プリミズム(ミューテックスなど)や、前述のメモリバリアによって解決されます。
volatile
は、キャッシュコヒーレンシの問題を直接解決するものではありません。あるコアのキャッシュに古い値が残っていても、コンパイラがその古いキャッシュ値を使って最適化しないようにする役割は果たしますが、キャッシュ間の値の伝播を保証するものではないということです。
第7部:volatile
とその他のキーワード/概念との関係
1. const
と volatile
の組み合わせ
volatile
と const
は同時に使用することができます。
c
volatile const int status_register; // または const volatile int status_register;
これは「この変数は volatile
である(外部要因によって値が変化する可能性がある)が、プログラム自身はその値を変更しない (const
)」という意味です。
このような組み合わせは、ハードウェアの読み込み専用ステータスレジスタを表現する際に使用されます。例えば、あるレジスタはハードウェアが設定する(だから volatile
)が、プログラムはそれを読み込むだけで書き込まない(だから const
)という場合です。
volatile const
変数に対してプログラムが書き込みを行おうとすると、コンパイラはエラーを出力します。読み込みは、volatile
として扱われるため、常にメモリからの読み込みが強制されます。
2. register
と volatile
register
キーワードは、コンパイラに「この変数を可能ならレジスタに割り付けて高速化してほしい」というヒントを与えるものです。しかし、volatile
は「この変数をレジスタにキャッシュせず、常にメモリにアクセスせよ」という強い指示です。
これらのキーワードが同時に指定された場合、volatile
の指示が優先されます。つまり、volatile register int my_var;
のように宣言しても、コンパイラはその変数をレジスタに割り当てることはなく、volatile
として扱います。実際、現代のコンパイラは register
キーワードをほとんど無視するか、単なる最適化のヒントとして扱うだけなので、volatile
と一緒に指定しても意味はありませんし、混乱を招くだけです。
3. C++11以降の std::atomic
との関係
前述のように、C++11以降では <atomic>
ヘッダで std::atomic
という機能が提供されています。これは、アトミック性とメモリ同期(メモリバリア)を組み合わせた、並行プログラミングのための高レベルなツールです。
volatile
はコンパイラ最適化の抑制という低レベルな機能に特化していますが、std::atomic
は、コンパイラ最適化の抑制に加えて、CPUレベルでのアトミックな読み書きや、メモリ操作の順序保証といった、並行処理で本当に必要な機能を提供します。
C++でマルチスレッドプログラミングを行う場合、共有変数に対して volatile
を使うのではなく、可能であれば std::atomic
型を使うことが強く推奨されます。std::atomic
を使用することで、より安全かつ移植性の高い並行処理を実現できます。
ただし、組み込み分野などで古かったり制約の多い環境では、C++11の機能が使えない場合もあります。また、ハードウェアレジスタへのアクセスやISRとの通信など、C言語でも同様の低レベルな課題は存在するため、C言語の volatile
の知識は依然として重要です。
第8部:よくある間違いと注意点
volatile
は強力ですが、正しく理解して使わないと意味がなかったり、かえって問題を複雑にしたり、パフォーマンスを低下させたりすることがあります。
volatile
を万能な同期手段と勘違いする:volatile
はアトミック性もメモリバリアも保証しません。マルチスレッド環境での同期には、ミューテックスやセマフォ、アトミック操作などを使いましょう。- 不要な場所に
volatile
を使用する:volatile
はコンパイラの最適化を抑制するため、必要な場所以外で使用すると、プログラムの実行速度が遅くなる可能性があります。通常のローカル変数や、外部要因によって非同期に値が変わることがない変数にはvolatile
を付けないでください。 - アトミック性が必要な操作に
volatile
だけで済ませる:i++
のような読み書き変更を伴う操作は、volatile
が付いていてもアトミックではありません。複数の実行主体が同時にアクセスする場合は、必ずアトミック操作または排他制御が必要です。 - ポインタの
volatile
修飾の意味を間違える:volatile T* ptr;
とT* volatile ptr;
は意味が全く異なります。どちらにvolatile
を付けるべきか、状況に応じて正しく判断してください。ハードウェアレジスタの場合は前者であることが多いです。 - 特定のプラットフォームでの
volatile
の解釈に過度に依存する: C言語の標準ではvolatile
の振る舞いに関する最低限の規定がありますが、メモリバリアのような低レベルな挙動は、コンパイラやハードウェアアーキテクチャに依存する場合があります。極めて厳密なタイミングや順序保証が必要な場合は、コンパイラのドキュメントを確認したり、プラットフォーム固有の機能を使用したりする必要が出てくることもあります。しかし、一般的なハードウェアレジスタやISRとの通信であれば、標準的なvolatile
のセマンティクスで十分な場合が多いです。
第9部:実践的な例 (概念的なコード)
ここでは、volatile
がない場合とある場合で、コンパイラの最適化によってどのように振る舞いが変わる可能性があるかを示す概念的なコード例を挙げます。実際に特定のコンパイラで特定の最適化レベルを設定してコンパイルし、生成されるアセンブリコードを比較すると、volatile
の効果をより具体的に確認できます。
例1: ポーリングループ (再掲)
“`c
// volatile なしの場合
int flag = 0;
// 外部要因 (例: ISR) が flag を 1 に変更する可能性がある
void wait_without_volatile() {
while (flag == 0) {
// ループ内で flag はプログラム自身によって変更されないため、
// コンパイラは while(true) と同等と見なし、無限ループと判断し、
// flag のメモリからの再読み込みを省略する可能性がある。
// 結果、外部から flag が 1 になってもループを抜けられない。
}
// ここに到達しない可能性がある
}
// volatile ありの場合
volatile int volatile_flag = 0;
// 外部要因 (例: ISR) が volatile_flag を 1 に変更する可能性がある
void wait_with_volatile() {
while (volatile_flag == 0) {
// volatile が付いているため、コンパイラはループの各反復で
// volatile_flag の値をメモリから再読み込みする機械語を生成する。
// 外部から volatile_flag が 1 になると、それを検知してループを抜けられる。
}
// ここに到達できる
}
“`
例2: 連続書き込みの省略
“`c
int config_reg;
void set_config_without_volatile(int val1, int val2, int val3) {
config_reg = val1; // コンパイラが省略する可能性あり
config_reg = val2; // コンパイラが省略する可能性あり
config_reg = val3; // この書き込みだけが残る可能性あり
// 外部デバイスが val1 や val2 の設定を期待している場合、正しく動作しない可能性がある。
}
volatile int volatile_config_reg;
void set_config_with_volatile(int val1, int val2, int val3) {
volatile_config_reg = val1; // volatile なので必ずメモリに書き込まれる
volatile_config_reg = val2; // volatile なので必ずメモリに書き込まれる
volatile_config_reg = val3; // volatile なので必ずメモリに書き込まれる
// すべての書き込みが順序通りに実行されるため、外部デバイスは期待通りに設定される。
}
“`
これらの例は、コンパイラの最適化がどのように動作し、volatile
がそれをどのように抑制するかを概念的に示しています。実際のコンパイラの挙動は、最適化レベルやターゲットアーキテクチャによって異なります。しかし、volatile
が「メモリへのアクセスを強制する」という役割を理解していれば、これらの問題を回避するための正しいコーディングが可能になります。
第10部:まとめと今後の学習
この記事では、C言語の volatile
キーワードについて、コンパイラ最適化の基本から始めて、その必要性、具体的な使用例(ハードウェアレジスタ、ISR)、ポインタや構造体への適用、そして volatile
だけでは解決できない問題(アトミック性、メモリバリア、キャッシュコヒーレンシ)について詳細に解説しました。
volatile
は、プログラムの実行とは独立した外部要因によって変数の値が非同期に変更される可能性がある場合に、コンパイラの最適化が引き起こす問題を回避するために不可欠なキーワードです。特に組み込みシステム開発においては、ハードウェアレジスタへのアクセスや割り込み処理の実装で頻繁に登場します。
しかし、volatile
の能力は「コンパイラ最適化の抑制」に限定されることを忘れてはなりません。複数の実行主体間での安全なデータ共有(並行処理)には、アトミック操作や同期メカニズムといった、より高レベルな手段が必要となることがほとんどです。
C言語の初心者にとっては、まず「コンパイラは賢く最適化する」「その最適化が外部要因による変数変更の検知を妨げることがある」「volatile
はそれを防ぐおまじない」という基本的な理解から始めるのが良いでしょう。そして、組み込み開発や並行プログラミングといった分野に進むにつれて、volatile
が解決しない問題や、より高度な同期の概念(アトミック性、メモリバリア、ロックなど)について深く学んでいくことが重要になります。
今後の学習のために:
- C言語の標準規格: C言語の標準規格書は
volatile
の厳密な定義を提供していますが、初心者には難解かもしれません。しかし、困ったときの最終的なよりどころとなります。 - 使用しているコンパイラのドキュメント: コンパイラによっては、
volatile
の解釈や、メモリバリアに関する独自の拡張機能を持っていることがあります。特定の環境で開発を行う場合は、コンパイラのドキュメントを確認することが重要です。 - 組み込みシステムに関する書籍/資料: ハードウェアレジスタの操作や割り込み処理は組み込みシステムのコア部分です。これらの分野を学ぶことで、
volatile
がなぜ必要なのか、具体的な応用例をさらに深く理解できます。 - 並行プログラミングに関する書籍/資料: マルチスレッドやマルチプロセスでのプログラミングを学ぶことで、
volatile
と同期メカニズム(ミューテックス、セマフォ、アトミック操作など)の関係性や使い分けを明確に理解できます。特にC++を使う場合はstd::atomic
について学びましょう。
volatile
は、C言語の深い部分、特にハードウェアと密接に関わる部分を理解する上での重要なステップです。この記事が、あなたの volatile
に対する理解を深め、安全で堅牢なプログラムを書くための一助となれば幸いです。
これで、C言語の volatile
キーワードに関する約5000語の詳細な解説記事は完了です。