hello, world
x86-64 の 64bit アセンブリプログラミングの全体像を見るために、有名な "hello, world" を表示するプログラムを x86 の 64bit Linux上でアセンブラを使って作成します。細かいところは気にせず、命令の詳細などは別に解説します。x86(32bit)Linux 上でアセンブリ言語が使える人はこのページだけで十分かもしれません。
x86-64 Linux版
ここでは NASMというアセンブラで直接 Linux カーネルのシステムコールを 使うプログラムを作成していきます。
まず最初に Linux でアセンブラ(nasm) を使ったプログラムを紹介します。 例によって、hello, world を表示するものです。
;------------------------------------ ; hello.s ; nasm -f elf64 hello.s ; ld -o hello01 hello.o ; ./hello ;------------------------------------ bits 64 section .text global _start _start: xor eax, eax mov edx, eax inc eax ; sys_write (01) mov edi, eax ; stdout (01) mov dl, len ; length (13) mov rsi, msg ; address syscall xor edi, edi ; return 0 mov eax, edi mov al, 60 ; sys_exit syscall section .data msg db 'hello, world', 0x0A len equ $ - msg
アセンブル、リンクは以下のコマンドで行います。
$ nasm -f elf64 hello.s $ ld -s -o hello hello.o
./hello で実行できます。
同等のプログラムの C バージョンは
/* helloc.c */ #include <stdio.h> int main(int argc, char* argv[]) { puts("hello, world\n"); return 0; }
gcc -o helloc helloc.c でコンパイル。 ./helloc で実行できます。C のほうが分かり易いですよね。
面倒なアセンブラでプログラムを作成して何がウレシイのでしょうか? ではプログラムの大きさを比較してみましょう。
-rwxr-xr-x 1 jun jun 608 2009-04-03 01:21 hello -rwxr-xr-x 1 jun jun 10820 2009-04-03 00:57 helloc
C言語版は10倍以上のサイズです。ストリップして余計な情報をのぞくと
$ strip helloc $ ls -l helloc -rwxr-xr-x 1 jun jun 6400 2009-04-03 00:58 helloc
かなり小さくなりました。
シェアドライブラリを使用しないでライブラリの必要な部分だけを実行ファイル に含めるような設定でコンパイルした場合には、コンパイル時にスタティックリンク を指定します。
$ gcc --static -o helloc hello.c $ ls -l helloc
-rwxr-xr-x 1 jun jun 700494 2009-04-03 01:00 helloc-static
ストリップすると
$ strip helloc-static $ ls -l helloc-static -rwxr-xr-x 1 jun jun 625920 2009-04-03 01:00 helloc-static
まだ620kバイト以上ですが、次のようにシェアドライブラリは不要です。
$ file helloc-static
helloc-static: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
for GNU/Linux 2.6.8, statically linked, stripped
このスタティックリンク版の helloc の実行に必要なのは Linux カーネルのみです。 ディストリビューションには関係無く、どこでも実行できます。
この hello.c のようなごく小さいプログラム1つだけの場合には、スタティック リンクしてもシステム全体のサイズに影響ありませんが、多くのプログラムを使う場合 (Linux のディストリビューション全体のように) には、シェアドライブラリを使用する ダイナミックリンクとするほうが全体として圧倒的に小さくなります。
さて、アセンブラで作成した 608 バイトの hello は シェアドライブラリを 使っているのでしょうか?
$ file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
statically linked, stripped
共有ライブラリをリンクしていないので当然ですが、スタティックリンクであると表示されます。 アセンブラで作成した場合の実行プログラムのサイズは、C で作成した場合の 1/1000 になっています。 また、Linuxカーネルさえあれば実行できます。initの代わりに実行させることもできます (意味ありませんが)。
もっと素直に
最初の hello.s を少し変更したバージョンの hellol.s を見てみましょう。 "mov rax" から 2番目の"syscall" までの行数が hellol.s の11行から 8行になっています。
;------------------------------------ ; hellol.s ; nasm -f elf64 hellol.s ; ld -o hellol hellol.o ; ./hellol ;------------------------------------ bits 64 section .text global _start _start: mov rax, 1 ; sys_write mov rdi, 1 ; stdout mov rsi, msg ; address mov rdx, len ; length (13) syscall mov rax, 60 ; sys_exit xor rdi, rdi ; 0 syscall section .data msg db 'hello, world', 0x0A len equ $ - msg
hello.s もこの hellol.s も文字列を表示するシステムコール(1番)とプログラムを 終了させるシステムコール(60番)の2つを使っています。x86-64 Linux のシステムコール番号は 32bit とまったく異なっています。こちらの対応表を参照してください。
x86-64 の Linux でシステムコールを呼び出すには以下のようにします。rcx と r11 の内容は保存されません(破壊される)。
- rax にシステムコール番号を設定
- 必要ならば、第 1 引数 を rdi に設定
- 必要ならば、第 2 引数 を rsi に設定
- 必要ならば、第 3 引数 を rdx に設定
- 必要ならば、第 4 引数 を r10 に設定
- 必要ならば、第 5 引数 を r8 に設定
- 必要ならば、第 6 引数 を r9 に設定
- システムコール命令( syscall ) を実行
以上の決まりに従って素直に書いたプログラムが hellol.s です。 プログラムがしていることは以下の内容です。
- write システムコールで文字列表示をするため、1番を rax に設定
- write の最初の引数として、標準出力を示す 1 を rdi に設定
- write の2番目の引数として、文字列の先頭アドレスを rsi に設定
- write の3番目の引数として、文字列のバイト数を rdx に設定
- システムコール命令( syscall ) を実行して文字を表示
- exit システムコールで終了するため、60番を rax に設定
- exit の最初の引数として、終了コード 0 を rdi に設定
- システムコール命令( syscall ) を実行してプログラムを終了
逆アセンブル
アセンブル、リンクされた実行形式の hello を逆アセンブルしてみます。 nasm には ndisasm という逆アセンブラが付属していますが、使いにくいため GNU asと同じ binutils パッケージに含まれる objdump を使用します。インテル形式で表示するため 「-M x86-64,intel」というオプションを付けて実行します。
jun@ubuntu64:~$ objdump -d -M x86-64,intel hello hello: file format elf64-x86-64 Disassembly of section .text: 00000000004000b0 <.text>: 4000b0: 31 c0 xor eax,eax 4000b2: 89 c2 mov edx,eax 4000b4: ff c0 inc eax 4000b6: 89 c7 mov edi,eax 4000b8: b2 0d mov dl,0xd 4000ba: 48 be d0 00 60 00 00 mov rsi,0x6000d0 4000c1: 00 00 00 4000c4: 0f 05 syscall 4000c6: 31 ff xor edi,edi 4000c8: 89 f8 mov eax,edi 4000ca: b0 3c mov al,0x3c 4000cc: 0f 05 syscall
青字の部分 が x86-64 CPU が実行するバイナリコードです。 30バイトあります。アセンブラが命令を1:1で対応するバイナリコードに翻訳していることがわかります。
システムコールのパラメータを素直にレジスタに設定したバージョンの hellol を逆アセンブルします。
jun@ubuntu64:~$ objdump -d -M x86-64,intel hellol hellol: file format elf64-x86-64 Disassembly of section .text: 00000000004000b0 <.text>: 4000b0: 48 b8 01 00 00 00 00 mov rax,0x1 4000b7: 00 00 00 4000ba: 48 bf 01 00 00 00 00 mov rdi,0x1 4000c1: 00 00 00 4000c4: 48 be ec 00 60 00 00 mov rsi,0x6000ec 4000cb: 00 00 00 4000ce: 48 ba 0d 00 00 00 00 mov rdx,0xd 4000d5: 00 00 00 4000d8: 0f 05 syscall 4000da: 48 b8 3c 00 00 00 00 mov rax,0x3c 4000e1: 00 00 00 4000e4: 48 31 ff xor rdi,rdi 4000e7: 0f 05 syscall
同様に 青字の部分 が x86-64 CPU が実行するバイナリーコードです。 hellolの場合は 57バイトあります。行数は hello の11行に比べて、 hellol は 8行と少ないのにバイナリコードは2倍近いサイズになっています。素直に書いた アセンブリコードと x86-64 のクセに合わせて書いたコードでこんな小さなプログラムでも 大きな差になることがわかります。64bitのレジスタに不用意に即値 (整数定数) を代入すると 実行プログラムは大きくなります。hello では 32bit レジスタを操作した場合、 64bit中の上位32bitは自動的にゼロクリア(ゼロ拡張)されることを利用しています。 xor でレジスタをゼロクリアする場合も 32bit レジスタを指定するほうがREXプリフィックスの 1バイト分小さくなります。
GNU as が採用しているAT&T形式で hello を逆アセンブルしてみます。ニーモニックに オペランドサイズを示す接尾語(サフィックス)が付き、レジスタ名に%が付きます。 また2つのオペランドの並びが逆になります。
jun@ubuntu64:~$ objdump -d -M x86-64,suffix hello hello: file format elf64-x86-64 Disassembly of section .text: 00000000004000b0 <.text>: 4000b0: 31 c0 xorl %eax,%eax 4000b2: 89 c2 movl %eax,%edx 4000b4: ff c0 incl %eax 4000b6: 89 c7 movl %eax,%edi 4000b8: b2 0d movb $0xd,%dl 4000ba: 48 be d0 00 60 00 00 movq $0x6000d0,%rsi 4000c1: 00 00 00 4000c4: 0f 05 syscall 4000c6: 31 ff xorl %edi,%edi 4000c8: 89 f8 movl %edi,%eax 4000ca: b0 3c movb $0x3c,%al 4000cc: 0f 05 syscall
私は AT&T 表記が好きになれません。慣れかもしれませんが、読みにくいですよね。