Code Generation — Junior¶
1. What code generation produces¶
Code generation is the last stage of the Go compiler. By the time we reach it, your source has already become an AST, then typed IR, then SSA (static single assignment form). The middle-end and SSA backend have optimized and lowered that SSA into architecture-specific operations. Code generation's job is to turn those operations into real machine instructions for a particular CPU, lay out a stack frame, allocate hardware registers, and emit the bytes the linker will assemble into the final binary.
The pipeline as a whole:
source → tokens → AST → typed IR → SSA (generic) → SSA (lowered, arch-specific)
→ code generation (regalloc + instruction emission) → obj.Prog list → object file → linker → binary
So this stage answers a concrete question: which exact instructions does a + b become on an x86-64 chip? On amd64 it becomes a single ADDQ. On arm64 it becomes a single ADD. The compiler picks the instruction, picks the registers, and writes it down.
The relevant Go source lives in cmd/compile/internal/ssa (register allocation), the per-arch packages cmd/compile/internal/amd64, cmd/compile/internal/arm64, etc. (instruction emission), and cmd/internal/obj (the architecture-neutral instruction list).
2. Dumping assembly with go build -gcflags=-S¶
You do not need to read the compiler's source to see what it produces. The -S flag tells the compiler to print the assembly it generated. You pass it through go build with -gcflags:
Note: it is -gcflags=-S, not go build -S. The -S is a flag for the compiler gc, and -gcflags forwards it. Output goes to stderr, so redirect it if you want to read it in a pager:
Take this tiny program:
package main
//go:noinline
func Add(a, b int) int { return a + b }
func main() { println(Add(1, 2)) }
The //go:noinline pragma stops the compiler from inlining Add into main, so we actually get a function to look at. Build it for amd64:
You will see, among the noise, something like:
main.Add STEXT nosplit size=4 args=0x10 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (main.go:4) TEXT main.Add(SB), NOSPLIT|NOFRAME|ABIInternal, $0-16
0x0000 00000 (main.go:4) PCDATA $3, $1
0x0000 00000 (main.go:4) ADDQ BX, AX
0x0003 00003 (main.go:4) RET
0x0000 48 01 d8 c3 H...
That is the whole function. Four bytes of code: 48 01 d8 c3.
3. Reading a trivial amd64 function line by line¶
Let's decode the Add listing above one line at a time.
This is the symbol header. STEXT means a text (code) symbol. nosplit means the function has no stack-growth check (it is tiny and provably safe). size=4 is four bytes of code. args=0x10 (16 bytes) is the space the caller reserves for arguments + results; locals=0x0 means no stack frame for locals.
TEXT declares the function. (SB) means the address is relative to the static base pseudo-register. NOFRAME means no stack frame is set up. ABIInternal is the modern register-based calling convention (more in middle/senior). $0-16 reads as frame size 0, argument+result area 16 bytes.
PCDATA is metadata the runtime uses (here, inline-tree info). It emits no machine code; it is a table keyed by program counter. Ignore it for now.
The real work. In Go assembly syntax the destination is on the right, so this is AX = AX + BX. The two int arguments arrived in registers AX (a) and BX (b); the result is left in AX. Q means 64-bit (quadword).
Return. The result is already in AX, which is where the caller expects an int result.
The raw machine-code bytes. 48 01 d8 is ADDQ BX,AX; c3 is RET. This is literally what runs on the CPU.
You can get a cleaner disassembly of the linked binary (no PCDATA noise, real addresses) with go tool objdump:
Same two instructions, now with absolute addresses (0x46fb80).
4. The three tools you'll use¶
| Command | What it shows | When to use |
|---|---|---|
go build -gcflags=-S . | Compiler's assembly before linking, with PCDATA/FUNCDATA and reloc notes | See exactly what the compiler emitted, per function |
go tool compile -S file.go | Same listing for a single self-contained file (no imports) | Quick one-file experiments |
go tool objdump -s 'regexp' binary | Disassembly of a linked binary | Real addresses, see what survived linking, inspect any binary |
go tool compile -S works only when the file has no external imports it cannot resolve standalone; for anything importing the standard library, prefer go build -gcflags=-S.
5. Common misconceptions¶
- "
-Sshows machine code." It shows Go's assembly listing (Plan 9 syntax), with the raw bytes appended per function. It is one level above pure hex but tied directly to it. - "Go assembly is just x86 assembly." No. Go uses its own Plan 9 assembler syntax: destination on the right, pseudo-registers like
FP/SB/SP, and its own mnemonics (MOVQ, notmov). It is portable across architectures by design. - "Arguments are always on the stack." Not since Go 1.17. The register-based ABI passes the first several integer/pointer arguments in registers (
AX, BX, CX, ...on amd64). You sawainAXandbinBX. - "
go build -Sis the flag." It isgo build -gcflags=-S. Plain-Sis not ago buildflag. - "Inlining makes my function disappear, so codegen is broken." Inlining is normal and good. Use
//go:noinlinewhen you specifically want to inspect a function in isolation. - "The output goes to stdout."
-Swrites to stderr. Redirect with2>&1to pipe it.
6. Things to do today¶
- Write the three-line
Addprogram above and rungo build -gcflags=-S .. Find theADDQline. - Remove
//go:noinlineand rebuild. Noticemain.Addvanishes — it got inlined. - Build a binary and run
go tool objdump -s 'main\.Add' prog. Compare with the-Soutput. - Change
inttofloat64inAddand re-dump. The instruction becomesADDSDand the registers becomeX0/X1(the XMM float registers). - Build the same program with
GOARCH=arm64and find theADD R1, R0, R0line. Same logic, different CPU.
7. Summary¶
- Code generation is the compiler's final stage: lowered SSA → real machine instructions → object code.
- Dump it with
go build -gcflags=-S .(writes to stderr) or disassemble a binary withgo tool objdump. - Go uses Plan 9 assembly syntax: destination on the right,
MOVQ/ADDQmnemonics, pseudo-registersSB/FP/SP. - Since Go 1.17 the register-based ABI passes the first arguments in registers (
AX, BX, ...on amd64), not on the stack. TEXT,STEXT,PCDATA, andFUNCDATAare structural; the instructions in between are the actual code.
Further reading¶
- A Quick Guide to Go's Assembler — Plan 9 assembly syntax and pseudo-registers
- Go source:
cmd/compile/internal/amd64 - Go source:
cmd/internal/obj cmd/compileREADME — compiler phase overviewgo doc cmd/compileandgo tool compile -helpfor the full flag list