Goアセンブラーガイド





ランタイムを実装して標準ライブラリを学習する前に、抽象アセンブラ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 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つの重要なポイント:



  1. 最初の引数a



    0(SP)



    ではなく8(SP)



    にあります。これは、呼び出し側が疑似関数CALL



    を使用して、戻りアドレスを0(SP)



    CALL



    保存するためです。
  2. 引数は逆の順序で渡されます。 つまり、最初の引数はスタックの最上部に最も近くなります。


 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
      
      





新しいものはありません:





0x000f SUBQ $24, SP

0x0013 MOVQ BP, 16(SP)

0x0018 LEAQ 16(SP), BP








上記のように、Goの呼び出し規約では、すべての引数をスタックに渡す必要があります。



呼び出し元SUBQ



は、仮想スタックポインタをデクリメントすることにより、スタックフレームを24バイト増やします( スタックが大きくなることを忘れないでください。この場合、 SUBQ



はスタックフレームを増やします
)。 これらの24バイトの構成:





最後に、スタックを増やした後、 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つのスタックポインターに巻き戻します(つまり、1レベル「ダウン」します)。
  2. スタックを24バイト減らして、以前に占有していたスペースを返します。
  3. アセンブラ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つになります。



参照資料






All Articles