Go Plan 9 Assembly

Go’s assembly language is not the native assembly of any CPU. It is a descendant of the assembler used in Bell Labs’ Plan 9 operating system — a deliberately uniform, machine-independent dialect layered over each architecture’s real instruction set (Go assembly docs). Its most distinctive feature is a set of four pseudo-registersFP, PC, SB, SP — that “are not real registers, but rather virtual registers maintained by the toolchain” and are “the same for all architectures” (asm docs). Understanding the Plan 9 heritage explains why Go assembly looks alien to anyone coming from GNU as or NASM: the operand order, the symbol syntax with its Unicode middle dot, the MOV-that-may-not-move, the 64-bit unsigned constant model, and the frame-relative addressing are all Plan 9 conventions, not amd64 or arm64 conventions. This note is about the language — its syntax, semantics, and the conventions an assembly author must internalise. The tool that consumes it, the build’s two-pass symabis dance, and the flag set are the subject of its sibling The Go Assembler; the two divide cleanly and cross-reference rather than repeat.

Mental Model

Plan 9’s designers — Ken Thompson, Rob Pike, and colleagues — built their OS to be portable across a zoo of architectures (MIPS, SPARC, 386, PowerPC, ARM, later amd64). Rather than maintain a dozen genuinely different assemblers, they built one assembler dialect that looked the same everywhere, with per-architecture instruction tables underneath. Rob Pike’s Plan 9 assembler manual frames them explicitly as “variations of a single program” sharing “left-to-right assignment order for instruction operands” and a common set of pseudo-registers (Plan 9 asm manual). Go inherited that toolchain — Go’s early authors are largely the same people — and that dialect is what cmd/asm accepts today.

The mental shift required: Go assembly is a portable abstraction, not a transcription of machine code. You are not writing down bytes for a CPU. You are writing a slightly-above-the-metal program in a notation that the toolchain finishes compiling. Concretely, the toolchain “operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation,” so a MOV you write “might not be a move instruction at all, perhaps a clear or load” (asm docs). The four pseudo-registers are the load-bearing part of this abstraction: they let you name “the first argument,” “this global symbol,” or “a frame-local spill slot” without knowing the architecture’s actual frame-pointer scheme or addressing modes.

flowchart TD
    subgraph Frame["A function's stack frame (grows toward lower addresses)"]
        direction TB
        ARGS["caller's args + return slots<br/>← addressed via FP, POSITIVE offsets:<br/>x+0(FP), y+8(FP), ret+16(FP)"]
        RA["return address (pushed by CALL)"]
        SAVED["saved BP / saved LR<br/>(toolchain-managed)"]
        LOCALS["this frame's locals + outgoing args<br/>← addressed via SP, NEGATIVE offsets:<br/>tmp-8(SP)"]
    end
    SB["SB — static base<br/>origin of the global address space<br/>foo(SB) = the symbol foo"]
    PC["PC — program counter<br/>targets of jumps/branches (usually via labels)"]
    SB -.global symbols.-> ARGS
    PC -.control flow.-> LOCALS

Figure: the four pseudo-registers and where they point. The insight: FP and SP carve the stack frame into a caller-owned region (arguments and return slots, addressed with positive offsets up from FP) and a callee-owned region (locals, addressed with negative offsets down from SP); SB and PC address the two things outside the frame — global data and code. The toolchain inserts the return-address and saved-register words between the two regions; you never address those directly.

Mechanical Walk-through

SB — the static base pointer

“The SB pseudo-register can be thought of as the origin of memory, so the symbol foo(SB) is the name foo as an address in memory. This form is used to name global functions and data” (asm docs). There is no real SB register on amd64 or arm64 — the toolchain resolves foo(SB) to whatever PC-relative or absolute addressing the architecture and linker need. Three forms: foo(SB) is the ordinary global; foo<>(SB) is file-local — “makes the name visible only in the current source file, like a top-level static declaration in a C file”; foo+4(SB) is “four bytes past the start of foo.” One restriction on control flow: “direct jumps and call instructions can target text symbols, such as name(SB), but not offsets from symbols, such as name+4(SB)” (asm docs).

FP — the virtual frame pointer

“The FP pseudo-register is a virtual frame pointer used to refer to function arguments” (asm docs). 0(FP) is the first argument, 8(FP) the second on a 64-bit machine, counting up through the caller-provided argument-and-results block. Crucially, the assembler “enforces” naming: you must write first_arg+0(FP), not bare 0(FP) — “the assembler enforces this convention, rejecting plain 0(FP) and 8(FP).” The name itself “is semantically irrelevant but should be used to document the argument’s name”; go vet’s asmdecl checker uses it to verify the offset and type match the Go prototype. The docs stress the abstraction: “It is worth stressing that FP is always a pseudo-register, not a hardware register, even on architectures with a hardware frame pointer” (asm docs). On 32-bit targets, the halves of a 64-bit value get _lo/_hi suffixes: arg_lo+0(FP), arg_hi+4(FP). And “if a Go prototype does not name its result, the expected assembly name is ret.”

SP — the virtual stack pointer, and a notorious trap

This is the most confusing pseudo-register because of a name collision. “The SP pseudo-register is a virtual stack pointer used to refer to frame-local variables and the arguments being prepared for function calls. It points to the highest address within the local stack frame, so references should use negative offsets in the range [−framesize, 0): x-8(SP), y-4(SP)” (asm docs). The trap: most architectures also have a real hardware register called SP. The disambiguation rule is the symbol name prefix, and the docs spell it out exactly: “x-8(SP) and -8(SP) are different memory locations: the first refers to the virtual stack pointer pseudo-register, while the second refers to the hardware’s SP register” (asm docs). A reference with a name prefix is the pseudo-register, frame-local; a reference without a name is the architectural register. They are different addressing concepts that happen to share three letters. On architectures where SP is a numbered register, the hardware one has a true R name — “on the ARM architecture the hardware SP and PC are accessible as R13 and R15” — and on amd64 the hardware SP is the real register file entry.

PC — the program counter

PC: Program counter: jumps and branches” (asm docs). In practice you write labels, not raw PC arithmetic: “branches and direct jumps are always written as offsets to the PC, or as jumps to labels.” Each label “is visible only within the function in which it is defined,” so different functions in one file may reuse label names freely.

Symbol naming and the middle dot

A Go symbol’s full name is “the package path followed by a period and the symbol name: fmt.Printf or math/rand.Int” (asm docs). But the assembler’s parser “treats period and slash as punctuation,” so they cannot appear in identifiers. The Plan 9 fix: “the assembler allows the middle dot character U+00B7 and the division slash U+2215 in identifiers and rewrites them to plain period and slash” — so you write fmt·Printf and math∕rand·Int. A leading ·name is shorthand: “the linker inserts the package path of the current object file at the beginning of any name starting with a period,” so ·Int inside math/rand’s implementation becomes math/rand.Int without hard-coding the import path — “this convention … makes it easier to move the code from one location to another.” (The -S compiler listings show the plain period and slash, not the Unicode forms.)

Operand order — data flows left to right

“Data in the instructions flows from left to right: MOVQ $0, CX clears CX. This rule applies even on architectures where the conventional notation uses the opposite direction” (asm docs). This is a Plan 9 convention. Intel syntax is destination-first; AT&T syntax is source-first; Plan 9 is uniformly source-first / destination-last, regardless of what the vendor manual says. Mnemonics, registers, and directives are “always in UPPER CASE … to remind you that assembly programming is a fraught endeavor” — the one exception being the g register renaming on ARM.

Constants — 64-bit unsigned, Go operator precedence

A subtle language detail the old draft omitted: “Constant expressions in the assembler are parsed using Go’s operator precedence, not the C-like precedence of the original. Thus 3&1<<2 is 4, not 0 — it parses as (3&1)<<2 not 3&(1<<2)” (asm docs). And “constants are always evaluated as 64-bit unsigned integers. Thus -2 is not the integer value minus two, but the unsigned 64-bit integer with the same bit pattern.” To avoid ambiguity, “division or right shift where the right operand’s high bit is set is rejected.” This matters whenever you compute a mask or offset inline.

The semi-abstract instruction set

Mnemonics are the toolchain’s, not the CPU’s — a Plan 9 MOVQ is a toolchain operation the back end lowers to a real encoding (possibly not a move at all). “Machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract” (asm docs). When the assembler genuinely lacks an opcode the hardware has — “the assemblers are designed to support the compiler so not all hardware instructions are defined” — you can lay raw bytes with BYTE and WORD directives inside a TEXT, or extend the assembler. PCALIGN $32 pads with no-ops to align the next instruction (supported on arm64, amd64, ppc64, loong64, riscv64).

Configuration and Code Examples

A function annotated to exercise every pseudo-register:

#include "textflag.h"
 
// func sum3(a, b, c int64) int64
TEXT ·sum3(SB), NOSPLIT, $8-32
    MOVQ  a+0(FP),  AX      // FP slot 0: first argument (named — bare 0(FP) is rejected)
    MOVQ  b+8(FP),  BX      // FP slot 1: second argument
    ADDQ  BX,       AX      // left-to-right: AX = AX + BX
    MOVQ  c+16(FP), BX      // FP slot 2: third argument
    ADDQ  BX,       AX
    MOVQ  AX,       tmp-8(SP)   // pseudo-SP: spill into the 8-byte frame-local area
    MOVQ  tmp-8(SP), AX         // reload from the same named local
    MOVQ  AX,       ret+24(FP)  // FP slot 3: the return value
    RET

Line 4, TEXT ·sum3(SB), NOSPLIT, $8-32: ·sum3 is thispkg.sum3 (middle-dot package separator); (SB) anchors it to the static base; NOSPLIT skips the stack-split preamble; $8-32 is an 8-byte frame with 32 bytes of arguments (three int64 inputs + one int64 result). Lines 5–9 read arguments through FP with mandatory names and positive offsets 0, 8, 16. Line 10 writes tmp-8(SP) — the pseudo SP, a named, negative-offset reference into this frame’s 8-byte local area. Line 11 reads it back. Line 12 stores at ret+24(FP), the slot past the three inputs. The contrast to memorise: tmp-8(SP) (named → pseudo-register, frame-local) versus 8(SP) with no name (the hardware SP register).

Global data, showing SB and the file-local <> form:

GLOBL ·tableSize(SB), RODATA, $8       // package-global symbol, read-only
DATA  ·tableSize(SB)/8, $4096          // initialize its 8 bytes
 
GLOBL scratch<>(SB), NOPTR, $256       // file-local; GC need not scan it

·tableSize(SB) is a package-global symbol; RODATA puts it in the read-only section (which also implies NOPTR); the DATA line initialises its 8 bytes. scratch<>(SB) uses <> for file-local visibility and NOPTR to tell the garbage collector this 256-byte region “contains no pointers and therefore does not need to be scanned.”

Using Go’s type layout instead of magic numbers (go_asm.h, generated by go build):

#include "go_asm.h"
// given Go: type reader struct { buf [1024]byte; r int }
//   reader__size  — the struct's size
//   reader_r      — the offset of field r
    MOVQ  reader_r(R1), AX     // load reader.r when R1 holds *reader

The garbage collector needs pointer maps for assembly that has a non-trivial frame or makes calls. The funcdata.h pseudo-instructions cover it: NO_LOCAL_POINTERS asserts the local frame holds no pointers; GO_RESULTS_INITIALIZED records that result slots are now live (asm docs). The docs note “no assembly functions in the standard library use GO_RESULTS_INITIALIZED” — the easier path is to arrange that assembly functions neither return pointers nor make calls, in which case the pointer information may be omitted entirely.

Failure Modes and Common Misunderstandings

Pseudo-SP vs hardware SP. The single most common Plan 9 assembly bug. x-8(SP) (named) addresses a frame-local; -8(SP) or 8(SP) (unnamed) addresses through the hardware register — “different memory locations” per the docs. When the toolchain adjusts the frame they diverge. Rule of thumb: always name pseudo-SP references; reach for unnamed SP only when you specifically mean the machine register.

Expecting bare 0(FP) to work. Rejected by design — the assembler “enforces this convention.” Write name+0(FP); the name lets go vet/asmdecl cross-check against the Go signature.

Assuming MOV emits a move. Because of the semi-abstract instruction set, reasoning about exact encodings from .s text is unsound. Disassemble with go tool objdump to see reality.

Operand-order mistakes. Coming from Intel syntax, MOVQ AX, BX looks like “move AX into BX” — and in Plan 9 it is (left-to-right, destination last). The reliable rule is the Plan 9 one: source on the left, destination on the right, always, ignoring the vendor manual’s notation.

Constant-precedence surprises. Because the assembler uses Go’s precedence and 64-bit unsigned arithmetic, an inline mask like 3&1<<2 does not mean what a C programmer expects, and a “negative” literal is its unsigned bit pattern. Parenthesise mask expressions; do not assume C semantics.

FP is not a hardware frame pointer. Go 1.17+ does maintain a real frame pointer on amd64 (RBP) and arm64 (R29) for debuggers (abi-internal), but the pseudo-FP you write is a virtual construct for naming arguments — a distinct thing.

The register ABI changes what “argument” means. Since Go 1.17 the compiler passes arguments and results in registers under ABIInternal (register-ABI proposal; abi-internal). On amd64 the integer argument/result registers are RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11; floating-point use X0X14; RDX carries the closure context and R14 permanently holds the current goroutine pointer. On arm64 the sequence is R0R15 for integers, F0F15 for floats, with R28 the goroutine and R26 the closure context. Hand-written assembly still speaks the stack-based ABI0 — your FP-relative argument loads are correct for an ABI0 caller. The toolchain inserts ABI wrappers so register-ABI Go code can still call your ABI0 assembly: the wrapper spills the incoming register arguments to the stack slots your assembly expects. The author-facing consequence is small — keep using name+offset(FP) — but it explains why a function may need an <ABIInternal> annotation (·name<ABIInternal>(SB)) when it must be referenced at the register ABI directly, and why crypto/runtime assembly occasionally carries such annotations. The full coordination mechanism (the -gensymabis pass, the symabis file, wrapper generation) belongs to the tool and is covered in The Go Assembler.

Alternatives and When to Choose Them

The genuine alternative is not writing assembly. Plan 9 assembly is portable-syntax but still architecture-specific in content: a .s file for amd64 does not run on arm64, so every assembly function multiplies maintenance by the number of supported architectures. Compiler Intrinsics eliminate most reasons to reach for it — math/bits, sync/atomic, and crypto primitives are lowered to single instructions by the compiler. For SIMD, the avo library generates Plan 9 .s files from type-checked Go, far less brittle than hand assembly. Genuine reasons to write Plan 9 assembly directly: the runtime’s irreducible core (context switches, signal handlers, cgo trampolines), and constant-time cryptographic code where the exact instruction sequence is security-relevant.

Compared with native assemblers: GNU as and NASM are transcribers — one mnemonic, one encoding, one CPU, vendor-faithful notation. Plan 9 assembly is an abstraction — uniform syntax across CPUs, toolchain-defined mnemonics, deferred instruction selection, integrated with Go’s frame and garbage-collection model. You give up direct control of encodings; you gain a notation the rest of the Go toolchain understands natively (stack maps, pcln tables, the scheduler’s preemption points).

Production Notes

The Go standard library’s runtime, crypto/*, math, math/big, and internal/bytealg packages are the canonical Plan 9 assembly in production, each with per-architecture .s files selected by file-name build constraints (memmove_amd64.s, memmove_arm64.s). The single largest body is crypto. The original Plan 9 assembler manual (9p.io/sys/doc/asm.html) remains the closest thing to a philosophy spec for the dialect, though Go’s cmd/asm has diverged. Practical advice: always run go vet on assembly (its asmdecl analyzer catches FP-offset and frame-size errors); use go tool objdump and go build -gcflags=-S to inspect what was really emitted; and internalise the four abbreviations that matter — FP Frame Pointer, SB Static Base, SP Stack Pointer, PC Program Counter — because every Go assembly file begins by addressing through them.

See Also