自作言語とそのコンパイラを Ruby で作って(一応 x86風のつもりの)オレオレVM向けにオレオレアセンブリ・オレオレ機械語を出力するということをこれまでやってきたのですが、今回はこれを改造して本物の x86_64 アセンブリを出力させてみます。
x86_64 アセンブリを触るのは今回が初めてで、ちゃんとやろうとすると大変そうなのでハードルを下げます。あんまりがんばらなくて済むようにしたい。
- 関数呼び出しと足し算だけ
- 正常系1パターンだけ動けばOK
- 「x86_64アセンブリとツールまわりを軽く触ってみた」の実績解除ができればOK
- 細かいところまで理解してなくてもOK
- 細かいところに深入りしない。深入りしないぞ!
- なんとなく雰囲気が分かればOK
今回のスコープはここまで。
これを改造します
<自作言語処理系(Ruby版)の説明用テンプレ>
自分がコンパイラ実装に入門するために作った素朴なトイ言語とその処理系です。簡単に概要を書くと下記のような感じ。
- 小規模: コンパイラ部分は 1,000 行程度
- pure Ruby / 標準ライブラリ以外のライブラリ不要
- x86風の自作VM向けにコンパイルする
- ライフゲームのために必要な機能だけ
- 変数宣言、代入、反復、条件分岐、関数定義
- 演算子:
+
,*
,==
,!=
のみ(優先順位は(
)
で明示) - 型なし(値は符号付き整数のみ) … B言語に近い?
- 作ったときに書いた備忘記事
-
本体には含めていない後付けの機能など
- 真偽値リテラル / break / if/else / 単項マイナス / Racc などを使って書いたパーサの別実装
-
他言語への移植
- コンパイラ部分のみ
- Python, Java, PHP, TypeScript, Julia, Dart, Haskell, Zig など、2022-08-21 の時点では 22言語
-
セルフホスト版
- さらに育てていくとセルフホストまでできます(できました)
- 製作過程を製作メモに全部書いています。凝ったことはしていないので Ruby 知らない人でも雰囲気くらいは分かるかも。
<説明用テンプレおわり>
機能は最低限のものに留めており、凝ったことをしていない素朴な実装なので、今回のように改造・実験したくなったときにサッといじって遊べます。
できたもの
- alt_target_x86_64_asm ブランチ
入力となるプログラムと期待する出力
先に最終形を貼っておきます。
func add(a, b) {
return a + b;
}
func main() {
var x;
call_set x = add(19, 23);
return x;
}
↑これをコンパイルしたら↓これを出力する。AT&T形式にしてみました。
.globl main
main:
call _main
mov %rax, %rdi
mov $60, %rax
syscall
add:
push %rbp
mov %rsp, %rbp
# 関数の処理本体
# return a + b;
mov 16(%rbp), %rax
push %rax
mov 24(%rbp), %rax
push %rax
pop %rcx
pop %rax
add %rcx, %rax
mov %rbp, %rsp
pop %rbp
ret
_main:
push %rbp
mov %rsp, %rbp
# 関数の処理本体
# var x;
sub $8, %rsp
# call_set x = add(23, 19);
mov $23, %rax
push %rax
mov $19, %rax
push %rax
call add add $16, %rsp
mov %rax, -8(%rbp)
# return x;
mov -8(%rbp), %rax
mov %rbp, %rsp
pop %rbp
ret
ちなみに、改造前の状態で出力されるオレオレアセンブリは以下です。作者の目からはだいたい似た感じに見えます。
call main
exit
label add
push bp
cp sp bp
# 関数の処理本体
cp [bp:2] reg_a
push reg_a
cp [bp:3] reg_a
push reg_a
pop reg_b
pop reg_a
add_ab
cp bp sp
pop bp
ret
label main
push bp
cp sp bp
# 関数の処理本体
add_sp -1
cp 23 reg_a
push reg_a
cp 19 reg_a
push reg_a
call add
add_sp 2
cp reg_a [bp:-1]
cp [bp:-1] reg_a
cp bp sp
pop bp
ret
# ... 略 ...
x86_64アセンブリを触ってみる
準備
# 010_base.s
.globl main
main:
# ここに main 関数の処理を追加していく
# exit N で終了
mov $60, %rax
mov $42, %rdi
syscall
終了ステータス=42 で終了するだけのプログラムです。これを雛形とします。
このアセンブリコードをアセンブル・リンクして、実行して、結果(終了ステータス)を表示する Bashスクリプト run.sh
がこちら。
#!/bin/bash
bname=$(basename $1 .s)
as -o ${bname}.o ${bname}.s # アセンブル
gcc -o ${bname} ${bname}.o # リンク
./${bname} # 実行
echo status=$? # 終了ステータスを表示
次のように実行します。
./run.sh 010_base.s
単に実行するだけでなく、 gdb で実行するための run_gdb.sh
も用意しました。
#!/bin/bash
bname=$(basename $1 .s)
as -o ${bname}.o ${bname}.s
gcc -o ${bname} ${bname}.o
gdb -tui ${bname}
gdb を使うのも今回始めてで、とりあえず下記の操作だけ覚えてそれでなんとかしました。短いプログラムなので今回はブレークポイントは使わず。
start
quit / q
stepi / si
info registers / i r
display
disassemble / disas
(TUI モード用)
C-x 2
C-x 2
→ 上にレジスタ、下にアセンブリが表示される
あと、 si
Enter
, si
Enter
, si
Enter
, …… のように毎回同じコマンドを入力しなくても、 Enter だけで同じコマンドを実行してくれます(最初これを知らなくてめんどくさいなーと思ってた)。
準備ができました。これを使って試していきます。
rax に即値をセットしてみる
はじめの1歩ということで。
.globl main
main:
mov $42, %rax # rax に 42 をセット
mov $1, %rdi
mov $60, %rax
syscall
gdb の TUIモードでステップ実行して rax に 42 がセットされたことを確認している様子:
rax にセットした値で exit してみる
mov $60, %rax
で rax が上書きされるので、その前に rax の値を rdi にコピーしておく。
.globl main
main:
mov $3, %rax
mov %rax, %rdi # rax の値を終了ステータスとして rdi にセット
mov $60, %rax
syscall
add 命令を使ってみる
足し算してみます。
.globl main
main:
mov $3, %rax
mov $1, %rcx
add %rcx, %rax # rax + rcx の結果を rax にセット
mov %rax, %rdi
mov $60, %rax
syscall
rbx は callee-save らしいので、rbx は避けて rcx を使ってみました。
call/ret 命令を使ってみる
call でラベル f に移動して、何もせず ret で main に戻る。以後はこれを関数の呼び出しに見立てます。
.globl main
f:
ret # 呼び出し元に戻る
main:
call f # 関数 f の呼び出し
mov $6, %rdi
mov $60, %rax
syscall
関数から値を返してみる
関数 f からの返り値を rax にセットして、 main 関数でその返り値を利用する。
.globl main
f:
mov $7, %rax
ret
main:
call f
# f から戻った時点で rax == 7 になっている
mov %rax, %rdi # 関数の返り値を利用
mov $60, %rax
syscall
足し算して結果を返却
呼び出した先の関数で足し算して、結果を返します。上でやったことを組み合わせてみたもの。
.globl main
f:
mov $3, %rax
mov $4, %rcx
add %rcx, %rax # rax == 7 になる
ret
main:
call f
# 関数の返り値 == 7
mov %rax, %rdi
mov $60, %rax
syscall
関数の準備・後片付け
スタックを利用して関数に引数を渡すための下ごしらえ。
.globl main
f:
# ★準備
push %rbp # ベースポインタをスタックに退避
mov %rsp, %rbp # ベースポインタをスタックポインタの位置まで持ってくる
# 関数の処理本体
mov $6, %rax
mov $4, %rcx
add %rcx, %rax
# ★後片付け
mov %rbp, %rsp # スタックポインタをベースポインタの位置に戻す
pop %rbp # ベースポインタをスタックから復元
ret
main:
call f
mov %rax, %rdi
mov $60, %rax
syscall
↓このあたりでやったやつ。懐かしい。
- vm2gol v2 製作メモ(11) 引数渡しの準備 / bp, push, pop
- vm2gol v2 製作メモ(13) サブルーチンに引数を1個渡す / add_sp
- vm2gol v2 製作メモ(14) 複数の引数を渡す / 返り値
関数に引数を1個渡す(渡すだけ)
(x86_64 では)普通は関数の引数はレジスタで渡すらしいのですが、スタックで渡す方式にします。コンパイラの方がスタックで渡す作りになっているので、そっちに合わせました。
この時点ではまだ引数を渡しているだけで関数の側では使っていない状態。
.globl main
f:
# 準備
push %rbp
mov %rsp, %rbp
# 関数の処理本体
mov $5, %rax
mov $6, %rcx
add %rcx, %rax
# 後片付け
mov %rbp, %rsp
pop %rbp
ret
main:
# ★引数をスタックに積む
push $7 # rsp は +8
# 関数を呼び出し
call f
# ★スタックに積んだ引数の分だけ戻す
add $8, %rsp
mov %rax, %rdi
mov $60, %rax
syscall
関数に渡した引数を使う
16(%rbp)
でスタックに置いた1つめの引数を参照できます。
.globl main
f:
# 準備
push %rbp
mov %rsp, %rbp
# 関数の処理本体
mov 16(%rbp), %rax # ★ bp+16 で引数1を参照
mov $5, %rcx
add %rcx, %rax
# 後片付け
mov %rbp, %rsp
pop %rbp
ret
main:
# 引数をスタックに積む
push $7
# 関数を呼び出し
call f
# スタックに積んだ引数の分だけ戻す
add $8, %rsp
mov %rax, %rdi
mov $60, %rax
syscall
2つ目の引数を渡して使う
2つ目の引数も同じ要領で。
.globl main
f:
# 準備
push %rbp
mov %rsp, %rbp
# 関数の処理本体
mov 16(%rbp), %rax # ★ 引数1 を参照
mov 24(%rbp), %rcx # ★ 引数2 を参照
add %rcx, %rax
# 後片付け
mov %rbp, %rsp
pop %rbp
ret
main:
# 引数をスタックに積む
push $5 # ★ 引数2
push $8 # ★ 引数1
# 関数を呼び出し
call f
# スタックに積んだ引数の分だけ戻す
add $16, %rsp
mov %rax, %rdi
mov $60, %rax
syscall
関数 f の「処理本体」の部分ではスタックが以下のようになっているはず。たぶん。
bp+0
bp+8 戻りアドレス
bp+16 引数1
bp+24 引数2
ローカル変数もやってみる
ここまでできれば必要なものが揃います。
.globl main
f:
# 準備
push %rbp
mov %rsp, %rbp
# 関数の処理本体
mov 16(%rbp), %rax # 引数1
mov 24(%rbp), %rcx # 引数2
add %rcx, %rax
# 後片付け
mov %rbp, %rsp
pop %rbp
ret
main:
# 準備 push %rbp
mov %rsp, %rbp
# ★ローカル変数を宣言
sub $8, %rsp
# 引数をスタックに積む
push $2 # 引数2
push $1 # 引数1
# 関数を呼び出し
call f
# スタックに積んだ引数の分だけ戻す
add $16, %rsp
# ★関数からの返り値をローカル変数にセット
mov %rax, -8(%rbp) # bp-8 でローカル変数を利用
# ★ローカル変数から返り値にセット
mov -8(%rbp), %rax
# 後片付け
mov %rbp, %rsp
pop %rbp
mov %rax, %rdi
mov $60, %rax
syscall
これで必要なものが揃いました。後はこんな感じのを出力するようにコード生成処理を改造すればOK。
コード生成処理の改造
システムコールの呼び出しを main の外側に追い出したかったため、コード生成時に main 関数を_main
にリネームし、 main
から呼び出す形にしてみました。
.globl main
main:
_main を呼び出し
mov %rax, %rdi # _main の返り値で exit
mov $60, %rax
syscall
add:
# ...
_main:
# ...
後は割と機械的な書き換えでOK。
--- a/vgcodegen.rb
+++ b/vgcodegen.rb
@@ -5,13 +5,13 @@ require_relative "./common"
$label_id = 0
def asm_prologue
- puts " push bp"
- puts " cp sp bp"
+ puts " push %rbp"
+ puts " mov %rsp, %rbp"
end
def asm_epilogue
- puts " cp bp sp"
- puts " pop bp"
+ puts " mov %rbp, %rsp"
+ puts " pop %rbp"
end
def to_fn_arg_disp(fn_arg_names, fn_arg_name)
@@ -27,10 +27,11 @@ end
# --------------------------------
def _gen_expr_add
- puts " pop reg_b"
- puts " pop reg_a"
+ # rbx は callee-save なので避ける
+ puts " pop %rcx"
+ puts " pop %rax"
- puts " add_ab"
+ puts " add %rcx, %rax"
end
def _gen_expr_mult
@@ -92,9 +93,9 @@ def _gen_expr_binary(fn_arg_names, lvar_names, expr)
operator, arg_l, arg_r = expr
gen_expr(fn_arg_names, lvar_names, arg_l)
- puts " push reg_a"
+ puts " push %rax"
gen_expr(fn_arg_names, lvar_names, arg_r)
- puts " push reg_a"
+ puts " push %rax"
case operator
when "+" then _gen_expr_add()
@@ -109,15 +110,15 @@ end
def gen_expr(fn_arg_names, lvar_names, expr)
case expr
when Integer
- puts " cp #{expr} reg_a"
+ puts " mov $#{expr}, %rax"
when String
case
when fn_arg_names.include?(expr)
disp = to_fn_arg_disp(fn_arg_names, expr)
- puts " cp [bp:#{disp}] reg_a"
+ puts " mov #{disp * 8}(%rbp), %rax"
when lvar_names.include?(expr)
disp = to_lvar_disp(lvar_names, expr)
- puts " cp [bp:#{disp}] reg_a"
+ puts " mov #{disp * 8}(%rbp), %rax"
else
raise panic("expr", expr)
end
@@ -133,12 +134,12 @@ def _gen_funcall(fn_arg_names, lvar_names, funcall)
fn_args.reverse.each do |fn_arg|
gen_expr(fn_arg_names, lvar_names, fn_arg)
- puts " push reg_a"
+ puts " push %rax"
end
gen_vm_comment("call #{fn_name}")
puts " call #{fn_name}"
- puts " add_sp #{fn_args.size}"
+ puts " add $#{fn_args.size * 8}, %rsp"
end
def gen_call(fn_arg_names, lvar_names, stmt)
@@ -152,17 +153,17 @@ def gen_call_set(fn_arg_names, lvar_names, stmt)
_gen_funcall(fn_arg_names, lvar_names, funcall)
disp = to_lvar_disp(lvar_names, lvar_name)
- puts " cp reg_a [bp:#{disp}]"
+ puts " mov %rax, #{disp * 8}(%rbp)"
end
def _gen_set(fn_arg_names, lvar_names, dest, expr)
gen_expr(fn_arg_names, lvar_names, expr)
- src_val = "reg_a"
+ src_val = "%rax"
case
when lvar_names.include?(dest)
disp = to_lvar_disp(lvar_names, dest)
- puts " cp #{src_val} [bp:#{disp}]"
+ puts " mov #{src_val}, #{disp * 8}(%rbp)"
else
raise panic("dest", dest)
end
@@ -290,7 +291,7 @@ def gen_stmts(fn_arg_names, lvar_names, stmts) end
def gen_var(fn_arg_names, lvar_names, stmt)
- puts " add_sp -1"
+ puts " sub $8, %rsp"
if stmt.size == 3
_, dest, expr = stmt
(他の部分もいくつか修正していますが、主な部分はこのあたり)
コンパイルして実行
コンパイルして実行まで行うスクリプトを用意して、
#!/bin/bash
mkdir -p ./tmp/
file="$1"
bname=$(basename $file .vg.txt)
tokensfile=tmp/${bname}.tokens.txt
treefile=tmp/${bname}.ast.json
asmfile=tmp/${bname}.s
objfile=tmp/${bname}.o
# 字句解析
ruby vglexer.rb $file > $tokensfile
# 構文解析
ruby vgparser.rb $tokensfile > $treefile
# コード生成(アセンブリコードを生成)
ruby vgcodegen.rb $treefile > $asmfile
# アセンブル+リンクして実行ファイルを生成
as -o $objfile $asmfile
gcc -o $bname $objfile
# 実行
./${bname}
status=$?
echo status=${status}
実行。
$ cat sample_x86_64.vg.txt
func add(a, b) {
return a + b;
}
func main() {
var x;
call_set x = add(19, 23);
return x;
}
$ ./run_x86_64.sh sample_x86_64.vg.txt
status=42
動きました!
というわけで今回はこれでOKです。満足。
せっかくなので objdump -d
せっかくなので実行ファイルを逆アセンブルしてみます。以下、 main, add, _main だけを抜粋したもの。
$ objdump -d sample_x86_64
... snip ...
00000000000005fa <main>:
5fa: e8 24 00 00 00 callq 623 <_main>
5ff: 48 89 c7 mov %rax,%rdi
602: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
609: 0f 05 syscall
000000000000060b <add>:
60b: 55 push %rbp
60c: 48 89 e5 mov %rsp,%rbp
60f: 48 8b 45 10 mov 0x10(%rbp),%rax
613: 50 push %rax
614: 48 8b 45 18 mov 0x18(%rbp),%rax
618: 50 push %rax
619: 59 pop %rcx
61a: 58 pop %rax
61b: 48 01 c8 add %rcx,%rax
61e: 48 89 ec mov %rbp,%rsp
621: 5d pop %rbp
622: c3 retq
0000000000000623 <_main>:
623: 55 push %rbp
624: 48 89 e5 mov %rsp,%rbp
627: 48 83 ec 08 sub $0x8,%rsp
62b: 48 c7 c0 17 00 00 00 mov $0x17,%rax
632: 50 push %rax
633: 48 c7 c0 13 00 00 00 mov $0x13,%rax
63a: 50 push %rax
63b: e8 cb ff ff ff callq 60b <add>
640: 48 83 c4 10 add $0x10,%rsp
644: 48 89 45 f8 mov %rax,-0x8(%rbp)
648: 48 8b 45 f8 mov -0x8(%rbp),%rax
64c: 48 89 ec mov %rbp,%rsp
64f: 5d pop %rbp
650: c3 retq
651: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
658: 00 00 00
65b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
... snip ...
call, ret が callq, retq になってるな、とか、retq の後に nop が加えられてるな、といったあたりをなんとなく眺めます。今回はなんとなく眺めるだけ。
参考
-
第二回 簡易アセンブラとディスアセンブラを作ろう | karino2の暇つぶしプログラム教室 C言語編
- アセンブリ入門の心構え
-
x86_64 プログラミング入門
- gdb の使い方
-
GNU/Linux (x86/x86-64) のシステムコールをアセンブラから呼んでみる - CUBE SUGAR CONTAINER
- exit(2) の呼び方など