c++の未定義動作を利用してコンパイラと最適化オプションを推測

この記事はKMC Advent Calendar 2019 - Adventar20日目の記事です。
adventar.org
前回の記事はpastakさんのウェブページの表示を遅くなくしたい時の道標 - ぱすたけ日記でした。

はじめに

こんにちは、id:kazakamiです。今年は自作基板を発注して自作キーボード作ったりしていて、そのことをアドベントカレンダーに書こうかとも思いましたが、ググれば情報結構出てきそうなので別の話題を書くことにしました。
さて、みなさんは書いたコードがコンパイラや最適化オプションの違いによって想定通り動いたり動かなかったりした経験はあるでしょうか?そのような事故はコンパイラのバグを踏んでいるのでなければ、未定義動作に起因することが多いと思います[要出典]。今回はそんな未定義動作を利用してコンパイラや最適化オプションを推測してみます。

出来上がったソースコードがこちらになります

#include <iostream>

int uninit() {
  int x;
  return x;
}

void piyo() {
  int x = 56562;
  return;
}

int main() {
  piyo();
  int i = uninit();
  // std::cout << i << std::endl;
  if (i == 0) {
    std::cout << "Optimised by gcc" << std::endl;
  } else if (i != 56562) {
    std::cout << "Optimised by clang" << std::endl;
  } else {
    std::cout << "No Optimised" << std::endl;
  }
}

gcc/clangで最適化なし、gccで最適化あり、clangで最適化ありの3パターンを識別します。未定義動作を利用しているのでお手元の環境ではうまく動かないかもしれません。
検証環境

  • clang: Apple clang version 11.0.0 (clang-1100.0.33.8)
  • gcc: g++-9 (Homebrew GCC 9.2.0_2) 9.2.0

解説

今回はハマりがちな未定義動作No1[要出典]である未初期化の変数の値を用いる時の動作を利用します。関数uninit()は初期化してない変数xをそのまま返しています。そして関数main()から呼び出され、その値を変数iに代入しています。上記の識別する3つの場合について、このコードをコンパイルするとどのようなアセンブリが出力されるか見てみましょう。

gcc/clangの最適化なし

最適化せずにコンパイルするとgcc/clangともに同じようなコードが出力されます。gccコンパイルした場合の各関数のアセンブリの重要な部分を抜粋したものを見ていきましょう。
まずはpiyo()。

movl $56562, -4(%rbp)
ret

これはスタックの一番目(関数一番目のローカル変数であるxに対応する部分)に値56562を代入しています。
次にuninit()。

movl -4(%rbp), %eax
ret

スタックの一番目の値をレジスタeaxに代入しています。
最後にmain()の冒頭部分。

call __Z4piyov
call __Z6uninitv
movl %eax, -4(%rbp)
cmpl $0, -4(%rbp)
jne L6
(中略)
jmp L7
L6:
cmpl $56562, -4(%rbp)
je L8
(中略)
jmp L7
L8:
(中略)
L7:

piyo()とuninit()を呼び出した後にeaxレジスタの値を用いて条件分岐しています。main()からはpiyo()を呼んで戻ってきた直後にuninit()を呼んでいるので、uninit()の実行時にはスタックの一番目に56562が入っている状態となって、eaxには56562が代入されて帰ってきます。元のソースコードの条件分岐に従い"No Optimised"が出力されます。

gccで最適化あり

出力されるアセンブリを見ると条件分岐は消えて、"Optimised by gcc"を出力する部分のみ残っていました。
また元ソースコードコメントアウト部分のコメント化を解除して変数iの内容を出力させると、

xorl    %ebx, %ebx

という部分が出現し、ebxの値を出力するようになっていました。
これは自分自身とのxorをとって自分自身に代入するという意味で、レジスタの値を0にするのによく用いられるコードです。
どうやらgccでは最適化オプションを有効にすると、未初期化の変数の値は0とするようです。

clangで最適化あり

gccの場合と同様条件分岐が消えて"Optimised by gcc"を出力する部分のみ残ってましたが、よりヤバイ最適化をするようです。例えばこんなコードをclangで最適化ありでコンパイルしてみてください。

#include <iostream>

int main() {
  int i;
  std::cout << i << std::endl;
  if (i != 0) {
    std::cout << "i is not 0" << std::endl;
  } else {
    std::cout << "i is 0" << std::endl;
  }
}

これを実行すると

-394856040
i is 0

などと表示されると思います。未定義動作なので、iは出力すると0以外の数値になるけど(i != 0)はfalseになっても良いわけですね。どうやら条件分岐のどの部分を残すかは順番だけ見てそうなので、元のソースコードでは色々と条件式の順番を変えて"Optimised by clang"が残るようにしました。

おわりに

この記事を書くにあたって変なコードをいろいろな条件でコンパイルしてみましたが、未定義動作を含むコードの最適化は奥が深いです。
未定義動作は本当に未定義なので何が起こるかわかりません。値を出力すると0じゃないのに!=0は満たさなかったりします。表示のような値に変更をもたらさない処理を追加しても挙動が変わったりします。printfデバッグはもはや無力です。
もしこのような”怪現象”に遭遇したら、霊障を疑う前に未定義動作を踏んでないか落ち着いてコードを見直しましょう。

次回の記事はtenさんの 絶対に遅刻しません です。もし遅刻したら木の下に埋めます。