簡単なアセンブリを読めるようになりたい。理由はカッコいいから。というのは嘘で、どういう最適化が行われたかある程度わかるようになりたい。
だが、面白いコードが書けるようになるまでにアセンブリだと道のりが遠すぎるので、コンパイラに簡単なコードを吐かせて読んでわかるようになっていくことを目指す。というか、本来の目的は最適化で何が起きたかわかるようになることなので、最短距離といえばそうではある。
解読:ただの足し算、最適化なし
ではやっていこう。とりあえず入門書の1, 2章分くらいは読んだ前提とする。 つまり、ちゃんと背景から使われ方まで理解していて書けるわけではないが、ごく基本的な用語程度は頭に入っているという非常に都合の良い設定である。入門書として私は「64ビットアセンブラ入門」(北山洋幸 著)なる本を買っていたので、書きながら読み進めている。ちなみにこの本はWindowsでIntel記法だが、以下ではUbuntuでGCCとobjdumpを使うためAT&T記法になるから注意だ。
とりあえず演算を一つだけしてみよう。
#include <stdint.h> int32_t add(int32_t a, int32_t b) { return a + b; }
このようなCコードを以下のようにコンパイルし、逆アセンブルして見てみる(初めからアセンブリを吐かせれば良いのではというのはある)。
$ gcc -pedantic -std=c99 -O0 -c addition.c && objdump -d addition.o
するとこうなる(初めは-gオプションをつけてみたがあまり参考にならないので外した)。
addition.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <add>: 0: push %rbp 1: mov %rsp,%rbp 4: mov %edi,-0x4(%rbp) 7: mov %esi,-0x8(%rbp) a: mov -0x4(%rbp),%edx d: mov -0x8(%rbp),%eax 10: add %edx,%eax 12: pop %rbp 13: retq
この時点で、先に示した本で引数はrcx
とrdx
に載せるとかいう話を聞いていた私は少し混乱している。edx
は出てきているが書き込まれているし、ecx
は使われていないからだ。
ただ、10: add %edx,%eax
でedx
に入っている値をeax
レジスタ、つまり本に書いてある返却値を入れておくためのrax
レジスタの下位32bit分に足しこんでいるのだということがわかり、取っ掛かりが得られて少し落ち着いた。ゆっくり見ていこう。
で、10:
時点でedx
とeax
がa
とb
を格納していることが想像される。そしてそれぞれがa:
とd:
で値を代入されている。-0x8(%rbp)
はアドレスの記法で、rbp
に入っているアドレスから8引いたメモリ位置へのアクセスだ。
ローカル変数ならスタックに積まれるだろうしそこを指すのはrsp
だと聞いていたけど? と思って遡ってみると、1: mov %rsp,%rbp
となっている。
これはrsp
の中身をrbp
にコピーしているようだ。よくわからないのだが、rbp
は変更してはならないレジスタではなかったか。
もう少し遡ると、最初にpush %rbp
としてrbp
の内容をスタックにプッシュしている。そして最後にpop %rbp
で積んだ値をpopしているようだ。なぜこんなことをしているのかは知らないが、rbp
の値は最終的には確かに復旧していそうに思える。
調べていればそのうちわかるようになるだろうしあまり深く考えずに続きを見ていこう。1:
の時点でrbp
の内容がrsp
の内容と等しくなった。なので4: mov %edi, -0x4(%rbp)
ではedi
の内容をローカル変数に格納していることになる。次もesi
について同じことをしている。
ということは、この2つのレジスタが第一、第二引数を格納していたのだろう。そして引数を格納したローカル変数からedx
に1つ目の引数がコピーされ、2つ目の引数がeax
にコピーされ、最後にedx
がeax
に加算される。最終的に加算の結果がeax
に戻り値として格納された状態で、制御が戻る。
本で「第一引数はRCXに、第二引数はRDXに」と書かれていたのでまだ困惑しているが、そう考えると筋が通るので仕方がない。
グダグダと思いついたことをそのまま書いてしまってわかりにくくなっているので一度整理しよう。
addition.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <add>: 0: push %rbp ; ベースポインタの値をスタックへ退避 1: mov %rsp,%rbp ; スタックポインタの値をrbpへコピー 4: mov %edi,-0x4(%rbp) ; 第一引数をスタックにコピー 7: mov %esi,-0x8(%rbp) ; 第二引数もスタックにコピー a: mov -0x4(%rbp),%edx ; 第一引数が置かれたスタックからint32_tをedxへコピー d: mov -0x8(%rbp),%eax ; 第二引数からint32_tをeaxへコピー 10: add %edx,%eax ; edxの値をeaxへ加算 12: pop %rbp ; rbpレジスタの値を戻す 13: retq ; 制御を返す
一番困るのは、この解釈が正しいか知る術がないことだ。書いて動いたならもうすこし自信が持てるのだが。特に引数の位置。 少し調べていると、以下のような記事を見つけた。
Stack frame layout on x86-64 - Eli Bendersky's website
この記事によると、どうやらWindowsとLinuxのABIが異なり、Linuxは6つまでの引数をレジスタに置くが、Windowsは4つまでで、Winではrcx, rdx, r8, r9
が使われるらしい。そこがOSによって変わるとは全く思っていなかったので驚いた。
解読: 最適化(-Ofast)
さて、とりあえず片付いたと思うので、次は最適化の結果を見てみよう。
以下を実行する。
$ gcc -std=c99 -pedantic -Ofast -c addition.c && objdump -S -d addition.o > addition.asm
すると、
addition.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <add>: 0: lea (%rdi,%rsi,1),%eax 3: retq
流石に-Ofast
なだけあって短い。%eax
に戻り値が格納されるのは変わりないので、この0: lea (%rdi,%rsi,1),%eax
が単体で計算を済ませているようだ。
lea
命令というのを知らないので検索したところ、X86アセンブラ/データ転送命令 - Wikibooksによると、アドレスを計算しそのアドレスそのもの(値ではない)をdstにロードする命令らしい。だから32bit intなのに%rdi, %rsi
のように64bitレジスタとして使われているのだろう。
で、実際に計算されるアドレスは、これはアドレスの記法として、%rdi + %rsi * 1
になり、つまりは%rdi + %rsi
だ。ここで引数のint32_t
が(64bitの)アドレス値として足し算され、その結果が直接%eax
に格納されている。
非常にシンプルだ。むしろこっちの方がわかりやすいというか、直感的だ。
とりあえず今回はここで終える。読んだだけだが、以外と知識が増えた。気が向いたら続けようと思う。
ここまででリンクを貼ったものの他に、参考にした記事は以下。