Undefined C (C って難しい…)

Others

この前、ネットワークオーダーの 2 byte バッファをホストオーダーに変換する際にバグを出してしまいました。原因は C の未定義動作だったのですが、C の未定義動作およびその影響が気になったので調べてみました。

問題のコード

バッファの特定の部分がネットワークオーダーの int16_t のデータが入っているのでそれを取得して 32 bit 整数にいれることを意図していましたが、負の値 (ex: 0xff60) に対して符号拡張がおこなわれず、正の値 (0x0000ff60) が value_i32 の値となってしまいました。

何が問題だったのか

2 行目の

のところで、uint8_t* -> uint16_t* の読み替えを行っているのですが、この読み替えは strict aliasing 違反により、コンパイラはこのコードを無視してよいと考え、結果的に符号拡張コードが生成されませんでした。

strict aliasing

strict aliasing の詳細については ((翻訳)C/C++のStrict Aliasingを理解する または – どうして#$@##@^%コンパイラは僕がしたい事をさせてくれないの! を見てもらうのが一番良いです。

大雑把に言うと 2 個のエイリアス不可能なポインタ型が存在するときには、コンパイラはそれらを違うメモリを指していると仮定するというコンパイラの最適化機能になります。上記の例でいうと、uint8_t* を uint16_t* に読み替えて使う個所はコンパイラの機嫌次第でどう使われるかわからなくなります。

上記の記事によると C の規格上エイリアス可能なポインタとは以下となるようです。

  • signed, unsigned, volatile の組み合わせが異なるもの
  • struct, union の要素となるポインタ型
  • struct, union の要素にポインタ型があればそのポインタは struct, union の外にあるポインタと同じメモリを指すことが許される
  • char*, singed char*, unsigned char*
  • これらは別のポインタのメモリを指してよい

エイリアス不可能なポインタが同じメモリを指しているのは C の未定義動作となります。

正しいコード

上記のコードは明示的にバッファから 2 byte 整数を構成することで修正しました。

未定義動作は何を引き起こすのか

C の規格書を見ると、

3.4.3 undefined behavior より
behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this International Standard imposes no requirements

とあり、ここからわかることはコンパイラは未定義動作があれば何をしてもよいということです。
つまり、コンパイル時にエラーを出そうが、実行時にクラッシュして終了しようが、ただ単にそこにあるコードを無視しようがなんでも自由となります。

未定義動作の一覧

未定義の動作 に未定義の動作の一覧があります。C には数を数えるのも嫌になるぐらいの未定義動作が存在します。

その中には

空でないソースファイルが、改行文字で終了していない場合。

とあり、C の規格的にはソースファイルが改行文字で終了していない場合には、そのコードはいきなり “HOGE” と言い出そうが、長々と沈黙したあげく “42” と出力しようが自由ということになります。
(どおりでソースファイルの末尾に改行がない警告を出されるわけですね)

未定義動作の実例

C言語分かってなかった (I Do Not Know C) には未定義動作も含めた C の落とし穴が多数紹介されています。また、 The Old New Thing: Undefined behavior can result in time travel (among other things, but time travel is the funkiest) には未定義動作によって発生した強烈なコード消失バグが紹介されています。また、JPCERT: MSC15-C. 未定義の動作に依存しない にも符号付整数のオーバーフローに関するコード除去の実例が紹介されています。

C言語分かってなかった (I Do Not Know C) より

(2) NULL ポインタの値取得

x が NULL のときは bar() がよばれないはずですが、コンパイラによっては NULL で呼んでも bar() が呼び出されます。
(1) のコードがあることで、コンパイラは x が NULL の場合は (未定義動作なので) 自由にコードを生成してよいと考え、最適化のために (2), (3) を除去します。結果的に実行されるコードは x が NULL のときも bar() が呼び出されます。

(7) strict aliasing 違反

このコードの実行結果の期待値は

ですが、コンパイラによっては

にもなりえます。

f の引数 i と j は違う型のポインタのため、違うメモリを指していると仮定できます。コンパイラがこの仮定を行ったときには (2) の代入は (3) に影響しないと判断して、(3) の printf に投入する値を (2) より前にメモリから取得することができます。

The Old New Thing: Undefined behavior can result in time travel (among other things, but time travel is the funkiest) より

常に true と判定される。

このコードはコンパイラの機嫌次第では以下に最適化されます。

なぜなら、i が 4 のときに table 配列の要素外の値を取得し、これは未定義動作のためコンパイラがどんなコードを生成してもよいからです。i が 0-3 のときの合法な処理によりこの関数は true が返る可能性があるため、最適化の結果常に true が返る関数が生成されます。

先行コード除去 (C++ だけ?)

このコードは以下のようにコンパイルしてよいです。

なぜなら、value_or_fallback を nullptr で呼び出すのは NULL ポインタの値取得の未定義動作を引き起こします。未定義動作を引き起こすような入力 (door_is_open が false の場合) に対する処理はどうなっても規格上問題ないので、一番簡単な walk_on_in() だけを呼ぶコードに最適化されます。
コード通りに実行されれば ring_bell() で何らかの警告を受け取れてからクラッシュするはずなのですが、実際に実行されるコードは何の警告もなく常に walk_on_in() が実行されるものになります。

ただしこれは (たぶん) C++ だけの話だと思われます。C++ の規格の 1.9 節には以下の記述があり、未定義動作になる入力が引き起こす処理がある場合は未定義動作部分を含めて何をしてもよいとのことです。

However, if any such execution sequence contains an undefined operation, this International Standard places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation).

C はそこまでアグレッシブな記述をしていないので、(該当コードおよび後続コード除去はともかく) 先行コード除去はしないものと思われます。

JPCERT: MSC15-C. 未定義の動作に依存しない より

これは a に INT_MAX に近い値を入れれば assert に引っかかる…と思ってはいけません。a + 100 が a より小さくなるのは a + 100 がオーバーフローした場合であり、それは未定義動作になるためにコンパイラーはそのケースを無視してもよいです。実際に

GCC 4.1.1 はすべての最適化レベルでアサートを除外し、GCC 4.2.3 は -O2 レベル以上の最適化でコンパイルされたプログラムのアサートを除外する。

とのことです。

なお、符号なし整数の場合にはオーバーフローも未定義動作ではありません。なぜなら、C の規格上未定義の動作はオーバーフローしないからです。C 規格書の 6.2.5 の 9 項には

A computation involving unsigned operands can never overflow, because a result that cannot be represented by the resulting unsigned integer type is reduced modulo the number that is one greater than the largest value that can be represented by the resulting type.

と “剰余をとるからオーバーフローしない” と書かれています。

演算にかかわる未定義動作

未定義の動作 から演算にかかわる主な項目を抜き出してみると以下になります。

  • /演算子の第2オペランドの値が0の場合。%演算子の第2オペランドの値が0の場合。(6.5.5)
  • シフト演算子の右オペランドの値が負の場合。または右オペランドが整数拡張した左オペランドの幅以上の場合。(6.5.7)

また、JPCERT: MSC15-C. 未定義の動作に依存しない で見られるように符号つき整数のオーバーフローも未定義動作となります。オーバーフローを引き起こす演算の演算子は以下となります。

  • +, -, *, /, %, ++, –, <<, -(単項演算子)

配列にかかわる未定義動作

未定義の動作 から配列にかかわる主な項目を抜き出してみると以下となります。

  • 配列オブジェクトの要素を指すポインタと整数型を持つ式の加算あるいは減算の結果が、同じ配列オブジェクトの要素、または配列オブジェクトの最後の要素を1つ越えたところを指していない場合。(6.5.6)
  • 配列オブジェクトの要素を指すポインタと整数型を持つ式の加算あるいは減算の結果が、同じ配列オブジェクトの最後の要素を1つ越えたところを指している場合で、その結果を評価される単項*演算子のオペランドとする場合。(6.5.6)
  • 2つのポインタを減算する場合において、その両方のポインタが同じ配列オブジェクトの要素か、その配列オブジェクトの最後の要素を一つ越えたところを指していない場合。(6.5.6)
  • 配列の添字が範囲外の場合。※配列が int a[4][5] と宣言されていて、左辺値の式に a[1][7] とあるような、指定された添え字でオブジェクトに明らかにアクセス可能と思われても未定義の動作となる。(6.5.6)

まとめると以下となります。

  • 値の取得は配列として宣言した範囲までしか行えない
  • 配列の要素を示すポインタは配列の最後の要素 + 1 までしか指せない

これらの配列のアクセスミスはやってしまいがちなのですが、コンパイラに見つかると未定義動作として予測不可能な挙動を引き起こします。

フレキシブル配列メンバは未定義ではない

上記のように配列の範囲外アクセスは未定義動作なのですが、構造体の最後のメンバを空配列にし、malloc 時に構造体サイズ以上の領域を確保して最後のメンバを不定長配列として扱う (フレキシブル配列メンバ) 場合には例外となります。以下のようのコードは合法です。

ただし、以下のように配列サイズを宣言してしまうと未定義動作となります。

まとめ (未定義の動作を避けるには)

未定義動作は星の数ほどあって全てを覚えておくのは難しいのですが、未定義動作によるコード消失に悩まないためには、強引なコード (型があわないとか計算結果がオーバフローするとか) を書かないことが重要だと思います。

特に以下はカジュアルに (バグとして非意図的に記述する場合も含む) やってしまいがちのため気を付けたいところです。

  • ポインタを読み替えない
  • ただし unsigned, signed, volatile だけは読み替え可能
  • 演算はオーバーフローさせない
  • オーバーフローがからむトリッキーなコードを書く場合は符号なし整数で行う
  • 配列の範囲外にアクセスしない
  • ただしフレキシブル配列メンバは例外
  • NULL チェックの前にそのポインタを使うコードは書かない

参考サイト

この記事を作成するにあたって以下のサイトを参考にしました