【組み込み】volatile はいつ必要?レジスタ・割り込み・ポーリング処理での使いどころを整理

  • URLをコピーしました!

組み込みC言語を学び始めると、volatile 修飾子を見かける場面が増えてきます。
マイコンのサンプルコードや周辺ドライバで頻繁に登場する一方で、「とりあえず付けるもの」と理解したまま使ってしまう人も少なくありません。

しかし、volatile は付ける場所を間違えると、意図しない不具合や読みにくいコードの原因になります。逆に、本来必要な場所で付いていないと、最適化の影響で値が正しく読めないことがあります。

この記事では、volatile の基本的な意味を整理したうえで、レジスタアクセス割り込み処理ポーリング処理での使いどころを具体例つきで解説します。

目次

volatileとは

volatile は、その変数の値がプログラムの見えている流れとは別に変化する可能性があることをコンパイラに伝えるための修飾子です。

通常、コンパイラは高速化のために、変数の値をレジスタに保持したり、不要と思われる読み直しを省いたりします。
ところが、ハードウェアレジスタや割り込みで更新される変数は、コンパイラから見ると「勝手に値が変わる」存在です。

そのため volatile を付けることで、コンパイラに対して次のような制約を与えます。

  • 必要な読み取りを省略しない
  • 必要な書き込みを勝手にまとめない
  • 毎回メモリ上の値を確認する前提で扱う

ただし、ここで注意したいのは、volatile排他制御の仕組みではないという点です。
スレッドセーフにしたり、割り込みとの競合を防いだりする機能はありません。

volatileが必要になる典型パターン

組み込み開発では、主に次の3つの場面で volatile が必要になります。

  1. メモリマップドI/Oのレジスタを読む・書くとき
  2. 割り込みハンドラとメイン処理で共有する変数
  3. 状態変化を待つポーリング処理

それぞれ見ていきます。

レジスタアクセスでのvolatile

ハードウェアレジスタは勝手に値が変わる

たとえば、UARTの受信完了フラグやGPIOの入力状態は、CPUが代入しなくてもハードウェア側で変化します。
このようなレジスタを普通の変数として扱うと、コンパイラが「値は変わらない」と判断してしまう可能性があります。

#define UART_STATUS (*(unsigned int *)0x40001000)

while ((UART_STATUS & 0x01) == 0) {
}

このコードでは、UART_STATUSvolatile が付いていないと、最適化によって1回しか読まれず、無限ループになることがあります。

そのため、レジスタ定義は次のように書くのが基本です。

#define UART_STATUS (*(volatile unsigned int *)0x40001000)

実務上のポイント

レジスタ定義で volatile を付けるのはほぼ定番です。
ただし、ドライバ全体のあちこちで生ポインタを書くより、ヘッダでまとめて管理したほうが安全です。

typedef struct {
    volatile unsigned int STATUS;
    volatile unsigned int DATA;
} UART_Type;

#define UART0 ((UART_Type *)0x40001000)

このようにしておくと、どのメンバがハードウェアレジスタなのかが明確になり、保守しやすくなります。

割り込みと共有変数でのvolatile

メイン処理と割り込みで同じ変数を使う場合

割り込みハンドラが変数を書き換え、メインループ側がその変化を監視するケースもよくあります。

volatile int g_rx_done = 0;

void UART_IRQHandler(void) {
    g_rx_done = 1;
}

int main(void) {
    while (g_rx_done == 0) {
    }

    /* 受信後の処理 */
}

この場合、g_rx_donevolatile が付いていないと、メイン側が初回に読んだ値を使い回し、「ずっと0のまま」とみなすことがあります。

volatileだけでは足りない場面もある

ここで誤解しやすいのが、volatile を付ければ安全になるわけではないことです。

たとえば、割り込み側とメイン側で複数バイトの変数を更新したり、読み書きの途中で割り込みが入ると困る処理をしたりする場合は、volatile に加えて次のような対策が必要です。

  • 割り込み禁止区間を設ける
  • アトミックなアクセス方法を使う
  • フラグとデータの更新順序を設計する

volatile は「最適化を抑える」ためのものであり、「競合を解決する」ものではありません。

ポーリング処理でのvolatile

状態が変わるまで待つ処理

組み込みでは、デバイスの準備完了を待つためにポーリングを書くことがあります。

#define ADC_STATUS (*(volatile unsigned int *)0x40002000)

while ((ADC_STATUS & 0x01) == 0) {
    /* 変換完了待ち */
}

このようなループでは、毎回レジスタを再読込する必要があります。
そのため、監視対象がハードウェアや割り込みで変化するなら volatile が必要です。

付けなくてよいケース

一方で、関数内だけで完結する普通のローカル変数には通常不要です。

int ready = 0;
while (ready == 0) {
}

このコード自体が不自然ですが、ready が外部要因で変わらないなら volatile を付けても意味はありません。
「ループで見ているから付ける」のではなく、誰が値を変えるのかで判断するのが大事です。

よくある誤用

なんとなく全部に付ける

volatile を多用すると、最適化の恩恵を受けにくくなり、コードの意図もぼやけます。
特に、通常のバッファや計算用変数にまで広げるのは避けたいところです。

排他制御の代わりに使う

前述の通り、volatile は排他制御ではありません。
RTOS環境やマルチコア環境では、volatile だけで同期を取ろうとすると危険です。

ビットフィールドや複雑な式に頼りすぎる

レジスタ操作で volatile な対象に複雑な読み書きを重ねると、想定以上にアクセス回数が増えることがあります。
副作用のあるレジスタでは、1回の読み出しが意味を持つこともあるため、実装時は生成コードやアクセス回数も意識したいところです。

まとめ

volatile は、組み込みC言語では非常に重要な修飾子ですが、役割ははっきりしています。
それは、ハードウェアや割り込みなどによって値が予期せず変わる可能性をコンパイラに伝えることです。

使いどころを整理すると、次のようになります。

  • レジスタアクセスでは基本的に必要
  • 割り込みと共有する変数にも必要になることが多い
  • ポーリング対象が外部要因で変化するなら必要
  • ただし、排他制御や同期の代わりにはならない

volatile を正しく使うコツは、「この値はCPUの通常の実行以外で変わるか?」と考えることです。
なんとなく付けるのではなく、必要な理由を説明できる形で使えるようになると、組み込みコードの見通しと安全性がかなり良くなります。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

エンジニア。20代。組み込みエンジニアとして働き始めるも、働き方や業務内容に限界を感じ、 AI,Web3エンジニアを目指して勉強中。 エンジニアとして思うことや、学んだことを発信します。

目次