skip to content

Why turn runtime.Callers' PCs into frames with runtime.CallersFrames instead of runtime.FuncForPC?

level: middleimportance: nice to knowfreq 24%

answer

  1. PCs are addresses, not names
  2. the compiler may have merged several calls into one
  3. a return address points just past the call
  4. expect more frames out than PCs in
  5. iterate Frames.Next until it says no more

basics

~20 s

runtime.Callers records return program counters, and one of those can stand for several inlined calls while pointing just past the call instruction. runtime.CallersFrames expands inlined frames and adjusts the PC back to the call site; runtime.FuncForPC does neither.

solid answer

~40 s

`runtime.Callers(skip, pc)` fills your `[]uintptr` with **return** program counters and returns how many it wrote. Two things make those numbers unsafe to symbolise one by one. First, the compiler inlines aggressively, so a single physical frame can represent several logical calls, and `runtime.FuncForPC` will report only the outermost one — you lose the function that actually did the work. Second, a return PC points at the instruction *after* the call, which can fall on the next source line or even in the next function. `runtime.CallersFrames(pc[:n])` handles both: it applies the minus-one adjustment and expands inlined calls, yielding one `runtime.Frame` per logical call as you iterate `Frames.Next` until it reports no more. Slicing `pc[:n]` matters too, otherwise you symbolise zero entries in the unused tail.

code

go · 14 lines
go
func callerFrames(max int) []runtime.Frame {
	pc := make([]uintptr, max)
	n := runtime.Callers(2, pc)
	frames := runtime.CallersFrames(pc[:n])
	var out []runtime.Frame
	for {
		frame, more := frames.Next()
		out = append(out, frame)
		if !more {
			break
		}
	}
	return out
}

go deeper

for a junior

Know that runtime.Callers gives you raw addresses rather than text, and that runtime.CallersFrames is the supported way to turn them into function, file and line.

for a middle

Explain both reasons the frames API exists: inlining means one PC can cover several logical calls, and a return PC needs adjusting back to the call site before symbolising.

for a senior

Demonstrate the practical use: capture a small PC slice cheaply on a hot path, store it, and symbolise lazily; trim your own helper frames with skip so users see their own code first.

for a principal

Decide what a shared diagnostics helper hands back — raw PCs, expanded frames, or formatted text — knowing that the choice fixes both the cost every caller pays and how much of your internals leak into their output.

## What Callers actually gives you ```go func Callers(skip int, pc []uintptr) int ``` `runtime.Callers` fills the caller-supplied slice with the **return program counters** of the function invocations on the calling goroutine's stack and returns the number of entries written. It is the low-level primitive underneath every logging package that prints a call site, every custom error type that captures a stack, and every hand-rolled tracing helper. The `skip` argument is the number of frames to skip before recording: **0 identifies the frame for `runtime.Callers` itself**, 1 identifies its caller. So a helper that wants to record its own caller's stack passes 2 — one for `Callers`, one for the helper. A trap worth memorising: `runtime.Caller` (singular) numbers `skip` differently. There, 0 identifies the caller of `Caller`. Porting code between the two functions off by one is a classic mistake, and the symptom is a log line that blames the logging package for every message. ## Why raw PCs are not names A `uintptr` is an address. Turning it into a function name, file and line means consulting the tables the compiler emitted into the binary. `runtime.FuncForPC(pc)` does that lookup and returns a `*runtime.Func`, whose `Name()` gives a function name. It works, and for a long time it was what everybody wrote. It is also wrong in two ways that the documentation now calls out explicitly. **Inlining.** The Go compiler inlines small functions into their callers. When it does, the machine code has one physical frame where the source had two or three nested calls. There is only one PC to record, and `FuncForPC` maps it to the single function that owns that code — the outermost one. The inlined callee, which is very likely the function you cared about, disappears from your trace. The compiler emits enough metadata to reconstruct those logical frames, but you have to ask for it. **Return-PC skew.** The value recorded is the address the call will *return to*, which is the instruction after the call. If the call is the last instruction attributable to a source line, that address may map to the following line; if the call is the last thing a function does, it may map past the end of the function altogether. Symbolising the raw value therefore reports a line number one off, or the wrong function entirely. The fix is to symbolise `pc-1` — an address inside the call instruction — which is exactly what the frames API does for you. ## The frames API ```go frames := runtime.CallersFrames(pc[:n]) for { frame, more := frames.Next() // frame.Function, frame.File, frame.Line if !more { break } } ``` `runtime.CallersFrames` takes the PC slice and returns a `*runtime.Frames` iterator. Each call to `Frames.Next` yields a `runtime.Frame` — with `Function`, `File`, `Line`, `PC` and `Entry` fields — plus a boolean saying whether more frames may follow. Because inlined calls are expanded, the iterator can yield **more frames than there were PCs**, which is the whole point and also why the API is an iterator rather than a slice-in, slice-out function. Two details of the loop matter. Pass `pc[:n]`, not `pc`: the tail of the slice beyond `n` holds zeros, and symbolising a zero address produces junk frames. And drive the loop on the `more` result rather than on a count, because the number of logical frames is not known in advance. ## When you would reach for this at all Most diagnostic needs are served by `debug.Stack()`, which hands you formatted text. `runtime.Callers` is what you use when you need the trace as **data**: to attach structured call-site fields to a log record, to store a compact `[]uintptr` inside an error value and symbolise it only if the error is ever printed, to deduplicate identical stacks by hashing the PC slice, or to trim frames belonging to your own helper before showing anything to a user. That last case is the everyday one: capturing PCs is cheap and formatting is not, so recording a small fixed-size array of PCs and symbolising lazily is a real optimisation in code that runs on a hot path. One more consequence of inlining is worth knowing when the numbers do not add up: a stack captured while a deeply inlined chain is executing can appear shorter in PCs than the source suggests, and only the frames API restores the shape a reader expects.

  • What does skip mean in runtime.Callers, and why is 2 the usual value in a helper?
    skip counts frames to omit before recording, with 0 being `runtime.Callers`' own frame and 1 its caller. A helper that captures its caller's stack therefore passes 2: one to drop `Callers`, one to drop the helper itself, so the recorded trace starts at the code the user actually wrote.
  • Why must you pass pc[:n] rather than the whole slice to runtime.CallersFrames?
    Because `Callers` reports how many entries it filled, and everything past that is still zero. Feeding those zeros to the frames iterator produces meaningless entries at the bottom of every trace. Slicing to the returned count is not optional tidiness — it is the contract.
  • How does runtime.Caller number its skip argument compared with runtime.Callers?
    Differently, by one. For `runtime.Caller`, skip 0 is the caller of `Caller`; for `runtime.Callers`, skip 0 is the frame of `Callers` itself. Code moved between the two without adjusting the number reports the wrong call site, usually blaming the diagnostic helper.

Inlining is the compiler photocopying a small function into its caller, so one address can stand for a stack of nested calls. CallersFrames unfolds the photocopy; symbolising the address directly only ever names the page it was copied onto.

saying these in an interview costs you the question

  • Believing FuncForPC on each PC gives the same answer
  • Assuming inlining leaves the frame list unchanged
  • Treating the recorded PC as the address of the call itself
  • Symbolising the whole slice instead of pc[:n]
  • Assuming Caller and Callers number skip identically