自力で関数を呼ぼう帰ろう
まさにこういう面白さを伝えたいわけだけど。
http://d.hatena.ne.jp/paella/20070125/1169651616
コンパイラについては、 C は宣言してない関数は int func(...) 扱いしたと思いますので、まぁ問題無いかと思われます。そもそも互換性の無いポインタ同士の変換も C 的にはたしか warning 止まりかなぁと。
でそれはともかく、やっぱ自分はそうだったんですが、アセンブリ覚える時に一番障害になったのは関数呼び出しと帰るところなので、それを自力でやってみようという話。こういうの慣れておかないとどうもアセンブリ読む時も書く時も障害になる気がする。アセンブリ知らない子、特に書いてみようと思ったけど gcc -S して愕然としたような人向けです。
まず呼び出し。
printf("%d\n", 3);
とか自分で書きたいなー、と思って gcc -S して出力を見るわけです。で、
main: leal 4(%esp), %ecx andl $-16, %esp pushl -4(%ecx) pushl %ebp movl %esp, %ebp pushl %ecx subl $20, %esp movl $3, 4(%esp) movl $.LC0, (%esp) call printf addl $20, %esp popl %ecx popl %ebp leal -4(%ecx), %esp ret
とかを見て、投げ出す人は多いと思います。 call printf が関数呼び出しなのはわかるけど、その前何やってるの、と思うわけです。こういう時は慣れるまでは前後に nop を入れてみることを推奨します。
追記: コメントで良くね?という指摘をいただきました(http://mkosaki.blog46.fc2.com/blog-entry-315.html)。まさしくその通りですなーと。つまり私は gas のコメントってどれだっけ ; じゃなくて # と /**/ なんだよな…とかいうことすらきちんと把握してないというか。
__asm__("nop"); printf("%d\n", 3); __asm__("nop");
こんなのをコンパイルすると、
#APP nop #NO_APP movl $3, 4(%esp) movl $.LC0, (%esp) call printf #APP nop #NO_APP
うむ関数呼び出しの場所がしぼれました。要は、これでやっと 4(%esp) とかがスタックに積むってことなんだろうなーとかわかるわけですが、ぶっちゃけどれが第一引数なんかとかサッパリです。これって何してるかっていうと、スタックに積むのに push 連打するよりインデックス指定して置いちまえば速いよねとかそういうことしてるわけです。つーわけでちゃんと効率良いバイナリ吐いてくれてるような GCC のことは忘れましょう。人力で書くなら push が楽です。例えばこんな感じ。
char fmt[] = "%d\n"; int main() { __asm__("push $3;\n" "push %0;\n" "call printf;\n" "pop %%eax;\n" "pop %%eax;\n" ::"g"(fmt)); }
こんくらいならわかりやすいような気がするかなぁと思います。第二引数を積んで、第一引数 (fmt は "g"(fmt) で %0 に渡ってきている) を積んで、 printf を呼んで、あとスタックのサイズそろえるために pop 二回、です。
んで、 call に頼ってるようでは自力とは言えません。みんな大人なんだから call くらいひとりでできるはずです。 x86 の呼び出し規約では、第一引数の次っていうかスタックの一番てっぺんに関数の戻る先を push しておくことになってます。つーわけで自分でするならこんな感じ。
char fmt[] = "%d\n"; int main() { __asm__(" push $3;\n" " push %0;\n" " push $return_addr;\n" " jmp printf;\n" "return_addr:\n" " pop %%eax;\n" " pop %%eax;\n" ::"g"(fmt)); }
return_addr ってのがラベルで、まぁそれを push してから jmp すれば、 ret でよろしく帰ってくれるわけ。
次は関数から自力で帰ってみよう。また gcc -S とかするとおどろおどろしい文字列が…本当に必要なのはどこなの!って感じになるわけです、が、ぶっちゃけこんだけでいいです。
.globl main main: ret
これを gcc ret.s とかしたらコンパイル & 実行できるはずです。これならできるよね!
でまぁ戻り値返してみましょうか。戻り値は int とかの場合はスタック使わずにレジスタの eax っていうのを使うことになっています。
i@u ~/test> cat ret.s .globl main main: mov $123, %eax ret i@u ~/test> gcc ret.s i@u ~/test> ./a.out i@u ~/test> echo $? 123
こんな感じ。なんだ簡単じゃんと。
引数でも使ってみますか。なんか argc をそのまんま返すとか。
i@u ~/test> cat ret.s .globl main main: pop %edx # 一番上に入ってるのは戻りアドレス pop %eax # これが argcのはずで EAX に入った push %eax # お行儀よくスタックを戻しておく push %edx # お行儀よくスタックを戻しておく ret i@u ~/test> gcc ret.s i@u ~/test> ./a.out i@u ~/test> echo $? 1 i@u ~/test> ./a.out hoge hage hige i@u ~/test> echo $? 4
push pop だけでやったら簡単簡単。要はスタックの状態変えなきゃいいわけです。
最後に ret に頼らず自力で戻る話を。 ret は call の逆みたいな命令で、戻りアドレスを pop して、そこに飛んでます。だから同じことをすればいい。
.globl main main: pop %edx # 一番上に入ってるのは戻りアドレス pop %eax # これが argcのはず push %eax # お行儀よくスタックを戻しておく jmp *%edx # %edx は戻さなくていい
さっきと違うのは %edx を push して戻してないってこと。 ret は popして jmp すりゃいいので、まぁそいうことです(すごいめんどうになってきた)。
補遺1:
呼び出し規約としてあと重要なこととして、破壊していいレジスタの話があるかと思います。 x86 では EAX と ECX と EDX は破壊していいです。逆に言うと ESI と EDI と EBX は破壊すると呼び出し前の関数が混乱する可能性があります。 ESI とかを使いたい場合は、
# 関数の最初 push %esi # ... pop %esi # 関数の最後
って感じで push して保存しておく、ってのが楽だと思います。
補遺2:
GCC の吐いたコード見ると push pop のかわりに (%esp) への操作が使われてるわけですが、 %esp はスタックポインタと言ってスタックの先っちょを指してるレジスタです。 push とか pop したらこれが勝手に動いてかつその指してる先に値が入るわけです。
http://d.hatena.ne.jp/shinichiro_h/20070124#1169569752
なんかを見ると、最初に出てくる二つのアセンブリを見比べると、
sub $12, %esp
と
push %edx push %eax push %ecx
が対応してるのがわかるかなと思います。 sub っていうのは引き算する命令で、ここでは esp から 12 を引いています。 12 っていう数字は int を 3 つなので 12Byte で、足し算じゃなくて引き算なのは、スタックは負の方向に成長する、っていう約束があるからです。
あとこの ESP ってヤツをいじってやると全然違う関数に戻るとかもできるわけでして、こーいう仕組みを知ってると shiro さんが Binary Hacks の序文に書かれていたように、継続とか自分で実装できるわけですね、と。
まとめ: