Compiler & Linker Flags — Senior¶
1. The compile→link pipeline¶
Each Go package goes through:
- Parse: source → AST.
- Type-check: AST → typed AST.
- Escape analysis + inlining (controlled by
-gcflags). - SSA codegen: typed AST → SSA → machine instructions.
- Object archive (
.afile in$GOCACHE).
The linker (cmd/link) takes archives + the runtime + stdlib + symbols and produces the binary. -ldflags apply at this stage.
Knowing where each flag acts lets you predict its effect.
2. Tracking compiler internals¶
-gcflags='-d=<key>=<value>' sets debug knobs:
| Knob | Effect |
|---|---|
-d=ssa/<pass>/debug=1 | Dump SSA before/after a pass |
-d=ssa/check/seed=42 | Seed SSA randomized passes |
-d=ssa/print/seed=42 | Print all SSA |
-d=checkptr=2 | Stricter unsafe.Pointer checks |
-d=initorder | Print package init order |
These are mostly used by Go contributors but occasionally help when diagnosing a compiler-version-specific bug.
3. -S and -spectre¶
Prints the generated assembly per function. Reading the output is the most reliable way to verify whether the compiler inlined or eliminated bounds checks.
Adds Spectre v1 mitigation. Slight performance cost. Useful in environments where untrusted code shares the same CPU.
4. The linker's job in detail¶
cmd/link does:
- Resolves symbol references across packages.
- Decides what to keep (dead code elimination).
- Lays out the binary (text, data, rodata, BSS).
- Embeds metadata (
go version -minfo). - Optionally strips symbol info.
For static binaries the linker also bundles libc; for cgo, it cooperates with the external linker.
5. -X injection deeply¶
package main
var (
version = "dev" // injectable
buildTime = "unknown" // injectable
Const = "fixed" // NOT injectable (it's a constant if compiler proves immutability, even though declared var)
)
Watch out:
- The variable must be a
var, not aconst. - It must be
string-typed; non-strings can't be injected. - The compiler must not prove it's read-only — if so, it might be folded into a constant (rare, but possible).
- Spaces in the value need quoting carefully.
For values you really need, declare them in a package that's used by main:
// pkg/buildinfo/buildinfo.go
package buildinfo
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
go build -ldflags="\
-X 'example.com/proj/pkg/buildinfo.Version=$(VERSION)' \
-X 'example.com/proj/pkg/buildinfo.BuildTime=$(date)' \
-X 'example.com/proj/pkg/buildinfo.GitCommit=$(git rev-parse HEAD)'" \
./cmd/app
6. -trimpath semantics¶
Without -trimpath:
With -trimpath:
The full local path is replaced by the canonical import path. For reproducible builds and for not leaking developer paths into binaries, always use -trimpath in release builds.
7. Build IDs and -buildid¶
Each Go binary contains a build ID — a hash of inputs that uniquely identifies the build. Used by:
- The build cache for incremental rebuilds.
go version -mto show what was built.- OS-level tooling (e.g.,
debuginfodon Linux).
-ldflags="-buildid=''" clears it. For reproducible binaries you want this; for production deployments, the build ID is useful to embed.
8. The external linker pathway¶
Internal linker (Go's own): pure Go, fast, supports all platforms. External linker (system gcc -o ...): required for some cgo cases, static linking, link-time decoration.
When cgo is enabled, linkmode=external is the default. For pure Go, the internal linker is preferred (faster, fewer toolchain dependencies).
-extldflags lets you pass arbitrary flags to the external linker — -static, -Wl,-rpath=..., etc.
9. PGO in detail¶
Profile collection:
# From a running service
curl -o cpu.pgo http://localhost:6060/debug/pprof/profile?seconds=60
# From a load test
go test -cpuprofile=cpu.pgo -bench=. ./...
Build with the profile:
Or place default.pgo next to main.go and use -pgo=auto.
The compiler uses the profile to:
- Inline more aggressively in hot paths.
- Devirtualize interface calls when the type is observed dominant.
- Optimize register allocation for hot loops.
Typical real-world gains: 5–10%. Higher (15%+) for heavily polymorphic code that PGO can devirtualize.
PGO profiles are stable across "small" code changes — you don't need to re-profile every commit. Refresh weekly or monthly.
10. Cross-compilation gotchas¶
Pure-Go cross-compile: straightforward.
Cgo cross-compile: needs a cross C toolchain.
For typical multi-platform release, easier paths:
CGO_ENABLED=0and accept the pure-Go path.- Docker buildx for each target image.
zig ccas a universal cross compiler.
11. Embedding VCS info¶
Go 1.18+ automatically embeds VCS metadata when building from a clean working tree inside a VCS repo. Visible via:
To disable (e.g., for reproducible builds where the commit shouldn't be embedded):
To require it (CI verification):
12. Race / sanitizer impact¶
| Mode | Cost | When |
|---|---|---|
-race | 2–20× slower, ~3× memory | Test suites, dev runs |
-msan | ~2× slower, requires cgo, Linux/amd64 or arm64 | Debugging uninit reads |
-asan | ~2× slower, requires cgo | Debugging out-of-bounds |
Never ship sanitizer-instrumented binaries to production. Always run race tests in CI.
13. Plugin-mode caveats¶
Works on Linux/macOS only. Subject to many restrictions:
- Main app and plugins must be built with identical toolchain, flags, and
GOROOT. - Plugin types and host types must match exactly (
reflect.TypeOfidentity). - Plugins can't be unloaded.
See 13-plugin-package for the dedicated chapter.
14. Summary¶
Senior-level toolchain control comes from knowing which flag acts at which stage: -gcflags at compile, -ldflags at link, plus top-level options like -pgo, -trimpath, -race, and the GOOS/GOARCH env vars. Reproducible builds need -trimpath plus pinned versions plus stripped build IDs. PGO is a low-effort 5–10% CPU win once your service has a hot path worth profiling.
Further reading¶
cmd/compileREADME: https://github.com/golang/go/tree/master/src/cmd/compilecmd/linksource: https://github.com/golang/go/tree/master/src/cmd/link- PGO design: https://go.dev/doc/pgo
- Reproducible builds: https://reproducible-builds.org/