chikaku

且听风吟

永远是深夜有多好。
github
email

Go アセンブリと ABI

封面图出自 Renee French 遵循 Creative Commons 4.0 Attributions license

レジスタ#

Plan9amd64通常用途
AXraxアキュムレータ算術操作数と戻り値を格納
BXrbxベースレジスタメモリの基準アドレス(構造体または配列)またはポインタを格納
CXrcxカウントレジスタループカウンタなどのカウント操作
DXrdxデータレジスタ乗数 / 除数などのデータを格納
DIrdiデスティネーションインデックス目的操作数のオフセット
SIrsiソースインデックスソース操作数のオフセット
BPrbpベースポインタスタックの基準アドレスを保存
SPrspスタックポインタスタックのトップポインタを保存
PCripプログラムカウンタプログラムカウンタ
R8-R14r8-r14汎用レジスタ

擬似レジスタ#

名称用途
FP(フレームポインタ)パラメータとローカル変数の基準アドレス
PC(プログラムカウンタ)プログラムカウンタ
SB(スタティックベースポインタ)グローバル変数の基準アドレス
SP(スタックポインタ)スタックポインタ(現在のスタックフレームの最高アドレス)

すべてのユーザープログラムで定義されたローカル変数は、FP と SB の 2 つのレジスタの基準アドレスに一定のオフセットを加えたものとしてコンパイルされます。

擬似レジスタ SB は、グローバル変数を参照するために使用されます。例えば:

  • foo (SB) はグローバル変数 foo のメモリアドレスを示します
  • foo<>(SB) はグローバル変数 foo が現在のファイル内でのみ可視であることを示します
  • foo+4 (SB) は foo のメモリ基準アドレスに 4 バイトのオフセットを加えたものを示します

擬似レジスタ FP は、関数パラメータを参照するために仮想スタックポインタを保存します。コンパイラは FP にオフセットを加えて現在の関数のパラメータにアクセスします。アクセス時にはパラメータ名を追加することもできますが、実際の用途はありませんが、コードの理解と読みやすさに役立ちます。また、アセンブラは FP を使用する際には必ずパラメータ名を追加する必要があります。例えば:

  • 0 (FP) または first_arg+0 (FP) は現在の関数の最初のパラメータを示します
  • 8 (FP) または second_arg+8 (FP) は現在の関数の 2 番目のパラメータ(最初のパラメータは 8 バイトを占める)を示します

注意:ハードウェア FP レジスタが存在するかどうかにかかわらず、FP は擬似レジスタです。

擬似レジスタ SP は仮想スタックポインタを保存し、現在のスタックフレーム内のローカル変数と関数呼び出しパラメータにアクセスするために使用されます。これは現在のスタックフレームの最高アドレスを指し、オフセットは [−framesize, 0) の範囲内でのみ使用できます。例えば x-8 (SP) や y-4 (SP) は、ハードウェア SP レジスタを持つアーキテクチャにおいて、パラメータ名のプレフィックスを持つアクセスと持たないアクセスには以下の違いがあります:

  • x-8 (SP) はパラメータ名のプレフィックスを持つアクセスで擬似レジスタ SP を使用します
  • -8 (SP) はパラメータ名のプレフィックスを持たないアクセスでハードウェアレジスタ SP を使用します

シンボル定義#

Go のオブジェクトファイルとバイナリファイルでは、完全なシンボル名はパッケージパスにドットとシンボル名を続けたものから構成されます。例えば math/rand.Int は、ソースファイルからアセンブリに変換される過程で、コンパイラはこれを math∕rand・Int に変換します。ここでスラッシュとドットは U+2215 と U+00B7 に変換されます。手書きのアセンブリでシンボルを定義する際には、完全なパッケージ名を含める必要はありません。リンクプロセス中に、リンカは自動的にドットで始まる各シンボルに完全なパッケージ名を追加します。したがって、・Int のようなシンボル名を定義するだけで済みます。

アセンブラはプログラム指示(directives)を使用してコードまたはデータをシンボルにバインドします。例えば:

関数シンボル定義#

関数(コードセクション)は TEXT 指令を使用して定義されます。例えば:

TEXT runtime·profileloop(SB),NOSPLIT,$24-8
  • pkgname: パッケージ名、省略可能
  • funcname (SB): 関数名、関数自体はグローバルシンボルであり、SB を通じて参照されます
  • NOSPLIT: アセンブリ指令のパラメータ、後で紹介します
  • $24-8: この関数のスタックフレームと(パラメータ + 戻り値)のサイズをそれぞれ示します。NOSPLIT パラメータを使用する場合は、スタックフレームのサイズを提供する必要があります。

グローバルデータシンボル定義#

グローバルデータは一連の DATA 指令と GLOBAL 指令を使用して定義されます。DATA 指令の形式は次の通りです。

DATA symbol+offset(SB)/width, value

これは、シンボル symbol の指定されたオフセット offset で、サイズが width で初期値が value のメモリセグメントを初期化することを示します。複数の DATA 指令の offset/width は連続している必要があります。GLOBAL 指令はグローバルシンボルを宣言するために使用され、シンボル名、パラメータ、サイズを指定する必要があります。DATA 指令に初期化値がない場合、GLOBAL はそれを 0 に初期化します。例えば:

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

上記のコードは、64 バイトの読み取り専用グローバル変数 divtab と 4 バイトのグローバル変数 runtime・tlsoffset を初期化し、両方を 0 に初期化します。ここで NOPTR が宣言されているため、これらのデータにはポインタが含まれていません。

シンボル定義のパラメータ#

各アセンブリ指令には 1 つまたは 2 つのパラメータを含めることができます。2 つのパラメータがある場合、最初のパラメータはフラグマスクでなければなりません。すべてのパラメータ定義は、#include "textflag.h" を通じて導入する必要があります。パラメータは次の通りです:

  • DUPOK: バイナリ内に同じシンボルが複数存在することを許可し、リンカはその中の 1 つを選択します
  • NOSPLIT: TEXT 指令用、スタックオーバーフローのチェックを挿入する必要がないことを示します
  • RODATA: DATA および GLOBAL 指令用、データを読み取り専用セクションに配置します
  • NOPTR: DATA および GLOBAL 指令用、データにポインタが含まれていないことを示し、GC スキャンが不要です
  • WRAPPER: TEXT 指令用、この関数がラッパーであることを示し、recover を無効にしないでください。ソースコード src/debug/gosym/pclntab.go を参照してください
  • NEEDCTXT: TEXT 指令用、この関数がクロージャであり、渡されたコンテキストレジスタを使用する必要があることを示します
  • TLSBSS: DATA および GLOBAL 指令用、TLS ストレージユニットを割り当て、そのオフセットを変数に保存することを示します
  • NOFRAME: TEXT 指令用、関数内にスタックフレームスペースを割り当てる指令を挿入しないことを示します。ゼロスタックフレーム関数に適しています
  • REFLECTMETHOD: 関数が reflect.Type.Method/reflect.Type.MethodByName を呼び出すことができることを示します
  • TOPFRAME: TEXT 指令用、この関数が呼び出しスタックの最上位であることを示し、スタックの展開はここで停止するべきです
  • ABIWRAPPER: TEXT 指令用、この関数が ABI のラッパーであることを示します

アセンブリでの Go 型と定数の使用#

パッケージに.s ファイルが含まれている場合、ビルド時にコンパイラは特別なヘッダーファイル go_asm.h を出力します。このヘッダーファイルには、構造体フィールドのオフセット、構造体のサイズ、および現在のパッケージで定義された定数の多くが含まれています。アセンブリでは、このヘッダーファイルを含めることで Go 型を使用できます。go_asm.h ファイル内では、さまざまな型が以下の形式で定義されています:

  • 定数: const_name
  • 構造体フィールドオフセット: type_field
  • 構造体サイズ: type__size
const bufSize = 1024

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

上記のコードを例にすると、アセンブリコード内では次のようにできます:

  • const_bufSize を通じて定数 bufSize を使用します
  • reader__size を通じて構造体 reader のサイズを取得します
  • reader_buf および reader_r を通じて buf および r フィールドのオフセットを取得します。もし R1 が reader ポインタを含む場合、reader_buf (R1) および reader_r (R1) を通じて 2 つのフィールドにアクセスできます。

実行時#

GC の正確な実行を確保するために、実行時はスタックフレームとグローバル変数に含まれるすべてのポインタを理解する必要があります。コンパイラは Go コードをコンパイルする際にこれらの情報を自動的に挿入しますが、アセンブリコードでは明示的に定義する必要があります。NOPTR パラメータを持つデータシンボルは、実行時に割り当てられたデータポインタを含みません。RODATA パラメータを持つシンボルデータはメモリの読み取り専用セクションに割り当てられるため、暗黙的に NOPTR マークが付与されます。ポインタサイズ未満の型も自然にポインタを含むことはありません。アセンブリコード内でポインタを含むシンボルを定義することはできませんが、Go コード内で定義し、アセンブリコード内で対応するシンボルを参照することができます。一般的には、すべての非読み取り専用シンボルを Go で定義し、アセンブリコードで定義しないのが最良の実践方法です。

各関数はそのパラメータ、戻り値、およびスタックフレーム内の生存ポインタの位置を注釈する必要があります。アセンブリ関数にポインタ戻り値がなく、関数呼び出しやスタックフレームスペースの要求がない場合、同じパッケージ内で Go 関数のプロトタイプ(シグネチャ)を定義するだけで済みます。より複雑な場合は、funcdata.h ヘッダーファイルを含めて擬似アセンブリ指令を参照し、明示的に注釈を付ける必要があります。パラメータや戻り値がない関数(TEXT 指令で $ n-0 と注釈された)では、ポインタ情報を無視できます。それ以外の場合、すべてのポインタ情報は Go コード内の関数プロトタイプ(シグネチャ)を通じて提供される必要があります。たとえ Go 関数が直接呼び出されないアセンブリ関数であってもです。

関数の冒頭では、パラメータが初期化されていると仮定できますが、戻り値は初期化されていません。戻り値に関数呼び出し中に生存しているポインタが存在する場合、関数は最初に戻り値を null に設定し、GO_RESULTS_INITIALIZED 擬似指令を実行する必要があります。この指令は、戻り値が初期化されたことを記録し、スタック転送(拡張)および GC 中にスキャンされるべきです。ほとんどの場合、アセンブリ関数でポインタを返すことを意図的に避けることができます。少なくとも標準ライブラリ内では、GO_RESULTS_INITIALIZED を使用するアセンブリ関数はありません。

関数にローカルスタックフレームがない場合(つまり、TEXT 指令で $ n-0 と宣言されている)や関数内に CALL 指令が含まれていない場合、ポインタ情報を無視できます。それ以外の場合、ローカルスタックフレームにはポインタを含めることはできません。アセンブラは擬似指令 NO_LOCAL_POINTERS を実行して検証します。スタックの拡張と縮小はスタックスペースをコピーして移動することで実現されるため、関数呼び出し中にスタックポインタが変更される可能性があります。そのため、スタック上のデータを指すポインタであっても、ローカル変数に保存すべきではありません。

アセンブリ関数は常に Go プロトタイプを提供する必要があります。これにより、パラメータと戻り値にポインタ情報を提供でき、go vet がオフセットの使用が正しいかどうかをチェックできます。

メモリレイアウト#

Go 言語に組み込まれている基本型のサイズとアライメント、及び複合型(構造体)内のフィールドオフセットの計算は、ABI ドキュメントMemory layoutを参照してください。他の型については:

  • map/chan/func 型のメモリレイアウトは * T に相当します
  • 配列型 [N] T のメモリレイアウトは N 個の T 型からなる連続メモリで構成されます
  • string 型はメモリ内で 2 つの部分から構成されます:バイト長を示す int と、[cap] T を指すポインタ
  • スライスタイプ [] T はメモリ内で 3 つの部分から構成されます:スライスの有効長を示す int、スライスの容量サイズを示す int、及び [cap] T を指すポインタ

構造体型のメモリは、その各フィールドのメモリが連続して構成されています。例えば、struct { f1 t1; ...; fM tM }型の構造体メモリ内の順序はt1, ..., tM, tPです。ここで tP は追加のバイトであり、最後のフィールド tM のサイズがゼロであり、前の任意のフィールド ti のサイズがゼロでない場合にのみ埋められます。実験により、構造体内のサイズゼロのフィールドのアドレスを取得すると、常にそのフィールドの後にある最初の非ゼロサイズ型フィールドのアドレスが返されることがわかります。したがって、最後のゼロサイズフィールドの後に 1 バイトを埋めることで、アドレス取得が外部メモリにアクセスしないことを保証します。

type S struct { // 0xc00034c000
    A struct{}  // 0xc00034c000
    B int       // 0xc00034c000
    C struct{}  // 0xc00034c008
    D struct{}  // 0xc00034c008
    E int       // 0xc00034c008
    F struct{}  // 0xc00034c010
}

空インターフェース interface {} 型 runtime.eface は以下の部分で構成されています:

  • 実行時の動的データ型記述を指すポインタ
  • 実行時の動的データ値を指す unsafe.Pointer ポインタ

非空インターフェース型は以下の部分で構成されています:

  • runtime.itab を指すポインタが含まれ:
    • runtime.interfacetype がこのインターフェースに関連するメソッドポインタを含みます
    • 実行時の動的データ型記述を指すポインタ
  • 実行時の動的データ値を指す unsafe.Pointer ポインタ

インターフェース型は直接型または間接型であることができます:

  • 直接型のインターフェースはデータを直接格納します
  • 間接型のインターフェースはデータを指すポインタを格納します
  • インターフェース内の値が単一のポインタのみで構成されている場合、このインターフェース型は直接型である必要があります

以上が Go のすべての型のメモリレイアウト構造ですが、手書きのアセンブリ関数ではこれらのルールに依存すべきではなく、go_asm.h ヘッダーファイルで定義された定数を参照すべきです。

関数呼び出しのパラメータと戻り値の渡し方#

関数呼び出し中に、パラメータ / 戻り値はスタックとハードウェアレジスタを通じて渡されます。各パラメータ / 戻り値は、全体がレジスタに格納される可能性があります(複数のレジスタを使用して 1 つのパラメータ / 戻り値を同時に格納できます)、またはスタックに格納されます。通常、レジスタへのアクセスはメモリへのアクセスよりも速いため、パラメータ / 戻り値は優先的にレジスタに格納されますが、残りのレジスタが完全な値を格納できない場合や、可変長配列を含む場合、パラメータ / 戻り値はスタックを通じて渡す必要があります。

各アーキテクチャは一組の整数レジスタと一組の浮動小数点レジスタを定義しています。高レベルでは、すべてのパラメータと戻り値の型は基本型に分解でき、順番にレジスタに格納されます。パラメータと戻り値は同じレジスタを共有できますが、同じスタックスペースを共有することはできません。呼び出し元は、レジスタに格納されているパラメータのためにスタック上にオーバーフロー用のスペースを確保しますが、このスペースは埋められません。具体的なパラメータ / 戻り値がレジスタまたはスタックに割り当てられるアルゴリズムは非常に複雑で、Function call argument and result passingを参照してください。

メソッドを呼び出す前に、呼び出し元のスタックフレームにメソッドレシーバー、スタック上のパラメータ、スタック上の戻り値、およびレジスタパラメータのオーバーフロー用スペースを格納するためのメモリを割り当てる必要があります。次に、対応するパラメータ値をレジスタまたはスタックスペースに格納し、呼び出し操作を実行します。呼び出しを実行する際、戻り値スタックスペース、オーバーフロースペース、および戻り値レジスタは初期化されていないため、呼び出し先は戻る前に戻り値をアルゴリズムに従って割り当てられた対応するレジスタまたはスタックフレームスペースに格納する必要があります。callee-save レジスタが存在しないため、すべての明示的な意味を持たないレジスタは上書きされる可能性があります。これにはパラメータレジスタも含まれます。

R0-R9 整数レジスタを持つ 64 ビットアーキテクチャにおいて、関数 f のシグネチャとその呼び出しスタックスペースは次のようになります:

func f(a1 uint8, a2 [2]uintptr, a3 uint8) (
    r1 struct { x uintptr; y [2]uintptr },
    r2 string,
)

// スタックスペースのレイアウト
// a2      [2]uintptr
// r1.x    uintptr
// r1.y    [2]uintptr
// a1Spill uint8
// a3Spill uint8
// _       [6]uint8  // アライメントパディング

a2 と r1 が配列を含むため、これらはスタック上に割り当てられ、値が設定されます。他のパラメータと戻り値はレジスタに割り当てることができます。r2 は 2 つの独立した部分に分解され、レジスタに割り当てられます。呼び出し時に a1 は R0 レジスタに、a3 は R1 レジスタに、a2 はスタック上に割り当てられます。戻り時に r2.base は R0 レジスタに、r2.len は R1 レジスタに、r1.x と r1.y はスタックスペースに割り当てられます。

クロージャ#

関数値のようなvar f funcは、クロージャオブジェクトへのポインタに相当します。クロージャはクロージャ関数のエントリアドレスと、クロージャ環境に関連するいくつかのメモリスペースで構成されます。クロージャの呼び出しルールは静的関数と基本的に一致しますが、唯一の例外は、各アーキテクチャで特別なクロージャコンテキストレジスタが設定されており、クロージャを呼び出す前にクロージャオブジェクトポインタがこのレジスタに保存されることです。これにより、クロージャ関数が終了した後でも、この特別なレジスタを通じてクロージャ内のオブジェクトを参照できます。

よく使われる命令#

// TODO

参考文献#

公式ドキュメントとコード:

その他の資料:

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。