LoginSignup
62
36

More than 5 years have passed since last update.

Go Binary Hacks - go buildせずにビルドする #golang

Last updated at Posted at 2017-01-23

はじめに

Goではルールに従いソースコードを配置してgo buildを実行するだけで実行バイナリを作ることができます。
とても便利なのですが、一体裏で何が行われているのでしょうか?

この記事ではgo buildの仕組み、簡単なシンボルテーブルの説明、Goに用意されているバイナリ操作コマンドを学ぶことができます。

実行バイナリの作成

実はGoでもC言語と同様に以下のフローで実行バイナリを生成しています。
1. ソースコードをコンパイルをしてオブジェクトファイルを作成
2. オブジェクトファイルをまとめてライブラリを作成
3. オブジェクトやライブラリをリンクして実行バイナリを作成

go buildではこれらの処理を複数コマンドにより実現しています。
コマンドの詳細はgo build-xオプションで確認することができます。

同一パッケージに1つのファイルしかない場合

main.go
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つのファイルがある場合

main.go
package main

func main() {
    test()
}
test.go
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に宣言を追加することで解決することができます。

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.gotest.goにそれぞれinit()関数がmain.initシンボルとして作成されてしまうため、リンクしたmain.otest.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.inittest.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

以下に、実際にコンパイルできるソース一式を置いておきましたのでお試しください。

Github - sonatard/buildtest

まとめ

安定しているGoの環境では中々バイナリレベルのトラブルになることはないと思いますが
何かの参考になればと思います。
今後はもう少しソースを読んでいきたいです。

関連

Goの仕様について興味がある方はこちらもどうぞ


  1. 通常、宣言を使うことはないように思えますが、Go本体でもシステムコールを呼ぶために利用しています。 

  2. 詳細はこちらを参照。packageに複数のinitがあるときの挙動 

62
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
62
36