プログラミング言語 自作入門の続編 (以下「続編」)を読むに当たり、機械語 の基礎知識をインプットしておきたいと考えて、まず「作って分かる!x86_64機械語入門」を読んでみた のだった。
この本のおかげで今のところ続編のHL-12までスムーズに読み書きすることができている。
あくまでも現時点での感想にすぎないのだが、続編のコードを改造する際は、直に機械語 を書くよりもディスアセンブル して機械語 を生成した方が楽だと感じた。
そこで最低限のアセンブリ言語 の読み書きができるようになっておこうと考えてインプットに使えそうなコンテンツを探した。
そして見つけたのがこの動画だ。ペアプロ の様子を撮っているのでものすごく分かりやすい!
VIDEO youtu.be
動画では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システムコール を呼ぶということだ。
動画 3:15
@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をやっておこう。
動画 16:12
@d0iasm
「syswriteは1番っぽい」(システムコール 番号)
「引数が、ファイルディスクリプタ を1番目の引数に取って、2番目がバッファのポインタ、3番目がサイズっぽい」
writeシステムコール を使ってfizzbuzz .sを次のように編集する。
.global main
main :
mov $0xd ,%edx
lea (string ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
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回表示するプログラムに改造してみよう。
動画 25:00
@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 :
mov $0x1 ,%edx
lea (numbers ),%eax
lea 1 (%eax ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
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 :
mov $0x1 ,%edx
mov $1 ,%eax
lea (numbers )(%eax ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
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 :
mov $0x1 ,%edx
push %ecx
mov %ecx ,%eax
lea (numbers )(%eax ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
pop %ecx
dec %ecx
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」
という具合になる。
動画 34:32
@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
push %edx
cmp $0x0 ,%eax
jne split_loop
print_loop :
mov $0x1 ,%edx
pop %eax
push %ecx
lea (numbers )(%eax ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
pop %ecx
cmp %esp ,%ebp
jne print_loop
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
よし、複数桁の数も表示できるようになった!
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
cmp $0x0 ,%edx
jne print_fizz
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
cmp $0x0 ,%edx
jne print_buzz
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
cmp $0x0 ,%edx
jne print_number
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
push %edx
cmp $0x0 ,%eax
jne split_loop
print_digit :
mov $0x1 ,%edx
pop %eax
push %ecx
lea (numbers )(%eax ),%ecx
mov $0x1 ,%ebx
mov $0x4 ,%eax
int $0x80
pop %ecx
cmp %esp ,%ebp
jne print_digit
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
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
おー、上手くいった。これでおしまい。
後日、書き換えたバージョン
おわりに
書き始めた時、この記事はコマンドとコードを羅列した備忘録でしたが、書いている途中でふと思いついて、アセンブリ言語 に入門しようとしている数時間前の自分に向けて語りかけるような文を添えてみました。
何らかの言語の入門者向けに段階的にコードを追加していく記事をいつか書いてみたいと思っていたので、「そうだ自分が入門するついでに書いてしまえ」と思ったのです。
入門者の私がアセンブリ言語 の説明をするのも変ですのでそれは動画に譲るとして、時には以前のコードに戻りながら小さくプログラムを作っていく様子が伝わると良いなと思います。
冒頭にも書いたように私が取り組んでいるプログラミング言語 自作入門の続編 の処理系を改造しようとすると機械語 の知識が必要になります。
処理系を改造する際にディスアセンブル して機械語 を生成したら少しは楽になるのではないかという単純な考えで、アセンブリ言語 に入門することにしました。
でも、何をどーすればいいんだろう? と悩んでいたところ、偶然見つけた動画に救われて、なんとか無事に(?)アセンブリ言語 に入門することができました。良い時代ですね、本当に。
動画を公開してくれたお二人と、この動画をシェアしてくれた人たちに感謝します。ありがとうございました。
アイキャッチ 画像