この記事はtraP夏のブログリレー18日目の記事です。
こんにちは。19BのImperiです。普段はCTFとかやってます。
この記事ではCコンパイラ作成の中で出会ったgdbデバッグのあれやこれやをまとめていきます。まさかの今夏ブログリレーで自作コンパイラ記事2個目です
Cコンパイラ作成の経緯
製作中のCコンパイラがこちら
Rui Ueyamaさんのcompiler bookを読み、今年度の5月くらいから趣味でCコンパイラの制作を始めました。行き詰ったりバグったりしながらゆるゆる開発していたんですが、今年の夏に参加したセキュリティキャンプの中で様々な人と交流しモチベアップ → 8月上旬にセルフホストまで出来ました。
このコンパイラ作るにあたってセキュリティキャンプの方々(特にCコンパイラゼミの方々)には本来申し込んだゼミでもないのに本当にお世話になりました。この場を借りて感謝申し上げます。
今は標準ライブラリのincludeも含めて動くようにプリプロセッサや更なる機能を実装しています。
Q. なんでそもそも作ろうと思ったの?
A. 本能です。
(ref: https://trap.jp/post/1638/)
Q. なんでgdbデバッグするの?
A. なんかgdb使いこなすのカッコよくないですか
バグった!gdbでデバッグするぞ
ここではソースコードをgccでコンパイルしてできたバイナリを第一世代(mycc1)、mycc1でコンパイルしてできたバイナリを第二世代(mycc2)とします。
#0 前提: エラー、警告、ログをちゃんと出力する
とりあえずコンパイラの警告やlinterで単純なミスは防ぎましょう(結論)
実装したり規格について調べると、厄介な条件分岐や意外な文法に気付くことがあります。
とりあえずerror(fmt,arg)
やnot_implemented()
,assert()
的な関数を用意して、気付いた段階でその箇所に置いておくのが気持ち的にも楽です。僕の場合正味かなりのバグはこういったassertで防ぐor簡単に解決できました。
また仮にそのエラーメッセージから直接的な原因が分からなかったとしても、gdbデバッグする上で結構嬉しい事があります。デバッグ情報が付いていないバイナリにおいても関数の呼び出しは追いかけやすい(関数名のラベル、スタックフレーム)ので、errorの呼び出し元を見れば原因が分かりやすくなります。
#1 mycc1でのデバッグ
まずオプションで-gを指定して(gcc -g ...
)デバッグ情報付きでコンパイルします。
そうすればmycc1にデバッグ情報が付くため、ソースコードも型情報も...
綺麗に表示されて嬉しい! (なおgdbのプラグインとして pwndbg を利用しています)
特に型情報は変数を表示するときにgdbが見やすくしてくれるので、圧倒的にデバッグがしやすくなります。
これでmycc1がsegmentation faultで落ちるときも、gdb --args ./mycc1 test.c
で起動
run
で実行すればセグフォを吐く場所で止まり、対応するソースコードも表示されるため原因が掴みやすくなります。変数を確認したければprint (変数名)
でさきほどの型情報のように変数の型情報を利用した見やすい表示がされます。
また #0 で挙げたようなerror()で落とすような状況でも上手くデバッグすることができます。
ではデバッグの例として以下のコード(test.c)をmycc1でコンパイルし、#0で用意していたerror()が呼ばれて落ちた場合を見ていきましょう。(いい例思いつかなかった)
int main(){
int a,b;
a = 10;
b = 20;
return a+b;
}
gdb --args ./mycc1 test.c
でgdbを起動
error()が呼ばれて落ちるのでbreak error
でerror関数にブレークポイントを貼ります。
run
コマンドを入力すれば実行を開始して、無事error関数で処理が止まります。
この状態でbacktrace
を入力すると...
error()が呼び出されるまでの関数呼び出しの流れを見ることが出来ます。expectもただのassertionなので、その前のparse_external_declが怪しそうです。
frame 2
で対応する関数のスタックフレームに移動します。この状態で関数内でのローカル変数を表示してみましょう。
tok
は現在パース中のトークン列を管理するローカル変数です。tok->str
を見る限り
int a,b;
のコロンで落ちていそうです。また上の行に表示されていますが、これは
parse.cの134行目 expect(&tok,tok,";");
でexpectを呼び出しているようです。
ここまで情報が得られれば、実装の中でどのケースを考慮していなかったか分かりやすくなります。
ちなみに今回はint a,b;
のように複数の変数を宣言する場合(複数のdeclarator)を考慮していなかったのが原因でした。
今回はソースコードの構文解析を例にしましたが、文字列でデバッグしづらいコンパイラ内での型情報の処理とかも同様に変数を表示していけば、十分な情報が得られることでしょう。
#2 mycc2でのデバッグ
#1まではデバッグ情報付きのmycc1が舞台だったので基本的なコマンドさえ覚えれば直観的に表示できて手間がかかりませんでしたが、mycc2ではそうは行きません。mycc1にはgccの-gのようなデバッグ情報付きコンパイルは実装していないためソースコードとアセンブリの対応はもちろん型情報も持っていません。
こうなると関数名とメモリ上の値、四則演算と転送命令たくさんのアセンブリしか見れないのでちょっと辛い気持ちになります。
それでも何とかgdbで少しでも効率よくデバッグしたい...
ということでちょっとだけ楽になる方法を考えてみました。もっといい方法あるかもしれないので情報お持ちの方は教えてほしいです。
gdbではadd-symbol-file
で追加のシンボルテーブルをロードすることができます。
そこでgcc -g -c (欲しい型を利用しているソースコード)
でデバッグ情報付きのオブジェクトファイルを作成し、これをgdbの中でadd-symbol-fileでロードします。
異なるコンパイラ(gcc)で作ったdebug_info読み込んで何になるんだという気持ちになりますが、これでなんか型情報は持ってこれます。但しこれはgccでコンパイルした際のメモリレイアウトが記述された型情報なのでもしmyccが異なるレイアウトを採用していたら(たぶん)使い物になりません。
まぁでも大体同じレイアウトになるでしょ(投げやり)
これで型情報は持ってこれましたが、変数名やソースコードとの対応はこういう裏技では厳しいと思います。そもそもgccとmyccでコンパイル結果のアセンブリが違うし...
ただその型情報を利用していると明らかな場所では、これだけでもかなりデバッグしやすくなるのではないのかなと思います。例えばparse_hoge(Token *tok)
に対して
print *(Token *)$rdi
のようにprintすればTokenの型情報を利用してmycc1のときのように見やすく表示してくれます。
(まぁ実はセルフホストのときにはあまりバグらなかったから #2 で挙げた裏技ほとんど利用してないんですけどね)
まとめ
できる限りログとかassertとかテスト使って、未然にバグを防ごう。
それでも訳分からんバグが発生したら、この記事のようにgdbを利用してみてはいかがでしょうか。
後書き(言い訳)
実は #2 の後に 「#3 myccでもデバッグ情報を付けてみよう」ということでDWARF調べて、.debug_infoセクションを吐くようにしてみる計画を立てていたんですが、ブログリレーに間に合わないのでこの記事では断念しました。というか #3 書いてなくとも間に合ってません。計画性...
「#3 myccでもデバッグ情報を付けてみよう」はまたどこかで書きます。