アセンブリ解読 その1

簡単なアセンブリを読めるようになりたい。理由はカッコいいから。というのは嘘で、どういう最適化が行われたかある程度わかるようになりたい。

だが、面白いコードが書けるようになるまでにアセンブリだと道のりが遠すぎるので、コンパイラに簡単なコードを吐かせて読んでわかるようになっていくことを目指す。というか、本来の目的は最適化で何が起きたかわかるようになることなので、最短距離といえばそうではある。

解読:ただの足し算、最適化なし

ではやっていこう。とりあえず入門書の1, 2章分くらいは読んだ前提とする。 つまり、ちゃんと背景から使われ方まで理解していて書けるわけではないが、ごく基本的な用語程度は頭に入っているという非常に都合の良い設定である。入門書として私は「64ビットアセンブラ入門」(北山洋幸 著)なる本を買っていたので、書きながら読み進めている。ちなみにこの本はWindowsIntel記法だが、以下ではUbuntuGCCと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

この時点で、先に示した本で引数はrcxrdxに載せるとかいう話を聞いていた私は少し混乱している。edxは出てきているが書き込まれているし、ecxは使われていないからだ。 ただ、10: add %edx,%eaxedxに入っている値をeaxレジスタ、つまり本に書いてある返却値を入れておくためのraxレジスタの下位32bit分に足しこんでいるのだということがわかり、取っ掛かりが得られて少し落ち着いた。ゆっくり見ていこう。

で、10:時点でedxeaxabを格納していることが想像される。そしてそれぞれが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にコピーされ、最後にedxeaxに加算される。最終的に加算の結果が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

この記事によると、どうやらWindowsLinuxの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に格納されている。 非常にシンプルだ。むしろこっちの方がわかりやすいというか、直感的だ。

とりあえず今回はここで終える。読んだだけだが、以外と知識が増えた。気が向いたら続けようと思う。

ここまででリンクを貼ったものの他に、参考にした記事は以下。

アセンブラに手を出してみる - Qiita