解説&翻訳 - A Quick Guide to Go's Assembler
この記事について
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アセンブリに出てくる
SP
やFP
やAX
やらって何のこと?と思っている人 - 単純に"A Quick Guide to Go's Assembler"の翻訳が読みたい人
-
go build
・go tool compile
・go tool asm
などのコマンドの違いが知りたい人
逆に、以下のような方は読んでも物足りないか、ここからは得たい情報が得られないかもしれません。
- Goアセンブリを自分で書けるようになりたい人
- Goアセンブリのマシン依存となる詳細が知りたい人
- Goソースコード/アセンブリから機械語生成のアルゴリズム詳細について知りたい人
ビルドまわりの用語解説
アセンブラだったりアセンブリだったりアセンブルだったり似たような用語が多すぎる & コンパイルとも混同しやすい分野でもあるため、ここで一回関連用語についてまとめておきましょう。
- コンパイラ : コンパイルを行うプログラムのこと。
- コンパイル : 「高級言語(Go)→機械語or低級言語」への変換を一度に行う動詞。
~~
- アセンブラ : アセンブルを行うプログラムのこと。
- アセンブリ : 低級言語の一種。
MOVQ $3, (SP)
などと書かれるこの言語。 - アセンブル : 「アセンブリ言語→機械語」への変換を行うことを指す動詞。
~~
- オブジェクトファイル : コンパイル/アセンブル後に生成されたファイルのこと。
~~
- リンカ : リンクを行うプログラムのこと。
- リンク : 1つ以上のオブジェクトファイルを結合して、実行ファイルを作成することを指す動詞。
~~
- 実行ファイル : コンピュータが直接中身を実行することが可能な機械語のファイル。
アセンブリ言語について
「アセンブリとは何?」というところをはっきりさせたところで、ここからはアセンブリ言語そのものについて深堀していきましょう。
まずは、Goアセンブリで書かれたコードについてみてみましょう。
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が立つ |
条件分岐
ニーモニック | 命令概要 | 命令例 |
---|---|---|
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が直接操作できる記憶装置はレジスタのみです。
メモリ上にロードされているプログラムの中から「命令された演算を実行するために、ここのアドレスのデータが欲しい」となった場合は、そのアドレス上にあったデータを一旦レジスタにコピーしてきて使う、という手順を踏む必要があります。
またその逆として、「レジスタ上に残った計算結果を、メモリに書き込むことで保存する」ということも行われます。
メモリについて
アセンブリで記述される内容は、メモリの中身をアドレスで直接指定するようなものが多いです。
そのため、メモリ構造・運用の仕方について知っておくと、アセンブリで何をしているのかが読みやすくなります。
ここからは、メモリ周りで知っておく概念・用語について説明します。
仮想メモリ
仮想メモリの構成
プロセス(=プログラム)が起動されると、そのプロセス上で自由に使える仮想メモリ空間が割り当てられます。
メモリ空間は一次元のアドレス番地をもち、ワード(=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
に渡って表示されました。
この数字の大小がそのままアドレスの高低となります。数字が小さい方が低い方に対応します。
オフセット
基準となるメモリアドレスからの増減のことをオフセットといいます。
例えば、0xc0000b2030
が基準なら、二番目の要素0xc0000b2038
はオフセット+8
となります。
プログラムカウンタ(PC)
CPUでは現在テキスト領域内のどこの命令を実行しているかを指すアドレスを、**プログラムカウンタ(PC)**というレジスタ内に保存しています。別名命令アドレスレジスタとも。
静的ベースレジスタ(SB)
プログラムのアドレス空間のトップを参照しているレジスタのことを**静的ベースレジスタ(SB)**といいます。
Goでの実行ファイルの作成
ここからは、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)
}
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
の中で自動でやっていることを手動でやろうとすると、意外と複雑です。
- アセンブリファイルで使うための
go_asm.h
ヘッダファイルをGoソースコードから生成 - アセンブル
- アセンブリファイルが使用しているABIの種類をファイルに出力
- コンパイル
- 複数個のオブジェクトファイルを一つにまとめる
- リンク
go_asm.h
ヘッダファイルをGoソースコードから生成
1. アセンブリファイルで使うためのGoのソースコードの中で、定数やユーザー定義の構造体があり、それをアセンブリコード内で参照してコードを書きたいという場合があります。
その場合、Goソースコードの中身を使えるようにするgo_asm.h
ヘッダファイルを生成し、アセンブリに渡してやる必要があります。
これを行うためには、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.
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.
4. コンパイル
Goのソースコードをコンパイルします。
今回はソースコードの中でアセンブリで書かれた関数を呼び出しているので、-symabis
フラグを使って、3で生成したABIの情報を一緒につけてコンパイルする必要があります。
$ go tool compile -symabis symabis -p main src.go
5. 複数個のオブジェクトファイルを一つにまとめる
現在カレントディレクトリ下にはsrc.o
とasm.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で採用された実行ファイル作成手順です。
コンパイラの成果物を元にアセンブラが動くような依存関係ではありません。コンパイラとアセンブラは双方ともに、直接リンカに渡せるオブジェクトファイルを生成します。
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]で、ある種の処理がアセンブリ言語でどう書かれているかを見たければ、runtime
やmath/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
ディレクティブには、ガベージコレクタによって使われる情報が含まれており、これらはコンパイラによって挿入されたのです。
リンク後のバイナリ内に含まれているものを確認したければ、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)
定数
いくら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]がセットされた状態の除算と右シフト実行は拒否されるようになっています。
シンボル
R1
やLR
のようないくつかのシンボル名は事前に定義されており、レジスタを表すために使われます。
定義済みシンボルの正確な一覧はアーキテクチャによって異なります。
擬似レジスタを表すための定義済みシンボルは4つあります。
それらは実際のレジスタそのものではありませんが、フレームポインタのように一連のツールチェインによって運用される仮想レジスタです。
以下の仮想レジスタシンボル名は全てのアークテクチャで統一されています。
-
FP
: フレームポインタ。引数とローカルオブジェクトの指定に使われる。 -
PC
: プログラムカウンタ。ジャンプ命令・分岐命令の指示に使われる。 -
SB
: 静的ベースポインタ。グローバルシンボルの指定に使われる。 -
SP
: スタックポインタ。スタックの先頭を表す。
全てのユーザー定義のシンボルは仮想レジスタFP
とSB
からのオフセットとして記述されます。
以下、FP
とSB
を利用したシンボル記述方法の詳細についてみていきましょう。
仮想レジスタ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
というのは仮想レジスタでありハードウェアのレジスタではありません。使用しているハードウェアがフレームポインタを持つようなアーキテクチャであったとしてもそれは変わりません。
Goソースコード内でプロトタイプ宣言をしているアセンブリ関数のために、go vet
コマンドは引数名とオフセットが合致しているかをチェックする機能を持っています。
32ビットシステムにおいて、64bit値の前半と後半は、arg_lo+0(FP)
やarg_hi+4(FP)
のように、変数名に_lo
/_hi
サフィックスをつけることによって区別されます。
Goソースコード内でのプロトタイプにおいて名前付き戻り値を使用してない場合、アセンブリ内においてはret
を戻り値名として使うのがよいでしょう。
仮想レジスタSP
は、関数内におけるローカル変数と、関数呼び出しのために用意された引数を参照するための仮想スタックポインタです。
これはローカルスタックフレームのトップを指しており、そのため参照には[-framesize, 0)
の範囲に存在する負の数のオフセットを使う必要があります。例えばx-8(SP)
やy-4(SP)
のようになります。
SP
という名前のハードウェアレジスタを持つアーキテクチャでは、変数名によるプレフィックスをつけることよって、それは仮想スタックポインタへの参照なのかアーキテクチャのSP
レジスタへの参照なのかを区別しています。
つまり、x-8(SP)
と-8(SP)
が指し示すメモリ位置は異なります。前者は仮想レジスタにおける仮想スタックポインタを、後者はハードウェアのSP
レジスタを指します。
SP
やPC
といった文字が伝統的に番号が付いた物理レジスタへのエイリアスとみなされるマシン上においても、GoアセンブラはSP
とPC
というシンボルに特別な扱いをします。
例えばFP
への参照をfirst_arg+0(FP)
とシンボルを付けて行うように、SP
への参照も、x-8(SP)
のように同様なシンボルを必要とします。
また、実際のハードウェアレジスタへの参照には、Rxx
のようにレジスタの実際の名前を使います。
例えば、ARMアーキテクチャにおいては、ハードウェアSP
やPC
はそれぞれR13
やR15
といった名前でアクセス可能です。
分岐命令とジャンプ命令は、常にPC
に対するオフセットで記述されたアドレスか、ラベルへのジャンプとして記述されます。
label:
MOVW $0, R1
JMP label
それぞれのラベルは、それが定義された関数内からしか参照できません。
そのため、同じファイル内の複数の関数の中で同じラベル名を使ったとしても問題にはなりません。
ジャンプ命令とCALL
命令はname(SB)
といったシンボルをターゲットにすることができますが、name+4(SB)
といったシンボルにオフセットを付けたものをターゲットとすることは不可能です。
命令、レジスタ、ディレクティブは、それが危険な処理をはらむことを意識するために常に大文字でなくてはなりません。
(ARMの別名であるg
はこの法則の例外です。)
Goのオブジェクトファイルとバイナリにおいて、シンボルの正式名称はfmt.Printf
やmath/rand.Int
のように、パッケージパスをピリオドでシンボル名につなげたものになります。
しかしアセンブラのパーサーはピリオドとスラッシュを句読点(punctuation)として認識してしまうため、このような文字列をそのまま識別子名として使うことはできません。
その代わり、アセンブリ内ではU+00B7
の中ドットとU+2215
のdivision slash[5]を識別子名の中で使うことができ、通常のピリオドとスラッシュはこれらに書き換えられます。
例えばアセンブリのソースファイルの中では、前述したシンボルはfmt·Printf
とmath∕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バイトの引数を持って呼び出すということを表します。
NOSPLIT
がTEXT
ディレクティブの引数として明記されていなくても、引数サイズは必ず明示される必要があります。
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バイトのゼロ値であることを暗黙的に宣言しています。
ディレクティブには1つか2つの引数がつきます。
もしも引数が2つであったとき、第一引数はフラグのビットマスクであり、加算・論理和をとるための数値表現として書かれるか、可読性重視のシンボルとして書かれるかのどちらかの形になっています。
第一引数のフラグに取れるシンボルは、標準#include
のtextflag.h
ファイル内で定義されており、以下のようなものがあります。
-
NOPROF = 1
(TEXT
に対して)このフラグがついた関数はプロファイルしないようにする。非推奨. -
DUPOK = 2
一つのバイナリの中で、このシンボルがついた複数のインスタンスが存在してもよい。
リンカはその中から一つを選んで使用する。 -
NOSPLIT = 4
(TEXT
に対して)スタックが分割されているかチェックするためのプリアンブルを挿入してはならない。
ルーチンや、それを呼ぶためのフレームはスタックセグメントの冒頭にある、ヒープ領域とのスペアスペースに入れなくてはならない。
スタックを分割する処理をするルーチンを保護するために使われる。 -
RODATA = 8
(DATA
とGLOBL
に対して)このデータを読み取り専用区域に格納する。 -
NOPTR = 16
(DATA
とGLOBL
に対して)このデータはポインタを含んでいないので、ガベージコレクタによるスキャンを必要としない。 -
WRAPPER = 32
(TEXT
に対して)これはラッパー関数で、revocer
不可能なものとして含めるべきではない。 -
NEEDCTXT = 64
(TEXT
に対して)この関数はクロージャであり、コンテキストレジスタを使用している。 -
LOCAL = 128
このシンボルは動的に共有されるオブジェクト中でのローカルオブジェクトである。 -
TLSBSS = 256
(DATA
とGLOBL
に対して)このデータはスレッドのローカルストレージに格納する。 -
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
という名前で参照することができます。
そのため、もしもレジスタR1
がreader
型へのポインタを含んでいるならば、アセンブラはr
フィールドをreader_r(R1)
と参照することができます。
これらの#define
名前が曖昧であった場合(例えば_size
という名前のフィールドを含む構造体)、#include "go_asm.h"
は"redefinition of macro"エラーを出力して失敗します。
ランタイムとの連携
ガベージコレクタを正しく作動させるために、ランタイムは全てのグローバルデータへのポインタ・スタックフレームのポインタがある場所を把握している必要があります。
Goコンパイラはソースファイルのコンパイル時にこれらの情報を出力しますが、アセンブラプログラムはこれを明示的に定義する必要があります。
NOPTR
フラグが立ったデータシンボルはruntime-allocatedなデータに対してポインタを含まないものとして扱われます。
RODATA
フラグがたったデータシンボルは読み取り専用のメモリに割り当てられ、それゆえに暗黙的にNOPTR
がついた状態と同じ扱いとなります。
ポインタよりもデータサイズが小さいデータシンボルに関しても同様に、暗黙的にNOPTR
がついた状態となります。
アセンブリソースファイルの中でポインタを含むシンボルを定義することはできません。
そのようなシンボルはアセンブリ内ではなく、Goのソースコードの中で定義されるべきです。
アセンブリソースはDATA
やGLOBL
ディレクティブなしでも、そのシンボルの名前を使うことでデータを参照することができます。
一般的な経験則として、RODATA
フラグなしのシンボルはアセンブリ内ではなくGoコードの中で定義するべきです。
それぞれの関数は自身の引数、返り値、ローカルスタックフレームの場所を指し示すポインタの場所についてのアノテーションを必要としています。
ポインタ返り値を持たない関数や、スタックフレームか関数呼び出しを持たない関数においては、必要なのは同じパッケージ内にある関数のプロトタイプ定義のみです。
アセンブラ関数の名前にパッケージ名を含めてはなりません。
(例えば、syscall
パッケージ内のSyscall
関数は、TEXT
ディレクティブに存在するsyscall·Syscall
と同名にするのではなく·Syscall
とすべきです)
さらに複雑な例のために、明示的なアノテーションは必要です。
これらのアノテーションは標準#include
のfuncdata.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
パッケージが管理しています。
参考文献
-
The Design of the Go Assembler
Rob Pike氏がGopherCon2016で発表したセッションスライドです。 -
Goでちょっとひといき 第4章 Goアセンブリを触ってみる
あやさん(@aya_122)が、アセンブリで書かれたGoのbootstrap処理をガリガリ読んだり、fmt.Println()
をアセンブリで自作したりしている記事です。
本文中で度々出てきたmain·add(SB)
アセンブリ関数をここからお借りしています。
GitHubはこちら(add2フォルダをお借りしました)。 -
Plan 9 Assembler Handbook
えぬかねさん(@n_kane)さんが執筆されたPlan9アセンブラの解説本です。
Goアセンブリの元はPlan9なので、基本的な概念は一致しています。 -
Go言語低レイヤー入門 Hello world が画面に表示されるまで
DQNEOさん(@DQNEO)さんがGoCon2021 Springで発表したセッションスライドです。
この方はアセンブリを読むに留まらず、Hello World の表示ができるところまでガリガリ書くところまで突き進んでいます。 -
Goアセンブリの書き方
go build
を使わずアセンブリをビルドする手順について、大いに参考にさせていただいたブログです。
Discussion