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 の内容は保存されません(破壊される)。

  1. rax にシステムコール番号を設定
  2. 必要ならば、第 1 引数 を rdi に設定
  3. 必要ならば、第 2 引数 を rsi に設定
  4. 必要ならば、第 3 引数 を rdx に設定
  5. 必要ならば、第 4 引数 を r10 に設定
  6. 必要ならば、第 5 引数 を r8 に設定
  7. 必要ならば、第 6 引数 を r9 に設定
  8. システムコール命令( syscall ) を実行

以上の決まりに従って素直に書いたプログラムが hellol.s です。 プログラムがしていることは以下の内容です。

  1. write システムコールで文字列表示をするため、1番を rax に設定
  2. write の最初の引数として、標準出力を示す 1 を rdi に設定
  3. write の2番目の引数として、文字列の先頭アドレスを rsi に設定
  4. write の3番目の引数として、文字列のバイト数を rdx に設定
  5. システムコール命令( syscall ) を実行して文字を表示
  6. exit システムコールで終了するため、60番を rax に設定
  7. exit の最初の引数として、終了コード 0 を rdi に設定
  8. システムコール命令( 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 表記が好きになれません。慣れかもしれませんが、読みにくいですよね。


続く...