接入 Go 工具链
摘要
一篇关于 Go 的 -toolexec 标志的深度教程,展示如何拦截 go build 流水线的每一步,以便对编译过程进行计时、改写和插桩,并结合 garble 与 otelc 的真实案例进行讲解。
<p><a href="https://lobste.rs/s/iqn5el/hooking_into_go_toolchain">评论</a></p>
查看缓存全文
缓存时间: 2026/10/05 13:12
# 接入 Go 工具链的源码
来源:https://internals-for-interns.com/posts/hooking-into-the-go-toolchain
今天我们要来看一看 Go 工具链,更具体地说,是如何让我们自己的代码参与到编译过程中。我们不会去给编译器打补丁。我们会在它工作时站在旁边,看它做了什么,然后时不时递给它一样它没要过的东西。就当是礼貌地给编译器来一张乱入合照吧。📸
进入的通道就是一个 flag,而我所知最便宜的实验长这样:
```
$ go build -a -o /tmp/app -toolexec=/usr/bin/time ./app
# internal/unsafeheader 0.02 real 0.00 user 0.00 sys
# internal/goarch 0.03 real 0.00 user 0.00 sys
# internal/coverage/rtcov 0.02 real 0.00 user 0.00 sys
...
```
那一行行小小的 `real user sys` 收据,都是 `go` 命令替我们运行的一个程序。对这个小程序来说一共有 108 个:59 次编译器运行、48 次汇编器运行和 1 次链接器运行。`-toolexec` 告诉 `go build` 把每个这样的程序都通过一个我们选定的程序来运行,而这里我们选了 `/usr/bin/time`,于是它就变成了一个秒表。一个话很多的秒表,但确实是秒表。⏱️
不过计时并不是最初的计划。Russ Cox 在 2015 年 1 月添加了这个 flag(https://github.com/golang/go/commit/83c10b204d619d18100716c9588404200acdf6e0),目的是让工具链可以在 valgrind 这类工具下运行,或者换上 toolstash 暗中换入的编译器副本——toolstash 是他为了开发编译器本身而写的一个工具。几周之后,toolstash 学会了为它运行的每个命令计时。多年以后,他把 `-toolexec=time` 描述为“一个意外——大多是个愉快的意外”(https://github.com/golang/go/issues/27628#issuecomment-702251208)。这篇文章讲的就是那个愉快的意外,也就是 Go 里一个被 Daniel Martí 称为“迄今为止探索不多的空间”的角落(https://golab.io/talks/dividing-into-the-go-toolchain-to-obfuscate-builds)。他一直在用 garble(https://github.com/burrowers/garble)这个混淆器探索它,而他的成果贯穿了这篇文章。我们会一路跟着这个秒表走。
首先我们会造一个自己的秒表。然后教它在编译器看到之前改写代码,偷偷塞进构建从没要求过的包,调用它本不被允许 import 的代码。在这个过程中,构建缓存会对我们说谎。最后我们会看看 otelc(https://github.com/open-telemetry/opentelemetry-go-compile-instrumentation),一个把这些全都当做日常工作来做的真实工具,并用同一个秒表给它计时。
感谢 Jesús 邀请我。他关于 Go 编译器的系列文章(https://internals-for-interns.com/series/understanding-the-go-compiler/)解释了编译器拿我们的代码做了什么;而今天讲的是我们如何从一开始就挡在 `go` 命令和编译器之间。
先说明一下:我在 Datadog 工作,并参与维护 otelc,所以请对最后一部分保持警觉,并且可以在合适的时候翻个白眼。
我们要写的全部代码都在一个配套仓库(https://github.com/kakkoyun/hooking-into-the-go-toolchain)里。
*为了简单起见,下面所有内容都在 macOS 上、Go 1.27.1 环境下运行。你的数字会不同,而这正是乐趣的一半。:)*
不过在我们能接入任何东西之前,我们得先知道我们在接入什么。让我们看看在没人盯着的时候,`go build` 到底做了什么。
## `go build` 实际运行了什么
我们可以站到构建的每一步面前。很好!但那些步骤是什么?
如果我们带着 `-x` 运行 `go build`,它会把它运行的每个命令都念出来,而 `-a` 会确保重新构建所有东西,而不是复用缓存:
```
$ go build -a -x -o /tmp/app ./app
...
$GOROOT/pkg/tool/darwin_arm64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" \
-p main -lang=go1.25 -complete -buildid kIewNeZLieJMDX0sK6uO/kIewNeZLieJMDX0sK6uO \
-goversion go1.27.1 -c=16 -shared -nolocalimports \
-importcfg $WORK/b001/importcfg -pack ./app/main.go
...
$GOROOT/pkg/tool/darwin_arm64/link -o $WORK/b001/exe/a.out \
-importcfg $WORK/b001/importcfg.link -buildmode=pie ...
...
$WORK/b001/_pkg_.a
```
对我们的小程序来说这差不多有 800 行,所以我只留下了其中两行。不用谢。
第一行编译我们的 `main` 包。编译器拿到了包路径(`-p main`)、末尾那一串 `.go` 文件列表,以及一个通过 `-importcfg` 传入的文件。第二行,也就是构建的最后一步,把所有东西链接成一个二进制文件。
换句话说,`go build` 在运行大量编译命令,并在最后把结果链接在一起。这说得通,但它怎么知道什么需要编译,什么要进入链接呢?
构建实际上是一张动作图(action graph),每个包对应一个或多个动作,而每个动作在 `$WORK` 下都有一个自己的编号目录——`$WORK` 是 `go` 命令为这次构建创建的一个临时目录。`b001` 就是我们的 `main` 包。在运行编译器之前,`go` 命令会把那个 `importcfg` 文件写进该动作的目录。对我们的包来说它长这样:
```
# import config
packagefile bytes=$WORK/b002/_pkg_.a
packagefile fmt=$WORK/b042/_pkg_.a
packagefile github.com/kakkoyun/hooking-into-the-go-toolchain/greet=$WORK/b060/_pkg_.a
packagefile os=$WORK/b049/_pkg_.a
packagefile time=$WORK/b054/_pkg_.a
packagefile runtime=$WORK/b009/_pkg_.a
```
`main.go` 导入的每个包占一行,外加 `runtime`,每一行都指向该包编译好的归档文件。这就是编译器对外部世界的全部认知。它不会搜索 `GOPATH`,也不读 `go.mod`;如果一个包不在这份文件里,它就不存在。
(如果你好奇那些归档里有什么,Jesús 关于统一 IR 格式的文章(https://internals-for-interns.com/posts/go-compiler-unified-ir/)会拆开一个给你看。)
链接器会拿到一份类似的文件,列出程序中的每个包。请记住这个 `importcfg`,因为它后面会咬我们一口。(没错,这是伏笔。👀)
那份日志里的大部分其他行,是 `go` 命令自己在做事,比如写那些文件、创建目录或者在各处复制归档。对我们来说重要的行是那些从 `$GOROOT/pkg/tool/` 启动某个工具(`compile`、`asm` 和 `link`)的行,而这些恰好就是 `-toolexec` 插手的那些行。`go help build` 对这个 flag 的描述是这样的:
```
-toolexec 'cmd args'
a program to use to invoke toolchain programs like vet and asm.
For example, instead of running asm, the go command will run
'cmd args /path/to/asm'. The TOOLEXEC_IMPORTPATH environment variable
will be set, matching 'go list -f {{.ImportPath}}' for the package
being built.
```
这就是全部接口了。我们的程序把工具路径作为第一个参数接收,随后是工具本身的参数,然后它可以在运行工具之前、之后或干脆代替运行工具,做任何它想做的事。`TOOLEXEC_IMPORTPATH` 变量告诉它当前在构建哪个包。
这个也是 Daniel 的工作成果(https://github.com/golang/go/commit/de74ea5d740ccc69dbb146578dc8a965351a3d6b),在 Go 1.16 中加入;在那之前,包装器只能从各种 flag 里猜出包是什么。
既然我们已经确切知道 `-toolexec` 从哪里切入,那就在那里放点我们自己的东西吧,从一个更好的秒表开始——一个至少能区分不同包的秒表。
## 造我们自己的秒表
`/usr/bin/time` 给了我们数字,但它不会告诉我们每个数字属于哪个包。让我们写一个能分辨的包装器。
它的核心很小:
```go
func main() {
tool, args := os.Args[1], os.Args[2:]
cmd := exec.Command(tool, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
// ... 把 SIGINT、SIGTERM、SIGHUP 和 SIGQUIT 转发给工具 ...
start := time.Now()
if err := cmd.Start(); err != nil {
fmt.Fprintf(os.Stderr, "stopwatch: %v\n", err)
os.Exit(1)
}
err := cmd.Wait()
logLine(tool, args, time.Since(start))
// 追加到 $STOPWATCH_LOG
os.Exit(exitCode(err))
}
```
它用相同的标准输入输出运行真正的工具,测量它花了多久,然后往日志文件里追加一行:工具、导入路径、毫秒数。然后它以工具自己的状态码退出,所以 `go build` 从头到尾都察觉不到我们的存在。
让我们把它构建出来,并把 `go build` 指向它:
```
go build -o .bin/stopwatch ./cmd/stopwatch
STOPWATCH_LOG=/tmp/sw.tsv go build -a -o /tmp/app -toolexec=$PWD/.bin/stopwatch ./app
```
下面是日志里的几行,每次工具调用一行:
```
compile | runtime | 860 | files=182
compile | fmt | 80 | files=5
compile | github.com/kakkoyun/hooking-into-the-go-toolchain/app | 16 | files=1
link | github.com/kakkoyun/hooking-into-the-go-toolchain/app | 59 | files=0
```
每一行都是一次工具调用:运行了哪个工具、它在处理哪个包、花了多少毫秒、收到了多少个源文件。整个日志有 111 行。
按工具统计一下,并把编译按耗时排序,我们得到:
```
tool runs -V=full
asm 48 1
compile 59 1
link 1 1
ms package
860 runtime
379 reflect
261 internal/abi
230 syscall
195 math
```
`runtime` 是编译最慢的包,这毫不意外;它在构建中承担着最艰难的工作。(不过别把这些毫秒数加起来当成构建时间:这些工具是并行运行的,所以它们的和会大于我们实际等待的时间。)
有趣的地方是第一张表的最后一列。在构建任何东西之前,`go` 命令用一个单独的参数 `-V=full` 把每个工具运行了一次,而且是通过我们的包装器运行的。在我们的日志里它长这样:
```
compile | - | 8 | -V=full
```
那是 `go` 命令在问每个工具:“你是谁?”。一次小小的身份危机,每次构建每个工具一次。
编译器回答 `compile version go1.27.1`,而这个回答就变成了工具的 ID。这个 ID 最终会出现在该工具编译的每个包的缓存键里。这个键,也就是 action ID,是该包源文件、其 flag、工具 ID 以及其依赖产物的一个哈希。
注意里面有什么是不在其中的:`-toolexec` 这个 flag 本身。
为什么要去问包装器,而不是直接读编译器二进制呢?`go` 命令的源码(https://github.com/golang/go/blob/go1.27.1/src/cmd/go/internal/work/buildid.go#L115-L185)解释道:“我们希望 `-toolexec toolstash` 继续能工作”。如果一个包装器换入了不同的编译器,缓存键应该能察觉到这一点。
完整的图景,从 `-V=full` 的提问到缓存:
> go build 会向秒表询问每个工具的 -V=full 答案,这个答案成为工具 ID;每个包的 action ID 是其源文件、flag、工具 ID 及其依赖产物的哈希;缓存命中时直接复用归档且不记录任何东西,缓存未命中时秒表运行真正的 compile、asm 或 link,并记录这次调用
这给我们的包装器带来了一个有趣的后果:它的标准输出其实已经不再属于我们了。在那个 `-V=full` 提问期间,stdout *就是*答案。
从 Go 1.10 起,`go` 命令(https://github.com/golang/go/issues/22588)在得到正确答案的前提下,会忽略工具向 stderr 打印的任何内容,但 stdout 却没有这样的待遇。如果我们的秒表在运行工具之前在 stdout 上打了声招呼,构建就会当场停下。粗鲁,但公平:
```
go: parsing buildID from go tool compile -V=full: unexpected output:
stopwatch: starting compile
compile version go1.27.1
```
如果它在工具之后打印,事情就更隐秘了。构建能成功,但我们的日志行里含有一个毫秒计数,所以答案、连同工具 ID,每次构建都会变化。第二次构建又重新编译了全部 59 个包,而没有任何东西告诉我们原因。
我们这个守规矩的包装器只写日志文件。
让我们在不带 `-a` 的情况下再跑一次构建,看看日志:
```
compile | - | 6 | -V=full
asm | - | 4 | -V=full
link | - | 5 | -V=full
```
只有三个问题,再无其他。每个包都直接从缓存里来,所以没有工具运行,我们的秒表也无事可做。
有史以来最好的构建,也是最糟糕的演示。🤷
一个 `-toolexec` 包装器只能看到缓存放行的那些东西。
记住这一点;它后面还会回来。
旁观很有趣,但我们的包装器所处的位置比这有意思得多。让我们看看当它不再做一个礼貌的旁观者时会发生什么。
## 在编译器看到之前改写代码
既然我们坐在了 `go` 命令和编译器之间,我们能做的就不只是观察了。
编译器把源文件作为参数接收,而我们先看到那些参数。如果我们递给它不同的文件呢?
从一个程序里改 Go 源代码,听起来像是只有在被逼无奈(或者打赌输了)的时候才会做的事,但 Go 对此出奇地友好。
编译器有它自己的 parser,也就是 Jesús 在他的 parser 文章(https://internals-for-interns.com/posts/the-go-parser/)里拆解的那个,但标准库为了工具另附带了一整套包:`go/token`(https://pkg.go.dev/go/token)记录位置信息,`go/parser`(https://pkg.go.dev/go/parser)把源代码变成语法树,`go/ast`(https://pkg.go.dev/go/ast)描述那棵树里的每个节点,而 `go/printer`(https://pkg.go.dev/go/printer)和 `go/format`(https://pkg.go.dev/go/format)则把一棵树变回代码。
这些就是 `gofmt` 基于构建的包(https://github.com/golang/go/blob/go1.27.1/src/cmd/gofmt/gofmt.go#L12-L16),而 `go vet` 的检查通过 analysis 框架(https://pkg.go.dev/golang.org/x/tools/go/analysis)——大多数 Go linter 都用的框架——运行在这些包之上。
如果你写过 linter,你就已经做过我们所需工作的前半部分了:找到你关心的代码。正如 Jesús 在他文章结尾所说,许多 Go 开发者使用 `go/ast` “以编程方式解析 Go 代码,并构建强大的工具”(https://internals-for-interns.com/posts/the-go-parser/#using-the-ast-in-your-own-code)。这正是我们第二个玩具包装器 `toyhook` 所做的。
它寻找那些标有 `//demo:log` 注释的函数,比如我们 app 里的这个:
```go
//demo:log
func countLines(path string) int {
data, err := os.ReadFile(path)
...
```
找到它们需要每个 linter 开局都要走的同样三步:解析文件、遍历树、检查每个节点。
稍微精简一下,`toyhook` 的核心看起来是这样的:
```go
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, abs, src, parser.ParseComments)
if err != nil {
return nil, 0, err
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil || !hasDirective(fn) {
continue
}
lbrace := fset.Position(fn.Body.Lbrace)
// ... 就在 lbrace.Offset 之后插入我们的语句 ...
}
```
`parser.ParseFile` 把文件读成一个 `*ast.File`,而 `ParseComments` 要求它保留注释——我们正是需要注释,因为我们的标记就是注释。然后我们遍历文件的顶层声明,只保留函数,而 `hasDirective` 检查每个函数的 doc 注释里有没有 `//demo:log`。
`token.FileSet` 是把一个节点变回文件、行号和字节偏移的东西,所以 `lbrace` 精确地告诉了我们函数左大括号在哪里。
现在我们需要加进日志语句。教科书式的方法是把它也构造成树:每个调用、标识符和字面量都变成一个结构体,然后把它们拼接进函数体。这行得通,但非常啰嗦,就像税表一样啰嗦。
下面只是 `start := time.Now()` 作为 AST 节点的样子,来自我为一次演讲写的注入器(https://github.com/kakkoyun/otel-night-berlin-2026/blob/65fc5bb0559331235704dc5707e165175f8c28a2/demo/toolchain/cmd/loginjector/main.go#L113-L124):
```go
startDecl := &ast.AssignStmt{
Lhs: []ast.Expr{ast.NewIdent("start")},
Tok: token.DEFINE,
Rhs: []ast.Expr{
&ast.CallExpr{
Fun: &ast.SelectorExpr{
X: ast.NewIdent("time"),
Sel: ast.NewIdent("Now"),
},
},
},
}
```
那个注入器最后为了两条日志语句写了 306 行。两条。日志。语句。😩
把树重新打印出来也有个坑:`go/ast` 的注释“是按字节偏移存储的,而不是附着在节点上,所以重新排列节点会破坏输出”。这段话直接来自 dst(https://github.com/dave/dst)的 README——一个第三方 Go 包(名字代表 Decorated Syntax Tree,带装饰的语法树),就是为了解决这个问题而建的。它让注释保持附着在它们所属的节点上,这就是为什么 otelc 这样的严肃工具会用它来改写代码。
我们的玩具走了一条捷径。它使用那棵树……
相似文章
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。
Go 工具链的 Fuzzing 分支
Trail of Bits 推出了 gosentry,这是一个面向模糊测试的 Go 工具链分支,集成 LibAFL 以增强路径约束求解、结构化模糊测试及漏洞检测能力,同时保留标准测试工作流程。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
深入理解Go运行时:性能分析
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。
Go 实验详解
本文介绍了 Go 语言中实验性功能的处理方式、生命周期以及近期实验示例。