ちょっと前アセンブラの話題が出たので、いまどきのアセンブラってどうなっているのだろうと調べてみました。gcc にはアセンブラを生成する機能があるそうなので、まずそれを使って雰囲気を見ます。
GCC を使ってアセンブラの雰囲気を知る。
/* hello.c */ #include <stdio.h> int main(int argc, char *argv[]) { printf("hello world\n"); return 0; }
$ gcc -S hello.c
としてコンパイルすると hello.s というファイルが出来ます。それに hello world, C and GNU as を参考にコメントをつけました。
.file "hello.c" /* 無視可能 */ .def ___main; .scl 2; .type 32; .endef /* 不要 */ .section .rdata,"dr" /* リードオンリーデータ。不要 */ LC0: .ascii "hello world\12\0" /* 文字列定数 */ .text /* プログラム開始 */ .globl _main /* リンカに見えるシンボルを宣言 */ .def _main; .scl 2; .type 32; .endef /* 不要 */ _main: pushl %ebp /* 初期化: フレームポインタを保存 */ movl %esp, %ebp /* 初期化: フレームポインタにスタックポインタ代入 */ subl $8, %esp /* 初期化: 一時変数の分(二つ)スタックポインタ減らす */ andl $-16, %esp /* 16 バイトにアライメント(0xFFFFFFF0 との and)。不要 */ movl $0, %eax /* 謎: eax = 0 */ addl $15, %eax /* 謎: eax += 15 */ addl $15, %eax /* 謎: eax += 15 */ shrl $4, %eax /* 謎: eax を四桁論理右シフト */ sall $4, %eax /* 謎: eax を四桁算術左シフト */ movl %eax, -4(%ebp) /* 謎: フレームポインタ-4 の位置に eax を代入 */ movl -4(%ebp), %eax /* 謎: eax に フレームポインタ-4 の値を代入 */ call __alloca /* 謎 */ call ___main /* 謎 */ movl $LC0, (%esp) /* 文字列のアドレスをフレームポインタの位置に代入 */ call _printf /* printf を呼び出す */ movl $0, %eax /* 0をアキュームレータに代入(返り値) */ leave /* スタックフレームを破棄 */ ret /* リターン */ .def _printf; .scl 2; .type 32; .endef /* 不要 */
これをアセンブルしてオブジェクトファイルを作り
$ as -o hello.o hello.s # アセンブル $ ld -o hello.exe /MinGW/lib/crt2.o -L/MinGW/lib/gcc/mingw32/3.4.5 hello.o -lmingw32 -lgcc -lmsvcrt -lkernel32 # リンク
でアセンブルとリンク。リンクのやり方は gcc -v を参考に要らないライブラリを削って行きました。結局それでもこんなに沢山要ります。
アセンブラの読み方
- 命令の後ろに l が付くのは 32 ビットのしるし
- movl は代入命令。向きは C と逆でコピー元 → コピー先
- 算術演算では、答えは後ろのレジスタに入る。
- レジスタの頭には % が付き、即値やラベル参照の前には $ が付く(AT&T 記法というらしい)。
- %ebp: フレームポインタ
- %esp: スタックポインタ
- %eax: アキュームレータ(普通のレジスタとどう違うのか謎)。
- %ebx, %ecx, %dx, %edi, %esi: 普通のレジスタ
- -4(%ebp) のような記法は、アドレス ebp-4 の位置にあるデータを参照する。
アセンブラを調べるのになかなか良い資料が見つかりませんでした。決定版は Intel のマニュアルなのだろうけど、PDF だし、でかいし。比較的 WikiBooks の命令表がコンパクトで検索しやすかったです。普通アセンブラの開発者は何を参考にしてるのかな?知ってたら教えてください!
アセンブラのお約束
さて、いくら C がアセンブラと相性が良いと言っても、さすがに上のコードでは読みにくくて仕方が無いです。hello world, C and GNU as にすっきりさせた版が紹介されてあるので載せます。また、さらに意味が分かりやすく書き直した物も載せます。
/* http://www.halcode.com/archives/2008/05/11/hello-world-c-and-gnu-as/ よりコピペ */ .data LC0: .ascii "hello, world!\n\0" /* 文字列定数 */ .text .global _main /* リンカに見えるシンボルを宣言 */ _main: pushl %ebp /* 初期化: フレームポインタを保存 */ movl %esp, %ebp /* 初期化: フレームポインタにスタックポインタ代入 */ subl $4, %esp /* 初期化: 一時変数の分(一つ)スタックポインタ減らす */ movl $LC0, (%esp) /* 文字列のアドレスをフレームポインタの位置に代入 */ call _printf /* printf を呼び出す */ movl $0, %eax /* 0 をアキュームレータに代入(返り値) */ leave /* スタックフレームを破棄 */ ret /* リターン */
/* さらに分かりやすくしてみたつもり */ .data LC0: .ascii "hello, world!\n\0" /* 文字列定数 */ .text .global _main /* リンカに見えるシンボルを宣言 */ _main: enter $0, $0 /* スタックフレームを作る(一時変数の作成) */ pushl $LC0 /* 文字列のアドレスをプッシュ */ call _printf /* printf を呼び出す */ movl $0, %eax /* 0 をアキュームレータに代入(返り値) */ leave /* スタックフレームを破棄 */ ret /* リターン */
最後のやつはわりと人間に読めると思います。
- まず main 関数に入るとすぐに enter でスタックに一時変数を作ります。この場合変数は無いのでゼロです。もう一つのゼロの意味は良くわかりませんでした。enter で作った領域は最後の leave で開放されます。このような作法を ABI と言います。gcc で生成した版では速度を重視して %esp や %ebp をあれこれ弄って同じ事をしています。
- 次に pushl $LC0 で文字列のアドレスをスタックにプッシュします。今回は引数を一つしか使っていませんが、複数の場合は右から左にプッシュするそうです。という事は呼び出される側にとっては左から右に引数を取り出す事になります。この ABI は引数の数が決まっていない C 言語に便利な方式なので、cdecl (シーデックル) と呼びます。
- それから call _printf で printf を呼びます。なんでアンダースコアが付くのかは忘れました。システムコールとかぶらないようにだったかな? この場合 _printf を読んですぐに終わりなので省略してますが、cdecl の場合この後スタックを元に戻す必要があります。
- 関数の返り値は movl %0, %eax でアキュームレータに入れます。
- その後 leave で関数の領域を開放して戻ります。開放と言うと大げさですが、ようはスタックを戻すだけです。
スタックを関数呼び出しでどう使うかは http://www.unixwiz.net/techtips/win32-callconv-asm.html に図入りで分かりやすく書いてあります。
システムコールを使って書く。
http://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax#Communicating_directly_with_the_operating_system にシステムコールを使って Hello World を書く方法が書いてあるので真似してみます。
/* hello2.s */ .text pushl $-11 /* STD_OUTPUT_HANDLE (winbase.h) */ call _GetStdHandle@4 /* GetStdHandle 呼出 */ pushl $0 /* 返り値の分一つ空ける */ leal 4(%esp), %ebx /* %ebx = %ebp + 4 */ pushl $0 /* lpOverlapped: NULL */ pushl %ebx /* lpNumberOfBytesWritten: 4(%ebp) が WriteFile の返り値になる */ pushl $14 /* nNumberOfBytesToWrite: 14 出力文字数 */ pushl $LC0 /* lpBuffer: "Hello, world!\n" */ pushl %eax /* hFile: 標準出力 = GetStdHandle の返り値 */ call _WriteFile@20 /* WriteFile 呼出 */ popl %eax /* 一応返り値を掃除する */ pushl $0 /* 返り値 = 0 */ call _ExitProcess@4 /* ExitProcess 呼出 */ LC0: .ascii "Hello, world!\n"
システムコール(Win32API) を呼ぶには _関数名@引数サイズ と言う名前を使うのと、cdecl では無く pascal という ABI を使う以外大体普通の関数と同じです。
ここで工夫した点はは、さらに main 関数さえ省略していきなりアセンブラの頭から処理が始まるようにした事です。すると C のランタイムライブラリを使わないので実行ファイルが短くなります。参考資料によると _main: の代わりに _start: を使うと良いと書いてあったのですが、_start: と書いただけでは起動時にそこにジャンプしてくれませんでした。その代わり LC0: を後ろに持って来るとすぐソースの先頭から実行する事が分かりました。これは LL 的で分かりやすいです。
アセンブルとリンクはこうします。
$ as -o hello2.o hello2.s $ ld -o hello2 hello2.o -lkernel32 $ ./hello2 Hello, world!
参考
- 呼出規約 http://ja.wikipedia.org/wiki/%E5%91%BC%E5%87%BA%E8%A6%8F%E7%B4%84
- WikiBooks X86 教科書 http://en.wikibooks.org/wiki/X86_Assembly
- WikiBooks の hello world http://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax
- システムコールを使う http://en.wikibooks.org/wiki/X86_Assembly/GAS_Syntax#Communicating_directly_with_the_operating_system
- http://www.halcode.com/archives/2008/05/11/hello-world-c-and-gnu-as/
- Pentium マニュアル(なぜか本家 Intel にはリンクが無い) http://www.x86.org/intel.doc/386manuals.htm
- http://www.unixwiz.net/techtips/win32-callconv-asm.html
- Hello World コレクション http://journal.mycom.co.jp/column/helloworld/005/index.html
- as + ldによるアセンブラ版Hello World http://yaguchi.txt-nifty.com/blog/2006/07/as_ldhello_worl_5d68.html