ABI, Assembly, and go:nosplit

Updated

September 8, 2026

ABI, Assembly, and go:nosplit

Overview

Most Go code never touches assembly. When you do (crypto, math, codecs, runtime), you meet ABIInternal, register conventions, and stack growth rules.

Diagram: ABI layers

diagram:
  GoFunc[Go function] --> ABIInt[ABIInternal]
  Asm[assembly TEXT] --> ABI0[ABI0 / NOSPLIT]
  Boundary[boundary] --> Transition[ABI transition]

ABIs (High Level)

ABI Role
ABI0 Stable-ish assembly convention used historically
ABIInternal Compiler’s internal register ABI for Go functions

The compiler transitions between them at certain boundaries. Assembly files use special headers:

// func Add(a, b int) int
TEXT ·Add(SB), NOSPLIT, $0-24
    // ...

go:nosplit

Prevents stack growth checks in a frame. Required for some runtime entry paths; dangerous in app code if the frame is large.

//go:nosplit
func tiny() { }

go:uintptrescapes / go:uintptrkeepalive

Document pointer lifetime when smuggling pointers as uintptr through syscalls — get this wrong and GC frees live memory.

SIMD Experiments

Go 1.26 introduced experimental simd/archsimd. Go 1.27 adds a portable simd package (vector-size-agnostic types such as Int8s / Float32s) and extends archsimd to arm64 Neon and Wasm SIMD. Both still require GOEXPERIMENT=simd and are not production-stable. Prefer them over hand asm for new vector experiments.

When To Write Asm

  1. Profile proves a hot leaf
  2. Algorithm is fixed-width crypto/math
  3. You can maintain per-GOARCH files
  4. You accept review cost

Otherwise prefer pure Go + compiler autovectorization where it exists, or cgo to a maintained C library (with eyes open).

Plan 9 listing and go tool objdump

The compiler emits Plan 9-style pseudo-assembly (SB = static base, SP = stack) and the Go linker writes the final binary. That is why you can inspect any GOARCH on any host without a cross-GDB.

# Compiler IR (stderr):
go build -gcflags='-S' . 2>&1 | head -80

# Real machine code, still printed in Plan 9 register names:
CGO_ENABLED=0 go build -o hello .
go tool objdump -s main.main hello

# Cross-arch: build ARM64, disassemble it on amd64/arm64/darwin alike:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o hello-arm64 .
go tool objdump -s main.main hello-arm64

Calls in that listing point at Go symbols compiled into the binary (fmt.Fprintln, runtime.morestack) — not at printf or libc.so. That is the same “no libc in the middle” fact as Go as a Systems Language. On ARM64, R28 holds the current goroutine (g).

Experiment

go build -gcflags='-S' . 2>&1 | head -80
package main

func add(a, b int) int { return a + b }

func main() {
    println(add(2, 3))
}

What to notice: Even simple functions show ABI prologue/epilogue; inlining may erase add entirely with optimizations.

Try next: Compare -gcflags='-l -S' (no inline) vs default for the same function. Then go tool objdump -s main.main on a GOARCH=arm64 binary built from this machine.