...
保存版ページのため、送信・ログイン・予約機能は利用できません。掲載の連絡先をご利用ください。
Run Format

Goアセンブラ入門

Goアセンブラ入門

このドキュメントでは、Goコンパイラ(6gや8gなど)であるgcスイートで使われている、風変わりなアセンブリ言語について紹介します。 ただし、このドキュメントはあまり包括的なものにはなっていません。

Goアセンブラは、Plan 9アセンブラへの入力に基づいています。このアセンブラについては、Plan 9のサイトで詳しく解説されています。 もしアセンブリ言語で書こうとしているなら、Plan 9固有のものではありますが、上記ドキュメントを読むことをお勧めします。 このドキュメントでは、アセンブリ言語の文法の概要と、Goと相互にやりとりをするアセンブリコードを書く際に独特な部分についてのみ紹介します。

Goアセンブラでまず気をつけなければいけないのは、それは計算機を直接表現したものではないということです。 いくらかの部分は正確に計算機上で実行されることになりますが、一部はそうではありません。 これは、コンパイラスイート(こちらの説明もどうぞ)は通常の処理過程ではアセンブラを処理しないためです。 かわりに、コンパイラは未完成の命令をバイナリ形式で出力します。そして、リンカがそれを完成させます。 とりわけ、リンカは命令の選択をします。そのため、もしMOVのような命令があっても、リンカが実際に生成する命令はメモリ転送の命令ではなく、クリアやロードの命令かもしれません。 もちろん、まったく同じ名前の命令を生成することもあります。 一般に、その計算機固有の演算はそのまま生成される傾向がありますが、一方でメモリ転送のような一般的なものや、サブルーチン呼び出し、リターンのような抽象的なものはそのままは生成されない傾向にあります。 これについてはアーキテクチャによっても変わり、不正確で申し訳ないのですが、明確な定義はありません。

アセンブラのプログラムはこのような中間的で不完全な命令を生成し、リンカへの入力とすることができます。 もし命令が特定のアーキテクチャのアセンブリでどのようになるかを知りたければ、標準ライブラリのruntimeやmath/bigパッケージのソースコードが参考になるでしょう。 また、コンパイラがどのようなアセンブリコードを出力するかを調べることもできます:

$ cat x.go
package main
func main() {
	println(3)
}
$ go tool 6g -S x.go        # or: go build -gcflags -S x.go
--- prog list "main" ---
0000 (x.go:3) TEXT    main+0(SB),$8-0
0001 (x.go:3) FUNCDATA $0,gcargs·0+0(SB)
0002 (x.go:3) FUNCDATA $1,gclocals·0+0(SB)
0003 (x.go:4) MOVQ    $3,(SP)
0004 (x.go:4) PCDATA  $0,$8
0005 (x.go:4) CALL    ,runtime.printint+0(SB)
0006 (x.go:4) PCDATA  $0,$-1
0007 (x.go:4) PCDATA  $0,$0
0008 (x.go:4) CALL    ,runtime.printnl+0(SB)
0009 (x.go:4) PCDATA  $0,$-1
0010 (x.go:5) RET     ,
...

FUNCDATAとPCDATAディレクティブは、ガーベッジコレクタによって使用される情報を含みます。これはコンパイラによって挿入されます。

シンボル

いくつかのシンボル、例えばPCやR0、SPは予め定義されており、レジスタを指しています。他にもSB (static base)とFP (frame pointer)という二つのシンボルがあります。 ユーザーが定義したジャンプラベル以外のシンボルは、これらの擬似レジスタのオフセットとして使用できます。

SB擬似レジスタは、メモリの基点として考えることができます。そのため、シンボルfoo(SB)は名前fooのメモリ内のアドレスとなります。

FP擬似レジスタは仮想的なフレームポインタで、関数の引数を指すために使われています。 コンパイラは仮想的なフレームポインタを管理し、スタック中の引数を擬似レジスタからのオフセットで指します。 つまり、0(FP)は関数の第一引数になり、8(FP)は第二引数(64ビット環境では)になり、以下同様です。 この方法で関数の引数を指すときは、はじめにfirst_arg+0(FP)やsecond_arg+8(FP)のようにして、名前をつけるのがよいでしょう。 いくつかのアセンブラはこれを強制し、単なる0(FP)や8(FP)を拒否します。 Goのプロトタイプをもつアセンブリ関数なら、go vetが引数名とオフセットが一致しているかどうかをチェックします。

SP擬似レジスタは仮想的なスタックポインタで、フレームのローカル変数と関数呼び出しの際に用意された引数を指すのに使われています。 これはローカルスタックフレームの先頭を指すので、参照する際は負のオフセット範囲 [-framesize, 0): x-8(SP), y-4(SP) を使う必要があります 実際にSPという名前のレジスタがあるアーキテクチャでは、名前プリフィックスによって、この仮想スタックポインタとそのアーキテクチャのSPレジスタを区別して指すことができます。 つまり、x-8(SP)と-8(SP)は違うメモリの場所を指します: 前者は仮想スタックポインタ擬似レジスタを指しますが、後者はハードウェアのSPレジスタを指します。

命令、レジスタ、アセンブラのディレクティブは常に大文字で書かれており、アセンブリプログラミングはやっかいなものであることを彷彿とさせることでしょう。 (例外: ARMでのmとgのレジスタリネーム)

Goのオブジェクトファイルとバイナリでは、シンボルの厳密な名前はパッケージパスとシンボルの名前をピリオドで繋いだものです: fmt.Printfやmath/rand.Intなど。 アセンブラのパーサーはピリオドやスラッシュを区切り文字として扱うので、これらの文字列を識別名として使うことはできません。 かわりに、アセンブラは識別子中の中黒文字(MIDDLE DOT: U+00B7)と割り算文字(DIVISION SLASH: U+2215)を普通のピリオドとスラッシュに置換します。 アセンブラのソースファイルでは、上記シンボルは次のように書く必要があります: fmt·Printfやmath∕rand·Int。 コンパイラに-Sフラグを与えたときに生成されるアセンブリコードは、ピリオドとスラッシュがそのまま出力されており、アセンブラから必要とされるUnicode文字への置き換えはされていません。

手で書かれたほとんどのアセンブリファイルはシンボル名の中に完全なパッケージパスが入ることはないでしょう。 これは、ピリオドで始まる名前に対してオブジェクトファイルのパッケージパスをリンカが挿入するためです。 例えばmath/randパッケージの実装のアセンブリソースファイルは、パッケージのInt関数を·Intで参照することができます。 これによってソースコードにパッケージのインポートパスをハードコードすることを避けられ、他の場所にコードを移動しやすくなります。

ディレクティブ

アセンブラは、テキストやデータをシンボルと結びつけるために、いくつかのディレクティブを使うことができます。 例えば、次の簡単な関数では、 TEXTディレクティブはシンボル runtime·profileloopを宣言し、その関数の中身の命令がそのあとに続きます。 TEXTブロックの最後の命令は何らかのジャンプ命令でなければなりません。通常は、RET(擬似)命令です。 (もしなければ、リンカが自分自身へジャンプする命令を挿入します。TEXTにfallthroughはありません。) さきほどのシンボルのあとには、引数としてフラグとフレームサイズの定数が入ります:

TEXT runtime·profileloop(SB),NOSPLIT,$8
	MOVQ	$runtime·profileloop1(SB), CX
	MOVQ	CX, 0(SP)
	CALL	runtime·externalthreadhandler(SB)
	RET

通常の場合、フレームサイズの後ろには引数のサイズがマイナス記号で区切られて配置されます。(これは引き算ではなく、特有の記法です。) フレームサイズ$24-8は、この関数は24バイトのフレームをもち、呼び出し元のフレームにある8バイトの引数で呼ばれることを宣言しています。 もしNOSPLITがTEXTで指定されていなければ、引数のサイズをかならず指定する必要があります。

このシンボルの名前は中黒を使ってコンポーネントを分離し、擬似レジスタSBからのオフセットとして指定されます。 パッケージruntimeのGoのソースからは、この関数は単にprofileloopという名前で呼び出すことができます。

DATAディレクティブは、シンボルのあとにスラッシュとこのシンボルが占めるバイト数を書きます。 引数はオプションのフラグとデータ自体です。 例えば、

DATA  runtime·isplan9(SB)/4, $1

は局所シンボルruntime·isplan9がサイズ4で値は1であることを宣言しています。 そして、このシンボルは中黒をもち、オフセットはSBがもとになります。

GLOBLディレクティブはシンボルがグローバルであることを宣言します。 引数はオプションのフラグとグローバルなものとして宣言されるデータのサイズで、DATAディレクティブで初期化しない限りはゼロが初期値となります。 GLOBLディレクティブは対応するDATAディレクティブの後に宣言される必要があります。 例えば、

GLOBL runtime·tlsoffset(SB),$4

はruntime·tlsoffsetがサイズ4をもつことを示します。

ディレクティブは一つか二つの引数をもつことができます。 もし二つの場合は、最初の一つはフラグのビットマスクで、数値式として加算や論理和とったり、人間が読みやすいようにシンボルで設定することもできます。 これらの値は、ファイルsrc/cmd/ld/textflag.hで定義されています:

アーキテクチャ固有の情報

それぞれの計算機の命令や詳細をすべて書き下すことはあまり現実的ではありません。 ある計算機でどのような命令が定義されているかは、対応するリンカのヘッダファイルを見てください。 たとえば32ビットIntel x86では、8lリンカのファイル$GOROOT/src/cmd/8l/8.out.hはCの列挙体asがあり、このアーキテクチャで解釈できる命令とそのスペルを含んでいます。 このファイルには、次の宣言があります

enum	as
{
	AXXX,
	AAAA,
	AAAD,
	AAAM,
	AAAS,
	AADCB,
	...

それぞれの命令は大文字のAで始まっており、AADCBはADCB(add carry byte: キャリー付きバイト加算)命令を示しています。 この列挙はアルファベット順ですが、いくつか後から追加されたものもあります(AXXXは無効な命令としてゼロ値になっています)。 順番は実際の計算機命令のエンコーディングとは無関係です。 そして、リンカがこれらの詳細な処理を行います。

前の章の例からもわかるとおり、命令中のデータは左から右へ流れます: MOVQ $0, CXはCXをクリアします。 この規則は、この逆が通常のモードであるアーキテクチャにおいても適用されます。

次は、サポートされているアーキテクチャについて、Go固有のことを説明します。

32ビット Intel 386

mおよびg構造体へのランタイムポインタは、MMUの(Goが関与する範囲においては)未使用レジスタの値として管理されています。 アセンブラのためのOS依存マクロget_tlsはアーキテクチャ依存ヘッダファイルを次のようにインクルードしている場合に定義されます:

#include "zasm_GOOS_GOARCH.h"

ランタイムでは、get_tlsマクロは引数レジスタからgとmを指すポインタペアへのポインタを読み込みます。 CXを使ってgとmを読み込むのは、次のようになっています:

get_tls(CX)
MOVL	g(CX), AX	// Move g into AX.
MOVL	m(CX), BX	// Move m into BX.

64ビット Intel 386 (別名: amd64)

mとgへのポインタにアクセスするアセンブリコードは386の場合と同じですが、MOVLのかわりにMOVQを使います:

get_tls(CX)
MOVQ	g(CX), AX	// Move g into AX.
MOVQ	m(CX), BX	// Move m into BX.

ARM

レジスタR9とR10、R11はコンパイラとリンカに予約されています。

R9とR10はm (machine)とg (goroutine)構造体を指します。 アセンブリコード内では、これらのポインタはmおよびgとして参照されなければなりません。 R9とR10という名前は認識されません。

アセンブリを簡単に書くために、ARMリンカは汎用アドレス形式やDIVやMODのような擬似命令を受け付けます。 これらは単一のハードウェア命令では表現されません。 これらの形式を複数命令で実装するために、一時領域としてR11レジスタが使われることがあります。 手動でアセンブリを書く場合にはR11を使用することもできますが、そのためには、その関数内でリンカがほかの命令を実装するためにR11を使用しないことを確認する必要があります。

TEXTを定義する際にフレームサイズ$-4を指定すると、リンカはこの関数を実行時にLRを保存しなくてよい末端の関数とみなします。

SPは常に仮想スタックポインタを指します。 ハードウェアのレジスタを指すには、R13を使ってください。

未対応の命令コード

アセンブラはコンパイラをサポートするように設計されているため、すべてのアーキテクチャのすべてハードウェア命令が定義されているわけではありません: もしコンパイラが生成しないものなら、ない可能性があります。 存在しない命令を使いたい場合は、二つの方法があります。 一つはその命令が使えるようにアセンブラを変更することです。これは正統な方法ですが、その命令を今後も使いたい場合だけ価値があります。 かわりに、単に一度だけ使いたいだけであれば、BYTEとWORDディレクティブを使ってTEXTのなかに命令コードを直接書くことも出来ます。 これは、386用のランタイムが64ビットアトミックロード関数を定義している方法です。

// uint64 atomicload64(uint64 volatile* addr);
// so actually
// void atomicload64(uint64 *res, uint64 volatile *addr);
TEXT runtime·atomicload64(SB), NOSPLIT, $0-8
	MOVL	4(SP), BX
	MOVL	8(SP), AX
	// MOVQ (%EAX), %MM0
	BYTE $0x0f; BYTE $0x6f; BYTE $0x00
	// MOVQ %MM0, 0(%EBX)
	BYTE $0x0f; BYTE $0x7f; BYTE $0x03
	// EMMS
	BYTE $0x0F; BYTE $0x77
	RET