Goアセンブリの書き方からビルド方法までを一通り調べました。Goアセンブリを書いたことのない人がコードを書いてリンクできるところまでは一通り書いているつもりですが、Goアセンブリの言語仕様を網羅してはいないので、興味があれば最後に書いた参考情報も読んでみてください。
この記事ではGo 1.16.xでAMD64命令セットを扱いますが、具体的な命令や値のサイズ以外は、他のアーキテクチャを使う場合でもだいたい同じだと思います。
アセンブリコードの書き方
GoのアセンブリはPlan 9アセンブリを概ね踏襲していて、AT&T記法です。整数を受け取って、それに2を加算した値を返す関数func add2(i int32) int32
を書いてみましょう。アセンブリのコードは.sファイルに書きます。また、アセンブリはアーキテクチャに強く依存するので、Goの習慣にしたがってファイル名にはアーキテクチャ名も入れておきましょう。
func_amd64.s
TEXT ·add2(SB),$0-12 MOVL i+0(FP),AX // 引数iをAXレジスタに ADDL $2, AX // 2を加算 MOVL AX, ret+8(FP) // 計算結果を戻り値として返す RET
馴染みのない場合はおそらく何もわからないと思うので、少し丁寧に書きます。
Goのアセンブリでは、関数はTEXTディレクティブで開始します。上の関数は·add2という名前ですが、名前の頭に奇妙な·(middle dot/center dot; 0u00B7)が付いています。これはパッケージ名を区切る文字で、本来はmain.add2となりますが、アセンブリでは.(dot)が名前に使えないので、代わりに·を使っています*1。macOSの場合、この文字はOpt+Shift+9で入力できます。Plan 9やplan9portならAlt→.→.と順番にキーを叩けばいいです*2。そしてパッケージ名を省略するとリンク時に補完されるので、最終的にこの命令はmain.add2関数を定義することになります。関数名の後ろについている(SB)
ですが、SBはStatic Base擬似レジスタで、プログラムアドレス空間の先頭オフセットを表します。「SBからの特定オフセットにmain.add2という名前をつけて以降の命令を関数ボディとする」が正確な意味らしいですが、関数を定義する場合は必ずこのように書く、と覚えてしまっていいと思います。
これでTEXT ·add2(SB)
まで説明しました。その後に$0-12
と続いている値は、演算ではなく$<スタックフレームのサイズ>-<引数と戻り値のサイズ>
を表します。add2関数は、ローカル変数を持たないのでスタックフレームのサイズは0です。次に引数と戻り値のサイズですが、int32が2つなので8になると考えてしまいますが、GoのABIは戻り値の開始位置はワード長でアラインされるようです。AMD64のワード長は8なので、引数部分が(4+align)
で8バイト、戻り値はそのまま4で合計12です。
次に MOVL i+0(FP),AX
という行は、MOVL
は32bitの数値を左から右へコピーする命令です。MOVx
は他にもいくつかあり、それぞれ
- MOVB(Byte): 8bit
- MOVW(Word): 16bit
- MOVL(Long): 32bit
- MOVQ(Quad): 64bit
- MOVO(Octo): 128bit
の値を扱います。Goのアセンブラでは、MOVx
に限らず他の命令も、上記のような末尾文字で扱う値のサイズを決定します。命令の時点でサイズが決まるため、RAX
やEAX
などレジスタを使い分ける必要はありませんが、8bitを扱う場合はAH
やAL
も使えます。命令の後に続くi+0(FP)
という表記は、FP擬似レジスタのオフセット0という意味です。i+
の部分はシンボル名で、エラーを検出するため、FPレジスタを参照する場合はシンボル名が必須です。
最後に、コード中に現れる$n
は即値です。この$
はTEXTディレクティブの最後に現れるものとは異なります。$(4+1)
のように演算も行えます。
ビルドしてみる
では次に、この関数を使ってみましょう。参照する側は1箇所を除いてよくあるGoのコードです。
main.go
package main import "fmt" func add2(i int32) int32 func main() { i := add2(20) fmt.Println(i) }
宣言だけのadd2関数がありますが、この宣言により、アセンブリで記述したmain.add2をGoの関数として参照できるようになります。ビルドする方法は普段通りgo build
です。
% go build -o a.out % ./a.out 22
このとき、関数宣言に書く引数の名前はi+0(FP)
の名前と合わせておきましょう。異なった名前にすると、go vet
により
% go vet # asm ./func_amd64.s:2:1: [amd64] add2: unknown variable i; offset 0 is ix+0(FP) ./func_amd64.s:3:1: [amd64] add2: 8(SP) should be ix+0(FP)
のように怒られます。戻り値が1つの場合はデフォルトのretが使えますが、多値を返す場合は名前をつけましょう。
GoのABI(Application Binary Interface)
ABIとは、関数やシステムコールの呼び出し規約などの総称です。ABIについては以下の記事が分かりやすいなと思いました。
satoru-takeuchi.hatenablog.com
上のコードで見たように、Goとアセンブリの関数はスタックを経由して引数と戻り値を渡します。例えばmainに
var i int32 i = 20 i = add2(i)
のようなコードがあった場合、add2を呼び出す前のスタック例(アドレスは適当)は
↑メモリの先頭アドレス 0x120..0x127 [main関数から戻る際のリターンアドレス] 0x128..0x12c [main関数の変数iが利用しているスタック領域] ↓メモリの末尾アドレス
のように積まれていますが、add2を呼び出すと
↑メモリの先頭アドレス +0x100..0x107 [add2関数から戻る際のリターンアドレス] +0x108..0x10b [add2関数の引数iで利用しているスタック領域] +0x10c..0x10f [未使用(アラインメント)] +0x110..0x113 [戻り値を書き込むスタック領域] +0x114..0x117 [未使用(アラインメント)] +0x118..0x11f [関数呼び出し前のBPレジスタ値] 0x120..0x127 [main関数から戻る際のリターンアドレス] 0x128..0x12c [main関数の変数iが利用しているスタック領域] ↓メモリの末尾アドレス
のように利用状況が変わります。このとき、SPレジスタは0x100を指し、FPレジスタは0x108を指します。なのでi+0(FP)
とすると最初の引数を参照できますし、上では使っていませんが2つ目の引数があればj+4(FP)
などと書けます。
SPレジスタとFPレジスタ
引数や戻り値を扱うとき、上ではFP擬似レジスタを使いましたが、go tool compile -S
やgo build -gcflags=-S
で生成したアセンブリコードはSP擬似レジスタを使って引数などを参照します。i+0(FP)
とi+8(SP)
は結局どちらも同じ場所を指すので、どちらを使っても支障はありませんが、FPレジスタの場合はアラインメント間違いなどを検出してエラーにしてくれるので、手書きする場合は基本的にFPレジスタを使う方が良いと思います。
ただし、複雑なのですがi+8(SP)
と8(SP)
は必ずしも同じ場所を指すとは限りません。ハードウェアがSPレジスタを持つ場合、SP擬似レジスタを扱う場合はi+8(SP)
のようにシンボル名が必須で、8(SP)
のようにシンボル名を省略するとハードウェアレジスタを扱うことになります。
文字列とスライス
Goから引数として文字列またはスライスをアセンブリのコードに渡す場合、文字列の場合はポインタと長さ、スライスの場合はポインタと長さとキャパシティの3つが渡されます。
func str(s string) func slice(p []byte)
の場合、アセンブリから参照する場合は以下のようになります。
TEXT ·str(SB),$0-16 MOVQ s+0(FP),AX MOVQ s_len+8(FP),CX RET TEXT ·slice(SB),$0-24 MOVQ p+0(FP),AX MOVQ p_len+8(FP),CX MOVQ p_cap+16(FP),DX RET
独自型を使う場合
独自に定義した構造体などを受け渡しする場合、アセンブリから#include "go_asm.h"
すると、それぞれのサイズやオフセットを定数で扱えるようになります。
type Point struct { X int Y int } func addpt1(p Point) Point func main() { fmt.Println(addpt1(Point{X: 1, Y: 2})) }
この場合、addpt1は以下のように書けます。定数を使うと、途中にフィールドが追加されたり、順番が変わったりしても安心ですね。
#include "go_asm.h" TEXT ·addpt1(SB),$0-32 MOVQ p+Point_X(FP),AX MOVQ p+Point_Y(FP),CX ...
go build
する場合、事前にgo_asm.hを用意する必要はなく、コンパイラが裏で作ってくれます。go_asm.hにどのような値が定義されるのか気になる場合は、以下のコマンドでgo_asm.hを出力できます。
% go tool compile -asmhdr go_asm.h *.go % cat go_asm.h // generated by compile -asmhdr from package main #define Point__size 16 #define Point_X 0 #define Point_Y 8
ここには現れてませんが、constで定義した定数も扱ってくれるようです。
他の関数を呼ぶ
ここまでで、Goの関数からアセンブリのコードを呼ぶ方法を書きました。今度はアセンブリ側からGoの関数を呼ぶ場合の手順です。結局は、呼び出す側で
↑メモリの先頭アドレス +0x100..0x107 [add2関数から戻る際のリターンアドレス] +0x108..0x10b [add2関数の引数iで利用しているスタック領域] +0x10c..0x10f [未使用(アラインメント)] +0x110..0x113 [戻り値を書き込むスタック領域] +0x114..0x117 [未使用(アラインメント)] +0x118..0x11f [関数呼び出し前のBPレジスタ値] 0x120..0x127 [main関数から戻る際のリターンアドレス] 0x128..0x12c [main関数の変数iが利用しているスタック領域] ↓メモリの末尾アドレス
のようなメモリレイアウトを作る必要があります。例として、
func neg(i int32) int32 { return -i }
をadd2から呼んで、2つ目の戻り値としてその結果を返すコードを書いてみましょう。Go側のプロトタイプは
func add2(i int32) (ret1 int32, ret2 int32)
に変更します。FPから戻り値のオフセットを参照するため、名前をつけているところにも気をつけてください。以下アセンブリのコードです。Goアセンブリでは、PUSH命令やPOP命令を使わずSPを直接操作します。
TEXT ·add2(SB),$24-16 // 1つ目の値は引数に+2するだけ、今までと同じ MOVL i+0(FP), AX ADDL $2, AX MOVL AX, ret1+8(FP) SUBQ $24, SP // neg関数の引数と戻り値サイズ+BPレジスタの退避先を確保 MOVQ BP, 16(SP) // 現在のBPレジスタをpush LEAQ 16(SP), BP // BPレジスタを新しいスタックに更新 MOVQ AX, (SP) // 最初の引数iを渡す CALL ·neg(SB) // main.negを呼ぶ MOVL 8(SP), AX // main.negの戻り値をAXレジスタに取り出す MOVQ 16(SP), BP // 退避していたBPレジスタをpop ADDQ $24, SP // スタックサイズを戻す MOVL AX, ret2+12(FP) // 2番目の戻り値として返す RET
ところで、気づいた人もいるかもしれませんが、CALL命令を実行する前はSPを24バイト減算していて、0(SP)
が最初の引数になっています。しかしmain.negは8(SP)
が最初の引数であることを期待します。この差は何なのかというと、CALL命令が暗黙的に、SPレジスタへリターンアドレスをpushしていることによるものです。RETで戻ると、リターンアドレス分がpopされて、SPレジスタがCALLする前の値へ戻ります。
また、他に注意した方が良い話題は、AMD64アーキテクチャの場合BPレジスタの値に連動してFP疑似レジスタも変わります。そのため、ADDQ $24, SP
でスタック位置を戻した後で戻り値をメモリに書き出す必要があります。逆にしてしまうと、意図しないメモリを更新することになります。
ABI0とABIInternal
これまで、Goとアセンブリのコードがどのように値を交換するのかをみてきました。基本的にスタックを経由してそれを行いますが、これはABI0というルールに則ります。
Go 1.16時点では、利用できるABIはABI0しかありません*3が、以下のプロポーザルによるとレジスタを使った値渡しのABIも検討されているようです。これが安定すれば、ABI1として利用可能になるかもしれません。
ABIの調べ方
上記以外に、error
やinterface{}
など色々な型を渡したくなると思います。またはポインタをGCで管理したくなるかもしれません*4。その場合、go tool compile -S
またはgo build -gcflags=-S
を使うと、コンパイルした結果を出力してくれるので、そうやって出力されたアセンブリのコードを眺めると良いかもしれません。ただし、最適化によって関数がインライン化される場合もあるので、//go:noinline
コメントで展開をしないようにコメントしておくといいでしょう。
% cat main.go package main import ( "fmt" "io" ) //go:noinline func isEOF(err error) bool { return err == io.EOF } func main() { fmt.Println(isEOF(fmt.Errorf("error"))) }
実行結果の例です。長いので最初数行だけ。
% go tool compile -S main.go "".isEOF STEXT size=105 args=0x18 locals=0x28 funcid=0x0 0x0000 00000 (fn.go:9) TEXT "".isEOF(SB), ABIInternal, $40-24 0x0000 00000 (fn.go:9) MOVQ (TLS), CX 0x0009 00009 (fn.go:9) CMPQ SP, 16(CX) ...
または、go tool objdump
で.oや実行ファイルからアセンブリコードを出力できますが、特に実行ファイルをgo tool objdump
した場合はとても長くなるので、目的の行を探すのは少し面倒かもしれません。
NOSPLITディレクティブ
ところで、標準パッケージのコードを見ると、アセンブリで書かれた関数のほとんどでNOSPLITディレクティブ(フラグ)をセットしています。
#include "textflag.h" TEXT ·func(SB),NOSPLIT,$0 ...
このNOSPLITはruntime/textflag.hで#define
されている値です。A Quick Guide to Go's Assemblerで、利用できるディレクティブが列挙されています。
#define NOSPLIT 4
通常、ゴルーチンは固有のスタックを持っていて、その初期サイズは決まっています*5。関数を呼び出したとき、コンパイラは「SPレジスタのアドレスと現在のスタック上限を比べて必要なら拡大する」処理を埋め込みますが、NOSPLITはこの動作を抑制するものです。実際に埋め込まれるコードは、この記事の最後に参考情報として挙げたGoアセンブリ入門によると、
again: MOVQ (TLS), CX CMPQ SP, 16(CX) JLS morestack // JBEと同じ意味 ... morestack: CALL runtime.morestack_noctxt(SB) JMP again
といったコードが挿入されるそうです。また、$GOROOT/src/runtime/stack.goのコメントでは、スタックの大きさによって3通りに分岐することが書かれていました。該当部分を引用します。
guard = g->stackguard frame = function's stack frame size argsize = size of function arguments (call + return) stack frame size <= StackSmall: CMPQ guard, SP JHI 3(PC) MOVQ m->morearg, $(argsize << 32) CALL morestack(SB) stack frame size > StackSmall but < StackBig LEAQ (frame-StackSmall)(SP), R0 CMPQ guard, R0 JHI 3(PC) MOVQ m->morearg, $(argsize << 32) CALL morestack(SB) stack frame size >= StackBig: MOVQ m->morearg, $((argsize << 32) | frame) CALL morestack(SB)
では次に、どういった場合にアセンブリで書いた関数へNOSPLITディレクティブ(フラグ)をセットすると良いのでしょうか。個人的には、正しい基準はあまり分かっていません。一切スタックを使わずCALLもしない関数を除いて、基本的にはNOSPLITを付与しないほうが安全に思えます。とはいえ公式のコードはほとんど全てNOSPLITを与えているし、$GOROOT/src/cmd/internal/obj/x86のstacksplitやprocessesを読むと、
// この定数は、実際は$GOROOT/src/cmd/internal/objabiで定義されている const ( StackSmall = 128 StackBig = 4096 ) // これは擬似コードです func processes(ctx, cursym, newprog) { p := cursym.Func().Text autooffset := p.To.Offset ... if autooffset < objabi.StackSmall && !p.From.Sym.NoSplit() { leaf := true if [CALL命令で引数が1つ以上ある関数を呼んでいる] { leaf = false } if [DUFFCOPY, DUFFZEROが使われている && autooffset >= objabi.StackSmall-8] { leaf = false } if leaf { p.From.Sym.Set(obj.AttrNoSplit, true) } } ... }
のようにNOSPLITを付与しているので、利用するスタックサイズが小さくCALL命令も使わない関数はNOSPLITを与えておくと良いかもしれません。
(雑談)go buildを使わずビルドする
基本的にはgo build
を使うだけで十分なんですが、裏で何が行われているのかを知っておくと便利なこともあるかもしれないので紹介します。
まず、go_asm.hがなければ定数が扱えないので、Goのソースコードからgo_asm.hを生成する必要があります。これはgo tool compile
で行います。
% go tool compile -asmhdr go_asm.h *.go
生成したら、アセンブリで書いたコードをビルドしましょう。.sに対応する.oファイルが作られたら正常です。go tool nm
コマンドで、.oに定義されたシンボルや未解決のシンボルなどを調べられます。
% go tool asm -p main func_amd64.s
% go tool nm func_amd64.o
go tool nm
が出力する2番目のフィールドはシンボルのタイプです。
- T テキストシンボル
- U 未解決のシンボル
などいくつかあり、それらはgo doc cmd/nmにまとめられています。
次に、アセンブリのコードがどのABIを利用しているか、をファイルとして用意しておく必要があります。今は手書きするコードなら全てABI0ですが、上で触れたように新しいABIが追加されるかもしれません。これはgo tool asm
で行います。
% go tool asm -gensymabis -o symabis *.s % cat symabis def "".add2 ABI0
上で作ったsymabisをgo tool compile
に渡します。これを渡しておかないと、
main.main: relocation target main.add2 not defined for ABIInternal (but is defined for ABI0)
のようなエラーでgo tool link
が失敗します。
% go tool compile -symabis symabis -p main main.go
上で作ったオブジェクトファイル(.oファイル)を.aファイルにまとめます。c
は新しくアーカイブファイルを作るというオプションです。
% go tool pack c main.a *.o
最後にリンクして終わり。
% go tool link main.a
一通りの手順をMakefileに書くとこのようになります。-P
オプションでパッケージ名を与えてますが、どちらでも良いと思います。
PKG=main TARG=a.out .PHONY: all all: $(TARG) $(TARG): main.a go tool link main.a main.a: main.o func_amd64.o go tool pack c $@ $^ main.o: main.go symabis go tool compile -symabis symabis -p $(PKG) $< func_amd64.o: func_amd64.s go_asm.h go tool asm -p $(PKG) $< func_amd64.s: go_asm.h symabis: *.s go tool asm -gensymabis -o $@ $^ go_asm.h: *.go go tool compile -asmhdr $@ $^ .PHONY: clean clean: rm -f *.o *.a .PHONY: nuke nuke: rm -f *.o *.a $(TARG) symabis go_asm.h
最後に、ここで書いたgo tool
コマンドで生成するオブジェクトは完全にgo build
と同じではありません。go build
の場合は同一パッケージに複数のfunc init()
があってもビルドが通るように調整などがされるようなので、基本はgo build
を使うと良いでしょう。
おわり。
参考情報
- A Quick Guide to Go's Assembler
- A Manual for the Plan 9 assembler
- Goアセンブリ入門
- The Go low-level calling convention on x86-64
- Go Binary Hacks - go buildせずにビルドする
物理本ですが、The Plan9 Assembler Handbookも参考になります。GoのアセンブラはPlan 9のものを下地にしているので、大部分はそのまま役に立ちます。意外とインターネットにはまとまった情報がないので、個人的にはとてもおすすめ。