Hurdle 2: The Register-Based Calling Convention

Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-ebpf-primer/viewer/45-hurdle2_abi.

Hurdle 1 got the hooks in place. The next problem is where a function’s arguments sit at the moment a hook fires.

The symptom contrasts with Hurdle 1. Point a generic eBPF tool that assumes stack-based argument passing at a Go 1.17 or later binary and read the arguments. Nothing crashes, nothing reports an error, and the values that come back are nonsense. The wrong values look plausible enough to pass.

What is an ABI?

An ABI (Application Binary Interface) is the set of low-level agreements around a function call. It fixes where the arguments go, where the return value goes, which registers each side must preserve, and which registers it may overwrite. Chapter 2 introduced the registers, and the ABI is what gives them their roles. The same Go source compiles to machine code that passes values according to these agreements.

The Go 1.17 shift

Before Go 1.17, Go pushed every argument onto the stack. That was easy to read from outside. At the function’s entry, a fixed offset from the stack pointer told you which argument you were looking at.

From Go 1.17 on, Go passes arguments in CPU registers for better performance1. The convention that Go uses internally has the name ABIInternal. The name itself dates to Go 1.12, where it distinguished that convention from ABI0, the stable one for calls out of assembly. What changed in Go 1.17 is that the contents of ABIInternal moved from stack-based passing to register-based passing. ABI0 is still there, and automatically generated ABI wrappers sit between it and functions written in assembly. On amd64, the compiler assigns integer arguments to registers in the following order.

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

Only integer and pointer arguments ride in this sequence. Arguments past the ninth go on the stack, and so do large structs that do not fit in registers. Floating-point arguments travel in the floating-point registers that Chapter 2 mentioned. This book follows only the integer sequence, because the arguments that OBI reads are pointers and integers alone. It instruments functions in net/http and gRPC, which take pointers to structs, strings, and integers. Not one of the OBI macros that I quote later reads a floating-point register. Chapter 2 explained why both RAX and AX appear in the notation.

The change made Go function calls faster. It also means that anyone who reads arguments from outside must know which argument lands in which register, for each architecture. A generic eBPF tool assumes the C calling convention, the standard on Linux. C also passes arguments in registers, but it picks different ones. C uses RDI, RSI, RDX, RCX, R8, R9, so it reads RDI for the first argument. What Go puts there is the value passed in its fourth register. Not empty, not zero: another argument’s value. Which argument lands there depends on the argument types. ABIInternal breaks each argument into its basic components before it assigns registers, so a value such as an int takes one register, a string and an interface take two, and a slice takes three. In a function whose arguments are all integers, RDI holds the fourth argument; in f(a string, b, c int), it holds the third one, c. The OBI macro GO_PARAM4 that I quote later also names the fourth register, not the fourth argument.

What makes this awkward is that getting it wrong produces nothing. The tool runs and returns a value. No error, no crash. Only the contents are wrong. The uretprobe in Hurdle 1 crashed the application, which made it noticeable. This one you cannot notice.

The Go 1.17 shift in the calling convention Figure 1: The arrows run from the argument’s storage location to the external reader, showing what that reader has to go on. Argument passing moved from the stack to registers. Calls became faster, while external readers now need a register mapping table for each architecture.

Arguments in registers

The function below takes three arguments, and the compiled result shows where they go. I add //go:noinline because an inlined function loses the machine code of the call, and with it any chance to watch the arguments move.

package main

import "fmt"

//go:noinline
func Add3(a, b, c int) int {
	return a + b + c
}

func main() {
	fmt.Println(Add3(1, 2, 3))
}

Print the assembly with go build -gcflags=-S, and the body of the function comes to three instructions.

$ go build -gcflags=-S -o /dev/null s3_abi.go
main.Add3 STEXT nosplit size=9 args=0x18 locals=0x0 funcid=0x0 align=0x0
	TEXT	main.Add3(SB), NOSPLIT|NOFRAME|ABIInternal, $0-24
	LEAQ	(BX)(AX*1), DX
	LEAQ	(CX)(DX*1), AX
	RET

The real output also carries lines that start with FUNCDATA and PCDATA, which hold auxiliary information for the GC and other machinery. I have cut them here. The three lines in the middle are the ones to read. TEXT on the first line declares the function, and (SB) and NOSPLIT note the conditions of its generation, so you can skip them. ABIInternal is the one mark on that line that concerns this book.

a arrives in AX, b in BX, and c in CX, and the function returns the result in AX. LEAQ computes an address, as its name says, but (BX)(AX*1) means BX plus AX times 1, so here the instruction serves as a way to add. The whole function is nine bytes, and it never reserves a single byte of a frame of its own (the closing RET still reads the return address off the stack, as Chapter 5 showed). Before Go 1.17, the same function read its arguments from the stack and wrote its result back to the stack.

To find the arguments from outside, you need this register mapping table.

The code where OBI reads the registers

In OBI, that mapping table takes the form of macro definitions in bpf/bpfcore/utils.h.

#if defined(__TARGET_ARCH_x86)

#define GO_PARAM1(x) ((void *)(x)->ax)
#define GO_PARAM2(x) ((void *)(x)->bx)
#define GO_PARAM3(x) ((void *)(x)->cx)
#define GO_PARAM4(x) ((void *)(x)->di)
#define GO_PARAM5(x) ((void *)(x)->si)
#define GO_PARAM6(x) ((void *)(x)->r8)
#define GO_PARAM7(x) ((void *)(x)->r9)
#define GO_PARAM8(x) ((void *)(x)->r10)
#define GO_PARAM9(x) ((void *)(x)->r11)

// In x86, current goroutine is pointed by r14, according to
// https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md#amd64-architecture
#define GOROUTINE_PTR(x) ((void *)(x)->r14)

#elif defined(__TARGET_ARCH_arm64)
// the arm64 variant is folded away below
#endif
The arm64 macros
#define GO_PARAM1(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[0])
#define GO_PARAM2(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[1])
#define GO_PARAM3(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[2])
#define GO_PARAM4(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[3])
#define GO_PARAM5(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[4])
#define GO_PARAM6(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[5])
#define GO_PARAM7(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[6])
#define GO_PARAM8(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[7])
#define GO_PARAM9(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[8])

// In arm64, current goroutine is pointed by R28 according to
// https://github.com/golang/go/blob/master/src/cmd/compile/abi-internal.md#arm64-architecture
#define GOROUTINE_PTR(x) ((void *)((PT_REGS_ARM64 *)(x))->regs[28])

The sequence ax, bx, cx, di, si, r8, r9, r10, r11 maps one to one onto the register sequence above, RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11. x is the copy of the CPU registers from the moment the uprobe fired, and each macro takes one register out of that copy. No public Go API takes part. OBI reads the registers as they stood at the instant the process stopped.

How to identify the current goroutine

The identity of the goroutine that runs right now matters more than the arguments. In a distributed trace, one request runs through several functions, and OBI has to tie those calls together as work on the same goroutine.

As Chapter 6 showed, the Go runtime has structs named g, m, and p. This book needs only g, the runtime-internal struct that represents one goroutine. At any moment, the Go code running on a CPU belongs to the g struct of the goroutine that runs now.

The Go runtime keeps a pointer to this g struct resident in a dedicated register.

ArchitectureRegister holding the g pointer
amd64 (x86_64)R14
arm64R28

That register is exactly the one that the GOROUTINE_PTR macro reads. The CMPQ SP, 0x10(R14) from Hurdle 1 went through the same R14: it took byte 16 of the g struct, stackguard0, and checked how much stack was left. R14 turns up all over the machine code of a program written in Go.

OBI makes a choice here that you would not predict. The g struct holds goid, a serial number for the goroutine, and reading it would give an identifier. OBI does not read it. Search the whole repository and goid never appears as a goroutine identifier2.

Instead, OBI uses the address of the g struct itself. The key pairs the pointer that GOROUTINE_PTR returns with the process ID.

typedef struct go_addr_key {
    u64 pid;  // PID of the process
    u64 addr; // Address of the goroutine
} go_addr_key_t;

The value that goes into addr is the value of GOROUTINE_PTR(ctx) itself.

OBI has to decide only whether the goroutine that runs now is the one that it saw before. It does not need a number that a person can read. The address settles that comparison, and it lets OBI read nothing at all inside the struct.

The choice pays off against the next hurdle. To read goid, OBI would need to know how many bytes into g the field sits, and that number can change with the version of Go. An address needs no field offset, so nothing has to track those changes. OBI’s offset table has no entry for runtime.g at all.

What OBI reads when a uprobe fires Figure 2: Solid arrows show the flow of data; the dashed line with the circle marks what OBI does not read. OBI uses the value of R14 (R28 on arm64) directly as the goroutine identifier, and it never reads the contents of the g struct behind that pointer. A change in the field layout between versions cannot break it.

The moving stack and the stationary g struct

Something moves and something stays put, so let me separate the two. What Hurdle 1 called moving is the goroutine’s stack. Every time it grows, the runtime copies it to a new region and its address changes.

What can serve as an identifier is the address of the g struct. The runtime allocates it on the heap, and it stays put as long as the goroutine lives. g holds the bounds of the stack region it uses in stack.lo and stack.hi, and those two fields are what change when the stack moves. The stack that g points at moves. g itself does not.

The g struct that stays put and the stack that moves Figure 3: The arrows represent references. The address of the g struct itself does not change, and only the destinations of stack.lo and stack.hi change. OBI uses the address that does not change as its identifier.

The address of the g struct is not on the stack, so a relocation leaves it unchanged. That is what makes it safe to use as a key.

An address as an identifier does leave OBI to look after what happens once the goroutine ends. The timeline below shows the benefit and the price together.

Until a g address is reused Figure 4: The arrows represent time order. While processing continues, the same address means the same goroutine; after it ends, the address goes to a different goroutine. The dashed part shows what happens when stale records stay behind. This cleanup appears as real code in Hurdle 4.

Go application code has no official way to identify the current goroutine. One known trick pulls goid out of the string that runtime.Stack returns. The Go team keeps goid unexposed on purpose, and its design says to carry goroutine-local values in a context.Context. OBI watches from outside and uses an address, which sits comfortably with that policy.

Hurdle 2 is that Go keeps its values in registers, and deeper still inside runtime structs, rather than at obvious places in memory. To read them correctly, you need knowledge that differs by architecture and by version. OBI does two things: it consults a register mapping table per architecture to read the arguments, and it uses the address of g as it stands to identify a goroutine.

For g, the decision not to read the fields sidesteps the problem. Other structs give OBI no such escape.


  1. Only amd64 switched to register-based argument passing in Go 1.17. arm64 followed in Go 1.18 and riscv64 in Go 1.19, one architecture at a time. This book assumes amd64 throughout. ↩︎

  2. The only hit is a partial match on sched_goidle, an unrelated name in an auto-generated kernel header. ↩︎