プログラミング言語自作入門の続編(以下「続編」)を読むに当たり、機械語の基礎知識をインプットしておきたいと考えて、まず「作って分かる!x86_64機械語入門」を読んでみたのだった。
この本のおかげで今のところ続編のHL-12までスムーズに読み書きすることができている。
あくまでも現時点での感想にすぎないのだが、続編のコードを改造する際は、直に機械語を書くよりもディスアセンブルして機械語を生成した方が楽だと感じた。
そこで最低限のアセンブリ言語の読み書きができるようになっておこうと考えてインプットに使えそうなコンテンツを探した。
そして見つけたのがこの動画だ。ペアプロの様子を撮っているのでものすごく分かりやすい!
動画ではFizzBuzzをIntel方式のx64-86アセンブリで書いているが、AT&T方式のx86 (32bit) アセンブリで書くことにする。
これは、続編の冒頭の「なぜ現在主流の64bitではなく、32bitにしたのか?」の内容を読んで、しばらくは(入門の際は)32bitで書こうと考えたためだ。
早速、見よう見まねで書いていこう。
ちなみに私の環境は、Pentium Silver J5005搭載 NUC (Ubuntu 20.04 LTS 日本語 Remix) 。build-essentialパッケージやlibc6-dev-i386パッケージはインストール済みだ。
まずは何もしないexitするだけのプログラムを作る
ここで「何もしない」とはexitシステムコールを呼ぶということだ。
@hikalium
「あー、でもreturn 0だとexitシステムコールは呼ばれないかな、プログラムの中では。return 0すると戻った先でexitシステムコールが呼ばれるけど、そのexitシステムコールはプログラムの中にはない」@d0iasm
「ふーん、確かに!」
x86 (32bit) でシステムコールを呼ぶ場合は、int n命令のオペランドに0x80番を指定して割り込みハンドラへのコールを生成する。
int $0x80
を使う際は、あらかじめシステムコール番号をeaxに格納しておく。
システムコール番号を調べてみる。
$ vi /usr/include/i386-linux-gnu/sys/syscall.h
/* This file should list the numbers of the system calls the system knows. But instead of duplicating this we use the information available from the kernel sources. */ #include <asm/unistd.h>
ふむ。
$ vi /usr/include/i386-linux-gnu/asm/unistd.h
# ifdef __i386__ # include <asm/unistd_32.h> # elif defined(__ILP32__) # include <asm/unistd_x32.h> # else # include <asm/unistd_64.h> # endif
ふむふむ。
$ vi /usr/include/i386-linux-gnu/asm/unistd_32.h
#ifndef _ASM_X86_UNISTD_32_H #define _ASM_X86_UNISTD_32_H 1 #define __NR_restart_syscall 0 #define __NR_exit 1 // 見つけた! #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6 ...
exitのシステムコール番号は0x1だと分かったので、fizzbuzz.sを作成して次のように編集する。ebxに格納しているのは第一引数だ。
.global main main: mov $0x0,%ebx mov $0x1,%eax int $0x80
このアセンブリファイルをgccに-cオプションをつけてアセンブルした上で、ldに-eオプションでエントリーポイントを指定して実行可能バイナリを生成する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o
fizzbuzz.binを実行すると何も表示されない(成功)。
$ ./fizzbuzz.bin $
hello, worldする(文字列を表示する)
もっとテンションを上げたいので、お約束のhello, worldをやっておこう。
@d0iasm
「syswriteは1番っぽい」(システムコール番号)
「引数が、ファイルディスクリプタを1番目の引数に取って、2番目がバッファのポインタ、3番目がサイズっぽい」
writeシステムコールを使ってfizzbuzz.sを次のように編集する。
.global main main: # write mov $0xd,%edx lea (string),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 # exit mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n"
ここではlea命令(Load Effective Address)を使って、文字列を置いた領域の実効アドレスをロードしている。
@hikalium
「アドレスを代入するには、mov rsi,string
だと上手くいかなくって、lea rsi,[string]
」
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin hello, world
よし。アセンブリ言語に入門している実感が沸々と湧いてきたぞ。
ループを導入する
次は、hello, worldを3回表示するプログラムに改造してみよう。
@hikalium 「決まった回数でやりたいなら0と比較するのが一番楽だから、最初に繰り返したい回数を入れて、それで0かどうかをチェックすればいいと思う」
@hikalium
「x86では、分岐命令は条件フラグ方式というのになっていて、演算結果によってセットされる条件フラグを参照してジャンプする」
fizzbuzz.sを次のように編集する。
.global main main: mov $0x3,%ecx loop: mov $0xd,%edx push %ecx lea (string),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx dec %ecx jz exit jmp loop exit: mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n"
writeを呼ぶ前後でpush
pop
しているのは、システムコールの呼び出しによって値が破壊されないようにするためだ。
@hikalium
「rcxが保存されないのかも、syscallで」@d0iasm
「システムコールが使っているレジスタはクリアされちゃうってこと?」@hilakium
「そうそう。呼び出し規約によって破壊していいレジスタと保存しなければいけないレジスタが決まっていて、たぶんrcxは破壊していいレジスタになっているんじゃないかな」@d0iasm
「なるほどね」@hikalium
「だから、それを防ぐためにsyscallの前後でrcxを保存する必要があるかも」
「とりあえず、syscallの前後にpush
pop
を入れておけばいいんじゃないかな」
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin hello, world hello, world hello, world
よし、上手くいった。次はカウンタの値を表示できるようにしたい。
1桁の数を表示する
動画のfizzbuzzは数を表示する代わりに改行を表示する形式で書いてある。
カウンタの値を表示するだけなら大して難しくなさそうな気がしたので試しにやってみることにした。
手始めに、上でhello, worldした(ループを導入する前の)コードを改造して1桁の数を表示する。
fizzbuzz.sを次のように編集する。
.global main main: # write mov $0x1,%edx lea (numbers),%eax lea 1(%eax),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 # exit mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n" numbers: .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9
lea 1(%eax),%ecx
の「1」は、numbersラベルを書いたアドレスからの相対位置を表す。
これが何なのかは、色々と説明するよりも実行結果を確認した方が話が早そうだ。
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin 1$
改行を入れていないので若干読みづらいが、プロンプトの手前にちゃんと文字「1」が表示されている。
lea 1(%eax),%ecx
をlea 2(%eax),%ecx
に変更して、fizzbuzz.binを実行すると次のようになる。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin 2$
今度は、numbersラベルを書いたアドレスからの相対位置が2なので、その位置にある文字「2」が表示されている。
じゃあ相対位置を3にするとどうなるだろうか?
勘のいい人は、私のやりたいことに気づいたかもしれない。そう、できるだけ簡単な方法で済ませてしまおう。
この先の改造がやりやすいように、文字「1」を表示するための上のコードを次のように書き直しておく。
.global main main: # write mov $0x1,%edx mov $1,%eax lea (numbers)(%eax),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 # exit mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n" numbers: .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9
これが差分だ。
そして、再びループを導入する。
.global main main: mov $0x3,%ecx loop: # print a number mov $0x1,%edx push %ecx mov %ecx,%eax lea (numbers)(%eax),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx dec %ecx # print a newline mov $0x1,%edx push %ecx lea (newline),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx jz exit jmp loop exit: mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n" newline: .byte '\n numbers: .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin 3 2 1
よし、とりあえず1桁の数は表示できるようになった。
もっと良い書き方があるかもしれないけど、今は気にしないでおこう!(と、自分に言い聞かせるのであった)
複数桁の数を表示する
1桁の数は表示できるようになったが、今の実装では2桁以上の数を表示することができない。
今の実装の延長で複数桁の数を表示するには、各桁の値を1つずつ取り出して並べ、最後に改行を出力すればいい。
各桁の値を取り出すには、まず元の数を基数で割って、その商をまた基数で割って、その商を…という具合に、余りが0になるまで繰り返し割る。
例えば「123」なら
- 123 / 10 -> 商:12 余り:3
- 12 / 10 -> 商:1 余り:2
- 1 / 10 -> 商:0 余り:1
- 商が0になったら、余りを1つずつ並べる -> 「1」「2」「3」
という具合になる。
@d0iasm
「余りを求める命令とか、ないのかな?(困)」@hikalium
「あるよ。あるんだけどね[…]それがx86的でね[…]ちなみにdivっていう命令なんだけど」@d0iasm
「divは、ただの割り算?」@hikalium
「そう。でもね、divはね、割り算をするのと同時に余りも返してくれるの」@d0iasm
「あ、なるほどね! 余りもどっかのレジスタに入れてくれるわけだ」@hikalium
「そう!」
「div
って書いた後にレジスタかメモリオペランドの64ビットの大きさのものを1つ置けるんだけど、それはどういう操作をするかというと、rdxとraxの組に入っている数値を、その指定されたレジスタの数値で割って、割った答えをraxに、割った余りをrdxに格納するっていう命令です」@d0iasm
「ほお(笑)」@hikalium
「うふふ(笑)」
これね、最後に二人が笑っている気持がわかるのよね。私もつい最近プログラミング言語自作入門のHL-11aを実装した際に、idiv命令とidiv命令が使うレジスタの関係を初めて知って「んん?」と戸惑ったからね(笑)
慣れるまでは面倒そうだけど、ぼちぼち書いてみよう。
div命令に係るインストラクションにコメントをつけておいた。
.global main main: mov %esp,%ebp mov $0xf,%ecx # カウンタ mov %ecx,%eax # 被除数 split_loop: mov $0xa,%ebx # 除数 mov $0x0,%edx # ゼロ拡張 div %ebx # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る) push %edx # 余りをスタックに格納 cmp $0x0,%eax # 商がゼロか jne split_loop print_loop: # print single digit number mov $0x1,%edx pop %eax # split_loopでpushした1桁の数をpopする push %ecx lea (numbers)(%eax),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx cmp %esp,%ebp # スタックが空か jne print_loop # print a newline mov $0x1,%edx push %ecx lea (newline),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx dec %ecx jz exit mov %ecx,%eax # 被除数 jmp split_loop exit: mov $0x0,%ebx mov $0x1,%eax int $0x80 string: .ascii "hello, world\n" newline: .byte '\n numbers: .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1
よし、複数桁の数も表示できるようになった!
Fizz、Buzz、FizzBuzzを表示する(完成)
3の倍数のときにFizzと表示し、5の倍数のときにBuzzと表示し、15の倍数のときFizzBuzzと表示するようにプログラムを改造する。
コードが長くなったが、よく見ると「print_fizzbuzz:」「print_fizz:」「print_buzz:」のラベル以下のそれぞれのコードは、オペランドに取る値(除数など)が変わるだけで処理は同じだ。
やっていることはこれまでと同じ。新しいことはしていない。
これで最後なので一気に書き上げてしまおう。
.global main main: mov %esp,%ebp mov $0x1,%ecx # カウンタ loop: print_fizzbuzz: mov %ecx,%eax # 被除数 mov $0xf,%ebx # 除数 mov $0x0,%edx # ゼロ拡張 div %ebx # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る) cmp $0x0,%edx # 余りがゼロか jne print_fizz # write mov $0x9,%edx push %ecx lea (fizzbuzz),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx jmp increment_counter print_fizz: mov %ecx,%eax # 被除数 mov $0x3,%ebx # 除数 mov $0x0,%edx # ゼロ拡張 div %ebx # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る) cmp $0x0,%edx # 余りがゼロか jne print_buzz # write mov $0x5,%edx push %ecx lea (fizz),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx jmp increment_counter print_buzz: mov %ecx,%eax # 被除数 mov $0x5,%ebx # 除数 mov $0x0,%edx # ゼロ拡張 div %ebx # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る) cmp $0x0,%edx # 余りがゼロか jne print_number # write mov $0x5,%edx push %ecx lea (buzz),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx jmp increment_counter print_number: mov %ecx,%eax # 被除数 split_loop: mov $0xa,%ebx # 除数 mov $0x0,%edx # ゼロ拡張 div %ebx # 符号なし除算(edxとeaxを連結した値をオペランドの値で割る) push %edx # 余りをスタックに格納 cmp $0x0,%eax # 商がゼロか jne split_loop print_digit: mov $0x1,%edx pop %eax # split_loopでpushした1桁の数をpopする push %ecx lea (numbers)(%eax),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx cmp %esp,%ebp # スタックが空か jne print_digit # print a newline mov $0x1,%edx push %ecx lea (newline),%ecx mov $0x1,%ebx mov $0x4,%eax int $0x80 pop %ecx increment_counter: inc %ecx cmp $0x10,%ecx jne loop # exit mov $0x0,%ebx mov $0x1,%eax int $0x80 fizzbuzz: .ascii "FizzBuzz\n" fizz: .ascii "Fizz\n" buzz: .ascii "Buzz\n" newline: .byte '\n numbers: .byte '0,'1,'2,'3,'4,'5,'6,'7,'8,'9
もっと良い書き方があるかもしれないけど、今は気にしないでおこう!(と、自分に言い聞かせるのであった)
fizzbuzz.binを実行する。
$ gcc -m32 -c -o fizzbuzz.o fizzbuzz.s && ld -m elf_i386 -e main -o fizzbuzz.bin fizzbuzz.o $ ./fizzbuzz.bin 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
おー、上手くいった。これでおしまい。
おわりに
書き始めた時、この記事はコマンドとコードを羅列した備忘録でしたが、書いている途中でふと思いついて、アセンブリ言語に入門しようとしている数時間前の自分に向けて語りかけるような文を添えてみました。
何らかの言語の入門者向けに段階的にコードを追加していく記事をいつか書いてみたいと思っていたので、「そうだ自分が入門するついでに書いてしまえ」と思ったのです。
入門者の私がアセンブリ言語の説明をするのも変ですのでそれは動画に譲るとして、時には以前のコードに戻りながら小さくプログラムを作っていく様子が伝わると良いなと思います。
冒頭にも書いたように私が取り組んでいるプログラミング言語自作入門の続編の処理系を改造しようとすると機械語の知識が必要になります。
処理系を改造する際にディスアセンブルして機械語を生成したら少しは楽になるのではないかという単純な考えで、アセンブリ言語に入門することにしました。
でも、何をどーすればいいんだろう? と悩んでいたところ、偶然見つけた動画に救われて、なんとか無事に(?)アセンブリ言語に入門することができました。良い時代ですね、本当に。
動画を公開してくれたお二人と、この動画をシェアしてくれた人たちに感謝します。ありがとうございました。