整数オーバーフローと未定義動作

「カウンタが突然マイナスになった」「最適化レベルを上げたらif文の挙動が変わった」。こうした怪現象の背後にいるのが整数オーバーフローです。C言語では、符号付きと符号なしでオーバーフロー時の扱いがまったく違い、片方は明確に定義された動作、もう片方は未定義動作です。本記事では、この違いと、コンパイラ最適化が絡んだときに何が起きるか、組み込みで頻出するパターンと防ぎ方を解説します。

符号なしは「定義済み」、符号付きは「未定義」

C言語の規格では、符号なし整数の演算は常に 2^n(nは型のビット幅)を法とする演算として定義されており、範囲を超えた結果はラップアラウンドします(桁あふれして先頭に折り返します)。32ビット(n=32)なら、最大値の次は0に戻る、という動きが言語仕様として定義されています。ただし、実際の演算は整数昇格等で型が変わった後の型でおこなわれます。結果がどの幅でラップするかは、最終的に適用される符号なし型の幅に依存します。

一方、符号付き整数のオーバーフローは未定義動作(Undefined Behavior、以下UB)です。「INT_MAXに1を足すと INT_MINになる」と思われがちですが、それは特定環境でたまたまそう見えるだけで、規格上は動作が保証されません。セキュアコーディングの標準であるSEI CERT C コーディングスタンダードの INT32-C(JPCERT/CCが日本語版を公開しています)でも、符号付き整数演算がオーバーフローを引き起こさないことを保証するよう求められています。

未定義動作×最適化=if文が消える

UBの怖さは、コンパイラが「それは起きない」という前提で最適化することにあります。有名なのがオーバーフロー検出のつもりで書いたif文が消える例です。

int i = /* 何らかの値 */;
if (i + 1 <= i) {
    /* オーバーフローの処理のつもり…… */
}

コンパイラは「符号付きのi+1がオーバーフローすることはない(=UBは起きない)」と仮定してよいため、この条件は常に偽と判断され、最適化でブロックごと削除され得ます。実際、最適化の有無で実行結果が変わる例が知られており、デバッグビルドでは動くのにリリースビルドで壊れる、という最悪の形で表面化します。

※同じ式でも unsigned なら i が UINT_MAX のときに真になり得ます。符号付きではオーバーフローがUBのため、この種の「オーバーフロー後の値で判定する」書き方は成立しません。

見落としがちな「整数昇格」の罠

もうひとつの伏兵が、intより小さい型は、intがその型の全値域を表現できる場合に演算前にintへ昇格されるというC言語の規則(整数昇格)です。次のコードは一見すべて符号なしですが、intが32ビットの環境ではUBになり得ます。

uint16_t a = 65535u;
uint16_t b = 65535u;
uint32_t c = a * b;   /* 危険! */
/* a と b は int(符号付き32ビット)に昇格して乗算される。
   65535 × 65535 = 4,294,836,225 は INT_MAX を超え、
   符号付きオーバーフロー=未定義動作になる。       */
uint32_t ok = (uint32_t)a * b;   /* 片方を広げてから掛ける */

「uint16_t同士の掛け算だから符号なしのはず」という直感が裏切られるポイントです。乗算や左シフトで桁が伸びる演算では、先に目的の幅へキャストしてから計算する癖をつけるとよいでしょう。

組み込みで頻出するパターン

  • ティックの経過時間計算は符号なしなら正しい:uint32_tのティックカウンタに対し「(uint32_t)(now - start)」と引き算すれば、カウンタが一周(ラップアラウンド)していても経過時間は正しく求まります。これは符号なしの引き算が 2^n を法として定義されているおかげで、約49日問題(32ビット・1 msティックの一周。2^32 ms ≒ 49.7日)への正攻法です。比較は「(uint32_t)(now - start) >= timeout」の形で書くのがコツです(now・start・timeout は同じ符号なし幅でそろえます)。「now >= start + timeout」と書くと、start + timeout がラップアラウンドして小さな値に折り返したときに判定が成立しなくなるため、この形は避けてください。
  • 積算値の幅不足:ADC値(12ビット、最大4095)の合計をuint16_tで受けると、わずか17サンプルで上限(65535)を超えます。平均化や積算は最初から広い型(uint32_tなど)でおこないます。
  • ループ変数とサイズ計算:要素数×要素サイズの掛け算、添字の減算など、サイズ計算は符号なしでおこないつつ「0を下回る引き算」がないか確認します。

防ぎ方:設計・コーディング・ツールの三段構え

  • 演算前にチェックする:加算なら「a > 上限 - b」、乗算ならまず「b != 0」を確認したうえで「a > 上限 / b」のように、オーバーフローする前に判定します(オーバーフロー後の値で判定するif文は前述のとおり消されます)。
  • 広い型・固定幅型で計算する:stdint.hの固定幅型を使い、中間計算は結果より広い型でおこないます。
  • コンパイラと動的検査を使う:警告を最大限有効にしたうえで、GCC(4.9以降)・Clangいずれも -fsanitize=undefined でUBSan(UndefinedBehaviorSanitizer)を有効にできます。符号付きオーバーフローに絞るなら -fsanitize=signed-integer-overflow を使うと、UBを実行時に検出して報告してくれます。GCCには挙動を変える -fwrapv や -ftrapv というオプションもあります。ただし、GCCでは -ftrapv と -fwrapv は互いに上書きし合うため、同時指定時は指定順にも注意してください(例:-ftrapv -fwrapv では -fwrapv が有効になります)。これらに依存した設計は移植性を損ないます。
  • コーディング規約で網を張る:MISRA Cなどの規約は暗黙の型変換を避ける方向のルールを持ち、静的解析ツールでこの種の問題を機械的に検出できます。

まとめ

整数オーバーフローは「符号なしは定義済み・符号付きは未定義」「小さい型はintに昇格する」の2つの規則を知っているかどうかで明暗が分かれます。経過時間計算のように符号なしのラップアラウンドを味方につける書き方と、符号付きを絶対にオーバーフローさせない防御の両方を、チームの共通知識にしておくとよいでしょう。

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