Go で Hello, World! する


これは Go で Hello, World! という文字列を出力する方法について調べた記事です。M1 Macbook で Go 1.21.6 を使用しています。

$ go version
go version go1.21.6 darwin/arm64

Hello, World!

Hello, World! という文字列を標準出力するプログラムは以下の通り。

package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

これを main.go のような名前で保存して go run main.go すると…

$ go run main.go

Hello, World!

Hello, World! を出力することができた。次は Visual Studio Code のデバッグ機能を使ってプログラムが何をしているかを見ていく。

デバッグ機能を使う

Visual Studio Code を開いてコード上で Breakpoint を指定してデバッグを実行する。“Step Over” と “Step Into” を繰り返すことで fmt.Println() が何を呼び出しているのかを調べる。

debug

Step Over は現在の行を実行し次の行に移動する。このとき、現在の行で呼び出される関数やメソッドの内部にはジャンプせず、その呼び出しを一つの step として扱う。基本的には、ある行が期待通りに動作していることを前提としてその行をスキップするのに使う。

Step Into は現在の行で呼び出される関数やメソッドに飛ぶ。基本的には、関数やメソッド内部の動作を理解したい場合に使う。

fmt.Println() は何をしているのか

fmt は Go の標準パッケージで、フォーマットされた I/O を提供する。C 言語の printf や scanf のように使うことができる。

まず main 関数から fmt.Println("Hello, World!") の行に step into すると、Fprintln に os.Stdout(標準出力)を与えて呼び出していることが分かる。

fmt.Println

次は Fprintln() の行に step into する。

fmt.Fprintln()

Fprintln() は io.Writer を満たす wを受け取り、その Write メソッドを呼び出す。os.Stdout は io.Writer を満たしているので問題ない。ここでは Write の引数として p.buf を与えているが、これは newPrinter() という関数で生成された構造体のメンバーで、中身は単なる[]byte である。

fmt.Fprintln

次は step over で Write(p.buf) の行まで進んで step into する。

os.File で Write()

この Write() メソッドはポインタレシーバを使用して実装されており、os.File 型の値に []byte を書き込む。os.Stdout は os.File 型なのでこれを使うことができる。これを使って標準出力にバイト列を書き込んでいるようだ。書き込む前に f.checkValid という関数を読んでいるが、これは f が nil のときだけエラーを返す。

(*File).Write

次は f.write(b) の行に step into する。

os.File で write()

write() は先頭が小文字、つまり外部に公開されていない f.pfd.Write() を呼び出している。

write

os.File 型は file 型へのポインタを持つ。file 型は OS によってその中身が異なっているが、pfd に入るのがファイルディスクリプタを示す構造体であることは共通している。

参考: Unix, Windows

その後で return する前に runtime.KeepAlive しているが、これはファイル操作を完了した後で結果を返すまでの期間に、何らかの理由で引数の f がガベージコレクタによって回収されることを防ぐためと考えられる。

次は f.pfd.Write(b) の行に step into する。

FD で Write()

前 step の f.pfd は internal パッケージの FD 型(ファイルディスクリプタの略)で、この FD のポインタレシーバを使って Write 関数が実装されている。

まずは(並列で同時に書き込みが行われないように)ファイルディスクリプタをロックしたり状態をチェックし、問題なければ for ループ内でファイルに書き込む。全てのデータが書き込まれる(nn == len(p))か、再試行可能なエラーが発生する(err == syscall.EAGAIN &&…)か、書き込みが 0 バイトで返ってきた(n == 0)場合に処理を終了する。

pfd.Write

Windows では別の Write() メソッドが呼ばれる。Windows と Unix ではファイルシステムや I/O を操作するための API が根本的に異なるはずだが、開発者がその違いを詳細に意識しなくてもファイルを扱うコードを書けるのは大変よい。

次は ignoringEINTRIO(syscall.Write, …) の行に step into する。

システムコールを呼ぶ

ignoringEINTRIO は第一引数として渡されていた関数を呼び、syscall.EINTR 以外のエラーが返ってきたときに結果を返す関数だ。EINTR はシステムコールが中断された(Interrupted)ときのエラーだが、これ以外のエラーが生じたときは for ループを中断して結果を返す。EINTR が生じたときは単に for ループでシステムコールが再試行される。

ignoringEINTRIO

次は fn(fd, p) に step into する。これは第一引数として与えられた syscall.Write() を呼んでいる。これは、それぞれ引数として受け取ったファイルディスクリプタに []byte を書き込む。

syscall.Write

次は write() 関数に step into する。libc_write_trampoline を介して C 言語の標準ライブラリ関数(libc)を通じて write を呼んでいるようだ。ここでは //go:cgo_import_dynamic libc_write write "/usr/lib/libSystem.B.dylib" からインポートしている。

syscall_darwin_arm64.write

最後に syscall の行に step into する。引数を使って libcCall() でシステムコールを実行している。

syscall_syscall

libSystem.B.dylib は macOS のダイナミックライブラリの一種で、アプリケーションが OS の機能にアクセスするためのシステムコールのラッパーや C 標準ライブラリなどを提供する。実行時に(macOS では .dylib, Windows では .dll を通じて)必要な関数やデータをロードするので、コンパイル時の実行ファイルには組み込まれない。

Go のバイナリに nm コマンドを使うと、他にも libc の trampoline 関数(Go の実行環境から libc の関数を呼ぶための中継点となる関数)があるようだった。なお、GOOSlinux, GOARCHamd64 にしたらこのコマンドの出力結果は空だったので、macOS だけ libc を介してシステムコールを呼び出すようになっているのかもしれない。

$ nm main | grep -i libc
000000010004da00 t _runtime.libcCall
0000000100072b50 t _syscall.libc_close_trampoline.abi0
0000000100072b60 t _syscall.libc_closedir_trampoline.abi0
0000000100072b70 t _syscall.libc_dup2_trampoline.abi0
0000000100072c00 t _syscall.libc_execve_trampoline.abi0
0000000100072b40 t _syscall.libc_fcntl_trampoline.abi0
0000000100072c30 t _syscall.libc_fstat_trampoline.abi0
0000000100072c20 t _syscall.libc_getcwd_trampoline.abi0
0000000100072b80 t _syscall.libc_getrlimit_trampoline.abi0
0000000100072bb0 t _syscall.libc_lseek_trampoline.abi0
0000000100072be0 t _syscall.libc_mmap_trampoline.abi0
0000000100072bf0 t _syscall.libc_munmap_trampoline.abi0
0000000100072b90 t _syscall.libc_open_trampoline.abi0
0000000100072ba0 t _syscall.libc_read_trampoline.abi0
0000000100072bc0 t _syscall.libc_setrlimit_trampoline.abi0
0000000100072c40 t _syscall.libc_stat_trampoline.abi0
0000000100072c10 t _syscall.libc_sysctl_trampoline.abi0
0000000100072bd0 t _syscall.libc_write_trampoline.abi0

ここまで Go が fmt.Println からシステムコールを呼んで OS の機能を使う流れを見てきた。

おまけ: go build でコンパイルしたバイナリを読む(読めない)

Hello, World! を標準出力するプログラムを go build でコンパイルすることで、実行ファイルを生成できる。筆者の環境だとファイルサイズが 1.9MB もあるのだが、この中には何が含まれているのだろうか?

VS Code の拡張機能として Hex Editor をインストールした上で、go build で生成したバイナリを読んでみる。まずはどこから何を読んでいいのか分からないので、とりあえずデコードされた文字列で Hello, World! を探してその周辺の文字列を読もうと試みた。

hex_editor

文字列が並んでいるが、自分の今の知識では何が書かれているのか分からなかった。

バイナリファイルには Go を実行するために必要なファイルやデータ(例えば標準ライブラリなど)が全て含まれている。そのため、実行時には Python のようにインタープリタは必要なく、バイナリをそのまま実行できる。

ただし OS や CPU アーキテクチャによってシステムコールのインターフェイスが異なるから、従って必要なバイナリも異なる。Go ではコンパイル時に GOOSGOARCH 環境変数でターゲットを指定できる。

アセンブリを読む

分からないなりにいろいろ調べていたら DQNEO さんのスライドが出てきた。自分が Visual Studio Code を使ってやっていたことは strace と gdb を使うと同じことができそう。

https://speakerdeck.com/dqneo/introduction-to-low-level-programming-in-go

macOS で Go が作るアセンブリのコードを表示する方法を調べていたら以下の Issue を見つけた。

https://github.com/golang/go/issues/58629

go build -gcflags=-S main.go する。最初の TEXT 命令がエントリーポイントになってそうだ。

# command-line-arguments
main.main STEXT size=112 args=0x0 locals=0x48 funcid=0x0 align=0x0
        0x0000 00000  TEXT    main.main(SB), ABIInternal, $80-0
        0x0000 00000  MOVD    16(g), R16
        0x0004 00004  PCDATA  $0, $-2
        0x0004 00004  CMP     R16, RSP
        0x0008 00008  BLS     96
        0x000c 00012  PCDATA  $0, $-1
        0x000c 00012  MOVD.W  R30, -80(RSP)
        0x0010 00016  MOVD    R29, -8(RSP)
        0x0014 00020  SUB     $8, RSP, R29
        0x0018 00024  FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
        0x0018 00024  FUNCDATA        $1, gclocals·EaPwxsZ75yY1hHMVZLmk6g==(SB)
        0x0018 00024  FUNCDATA        $2, main.main.stkobj(SB)
        0x0018 00024  STP     (ZR, ZR), main..autotmp_8-16(SP)
        0x001c 00028  MOVD    $type:string(SB), R5
        0x0024 00036  MOVD    R5, main..autotmp_8-16(SP)
        0x0028 00040  MOVD    $main..stmp_0(SB), R5
        0x0030 00048  MOVD    R5, main..autotmp_8-8(SP)
        0x0034 00052  PCDATA  $0, $-3
...(中略)...

以下の箇所でメモリに Hello, World! を格納している。“Hello, World!” は 13 バイトで、先ほど Hex Editor で見た通り 16 進数では 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 だ。

go:string."Hello, World!" SRODATA dupok size=13
        0x0000 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21           Hello, World!

今回見てきたのは Hello, World! という文字列を出力するだけのプログラムだが、OS の違いに伴うシステムコールのインターフェイスの違いを吸収したり必要に応じて各段階でエラー処理をしたり、調べると様々な仕組みを知ることができて楽しかった。

情報系の大学生はこういうのをやってきているんだろうな…