Skip to content

Latest commit

 

History

History
557 lines (425 loc) · 27.7 KB

intro-to-x8664-asm-for-compiler-writers.mkd

File metadata and controls

557 lines (425 loc) · 27.7 KB
tags
プログラミング

"Introduction to X86-64 Assembly for Compiler Writers"を読んだ

コンパイラ開発の調査,勉強がてら, "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でライセンスされている.

Introduction to X86-64 Assembly for Compiler Writers

Overview

未翻訳

Open Source Assembler Tools

未翻訳

Registers and Data Types

x86-64は64ビット整数の(大抵)汎用のレジスタを16個持っている.

  • %rax
  • %rbx
  • %rcx
  • %rdx
  • %rsi
  • %rdi
  • %rbp
  • %rsp
  • %r8
  • %r9
  • %r10
  • %r11
  • %r12
  • %r13
  • %r14

大抵というのは,プロセッサの早期バージョンは,各レジスタが特定の用途で使われることを想定していて,すべての命令に適用できなかったからだ.デザインが開発され,新しい命令とアドレッシング・モードが追加され,様々なレジスタがほぼ等しくなった. いくつかの命令,特に文字列処理に関するものは,rsi, rdi レジスタを使う必要がある. また, rsprbpはそれぞれスタックポインタとベースポインタ用に予約されている. それ以外の,番号付きの 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の仮想メモリをアドレッシングすることが可能だ.

Addressing Modes

あなたが知るべき最初の命令は 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" は単に,xprintfのように,修飾されていない名前のことを指す. "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"を使うことは出来ない. どのアドレッシング・モードの組み合わせがサポートされているかを正確に見るには,問題の命令のマニュアルページを読まなければならない.

Basic Arithmetic

あなたのコンパイラには,4つの基本的な算術命令が必要だろう.ADD, SUB, IMUL, IDIVだ. ADDとSUBは2つのオペランドを持つ.sourceとdestructive targetだ. 例えば,この命令は,

ADDQ %rbx, %rax

%rbx%raxを足して,%raxに結果を置く.以前そこに合ったものを上書きする. これは,あなたがどのようにレジスタを使うかについて,少し気をつける必要がある. 例えば,abがグローバルな整数で,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:%rax5で割る.結果は %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で,レジスタ内外に値をロードし, レジスタのみを使用して算術を行うのが,最も便利であることに気づくだろう.

Sidebar: Floating Point

浮動小数点の操作の詳細についてはカバーしないが,違った命令セット,レジスタで処理することを最低限知っておくべきだろう. 古いマシンでは,浮動小数点命令は8087 FPUと呼ばれる外部のチップで処理されており, それゆえ,今はその能力はCPUに内蔵されているものの,x87オペレーションとして説明されている.

x87 FPUはスタックに配置された8つの80ビットレジスタ(R0~R7)を含む. 浮動小数点の算術をするには,コードはデータをFPUスタックにプッシュし,スタックのトップを暗黙的に操作し,メモリにアイテムを書き出す命令を発行しなければならない. メモリでは倍精度の数字が64ビット値で格納されている.

このアーキテクチャの奇妙なところは,内部表現(80ビット)はメモリ上の表現(64ビット)に比べて高い精度を持っていることだ. 結果として,浮動小数点計算の値は,値がレジスタ・メモリ間を移動する際の正確な順番によって変化することがある.

浮動小数点の計算は,最初に現れたよりも奇妙だが,以下を読むことをおすすめする.

Comparisons and Jumps

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ディレクティブが与えられていない限り,一つのアセンブリファイル内でプライベートでなければならない.

Stack

スタックは,補助的なデータ構造で,主に,レジスタに収まらないローカル変数を伴うプログラムの,関数呼び出しのヒストリを記録するために用いられる,

慣習として,スタックは上位の値から下位の値に,下向きに成長する. %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

Calling Other Functions

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関数から復帰する

Defining a Simple Leaf Function

関数の引数はレジスタに渡されるため,値を計算し,それを返すシンプルな数学関数を書くのは簡単である. 例えば,以下のような関数のコードについて,

long square(long x) {
  return x * x;
}

これは以下のようにできる.

.global square
square:
  movq %rdi, %rax  # 最初の引数を %raxにコピーする
  imulq %rdi, %rax # それを自身の値で乗算する
  ret              # 呼び出し元に復帰する

残念ながら,これは 他の関数を呼ばない,leaf function としてのみ機能し,すでに渡されたレジスタのセット内で処理できる. コンパイラによって出力される一般化された関数は,スタックの状態を保存・復元しているはずだ.

訳注: leaf functionは,木構造の "leaf" にちなんでいるのだと推測できる

Defining a Complex Function

完全な関数は,他の関数の呼び出し,任意の複雑さの式の計算が可能で,損なわれていない状態で呼び出し元に復帰できなければならない.以下のような,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%rbprbx,と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                # 呼び出し元に返す

Thoughts on Optimization

振り返ると,挙げた例を改善できる方法が多くある. 特定のコードは, レジスタ%rbx-%r15を使う必要はなかったので,それらを保存する必要はなかった. 注意して,引数をスタックに保存することなく,レジスタに保持しておくことが出来た. 結果をローカル変数に保存せずに, %raxに直接計算することが出来た. これらの最適化はコードを手で書いているときは簡単だが,コンパイラを書いている時は簡単ではない.

あなたの最初のコンパイラ作成の試みでは,作成されたコードは,それぞれの文が独立して翻訳されるため,効率的ではないでしょう.

あとで使用されるレジスタなど,先験的なことについて知らないので,関数のプリアンブルは,すべてのレジスタを保存しなければならない.

ローカル変数が返り値として使用されることを知らないため,値を計算する文はローカル変数にそれを保存しなければならない.

とは言うものの,賢いコンパイラは有意に物事を減らすことができる. func のCのソースコードを-O フラグ無しで,gccコンパイラでコンパイルし,注意深く見ると,どのように最適化されているか確認できる.

学期の後で,コンパイラが最適化されたコードを生成する様々な方法について,話し合う. 今の所,効率よりも,あなたのコードを保守的にすることを考えること.

Further Reading

この文書はx86-64アセンブリの基礎をあなたに与えるが,より学ぶことがある. コンパイラを完成させるには,ここには載っていない命令を見つける必要があることに気づくだろう.

Intel manualのVolume 1 section 5.1にある命令のリストを熟読することを推奨する.

望む命令を特定したら,Volume 2で詳細を見つけること.