
ランタイムを実装して標準ライブラリを学習する前に、抽象アセンブラGoをマスターする必要があります。 このガイドが、必要な知識をすばやく得るのに役立つことを願っています。
内容
この記事は、読者があらゆる種類のアセンブラーの基本的な知識を持っていることを前提としています。
アーキテクチャ関連の問題になると、linux / amd64の使用が常に暗示されます。
私たちは、常に含まれるコンパイラの最適化を使用します。
すべての引用は、特に明記しない限り、公式文書および/またはコードベースからのものです。
疑似アセンブラー
Goコンパイラーは、ハードウェアに縛られない抽象的でポータブルなアセンブラーを生成します。 次に、アセンブラーGoはこの擬似アセンブラーを使用して、ターゲット機器用のマシン固有の命令を生成します。
この追加の「レベル」には多くの利点があります。 主なものは、Goを新しいアーキテクチャに簡単に移植できることです。 詳細については、Rob Pikeのパフォーマンス「 The Design of the Go Assembler 」をお送りします。
Goアセンブラについて知っておく必要がある最も重要なことは、それが言語の基になるマシンを直接表現していないことです。 何かがマシンに直接マッピングされ、何かはそうではありません。 実際、コンパイラはアセンブラを通常のパイプラインに渡す必要はありません。 代わりに、コンパイラーは、コードの生成後に部分的に選択される命令の半抽象セットで動作します。 アセンブラは半抽象形式で動作するため、MOV命令が表示されても、ツールキットがこの操作の移動命令を生成することにはなりません。 おそらく、これはクリーニングまたはロードの指示になります。 または、生成された命令は、同じ名前のマシン命令と完全に一致する場合があります。 一般に、マシン固有の操作はそのように見え、メモリの移動やルーチンの呼び出しと戻りなどのより一般的な概念はより抽象的です。 詳細はアーキテクチャに依存し、不正確さをおforびします;状況は不確かです。
アセンブラープログラムは、この半抽象命令セットの説明を解析し、リンカーに転送するための命令に変換する方法です。
単純なプログラムの分解
次のGoコード( direct_topfunc_call.go )を検討してください。
//go:noinline func add(a, b int32) (int32, bool) { return a + b, true } func main() { add(10, 32) }
(コンパイラ指令に注意してください
//go:noinline
...注意してください。)
アセンブラーでコードをコンパイルしましょう:
$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go 0x0000 TEXT "".add(SB), NOSPLIT, $0-16 0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB) 0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 MOVL "".b+12(SP), AX 0x0004 MOVL "".a+8(SP), CX 0x0008 ADDL CX, AX 0x000a MOVL AX, "".~r2+16(SP) 0x000e MOVB $1, "".~r3+20(SP) 0x0013 RET 0x0000 TEXT "".main(SB), $24-0 ;; ...omitted stack-split prologue... 0x000f SUBQ $24, SP 0x0013 MOVQ BP, 16(SP) 0x0018 LEAQ 16(SP), BP 0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d MOVQ $137438953482, AX 0x0027 MOVQ AX, (SP) 0x002b PCDATA $0, $0 0x002b CALL "".add(SB) 0x0030 MOVQ 16(SP), BP 0x0035 ADDQ $24, SP 0x0039 RET ;; ...omitted stack-split epilogue...
コンパイラの動作を理解するために、2つの関数を行ごとに配置しました。
add
分析
0x0000 TEXT "".add(SB), NOSPLIT, $0-16
-
0x0000
:関数の開始に対する現在の命令のオフセット。 -
TEXT "".add
:TEXT
ディレクティブは、シンボル"".add
.text
セクション(つまり、実行可能コード)の一部"".add
宣言し、ディレクティブに続く命令が関数の本体であることを意味します。
リンク中の空の文字列""
は、現在のパッケージの名前に置き換えられます。たとえば、最終バイナリへのリンク後の"".add
になりmain.add
。 -
(SB)
:SB
は、「静的ベース」ポインター、つまりプログラムのアドレス空間の先頭のアドレスを含む仮想レジスタです。
"".add(SB)
は、キャラクターがアドレス空間の先頭から一定のオフセットでアドレスに配置されていることを通知します。 つまり、これは、グローバル関数のシンボルが書き込まれる絶対的な直接アドレスです。 これはobjdump
確認します:
$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g F .text 000000000000000f main.add
すべてのユーザー文字は、擬似レジスターFP(引数およびローカル変数)およびSB(グローバル変数)のオフセットとして書き込まれます。 SB擬似レジスタはメモリソースと見なすことができるため、シンボル
foo(SB)
はメモリ内のアドレスとしての名前fooです。 -
NOSPLIT
は、現在のスタックを拡大する必要があるかどうかを確認するスタック分割プリアンブルを挿入しないようコンパイラーに指示します。
add
関数の場合、コンパイラはこのフラグ自体を設定します。十分にスマートであり、add
はローカル変数と独自のスタックフレームがないため、現在のスタックを単純に拡張することはできません。 つまり、呼び出しごとにチェックが実行され、プロセッササイクルが無駄になります。
"NOSPLIT"
:スタックを分割する必要がある場合、初期チェックを挿入しません。 ルーチンのフレームは、それが呼び出すものと同様に、スタックセグメントの先頭のスペアスペースに収まる必要があります。 スタック分割コード自体などのルーチンを保護するために使用されます。 記事の最後で、ゴルーチンとスタックパーティションについて少し説明します。 -
$0-16: $0
メモリに割り当てられたスタックフレームのサイズ(バイト単位)。$16
は、呼び出し元に渡される引数のサイズです。
一般に、引数のサイズの後には、マイナス記号で区切られた引数のサイズが続きます(これは減算ではなく、愚かな構文です)。
$24-8
フレームサイズは、関数が24バイトのフレームを持ち、呼び出し側のフレームにある8バイトの引数で呼び出されることを意味します。TEXT
NOSPLIT
が指定されていない場合、引数のサイズを指定する必要があります。 Goプロトタイプを使用するアセンブラー関数の場合、go vet
は引数のサイズが正しいことを確認します。
0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA
および
PCDATA
コンパイラーによって提供され、ガベージコレクターの情報が含まれています。
まだ深く掘り下げてはいけません。ガベージコレクションを調べる記事でこれに戻ります。
0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX
Go呼び出し規約は、呼び出し元のスタックのフレーム内の事前予約スペースを使用して、すべての引数をスタックにプッシュするように指示します。 呼び出し元に引数を渡し、呼び出し元に値を返すことができるように、スタックを縮小および増加することは、呼び出し元の責任です。
GoコンパイラーはPUSH / POPファミリー命令を生成しません。SP機器の仮想スタックポインターをデクリメントまたはインクリメントすることでスタックサイズが変更されます( 問題#21についての説明を参照してください:SPレジスターについて )。
SP擬似レジスタは、関数呼び出し用に準備されたローカルフレーム変数と引数を参照するために使用される仮想スタックポインターです。 ローカルスタックフレームの先頭を指すため、リンクは[-framesize、0]の範囲の負のオフセットを使用する必要があります:x-8(SP)
、y-4(SP)
など。
公式ドキュメントでは、「すべてのユーザー文字はFP疑似レジスター(引数およびローカル変数)に対するオフセットとして書き込まれます」と書かれていますが、これはユーザーが作成したコードにのみ当てはまります。
最新のコンパイラと同様に、生成されたコード内のGoツールキットは、常にスタックポインターからのオフセットを使用して引数とローカル変数を直接参照します。 これにより、スタックフレームをレジスタの少ないプラットフォーム(x86など)で汎用レジスタとして使用できます。
このような退屈な詳細が好きな場合は、「 x86-64スタックフレーム図 」をご覧ください(問題#2の説明も参照してください:フレームポインター)。
"".b+12(SP)
および
"".a+8(SP)
は、スタックの最上部から12および8バイトにあるアドレスを指します(覚えておいてください:スタックが下がっていきます!)。
.a
および
.b
は、参照する場所の任意のエイリアスです。 セマンティックな意味はまったくありませんが、仮想レジスタへの相対アドレス指定を使用する場合に使用するように規定されています。 以下は、仮想フレームポインターに関するドキュメントの説明です。
FP擬似レジスタは、関数の引数を参照するために使用される仮想フレームポインターです。 コンパイラは仮想フレームポインタをサポートし、スタック上の引数を擬似レジスタからのオフセットとして参照します。 したがって、0(FP)は関数の最初の引数、8(FP)は2番目(64ビットマシン上)などです。 ただし、この方法で関数の引数を参照する場合は、最初に名前を付ける必要があります。たとえば、first_arg + 0(FP)およびsecond_arg + 8(FP)(ここで、オフセット-フレームポインターから-SBとは異なります。文字)。 アセンブラはこの規則を強制的に使用し、単純な0(FP)と8(FP)を拒否します。 実際の名前は意味的には一致しませんが、引数の名前を文書化するために使用する必要があります。
最後に、注意すべき他の2つの重要なポイント:
- 最初の引数
a
は0(SP)
ではなく8(SP)
にあります。これは、呼び出し側が疑似関数CALL
を使用して、戻りアドレスを0(SP)
CALL
保存するためです。 - 引数は逆の順序で渡されます。 つまり、最初の引数はスタックの最上部に最も近くなります。
0x0008 ADDL CX, AX 0x000a MOVL AX, "".~r2+16(SP) 0x000e MOVB $1, "".~r3+20(SP)
ADDL
は、
AX
と
CX
にある2つのロングワード(4バイト値など)を追加し、結果は
AX
書き込まれます。 次に、この結果は
"".~r2+16(SP)
に移動され
"".~r2+16(SP)
このスタックでは、呼び出し元が以前に場所を予約し、そこで戻り値を探します。 繰り返しますが、この場合は
"".~r2
には意味的な意味はありません。
Goが複数の戻り値を処理する方法を示すために、
true
定数ブール値を返し
true
。 メカニズムは、最初の戻り値の場合とまったく同じです
SP
変更に対応するのはオフセットのみです。
0x0013 RET
RET
疑似命令は、呼び出しルーチンから結果を正しく返すために、ターゲットプラットフォームで使用される呼び出し規約に必要な命令を挿入するようにアセンブラGoに指示します。 これにより、コードは確実に
0(SP)
あるリターンアドレスをポップ(ポップ
0(SP)
、そこに戻ります。
TEXTブロックの最後の命令は、何らかの遷移である必要があります。これは通常(疑似)RET命令です。 そうでない場合、リンカは、それ自体への遷移(jump-to-itself)を持つ命令を追加します。 TEXTブロックにフォールスルーはありません。
一度に多くの構文とセマンティクスを学ぶ必要があります。 上記のインラインの概要は次のとおりです。
;; Declare global function symbol "".add (actually main.add once linked) ;; Do not insert stack-split preamble ;; 0 bytes of stack-frame, 16 bytes of arguments passed in ;; func add(a, b int32) (int32, bool) 0x0000 TEXT "".add(SB), NOSPLIT, $0-16 ;; ...omitted FUNCDATA stuff... 0x0000 MOVL "".b+12(SP), AX ;; move second Long-word (4B) argument from caller's stack-frame into AX 0x0004 MOVL "".a+8(SP), CX ;; move first Long-word (4B) argument from caller's stack-frame into CX 0x0008 ADDL CX, AX ;; compute AX=CX+AX 0x000a MOVL AX, "".~r2+16(SP) ;; move addition result (AX) into caller's stack-frame 0x000e MOVB $1, "".~r3+20(SP) ;; move `true` boolean (constant) into caller's stack-frame 0x0013 RET ;; jump to return address stored at 0(SP)
そして、
main.add
は
main.add
実行完了後のスタックの内容の視覚的表現です:
| +-------------------------+ <-- 32(SP) | | | G | | | R | | | O | | main.main's saved | W | | frame-pointer (BP) | S | |-------------------------| <-- 24(SP) | | [alignment] | D | | "".~r3 (bool) = 1/true | <-- 21(SP) O | |-------------------------| <-- 20(SP) W | | | N | | "".~r2 (int32) = 42 | W | |-------------------------| <-- 16(SP) A | | | R | | "".b (int32) = 32 | D | |-------------------------| <-- 12(SP) S | | | | | "".a (int32) = 10 | | |-------------------------| <-- 8(SP) | | | | | | | | | \ | / | return address to | \|/ | main.main + 0x30 | - +-------------------------+ <-- 0(SP) (TOP OF STACK) (diagram made with https://textik.com)
main
分析する
記事を読み進める必要がないように、
main
機能がどのように見えるかを思い出させてください。
0x0000 TEXT "".main(SB), $24-0 ;; ...omitted stack-split prologue... 0x000f SUBQ $24, SP 0x0013 MOVQ BP, 16(SP) 0x0018 LEAQ 16(SP), BP ;; ...omitted FUNCDATA stuff... 0x001d MOVQ $137438953482, AX 0x0027 MOVQ AX, (SP) ;; ...omitted PCDATA stuff... 0x002b CALL "".add(SB) 0x0030 MOVQ 16(SP), BP 0x0035 ADDQ $24, SP 0x0039 RET ;; ...omitted stack-split epilogue... 0x0000 TEXT "".main(SB), $24-0
新しいものはありません:
-
"".main
(一度main.main
)は、.text
セクションのグローバル関数のシンボルであり、そのアドレスはアドレス空間の先頭からの一定のオフセットです。 - このコードは、24バイトのスタックフレームをメモリに配置し、引数を受け取らず、値を返しません。
0x000f SUBQ $24, SP
0x0013 MOVQ BP, 16(SP)
0x0018 LEAQ 16(SP), BP
上記のように、Goの呼び出し規約では、すべての引数をスタックに渡す必要があります。
呼び出し元
SUBQ
は、仮想スタックポインタをデクリメントすることにより、スタックフレームを24バイト増やします( スタックが大きくなることを忘れないでください。この場合、
SUBQ
はスタックフレームを増やします )。 これらの24バイトの構成:
- 8バイト(
16(SP)-24(SP)
)は、BPフレームポインター( 現在! )の現在の値を格納するために使用されます。スタックの昇格(スタックの巻き戻し)とデバッグの簡素化のために。 - 1 + 3バイト(
12(SP)-16(SP)
)は、2番目の戻り値(bool
)に加えて、amd64で必要な3バイトのアライメント用に予約されています。 - 4バイト(
8(SP)-12(SP)
)は、最初の戻り値(int32
)用に予約されています。 - 4バイト(
4(SP)-8(SP)
)は、引数b(int32
)の値用に予約されています。 - 4バイト(
0(SP)-4(SP)
)は、引数a(int32
)の値用に予約されています。
最後に、スタックを増やした後、
LEAQ
はフレームポインターの新しいアドレスを計算し、それを
BP
格納します。
0x001d MOVQ $137438953482, AX 0x0027 MOVQ AX, (SP)
呼び出し元は、呼び出し先の引数をクワッドワード(8バイト値)として受け取り、展開したばかりのスタックの一番上に置きます。
一見するとランダムなゴミのように見えますが、実際には
137438953482
は4バイト値
10
と
32
に対応しており、これらは1つの8バイト値に接続されています。
$ echo 'obase=2;137438953482' | bc 10000000000000000000000000000000001010 \____/\______________________________/ 32 10 0x002b CALL "".add(SB)
CALL
を、静的ベースポインターに対するオフセットとして
add
関数に適用します。 つまり、直接アドレスへの直接遷移です。
CALL
は、スタックの先頭に戻りアドレス(8バイト値)も配置することに注意してください。 したがって、
add
関数内から
SP
への各参照は8バイトシフトされます! たとえば、
"".a
は
0(SP)
ではなく
8(SP)
ます。
0x0030 MOVQ 16(SP), BP 0x0035 ADDQ $24, SP 0x0039 RET
最後に、我々:
- フレームポインターを1つのスタックポインターに巻き戻します(つまり、1レベル「ダウン」します)。
- スタックを24バイト減らして、以前に占有していたスペースを返します。
- アセンブラGoに戻りルーチンを挿入するように依頼します。
ゴルーチン、スタック、パーティションに関するいくつかの言葉
今はゴルーチンのギブルを扱う時でも場所でもありませんが、アセンブラーに飛び込み始めた場合、スタックの管理に関する指示にすぐに慣れる必要があります。
これらのパターンを迅速に認識し、一般にそれらが何をどのように行うかを理解できる必要があります。
スタック
Goプログラムのゴルーチンの量は決定されておらず、実際には数百万に達する可能性があるため、使用可能なメモリをすべて消費しないようにするには、実行時にゴルーチンにスタックを割り当てる保守的な方法に従う必要があります。
したがって、新しいゴルーチンはそれぞれ、実行時に最初に小さな2 KBスタックを取得します(実際、ヒープ上にあります)。
実行中、goroutinはスタックの初期スペースより大きくなることがあります(つまり、スタックオーバーフローが発生します)。 これを防ぐために、ランタイム環境は、スタックを満たすときに、古いスタックの2倍のサイズの新しいスタックを割り当て、その内容が新しいスタックにコピーされます。
このプロセスはスタックスプリットと呼ばれ、ゴルーチンに動的なスタックメカニズムを提供します。
部門
スタック分離メカニズムが機能するために、コンパイラーは、スタックをオーバーフローさせる可能性のある各関数の先頭と末尾に新しい命令を挿入します。
不要なオーバーヘッドを回避するために、スタックを超える可能性が低い関数にはNOSPLITのマークが付けられます。これは、チェックを挿入しないようコンパイラーに指示します。
メイン関数を見てみましょうが、今回はスタック分割プリアンブルを削除せずに:
0x0000 TEXT "".main(SB), $24-0 ;; stack-split prologue 0x0000 MOVQ (TLS), CX 0x0009 CMPQ SP, 16(CX) 0x000d JLS 58 0x000f SUBQ $24, SP 0x0013 MOVQ BP, 16(SP) 0x0018 LEAQ 16(SP), BP ;; ...omitted FUNCDATA stuff... 0x001d MOVQ $137438953482, AX 0x0027 MOVQ AX, (SP) ;; ...omitted PCDATA stuff... 0x002b CALL "".add(SB) 0x0030 MOVQ 16(SP), BP 0x0035 ADDQ $24, SP 0x0039 RET ;; stack-split epilogue 0x003a NOP ;; ...omitted PCDATA stuff... 0x003a CALL runtime.morestack_noctxt(SB) 0x003f JMP 0
ご覧のとおり、プリアンブルはプロローグとエピローグに分かれています。
- プロローグでは、ゴルーチンに割り当てられたスペースがオーバーフローしているかどうかがチェックされ、オーバーフローしている場合、実行はエピローグに進みます。
- エピローグはスタック拡張メカニズムを開始し、プロローグに戻ります。
フィードバックループが発生し、「飢ving」ゴルーチンに十分に大きなスタックが割り当てられるまで機能します。
プロローグ
0x0000 MOVQ (TLS), CX ;; store current *g in CX 0x0009 CMPQ SP, 16(CX) ;; compare SP and g.stackguard0 0x000d JLS 58 ;; jumps to 0x3a if SP <= g.stackguard0
TLS
は、ランタイム環境によって維持される仮想レジスタであり、現在の
g
、つまりゴルーチンの状態全体を監視するデータ構造へのポインターを含みます。
ランタイムソースコードの
g
の定義を見てみましょう。
type g struct { stack stack // 16 bytes // stackguard0 is the stack pointer compared in the Go stack growth prologue. // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption. stackguard0 uintptr stackguard1 uintptr // ...omitted dozens of fields... }
16(CX)
は、ランタイム環境でサポートされるしきい値である
g.stackguard0
に対応します。 彼女はこの値をスタックポインターと比較し、ゴルーチンがスタック不足に近づいているかどうかを調べます。 つまり、プロローグは、現在の
SP
値が
stackguard0
(正確には大きい)かどうかを確認し、必要に応じてエピローグに進みます。
エピローグ
0x003a NOP 0x003a CALL runtime.morestack_noctxt(SB) 0x003f JMP 0
エピローグの本体は単純です:ランタイム中に呼び出され、スタックを増やすためのすべての作業を行った後、関数の最初の命令(つまり、プロローグ)に戻ります。
NOP
命令は
CALL
前にあるため、プロローグは
CALL
に直接移動しません。 一部のプラットフォームでは、これにより悪影響が生じる可能性があります。 そのため、コール自体の直前に、通常はnoop命令を挿入し、
NOP
到達します( 「問題4:コール前のnop」段落の説明も参照してください)。
微妙なマイナス
氷山の一角だけを調べました。 スタックの成長の内部メカニズムにはさらに多くのニュアンスがあります。プロセスは非常に複雑で、詳細なレビューのために別の記事が必要です。
おわりに
次の記事でGoデバイスに没頭すると、Goアセンブラーは、内部メカニズムと一見あまり明らかではないものとの関係を理解するための最も重要なツールの1つになります。
参照資料
- [公式ドキュメント] Go Assemblerクイックスタートガイド
- [ホワイトペーパー] Go Compiler Directives
- [ホワイトペーパー] Go Assembler Architecture
- [ホワイトペーパー]連続スタックアーキテクチャ
- [公式ドキュメント]定数_StackMin
- [ディスカッション]問題#2:フレームポインター
- [ディスカッション]問題#4:「呼び出し前にnop」段落を明確化
- Goアセンブラープログラミングに入る
- Go関数のアセンブラーへの変換
- EBPフレームポインターレジスタの目的は何ですか?
- x86-64のスタックフレーム図
- Goスタックの仕組み
- スタックが減少している理由