【C言語レベルアップ講座】組み込みエンジニアが絶対知っておくべき「整数変換」の罠と対策

<span id="hs_cos_wrapper_name" class="hs_cos_wrapper hs_cos_wrapper_meta_field hs_cos_wrapper_type_text" style="" data-hs-cos-general-type="meta_field" data-hs-cos-type="text" >【C言語レベルアップ講座】組み込みエンジニアが絶対知っておくべき「整数変換」の罠と対策</span>

はじめに:なぜ「整数変換」が重要なのか?

昨今、SDxSoftware-Defined XSDVSoftware-Defined Vehicle)という言葉に代表されるように、組み込み開発におけるソフトウェアの重要性はかつてないほど高まっています。ソフトウェアは企業の重要な「資産」です。

しかし、C言語は記述能力や融通性が非常に高い反面、「異なるマイコン(アーキテクチャ)への移植時に、互換性のない動作をしてしまう」リスクを秘めています。せっかく作ったソフトウェアも、バグを内包したままでは真の資産とは言えません。

移植時のトラブル原因として、常にトップ3に入るのが「整数変換(Integer Promotion / 整数拡張)」に関する問題です。特に8ビット/16ビットマイコンから32ビットマイコンへ移行する(あるいはその逆の)タイミングで、この問題は牙を剥きます。

また、機能安全(MISRA C)やサイバーセキュリティ(CERT CCWE)のガイドラインでも、整数変換に関する厳格なルールが定められており、これらを正しく理解して作り込むことは現代の組み込み開発において必須となっています。

1. イントロダクション:イントのサイズがもたらす移植の罠

まずは、以下の簡単なC言語のコードを考えてみましょう。

int a = 30000;

int b = 4;

int result;

result = a * b;

printf("%d\n", result);

一見、何の問題もない掛け算($30000 \times 4 = 120000$)に見えますが、この実行結果はマイコンのアーキテクチャによって異なります。

マイコンのビット幅

int のサイズ

計算結果

理由

32ビットマイコン (Cortex-Mなど)

4バイト (32ビット)

120000

32ビット幅があるためオーバーフローしない

8 / 16ビットマイコン (RL78など)

2バイト (16ビット)

-11072

16ビットの表現範囲(-3276832767)を超え、最上位ビットが溢れて符号反転する

このように、基本データ型(char, short, intなど)をそのまま使っていると、マイコンを変えただけで想定外のバグを生み出す原因になります。

組み込み開発の鉄則:

移植性を高めるため、基本データ型をそのまま使うのはやめましょう。C99規格から導入された <stdint.h> をインクルードし、サイズと符号が明確なデータ型(int8_t, uint8_t, int32_t, uint32_t など)を使用することを強く推奨します。

2. C言語の自動変換ルール:「整数拡張」と「定数(乗数)」の罠

2.1 整数拡張(Integer Promotion)とは?

C言語には、int より小さい整数型(char short など)は、演算を行う際に一度 int(または unsigned int)に自動変換されてから計算される」というルールがあります。これを整数拡張と呼びます。

例えば、以下のコードを見てみましょう。

signed char a = 4;

signed char b = 40;

signed char c;

c = a * b / 10;

算数として考えると 4*40= 160 となり、signed char の最大値(127)を超えているため、途中でオーバーフロー(桁落ち)しそうに見えます。しかし、C言語のルールにより a b は一時的に int32ビット環境なら32ビット幅)に拡張されてから掛け算が行われます。そのため、途中の「160」という値は正常に保持され、10で割った結果である 16 が正しく c に代入されます。

2.2 「定数」の書き方で型が変わる?

ソースコードに直接書く「10」や「0x80000000」といった定数(乗数)も、書き方(10進数か16進数か)によってコンパイラが解釈する型が変わります。

    • 10進数の場合: int $\rightarrow$ long int $\rightarrow$ long long int の順で、収まる最小の型にマッチングされます(符号付きが優先)。
    • 16進数の場合: int $\rightarrow$ unsigned int $\rightarrow$ long int $\rightarrow$ unsigned long int $\rightarrow$ の順でマッチングされます(符号なしも候補に入る)。

例えば、32ビットマイコンにおいて 214748364810進数)は long long int8バイト)として扱われますが、同じ値を16進数で 0x80000000 と書くと unsigned int4バイト)として扱われるケースがあります。後ろに U(例: 10U)などを明記しないと、思わぬ型ミスマッチを引き起こす原因になります。

3. 実例で見る!整数変換が引き起こす4つの怪奇現象

signed unsigned の比較

if (-1 < 100U) {

/* ここを通ると思っていませんか? */

} else {

/* 実際はこちら(else)を通ります */

}

 

-1(符号付き int)と 100U(符号なし unsigned int)を比較する場合、C言語の共通の型に合わせるルール(通常の算術変換)によって、-1 unsigned int に変換されます。 -1 を符号なしに変換すると、その型の最大値(32ビットなら 4294967295)になってしまうため、100U より大きいと判定されて else 側が実行されてしまいます。

if文と三項演算子の結果が食い違う

論理的には同じに見える処理でも、代入を挟むかどうか、あるいは三項演算子を使うかによって結果が変わることがあります。

// パターンA:一度代入を挟む

unsigned short tmp = 32777 + 0; // unsigned同士の演算

short s2 = tmp; // ここで符号付きショートに代入(負の値に化ける)

if (s2 > 32760) { ... } // 結果:偽(0)になる

// パターンB:三項演算子で直接比較

if ((32777 + 0) > 32760) { ... } // 結果:真(1)になる(unsignedとして比較されるため)

intermediate(中間)変数の型を意識しないと、このように挙動が変化します。

:ビット反転とシフト演算

8ビットの制御レジスタの値を操作する際によくあるミスです。

uint8_t a = 0x5A; // 2進数: 0101 1010

uint8_t result;

result = ~a >> 4;

組み込みエンジニアの脳内では「0x5A を反転して 0xA5、それを4ビット右シフトして 0x0A になる」と考えがちです。

しかし実際には、`~a`(ビット反転)を行う前に `a` が整数拡張されて `int` 型として評価されます。 さらに、この挙動は 処理系における `int` のビット幅(16bit / 32bit)に依存します。

例えば 32ビット環境では、以下のように計算されます:

  1.  `a` → `0x0000005A`(32ビットの int に拡張)

  2.  反転 → `0xFFFFFFA5`

  3.  ビット右シフト → `0x0FFFFFFA`

  4.  `uint8_t` への代入(上位ビット廃棄) → `0xFA`

また、16ビット環境では以下のようになります:

  1. `a` → `0x005A`

  2. 反転 → `0xFFA5`

  3. 右シフト → `0x0FFA`

  4. `uint8_t` への代入 → `0xFA`

このように、最終結果は同じでも、途中の計算過程は環境によって異なります。

脳内計算の `0x0A` と、実際の計算結果の `0xFA` で全く異なる値になってしまいました。

補足:符号付き整数に対する右シフトは処理系依存の挙動となるため、コンパイラによって結果が異なる可能性があります。

4. 整数変換のバグを防ぐための対策

これらのトラブルを防ぐためには、以下の5つの習慣を徹底することが重要です。

    • 演算の左右(右辺と左辺、あるいは演算子の両辺)の型を必ず揃える。
    • ビット幅(8, 16, 32ビットのどれで計算しているか)を常に意識する。
    • 定数(乗数)には U などのサフィックスを明記し、符号付き/なしを曖昧にしない。
    • 符号付き型(特に負の値)と符号なし型を混ぜて比較・演算しない。
    • 意図したビット幅で計算させるために「演算結果に対して適切にキャスト」を行う。(途中のオペランドへのキャストでは効果がない場合がある)

先ほどのビット反転の例であれば、以下のように演算結果に対してキャストを行うことで、8ビットマイコンと同じ意図通りの動作(0x0A)を保証できます。

// NG例:途中にキャストしても意味がない(整数拡張されるため)

result = ((uint8_t)~a) >> 4;

// OK例:演算結果にキャストする

result = (uint8_t)((~a)) >> 4

コンパイラの最適化とマクロの危険性

なお、実際の製品開発ではコンパイラの最適化が働くため、アセンブラレベルでこれらの符号拡張のステップを追いかけるのはほぼ不可能です。さらに、型を持たないマクロ(#define)を多用していると、整数拡張の罠を見落とす危険性が跳ね上がります。

5. 究極の対策:静的解析ツール(C-STAT)の活用

整数変換のルールは非常に複雑であり、これらをすべて人間の「目視確認(ソースコードレビュー)」でチェックしようとすると、確実に限界が来ます。特にグローバル変数やインクルードされたヘッダーファイル内のマクロが絡むと、人間の脳内だけでシミュレーションするのは困難です。

そこで最も有効なのが、静的解析ツールの導入です。

IAR Embedded WorkbenchEW)のユーザーであれば、強力な静的解析アドオンツールである「C-STAT」を内蔵させることができます。

C-STATで検出できる整数変換関連のルール例(MISRA C

C-STATを使用すると、脳が爆発しそうな複雑な暗黙の型変換を自動で検出し、警告してくれます。

    • MISRA C Rule 10.3 (暗黙の型変換の制限): 式の値を、より狭い基本型や異なるカテゴリーのオブジェクトに暗黙的に代入している箇所を検出します。(例: int から short への代入など)
    • MISRA C Rule 9.4 (三項演算子の型一致): 三項演算子の第2オペランドと第3オペランドで型が異なっている場合に警告を出します。
    • MISRA C Rule 10.4 (演算時の一致): A + B などの複合式において、より広い型(あるいは異なる型)のオブジェクトへ不用意に代入・キャストしている箇所を自動でリストアップします。

まとめ

C言語の整数変換は、組み込みソフトウェアを異なるマイコンへ移植する際の「見えない地雷」です。

    • 基本的な変換ルール(整数拡張、定数の型マッチング)を正しく理解すること
    • <stdint.h> による明確な型定義と、適切なキャストを行うこと
    • 人間の目だけに頼らず、C-STATのような静的解析ツールを賢く活用して機械的にバグを潰すこと

これらを徹底し、どのような環境でも正しく動く、真の「ソフトウェア資産」を構築していきましょう。

参考リファレンス:

より詳細な規格やルールについては、以下のサイトも併せてご参照ください。