グローバル変数の扱い方

「グローバル変数は使うな」とはよく言われますが、組み込みのコードベースを開くと、現実にはグローバル変数だらけです。動的確保を避ける文化、割り込みとの共有、常駐する機器状態。組み込みには静的な変数が増える正当な理由があり、「全部禁止」は現実解になりません。なお、この記事でいうグローバル変数は、外部結合(external linkage)を持ち、他の翻訳単位から参照・更新できる変数(ヘッダでextern宣言されるもの)を主な対象とします。※externは宣言であり、実体(定義)は通常.c側に1か所置きます。ファイルスコープstaticはモジュール内状態として区別して扱います。この記事では、グローバル変数を禁止ではなく管理する考え方として、スコープ最小化・カプセル化・共有時の保護という3段階の整理を解説します。

なぜ嫌われるのか:4つの実害

  • 原因追跡が難しい:どこからでも書き換えられるため、「いつ誰がこの値にしたのか」を探す作業に多大な工数がかかります。
  • 隠れた結合:モジュールAとBがグローバル変数経由で暗黙につながり、片方の変更がもう片方の動作に悪影響を及ぼします。関数の引数と戻り値に現れない依存は、レビューでも見えません。
  • テストしづらい:単体テストで特定の状態を作るために、あちこちのグローバル変数を仕込む必要が生じます。
  • 割り込みとの競合:ISR(Interrupt Service Routine:割り込みサービスルーチン、割り込みハンドラとも呼びます)と主処理が同じ変数を触ると、読みかけの値を壊す競合が起きます。

なぜ組み込みでは増えるのか

一方で、組み込みで静的な変数が多いのには理由があります。ヒープの動的確保を避けてメモリを静的に確保する設計方針、ISRと主処理のデータ受け渡し、電源が入っている間ずっと存在する機器状態の保持。これらは設計として正当です。つまり問題は「静的な変数が存在すること」ではなく、「どこからでも見えて、どこからでも書けること」にあります。攻めるべきは可視範囲です。

第1段階:スコープを最小にする

SEI CERT C Coding StandardのDCL19-C(変数と関数の有効範囲を最小限にする)という推奨事項があります。これを怠ると、信頼性・可読性・再利用性が低下します。具体的な第一歩は、ヘッダでextern宣言されている変数の棚卸しです。実は1つのCソースファイルでしか使っていない変数が大量に見つかるはずです。それらはextern公開をやめ、ファイルスコープstatic(内部結合:その翻訳単位の外から参照できない)に変更します。staticにすれば翻訳単位の外から見えなくなり、他モジュールから誤って(あるいは安易に)触られることがなくなります。関数内でしか使わない変数は、さらにローカルstatic変数(ブロックスコープのstatic変数)へ移します。

第2段階:カプセル化(書き込み箇所を1箇所に集める)

モジュールをまたいで共有が必要な状態は、変数そのものを公開せず、static変数+公開関数(アクセサ、getterやsetterとも呼びます)の形に包みます。

/* motor.c ─ 状態はファイル内に閉じ込める */
static uint16_t speed_rpm;      /* externしない */
void motor_set_speed(uint16_t rpm)
{
    if (rpm > MOTOR_RPM_MAX) {
        rpm = MOTOR_RPM_MAX;    /* 範囲ガードも一緒に集約できる */
    }
    speed_rpm = rpm;            /* 書き込みはこの関数だけ */
}
uint16_t motor_get_speed(void)
{
    return speed_rpm;
}

この形の実利は3つあります。①書き込み箇所が関数に集約されるので、デバッガのブレークポイント1個で「誰がいつ書いたか」を全部監視できる。②範囲チェックやログ、排他をあとから1箇所に足せる。③テスト時にこの関数だけ差し替えられる。「関数呼び出しにオーバーヘッドがかかるのでは」と心配になるかもしれませんが、最適化を有効にした場合、この程度の関数はインライン展開されることが多く、オーバーヘッドが問題になりにくいケースがほとんどです(ただしビルド条件によります)(「最適化オプションの使い分け」参照)。

第3段階:それでも共有するなら保護する

  • 割り込みと共有する変数:volatileは、最適化による読み書きの省略や並べ替えを抑制する(少なくとも対象オブジェクトへのアクセスについて)ためのものであり、排他・原子性・メモリバリアを保証するものではありません。原子性が必要な場合は、クリティカルセクション(割り込み禁止区間や排他ロックで保護する区間)やC11のatomics、RTOSのミューテックスなどを使います。(例)8ビット・16ビットMCUでは、32ビット値の読み書きが複数命令になりやすく、途中で割り込みが入ると不整合が起き得ます。counter++のようなRead-Modify-Write操作、複数バイトの値、複数変数のセットの整合は、クリティカルセクションなどで保護します。
  • RTOSのタスク間:そもそも変数を共有せず、キューやメッセージで値を渡す設計に寄せると、排他制御に関する課題を局所化できます。
  • 読み取り専用の公開:他モジュールには値を見せたいだけなら、アクセサのみを公開するか、constデータへのポインタを返して読み取り専用として参照させ、書き込みはモジュール内の関数に限定して、書ける場所を構造的に制限します。

チームで運用するチェックリスト

  • ヘッダでextern宣言されている変数が、いずれもその宣言が必要な理由を説明できるものに限られているかを確認します(説明できなければstatic化します)。
  • グローバル変数には命名規約(「g_」プレフィックスなど)を適用し、レビューで目立たせます。
  • ISRから触る変数の一覧を設計書に持ち、volatileと保護方法をセットで明記します。
  • 静的解析ツールで「外部結合の変数一覧」を定期的に出し、増加を監視します。

まとめ

グローバル変数対策は「禁止」ではなく、スコープ最小化(static格下げ)→カプセル化(アクセサで書き込み集約)→共有時の保護(volatileと排他、またはキュー化)の3段階で進めるのが現実的です。新規コードはこの形で書き、既存コードはexternの棚卸しから始めます。それだけで「いつの間にか値が変わっている」という種類のバグの調査時間が目に見えて減ります。

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