初学者向け x86/MacOSX 64bit アセンブリ

私自身が MacOSX 64bit (MachO64) 環境でアセンブリ・プログラミングを試した際、初学者向けにまとめた資料です。

step 0. この文書の目的

この文書は 64bit MacOSX (MachO64) 環境で x86 アセンブリを書いてみよう、サンプルコードを試してみよう、と思い立ったアセンブリ初学者が短時間で試せ、その後の足がかりとなるようなものを目指して作ってみたものです。

x86アセンブリの資料として検索してすぐ見つかるのは 32bit Linux 環境向けのものが多く、MachO64環境ではうまく動作しないものによく遭遇します。またそのとき出てくるエラーメッセージは理解しづらいものが多いため、そこで初学者がメゲないようにMacOSX 環境でそのまま動作するチュートリアルとしてまとめました。

ただし、CPUや機械語に関して、以下あたりの知識・理解があることを前提としています。

機械語そのものの勉強、x86 命令セットの詳細について、といったことはここではちゃんと説明しません。説明は常に初学者がアセンブリ・プログラムを体験するために必要最低限の簡単な(つまり中途半端な)範囲に絞っています。そうしないと「試す」までに読む量が多すぎて、やる気が無くなりますからね。

動作環境

私がこの資料を作成する際にテストや動作確認を行った動作環境を出しておきます。

step 1. 簡単なアセンブリの例

まずとても簡単な x86 アセンブリのコードを示します。意味無く 10 と 21 を加算する(31 になる)だけのものです。x86アセンブリに既になじみのある人は step 2. まで飛ばすのが良いでしょう。

    movq $10, %rax;                 # move 10 to rax
    movq $21, %rbx;                 # move 21 to rbx
    addq %rbx, %rax;                # add rbx to rax (rax += rbx)

アセンブリでは、各行の最初に命令(インストラクション)があり、それに続いてオペランド(各命令に必要なパラメタ)が示されます。# より後ろはコメントとなります。C など他の手続き型言語と同じく、書かれている命令を上から一行ずつ実行していきます。

プログラムの内容を簡単に説明します。

1行目:データをコピーする movq 命令によって 10 を 64bit の rax レジスタに転送します。これで rax レジスタの値が 10 になります。

オペランドに値(数)を書く場合は $ をつけます。レジスタ名の前には % をつけます。# より後ろはコメントとなります。

2行目:今度は movq 命令によって 64bit の rbx レジスタの値を 21 にします。

3行目:足し算命令である addq によって rbx の値が rax に加算されます。結果、rax の値は 31 になります。

誤解を怖れず、C 言語っぽい記述で等価な振る舞いをするように表現すると以下のような感じでしょうか。

{.C .numberLines startFrom="1";} rax = 10; rbx = 21; rax = rax + rbx;

movq と mov

検索して出てくるサンプルコードを見ていると、movq や addq ではなく mov, add と書かれているものもあります。q は quad word の略で、4 語長つまり 64bit データを扱うことを意味します。x86 では命令にビット長を明示指定することができ、b : byte (8bit)、w : word (16bit)、l : long word (32bit)、q : quad word (64bit) が指定できます。x86 はもともと 16bit CPU である 8086 から出発しているので、16bit を基準の語長(ワードサイズ)としています。

多くの場合、movq と書かず単に mov とだけ書けばアセンブラが自動的に適切な長さを判断してくれるのですが、この資料では長さを明示する方を選びました。

AT&T 表記と Intel 表記

x86 のアセンブリ・コードの表記には AT&T 表記と Intel 表記の二種類があり、かなりの相違があります。この資料は AT&T 表記に沿って書いています。

両者で最も混乱するところはソースとデスティネーションが逆転していることでしょう。以下の例は、ともに 1 を rax レジスタにセットする記述ですが、1 と rax のオペランド中の配置が逆転していますね。

AT&T表記movq $1, %rax # move 1 to rax register Intel表記mov rax, 1 ; move 1 to rax register

またIntel 表記では値を書く時の $ やレジスタ名の % は無く、コメントは ; に続けて書くところも違います。もしサンプルコードを検索などで見つけた場合、レジスタ名に % が付いていたら AT&T 構文だと思えば良いでしょう。

参考資料:GAS と NASM を比較する (IBM developerWorks)

ループのある処理

もう少しだけアセンブリによるプログラム例を出しておきます。今度はループと条件分岐です。このプログラムは rax に +5 する処理を4回くり返してループを抜けます。つまりループを抜け出した時点で rax の値は 20 になっています。

    movq $0, %rax;                  # move 0 to rax
    movq $4, %rbx;                  # move 4 to rbx
loop1:
    addq $5, %rax;                  # add 5 to rax (rax += 5)
    subq $1, %rbx;                  # subtract 1 to rbx (rbx -= 1)
    cmpq $0, %rbx;                  # compare 0 and rbx
    jne  loop1                       # if Not Equal, jump to 'loop'

プログラムについて簡単に説明します。

1,2行目:rax レジスタに 0, rbx レジスタに 4 をセットします。

3行目:ラベルです。7行目のジャンプ命令で飛び先としてここが指定されています。

5行目:引き算命令である subq によって rbx レジスタの値から 1 を引きます。

6, 7行目:0 と rbx レジスタの値を比較し、それが Not Equal だったら3行目の loop1 ラベルの行にジャンプします(次に実行する行がそこになる)。Equal だったらジャンプしないので、その下の行(つまり8行目)に処理が進みます。

同様に C っぽい記述で等価な振る舞いをするように表現すると以下のような感じでしょうか。

    rax = 0;
    rbx = 4;
loop:
    rax = rax + 5;
    rbx = rbx - 1;
    if ( 0 == rbx ) goto loop; 

アセンブリでは比較命令 (cmpq) と条件つきジャンプ命令 (jne) の 2 命令のセットで 6行目 の if ( == ) を実現していますから、アセンブリと上の if 文による記述はうまく対応できていないのですが、まあ良いでしょう。ともかくアセンブリもこういうふうに 1 ステップずつ命令が処理されていきます。

最近では C 言語の goto を教えないし、モダンな言語はジャンプ(goto)の概念ごとありませんから、ちょっと今どきのプログラマにとってはジャンプそのものが違和感あるかもしれませんね。

さて、下勉強はここまでにして、実験をはじめましょう。

step 3. アセンブリ・プログラムを実行する

なおこれ以降の記述の多くは “Writing 64 Bit Assembly on Mac OS X” (http://www.idryman.org/blog/2014/12/02/writing-64-bit-assembly-on-mac-os-x/) を参考にしています。作者さまに感謝です。ただこのドキュメントは edi と書くべきところを ebi と書いていたり、若干混乱するところがあるので注意が必要です。

最初のコードとアセンブル・実行まで

以下のコードを、アセンブリ・ソースファイルの拡張子である .s がついたファイル名で作って下さい。

filename: add_exit.s

.section __TEXT,__text
.globl _main
_main:
    movq $0, %rax;            # move 0 to rax
    movq $4, %rbx;            # move 4 to rbx
loop:
    addq $5, %rax;            # add 5 to rax (rax += 5)
    subq $1, %rbx;            # subtract 1 to rbx (rbx -= 1)
    cmpq $0, %rbx;            # compare 0 and rbx
    jne  loop                 # if Not Equal, jump to 'loop'

    movq %rax, %rdi           # set result to the exit code
    movq $0x2000001, %rax     # system call $1 with $0x2000000 offset
    syscall

このプログラムは先に出していた「 5 を 4 回足す」コードに、アセンブルして実行し、結果を確認するために必要な記述を前後に加えたものです。

1-3行目:アセンブリのプログラムを書くための周辺的な作業です。定型のおまじないと思ってください。

4-10行目:先に出していた「 5 を加算する処理を 4 回ループする」コードそのものです。

10行目より後ろはループを終えて rax レジスタに残った計算結果(20という値)を外部に出力するための処理です。本当は画面に「20」と表示させるのがカッコいいのですが、あまり簡単にはできません。そこで今回は最も簡単な方法として、このプログラムの終了ステータスが計算結果となるようにします。

C 言語ではプログラムの終了ステータスを exit(20); などとしてセットすることができるのを知っていますか?アセンブリでも exit() 関数相当の処理があり、システムコール(カーネルが用意している関数群)の 0x2000001 番として用意されています。

12行目:exit() 関数に引数を与えるのと同様、計算結果(20)が残っている rax レジスタの内容を rdi レジスタにセットします。

13行目:exit() 相当のシステムコールは 0x2000001 番なので、その値を rax レジスタにセットします。

14行目:この状態(終了ステータスとなる値が rdi レジスタ、呼び出すシステムコールの番号が rax レジスタにセットされている)で syscall 命令を実行します。

このプログラム add_exit.s を以下のようにしてアセンブル・実行してください。

$ cc add_exit.s 
$ ./a.out
$ echo $?
20
$

つまりアセンブリのプログラムもこうやって簡単に cc コマンドでアセンブルしてくれます。出来上がった実行形式のファイルも、いつものようにして実行可能です。

3行目 で echo コマンドで終了ステータスを表示していることに注目してください。4行目 に計算結果である 20 が表示されています。

アセンブリ・プログラムの 5 行目、movq $4, %rbx の $4 を $5 などに変えて(つまりループを 5 回にする)再アセンブルし、実行し、echo $? すると、今度は終了ステータスが 25 になっていることが確認できると思います。

特殊なシェル変数 $? は直前に実行したコマンドの終了ステータスを保持しています。このあたりの詳しい説明はしませんので、興味のある人は bash の資料などを参照すると良いでしょう。
それにしても C 言語のプログラムで exit(20) とするとプログラム終了後に echo $? で 20 が表示されること自体を知らない人にはちょっとここの作業の意味が分かりにくいかもしれませんね。ごめんなさい。

参考までに、これも同様に C っぽい記述で表現しておきます。

int main(void) {
    int rax, rbx, rdi;
    rax = 0;
    rbx = 4;
loop:
    rax = rax + 5;
    rbx = rbx - 1;
    if ( 0 != rbx ) goto loop; 
    rdi = rax;
    exit(rdi);
}

アセンブリでは、C 言語の exit() 関数に相当する処理は「システムコール」と呼ばれる仕組みで実現されています。これをアセンブリから呼び出すためには、幾つかのレジスタに値をセットして、syscall 命令を実行します。このあたりの詳しいことは、もう少し下の「システムコールの呼び出し方」で説明します。

as コマンドでのアセンブル

せっかくアセンブリで書いたのだから cc コマンドでなく as コマンドでアセンブルしたい、という場合は以下のようにします。

$ as -o add_exit.o add_exit.s 
$ ld -lSystem -macosx_version_min 10.14 -o add_exit add_exit.o
$ ./add_exit
$ echo $?
0
$

まず as コマンドでオブジェクトファイルを作ります。次に ld コマンドでリンクして実行形式を作るわけですが、そこではいくつかオプションの指定が必要です。もし上記のように -lSystem と最低バージョンに関するオプションを指定しなかった場合は以下のようなエラーが出るでしょう。

$ ld -o add_exit add_exit.o 
ld: warning: No version-min specified on command line
ld: dynamic main executables must link with libSystem.dylib for inferred architecture x86_64
$

3 行目、libSystem と一緒に link しないとダメ、とエラーになっています。2行目は実行時の最低バージョンを指定していないことに対する警告です。

システムコールを使う程度であれば -lSystem でなく -lc としても良いです。それでもバージョン指定がない場合同様に二行目の警告は出ます。-lc は libc をライブラリとして指定することを意味しますが、それとステムコールを使う事の関係についてはここでは説明しません。興味のある人は調べてみると良いです。

エントリー・ポイント

エントリー・ポイントとは、プログラムを実行したとき、最初に処理が始まる場所(起点)のことを指しています。例えば C 言語では main() という関数(の中の最初の行)がエントリー・ポイントに相当します。main() 関数を必ずしもプログラムコード中の先頭に書かなくても良いように、アセンブリのプログラムでもエントリー・ポイントはコードのどこにでも置けます。その代わり、処理系(具体的にはリンカ)はどこがエントリー・ポイントなのか判断できなければなりません。

さて、最近の MacOSX 環境では、_main という名前がエントリー・ポイントとなっています。しかし検索すると _main ではない名前(start や main など)をエントリー・ポイントに使っているサンプルが見つかったりします。

そのような(例えば add_exit.s の2, 3 行目にある _main の場所に start と書いた)コードもアセンブル処理は問題無く通るのですが、そのまま先ほどと同様にリンクしようとすると以下のようなエラーになります。

$ as -o add_exit.o add_exit.s 
$ ld -lSystem -macosx_version_min 10.14 -o add_exit add_exit.o
Undefined symbols for architecture x86_64:
  "_main", referenced from:
     implicit entry/start for main executable
ld: symbol(s) not found for inferred architecture x86_64
$

ちょっと分かりにくいエラーなのでちゃんとした解説はしませんが、ともかく _main が見つからないよ、と言っています。対応としては ld -e start -o add_exit add_exit.o のように -e オプションを使ってエントリー・ポイントを明示してやれば良いはず、、、なのですが、今の MacOSX ではそうは行かず、こんなエラーになります。

$ ld -e start -lSystem -macosx_version_min 10.14 -o add_exit add_exit.o
ld: warning: Ignoring '-e start' because entry point 'start' is not used for the targeted OS version
Undefined symbols for architecture x86_64:
  "_main", referenced from:
     implicit entry/start for main executable
ld: symbol(s) not found for inferred architecture x86_64
$

メッセージの内容がかなり意味不明だと思いますが、もうこれ以上原因を追わず、_main でないサンプルコードを見つけたら _main に書き換えて試すのが良いと思います。

そうは言っても納得が行かない人が多いでしょうから、そうした人向けの参考として、私の(あまり正しさに自信の無い=ほぼ推測に近い)解釈を書いておきます。
まずエラーメッセージを素直に読むと、-e start オプションは「無視するよ」とあります。だって start というエントリー・ポイントはターゲットの OS バージョンでは使われていないから、と。検索で見つかるサンプルコードの多くがエントリー・ポイントを start としているのは Linux 向けだからだと思います。ところが最近のバージョンの MacOSX は’_main と言う名前を使っているので、ld コマンドは暗黙に _main があるものと思ったものの、見つからないよ、とエラーを出しています。ここまではあまり疑念はありません。問題はここからです。
それなら -e start と明示的に指定したら済みそうなものですが、MacOSX の ld コマンドは -e start オプションによる指定に対して「ターゲットとなるOSのバージョンではもう使ってない」から「無視」するよ、と指定自体を拒否します。どうやら MacOSX も過去に(Lion 時代あたりまで?)、start を使っていたようなので、それとの混在あるいはうっかりミスを避けるためかなと思います。意図は分からないでは無いですが、ちょっとお節介な印象です。

step 3. システムコールをもう少し試す

基本的な x86 アセンブリの記法や操作についてはここまでの段階で最低限示せたと思います。しかしもう少し複雑なプログラムについて試すには、道具が不十分です。せめて printf() 相当の、画面に結果を表示するところまではたどり着かないと。そのためにはシステムコールの呼び出し方についてもう少し理解する必要があります。

というわけで、以下に exit() 関数相当の syscall を実行する部分だけを取り出したコードを示し、システムコールの呼び出し方について解説します。

filename: syscall.s

~~~~ {.asm .numberLines startFrom=“1”;} # Simple syscall exit program .section __TEXT,__text .globl _main _main: movq $0x2000001, %rax # system call $1 with $0x2000000 offset movq $0, %rdi # set the exit code to be $0 syscall ~~~~

3, 4 行目:エントリー・ポイントとなる名前を _main にしています。

5 行目:rax レジスタにシステムコール番号 1 、つまり SYS_exit を設定しています。ただし 1 ではなく 0x2000000 を加算した値をセットする必要があります。(後述)

6行目:rdi レジスタにSYS_exit システムコールに渡す引数( exit()関数に渡す引数と同等)として0を設定します。先に出したサンプルコードでは rax と rdi の内容をセットする順番、つまり 5 行目と 6 行目の順が逆転していましたが、rax, rdi の内容は syscall を実行する前にセットしておきさえすれば、その順番は関係ありません。

このコードの元になっている資料では6行目の引数を格納するレジスタがebx(64bit なら rbx)となっていますが、それは誤記で、edi (64bit なら rdi)が正しいレジスタです。

システムコール番号への 0x2000000 の加算

5行目の解説に示したように、システムコール番号には 0x2000000を加算する必要があります。これは MacOSX (BSD系OS) はシステムコールを四つのクラスに分けており、MacOSX でのUnix系システムコールは class 2 であるためです。

参考:XNU syscall reference https://opensource.apple.com/source/xnu/xnu-1699.26.8/osfmk/mach/i386/syscall_sw.h

システムコール番号は以下のように syscall.h で定義されています。

#define SYS_syscall        0
#define SYS_exit           1
#define SYS_fork           2
#define SYS_read           3
#define SYS_write          4
#define SYS_open           5
#define SYS_close          6

ここに 0x2000000 を加算したものが MacOSX で syscall 呼び出しの際に eax レジスタに設定する値となります。

なお Linux では 0x2000000 の加算などは無く、そのまま 1 を eax レジスタに設定して syscall します。Linux 向けのサンプルコードで syscall があった場合、そこを直さないと MacOSX 環境では動作しないことに注意して下さい。

syscall.h は Linux などでは /usr/include/sys/syscall.h にありますが、いまの Xcode は標準 include ファイルを /usr/include ではなく Xcode アプリケーションの中に取り込んでいます。以下のあたりを見てください。
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/sys/syscall.h
この名前と値は Linux 系とも共通なので、syscall.h を検索してそれを参照するのも手です。
私は以下のコマンドを実行して MacOSX 環境でのファイルの場所を見つけました。
$ echo ‘#include <sys/syscall.h>’ | cc -E -

ラッパー関数 callq の利用

次に syscall によって SYS_exit を直接呼ぶのでは無く、ラッパー関数 callq を通して呼んでみましょう。以下の例では終了ステータスに 5 を設定しています。

callq は call quad-word の略で、64bit モードで呼び出す場合に使う call 命令、程度に思って下さい。

filename: exit.s

# Simple call exit() program
.section __TEXT,__text
.globl _main
_main:
    pushq %rbp        # preserve the callee address (old base pointer)
    movq %rsp, %rbp   # set new base pointer as stack address
    movq $5, %rdi     # exit(5);
    callq _exit

5, 6 行目:スタック周りのお決まりの処理です。いまは余り気にせず、何かの準備が要るのだ、くらいで考えてください。

7行目:rdi レジスタに _exit コールに渡す引数(つまり exit()関数に渡す引数)として0を設定します。

8行目:callq によって _exit を呼び出します。

これも同様に cc コマンドによってアセンブル・実行して終了ステータスを表示すると、正しく 5 になっていることが確認できるでしょう。

Segmentation Fault エラー

もし5, 6行目のスタック周りのお決まりの処理をせずにcallq による呼び出しを掛けた場合、以下のようなエラーが発生するでしょう。

$ cc exit_err.s
$ ./a.out 
Segmentation fault
$

step 4. Hello World (by printf)

では準備が出来たところで Hello World をやってみましょう。ラッパー関数 callq を使って printf() を実行します。

filename: hello.s

# Simple call printf() program
.section __DATA,__data
msg:
    .asciz "Hello World\n"

.section __TEXT,__text
.globl _main
_main:
    pushq %rbp                      # preserve the callee address
    movq %rsp, %rbp                 # set the base pointer

    movq msg@GOTPCREL(%rip), %rdi   # 1st argument (rdi) is format string
    movq $0, %rax                   # clear the return value space (A register)
    callq _printf                   # call printf()

    movq $0, %rax                   # set the exit code to be $0
    popq %rbp                       # restore old %rbp
    retq                            # function ends

3, 4 行目:コードに先んじて、この DATA section 領域にデータを書きます。文字列 “Hello World” の内容を格納したメモリ領域に msg と名前を付けています。変数名に相当するものですから、hello でもなんでも好きな名前を付けられます。

6-8 行目:一つ前のプログラム例でも登場した、アセンブリを書くためのお決まりの処理です。

9, 10 行目:一つ前のプログラム例でも登場した、スタック周りのお決まりの処理です。

12 行目:printf() は第一引数として与えられたメモリ領域(中身は文字列)を出力します。もし文字列に %d などの指定があれば、そこに第二引数以降に与えられた変数を指定されたフォーマットで当てはめて整形しますが、今回はそのような指定は無いので、第一引数の文字列をそのまま出力します。この第一引数、つまり出力文字列のアドレスを rdi レジスタにセットして呼び出すことになります。アドレスを取得する際の GOTPCREL 記述についてはこのすぐ後で説明します。

13, 14行目:次に printf() が返す戻り値を受け取るA レジスタ(rax)の値をゼロにしています。恐らく必要無い処理だと思うのですが、多くの資料はこうなってます。その方が安全なのでしょう。 12行目の操作と合わせて、この二つの準備をしたのち、callq で printf() を呼び出しています。

16行目:このプログラムの終了ステータスをゼロにセットします。

17,18 行目:3, 4 行目と対応する、終了時のお決まりの処理です。最後の retq 命令によって、このプログラム、つまり _main という名前の関数を呼び出した親プログラムに return させます。16行目で rax レジスタに設定した値は、この関数自体の戻り値となります。C 言語で main() 関数から return する時に return 0 と書くのと同じです。exit() で与えたシステム終了ステータスと同じ意味になります。

@GOTPCREL(%rip) 記述

12行目は第一引数として msg が指すメモリ領域のアドレスを printf() に渡す処理なのですが、そこに登場した @GOTPCREL(%rip) という記述は MacOSX 64bit 環境でのお決まりだと思って下さい。一般的な Linux 向けの Hello World プログラムでは恐らくこのような(シンプルな)記述になっていると思います。

leaq msg, %rdi

ところがこのような書き方で MacOSX 上でアセンブルすると、以下のようなエラーとなります。

$ as hello.s 
hello.s:12:5: error: 32-bit absolute addressing is not supported in 64-bit mode
    leaq num, %rcx # load address of num
    ^
$

つまりこの msg だけを書く記法は32bit 絶対アドレッシングを意味しており、これは 64bit mode ではダメだよ、と。というわけで、leaq msg のように書かれていたコードは movq msg@GOTPCREL(%rip) のようにして書くのが 64bit 環境の相対アドレッシング表記なのだ、と丸覚えして、そのように書いて下さい。

64bit 環境でラベルのアドレスを取得する方法は幾つかあるようで、例えば以下のようにもできます。
leaq num(%rip), %rcx

ただあまり見掛けないので、他の資料にもよく登場する @GOTPCREL をここでは用いています。興味のある人向けは文末の「A.1 Global Offset Table」を見て下さい。

printf() の戻り値

printf() 関数は戻り値として最終的に出力したバイト数を返します。man コマンドを用いて man 3 printf とすると確認できるでしょう(RETURN VALUES の節を参照)。

callq _printf も同様に戻り値を int 型つまり 32bit で返します。その値は A レジスタの 32bit ぶん、つまり eax レジスタの領域にセットされます。このプログラムではその戻り値を何かに使う事がないのですが、callq から戻ってきた時点では eax レジスタ(あるいは rax)に出力されたバイト数が入っているはずです。

つまり 16 行目の movq $0, %rax をコメントアウトしてから再アセンブル・実行すれば、このプログラムの終了ステータスに callq _printf の戻り値を確認することが出来ます。以下のような感じですね。

$ cc hello.s
$ ./a.out 
Hello World
$ echo $? 
12
$

改行文字を入れて 12 バイト出力されたことが分かります。

step 5. Hello World (by write)

再び Hello World の紹介です。今度は SYS_write システムコールを使って出力する例を示します。

man 2 write とすると分かるように、write() は三つの引数(出力 FD、出力データ領域、出力バイト長)を取ります。つまりこの例は、システムコールに対して複数の引数を与える時の作法を知るためのものです。

filename: write.s

# Simple call write() program
.section __DATA,__data
msg:
    .asciz "Hello World\n"

.section __TEXT,__text
.globl _main
_main:
    pushq %rbp                      # preserve the callee address
    movq %rsp, %rbp                 # set the base pointer

    movq $0x2000004, %rax           # preparing system call 4
    movq $1, %rdi                   # STDOUT file descriptor is 1 (as 1st arg)
    movq msg@GOTPCREL(%rip), %rsi   # The data to print (as 2nd arg)
    movq $12, %rdx                  # the size of the data to print (as 3rd arg, 12 chars)
    syscall
    # eax has return value as bytes of written or -1 on error. we don't use here

    movq $0, %rax                   # set return value (of this function) to zero
    popq %rbp                       # restore old %rbp
    retq                            # function ends

3, 4 行目:“Hello World” の文字列を準備。

9, 10 行目:前のプログラム例でも登場した、スタック周りのお決まりの処理です。

12-15 行目:システムコール番号 4 つまり write を rax に設定し、三つの引数(出力 FD、出力データ領域、出力バイト長)を rdi, rsi, rdx に設定します。ところで syscall への引数は最大6つまで与えることができ、それぞれ rdi, rsi, rdx, r10, r8, r9 レジスタに値をセットします。(後述)

16, 17 行目:準備が済んだので syscall を呼びます。write() は実際に出力したバイト数を Return Value として eax レジスタ(rax の下位半分)にセットします。が、このプログラムではこの値を参照することはありません。本当はエラー (-1) でないことをチェックした方が良いのでしょうが。

19-21 行目:20, 21 行目が、9, 10 行目と対応する終了時のお決まりの処理です。最後の retq 命令によって、このプログラム、つまり _main という名前の関数を呼び出した親プログラムに return させます。この関数自体の戻り値、つまり終了ステータスは 19 行目でゼロに設定しています。

syscall の戻り値

write() は戻り値として最終的に出力したバイト数を返します。もしエラーがあれば -1 を返します。man コマンドを用いて man 2 write とすると確認できるでしょう(RETURN VALUES の節を参照)。

ここでの syscall による write も同様に戻り値を int 型つまり 32bit で返します。その値は A レジスタの 32bit ぶん、つまり eax レジスタの領域にセットされます。このプログラムではその戻り値を何かに使う事がないのですが、syscall から戻ってきた時点では eax レジスタ(あるいは rax)に出力されたバイト数が入っているはずです。

つまり 16 行目の movq $0, %rax をコメントアウトしてから再アセンブル・実行すれば、このプログラムの終了ステータスに write の戻り値(12 バイト出力したこと)を確認することが出来ます。

レジスタの名前とビット長

上の A レジスタ(rax, eax)の説明はかなりいい加減になっています。Intel の 64bit x86 アーキテクチャは 1972 年に発表された Intel 最初の 8bit CPU である 8008 から延々と 16bit, 32bit, 64bit に拡張してきたため、過去の命名規則や後方互換性のために相当におかしな事になっています。興味のある人は本文末の「A.2 レジスタの名前とビット長」をご覧下さい。

step 6. 引数付き printf の制御

さて今度は幾つかの引数をもつ printf() を実行するアセンブリのコードを示します。

まず分かりやすさのために等価な C プログラムを示します。

# include <stdio.h>
# include <stdlib.h>
int main(void){
    printf("value =%d, %d\n", 10, 20);
    exit(0);
}

アセンブリのコードとしては以下のようになります。読めますかね。

filename: printf.s

.section __DATA,__data
msg:
    .asciz "value =%d, %d\n"

.section __TEXT,__text
.globl _main
_main:
    pushq %rbp                      # preserve the callee address
    movq %rsp, %rbp                 # set the base pointer

    movq msg@GOTPCREL(%rip), %rdi   # 1st argument (rdi) is format string
    movq $10, %rsi                  # 2nd argument (rsi) is 10
    movq $20, %rdx                  # 3rd argument (rdx) is 20
    movq $0, %rax                   # clear the return value space (rax)
    callq _printf                   # call printf()
    
    movq $0, %rdi                   # 1st argument (rdi) is 0
    callq _exit                     # call exit(0);

実行すると以下のようになるはずです。

$ cc printf.s
$ ./a.out 
value =10, 20
$ echo $? 
0
$

syscall への引数は最大6つまで与えることができ、それぞれ rdi, rsi, rdx, r10, r8, r9 レジスタに値をセットします。このプログラムでは 11-13 行目で printf() に与える三つの引数、つまり “value =%d, %d”, 10, 20 をそれぞれ rdi, rsi, rdx レジスタにセットしています。

これより前に紹介したとおり、_printf の戻り値(出力バイト数)は eax レジスタ(rax の下半分)にセットされますが、このプログラムではそれを使っていません。つまりもし _exit の引数としてゼロを設定している 18 行目を movq %rax, %rdi とすれば、_printf の戻り値はそのまま _exit の引数として利用されます。そのように修正して実行した例を以下に示します。

$ cc printf.s
$ ./a.out 
value =10, 20
$ echo $? 
14
$

改行文字を入れて 14 バイト出力されたことが分かります。

step 7. scanf を試す

printf() によって出力する方法がおおよそ把握できたところで、残るは入力です。以下に scanf() を用いたデータの入力に関する例を示します。

まず分かりやすさのために等価な C プログラムを示します。

# include <stdio.h>
# include <stdlib.h>
int main(void){
    int num;
    scanf("%d", &num);
    printf("value =%d\n", num);
    exit(0);
}

アセンブリのコードとしては以下のようになります。読めますかね。

filename: scanf_test.s

.section __DATA,__data
inform: .asciz "%d"
num: .long 0
value: .asciz "num =%d\n"

.section __TEXT,__text
.globl _main
_main:
    pushq %rbp                       # preserve the callee address
    movq %rsp, %rbp                  # set the base pointer

# scanf("%d", &num);
    movq inform@GOTPCREL(%rip), %rdi # 1st argument (rdi) is format string
    movq num@GOTPCREL(%rip), %rsi    # 2nd argument (rsi) is variable space of num
    callq _scanf                     # call scanf()

# printf("value =%d\n", num);
    movq value@GOTPCREL(%rip), %rdi  # 1st argument (rdi) is format string
    movq num@GOTPCREL(%rip), %rcx    # load address of num
    movl (%rcx), %esi                # 2nd argument (esi/rsi) is data of that address
    movq $0, %rax                    # clear the return value space (rax)
    callq _printf                    # call printf()

# exit(0);
    movq $0, %rdi                    # 1st argument (rdi) is 0
    callq _exit                      # call exit(0);

実行すると以下のようになるはずです。

$ cc scanf_test.s
$ ./a.out
100
value =100
$

例によって新しく登場した記述について説明します。

3行目num: .long 0 として、long word サイズ(4 バイト)のメモリ領域を確保し、そこに num という名前を付け、初期値をゼロとしてセットしました。int num=0; として int 型変数を宣言したことと同等です。

12-15行目:scanf() の第一引数として inform のアドレスを rdi にセットし、第二引数として num のアドレスを rsi にセットします。その後 _scanf を呼び出しています。

これによってキーボードから入力された文字列(例えば “10” )を、10進数として解釈し、その値がアドレス num から続く 4 バイトの領域に数値データとしてセットされます。

18行目:printf() の第一引数として value のアドレスを rdi にセットしています。

19-20行目:恐らくここが最も混乱するところと思います。第二引数には num の値、つまりメモリ中の num 領域にある値を取り出して、rsi に与えます。

21行目以降は、これまでに出てきた記述ばかりなので説明を省略します。

なお、19-20行目の「アドレスを取得」してから「そのアドレスの中の値を取り出す」二段階の操作ですが、movl num(%rip), %esi; のように一行で書くこともできます。ただ、この記法はどういうわけか殆ど事例として出て来ないので、この資料では上のように二段階の操作で値を取り出しています。

step 8. ちょっと意味のあるプログラム

さて、これが最後です。これまでに示したプログラムを全部まとめて、少し意味のあるプログラムを作ってみます。

まず分かりやすさのために等価な C プログラムを示します。入力された二つの数値の和を出力します。

# include <stdio.h>
# include <stdlib.h>
int main(void){
    int num1, num2;
    scanf("%d%d", &num1, &num2);
    printf("num1 =%d num2 =%d\n", num1, num2);
    num1 += num2;
    printf("answer =%d\n", num1);
    exit(0);
}

アセンブリのコードとしては以下のようになります。読めますかね。

filename: scanf_calc.s

.section __DATA,__data
inform: .asciz "%d%d"
num1: .long 0
num2: .long 0
verify: .asciz "num1 =%d num2 =%d\n"
answer: .asciz "answer =%d\n"

.section __TEXT,__text
.globl _main
_main:
    pushq %rbp                       # preserve the callee address
    movq %rsp, %rbp                  # set the base pointer

# scanf("%d%d", &num1, &num2);
    movq inform@GOTPCREL(%rip), %rdi # 1st argument (rdi) is format string
    movq num1@GOTPCREL(%rip), %rsi   # 2nd argument (rsi) is variable space of num
    movq num2@GOTPCREL(%rip), %rdx   # 3rd argument (rdx) is variable space of times
    callq _scanf                     # call scanf()

# printf("num1 =%d num2 =%d\n", num1, num2);
    movq verify@GOTPCREL(%rip), %rdi # 1st argument (rdi) is format string
    movq num1@GOTPCREL(%rip), %rcx   # load address of num
    movl (%rcx), %esi                # 2nd argument (esi/rsi) is data of that address
    movq num2@GOTPCREL(%rip), %rcx   # load address of times
    movl (%rcx), %edx                # 3rd argument (edx/rdx) is data of that address
    movq $0, %rax                    # clear the return value space (rax)
    callq _printf                    # call printf()

# move num1 to %rax
    movq num1@GOTPCREL(%rip), %rcx   # load address of num
    movl (%rcx), %eax;               # move data of num to eax/rax
# move num2 to %rbx
    movq num2@GOTPCREL(%rip), %rcx   # load address of times
    movl (%rcx), %ebx;               # move data of num to ebx/rbx
# add rbx to rax (rax += rbx)
    addq %rbx, %rax;                 # add rbx to rax (rax += rbx)

# printf("answer =%d\n", rax);
    movq answer@GOTPCREL(%rip), %rdi # 1st argument (rdi) is format string
    movq %rax, %rsi                  # 2nd argument (rsi) is data of that address
    movq $0, %rax                    # clear the return value space (rax)
    callq _printf                    # call printf()

# exit(0);
    movq $0, %rdi                    # 1st argument (rdi) is 0
    callq _exit                      # call exit(0);

実行すると以下のようになるはずです。

$ cc scanf_calc.s
$ ./a.out
123 456
num1 =123 num2 =456
answer =579
$

特に新しく登場した記述は無いと思います。コードの内容と動作について納得できるでしょうか。

さあ、ここまで来たら、あとは x86 の命令セットについて勉強して、検索であがってくる各種サンプルコードを MacOSX 64bit 環境に移して試せるんじゃないか、、、と思います。頑張って下さい。

Appendix

興味のある人向けに少し細かな説明を加えておきます。

A.1 Global Offset Table

@GOTPCREL つまり Global Offset Table を用いた相対アドレスによる表記についてもう少し書いておきます。@GOTPCREL を使わずに leaq num, %rcx などとラベルを直接書いて MacOSX 上でアセンブルすると、以下のようなエラーとなります。

$ as hello.s 
hello.s:12:5: error: 32-bit absolute addressing is not supported in 64-bit mode
    leaq num, %rcx # load address of num
    ^
$

つまりこの msg だけを書く記法は32bit 絶対アドレッシングを意味しており、これは 64bit mode ではダメだよ、と。

それなら -m32 オプションを指定して、32bit アーキテクチャとしてアセンブルすれば良いか?と言うと、上に示したコードそのままなら、以下のようなエラーが出てしまいます。今度は rbp や rsp レジスタは 64bit レジスタだよ、と言うわけです。

$ as -m32 hello.s 
e.s:9:11: error: register %rbp is only available in 64-bit mode
    pushq %rbp                      # preserve the callee address
          ^~~~
e.s:10:10: error: register %rsp is only available in 64-bit mode
    movq %rsp, %rbp                 # set the base pointer
         ^~~~
(途中略)
e.s:17:5: error: instruction requires: 64-bit mode
    retq                            # function ends
    ^
$

というわけで、msg@GOTPCREL(%rip) のようにして書くのが 64bit 環境の相対アドレッシング表記なのだ、と丸覚えして、そのように書いて下さい。Linux 向けの、シンプルに movq msg… とだけ書いてあるようなサンプルはそのように直してしまうのが良いです。

GOT (Global Offset Table) について深掘りしたい人は相対アドレッシング(位置独立コード)の資料を別途捜すと良いと思います。たとえば こちら など。
もちろん 32bit レジスタだけを使うようにして 32bit 環境向けにアセンブルすることは出来ますが、この資料は64bit 環境の初学者に絞って作っているので、このあたりはあまり説明しないでおきます。興味のある人はこの下の「32bit コードの例」を参照すると良いかも知れません。

実はleaq num(%rpi), %rcx;と書くことも出来るのですが、あまり他の資料に登場しないため、この資料では積極的にこの記法を使う事をしていません。

A.2 レジスタの名前とビット長

本資料はアセンブリの事などがある程度以上知識があることを前提に、最近の MacOSX で x86 アセンブリプログラミングを試す人のために作りました。そのためレジスタの説明はかなりいい加減になっています。しかし Intel の 64bit x86 アーキテクチャは 1972 年に発表された Intel 最初の 8bit CPU である 8008 から延々と 16bit, 32bit, 64bit に拡張してきたため、過去の命名規則や後方互換性のために相当におかしな事になっています。少し説明を加えておいた方が良いかもしれません。

x86_64 のレジスタ構成

特に注意すべきは、一つのレジスタの部分的なビット領域を別の名前で呼ぶことです。 例えば現在の 64bit アーキテクチャでは 64bit である A レジスタを普通 RAX レジスタと呼びますが、

  1. その下位 32bit 部分を EAX レジスタと呼んで、32bit レジスタのように扱うことができます
  2. 同様に EAX の下位 16bit 部分を AX レジスタと呼び、16bit レジスタとして使えます
  3. 同様に AX の上位 8bit 部分を AH、下位 8bit 部分を AL と呼び、それぞれ 8it レジスタとして使えます

以下に各種の名前で呼んだときに、64bit (8Byte) のどの位置を部分的に切り出すことになるか図示します。

7 6 5 4 3 2 1 0
RAX (64bit)
EAX (32bit)
AX (16bit)
AH (8bit) AL (8bit)

ややこしいですね。もう覚える他ありませんのでレジスタ構成一覧の資料 などを見ながら作業することを勧めます。以下に他の汎用レジスタの名前について列挙しておきます。

64bit 32bit 16bit 8bit High 8bit Low
RAX EAX AX AH AL
RBX EBX BX BH BL
RCX ECX CX CH CL
RDX EDX DX DH DL
RSI ESI SI - SIL
RDI EDI DI - DIL
RBP EBP BP - BPL
RSP ESP SP - SPL
R8 R8D R8W - R8B
以下、R9-R15も同様の名前付けになっている。

どうしてこうなった

これは歴史的経緯によるものです。興味のある人向けに、以下にざっと(かなり省いて)私なりに経緯をまとめてみます。

step 5. のプログラムを 32bit レジスタにしてみる

ところで step 5. に示していた write を用いた Hello World のコードは、すべて 64bit レジスタとして rax などに値を書き込んでいましたが、システムコール番号および第一引数のFDと第三引数の出力バイト数は 32bit int 型なので、実質それらは下位 32bit しか使っていません。つまり 12行目 以降のシステムコール番号、第一、第三引数は 32bit レジスタである eda, edi, edx などを使って次のようにも書けます。

    movl $0x2000004, %eax           # preparing system call 4
    movl $1, %edi                   # STDOUT file descriptor is 1 (as 1st arg)
    movq msg@GOTPCREL(%rip), %rsi   # The data to print (as 2nd arg)
    movl $12, %edx                  # the size of the data to print (as 3rd arg, 12 chars)
    syscall

12 行目:システムコール番号として、movl つまり2語長である 32bit 命令で $0x2000004 を A レジスタの下位 32bit 部分である eax レジスタに書き込んでいます。64bit汎用レジスタの下位32bitに値を書き込むと、上位32bit はゼロになるため、これで実際には 64bit すべてが設定されます。こうすることでコードサイズが小さくできるので、検索して出てくるサンプルコードは32bitレジスタとして使っているものが多くあります。

なお32bit で値を扱う命令は movl (move long) で、64bit で扱うのは movq (move quad-word) です。long が2倍、quad が4倍ワードを意味します。標準ワードが16bitだった時代からの命名なので、16x4 で 64bit になります。ややこしいので、一度ちゃんとした書籍を見ることを勧めます。

13行目:write の第一引数は int 型なので 32bit 命令である movl で rdi レジスタの下位 32bit 部分である edi に書き込んでいます。

14行目:第二引数はアドレスなので 64bit のrsiレジスタに書き込みます。

15行目:第三引数は再び int なので rdx レジスタの下位 32bit 部分である edx レジスタにバイト数である 12 をセットしています。ここでも rdx レジスタの上位 32bit には自動的にゼロがセットされるので、12が予期せず大きな数字に化けたりするような心配は無用です。

ところで x86 に限らず、演算機構が付いた、最も高機能つまり重要なレジスタを「アキュムレータ」のつもりで A レジスタと呼んでいました。それ以外の一時的なデータ置き場用の(演算機構を持たない)レジスタは B, C, D レジスタなどと呼びました。後に半導体をたくさん詰め込めるようになり、どのレジスタにも演算機構が付いて特殊性は無くなり、汎用レジスタと呼ばれるようになったのですが、A, B といった呼称はそのまま残ってしまいました。モダンな CPU では、レジスタはすべて汎用で、名前も単に R0 から R16 まで、などと呼んでいます。

A.3 32bit コードの例

ところで x86 OS の世界では 64bit アーキテクチャと 32bit アーキテクチャで随分と関数、またシステムコールの呼び出し方に違いがあります。参考として幾つかの例を示しておきます。

printf による Hello World

“Assembler on a Mac? Yes We Can!” に以下のような例がありました。printf を呼び出す形で実現する Hello World です。一応 Mac 用なのでエントリーポイントの名前が _main だったり、とにかくそのままアセンブル・実行可能な状態です。

    .globl  _main           # set start point of program

message:                                 
    .asciz "Hello World\n"

_main:                      # program starts here               
    push    %ebp            # save base-pointer register 
    sub     $8, %esp        # reserve bytes from stack to call _printf

    lea     message, %eax   # get memory address of message string...
    mov     %eax, (%esp)    # ...and store it into reserved stack area

    call    _printf         # display a "Hello World" on console

    add     $8, %esp        # free up reserved stack memory
    pop     %ebp            # restore base-pointer register 
    xor     %eax, %eax      # set return code to zero
    ret                     # exit program

目立った違いのあるところについて説明します。

8行目:スタックポインタを 8 バイトぶん持ち上げています。スタックはアドレスの下から上に向かって積み上げていくので、スタックポインタである eax レジスタの値を 8 バイト減算することで 4 バイトのデータ二つぶんの領域を確保することができます。sub 命令(減算)で処理していますが、恐らくこれは何でも良いので 32bit レジスタを二回 push しても同じ効果が得られます。

10,11行目:printf() に対する第一引数として、出力対象となる領域(message)のアドレスを取得し、スタックに積みます。

13行目:_printf を call します。

15行目:8行目のスタック操作の対になる部分です。ここも領域を開放するだけなので、何でも良いから pop 処理を二度実行すれば同じ効果が得られます。

なお 17行目 の xor %eax, %eax が見慣れない人も多いでしょうが、これは単に(プログラム終了ステータスの値となるはずの) eax レジスタをゼロにしているだけです。同じレジスタに対して XOR を取ると、必ず全ビットフィールドがゼロになることが分かるでしょう。

システムコールへの引数の渡し方

この例ではっきり分かるように、32bit 環境ではシステムコールなどへの引数の渡し方が 64bit 環境とは大きく異なります。具体的にはスタックに引数を積んで渡し、戻ってきたときにはそのスタックに戻り値が積まれた格好になります。64bit 環境の syscall はスタックに積まず、第一引数から順に rdi, rsi, rdx, r10, r8, r9 レジスタに値をセットして呼び出します。(もうちょっと複雑ですが、ここでは説明しません。)

32bit 環境向けであることを指定したビルドと実行

とりあえず以下のように -m32 と -Wl,-no_pie オプションを付けて cc コマンドで処理できるかどうか試して下さい。問題無く実行形式が作れれば実行できることを確認してください。

$ cc -m32 hello_32.s -Wl,-no_pie
$ ./a.out 
Hello World
$ 

もし上のように指定しても次のようなエラーが出る場合、あなたの環境は 32bit 用のビルド環境を持っていないと思われます。

$ cc -m32 hello_32.s -Wl,-no_pie
ld: warning: The i386 architecture is deprecated for macOS (remove from the Xcode build setting: ARCHS)
ld: warning: ignoring file /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk/usr/lib/libSystem.tbd, missing required architecture i386 in file /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk/usr/lib/libSystem.tbd
Undefined symbols for architecture i386:
  "_printf", referenced from:
      _main in hello_32-b6a4c1.o
ld: symbol(s) not found for architecture i386
clang: error: linker command failed with exit code 1 (use -v to see invocation)
$

as コマンドによるビルドと実行

上に示したようなエラーが出てしまう場合でも、as, ld コマンドを使ってアセンブル・リンクする事が可能です。-lSystem や -e オプションについてはこのドキュメントの上の方でも登場しています。

$ as -m32 -o hello_32.o hello_32.s 
$ ld -lSystem -macosx_version_min 10.14 -e _main -no_pie -o hello_32 hello_32.o
$ ./hello_32
Hello World
$ 

なお -e オプションを付けないでリンクしようとすると「start が定義されてないよ」的なメッセージが出ます。つまり 32bit 環境では MacOSX もエントリーポイントの名前は start だったのでしょう。

オプションの意味(-m32 と -no_pie)

今までやってきたように cc コマンドに何もオプションを付けず処理させると、cc コマンドは 64bit アーキテクチャのつもりで作業しようとして、以下のように「この命令は 64bit モード以外を要求する」とエラーを出します。

$ cc hello_32.s
hello_32.s:7:5: error: instruction requires: Not 64-bit mode
    push %ebp # save base-pointer register
    ^
hello_32.s:16:5: error: instruction requires: Not 64-bit mode
    pop %ebp # restore base-pointer register
    ^
$ 

では、と、32bit アーキテクチャ向けの処理であることを指定する -m32 オプションを付けると、アセンブルは通りますがリンク時に警告が出ます。(それでも実行形式ファイルはちゃんと作ります)

$ cc -m32 hello_32.s
ld: warning: PIE disabled. Absolute addressing (perhaps -mdynamic-no-pic) not allowed in code signed PIE, but used in _main from /var/folders/d2/5f_1p1jj13v7dlvns5tt4_y80000gn/T/a-a3d05a.o. To fix this warning, don't compile with -mdynamic-no-pic or link with -Wl,-no_pie
$ 

このメッセージですが、「リンクする時に PIE (Position Independent Executables) が disable になっていて、絶対アドレッシングになっているが大丈夫?」といった話をしています。これは @GOTPCREL(%rip) 記述 の節で説明した相対アドレッシングあたりの事なのですが、ここでは余り気にしないで「絶対アドレッシングで大丈夫」である旨を(メッセージがお勧めするそのまま) -Wl,-no_pie オプションを指定しています。

write による Hello World

Macでx86アセンブリ入門してみた には、また異なる 32bit 向けの形でシステムコールを呼び出すサンプルがあります。例えば以下もそのままアセンブル・実行可能な状態です。

.global _main

.data
str:
    .ascii  "Hello World\n"

.text
_main:
    pushl  $12
    pushl  $str
    pushl  $1
    movl   $4, %eax # write(1, str, 12)
    pushl  %eax     # to make esp -4, push 4 bytes to stack as dummy (make return space)
    int    $0x80

    pushl  $0
    movl   $1, %eax # exit(0)
    pushl  %eax
    int    $0x80

プログラム前半に write、後半に exit による処理があることが分かるでしょう。

目立った違いは 14, 19行目の int $0x80 によってシステムコールを呼び出すことでしょうか。int は interrupt の略で、ソフトウェア割り込み、と言われます。ここではちゃんと説明しませんが、syscall 命令も内部的には同様にソフトウェア割り込みを発生させています。



Yutaka Yasuda

2019/02/17