Let's take a look at Linux. Other systems are similar.

; go tool objdump -s main.main a
TEXT main.main(SB) /private/tmp/a/a.go
a.go:9	0x400c10	64488b0c25f0ffffff	FS MOVQ FS:0xfffffff0, CX
a.go:9	0x400c19	483b21	CMPQ 0(CX), SP
a.go:9	0x400c1c	7707	JA 0x400c25
a.go:9	0x400c1e	e8ddf90100	CALL runtime.morestack00_noctxt(SB)
a.go:9	0x400c23	ebeb	JMP main.main(SB)
a.go:10	0x400c25	e8d6ffffff	CALL main.foo(SB)
a.go:11	0x400c2a	c3	RET
a.go:11	0x400c2b	0000	ADDL AL, 0(AX)
a.go:11	0x400c2d	0000	ADDL AL, 0(AX)
a.go:11	0x400c2f	00	?

On linux/amd64 we can use the Local Executables TLS access procedure. In particular, we use a negative offset from the FS segment register to get a TLS slot (our job is simpler because we are always the main executable).

MOVQ FS:0xfffffff0, CX

We make use of two TLS variables, g and m (soon we will only use one), a pointer to g is at -16(FS). We access it in this first instruction.

g is an instance of struct G, see go/src/pkg/runtime/runtime.h:/struct.G. It contains many things, but it starts like this:

struct	G
{
uintptr	stackguard0;
uintptr	stackbase;
...

In particular the first word (at offset zero) is the stackguard, which indicates the stack limit (it is also used for voluntary preemption, but that doesn't matter here).

This instruction in the stack check preamble:

CMPQ 0(CX), SP

Compares the current stack pointer with the stackguard. In most cases we have enough stack, so the next instruction just skips past the preamble to the real function code.

JA 0x400c25

When we don't have enough stack, we call a function in the runtime (one of the runtime.morestack functions). This function allocates a new stack segment (from the heap). Currently we use contiguous stacks, so if we have complete type information in the current stack we can just copy the old stack to the new stack segment fixing any pointers as dictated by the type information, and then we switch the stack pointer.

If we don't have enough type information (or in previous Go versions), we use segmented stacks. We allocate a new stack segment, but we don't copy the stack; we just switch the stack pointer and we take care to be able to do the reverse operation when we return from the function.

Take a look at the next instruction after the call to runtime.morestack.

JMP main.main(SB)

We just jump to the beggining of the function like nothing has happened. Then the algorithm repeats, but we won't fails the stack limit check again, so it will skip it. Why it jumps to the begining of the function instead of just continuing in the body of the function is left as an exercise to the reader.

We used the Local Executables TLS access model here, sometimes we have to use the Initial Executable model. If we ever allow Go programs to be loaded by C programs as dynamic objects, we would have to use more complicated models.

On ARM we just use a register instead of using any form of TLS. On most systems Go binaries set the FSbase register to some value on the heap, but when we use cgo, or on platforms that don't support static binaries we don't touch FSbase, as it was already set up by libc.

Functions that use little stack (under 120 bytes) can be excepted from this stack check.