tags | |
---|---|
|
コンパイラ開発の調査,勉強がてら, "Introduction to X86-64 Assembly for Compiler Writers" という資料を読んだ. どうやら,The University of Notre Dame(ノートルダム大学?) では,学部の授業で,"C-minor"というCライクな言語のコンパイラを作るというものがあり,その授業資料の一部のようだ.
資料の内容としては,x86アーキテクチャに関する基本的な説明をして,呼び出し規約を紹介した後にCのコードをアセンブリに変換する例をいくつか挙げている,といった流れだ.
途中から翻訳しながら読んでいたのだが,最初の方は翻訳していない. 自分にわかれば良いような,適当な訳も含まれていて,不完全な状態だが,後々のために残しておこうと思う.
なお,元の資料へのリンクは以下を参照されたい. "Introduction to X86-64 Assembly for Compiler Writers" また,元の資料はCreative Commons Attribution-ShareAlike 4.0 International Licenseでライセンスされている.
未翻訳
未翻訳
x86-64は64ビット整数の(大抵)汎用のレジスタを16個持っている.
- %rax
- %rbx
- %rcx
- %rdx
- %rsi
- %rdi
- %rbp
- %rsp
- %r8
- %r9
- %r10
- %r11
- %r12
- %r13
- %r14
大抵というのは,プロセッサの早期バージョンは,各レジスタが特定の用途で使われることを想定していて,すべての命令に適用できなかったからだ.デザインが開発され,新しい命令とアドレッシング・モードが追加され,様々なレジスタがほぼ等しくなった.
いくつかの命令,特に文字列処理に関するものは,rsi
, rdi
レジスタを使う必要がある.
また, rsp
とrbp
はそれぞれスタックポインタとベースポインタ用に予約されている.
それ以外の,番号付きの 8 つのレジスタは,特に制約はない.
長年に渡って,アーキテクチャは 8, 16, 32 ビットと拡張されてきている. それに関連して,それぞれのレジスタは,あなたが知っておくべき内部的な構造を持っている.
rax
レジスタの下位 8 ビットはal
という 8 ビットのレジスタで,次の 8 ビットは ah
として知られている.
下位16ビットax
,32ビットはeax
,64ビットはrax
として知られている.
r8
からr15
の番号付きのレジスタは,少し違った命名だが,同様の構造を持っている.
シンプルにするために,我々は64ビットレジスタにフォーカスを当てる(C-minorは明示的に64ビットの算術を使うように設計してある)
しかし,ほとんどの実際のコンパイラは,ミックスしたモードを使う. 32ビットレジスタは一般的に整数の算術に使われる.プログラマは整数値に32ビット以上必要としないからだ. 64ビットレジスタは一般的にメモリアドレスを格納するのに使用される. 16EBの仮想メモリをアドレッシングすることが可能だ.
あなたが知るべき最初の命令は mov
だ.この命令はメモリ,またはレジスタ感でデータを移動する.
x86-64はComplex Instruction Set Computer(CISC)なので,mov命令は,異なるセル間で異なるタイプのデータを移動する,たくさんの別形がある.
mov
をはじめ,ほとんどの命令は,移動するデータ量を決定するための1文字の接尾辞を持っている.
異なるサイズのデータ値を表すために,以下の名前を使う.
Suffix | Name | Size |
---|---|---|
B | BYTE | 1byte |
W | WORD | 2bytes |
L | LONG | 4bytes |
Q | QUADWORD | 8bytes |
なので, movb
はバイト,movwはワード,movlはロングを,movqはクアッドワードを移動する.
一般的に,移動先と移動元のサイズは一致していなければならない.
接尾辞を取り去ること可能で,その場合,アセンブラは引数から正しいサイズを選択する.
しかし,予期しない影響を及ぼすので推奨されていない.
mov
の引数は一つのアドレッシング・モードを持つことができる.
"global value" は単に,x
やprintf
のように,修飾されていない名前のことを指す.
"immediate value"は定数値で,$56
のようにドル記号で表される.
"register value"は%rbx
のようなレジスタの名前のことだ.
"indirect"はレジスタに含まれる値をアドレスとして参照する.
たとえば, (%rsp)
は %rsp
が指す値を参照する.
"base-relative value" はレジスタ名に定数を加えることで与えられる.
例えば,-16(%rcx)
は%rcx
が表すアドレスの16バイト前のメモリアドレスの場所の値を指す.
このノードは,スタックやローカルの値,関数の引数を操作するのに重要である.
"base-relative"の複雑なバリエーションの別形がある.
例えば, -16(%rbx, %rcx, 8)
は-16 + %rbx + %rcx * 8
のアドレスの値を指す.
このモードは配列の中の,稀なサイズの要素にアクセスするのに便利である.
ここに,それぞれのアドレッシング・モードで%rax
に64ビットの値を読み込む例を示す.
Mode | Examle |
---|---|
Global Symbol | movq x, %rax |
immediate | movq $56, %rax |
Register | movq %rbx, %rax |
indirect | movq (%rsp), %rax |
Base-Relative | movq -8(%rbx), %rax |
Offset-Scaled-Base Relative | movq -16(%rbx, %rcx, 8), %rax |
ほとんどのパートでは,データを保存するのに,レジスタとメモリの位置に,同じアドレッシング・モードを用いるだろう.
しかし,すべてのモードはサポートされていない.
例えば,movq -8(%rbx), -8(%rbx)
のように, movの引数の両方に"base-relative"を使うことは出来ない.
どのアドレッシング・モードの組み合わせがサポートされているかを正確に見るには,問題の命令のマニュアルページを読まなければならない.
あなたのコンパイラには,4つの基本的な算術命令が必要だろう.ADD, SUB, IMUL, IDIVだ. ADDとSUBは2つのオペランドを持つ.sourceとdestructive targetだ. 例えば,この命令は,
ADDQ %rbx, %rax
%rbx
と%rax
を足して,%rax
に結果を置く.以前そこに合ったものを上書きする.
これは,あなたがどのようにレジスタを使うかについて,少し気をつける必要がある.
例えば,a
とb
がグローバルな整数で,c=c*(b+a)
をトランスパイルしたいとする.
これを行うには,足し算を行う際に,bの値を上書きしないように気をつけなければならない.
ここに,一つの考えられる変換を示す.
movq a, %rax
movq b, %rbx
addq %rbx, %rax
imulq %rbx
movq %rax, c
imul
命令は少し変わっている.
これは引数を%rax
の値で乗算して,結果の下位64ビットを%rax
に,上位64ビットをrdx
に置く.(64ビットの数字の乗算は128ビットの数字を生み出す)
idiv
命令も,以下のことを除けば同様のことをする.
この命令は下位64ビットが%rax
,上位64ビットがrdx
にある,128ビットの整数で始まり
これを命令で与えられた値で除算する.
その商は %rax
に,余りは%rdx
に置かれる.
例えば,5で割るには,以下のようになる.(CDQO命令は,負の値を正しく扱うために,%rax
を%rdx
に符号拡張する,とても特殊な目的を扱う)
movq a, %rax # "割られる数"の下位64ビットを配置
cdqo # %raxを%rdxに符号拡張する
idivq $5 # %rdx:%rax を5で割る.結果は %raxに置かれる
訳注:
CDQ
であれば"Convert Double to Quad" だろうという推測をしているが, CDQOについては予測がつかない.
inc
, dec
命令はレジスタを破壊的にインクリメント,デクリメントする.
例えば, a = ++b
は以下のように変換できる.
movq b, %rax
incq %rax
movq %rax, a
論理演算子も似たようなマナーで動作する.and
, or
, xor
は2つのオペランドで,破壊的に論理演算を行う. not
は1つのオペランドで破壊的に動作する.
mov
命令のように,様々な算術命令は複数のアドレッシング・モードで動作する.
しかしながら,あなたのコンパイラプロジェクトでは,mov
で,レジスタ内外に値をロードし,
レジスタのみを使用して算術を行うのが,最も便利であることに気づくだろう.
浮動小数点の操作の詳細についてはカバーしないが,違った命令セット,レジスタで処理することを最低限知っておくべきだろう. 古いマシンでは,浮動小数点命令は8087 FPUと呼ばれる外部のチップで処理されており, それゆえ,今はその能力はCPUに内蔵されているものの,x87オペレーションとして説明されている.
x87 FPUはスタックに配置された8つの80ビットレジスタ(R0
~R7
)を含む.
浮動小数点の算術をするには,コードはデータをFPUスタックにプッシュし,スタックのトップを暗黙的に操作し,メモリにアイテムを書き出す命令を発行しなければならない.
メモリでは倍精度の数字が64ビット値で格納されている.
このアーキテクチャの奇妙なところは,内部表現(80ビット)はメモリ上の表現(64ビット)に比べて高い精度を持っていることだ. 結果として,浮動小数点計算の値は,値がレジスタ・メモリ間を移動する際の正確な順番によって変化することがある.
浮動小数点の計算は,最初に現れたよりも奇妙だが,以下を読むことをおすすめする.
- Chapter 8-1 of the Intel manual.
- David Goldberg, What Every Computer Scientist Should Know About Floating Point Arithmetic, ACM Computing Surveys, volume 23, issue 1, March 1991.
- A more informal guide: What Every Programmer Should Know about Floating Point
JMP命令を使うと,%rax
レジスタを用いて,ゼロからカウントアップするシンプルな無限ループを作成できる.
movq $0, %rax
loop:
incq %rax
jmp loop
終了するループや,if-then文のような,より便利な構造を定義するには,値の評価とプログラムフローの変更のメカニズムが必要だ. 殆どのアセンブリ言語では,これらは違った種類の命令で処理される.比較とジャンプだ.
すべての比較は cmp
命令でできる.CMP
は2つの異なるレジスタを比較し,いくつかのビットを,内部の eflags
レジスタにセットし,値が等しい,大きい,小さいを記録する.
あなたは EFLAGS レジスタを直接見る必要はない. 代わりに,以下の命令が EFLAGSを見て,適切にジャンプを行う.
Instruction | Meaning |
---|---|
JE | Jump If Equal |
JNE | Jump If Not Equal |
JL | Jump If Less Than |
JLE | Jump If Less or Equal |
JG | Jump If Greater Than |
JGE | Jump If Greater or Equal |
例えば, %rax
を0から5にカウントするループの例を示す
movq $0, %rax
loop:
incq %rax
cmpq $5 %rax
JLE loop
また,以下は条件付き代入だ.グローバル変数 x
がゼロより大きければ グローバル変数 y
は10になり,それ以外の場合は 12 になる.
movq x, %rax
cmpq $0, %rax,
JLE twenty
ten:
movq $10, %rbx
jmp done
twenty:
movq $12, %rbx
jmp done
done:
movq %rbx, y
ジャンプは,コンパイラがターゲットのラベルを定義する必要がある.
これらのラベルは,一意で,.globl
ディレクティブが与えられていない限り,一つのアセンブリファイル内でプライベートでなければならない.
スタックは,補助的なデータ構造で,主に,レジスタに収まらないローカル変数を伴うプログラムの,関数呼び出しのヒストリを記録するために用いられる,
慣習として,スタックは上位の値から下位の値に,下向きに成長する.
%rsp
レジスタは "スタックポインタ"として知られ,スタックの最も下のアイテムを追跡する.
したがって, %rax
をスタックにプッシュするには, %rsp
から8バイト引いてから,%rsp
が指す場所を書き込まなければならない.
subq $8 %rsp
movq %rax, (%rsp)
スタックから値をポップするには,反対のことを行う.
movq (%rsp) %rax
addq $8 %rsp
最も最新の値をスタックから破棄するには,単にスタックポインタを動かせば良い.
addq $8, %rsp
もちろん,%rsp
を参照してスタックからのプッシュ,ポップを行うことはとても一般的なので,
2つの操作は上記と同様に動作する命令が用意されている.
pushq %rax
popq %rax
Cの標準ライブラリで利用可能なすべての関数は,アセンブリ言語のプログラムでも利用可能である. 関数は,「呼び出し規約」として知られる,標準的な方法で呼び出されるので,複数の言語で書かれたコードはすべて互いにリンクできる.
ほとんどのアセンブリ言語では,呼び出し規約は単にそれぞれの引数をスタックにプッシュし,関数を呼び出すだけだ. 呼び出された関数はスタック上にある引数を見て,処理を行い,結果を一つのレジスタに返す. その後,呼び出し元は,引数をスタックからポップする.(実際,これは丁度x86 32ビットコードの呼び出し規約で,あなたが自分のコードでのみ作業する場合,これを使い続けることができる)
Linux上のx86-64で使われている呼び出し規約はやや異なり,System V ABIと呼ばれている.
完全な規約はやや複雑だが,以下のシンプルにした説明で我々には十分だろう.
- 整数の引数(ポインタを含む)は,
%rdi
,%rsi
,%rdx
,%rcx
,%r8
, と%r9
,にこの順番に置かれる. - 浮動小数点の引数は
%xmm0
-%xmm7
のレジスタにこの順番で置かれる. - 残りの引数はスタックにプッシュされる
- 関数が可変長の引数を取る場合,(
printf
のように),%rax
には浮動小数点引数の数がセットされなければならない - 呼び出された関数は任意のレジスタを使用できるが,
%rbx
,%rbp
,%rsp
,と%r12
-%r15
を変更する場合,その値を復元しなければならない. - 関数の返り値は
%rax
に置かれる
知っておく必要のある事柄を表に要約した.
Register | Purpose | Saved? |
---|---|---|
%rax | result | not saved |
%rbx | scratch | callee saves |
%rcx | argument 4 | not saved |
%rdx | argument 3 | not saved |
%rsi | argument 2 | not saved |
%rdi | argument 1 | not saved |
%rbp | base pointer | callee saves |
%rsp | stack pointer | callee saves |
%r8 | argument 5 | not saved |
%r9 | argument 6 | not saved |
%r10 | scratch | CALLER saves |
%r11 | scratch | CALLER saves |
%r12 | scratch | callee saves |
%r13 | scratch | callee saves |
%r14 | scratch | callee saves |
%r15 | scratch | callee saves |
関数を呼び出すには,まず最初に引数を計算し,レジスタに配置する必要がある.
そして,2つの呼び出し側によって保存されるレジスタ(%r10
と%11
)を,それらの値を保存するためにスタックにプッシュしなければならい.
call
命令を発行し,スタック上のcurrent incsturuction pointerが関数のコードの場所にジャンプする.
関数から戻る際,2つの呼び出し側によって保存されるレジスタをスタックからポップし,%rax
レジスタの返り値を見る.
たとえば,以下のCコードの断片について
long x = 0;
long y = 10;
int main() {
x = printf("value: %d", y)
}
これは以下のように変換することができる.
.data
x:
.quad 0
y:
.quad 10
str:
.string "value: %d\n"
.text
.globl main
main:
movq $str, %rdi # 最初の引数を%rdiへ: 文字列のポインタ
movq y, %rsi # 2番目の引数を%rsiへ: yの値
movq $0, %rax # 0を表す浮動小数点の引数がある
pushq %r10 # 呼び出し側で保存しておくレジスタを保存する
pushq %r11
call printf # printfを呼び出す
popq %r11 # 呼び出し側で保存しておくレジスタを復元する
popq %r10
movq %rax, x # xに結果を格納する
ret # main関数から復帰する
関数の引数はレジスタに渡されるため,値を計算し,それを返すシンプルな数学関数を書くのは簡単である. 例えば,以下のような関数のコードについて,
long square(long x) {
return x * x;
}
これは以下のようにできる.
.global square
square:
movq %rdi, %rax # 最初の引数を %raxにコピーする
imulq %rdi, %rax # それを自身の値で乗算する
ret # 呼び出し元に復帰する
残念ながら,これは 他の関数を呼ばない,leaf function としてのみ機能し,すでに渡されたレジスタのセット内で処理できる. コンパイラによって出力される一般化された関数は,スタックの状態を保存・復元しているはずだ.
訳注: leaf functionは,木構造の "leaf" にちなんでいるのだと推測できる
完全な関数は,他の関数の呼び出し,任意の複雑さの式の計算が可能で,損なわれていない状態で呼び出し元に復帰できなければならない.以下のような,3つの引数を取り,2つのローカル変数を使う関数を考える.
.globl func
func:
pushq %rbp # ベースポインタを保存
movq %rsp, %rbp # 新しいベースポインタをセット
pushq %rdi # スタック上の最初の引数を保存
pushq %rsi # スタック上の2番目の引数を保存
pushq %rdx # スタック上の3番目の引数を保存
subq $16, %rsp # 2つのローカル変数を割り当てる
pushq %rbx # 呼び出し側で保存しておくレジスタを保存する
pushq %r12
pushq %r13
pushq %r14
pushq %r15
### body of function goes here ###
popq %r15 # 呼び出し側で保存しておくレジスタを復元する
popq %r14
popq %r13
popq %r12
popq %rbx
movq %rbp, %rsp # スタックを以前のベースポインタにリセットする
popq %rbp # ベースポインタを復元する
ret # 呼び出し元に復帰する
理解することがたくさんある.
関数に与えられた引数,返すべき情報,そしてローカルの計算に必要な領域だ.
この目的では, ベーススタックポインタ %rbp
を使う.
スタックポインタ%rsp
は,新しいデータがプッシュされる,スタックの最後を指すのに対し,ベースポインタ %rbp
は,この関数で使用される最初の値を指す..
%rbp
と %rsp
の間の領域は,関数呼び出しの"スタックフレーム" または "アクティベーションレコード" として知られている.
そして,もう一つの複雑なことがある.それぞれの関数は計算を行うのに,レジスタの選択を使用する必要がある.しかしながら,1つの関数が別の関数の途中で呼び出された時,何が起こっているのだろうか.
現在使っているレジスタを,呼び出された関数によって上書きされたくない.
それを防ぐために,それぞれの関数は,使用するすべてのレジスタを,最初にスタックにプッシュし,返す前にスタックからポップすることで,保存・復元しなければならない.
System V ABIによると,それぞれの関数は %rsp
,%rbp
,rbx
,とr12
-%r15
の値を完了した際に保存しなければならない.
func
のスタックレイアウトを考えると,以下のように成る.
Contents | Address | |
---|---|---|
old %rip register | 8(%rbp) | |
old %rbp register | (%rbp) | <-- %rbp points here |
argument 0 | -8(%rbp) | |
argument 1 | -16(%rbp) | |
argument 2 | -24(%rbp) | |
local variable 0 | -32(%rbp) | |
local variable 1 | -40(%rbp) | |
saved register %rbx | -48(%rbp) | |
saved register %r12 | -56(%rbp) | |
saved register %r13 | -64(%rbp) | |
saved register %r14 | -72(%rbp) | |
saved register %r15 | -80(%rbp) | <-- %rsp points here |
("top" of the stack) |
ベースポインタ(%rbp
)は,スタックフレームの最初を指している.
なので,関数の本体では,引数とローカル変数を指すのにBase-Relative アドレッシングを使うだろう.
関数への引数はベースポインタに続いているので, argument 0は -8(%rbp)
,argument 1は -16(%rbp)
と続く.
関数のローカル変数は -32(%rbp)
以降にあり,保存したレジスタは-48(%rbx)
にある.
スタックポインタはスタックの最後のアイテムを指す.
もし,スタックを追加の目的で使うのなら,データはより負の方向にプッシュされるだろう(すべての引数と変数は8バイトであると仮定する.他のタイプがある場合,オフセットは変わってくる).
ここに,完全な例を示す.以下のようなCの関数が定義されていると仮定する.
int func(int a, int b, int c) {
int x, y;
x = a + b + c;
y = x * 5;
return y;
}
保守的な関数の変換を示す.
.globl func
func:
##################### スタックをセットアップする関数のプリアンブル
pushq %rbp # ベースポインタを保存
movq %rsp, %rbp # rspに新しいベースポインタをセット
pushq %rdi # 最初の引数(a)をスタックに保存
pushq %rsi # 2番目の引数(b)をスタックに保存
pushq %rdx # 3番目の引数(c)をスタックに保存
subq $16, %rsp # 2つのローカル変数を割り当てる
pushq %rbx # 呼び出し側で保存するレジスタを保存する
pushq %r12
pushq %r13
pushq %r14
pushq %r15
######################## 関数の本体はここから
movq -8(%rbp), %rbx # 各引数をスクラッチレジスタに読み込む
movq -16(%rbp), %rcx
movq -24(%rbp), %rdx
addq %rdx, %rcx # 引数を足す
addq %rcx, %rbx
movq %rbx, -32(%rbp) # 結果をlocal 0 (x)に保存する
movq -32(%rbp), %rbx # local 0 (x) をスクラッチレジスタに読み込む
imulq $5, %rbx # 5で乗算する
movl %rbx, -40(%rbp) # local 1 (y) に結果を保存する
movl -40(%rbp), %rax # 結果レジスタにlocal 1 (y) を移動する
#################### スタックを復元する関数のエピローグ
popq %r15 # 呼び出し側で保存するレジスタを復元する
popq %r14
popq %r13
popq %r12
popq %rbx
movq %rbp, %rsp # スタックをベースポインタにリセットする
popq %rbp # 古いベースポインタを復元する
ret # 呼び出し元に返す
振り返ると,挙げた例を改善できる方法が多くある.
特定のコードは, レジスタ%rbx
-%r15
を使う必要はなかったので,それらを保存する必要はなかった.
注意して,引数をスタックに保存することなく,レジスタに保持しておくことが出来た.
結果をローカル変数に保存せずに, %rax
に直接計算することが出来た.
これらの最適化はコードを手で書いているときは簡単だが,コンパイラを書いている時は簡単ではない.
あなたの最初のコンパイラ作成の試みでは,作成されたコードは,それぞれの文が独立して翻訳されるため,効率的ではないでしょう.
あとで使用されるレジスタなど,先験的なことについて知らないので,関数のプリアンブルは,すべてのレジスタを保存しなければならない.
ローカル変数が返り値として使用されることを知らないため,値を計算する文はローカル変数にそれを保存しなければならない.
とは言うものの,賢いコンパイラは有意に物事を減らすことができる.
func
のCのソースコードを-O
フラグ無しで,gccコンパイラでコンパイルし,注意深く見ると,どのように最適化されているか確認できる.
学期の後で,コンパイラが最適化されたコードを生成する様々な方法について,話し合う. 今の所,効率よりも,あなたのコードを保守的にすることを考えること.
この文書はx86-64アセンブリの基礎をあなたに与えるが,より学ぶことがある. コンパイラを完成させるには,ここには載っていない命令を見つける必要があることに気づくだろう.
Intel manualのVolume 1 section 5.1にある命令のリストを熟読することを推奨する.
望む命令を特定したら,Volume 2で詳細を見つけること.