C言語で困ったときはアセンブリを読もう

ブログ

わたすけです。C言語で困っていませんか?今そうでなくとも、困ったことがある人は多いでしょう。

今回は、そんな困ったときの打開策、「アセンブリを読む」ということを紹介していこうと思います。デバッガ以外の選択肢もあるんだよということで見てもらえれば幸いです。

スポンサーリンク

きっかけ

きっかけはとあるDiscord鯖。C言語のソースコードが流れてきました。

#include <stdio.h>

int main(void) {
  int a = 0;
  a = a++;
  printf("%d\n", a);
  return 0;
}

このコードの実行結果はどうなるでしょうか。

a = a++のところでは、

  1. a++は優先度が低いので、aにaが代入される(a=0)
  2. a++が実行される(a=1)

という処理が行われ、1が出力されそうです。

しかし、実際はこうなります。
(-Wallを付けていますが、clangは付けなくても警告を出してくれます)

1じゃなかった

0が出力されているのがわかるでしょうか。

「aに対する演算は定義されていません」とはどういうことでしょうか。ということで、アセンブリを読んでいきたいと思います(???)

ちなみにこれはどうでもいいのですが、++aにすると1になります。

そもそもアセンブリとは?

機械語とC言語の中間です(怒られそう)

機械語を多少人間向けにしたものみたいな感じで良いと思います。C言語はコンパイルによってアセンブリに変換され、このアセンブリがアセンブラによってアセンブルされることによってオブジェクトファイルが作られます。

アセンブリを読むには?

こんな感じのコマンドでいけます。

$ gcc -c a.c
$ objdump -Mintel -d a.o

gcc -Sじゃないのかよ!と思った方もいると思います。許してください。

-c オプションによって、リンク直前のオブジェクトファイルを出力しています。オブジェクトファイルは他のいろんな関数や終了処理などをリンクされることによって実行形式となりますが、今回はmain関数を読みたいだけなので実行形式ファイルである必要はないです。コンパイルのみ行うことによって見やすくなるので試してみてください。

そして、objdumpでアセンブリを表示します。-Mintelはアセンブリの記法をintel形式にして出力するオプションです。AT&T形式もありますが、個人的にはintelのほうが読みやすい気がするのでこうしてます。
-dでディスアセンブルしてもらうようにお願いします。アセンブルがアセンブリを機械語に変換するのに対し、ディスアセンブルは逆です。機械語をアセンブリに変換します。

アセンブリが出力されている様子

こんな感じです。同じソースファイルでも、コンパイラによって出力されるアセンブリが違うわけです。

以下、clangを使って解説しようと思います。

もう少し読みやすくする

このままだと、C言語のどの行がどの命令に対応するのかわかりません。

ということで、読みやすくするために、コマンドをこんな感じにします。

$ clang -c -g a.c
$ objdump -Mintel -dS a.o

-gはおなじみ、デバッガ用の情報を埋め込むみたいなオプションです。そして、objdumpは-dSと、Sがくっついています。
objdumpのヘルプを見ると、「Intermix source code with disassembly」らしいです。-gオプションを付けてコンパイルされたファイルであれば、対応するソースコードを表示してくれます

実行結果はこの通り。

ソースコードが見える

a = a++;に相当する命令郡は、16~21らしいですね。そして、int a = 0;の命令から、[rbp-0x8]みたいな命令は変数aのことみたいな気がします。

それでは、16~21を見ていきましょう。

読んでみる

mov    eax, DWORD PTR [rbp-0x8]
mov    ecx, eax
add    ecx, 0x1
mov    DWORD PTR [rbp-0x8], ecx
mov    DWORD PTR [rbp-0x8], eax

アセンブリ用のシンタックスハイライトありませんでした。許してください。

mov命令は代入文です。eax, ecxというのはレジスタです(レジスタはCPUが持つメモリみたいなもの)。eaxレジスタというのは変数Aで、ecxレジスタというのは変数Cであると考えればわかりやすいかもしれないです。そして、DWORD PTR [rbp-0x8]という部分は変数aのことです。
mov ecx, eaxというのは「C = A」、すなわち「ecxレジスタにeaxレジスタの中身を代入しろ」という命令であるわけです。

add命令はその名の通り、加算命令です。add ecx, 0x1で「C += 1」、すなわち「ecxに1を足せ」という命令ということです。

以上のことから、アセンブリを見ると、以下の順番に処理が行われていることがわかります。

  1. A = a  (a == 0なので、A=0)
  2. C = A  (A == 0なので、C = 0)
  3. C += 1  (C = 1)
  4. a = C  (C == 1なので、a = 1)
  5. a = A  (A == 0なので、a = 0)

なんということでしょう。インクリメントも何もされていないAが最後に代入されています。4でaに1が代入されますが、5で0が代入されているため、2~4は全く無意味だったということです。

終わりに

こんな感じで、レジスタの動きを追っていくことで見えることがあるかもね、ということでした。可能な限りわかりやすいように薄めたつもりですが、どうだったでしょうか。

そもそもa = a++なんて式を書くべきではないし、多くのケースではデバッガを使えば解決しそうなものではありますが、そうであってもこういうやり方を覚えておいて損はないと思います。

余談

コンパイラに最適化オプションを渡すと、また違った処理になります。O0では変わりませんが、O1だとa = a++が常に0であることを検出し、さらに変数aがprintf以外に使われていないため、変数aの宣言すら完全に消して「printfで0を表示するだけのプログラム」を出力してくれます。

最適化あたりはかなり面白いので、色々試してみてください。

コメント

タイトルとURLをコピーしました