Go のバイナリを覗いてみた

Go のバイナリを覗いてみた

2026.03.07

人材育成室 育成メンバーチームで 研修中の はすと です。

Goは速いとよく言われはしますが、実際なぜ速いの?と聞かれると、コンパイル言語であることや、並列処理くらいしか理由を挙げることができません。そこで、もっと具体的になぜ速いのかを明確にしたいと思いました。

Goはコンパイルするとバイナリが出力され、アプリケーションは、バイナリを実行して動きます。そのため、このバイナリにGoの早さの秘密が隠されているのではないかと考え、Go のバイナリを覗いてみました。

バイナリ構造を把握する

いきなりバイナリを覗いても、訳がわからないので、まずはバイナリ構造をざっくり把握していこうと思います。

サンプルコードとして、Hello World を表示するコードを用意します。

package main
import (
	"fmt"
)
func main() {
	fmt.Println("Hello World")
}

これをコンパイルします。

$ go build -o ./bin/hello_go ./go/hello.go

すると、Gバイナリが出力されるのでこのファイルを使っていきます。

file コマンドでファイルタイプを覗く

中身を見る前に、fileコマンドを使ってファイル情報を見てみます。

$ file ./bin/hello_go
./bin/hello_go: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=Gvb1P_J9Q2QsWbndRE-i/BqD-me_Z_gagcH3CKmc2/NiYP7-zce4bZvI4adIfW/sc5inU9iSjuruq9zQB7Q, BuildID[sha1]=69f95bfe6774aaf03293ba9d6d361d33b7a3a764, with debug_info, not stripped

色々書いているように見えますが、必要なところだけ拾っていきます。

ELF 64-bit LSB executable, ARM aarch64

これは、ELFファイルであることと、64bit、ARM アーキテクチャであることが書かれています。

64bitとARMについて

64bitというのは、CPUが一度に処理できるデータの幅で、ARMというのは 命令セットアーキテクチャ(ISA) の種類です、CPUが「どんな命令語を理解するか」の設計の違いを表していています。

  • ARM → 命令をシンプルに絞った設計(Mac M1、スマホなど)
  • x86 → 複雑な命令を多く持つ設計(Intel、AMD)
ELFわからない方はこちらをご覧ください

ELFについて

先ほどファイル情報を見た際に出てきたELFについて見ていきたいと思います。

そもそも ELFとは Executable and Linkable Format の略で、LinuxをはじめとするUnix系OSで使われる実行ファイルの形式のことです。
ELFファイルは大きく3つの要素で構成されていて、ELF Header, セクション, セグメント の3つで構成されています。

ELF Header
ファイルの先頭に位置し、OSがこのファイルをELFと認識するための情報や、プログラムの実行がどのアドレスから始まるかを示すエントリポイントのアドレスも格納されています。

セクション
ELFの中身は、セクションという単位で役割ごとに分けられています。

セクション 内容
.text 機械語命令(CPUが実行するコード)
.rodata 読み取り専用データ(文字列リテラルなど)
.data 初期化済みグローバル変数
.bss 未初期化グローバル変数
.debug_info デバッグ情報(行番号とアドレスの対応表など)

セグメント

OSがELFをメモリに展開する時に、セクションをそのまま1つずつ載せるのではなく、パーミッションが同じセクション同士を束ねてメモリに載せます。この束ねる単位をセグメントと言います。

パーミッション 対象セクション 意味
R+X .text 読み取り+実行可能
R .rodata 読み取り専用
RW .data/.bss 読み書き可能

つまり、セクションはリンカ・デバッガ向けの細かい単位で、セグメントはOSがメモリに展開するための単位になります。

statically linked,

これは実行前に解決済みという意味で、依存ライブラリが全てバイナリに含まれているおかげで、Goのバイナリは単体で動くことができます。

with debug_info, not stripped

これはデバッグ情報と、デバッガが使用できるシンボル情報などが削除されていない状態を表しています。消すこともできますが、このシンボル情報があることで、デバッガがアドレスと関数名を対応づけることができて、人間が読める形で追うことができます。

size コマンドでセクションごとのサイズを見る

次は、sizeコマンドを使って、ELFのセクションごとのサイズを見てみます。

$ size ./bin/hello_go 
   text    data     bss     dec     hex filename
1456363   42700  246328 1745391  1aa1ef ./bin/hello_go

結果を見ると、.text がかなり多いことがわかります。コード上では、fmt.Println("Hello World") しか書いていないのになぜでしょうか。これは、機械語命令(.text)に、Goランタイムやfmt などの静的リンクされたパッケージが入っているためです。

ただ、これだけでは、具体的に何が入っているのかがわからないため、関数名とアドレスの対応表であるシンボルテーブル を覗いてみます。

シンボルテーブルを覗いてみる

.textセクションには大量の機械語命令が詰め込まれていますが、どの関数がどのアドレスから始まるかを対応付けているのがシンボルテーブルで、go tool nm はそのシンボルテーブルを人間が読める形で出力してくれます。

以下を実行します。

$ go tool nm ./bin/hello_go 

すると以下のような出力が大量に得られます

# アドレス / シンボルの種類 / シンボル名(パッケージ関数名)
   a6010 T main.main
   9e810 T os.(*File).Write
   9e990 T os.(*File).wrapErr
   9e780 T os.(*SyscallError).Error
   9e800 T os.(*SyscallError).Unwrap
   ...

シンボルテーブルは大量すぎて記事には載せることができないため一部だけ載せます。

シンボルの種類

シンボルの種類にはどんなものがあるのかをまとめます。

記号 意味
T .text セクションの関数(公開)
t .text セクションの関数(非公開)
D .data セクションの変数(公開)
d .data セクションの変数(非公開)
R .rodata セクションの定数(公開)
r .rodata セクションの定数(非公開)

**大文字 = 公開(exported)、小文字 = 非公開(unexported)**というルールになっています。

全部を見ることはできないので、どのシンボルが多いかを集計してみます。

$ go tool nm ./bin/hello_go | awk '{print $3}' | grep -o '^[^.]*' | sort | uniq -c | sort -rn | head -20
   1677 runtime
     83 type:
     78 reflect
     44 fmt
     42 syscall
     40 internal/strconv
     40 internal/poll
     33 os
     33 internal/runtime/maps
     31 sync
     25 internal/runtime/cgroup
     24 internal/sync
     23 internal/godebug
     18 $f64
     17 go:itab
     13 unicode
     11 sync/atomic
     11 internal/abi
     10 strconv
     10 internal/runtime/exithook

結果として、runtime が圧倒的に多く、Goのランタイムがバイナリにそのまま内包されていることがわかりました。

バイナリから見えたこと

ここまで、filesizego tool nm と覗いてきたことで、共通して見えてきたことがあります。それは、各パッケージが静的リンクされており、ELFファイルでいうところの、機械語命令(.text)が非常に多いということです。さらにそこからシンボルのリストを覗いた結果、runtime が多くを占めていることがわかりました。

また、私は fmt.Println("Hello World") しか書いていませんが、コンパイル時に、Goのランタイムやパッケージが丸ごと入っていました。この結果から何が言えるかというと、Goは全てを詰め込む設計をとっていることが見えてきたと思います。静的リンクで、バイナリに詰め込むことで実行時に探しにいく必要がなく、オーバヘッドが少なくなるため、実行速度が速くなるという原理であるということが言えそうです。
トレードオフとして、バイナリサイズは大きくなってしまいます。

まとめ

今回、Goのバイナリを覗いてみることで、普段の業務では見えない部分を知ることができて学びの連続でした。次回は、バイナリの中で最も多くを占めていた runtime の内部に焦点を当てて、さらに深く覗いていきたいと思います。

参考
https://linux-audit.com/elf-binaries-on-linux-understanding-and-analysis/
https://internals-for-interns.com/posts/understanding-go-runtime/

この記事をシェアする

FacebookHatena blogX

関連記事