How a Thread Call Stack Actually Works on iOS
Understanding the register-level mechanics of frame records, `bl`/`ret`, and the stack pointer removes the magic from crash logs and backtraces. When symbolication fails or a stack trace looks truncated, knowing that leaf functions omit frame records and that `x29` chains are compiler-optional explains the gaps.
Every function call on ARM64 allocates a stack frame by decrementing the stack pointer and, for non-leaf functions, writes a frame record containing the caller's FP and the return address. The `bl` instruction sets the link register (x30) to the next instruction before jumping, and `ret` uses that address to return. A debugger reconstructs the human-readable call stack by walking this linked list of frame records and mapping addresses back to Mach-O symbols.
Leaf functions that make no further calls can skip saving x30 and establishing a new frame record, which is why a stack trace may appear to skip frames. The compiler decides the exact layout, but the ARM64 calling convention mandates that x0–x7 carry integer arguments and return values, while x19–x28 must be preserved by the callee.
Stack overflow happens when recursive or deep call chains exhaust the per-thread stack space, causing a memory access fault. The runtime does not store function names; symbolication is a separate step that resolves raw addresses against the binary's symbol table.
Many developers treat the call stack as a given, but it is a convention enforced by the compiler and ABI, not a hardware feature. On ARM64, the frame pointer is optional, and a release build may omit frame records entirely, making backtracing rely on unwind info (CFI) instead.
The article's assembly walkthrough makes visible a subtle point: Swift's `Int` addition emits an overflow check (`adds` + `cset` + `tbnz`) that a C integer addition would not, adding a small but real cost to every arithmetic operation in debug builds.