Chapter 03

continue の実装

okarin
okarin
2024.12.27に更新

Hello, World! プログラムの実行

最初に以下のようなディレクトリ階層となるように、 cmd/helloworld ディレクトリを作成し、 main.go ファイルを作成します。

go-debugger/
├── cmd
│   └── helloworld
│     └── main.go
└── go.mod

その後、 main.go で Hello, World! を出力するプログラムを作成します。

go-debugger/cmd/helloworld/main.go
package main

import "fmt"

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

念の為、動作を確認します。

go run ./cmd/helloworld/main.go 
# Hello, World!

Hello, World! プログラムのビルド

簡単なプログラムを作成できたので、これをデバッグできるようにビルドするプログラムを作成します。
今度は以下のようにトップレベルに main.go と build.go を作成します。

go-debugger/
  ├── cmd
  │   └── helloworld
  │     └── main.go
+ ├── build.go
  ├── go.mod
+ └── main.go

ファイルを作成したら、 Hello, World! プログラムをビルドして、終了前にビルドしたファイルを削除するプログラムを作成します。

まず、 build.go の中身は以下のようになります。
buildDebuggeeProgram 関数はデバッグしたい Go プログラムをビルドして、ビルドしたファイルのパスとクリーンアップ関数を返す関数になります。ビルドするときのオプションの gcflags に関しては後述します。

go-debugger/build.go
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"time"
)

// buildDebuggeeProgram build go program to debug and returns cleanup function.
func buildDebuggeeProgram(path string) (absPath string, cleanup func() error, err error) {
	// Name the file to build in the format __debug__1730159170
	name := fmt.Sprintf("__debug__%d", time.Now().Unix())
	cmd := exec.Command("go", "build", "-o", name, "-gcflags", "all=-N -l", path)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		return "", nil, fmt.Errorf("failed to build go program: %w", err)
	}

	// Get the absolute path of the built file
	absPath, err = filepath.Abs(name)
	if err != nil {
		return "", nil, fmt.Errorf("failed to get absolute path of debuggee program: %w", err)
	}

	return absPath, func() error {
		if err := os.Remove(absPath); err != nil {
			return fmt.Errorf("failed to remove debuggee program: %w", err)
		}

		return nil
	}, nil
}

main.go を以下のように実装します。 Go プログラムのパスを引数で受け取って、先ほどの buildDebuggeeProgram 関数を実行してビルドします。今回は特に何もしないので、処理が終わったらログを出力しておきます。

go-debugger/main.go
package main

import (
	"flag"
	"fmt"
	"log"
)

var debuggeePath string

func init() {
	flag.StringVar(&debuggeePath, "path", "", "path of debuggee program")
}

func main() {
	flag.Parse()

	if debuggeePath == "" {
		log.Fatalf("path of debuggee program must be given")
	}

	absDebuggeePath, cleanup, err := buildDebuggeeProgram(debuggeePath)
	if err != nil {
		log.Fatalf("failed to build debuggee program: %s", err)
	}
	defer cleanup()

	fmt.Printf("built program path is %s\n", absDebuggeePath)
}

ここまでコードが書けたら、プログラムを実行しておきましょう。
以下のように、ビルドしたプログラムのパスが出力されていると成功です。

go run . -path ./cmd/helloworld
# built program path is /Users/<username>/lima/go-debugger/__debug__1730159855

gcflags について

今回、 go build の引数として -gcflags=all=-N -l を渡しているのでここで簡単に解説します。
go build に渡す引数に関しては、 cmd/go のCompile packages and dependencies に記載があります。

-gcflags '[pattern=]arg list'
arguments to pass on each go tool compile invocation.

go build の内部では、 go tool compile を実行するのですが、そのときに渡す引数を gcflags で指定できます。
pattern は今回は -all としていますが、ビルドしたいパッケージと、それが依存するパッケージ全てのパッケージが対象となります。pattern の詳細は go help packages で確認することができます。

引数のリストに関しては、 cmd/compile に記載があります。
-N はコンパイル時の最適化を無効にし、 -l はインライン化を無効にします。それぞれ、デバッグを簡単にするために必要なので指定しておきます。

-N
Disable optimizations.
-l
Disable inlining.

Go コンパイラに関してさらに詳しく知りたい方は cmd/compile の README を読むとさらに理解が進むと思います。

ptrace でプログラムを追跡可能にする

まず、プログラムを実行するための関数を記述するための execute.go ファイルを作成します。

go-debugger/
  ├── cmd
  │   └── helloworld
  │     └── main.go
  ├── build.go
+ ├── execute.go
  ├── go.mod
  └── main.go

ビルドしたプログラムを実行するときに、 ptrace を使用するために、 cmd.SysProcAttrPtrace フィールドを true に設定しています。この設定によって、プログラムを追跡可能になります。
その後、 cmd.Start によってプログラムを子プロセスで実行します。

go-debugger/execute.go
package main

import (
	"log"
	"os"
	"os/exec"
	"syscall"
)

func executeDebuggeeProcess(debuggeePath string) (pid int, err error) {
	cmd := exec.Command(debuggeePath)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// trace debuggee program
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Ptrace: true,
	}

	if err := cmd.Start(); err != nil {
		log.Fatalf("failed to start command: %s", err)
	}

	return cmd.Process.Pid, nil
}

cmd.Start

cmd.Start をもう少し詳しく見てみると、以下のようなコードに辿り着きます。 fork して子プロセスを生成して、 execve で実行していることが分かります。
cmd.Start を実行した後は、生成した子プロセスのプロセス ID が cmd.Process.ID で取得できるようになります。

exec_linux.go
// Time to exec.
pid, err1 = rawVforkSyscall(SYS_CLONE, flags, 0, uintptr(unsafe.Pointer(&pidfd)))

...

_, _, err1 = RawSyscall(SYS_EXECVE,
	uintptr(unsafe.Pointer(argv0)),
	uintptr(unsafe.Pointer(&argv[0])),
	uintptr(unsafe.Pointer(&envv[0])))

ptrace

syscall.SysProcAttrPtrace フィールドがどのように使われているのかを調べてみると、以下のように Ptrace フィールドを使っているコードがみつかります。
fork によって生成された子プロセス(tracee)で ptrace システムコールを実行しており、 PTRACE_TRACEME を渡しています。 これを実行すると tracee が親プロセス(tracer)に追跡されている状態になります。さらに tracee が execve を実行すると一時停止して、 tracer が次の ptrace を実行するまで処理を停止します。
したがって、 cmd.SysProcAttr の Ptrace フィールドを true にして、 cmd.Start を実行すると、プログラムは実行されずに停止します。

exec_linux.go
// Fork succeeded, now in child.

...

if sys.Ptrace {
	_, _, err1 = RawSyscall(SYS_PTRACE, uintptr(PTRACE_TRACEME), 0, 0)
	if err1 != 0 {
		goto childerror
	}
}

executeDebuggeeProcess が実装できたら、 main.go で実行します。

go-debugger/main.go
func main() {
	...

	defer cleanup()

+	pid, err := executeDebuggeeProcess(absDebuggeePath)
+	if err != nil {
+		log.Fatalf("failed to execute debugee program: %s", err)
+	}

+	fmt.Printf("pid of debuggee program is %d\n", pid)
-	fmt.Printf("built program path is %s\n", absDebuggeePath)
}

ここまで実装できたら実行してみましょう。
ここで Hello, World! が出力されないことを確認してください。

go run . -path ./cmd/helloworld/
# pid of debuggee program is 9742

continue で処理を再開する

まず、 syscall.Wait4 で子プロセスが停止するまで待機します。これは内部的に wait4 システムコールを実行しています。

その後プログラムの処理を再開するために、syscall.PtraceCont を実行します。これは内部的には PTRACE_CONT を渡して ptrace システムコールを実行しています。これは追跡中のプログラムが停止している場合、処理を再開するためのものです。これに関しても詳細は次章で解説します。

continue の後も、 syscall.Wait4 で子プロセスの処理が再開するまで待機するようにしておきます。

go-debugger/main.go
func main() {
	...

	fmt.Printf("pid of debuggee program is %d\n", pid)

+	var ws syscall.WaitStatus
+	_, err = syscall.Wait4(pid, &ws, syscall.WALL, nil)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "failed to wait pid %d\n", pid)
+		return
+	}

+	if err := syscall.PtraceCont(pid, 0); err != nil {
+		fmt.Fprintf(os.Stderr, "faield to execute ptrace cont: %s\n", err)
+		return
+	}

+	_, err = syscall.Wait4(pid, &ws, syscall.WALL, nil)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "failed to wait pid %d\n", pid)
+		return
+	}
}

それではプログラムを実行してみましょう。以下のように Hello, World! が出力されていたら成功です。プロンプトの後に出力されることもありますが、あまり気にしなくて大丈夫です。

go run . -path ./cmd/helloworld/
# pid of debuggee program is 16846
# Hello, World!