c++の未定義動作を利用してコンパイラと最適化オプションを推測
この記事はKMC Advent Calendar 2019 - Adventarの20日目の記事です。
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パターンを識別します。未定義動作を利用しているのでお手元の環境ではうまく動かないかもしれません。
検証環境
解説
今回はハマりがちな未定義動作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"が残るようにしました。