Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 15456] ファイルの概要

このコミットは、Go言語のバイナリに格納され、実行時に使用されるシンボルテーブルの内部表現を調整するものです。セマンティクスや構造自体は変更せず、ビットのパッキング方法を効率化することで、特に64ビットシステムでのホストリンカとの連携を改善し、シンボルテーブルのサイズを削減することを目的としています。

コミット

commit c8dcaeb25deddac52cfca6ae6882ce94780582d3
Author: Russ Cox <rsc@golang.org>
Date:   Tue Feb 26 22:38:14 2013 -0500

    cmd/ld, runtime: adjust symbol table representation
    
    This CL changes the encoding used for the Go symbol table,
    stored in the binary and used at run time. It does not change
    any of the semantics or structure: the bits are just packed
    a little differently.
    
    The comment at the top of runtime/symtab.c describes the new format.
    
    Compared to the Go 1.0 format, the main changes are:
    
    * Store symbol addresses as full-pointer-sized host-endian values.
      (For 6g, this means addresses are 64-bit little-endian.)
    
    * Store other values (frame sizes and so on) varint-encoded.
    
    The second change more than compensates for the first:
    for the godoc binary on OS X/amd64, the new symbol table
    is 8% smaller than the old symbol table (1,425,668 down from 1,546,276).
    
    This is a required step for allowing the host linker (gcc) to write
    the final Go binary, since it will have to fill in the symbol address slots
    (so the slots must be host-endian) and on 64-bit systems it may
    choose addresses above 4 GB.
    
    R=golang-dev, iant
    CC=golang-dev
    https://golang.org/cl/7403054

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/c8dcaeb25deddac52cfca6ae6882ce94780582d3

元コミット内容

Goのシンボルテーブルのエンコーディングを変更し、バイナリに格納され実行時に使用される形式を調整します。これはセマンティクスや構造を変更するものではなく、単にビットのパッキング方法を少し変えるものです。

Go 1.0のフォーマットと比較して、主な変更点は以下の通りです。

  • シンボルアドレスをフルポインタサイズのホストエンディアン値として格納します。(例えば、6gの場合、アドレスは64ビットのリトルエンディアンになります。)
  • その他の値(フレームサイズなど)はvarintエンコードされます。

2番目の変更(varintエンコード)は、1番目の変更(フルポインタサイズのアドレス)を補って余りある効果があり、OS X/amd64上のgodocバイナリでは、新しいシンボルテーブルが古いシンボルテーブルよりも8%小さくなりました(1,546,276バイトから1,425,668バイトに減少)。

この変更は、ホストリンカ(gccなど)が最終的なGoバイナリを書き込むことを可能にするために必要なステップです。ホストリンカはシンボルアドレスのスロットを埋める必要があるため(スロットはホストエンディアンである必要があります)、64ビットシステムでは4GBを超えるアドレスを選択する可能性があるためです。

変更の背景

このコミットの背景には、Goバイナリのビルドプロセスにおけるリンカの役割と、特に64ビットシステムでのアドレス空間の利用に関する課題があります。

Go 1.0の時代では、Goのツールチェイン(具体的にはGoリンカであるcmd/ld)がバイナリの最終的なリンクを主導していました。しかし、既存のシステムリンカ(例えばLinux上のldやmacOS上のld、Windows上のlink.exeなど)とより密接に連携し、Goバイナリを生成するプロセスを標準化したいという要望がありました。

システムリンカがGoバイナリをリンクする際、Goのシンボルテーブル内のアドレスを適切に解決し、埋め込む必要があります。Go 1.0のシンボルテーブルは、Plan 9のフォーマットをベースにしており、アドレスの格納方法がシステムリンカの期待する形式と異なる場合がありました。特に、64ビットシステムではアドレスが4GBを超える可能性があり、Go 1.0のシンボルテーブルがこれを効率的かつ標準的な方法で扱えないという問題がありました。

また、シンボルテーブルのサイズも懸念事項でした。Goバイナリは、デバッグ情報やリフレクションのために豊富なシンボル情報を含んでおり、これがバイナリサイズを増大させる一因となっていました。シンボルテーブルのサイズを削減することは、バイナリの配布やロード時間の観点から望ましい改善でした。

このコミットは、これらの課題に対処するため、シンボルテーブルのエンコーディングを、システムリンカが扱いやすい「ホストエンディアンのフルポインタサイズアドレス」と、サイズ効率の良い「varintエンコード」を組み合わせた新しい形式に移行することを目的としています。これにより、Goのビルドシステムがより柔軟になり、将来的にシステムリンカとの統合が容易になる基盤を築いています。

前提知識の解説

このコミットを理解するためには、以下の技術的な概念を把握しておく必要があります。

  1. シンボルテーブル (Symbol Table):

    • プログラムの実行可能ファイル(バイナリ)に含まれるデータ構造の一つ。
    • 関数名、変数名、型名などのシンボルと、それらがメモリ上のどこに配置されているか(アドレス)などの情報をマッピングします。
    • デバッグ、プロファイリング、リフレクション、動的リンクなどの目的で実行時に利用されます。
    • Go言語では、特にリフレクションやスタックトレースの生成、ガベージコレクションの正確な動作のために、実行時にもシンボル情報が重要になります。
  2. リンカ (Linker):

    • コンパイラによって生成されたオブジェクトファイル(機械語コードとデータを含む)を結合し、実行可能なプログラムやライブラリを生成するツール。
    • 異なるオブジェクトファイル間で参照されるシンボル(関数呼び出しや変数アクセスなど)を解決し、実際のアドレスに置き換える役割を担います。
    • Go言語には独自のリンカ(cmd/ld)がありますが、最終的な実行可能ファイルを生成する際に、システムにインストールされているリンカ(例: GNU ld)と連携することもあります。
  3. エンディアン (Endianness):

    • マルチバイトのデータをメモリに格納する際のバイト順序のこと。
    • ビッグエンディアン (Big-endian): 最上位バイト(最も大きな桁のバイト)が最も小さいアドレスに格納される方式。人間が数字を読む順序と同じ。
    • リトルエンディアン (Little-endian): 最下位バイト(最も小さな桁のバイト)が最も小さいアドレスに格納される方式。
    • ホストエンディアン (Host-endian): プログラムが実行されるCPUのネイティブなエンディアンのこと。異なるアーキテクチャ間でバイナリを共有する場合、エンディアンの違いが問題となることがあります。
  4. ポインタサイズ (Pointer Size):

    • メモリアドレスを格納するために使用されるバイト数。
    • 32ビットシステムでは通常4バイト(32ビット)のポインタが使用され、最大4GBのアドレス空間を扱えます。
    • 64ビットシステムでは通常8バイト(64ビット)のポインタが使用され、理論上はるかに大きなアドレス空間を扱えます。
  5. Varint エンコーディング (Variable-length integer encoding):

    • 整数値を可変長のバイト列で表現するエンコーディング方式。
    • 小さな値は少ないバイト数で表現され、大きな値はより多くのバイト数で表現されます。
    • これにより、平均的にデータサイズを削減できます。例えば、Protocol BuffersやSQLiteなどで利用されています。
    • 一般的なvarintエンコーディングでは、各バイトの最上位ビット(MSB)が「次のバイトが続くか」を示すフラグとして使われ、残りの7ビットがデータとして使われます。
  6. Go 1.0 シンボルテーブルフォーマット (Plan 9 Format):

    • Go 1.0のシンボルテーブルは、Plan 9オペレーティングシステムで使われていたシンボルテーブルフォーマットをベースにしていました。
    • このフォーマットは、各エントリが可変長であり、4バイトの値(ビッグエンディアン)1バイトのタイプNULL終端された名前4バイトのGo型アドレス(ビッグエンディアン)という構造を持っていました。
    • このフォーマットは、Goの内部ツールチェインでは問題なく機能しましたが、外部のシステムリンカとの連携には課題がありました。

これらの概念を理解することで、コミットがなぜシンボルテーブルのエンコーディングを変更し、それがGoのビルドシステムとバイナリにどのような影響を与えるのかを深く把握できます。

技術的詳細

このコミットは、Goのシンボルテーブルの内部表現を根本的に変更し、Go 1.1で導入される新しいフォーマットを定義しています。この変更は、主にsrc/cmd/ld/symtab.c(Goリンカのシンボルテーブル生成部分)、src/pkg/debug/gosym/symtab.go(Goバイナリからシンボル情報を読み取るライブラリ)、およびsrc/pkg/runtime/symtab.c(Goランタイムのシンボルテーブル解析部分)に影響を与えています。

新しいシンボルテーブルフォーマットの主要な技術的変更点は以下の通りです。

  1. ヘッダの変更:

    • Go 1.0のシンボルテーブルは、特定のバイトシーケンス(0xFE 0xFF 0xFF 0xFF 0x00 0x00)でリトルエンディアンであることを示していました。
    • 新しいGo 1.1フォーマットでは、シンボルテーブルの先頭に8バイトの新しいマジックナンバーとメタデータが追加されます。
      • ビッグエンディアンの場合: 0xFF 0xFF 0xFF 0xFD 0x00 0x00 0x00 xx
      • リトルエンディアンの場合: 0xFD 0xFF 0xFF 0xFF 0x00 0x00 0x00 xx
      • ここでxxはポインタサイズ(4または8バイト)を示します。
    • この新しいマジックナンバーは、古いGo 1.0のコードが新しいテーブルを読み込んだ際に、空のテーブルとして認識するように設計されています(古いコードはタイプ0のエントリで停止するため)。
  2. シンボルアドレスの格納方法の変更:

    • Go 1.0フォーマット: シンボルアドレスは常に4バイトのビッグエンディアン値として格納されていました。これは32ビットシステムでは問題ありませんでしたが、64ビットシステムで4GBを超えるアドレスを扱う場合には不十分でした。
    • Go 1.1フォーマット:
      • シンボルアドレスは「フルポインタサイズ」で「ホストエンディアン」の値として格納されます。
      • これは、32ビットシステムでは4バイト、64ビットシステムでは8バイトのアドレスが、そのシステムが使用するエンディアン(リトルエンディアンまたはビッグエンディアン)で直接格納されることを意味します。
      • これにより、システムリンカがシンボルアドレスを直接書き込むことが可能になり、64ビットシステムでの4GBを超えるアドレスのサポートが容易になります。
  3. その他の値のVarintエンコーディング:

    • フレームサイズ、引数サイズ、ローカル変数サイズなど、シンボルに関連するその他の数値データは、varint(可変長整数)エンコードされるようになりました。
    • varintエンコーディングは、小さな値を少ないバイト数で、大きな値をより多くのバイト数で表現するため、データ全体のサイズを効率的に削減できます。
    • コミットメッセージにあるように、この変更がシンボルアドレスのフルポインタサイズ化によるサイズ増加を相殺し、結果的にシンボルテーブル全体のサイズを削減する主要因となっています。
  4. シンボルエントリの構造変更:

    • Go 1.0フォーマット: 値 (4バイト) | タイプ (1バイト) | 名前 (NULL終端) | Go型アドレス (4バイト)
    • Go 1.1フォーマット:
      • 1バイトのタイプバイト(下位6ビットがタイプ、上位2ビットがフラグ)
        • 0x40ビットがセットされている場合: 値はフルポインタサイズの固定幅で格納される。
        • 0x40ビットがセットされていない場合: 値はvarintエンコードされる。
        • 0x80ビットがセットされている場合: Go型アドレスがフルポインタサイズの固定幅で続く。
        • 0x80ビットがセットされていない場合: Go型アドレスは存在しない。
      • (上記フラグに応じて固定幅またはvarint)
      • Go型アドレス(上記フラグに応じて固定幅またはなし)
      • 名前(NULL終端、またはz/Zタイプの場合は二重NULL終端)
    • この新しい構造により、シンボルエントリのパッキングがよりコンパクトになり、必要な情報のみが格納されるようになります。

これらの変更は、Goのビルドシステムとランタイムがシンボル情報をどのように扱い、バイナリに格納するかという低レベルな部分に深く関わっています。特に、src/cmd/ld/symtab.cputsymb関数とsymtab関数、src/pkg/debug/gosym/symtab.gowalksymtab関数、そしてsrc/pkg/runtime/symtab.cwalksymtab関数が、この新しいフォーマットの読み書きロジックを実装しています。

コアとなるコードの変更箇所

このコミットにおける主要なコード変更は、Goのリンカ(cmd/ld)、Goのデバッグシンボルパッケージ(debug/gosym)、およびGoのランタイム(runtime)にまたがっています。

  1. src/cmd/ld/symtab.c:

    • putsymb関数: シンボルテーブルエントリをバイナリに書き込むロジックが大幅に変更されています。
      • 新しい1バイトのタイプバイト(フラグを含む)のエンコード。
      • シンボル値の格納方法が、フルポインタサイズの固定幅(s != nilの場合)またはvarintエンコード(それ以外の場合)に分岐。
      • Go型アドレスの格納も、存在する場合にフルポインタサイズの固定幅で行われるように変更。
    • symtab関数: シンボルテーブルのヘッダを書き込む部分が変更され、新しい8バイトのマジックナンバーとポインタサイズが追加されています。
    • dodata関数: reloffsetシンボルの参照が削除されています。
  2. src/libmach/sym.c:

    • syminit関数: バイナリからシンボルテーブルを読み取るロジックが更新されています。
      • 新しい8バイトのヘッダを認識し、ポインタサイズ(svalsz)を読み取るロジックが追加。
      • シンボルエントリの解析ロジックが、新しいGo 1.1フォーマット(タイプバイトのフラグ、固定幅/varintの値、Go型アドレスの有無)に対応するように変更。
      • 古いGo 1.0フォーマットとの互換性も維持されています。
  3. src/pkg/debug/gosym/symtab.go:

    • walksymtab関数: Goバイナリからシンボルテーブルを解析し、シンボルをウォークするGo言語側のロジックが変更されています。
      • 新しい8バイトのヘッダ(littleEndianSymtab, bigEndianSymtab)を認識し、ptrsz(ポインタサイズ)を抽出。
      • シンボルエントリの解析ロジックが、タイプバイトのフラグ(wideValue, goType)に基づいて、値の読み取り(固定幅またはvarint)とGo型アドレスの読み取りを行うように変更。
      • sym構造体のvaluegotypeフィールドがuint32からuint64に変更され、64ビットアドレスに対応。
  4. src/pkg/runtime/symtab.c:

    • walksymtab関数: Goランタイムがシンボルテーブルを解析するC言語側のロジックが変更されています。
      • 新しい8バイトのヘッダを検証し、ポインタサイズを考慮。
      • シンボルエントリの解析ロジックが、タイプバイトのフラグ(widevalue, havetype)に基づいて、値の読み取り(readwordまたはvarint)とGo型アドレスの読み取りを行うように変更。
      • readwordヘルパー関数が追加され、ホストエンディアンでのポインタサイズのワード読み取りを抽象化。
    • dofunc関数: reloffsetの利用が削除され、関数のエントリポイントが直接シンボル値から取得されるように変更。
  5. src/cmd/ld/lib.c:

    • genasmsym関数: シンボル情報を生成する際に、自動変数とパラメータのオフセット計算ロジックが修正されています。特に、負のオフセットを避けるための調整が行われています。
  6. src/pkg/runtime/arch_*.h:

    • arch_386.h, arch_amd64.h, arch_arm.h: 各アーキテクチャのヘッダファイルにBigEndian = 0が追加されています。これは、Goのビルドシステムがデフォルトでリトルエンディアンを想定していることを明示し、シンボルテーブルのエンディアン処理に影響を与えます。

これらの変更は、Goバイナリのシンボルテーブルの低レベルなフォーマットと、それを生成・解析するツールチェインおよびランタイムのコードに直接影響を与えています。

コアとなるコードの解説

ここでは、特にシンボルテーブルの新しいエンコーディングとデコーディングのロジックに焦点を当てて解説します。

src/cmd/ld/symtab.cputsymb 関数 (エンコーディング側)

この関数は、リンカがシンボル情報をバイナリのシンボルテーブルセクションに書き込む際の中心的なロジックです。

void
putsymb(Sym *s, char *name, int t, vlong v, vlong size, int ver, Sym *typ)
{
	int i, f, c;
	vlong v1;
	Reloc *rel;

	USED(size);
	
	// type byte
	// タイプバイトのエンコード:
	// 'A'-'Z' は 0-25, 'a'-'z' は 26-51 にマッピング。
	// s != nil の場合、0x40 (wide value) フラグをセット。
	// typ != nil の場合、0x80 (has go type) フラグをセット。
	if('A' <= t && t <= 'Z')
		c = t - 'A';
	else if('a' <= t && t <= 'z')
		c = t - 'a' + 26;
	else {
		diag("invalid symbol table type %c", t);
		errorexit();
		return;
	}
	
	if(s != nil)
		c |= 0x40; // wide value (固定幅アドレス)
	if(typ != nil)
		c |= 0x80; // has go type
	scput(c); // エンコードされたタイプバイトを書き込み

	// value
	if(s != nil) {
		// full width (固定幅アドレス)
		// シンボルsが存在する場合、そのアドレスはフルポインタサイズで格納される。
		// これはシステムリンカが後でアドレスを埋めるため。
		rel = addrel(symt); // リロケーションエントリを追加
		rel->siz = PtrSize; // ポインタサイズ(4または8バイト)
		rel->sym = s;       // 対象シンボル
		rel->type = D_ADDR; // アドレスリロケーション
		rel->off = symt->size; // 現在のシンボルテーブル内のオフセット
		if(PtrSize == 8)
			slput(0); // 64ビットの場合、プレースホルダとして0を書き込み
		slput(0); // プレースホルダとして0を書き込み
	} else {
		// varint (可変長整数)
		// シンボルsが存在しない場合(例: フレームサイズ、ローカル変数サイズなど)、
		// 値vはvarintエンコードされる。
		if(v < 0) {
			diag("negative value in symbol table: %s %lld", name, v);
			errorexit();
		}
		v1 = v;
		while(v1 >= 0x80) { // 最上位ビットが1の場合、次のバイトが続く
			scput(v1 | 0x80);
			v1 >>= 7; // 7ビットずつシフト
		}
		scput(v1); // 最後のバイト
	}
 
	// go type if present
	// Go型が存在する場合、そのアドレスもフルポインタサイズで格納される。
	if(typ != nil) {
		if(!typ->reachable)
			diag("unreachable type %s", typ->name);
		rel = addrel(symt);
		rel->siz = PtrSize;
		rel->sym = typ;
		rel->type = D_ADDR;
		rel->off = symt->size;
		if(PtrSize == 8)
			slput(0);
		slput(0);
	}
	
	// name
	// シンボル名(NULL終端または二重NULL終端)を書き込み。
	if(t == 'f') // 'f'タイプは名前の先頭をスキップ
		name++;
	if(t == 'Z' || t == 'z') { // 'Z'または'z'タイプは特殊な二重NULL終端
		scput(name[0]);
		for(i=1; name[i]; i++) {
			if(name[i] == '/')
				scput(0);
			else
				scput(name[i]);
		}
		scput(0);
		scput(0);
	} else { // 通常のNULL終端
		for(i=0; name[i]; i++)
			scput(name[i]);
		scput(0);
	}
}

src/pkg/debug/gosym/symtab.gowalksymtab 関数 (デコーディング側)

この関数は、Goバイナリからシンボルテーブルを読み取り、解析するGo言語側のロジックです。

func walksymtab(data []byte, fn func(sym) error) error {
	var order binary.ByteOrder = binary.BigEndian // デフォルトはビッグエンディアン
	newTable := false
	var ptrsz int // ポインタサイズ

	switch {
	case bytes.HasPrefix(data, oldLittleEndianSymtab):
		// 古いリトルエンディアンフォーマットの検出と処理
		data = data[6:]
		order = binary.LittleEndian
	case bytes.HasPrefix(data, bigEndianSymtab):
		// 新しいビッグエンディアンフォーマットの検出
		newTable = true
	case bytes.HasPrefix(data, littleEndianSymtab):
		// 新しいリトルエンディアンフォーマットの検出
		newTable = true
		order = binary.LittleEndian
	}

	if newTable {
		// 新しいテーブルの場合、8バイトヘッダからポインタサイズを読み取る
		if len(data) < 8 {
			return &DecodingError{len(data), "unexpected EOF", nil}
		}
		ptrsz = int(data[7]) // ヘッダの最後のバイトがポインタサイズ
		if ptrsz != 4 && ptrsz != 8 {
			return &DecodingError{7, "invalid pointer size", ptrsz}
		}
		data = data[8:] // ヘッダをスキップ
	}

	var s sym
	p := data // 現在の読み取り位置
	for len(p) >= 4 { // 最低限のデータ長(Go 1.0フォーマットのvalue+type)
		var typ byte
		if newTable {
			// 新しいテーブルフォーマットの解析
			// 1バイトのタイプバイトを読み取り、フラグを解析
			typ = p[0] & 0x3F // 下位6ビットがタイプ
			wideValue := p[0]&0x40 != 0 // 0x40ビットがセットされていれば固定幅値
			goType := p[0]&0x80 != 0 // 0x80ビットがセットされていればGo型が存在
			
			// タイプバイトをASCII文字に変換
			if typ < 26 {
				typ += 'A'
			} else {
				typ += 'a' - 26
			}
			s.typ = typ
			p = p[1:] // タイプバイトをスキップ

			if wideValue {
				// 固定幅値の読み取り (ポインタサイズに応じて4または8バイト)
				if len(p) < ptrsz { /* エラーハンドリング */ }
				if ptrsz == 8 {
					s.value = order.Uint64(p[0:8])
					p = p[8:]
				} else {
					s.value = uint64(order.Uint32(p[0:4]))
					p = p[4:]
				}
			} else {
				// varint値の読み取り
				s.value = 0
				shift := uint(0)
				for len(p) > 0 && p[0]&0x80 != 0 { // 最上位ビットが1の間は続く
					s.value |= uint64(p[0]&0x7F) << shift
					shift += 7
					p = p[1:]
				}
				if len(p) == 0 { /* エラーハンドリング */ }
				s.value |= uint64(p[0]) << shift // 最後のバイト
				p = p[1:]
			}

			if goType {
				// Go型アドレスの読み取り (固定幅)
				if len(p) < ptrsz { /* エラーハンドリング */ }
				if ptrsz == 8 {
					s.gotype = order.Uint64(p[0:8])
					p = p[8:]
				} else {
					s.gotype = uint64(order.Uint32(p[0:4]))
					p = p[4:]
				}
			}
		} else {
			// 古いGo 1.0フォーマットの解析 (Plan 9形式)
			s.value = uint64(order.Uint32(p[0:4]))
			if len(p) < 5 { /* エラーハンドリング */ }
			typ = p[4]
			if typ&0x80 == 0 { /* エラーハンドリング */ }
			typ &^= 0x80 // 0x80ビットをクリア
			s.typ = typ
			p = p[5:]
		}

		// シンボル名の読み取り
		var i int
		var nnul int // 二重NULL終端の場合の追加NULLバイト数
		for i = 0; i < len(p); i++ {
			if p[i] == 0 {
				if s.typ == 'z' || s.typ == 'Z' {
					// 'z'/'Z'タイプは二重NULL終端
					if i+1 < len(p) && p[i+1] == 0 {
						nnul = 1
						break
					}
				} else {
					break // 通常のNULL終端
				}
			}
		}
		if len(p) < i+nnul { /* エラーハンドリング */ }
		s.name = p[0:i] // シンボル名を抽出
		i += nnul
		p = p[i:] // 名前部分をスキップ

		if !newTable {
			// 古いテーブルの場合、Go型アドレスを読み取る
			if len(p) < 4 { /* エラーハンドリング */ }
			s.gotype = uint64(order.Uint32(p[:4]))
			p = p[4:]
		}
		fn(s) // コールバック関数を呼び出し
	}
	return nil
}

これらのコードは、シンボルテーブルのバイト列を、定義された新しいフォーマットに従って正確に読み書きするためのロジックを示しています。特に、タイプバイトのフラグを利用して値のエンコーディング形式を動的に切り替えたり、Go型アドレスの有無を判断したりする部分が、この変更の核心です。これにより、Goバイナリのシンボルテーブルがよりコンパクトかつ柔軟に扱えるようになっています。

関連リンク

参考にした情報源リンク