いくつかプログラムを書いてみたので忘れないうちにメモ。
最近はIntelシンタックスで書くのが主流みたいだけど、gcc -S
とかそういうツールでAT&Tシンタックスをよく目にするし、movlやmovbなどの区別を考えながら書いたほうが初学者には取っ付きやすそうということで、AT&Tシンタックスを選択した。
①exitするだけのコマンド
.global _main
.text
_main:
pushl $0
movl $1, %eax
pushl %eax # espを4下げるためにダミーでスタックに4バイトぶん積む
int $0x80
まず、システムコールを呼ぶには、まずスタックにシステムコールの引数(exitの場合は終了ステータス)をpushし、eaxにシステムコール番号を入れて、スタックに4バイト積んで、それからint命令に0x80を渡す。
pushl %eax
のところは実は subl $4, %esp
でも別に良い。espを4下げる理由はおそらく、システムコールがcall命令によって呼ばれることが一般的で、call命令は戻り先アドレスをスタックにpushするため、それに合わせたらしい。
- http://stackoverflow.com/questions/21367494/understanding-osx-16-byte-alignment
- http://d.hatena.ne.jp/TAKESAKO/20090313/1236937017
また、竹迫さんの記事にもあるように、システムコールの引数をスタックに積むのはFreeBSD由来の流儀で、Linuxの場合はレジスタで渡すらしい。
②コマンドライン引数の数を終了ステータスにする
.global _main
.text
_main:
movl 4(%esp), %eax # eaxにesp+4のアドレスの値を入れる
subl $1, %eax # 起動コマンドを含まない引数の数を取りたいので1引く
pushl %eax # 終了ステータス
movl $1, %eax
pushl %eax
int $0x80
まず、 4(%esp)
はespに4を足したアドレスに格納されている値のこと。
- アドレスオペランドというらしい。記法に慣れるまでつらい。
mainが実行されるときにesp+4の位置にCでいうところのargcが入っているので、それから1を引いたものをpushした上でexitのシステムコールを呼んでいる。
③Hello World!
ハローワールドを出力してみる。
.global _main
.data
str:
.ascii "Hello, World!\n"
.set str_len, . - str
.text
_main:
pushl $str_len
pushl $str
pushl $1
movl $4, %eax # write(1, str, str_len)
pushl %eax
int $0x80
pushl $0
movl $1, %eax
pushl %eax
int $0x80
writeシステムコールは4番だそうな。というのはMacのオープンソース関係のページにコードがあるのでそこから調べるらしい。
writeの引数には、ファイルディスクリプタ、出力したい文字列の先頭のアドレス、文字列の長さを渡す。定数文字の長さを得るたのイディオムが .set str_len, . - str
というやつ。ドットはおそらく現在地のアドレスで、そこからstrというラベルのアドレスを引くことで長さを得ている。
.asciiは、後ろにヌル文字を付けない文字のこと。↓に詳しい。
④実行されている自分自身のコマンド名を出力するコマンド
argv[0]をprintする。
まずはこんな感じで書いてみたらとりあえず動いた。
.global _main
.text
strlen: # eaxに入っているアドレスが指すNULL終端文字列の長さをeaxに入れて返す
movl $0, %ebx
subl $1, %eax
loop:
addl $1, %ebx
addl $1, %eax
cmpb $0, (%eax)
jne loop
subl $1, %ebx
movl %ebx, %eax
ret
_main:
movl 8(%ebp), %eax # ebp+8のアドレスからargv[0]が始まる
call strlen
pushl %eax # 出力する文字列の長さ
pushl 8(%ebp) # 出力する文字列 argv[0]
pushl $1 # ファイルディスクリプタ
movl $4, %eax # writeシステムコール番号
pushl %eax # 4バイト下げる
int $0x80 # システムコール
movl 4(%esp), %eax
subl $1, %eax
pushl %eax
movl $1, %eax
pushl %eax
int $0x80
call strlen
は pushl %eip; jmp strlen
と同じことらしい。
関数をきちんと定義する
上ので動くには動くが、調べてみると、色々規約を守っていないことがわかった。
- http://capm-network.com/?tag=GAS_%E9%96%A2%E6%95%B0
- http://labs.cybozu.co.jp/blog/mitsunari/2007/10/x863_1.html
関数定義の基本形
関数呼び出しを行う場合には、必ず呼び出した時のレジスタの値と関数処理が終了した時のレジスタの値を一致させる必要があります。
関数に入った(call命令を実行した)時点で、スタックの先頭には関数から帰るべきアドレス(命令ポインタ)が格納されています。
espが変更されては、関数から復帰する際に戻るべき場所が判らなくなってしまいますので、必ずレジスタを復元する処理が必要となります。
一般的に、関数処理時のレジスタルールは以下のものがあります。
- 関数に入る時と出る時で変化してはいけないレジスタ: EBP, ESP, EDI, ESI, EBX
- 戻り値を格納するレジスタ: EAX
これに気をつけて書いてみると、このようになった。
.global _main
.text
strlen: # 第一引数に渡されたアドレスが指すNULL終端文字列の長さをeaxに入れて返す
push %ebp # ベースポインタをスタックに保存
movl %esp, %ebp # 現時点でのスタックポインタの位置をベースポインタに設定
movl 8(%ebp), %edx # 第一引数で渡されたアドレスをedxに入れる
movl $0, %eax # 返り値となるeaxにゼロを入れる(これをインクリメントしていく)
subl $1, %edx # edxから1引いておく(ループの数合わせのため)
loop:
addl $1, %eax # eaxをインクリメント(文字数+1)
addl $1, %edx # edxをインクリメント(アドレスを1進める)
cmpb $0, (%edx) # edxの指している値をゼロ(NULL文字)と比較
jne loop # もしNULL文字でなければ、loopまで戻る
subl $1, %eax # ループから抜けたらeaxをデクリメント(ループの数合わせのため)
movl %ebp, %esp # スタックポインタを復元する
popl %ebp # ベースポインタを復元する
ret
_main:
pushl 8(%ebp) # ebp+8のアドレスargv[0]をstrlenの第一引数に突っ込む
call strlen
pushl %eax # 出力する文字列の長さ
pushl 8(%ebp) # 出力する文字列 argv[0]
pushl $1 # ファイルディスクリプタ
movl $4, %eax # writeシステムコール番号
pushl %eax # 4バイト下げる
int $0x80 # システムコール
movl 4(%esp), %eax
subl $1, %eax
pushl %eax
movl $1, %eax
pushl %eax
int $0x80
リファクタリング
「ループの数合わせのため」と書いてあるところが嫌な感じなので、strlenをリファクタリングしてみた。
strlen: # 第一引数に渡されたアドレスが指すNULL終端文字列の長さをeaxに入れて返す
push %ebp # ベースポインタをスタックに保存
movl %esp, %ebp # 現時点でのスタックポインタの位置をベースポインタに設定
movl 8(%ebp), %edx # 第一引数で渡されたアドレスをedxに入れる
movl $0, %eax # 返り値となるeaxにゼロを入れる(これをインクリメントしていく)
loop:
cmpb $0, (%edx) # edxの指している値をゼロ(NULL文字)と比較
je loop_break # NULL文字ならbreak
addl $1, %eax # eaxをインクリメント(文字数+1)
addl $1, %edx # edxをインクリメント(アドレスを1進める)
jmp loop # breakしなかった場合はループの先頭まで戻る
loop_break:
leave # movl %ebp, %esp; popl %ebp と同じ
ret
元のが do {...} while (...)
で、新しいのが while (...) {...}
に近い。
⑤引数の数を文字列として出力するコマンド
puts(itoa(argc))
みたいなことをする。
.global _main
.text
putchar: # 第一引数で与えられた文字(アドレスではなくASCII文字そのもの)を出力する
pushl %ebp
movl %esp, %ebp # お決まりの2行
movl %ebp, %edx
addl $8, %edx # edxにebp+8を入れる(第一引数を指すアドレス)
pushl $1
pushl %edx # edxをwrite
pushl $1
movl $4, %eax
pushl %eax
int $0x80
addl $16, %esp # この関数内でpushした数x4をespに足す(スタックを元に戻す)
leave
ret
print_digit: # 第一引数で与えられた0から9までの数字を文字列として出力する
pushl %ebp
movl %esp, %ebp # お決まりの2行
movl 8(%ebp), %edx # 第一引数の値(数字)をedxに入れる
addl $0x30, %edx # edxに0x30を足す(数字をASCII文字に変換)
pushl %edx
call putchar
addl $4, %esp # この関数内でpushした数x4をespに足す(スタックを元に戻す)
leave
ret
print_digits: # 第一引数で与えられた整数を文字列として出力する
pushl %ebp
movl %esp, %ebp # お決まりの2行
movl 8(%ebp), %eax # 第一引数の値をeaxに入れる
pushl %ebx # この関数内でebxの値が変わるので退避させる
pushl $123 # 0-9ではない適当な値(番兵)をスタックに積む
loop_push_digits:
movl $10, %ebx # 割る数(10)
movl $0, %edx # divlを呼ぶ前にedxをゼロにする
divl %ebx # 商と余りを同時に求める eax = div(eax, ebx), edx = mod(eax, ebx)
pushl %edx # 10で割った余りをスタックに積む
cmpl $0, %eax # eaxを10で割った商とゼロを比較
jne loop_push_digits # ゼロでなければ再度ループ
loop_pop_digits:
cmpl $123, (%esp) # スタックの先頭を番兵と比較
je loop_pop_digits_break # 番兵と同じであればループを抜ける
call print_digit # スタックの先頭に入っている数字を出力
popl %eax # 出力した数字はもう不要なのでpop
jmp loop_pop_digits
loop_pop_digits_break:
popl %eax # 番兵をpop
popl %ebx # 保存しておいたebxをpop
leave
ret
_main:
pushl 4(%ebp)
call print_digits
movl $0, %eax
movl $1, %eax
pushl %eax
int $0x80
いちいち解説するのが面倒な分量になってきたので割愛。argcを出力してくれる。
$ ./main 1 2 3 4 5 6 7 8 9 10
11
divlは商と余りを一気に計算してくれて、レジスタも3つ使うというのはおもしろかった。
まとめと感想
Macでx86アセンブリを書いて、システムコールを呼んだり関数を作ったりするところまでできた。
事前調査を1週間ぐらいやっていたとは言え、手を動かしたのは1日程度でこのぐらいまでできたのはだいぶ満足。Xcodeでやったのがだいぶ良かった。メモリを見られたり、好きなところにブレークポイントを置けるのはかなり助かる。
x86で調べるとLinuxの情報が出てくることが多くてMacでは通用しなくて戸惑った。x86 FreeBSDと調べると解決することが多かった。その他、nasmの情報や、GASのAT&TとIntelの情報が混在していてだいぶ調べにくかった。
その他参考リンク
-
プログラミングノート - x86
- それぞれのレジスタの用途などが参考になる
- Linux のアセンブラー: GAS と NASM を比較する
-
X86アセンブラ/x86アーキテクチャ
- eflagsの内容が詳しい
-
X86アセンブラ/GASでの文法
- アドレスオペランドの文法というところを参考にした