2.2 Stack Frames and Symbols in Assembly
The atomic operation Cas in the runtime is implemented on amd64 by an assembly function whose
first line is:
| |
To read this line and the code after it, you first need to know what happens on the stack during a
function call. This section first draws the stack layout from a real disassembly, then uses it to
read Cas, and finally reads two pieces of runtime code that write the stack pointer and the
program counter directly.
2.2.1 Stack Frame Layout
The amd64 CALL instruction does two things: it pushes the return address (the address of the
instruction after CALL) onto the stack, decrementing SP by 8, and then jumps to the target
function. RET does the reverse: it pops the return address off the stack and jumps back. Once the
callee starts, it usually pushes the old value of the BP register and then lowers SP to reserve
space for local variables. Go enables a frame pointer on amd64: BP always points to a fixed
position in the current function’s frame, and the saved BP values in successive frames form a linked
list. Debuggers and profilers follow this list to walk the call chain frame by frame.
The region that one function call occupies on the stack is called a stack frame. The calling
convention that hand-written assembly follows (see 2.3) passes arguments and
results on the stack: before executing CALL, the caller writes them at the bottom of its own
frame, next to the return address. The assembly function below has two arguments, one result and 24
bytes of local variables:
| |
$24-24 declares the frame size: before the hyphen is the number of bytes of local variables, and
after it the combined size of arguments and results (a, b and ret take 8 bytes each). The
hyphen is only a separator and does not denote subtraction
[Go Authors, 2026a].
Cross-compiled on darwin/arm64 for linux/amd64 and disassembled with go tool objdump, the result
is:
PUSHQ BP // inserted by the assembler: save the old BP
MOVQ SP, BP
SUBQ $0x18, SP // inserted by the assembler: reserve 24 bytes for locals
MOVQ 0x28(SP), AX // a+0(FP)
MOVQ 0x30(SP), BX // b+8(FP)
MOVQ AX, 0x10(SP) // x-8(SP)
MOVQ BX, 0(SP) // 0(SP)
ADDQ BX, AX
MOVQ AX, 0x38(SP) // ret+16(FP)
ADDQ $0x18, SP // inserted by the assembler: release the locals
POPQ BP
RET
The first three and last two lines, absent from the source, were inserted by the assembler. Converting each notation to a position relative to the hardware SP gives the following stack layout:
| Position | Contents | Notation in assembly |
|---|---|---|
SP+0x38 | result | ret+16(FP) |
SP+0x30 | argument b | b+8(FP) |
SP+0x28 | argument a | a+0(FP); the pseudo-register FP points here |
SP+0x20 | return address | pushed by CALL |
SP+0x18 | old value of BP | the pseudo-register SP points here |
SP+0x10 | local variable x | x-8(SP) |
SP+0x08 | local variable | |
SP+0x00 | local variable | 0(SP); the hardware SP points here |
The top three rows of the table lie in the caller’s frame and the bottom five belong to this
function’s frame. x-8(SP) and 0(SP) look as if they differ only by a name, but they are 16 bytes
apart; the pseudo-register SP sits 24 bytes above the hardware SP, exactly the space taken by the
local variables.
This function has no stack check at its start. It calls no other function and its locals take fewer than 128 bytes, so the assembler concludes that the stack it uses cannot exceed the reserved margin and omits the check. What the check does and where the margin comes from are covered in 2.4.
2.2.2 Reading Cas
With this layout in hand, Cas is not hard to follow. In Go it is only declared; its body lives in
an assembly file:
| |
| |
The details, item by item:
TEXT ·Cas(SB)declares the functionCasin the current package, that is,internal/runtime/atomic.Cas.NOSPLITtells the assembler not to insert the stack check at the start of the function. Such functions may use very little stack, and the linker checks the call chains formed by them to ensure their combined stack use stays within the margin.$0-17: no local variables; the arguments and result total 17 bytes: 8 for the pointer, 4 for eachuint32, and 1 for theboolresult. Offsets 0, 8, 12 and 16 are exactly where they sit, packed one after another. Theasmdeclchecker ofgo vetcomputes these offsets from the Go declaration and checks them one by one against the assembly [Go Authors, 2026c].MOVQmoves 8 bytes andMOVLmoves 4, matching the sizes of the arguments.- The
LOCKprefix applies to the next instruction. It guarantees that the comparison and the write ofCMPXCHGLcomplete as one atomic operation on a multicore machine, so no other core observes an intermediate state [Intel, n.d.].
On arm64, Cas lives in atomic_arm64.s. Its declaration is likewise $0-17, and the arguments
are fetched the same way, as ptr+0(FP) and old+8(FP); only the instructions in between are arm64
instructions. What the architectures share is the function declaration, the frame size and the
notation for arguments; the instructions themselves differ.
2.2.3 SP and PC as Data: gogo and morestack
When a goroutine pauses, the runtime saves the values of its SP, PC and other registers in a small
struct named gobuf; resuming the goroutine means loading these values back into the registers.
This is exactly what gogo does (excerpt from runtime/asm_amd64.s
[Go Authors, 2026b]):
| |
One MOVQ replaces the stack, and one JMP replaces the next instruction to execute. From this
moment the processor runs another goroutine’s code on another stack. These two steps cannot be
expressed in the Go language, so gogo can only be implemented in assembly.
morestack is called when a goroutine’s stack is about to run out (2.4). It must first
record the function that called it (call it f) and its execution position, so that it can return
there and continue once a larger stack is in place:
| |
Here 0(SP) and 8(SP) carry no name and refer to the hardware SP. NOFRAME means morestack
sets up no frame of its own, so on entry SP points exactly at the return address just pushed by
CALL, which can be read directly. The original stack is nearly exhausted and morestack cannot
keep running on it, so it switches to the current thread’s g0 stack (3.5)
and then calls newstack, which is implemented in Go.
Most of the assembly in the chapters on scheduling
(9.4), signals
(9.6) and stacks
(Chapter 14) takes one of these two forms: it accesses
arguments through FP, or it reads and writes SP and PC as ordinary data. In GDB you can
disassemble against the source and inspect each goroutine’s call stack, reading this assembly side
by side with the runtime’s live state [Go Authors, 2026d].
Further Reading
- [Go Authors, 2026a] The Go Authors. “A Quick Guide to Go’s
Assembler.” https://go.dev/doc/asm The official description of
TEXT, frame sizes, flags such asNOSPLIT, and the pseudo-registers. - [Go Authors, 2026b] The Go Authors.
runtime/asm_amd64.s,internal/runtime/atomic/atomic_amd64.s,runtime/textflag.h. https://github.com/golang/go/blob/release-branch.go1.26/src/runtime/asm_amd64.s https://github.com/golang/go/tree/master/src/runtime The source ofgogo,morestackandCasin this section;textflag.hlists all flags, includingNOSPLIT. - [Go Authors, 2026c] The Go Authors. The
asmdeclchecker. https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/asmdecl With it,go vetchecks that argument names and offsets in assembly agree with the Go declaration. - [Intel, n.d.] Intel. Intel 64 and IA-32 Architectures Software
Developer’s Manual, Volume 2, “CMPXCHG” and “LOCK”.
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html The original
description of
LOCK CMPXCHG. - [Go Authors, 2026d] The Go Authors. Debugging Go Code with GDB. https://go.dev/doc/gdb (Debugging assembly against the runtime’s live state)
- This book: 2.1 The Plan 9 Assembly Language, 2.3 Calling Convention and the Register ABI (the custom ABI), 6.1 Function Calls, 9.4 The Scheduling Loop (stack switching in gogo/mcall), 14.3 Stack Growth (morestack and stack growth).