はじめに
Goではルールに従いソースコードを配置してgo build
を実行するだけで実行バイナリを作ることができます。
とても便利なのですが、一体裏で何が行われているのでしょうか?
この記事ではgo buildの仕組み、簡単なシンボルテーブルの説明、Goに用意されているバイナリ操作コマンドを学ぶことができます。
実行バイナリの作成
実はGoでもC言語と同様に以下のフローで実行バイナリを生成しています。
- ソースコードをコンパイルをしてオブジェクトファイルを作成
- オブジェクトファイルをまとめてライブラリを作成
- オブジェクトやライブラリをリンクして実行バイナリを作成
go build
ではこれらの処理を複数コマンドにより実現しています。
コマンドの詳細はgo build
の-x
オプションで確認することができます。
同一パッケージに1つのファイルしかない場合
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
以下、コメントは私が追記したものです。
$ go build -x
# 作業用のディレクトリを作成
WORK=/var/folders/4z/yzprxc9n5sx7gly7ybrynkl80000gn/T/go-build482602531
mkdir -p $WORK/github.com/sonatard/buildtest/_obj/
mkdir -p $WORK/github.com/sonatard/buildtest/_obj/exe/
cd /Users/ken/src/github.com/sonatard/buildtest
# コンパイル
/usr/local/Cellar/go/1.7.3/libexec/pkg/tool/darwin_amd64/compile -o $WORK/github.com/sonatard/buildtest.a -trimpath $WORK -p main -complete -buildid 77c8cc4f3c5bf3be375a16c76ac243e317fad15b -D _/Users/ken/src/github.com/sonatard/buildtest -I $WORK -pack main.go
cd .
# リンク
/usr/local/Cellar/go/1.7.3/libexec/pkg/tool/darwin_amd64/link -o $WORK/github.com/sonatard/buildtest/_obj/exe/a.out -L $WORK -extld=clang -buildmode=exe -buildid=77c8cc4f3c5bf3be375a16c76ac243e317fad15b $WORK/github.com/sonatard/buildtest.a
# 作業ディレクトリから実行バイナリをカレントディレクトリにコピー
mv $WORK/github.com/sonatard/buildtest/_obj/exe/a.out buildtest
上記では表現されていませんがgo build
終了後にWORKディレクトリ以下は削除されます。
削除しないためには-work
オプションを指定します。
最低限必要なコマンドとオプションに絞ると以下のようになります。以降はこちらの表記で説明していきます。
SRC_PATH="$GOPATH/src"
PKG_PATH="$GOPATH/pkg/darwin_amd64"
go tool compile -o buildtest.a -p main -I $PKG_PATH -pack main.go
go tool link -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
$ ls
buildtest buildtest.a main.go
$ ./buildtest
Hello, World!
go tool compile
go tool compile -o buildtest.a -p main -I $PKG_PATH -pack ./main.go
C言語と似ていますが、少し異なる点としてコンパイルする際に-pack
オプションという静的ライブラリを生成するオプションが用意されているため.o
ファイルを経由せずに.a
ファイルを作成することができます。
あとは-p
でパッケージを指定。-I
でimportファイルを検索するディレクトリを指定します。
go tool link
go tool link -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
-L
オプションでライブラリを検索するディレクトリを指定します。
同一パッケージに2つのファイルがある場合
package main
func main() {
test()
}
package main
import "fmt"
func test() {
fmt.Printf("Hello, Test!\n")
}
同ディレクトリに複数ファイルがある場合は、go build
は複数ファイルをまとめてコンパイルします。
go tool compile -o buildtest.a -p main -I $PKG_PATH -pack ./main.go ./test.go
go tool link -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
しかしC言語のように複数ファイルがある場合にはファイルごとにコンパイルすることで、**「変更があったファイルだけを再度コンパイルする」**ということがしたくなります。
go tool compile
には-pack
オプションをつけないことでオブジェクトファイルを作成することができ、またgo tool pack
コマンドで複数のオブジェクトファイルをまとめたライブラリを作成することもできるので、「変更があったファイルだけを再度コンパイルする」ということができそうです。しかし以下のコマンドを実際に実行してみるといくつかの問題が発生します。
SRC_PATH="$GOPATH/src"
PKG_PATH="$GOPATH/pkg/darwin_amd64"
go tool compile -o main.o -p main -I $PKG_PATH main.go
go tool compile -o test.o -p main -I $PKG_PATH test.go
go tool pack vc buildtest.a main.o test.o
go tool link -v -n -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
問題1 他のファイルの関数のシンボルが見つからない
$ go tool compile -o main.o -p main -I $PKG_PATH main.go
main.go:6: undefined: test
main.go
で呼び出しているtest()
のシンボルが見つからないためエラーになります。
これはmain.go
に宣言を追加することで解決することができます。
package main
func test()
func main() {
test()
}
$ go tool compile -o main.o -p main -I $PKG_PATH main.go
$ ls main.o
main.o
無事コンパイルできました。
上記のようにGoでもC言語のように関数定義とは別に関数宣言をすることが可能です。1
作成したシンボルテーブルを確認してみましょう。go tool nm
コマンドを使います。
go tool nm
コマンドの表示結果のシンボルクラスはnm
コマンドと同様です。
U
は未定義シンボル、T
はテキスト(コード)セクションB
は未初期化データ領域(BSS)、R
は読み込み専用データセクションです。
以下のように宣言だけしており定義がされていないtest()
はU %22%22.test
のように正しく未定義シンボルとなっていることが確認できます。(%22%22はmainパッケージを意味する模様、何故こうなっているのかは未調査。%22は" ダブルクォーテーションを表す)
$ go tool nm main.o
U
1df T %22%22.init
22e B %22%22.initdone·
23e R %22%22.init·f
1af T %22%22.main
236 R %22%22.main·f
U %22%22.test
21e R %22%22.test.args_stackmap
22e R %22%22.test·f
226 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
U runtime.morestack_noctxt
U runtime.throwinit
問題2 init関数のシンボルが衝突する
続いてtest.go
をコンパイル、ライブラリ作成、リンクをします。
$ go tool compile -o test.o -p main -I $PKG_PATH test.go
$ go tool pack vc buildtest.a main.o test.o
$ go tool link -v -n -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
2017/01/22 14:56:24 duplicate symbol main.init (types 1 and 1) in main and buildtest.a(test.o)
リンクエラーになりました。
mainパッケージのmain.init
シンボルの衝突エラーです。
皆さんご存知の通りGoでは.goファイルごとにinit()
が最初に実行されます。今回のようにgo tool compile
でコンパイルしたmain.go
とtest.go
にそれぞれinit()
関数がmain.initシンボルとして作成されてしまうため、リンクしたmain.o
とtest.o
でシンボルが衝突してしまいます。
go tool nm
で確認してみます。
$ go tool nm main.o test.o
main.o: U
main.o: 1df T %22%22.init
main.o: 22e B %22%22.initdone·
main.o: 23e R %22%22.init·f
main.o: 1af T %22%22.main
main.o: 236 R %22%22.main·f
main.o: U %22%22.test
main.o: 21e R %22%22.test.args_stackmap
main.o: 22e R %22%22.test·f
main.o: 226 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
main.o: U runtime.morestack_noctxt
main.o: U runtime.throwinit
test.o: U
test.o: 26e T %22%22.init
test.o: 2dd B %22%22.initdone·
test.o: 2e5 R %22%22.init·f
test.o: 1f2 T %22%22.test
test.o: 2dd R %22%22.test·f
test.o: U fmt.Printf
test.o: U fmt.init
test.o: 2d5 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
test.o: 2c9 R go.string."call test()\n"
test.o: 2b9 R go.string.hdr."call test()\n"
test.o: U runtime.morestack_noctxt
test.o: U runtime.throwinit
test.o: 2ed R type..importpath.fmt.
今回重要なのはmain.o: 1df T %22%22.init
とtest.o: 26e T %22%22.init
の行です。同じシンボルが2つ定義されています。
つまりリンクするためにはシンボルを衝突を回避しなければなりません。
以下のようにtest.o
のinitシンボルを強引に変更してからリンクしてみます。
$perl -pi -e 's|init|tnit|g' test.o
$ go tool nm main.o test.o
main.o: U
main.o: 1df T %22%22.init
main.o: 22e B %22%22.initdone·
main.o: 23e R %22%22.init·f
main.o: 1af T %22%22.main
main.o: 236 R %22%22.main·f
main.o: U %22%22.test
main.o: 21e R %22%22.test.args_stackmap
main.o: 22e R %22%22.test·f
main.o: 226 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
main.o: U runtime.morestack_noctxt
main.o: U runtime.throwinit
test.o: U
test.o: 1f2 T %22%22.test
test.o: 2dd R %22%22.test·f
test.o: 26e T %22%22.tnit
test.o: 2dd B %22%22.tnitdone·
test.o: 2e5 R %22%22.tnit·f
test.o: U fmt.Printf
test.o: U fmt.tnit
test.o: 2d5 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
test.o: 2c9 R go.string."call test()\n"
test.o: 2b9 R go.string.hdr."call test()\n"
test.o: U runtime.morestack_noctxt
test.o: U runtime.throwtnit
test.o: 2ed R type..importpath.fmt.
$ go tool pack vc buildtest.a main.o test.o
$ go tool link -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
test.o: 26e T %22%22.tnit
のように変更されていることが確認できます。
無理やりコンパイルを通しているだけなので正しく実行はできません。(実行できるように修正したかったのですがオブジェクトファイルフォーマットの理解が足りないためできませんでした)
./buildtest
では普段使っているgo build
では、この問題をどのように解決しているのでしょうか?
go build
は最初に紹介した通りgo compile
時に必ず同一パッケージ内の".go"ファイルを同時にコンパイルします。その時に複数ファイルがある場合には、initのシンボル名を衝突しないように変更します。もしくはinit関数が不要な場合はシンボルを削除するようです。2
go build
にはC言語のようにファイルごとにオブジェクトを作成しておいて、変更があったファイルのみ差分コンパイルする仕組みがありません。
つまり**Goは必ず1つのパッケージ内では複数ファイルを同時にコンパイルして、ライブラリを作成するまでが仕様になっています。**Goらしい割り切った仕様ですが、それでいいよねって話です。(ここらへんについてはドキュメントやソースを追ったわけではないので確信がありません余裕があったらソースを読んでみようと思います。)
そもそも差分コンパイルのために多くのC言語プログラマーがMakefileの作成で苦しんできた歴史を考えると、Goの決断は素晴らしいと思います。また差分コンパイルができなくても困らないのは、そもそもGoのコンパイルがとても速いためです。C言語で毎回フルコンパイルさせられたら堪りません。
本当にGoは素晴らしいですね。
コマンドとしては以下のようになります。
$ go tool compile -o buildtest.a -p main -I $SRC_PATH -I $PKG_PATH -pack main.go test.go
今回は、5dd T %22%22.init
が1つかないことが確認できます。
$ go tool nm buildtest.a
U
5dd T %22%22.init
664 B %22%22.initdone·
674 R %22%22.init·f
4de T %22%22.main
664 R %22%22.main·f
50e T %22%22.test
66c R %22%22.test·f
U fmt.Println
U fmt.init
628 R gclocals·33cdeccccebe80329f1fdbee7f5874cb
65c R gclocals·69c1753bd5f81501d95132d08af04464
64c R gclocals·e29b39dba2f7b47ee8f21f123fdd2633
640 R go.string."Hello, Test!"
630 R go.string.hdr."Hello, Test!"
7c3 R go.typelink.*[1]interface {}
787 R go.typelink.[1]interface {}
728 R go.typelink.[]interface {}
U runtime.algarray
U runtime.convT2E
6dd R runtime.gcbits.01
67c R runtime.gcbits.03
U runtime.morestack_noctxt
U runtime.throwinit
78b R type.*[1]interface {}
7c7 R type..importpath.fmt.
72c R type..namedata.*[1]interface {}.
6de R type..namedata.*[]interface {}.
67d R type..namedata.*interface {}.
73f R type.[1]interface {}
6f0 R type.[]interface {}
68d R type.interface {}
U type.string
複数パッケージが存在する場合
最後にgo build
時に複数パッケージが存在する場合を簡単に紹介します。
$ ls ./ ./hello
./:
hello/ main.go test.go
./hello:
hello.go
このような構成の時は、以下のようにビルドされます。
SRC_PATH="$GOPATH/src"
PKG_PATH="$GOPATH/pkg/darwin_amd64"
cd hello
go tool compile -o ../hello.a -p github.com/sonatard/buildtest/hello -pack ./hello.go
cd ..
go tool compile -o buildtest.a -p main -I $SRC_PATH -I $PKG_PATH -pack ./main.go ./test.go
go tool link -o buildtest -L $SRC_PATH -L $PKG_PATH buildtest.a
以下に、実際にコンパイルできるソース一式を置いておきましたのでお試しください。
まとめ
安定しているGoの環境では中々バイナリレベルのトラブルになることはないと思いますが
何かの参考になればと思います。
今後はもう少しソースを読んでいきたいです。
関連
Goの仕様について興味がある方はこちらもどうぞ
-
通常、宣言を使うことはないように思えますが、Go本体でもシステムコールを呼ぶために利用しています。 ↩
-
詳細はこちらを参照。packageに複数のinitがあるときの挙動 ↩