💻

解説&翻訳 - A Quick Guide to Go's Assembler

31 min read

この記事について

Goの実装というのは基本的にはGo言語そのもので行われますが、runtimeパッケージやmath/bigといった低レイヤ・マシンと密接に関わる部分に関してはGoアセンブリで実装されています。
そしてGoアセンブリについては、公式ドキュメントであるA Quick Guide to Go's Assemblerにある程度の説明が記載されています。

この記事では、前半ではドキュメント"A Quick Guide to Go's Assembler"を読むために必要になる前提知識をまとめ、後半ではドキュメントの翻訳を行います。

アセンブリ・メモリ周りの基本的な知識が既にある方は、後半の翻訳部分まで飛ばしてもらって構いません。
また、アーキテクチャ依存部分の記述についての翻訳は今回省略しています。

使用する環境・バージョン

  • go version go1.16.2 darwin/amd64

想定読者

この記事では、以下のような方を想定しています。

  • runtimeパッケージやmath/bigパッケージ内に存在するアセンブリを読んで、何やってるのかある程度理解できるようになりたい人
  • "A Quick Guide to Go's Assembler"を読んでみたけど、「シンボル」や「ディレクティブ」みたいな専門用語が全然分からずに挫折した人
  • よくGoアセンブリに出てくるSPFPAXやらって何のこと?と思っている人
  • 単純に"A Quick Guide to Go's Assembler"の翻訳が読みたい人
  • go buildgo tool compilego tool asmなどのコマンドの違いが知りたい人

逆に、以下のような方は読んでも物足りないか、ここからは得たい情報が得られないかもしれません。

  • Goアセンブリを自分で書けるようになりたい人
  • Goアセンブリのマシン依存となる詳細が知りたい人
  • Goソースコード/アセンブリから機械語生成のアルゴリズム詳細について知りたい人

ビルドまわりの用語解説

アセンブラだったりアセンブリだったりアセンブルだったり似たような用語が多すぎる & コンパイルとも混同しやすい分野でもあるため、ここで一回関連用語についてまとめておきましょう。

  • コンパイ : コンパイルを行うプログラムのこと。
  • コンパイ : 「高級言語(Go)→機械語or低級言語」への変換を一度に行う動詞

~~

  • アセンブ : アセンブルを行うプログラムのこと。
  • アセンブ : 低級言語の一種。MOVQ $3, (SP)などと書かれるこの言語。
  • アセンブ : 「アセンブリ言語→機械語」への変換を行うことを指す動詞

~~

  • オブジェクトファイル : コンパイル/アセンブル後に生成されたファイルのこと。

~~

  • リン : リンクを行うプログラムのこと。
  • リン : 1つ以上のオブジェクトファイルを結合して、実行ファイルを作成することを指す動詞

~~

  • 実行ファイル : コンピュータが直接中身を実行することが可能な機械語のファイル。

アセンブリ言語について

「アセンブリとは何?」というところをはっきりさせたところで、ここからはアセンブリ言語そのものについて深堀していきましょう。

まずは、Goアセンブリで書かれたコードについてみてみましょう。

asm.s
TEXT    main·add(SB),$0-24
    MOVQ    main·n+16(SP), AX
    MOVQ    main·m+8(SP), CX
    ADDQ    CX, AX
    MOVQ    AX, main·ret+24(SP)
    RET

通常のプログラミング言語同様に、一行単位でやりたい処理を書き連ねていってます。
次に、この一行単位の処理に含まれている要素について説明しましょう。

用語

普通のプログラミング言語にも「変数」や「定数」といった用語があるように、アセンブリ言語にも特有の概念・用語があります。
まずはそれについて解説していきます。

  • シンボル : アセンブリコードの中で、データやアドレスにつける名前のこと。
    (例) main·add(SB)
  • 命令(instruction) : アセンブリファイルの中で一行で書かれたかたまりの処理のうち、機械語になるもの。
    (例) ADDQ CX, AX
  • ディレクティブ(directive) : アセンブリファイルの中で一行で書かれたかたまりの処理のうち、機械語にならないもの。擬似命令ともいう。
    (例) TEXT main·add(SB),$0-24
  • ニーモニック : アセンブリの中で使われる、人間が読みやすいように名前が付けられたシンボリックネームのこと。オペコードとオペランドで構成されている。
    (例) ADDQ, CX, AX...
  • オペコード : 演算子のこと。 (例)ADDQ, MOVQ...
  • オペランド : 引数や演算対象のこと。

先ほどのアセンブリコードの中で、上で紹介した概念がどれに該当するのかについてみてみましょう。

;  TEXTディレクティブでmain·addシンボルの宣言
;  以下に続く複数の命令で関数のボディを定義
TEXT    main·add(SB),$0-24
    ;  MOVQ命令
    ;  MOVQがニーモニックなオペコード
    ;  続くmain·n+16(SP)というシンボルと, AXというニーモニックがオペランド
    MOVQ    main·n+16(SP), AX
    ;  MOVQ命令
    MOVQ    main·m+8(SP), CX
    ;  ADDQ命令
    ADDQ    CX, AX  
    ;  MOVQ命令
    MOVQ    AX, main·ret+24(SP)
    ;  RET命令
    RET

アセンブリコード自体の構造についてなんとなく理解したところで、次は書かれている内容そのものについて見ていきましょう。

命令

ここでは、Goアセンブリ内で用いられる代表的な命令について、ニーモニックとともに紹介します。

メモリ操作

ニーモニック 命令概要 命令例
MOVQ オペランド2にオペランド1の値をコピー MOVQ main·n+16(SP), AX ; main·n+16(SP)の値をAX上にコピー
LEAQ オペランド2にオペランド1のアドレスをコピー 8(SP), SI; 8(SP)のアドレスをSIレジスタ上にコピー
CLD ディレクションフラグ(DF)をクリア オペランドなし

算術演算

ニーモニック 命令概要 命令例
ADDQ オペランド2にオペランド1の値を足す ADDQ CX, AX ; CX+AXの結果をAXにいれる
SUBQ オペランド2からオペランド1の値を引く SUBQ $~15, SP ; SP-15の結果をSPにいれる
ANDQ オペランド2にオペランド1の値をAND演算する ANDQ $~15, SP ; SP&15の結果をSPにいれる
CMPQ オペランド1の値とオペランド2の値を比較→結果をZF(ゼロフラグ)に格納 CMPQ AX $0; AXが0に等しいならZFが立つ

ADDQ,SUBQのように、末尾にQがつく命令が多いと思うかもしれませんが、これは演算対象となるオペランドのデータサイズを表すサフィックスです。
Qの場合は、オペランドが64bitであることを示します。他にも以下のようなサフィックスが存在します。
B → byte, 8bit, W →word 16bit , L → long 32bit , Q → quad 64bit

条件分岐

ニーモニック 命令概要 命令例
JE ZF(ゼロフラグ)が立っているなら指定ラベルにジャンプ CMPQ命令の後にJE [ラベル]などと用いられることが多い
JNE ZF(ゼロフラグ)が立っていないなら指定ラベルにジャンプ CMPQ命令の後にJNE [ラベル]などと用いられることが多い

分岐

ニーモニック 命令概要 命令例
JMP オペランドで指定されたアドレスにジャンプする JMP _rt0_amd64(SB); シンボル_rt0_amd64(SB)で表されたアドレスにジャンプ
CALL オペランドで指定されたアドレスにある関数を動かすサブルーチンを呼び出す CALL AX ; AXレジスタに入っている関数を呼び出す
RET サブルーチンから復帰 オペランドなしで「RET」とTEXTディレクティブの末尾で呼ばれて、関数の呼び出し元に戻る。

スタック操作

ニーモニック 命令概要 命令例
PUSHQ スタックにオペランドをpushする PUSHQ $0 ; 値0をpush
POPQ スタックからpopした内容をオペランドに格納 POPQ AX ; pop結果をAXにいれる

その他機能

ニーモニック 命令概要 命令例
SYSCALL 決められたレジスタの中身に沿ったシステムコールを呼ぶ オペランドなし
CPUID 使用しているCPUベンダIDを取得 オペランドなし

ディレクティブ

Goアセンブリで使われるディレクティブについてもまとめていきます。

ディレクティブ 概要
TEXT エントリポイントの定義 TEXT main·add(SB),$0-24 ; スタックフレームのサイズ0,引数と戻り値のサイズ24のadd関数を定義
DATA 指定アドレス領域が指定した値で初期化されていることを宣言 DATA array+0(SB)/1, $’a’; arrayの0番目を1byteの文字aで初期化
GLOBL 指定された長さを持つシンボルをグローバルなものと宣言 GLOBL array(SB), $4 ; 4バイトのarrayをグローバルにする
LONG long型の数字データを格納する領域を確保する LONG $12345 ; 10進法の"12345"のメモリ領域を確保する

レジスタについて

命令・ディレクティブといった、いわゆるオペコード側の要素について説明したところで、次にオペランドになりやすいものについて扱っていきましょう。
例えば、これまでADDQ CX, AXといった記述をみて、「ADDQはわかるけど、CXとかAXとか何者?」と思った方もいるのではないでしょうか。

これらはレジスタという、PC内の記憶回路を表しています。

レジスタとメモリの関係

レジスタもデータを記憶するものだったら、メモリとは何が違うの?と思う方もいるでしょう。

レジスタとメモリの大きな違いは、それがある場所です。
レジスタはCPUの中に存在する記憶回路であるのに対し、メモリはCPUとは独立して存在します。

基本的に、CPUが直接操作できる記憶装置はレジスタのみです。
メモリ上にロードされているプログラムの中から「命令された演算を実行するために、ここのアドレスのデータが欲しい」となった場合は、そのアドレス上にあったデータを一旦レジスタにコピーしてきて使う、という手順を踏む必要があります。
またその逆として、「レジスタ上に残った計算結果を、メモリに書き込むことで保存する」ということも行われます。

メモリ-レジスタ間のデータのやり取りは、MOV系の命令で行われることが多いです。

メモリについて

アセンブリで記述される内容は、メモリの中身をアドレスで直接指定するようなものが多いです。
そのため、メモリ構造・運用の仕方について知っておくと、アセンブリで何をしているのかが読みやすくなります。

ここからは、メモリ周りで知っておく概念・用語について説明します。

仮想メモリ

仮想メモリの構成

プロセス(=プログラム)が起動されると、そのプロセス上で自由に使える仮想メモリ空間が割り当てられます。
メモリ空間は一次元のアドレス番地をもち、ワード(=CPUが一度に処理できるバイト数)を幅とする柱のように図示されることが多いです。

メモリ空間・ワードの大きさはコンピュータのCPU・アーキテクチャによって異なります。

メモリ領域の種類

プロセスに与えられた仮想メモリ空間の中は、用途によって大きく5つに分かれています。

  • テキスト領域 : 機械語のコード・命令がそのまま格納される領域
  • データ領域 : 初期化済みグローバル変数が格納される領域
  • bss領域 : 初期化されていないグローバル変数が格納される領域
  • ヒープ領域 : プログラム側で動的に確保・開放が行えるメモリ領域。C言語でいうとmalloc関数でユーザーが確保できるのは、この領域に含まれるメモリ。
  • スタック領域 : LIFOの順で開放するメモリ領域。関数呼び出しの管理はここで行われる。詳細後述。

仮想メモリ空間上にこの5領域は、以下のように配置されます。

ヒープ領域とスタック領域は、プログラム実行中に確保・開放がなされ、領域の大きさが変動します。
そのため、この2領域の間には未使用領域が残されており、新規にメモリを確保する際にはこの未使用領域から使われます。
ヒープ領域はアドレスが高くなる方向に向かって確保され、スタック領域はアドレスが低くなる方向に向かって確保されます。

スタックフレーム

関数呼び出しのときに、以下のデータをスタック領域にpushしていきます。

(例)

  • ローカル変数
  • 関数の引数
  • リターンアドレス(関数の実行が終了したら、どこのアドレスの命令にジャンプするかという情報)
  • 呼び出し元のレジスタ(関数実行が終了したら、SPやFPをどこに移動させるかという情報)

1関数呼び出しごとに用意されるこのデータのまとまりのことをスタックフレームといいます。

スタックポインタ(SP)

スタック領域は、アドレスが高い方から順番に埋まっていきます。
この「どこのアドレスまでスタックが詰まっているか」というスタックトップアドレスへのポインタが**スタックポインタ(SP)**です。

フレームポインタ(FP)

SPを参照してすぐのところにあるのは、その関数が持つローカル変数のデータです。
そのため、その関数の引数データを参照しようとすると、「SPに適切なオフセットを足す」というワンステップを踏んでからアクセスする必要があり不便です。

そのため、SPだけではなく**フレームポインタ(FP)**という概念も導入されました。
現在実行している関数の引数部分のトップアドレスへのポインタがフレームポインタ(FP)です。

アドレス

アドレスの高低

普段私たちが「アドレス」といっているものは、実際にはただの数字でしかありません。
例えば、次のようなコードを実行してみます。

func main() {
	a := [5]int{0, 1, 2, 3, 4}
	for i := range a {
		// a[i]の要素が格納されているアドレスを表示
		fmt.Printf("%p\n", &a[i])
	}
}
// 結果
0xc0000b2030
0xc0000b2038
0xc0000b2040
0xc0000b2048
0xc0000b2050

5個の配列要素アドレスが0xc0000b2030から0xc0000b2050に渡って表示されました。
この数字の大小がそのままアドレスの高低となります。数字が小さい方が低い方に対応します。

プレフィックスの0xは、この数字が16進法リテラルであることを示しています。

オフセット

基準となるメモリアドレスからの増減のことをオフセットといいます。
例えば、0xc0000b2030が基準なら、二番目の要素0xc0000b2038はオフセット+8となります。

プログラムカウンタ(PC)

CPUでは現在テキスト領域内のどこの命令を実行しているかを指すアドレスを、**プログラムカウンタ(PC)**というレジスタ内に保存しています。別名命令アドレスレジスタとも。

静的ベースレジスタ(SB)

プログラムのアドレス空間のトップを参照しているレジスタのことを**静的ベースレジスタ(SB)**といいます。

Goでの実行ファイルの作成

ここからは、Goアセンブリで書かれた処理をどう実行するのか、ということに関して説明します。

以下のようなコードを用意します。

src.go
package main

import "fmt"

func add(m int, n int) int

func main() {
	i := add(1, 2)
	fmt.Println(i)

	i = add(3, 4)
	fmt.Println(i)
}
asm.s
TEXT    main·add(SB),$0-24
    MOVQ    main·n+16(SP), AX
    MOVQ    main·m+8(SP), CX
    ADDQ    CX, AX
    MOVQ    AX, main·ret+24(SP)
    RET

一つはGoのソースコード、一つはGoアセンブリです。
ソースコード中にあるadd関数の中身が、アセンブリで実装されています。

このプログラムを実行するためには、ソースコード・アセンブリ両方をうまく連携させる必要があります。
どのようにすれば良いでしょうか。

まとめて処理

一番楽なのは、go buildコマンドを使うことです。このコマンド1つで実行ファイルを作成することができます。

$ ls
asm.s   src.go
$ go build -o a.out
$ ./a.out
3
7

きちんとadd(1, 2)add(3, 4)の答えである3,7が出力されました。

一つずつ処理

go buildの中で自動でやっていることを手動でやろうとすると、意外と複雑です。

  1. アセンブリファイルで使うためのgo_asm.hヘッダファイルをGoソースコードから生成
  2. アセンブル
  3. アセンブリファイルが使用しているABIの種類をファイルに出力
  4. コンパイル
  5. 複数個のオブジェクトファイルを一つにまとめる
  6. リンク

1. アセンブリファイルで使うためのgo_asm.hヘッダファイルをGoソースコードから生成

Goのソースコードの中で、定数やユーザー定義の構造体があり、それをアセンブリコード内で参照してコードを書きたいという場合があります。
その場合、Goソースコードの中身を使えるようにするgo_asm.hヘッダファイルを生成し、アセンブリに渡してやる必要があります。

go_asm.hヘッダについては後ほど"A Quick Guide to Go's Assembler"でも触れられる部分です。

これを行うためには、go tool compileコマンドに-asmhdrフラグを渡してやります。

$ ls
asm.s   src.go

$ go tool compile -asmhdr go_asm.h src.go
→ go_asm.hとsrc.oができる

-asmhdrフラグについては、ドキュメントに以下のように記載されています。

Usage:
go tool compile [flags] file...
Flags:
-asmhdr file
Write assembly header to file.

出典: pkg.go.dev - cmd/compile

2. アセンブル

次に、asm.sファイル内にあるファイルをアセンブルして、オブジェクトコードを作っていきましょう。

$ go tool asm -p main asm.s
→ asm.oができる

-p mainとフラグをつけることで、「mainパッケージをインポートする」という風に紐付けすることができます。

3. アセンブリファイルが使用しているABIの種類をファイルに出力

これは、次のステップ4のための下準備です。

$ go tool asm -gensymabis -o symabis asm.s
→ symabisができる

-o symabisオプションをつけることで、出力先をsymabisファイルにするように指定してます。
go tool asm-gensymabisフラグについては、ドキュメントに以下のような記載があります。

Usage:
go tool asm [flags] file
Flags:
-gensymabis
Write symbol ABI information to output file. Don't assemble.

出典: pkg.go.dev - cmd/asm

4. コンパイル

Goのソースコードをコンパイルします。
今回はソースコードの中でアセンブリで書かれた関数を呼び出しているので、-symabisフラグを使って、3で生成したABIの情報を一緒につけてコンパイルする必要があります。

$ go tool compile -symabis symabis -p main src.go

5. 複数個のオブジェクトファイルを一つにまとめる

現在カレントディレクトリ下にはsrc.oasm.oの2つのオブジェクトファイルがあります。
これを、main.aという1つのファイルにまとめて、リンカが使える形にしていきます。

これを行うためのコマンドが、go tool packコマンドになります。

$ go tool pack c main.a *.o

6. リンクする

オブジェクトファイルが一つにまとまったところで、リンクをしていきましょう。
Goでリンクを行うコマンドはgo tool linkです。

$ go tool link main.a
→ a.outができる

こうしてできたa.outが実行ファイルです。

$ ./a.out
3
7

実行ファイル生成までの関係図


画像出典:The Design of the Go Assembler

これはRob Pike氏がGopherCon2016で行ったThe Design of the Go Assemblerというセッションで使ったスライドの一部です。
一つの実行ファイルを作る過程において、コンパイラ・アセンブラ・リンカがどう絡んでくるのかという順番を表しています。


一番上のこの部分は、標準的な処置手順を表しています。
ソースコードはコンパイラによってアセンブリに書き換えられ、アセンブリはアセンブラを通った後にリンクされ、実行ファイルが出来上がります。
別の言い方をすると、コンパイラから生成された成果物は直接リンカに渡すことはできず、必ずアセンブラを通る必要があるワークフローです。


下2つは、Goで採用された実行ファイル作成手順です。
コンパイラの成果物を元にアセンブラが動くような依存関係ではありません。コンパイラとアセンブラは双方ともに、直接リンカに渡せるオブジェクトファイルを生成します。

ここまでの知識があれば、A Quick Guide to Go's Assemblerの大枠は理解できると思います。

(翻訳)Goアセンブラ早わかりガイド

このドキュメントは、Goコンパイラgcで使われている特有のアセンブリ言語についての概要について簡潔に記したものです。
そのアセンブリ言語について網羅的な説明を提供するものではありません。

このGoアセンブラはPlan9[1]におけるアセンブリの記法を元に作られています。
Plan9アセンブリについての詳細は文書 "A Manual for the Plan 9 assembler"に譲りたいと思います。紹介したこのドキュメントはPlan9特有の記述ではありますが、もしあなたがこれからアセンブリ言語を書こうとしているのであれば一度目を通しておくことをおすすめします。
このドキュメントでは、Plan9アセンブリ言語で使用する構文・それらとGoアセンブリとの違いについてを要約して述べ、またGoアセンブリからGo言語の関数を呼び出す、またはその逆といった、Go言語のソースコードと互換性のある処理をアセンブリコードで書くときに必要になる性質・特色についても記述します。

Goアセンブリについて学ぶ上で最も重要なことは、これがそのままマシン上で用いられている機械語と一対一対応したものにはなっていないということです。
いくつかのアセンブリ命令は機械語と正確に対応していますが、そうでないものもあります。
それはなぜかというと、コンパイラ(詳しくはこちら(文書 "Plan 9 C Compilers"))はコンパイル過程において通常アセンブリコードをパスする[2]必要としていないからです。
その代わりにコンパイラはある種の擬似アセンブリ命令セットを扱い、その擬似アセンブリ命令に実際の命令を対応させる作業についてはコード生成の後に部分的に行われます。
アセンブラはその擬似アセンブリに対して作用するため、ツールチェインが実際に生成したGoアセンブリのMOV命令がそのまま機械語のMOVにならず、メモリクリアやロード命令になっていることもあるのです。
またそうではなく、生成されたアセンブリ命令の名前はマシンが使っているそれと完全に一致していることもあります。
一般的に、メモリ移動やサブルーチン呼び出し、returnコールのような汎用的な操作が抽象的な名前を持ち、マシン依存の演算名はより「体を表した」命名になることが多いです。
詳細はマシンアーキテクチャによって異なるため、ここはいささか不正確な説明なのかもしれませんが、しかし命名についてこれといったルールが特別あるわけではないのです。

アセンブラは擬似アセンブリ命令で書かれた処理を解釈し、リンカに渡す形に変換するためのものです。
もしamd64のような所定のアークテクチャ[3]で、ある種の処理がアセンブリ言語でどう書かれているかを見たければ、runtimemath/bigのような標準ライブラリ内に数多くの事例が存在します。
また、コンパイラがアセンブラ言語に変換したコードそのものを確認することもできます(実際の出力結果はアーキテクチャ依存であるため、以下に示すものとは異なる可能性があります)。

$ cat x.go
package main

func main() {
	println(3)
}
$ GOOS=linux GOARCH=amd64 go tool compile -S x.go        # or: go build -gcflags -S x.go
"".main STEXT size=74 args=0x0 locals=0x10
	0x0000 00000 (x.go:3)	TEXT	"".main(SB), $16-0
	0x0000 00000 (x.go:3)	MOVQ	(TLS), CX
	0x0009 00009 (x.go:3)	CMPQ	SP, 16(CX)
	0x000d 00013 (x.go:3)	JLS	67
	0x000f 00015 (x.go:3)	SUBQ	$16, SP
	0x0013 00019 (x.go:3)	MOVQ	BP, 8(SP)
	0x0018 00024 (x.go:3)	LEAQ	8(SP), BP
	0x001d 00029 (x.go:3)	FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (x.go:3)	FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (x.go:3)	FUNCDATA	$2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
	0x001d 00029 (x.go:4)	PCDATA	$0, $0
	0x001d 00029 (x.go:4)	PCDATA	$1, $0
	0x001d 00029 (x.go:4)	CALL	runtime.printlock(SB)
	0x0022 00034 (x.go:4)	MOVQ	$3, (SP)
	0x002a 00042 (x.go:4)	CALL	runtime.printint(SB)
	0x002f 00047 (x.go:4)	CALL	runtime.printnl(SB)
	0x0034 00052 (x.go:4)	CALL	runtime.printunlock(SB)
	0x0039 00057 (x.go:5)	MOVQ	8(SP), BP
	0x003e 00062 (x.go:5)	ADDQ	$16, SP
	0x0042 00066 (x.go:5)	RET
	0x0043 00067 (x.go:5)	NOP
	0x0043 00067 (x.go:3)	PCDATA	$1, $-1
	0x0043 00067 (x.go:3)	PCDATA	$0, $-1
	0x0043 00067 (x.go:3)	CALL	runtime.morestack_noctxt(SB)
	0x0048 00072 (x.go:3)	JMP	0
...

FUNCDATAディレクティブとPCDATAディレクティブには、ガベージコレクタによって使われる情報が含まれており、これらはコンパイラによって挿入されたのです。

FUNCDATAディレクティブとPCDATAディレクティブの内容は、Goのソースコードに書かれていたprintln(3)の処理内容を反映したものではない、ということです。

リンク後のバイナリ内に含まれているものを確認したければ、go tool objdumpコマンドを使用してください。

$ go build -o x.exe x.go
$ go tool objdump -s main.main x.exe
TEXT main.main(SB) /tmp/x.go
  x.go:3		0x10501c0		65488b0c2530000000	MOVQ GS:0x30, CX
  x.go:3		0x10501c9		483b6110		CMPQ 0x10(CX), SP
  x.go:3		0x10501cd		7634			JBE 0x1050203
  x.go:3		0x10501cf		4883ec10		SUBQ $0x10, SP
  x.go:3		0x10501d3		48896c2408		MOVQ BP, 0x8(SP)
  x.go:3		0x10501d8		488d6c2408		LEAQ 0x8(SP), BP
  x.go:4		0x10501dd		e86e45fdff		CALL runtime.printlock(SB)
  x.go:4		0x10501e2		48c7042403000000	MOVQ $0x3, 0(SP)
  x.go:4		0x10501ea		e8e14cfdff		CALL runtime.printint(SB)
  x.go:4		0x10501ef		e8ec47fdff		CALL runtime.printnl(SB)
  x.go:4		0x10501f4		e8d745fdff		CALL runtime.printunlock(SB)
  x.go:5		0x10501f9		488b6c2408		MOVQ 0x8(SP), BP
  x.go:5		0x10501fe		4883c410		ADDQ $0x10, SP
  x.go:5		0x1050202		c3			RET
  x.go:3		0x1050203		e83882ffff		CALL runtime.morestack_noctxt(SB)
  x.go:3		0x1050208		ebb6			JMP main.main(SB)

go tool compile -Sコマンドを使用して確認できるアセンブリはリンク前のもので、go tool objdumpコマンドを使用して逆アセンブリした結果はリンク後のものであるため、両者の結果は一致しないということは特筆すべき事項でしょう。

定数

いくらPlan9に準拠して作られたとはいえ、GoのアセンブラはPlan9のそれとは別個のものであるため、いくつかの相違点が存在します。その一つが定数評価です。
Goアセンブラにおいて、定数表現はGoの演算子の優先順位にしたがってパースされ、Plan9のようにC言語風には扱われません。
したがって、3&1<<2というのはGo言語風に(3&1)<<2とパースされ4と評価されます。C言語のように3&(1<<2)とパースされ0と評価されることはありません。
また、定数は常に64bitの符号なし整数として評価されます。そのため、-2は整数値-2ではなく、同様のビットパターンを持つ64bit符号なし整数として扱われます。
このGoアセンブラとPlan9との相違点はほとんど問題になることはありませんが、曖昧さを排除するために、右オペランドの最上位ビット[4]がセットされた状態の除算と右シフト実行は拒否されるようになっています。

シンボル

R1LRのようないくつかのシンボル名は事前に定義されており、レジスタを表すために使われます。
定義済みシンボルの正確な一覧はアーキテクチャによって異なります。

擬似レジスタを表すための定義済みシンボルは4つあります。
それらは実際のレジスタそのものではありませんが、フレームポインタのように一連のツールチェインによって運用される仮想レジスタです。
以下の仮想レジスタシンボル名は全てのアークテクチャで統一されています。

  • FP: フレームポインタ。引数とローカルオブジェクトの指定に使われる。
  • PC: プログラムカウンタ。ジャンプ命令・分岐命令の指示に使われる。
  • SB: 静的ベースポインタ。グローバルシンボルの指定に使われる。
  • SP: スタックポインタ。スタックの先頭を表す。

全てのユーザー定義のシンボルは仮想レジスタFPSBからのオフセットとして記述されます。
以下、FPSBを利用したシンボル記述方法の詳細についてみていきましょう。

仮想レジスタSBはメモリの先頭アドレスだとみなすことができます。そのためシンボルfoo(SB)はメモリ上のアドレスにつけたfooという名前という意味になります。
この記述方法はグローバルな関数とデータを表す際に使われます。
foo<>(SB)のように、シンボル名に<>を付け足すことで、C言語のファイル冒頭で行うstatic宣言のように、シンボルの参照をそれが位置するローカルファイル上からのみに制限することができます。
また、シンボル名にオフセットを付け足すことで、そのシンボルアドレスからのオフセットを表すことができます。例えば、foo+4(SB)は、fooの開始位置から+4バイト進んだ場所を表します。

仮想レジスタFPは関数の引数を表すために使われる仮想フレームポインタです。
コンパイラは仮想フレームポインタを正しく更新・管理し、その仮想レジスタからのオフセットを使うことで関数引数を参照します。
そのため、0(FP)は関数の第一引数で、8(FP)は64ビットマシンにおいては第二引数を表します。
しかしながら、このような方法で関数の引数を表現するのならば、first_arg+0(FP)second_arg+8(FP)のように接頭辞として引数名をつける必要があります。
(FPにおけるオフセットはフレームポインタからの差分を表し、シンボル名からの差分であるSBのそれとは意味が異なります。)
アセンブラはこの書き方を強制し、0(FP)8(FP)といった書き方を拒否します。
ここでつける引数名は意味論的にたいして重要ではありませんが、引数の名前を記録するために使われるべきです。
繰り返しますが、FPというのは仮想レジスタでありハードウェアのレジスタではありません。使用しているハードウェアがフレームポインタを持つようなアーキテクチャであったとしてもそれは変わりません。

64bitマシンでは、メモリにおけるワードの大きさは64bit=8byteであるため、FPのアドレスから0~7byteまでにデータ1個、8~15byteまでに次のデータ1個という格納方式になります。

Goソースコード内でプロトタイプ宣言をしているアセンブリ関数のために、go vetコマンドは引数名とオフセットが合致しているかをチェックする機能を持っています。
32ビットシステムにおいて、64bit値の前半と後半は、arg_lo+0(FP)arg_hi+4(FP)のように、変数名に_lo/_hiサフィックスをつけることによって区別されます。
Goソースコード内でのプロトタイプにおいて名前付き戻り値を使用してない場合、アセンブリ内においてはretを戻り値名として使うのがよいでしょう。

プロトタイプとは、中身を書かずに関数名とその引数・戻り値だけを宣言しておくフォーマットのことです。
(例) func add(m int, n int) int

仮想レジスタSPは、関数内におけるローカル変数と、関数呼び出しのために用意された引数を参照するための仮想スタックポインタです。
これはローカルスタックフレームのトップを指しており、そのため参照には[-framesize, 0)の範囲に存在する負の数のオフセットを使う必要があります。例えばx-8(SP)y-4(SP)のようになります。

SPという名前のハードウェアレジスタを持つアーキテクチャでは、変数名によるプレフィックスをつけることよって、それは仮想スタックポインタへの参照なのかアーキテクチャのSPレジスタへの参照なのかを区別しています。
つまり、x-8(SP)-8(SP)が指し示すメモリ位置は異なります。前者は仮想レジスタにおける仮想スタックポインタを、後者はハードウェアのSPレジスタを指します。

SPPCといった文字が伝統的に番号が付いた物理レジスタへのエイリアスとみなされるマシン上においても、GoアセンブラはSPPCというシンボルに特別な扱いをします。
例えばFPへの参照をfirst_arg+0(FP)とシンボルを付けて行うように、SPへの参照も、x-8(SP)のように同様なシンボルを必要とします。
また、実際のハードウェアレジスタへの参照には、Rxxのようにレジスタの実際の名前を使います。
例えば、ARMアーキテクチャにおいては、ハードウェアSPPCはそれぞれR13R15といった名前でアクセス可能です。

例えばx86には、R8からR15までの番号が付いた汎用レジスタがあります。

分岐命令とジャンプ命令は、常にPCに対するオフセットで記述されたアドレスか、ラベルへのジャンプとして記述されます。

label:
	MOVW $0, R1
	JMP label

それぞれのラベルは、それが定義された関数内からしか参照できません。
そのため、同じファイル内の複数の関数の中で同じラベル名を使ったとしても問題にはなりません。
ジャンプ命令とCALL命令はname(SB)といったシンボルをターゲットにすることができますが、name+4(SB)といったシンボルにオフセットを付けたものをターゲットとすることは不可能です。

命令、レジスタ、ディレクティブは、それが危険な処理をはらむことを意識するために常に大文字でなくてはなりません。
(ARMの別名であるgはこの法則の例外です。)

Goのオブジェクトファイルとバイナリにおいて、シンボルの正式名称はfmt.Printfmath/rand.Intのように、パッケージパスをピリオドでシンボル名につなげたものになります。
しかしアセンブラのパーサーはピリオドとスラッシュを句読点(punctuation)として認識してしまうため、このような文字列をそのまま識別子名として使うことはできません。
その代わり、アセンブリ内ではU+00B7の中ドットとU+2215のdivision slash[5]を識別子名の中で使うことができ、通常のピリオドとスラッシュはこれらに書き換えられます。
例えばアセンブリのソースファイルの中では、前述したシンボルはfmt·Printfmath∕rand·Intと書かれます。
go tool compile -Sコマンド実行時にコンパイラによって生成されるアセンブリリストは、アセンブラが要求するような上述の置き換えをしていない、ピリオドとスラッシュをそのまま表示させます。

ほとんどの手書きのアセンブリファイルでは、シンボル名のプレフィックスにパッケージ名のフルパスをつけることはありません。
なぜなら、リンカがピリオドで始まる全てのシンボル名の冒頭にオブジェクトファイルのパスを挿入する役割を担うからです。
math/randパッケージ内で実装されたアセンブリファイルでは、Int関数は·Int関数として参照されます。
この記法によって、ソースコード内にパッケージのインポートパスをハードコーディングする必要をなくし、ソースコードを置くディレクトリを移動させることをより容易にしています。

ディレクティブ

アセンブラはテキストやデータをシンボル名にバインドするために、様々なディレクティブを使用しています。
例えば、シンプルな関数定義を考えてみましょう。
TEXTディレクティブはruntime·profileloopというシンボルとそれに続く関数のボディ実装を宣言しています。
TEXTブロックにおける最後の命令はある種のジャンプ命令でなくてはならず、通常は擬似命令RETが使われます。
(もしそうでないなら、リンカはそのTEXTブロック自身にジャンプする命令を挿入します。つまり、TEXTブロックにおいてはフォールスルーの挙動は存在しません。)
シンボルの後には、引数としてフラグやフレームサイズ、定数が続きます。

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

一般的なケースにおいて、フレームサイズは引数サイズに続き、マイナス記号で区切られて表現されます。(このマイナス記号は引き算を表すのではなく、特異的なシンタックスにすぎません。)
フレームサイズ$24-8は、その関数は24バイトのフレームを持ち、呼び出し側のフレーム内に存在する8バイトの引数を持って呼び出すということを表します。
NOSPLITTEXTディレクティブの引数として明記されていなくても、引数サイズは必ず明示される必要があります。
Goのソースコード内でプロトタイプ宣言されたアセンブリ関数のために、go vetは引数サイズが正しいかどうかをチェックします。

シンボル名は要素を分けるために中点を使っており、また仮想レジスタSBからのオフセットが明記されます。
この関数はruntimeパッケージ内に存在するprofileloop関数として、Goソースコードから呼び出されます。

グローバルデータを表すシンボルはDATAディレクティブから始まる初期化シーケンスによって定義され、GLOBLディレクティブがその後に続きます。
それぞれのDATAディレクティブは対応するメモリ領域を初期化します。
明示的に初期化されなかったメモリはゼロ値になります。
DATAディレクティブの一般的な使用例は以下のようになります。

DATA	symbol+offset(SB)/width, value

これは与えられたオフセット(offset)から始まる幅width分だけのメモリが、与えられた値valueで初期化されます。
与えられたシンボルに対するDATAディレクティブは、正のオフセットを伴って書かなくてはなりません。

GLOBLディレクティブは、そのシンボルがグローバルなオブジェクトであることを宣言します。
引数として、任意のフラグと、グローバルに宣言するデータサイズを付ける必要があります。
DATAディレクティブによって初期化されていない場合は、そのオブジェクトはゼロ値になります。
GLOBLディレクティブは対応するDATAディレクティブに続けて書かれなくてはなりません。

例えば以下をみてみましょう。

DATA divtab<>+0x00(SB)/4, $0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384
GLOBL divtab<>(SB), RODATA, $64

GLOBL runtime·tlsoffset(SB), NOPTR, $4

これはdivtab<>を宣言し、読み取り専用の4バイト整数値のテーブルとして初期化しています。
また、runtime·tlsoffsetを4バイトのポインタを含まない4バイトのゼロ値であることを暗黙的に宣言しています。

DATA divtab<>+0x00(SB)/4, $0xf4f8fcffの第二引数は、$0xf4f8fcffのように先頭に$をつけています。
これはどういうことかというと、アセンブリ言語において「16進数0xf4f8fcffの値そのもの」という即値を表現するためには、先頭に$をつける必要があるからです。
これを、$をつけずにただ0xf4f8fcffと書くと、「アドレス0xf4f8fcff番への参照」という風に解釈されます。

ディレクティブには1つか2つの引数がつきます。
もしも引数が2つであったとき、第一引数はフラグのビットマスクであり、加算・論理和をとるための数値表現として書かれるか、可読性重視のシンボルとして書かれるかのどちらかの形になっています。
第一引数のフラグに取れるシンボルは、標準#includetextflag.hファイル内で定義されており、以下のようなものがあります。

TEXT runtime·profileloop(SB),NOSPLIT,$8

の場合、NOSPLITが第一引数、$8が第二引数です。

  • NOPROF = 1
    (TEXTに対して)このフラグがついた関数はプロファイルしないようにする。非推奨.
  • DUPOK = 2
    一つのバイナリの中で、このシンボルがついた複数のインスタンスが存在してもよい。
    リンカはその中から一つを選んで使用する。
  • NOSPLIT = 4
    (TEXTに対して)スタックが分割されているかチェックするためのプリアンブルを挿入してはならない。
    ルーチンや、それを呼ぶためのフレームはスタックセグメントの冒頭にある、ヒープ領域とのスペアスペースに入れなくてはならない。
    スタックを分割する処理をするルーチンを保護するために使われる。
  • RODATA = 8
    (DATAGLOBLに対して)このデータを読み取り専用区域に格納する。
  • NOPTR = 16
    (DATAGLOBLに対して)このデータはポインタを含んでいないので、ガベージコレクタによるスキャンを必要としない。
  • WRAPPER = 32
    (TEXTに対して)これはラッパー関数で、revocer不可能なものとして含めるべきではない。
  • NEEDCTXT = 64
    (TEXTに対して)この関数はクロージャであり、コンテキストレジスタを使用している。
  • LOCAL = 128
    このシンボルは動的に共有されるオブジェクト中でのローカルオブジェクトである。
  • TLSBSS = 256
    (DATAGLOBLに対して)このデータはスレッドのローカルストレージに格納する。
  • NOFRAME = 512
    (TEXTに対して)スタックフレームを割り当てるために命令を挿入してはならず、leaf functionでなかったとしてもリターンアドレスを保存しておかなくてはならない。
    フレームサイズが0である関数でのみ有効。
  • TOPFRAME = 2048
    (TEXTに対して)この関数はスタックフレームの先頭にある。
    トレースバックはこの関数で止める必要がある。

Goソースコード内の型・定数と互換的な処理をする

パッケージ内に拡張子.sのファイルが存在するならば、go buildコマンドはgo_asm.hという、拡張子.sのファイルが自身に#includeできるようにする特別なヘッダファイルを出力させるように、コンパイラに指示します。
そのヘッダファイルには、Goの構造体フィールドのオフセット用に#defineで定義されたシンボリック定数と、構造体型のサイズと、ビルド対象となったGoパッケージ内で定義されたconst宣言のほとんどが含まれます。
Goアセンブリコード内でGoソースコードでの型レイアウトについて推測するようにするのを避けるべきであり、その代わりこのようなヘッダファイルを用います。
こうすることでアセンブリの可読性を改善させ、Goの型宣言やGoコンパイラで使われるデータレイアウト規則が変わったときにおけるロバスト性を担保することができます。

定数はconst_nameというフォーマットをとります。
例えば、const bufSize = 1024という宣言があった時に、アセンブラコードはこの定数をconst_bufSizeという名前で参照することができます。

フィールドオフセットはtype_fieldというフォーマットになります。
構造体のサイズはtype__sizeとなります。
例えば、次の構造体宣言について考えます。

type reader struct {
	buf [bufSize]byte
	r   int
}

アセンブラはこの構造体のサイズをreader__sizeという名前で、2つのフィールドのオフセットをそれぞれreader_buf,reader_rという名前で参照することができます。
そのため、もしもレジスタR1reader型へのポインタを含んでいるならば、アセンブラはrフィールドをreader_r(R1)と参照することができます。

これらの#define名前が曖昧であった場合(例えば_sizeという名前のフィールドを含む構造体)、#include "go_asm.h"は"redefinition of macro"エラーを出力して失敗します。

ランタイムとの連携

ガベージコレクタを正しく作動させるために、ランタイムは全てのグローバルデータへのポインタ・スタックフレームのポインタがある場所を把握している必要があります。
Goコンパイラはソースファイルのコンパイル時にこれらの情報を出力しますが、アセンブラプログラムはこれを明示的に定義する必要があります。

NOPTRフラグが立ったデータシンボルはruntime-allocatedなデータに対してポインタを含まないものとして扱われます。
RODATAフラグがたったデータシンボルは読み取り専用のメモリに割り当てられ、それゆえに暗黙的にNOPTRがついた状態と同じ扱いとなります。
ポインタよりもデータサイズが小さいデータシンボルに関しても同様に、暗黙的にNOPTRがついた状態となります。
アセンブリソースファイルの中でポインタを含むシンボルを定義することはできません。
そのようなシンボルはアセンブリ内ではなく、Goのソースコードの中で定義されるべきです。
アセンブリソースはDATAGLOBLディレクティブなしでも、そのシンボルの名前を使うことでデータを参照することができます。
一般的な経験則として、RODATAフラグなしのシンボルはアセンブリ内ではなくGoコードの中で定義するべきです。

それぞれの関数は自身の引数、返り値、ローカルスタックフレームの場所を指し示すポインタの場所についてのアノテーションを必要としています。
ポインタ返り値を持たない関数や、スタックフレームか関数呼び出しを持たない関数においては、必要なのは同じパッケージ内にある関数のプロトタイプ定義のみです。
アセンブラ関数の名前にパッケージ名を含めてはなりません。
(例えば、syscallパッケージ内のSyscall関数は、TEXTディレクティブに存在するsyscall·Syscallと同名にするのではなく·Syscallとすべきです)
さらに複雑な例のために、明示的なアノテーションは必要です。
これらのアノテーションは標準#includefuncdata.hファイルの中で定義された擬似命令を使います。

関数が引数・返り値を持たない場合、ポインタに関する情報は省略することができます。
これはTEXTディレクティブにおける引数サイズを表す$n-0アノテーションによって示されます。
そうでなければ、たとえアセンブラ関数がGoのソースコード内から直接呼ばれていなかったとしても、ポインタ情報はGoのソースファイル内の関数プロトタイプによって提供されます。
(関数プロトタイプの引数参照が正しいかどうかはgo vetコマンドによってチェックすることができます。)
関数実行の冒頭において、引数は初期化されていると仮定されますが、返り値に関しては初期化なしと推測されます。
もしもCALL命令実行の間に返り値がポインタを持つ場合、関数は返り値をゼロ化する処理から入るべきであり、それからGO_RESULTS_INITIALIZED擬似命令を実行するべきです。
このGO_RESULTS_INITIALIZED擬似命令は返り値が初期化されており、スタック移動とガベージコレクトの際に返り値がスキャンされるべきだということを示します。
アセンブリ関数がポインタを返さないようにすること、またCALL命令を含まない用にすることは一般的に容易なことです。
GO_RESULTS_INITIALIZEDを使っているアセンブラ関数は、標準ライブラリ内には存在しません。

関数がローカルスタックフレームを持たない場合、ポインタ情報は省略することができます。
これはTEXTディレクティブにおけるローカルフレームサイズを表す$0-nアノテーションによって示されます。
関数がCALL命令を含まない場合においても、ポインタ情報は省略することができます。
そうでなければ、ローカルスタックフレームはポインタを含んではならず、アセンブラは擬似命令NO_LOCAL_POINTERSを実行することでこの事実を確認しなくてはなりません。
スタックリサイズはスタックを移動させることで実装されているため、スタックポインタは関数呼び出しの間に変わることがあります。
スタックデータへのポインタですら、ローカル変数として格納すべきではありません。

ポインタ情報を引数と返り値に提供するため、またgo vetにそれらにアクセスするために使われているオフセットが正しいかどうかをチェックさせるために、アセンブリで定義された関数はGoソースコード内での関数プロトタイプを常に持つべきです。

アーキテクチャ依存の詳細

アセンブリ中で利用できる全ての命令と、それぞれのマシン上での詳細について全て述べるのは非現実的なのでここではしません。
あるマシンアーキテクチャ上で定義されている命令一覧を確認するためにはobjパッケージ内の補助ライブラリをご覧ください。
例えばARMで使用できる命令一覧はsrc/cmd/internal/obj/armパッケージ内にあり、その中のa.out.goファイル内では、Aから始まる大量の定数が定義されています。

const (
	AAND = obj.ABaseARM + obj.A_ARCHSPECIFIC + iota
	AEOR
	ASUB
	ARSB
	AADD
	...

これがARMアーキテクチャにおいてアセンブラとリンカが認識している、命令とニーモニックの対応表です。
このリスト上にある命令の頭文字は、それぞれAから始まっています。つまり、表中にあるAANDはビットごとの論理和AND演算命令を表しており、これを実行するためのアセンブリソースコード内ではANDと書かれます。
このリストのほとんどがアルファベット順です。
(cmd/internal/obj内で定義された、アーキテクチャから独立して定義されたAXXXという命令は、ここでは不正な命令として認識されます)
これらAから始まる名前は、実際に行われる機械語エンコードとはなんの関係もありません。
機械語とこれらの名前との対応付けの詳細はcmd/internal/objパッケージが管理しています。

以下、マシン依存の話に入っていくので省略します。

参考文献

脚注
  1. Plan9とは、Goの生みの親であるRob Pike氏がかつて所属していたベル研究所にて作られた、UNIXの後継OSです。 ↩︎

  2. コンパイラが成果物生成のためにソースコードを走査することを「パスする」といいます。 ↩︎

  3. アーキテクチャとは、ターゲット環境で実行できるバイナリの種類のこと。 ↩︎

  4. 最上位ビットとは、2の補数表現において数値の符号を表すビットのことを指します。 ↩︎

  5. U+00B7U+2215はUnicodeの文字コードです。 ↩︎

この記事に贈られたバッジ

Discussion

ログインするとコメントできます