How a Thread Call Stack Actually Works on iOS
Beginning
Developers who frequently deal with crashes are no strangers to call stacks. For example, the following code in ViewController of an empty Swift project causes a crash:
override func viewDidLoad() {
super.viewDidLoad()
let arrayA = [1...10]
print(arrayA[10])
}
When execution reaches this point, the project crashes, and we get a call stack like this:
At a glance, you can see which method caused the crash. This might be taken for granted during local development, but when your code has been compiled into a pile of binaries in production, restoring this call stack to locate the problem is no longer a simple matter.
This article explores this issue.
What is a Call Stack?
There are several concepts here: thread stack, stack frame, and call stack.
The thread stack and the call stack can be understood as "two perspectives on the same thing."
A thread stack is a real range of memory addresses.
Each thread has its own stack space, used to manage the stack frames and some local data generated by function calls.
The portion occupied by a single function call within the current thread stack is called a stack frame.
And the call stack emphasizes the sequence of function calls that have not yet returned. Each time a function is called, a stack frame is added; when the function returns, the corresponding stack frame disappears.
It's actually like a train: the thread stack is the train, and stack frames are like individual carriages. Each time a function call is added, a carriage is attached.
Thread stack of the main thread
└── main()
└── viewDidLoad()
└── startCrashFlow()
└── loadData()
└── triggerCrash() ← Current execution position / stack top
After understanding this structure, let's look at what exactly happens during a function call. But before that, we need to understand some register concepts.
Registers
Registers are a small set of high-speed storage locations inside the CPU. When executing machine instructions, the CPU uses them to store:
- Which instruction is currently being executed
- Where the current thread stack is
- Where to return after a function call
- Function parameters and return values
- Integers, addresses, or intermediate results involved in calculations
- Low-level information about the most recent CPU exception
Each thread has its own set of "logical register states."
What does that mean?
When the operating system switches the CPU from thread A to thread B, it saves A's registers and restores B's registers. This process is called a context switch.
Registers can be divided into two categories: basic and exception.
Under ARM64, there are 31 64-bit general-purpose registers:
x0~x7: Integer/pointer parameters and return values; caller-savedx8: Indirect result for large return valuesx9~x15: Temporary registers; caller-savedx16, x17:IP0/IP1, linker trampolines and temporary calculationsx18: Reserved on Apple platforms, should not be usedx19~x28: Registers that the called function must restorex29 / fp: Frame Pointerx30 / lr: Link Register, function return address
Additionally, there are some special registers:
sp: Stack Pointer, the current top of the thread's stackpc: Program Counter, the address of the currently executing instructioncpsr / nzcv: CPU status and condition flags
In an exception state, it may also include:
far: The address where a memory access error occurredesr: Exception syndrome register (reason encoding)
fp
FP is the Frame Pointer, corresponding to x29 in ARM64.
After a function is called, it establishes its own stack frame on the current thread stack. FP usually points to the frame record within the current stack frame. The frame record stores:
- The FP of the previous function
- The address to return to the previous function
So it can form:
Current FP
-> Previous FP
-> The FP before that
-> ...
This is an important path for unwinding the call stack.
lr
LR is the Link Register, corresponding to x30.
When ARM64 executes a branch-with-link instruction, it places the return address into LR:
Function A calls Function B
↓
LR stores the address in A to return to after B finishes executing
sp
SP is the Stack Pointer.
It points to the active position of the current thread's stack.
Each thread has its own stack, and therefore its own SP.
pc
Program Counter.
Indicates the address of the instruction the thread is currently executing.
After knowing this, let's look at function calls.
Function Calls
Consider the following code:
@inline(never)
func add(_ a: Int, _ b: Int) -> Int {
let result = a + b
return result
}
let total = add(10, 20)
Using the compiler to generate assembly code, we get (partially omitted):
_main:
sub sp, sp, #64
stp x29, x30, [sp, #48]
add x29, sp, #48
mov w8, #10
mov x0, x8
mov w8, #20
mov x1, x8
bl _$s4main3addyS2i_SitF
adrp x8, _$s4main5totalSivp@PAGE
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
; The assembly corresponding to print(total) is not part of this section, omitted
ldp x29, x30, [sp, #48]
add sp, sp, #64
ret
Then the add function:
_$s4main3addyS2i_SitF: // add
sub sp, sp, #32
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
str x0, [sp, #24]
str x1, [sp, #16]
adds x8, x0, x1
str x8, [sp]
cset w8, vs
tbnz w8, #0, LBB1_2
b LBB1_1
LBB1_1:
ldr x0, [sp]
str x0, [sp, #8]
add sp, sp, #32
ret
LBB1_2:
brk #0x1
Some Concepts in Assembly
First, registers, as mentioned before, but here the xzr register also appears. It is actually the Zero Register; reading it always yields zero.
There is also the w8 register, which is actually the lower 32 bits of x8. Because ARM64 registers are 64-bit, x8 is the full 64 bits.
Executing:
mov w8, #10
writes 10 into w8 and clears the upper 32 bits of x8, so ultimately:
x8 = 10
Immediate Values
The # in assembly represents a constant written directly in the instruction:
#64
#32
#10
#20
For example:
sub sp, sp, #64
means:
sp = sp - 64
Memory Address Notation
[sp, #24]
represents the address pointed to by sp, offset by 24 bytes towards higher addresses.
For example:
str x0, [sp, #24]
means writing the value of x0 to the memory address sp + 24.
Instructions
Meanings of some common instructions:
| Instruction | Meaning |
|---|---|
| mov | Copy a value into a register |
| sub | Perform subtraction |
| add | Perform addition, does not update status flags |
| adds | Perform addition, also updates CPU status flags |
| str | Store Register, write a register to memory |
| ldr | Load Register, read from memory into a register |
| stp | Store Pair, save two registers at once |
| ldp | Load Pair, restore two registers at once |
| bl | Branch with Link, call a function |
| ret | Return to the caller |
| adrp | Get the base address of the memory page containing a symbol |
| cset | Set a register to 0 or 1 based on CPU status |
| tbnz | Test a bit, branch if not zero |
| b | Unconditional branch |
| brk | Trigger a processor breakpoint exception |
In this example, the most important ones are:
bl
ret
sub sp
add sp
Calling a function, returning to the caller, subtracting, and adding—this process embodies function calls and returns.
Let's analyze sentence by sentence, starting with _main.
_main
Swift top-level code is compiled into the _main function of the Mach-O.
_main:
sub sp, sp, #64
stp x29, x30, [sp, #48]
add x29, sp, #48
mov w8, #10
mov x0, x8
mov w8, #20
mov x1, x8
bl _$s4main3addyS2i_SitF
adrp x8, _$s4main5totalSivp@PAGE
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
; The assembly corresponding to print(total) is not part of this section, omitted
ldp x29, x30, [sp, #48]
add sp, sp, #64
ret
_main can be divided into several parts:
- Establish the stack frame
- Prepare the first parameter
- Prepare the second parameter
- Call
add - Receive the return value
1. Establish the Stack Frame
sub sp, sp, #64
new sp = old sp - 64
Decreasing sp by 64 allocates 64 bytes of stack space for main, which is the stack frame space used by the current main.
Why is stack space needed?
Because main needs to save:
- The original
x29 / fp - The original
x30 / lr - Local variables
- Temporary data used when calling other functions
x29 is fp, the frame pointer, and x30 is lr, the address to which main should ultimately return.
Next:
stp x29, x30, [sp, #48]
Saves the old x29 and x30 from when main was entered to:
sp + 48 = S0 - 16
sp + 56 = S0 - 8
Resulting in:
S0 - 16: old x29
S0 - 8 : old x30
Because:
- The old
x29points to the caller's frame record, which is needed later to go back up a level. - The old
x30ismain's own return address. - If
mainexecutes a newbllater, it will overwritex30, so it must be saved in advance.
Then:
add x29, sp, #48
After execution: x29 = S0 - 16, meaning x29 points to the location where the old x29 was just saved.
At this point:
[x29] = caller's x29
[x29 + 8] = main's return address
These two consecutive 64-bit values are called the frame record:
x29
│
▼
┌──────────────────────────┐
│ caller's x29 │ ← [x29]
├──────────────────────────┤
│ current function's │
│ return address (x30) │ ← [x29 + 8]
└──────────────────────────┘
It cannot point to sp here, because sp may continue to change during function execution, whereas x29 generally remains fixed once established.
Then, the first value of each frame record is the caller's x29:
current x29
→ [current x29]
→ caller's x29
→ [caller's x29]
→ the x29 of the level above
This forms a linked list that can be traced upwards.
Then, as long as x29 is known, you can obtain through fixed positions:
[x29] → caller's frame record
[x29 + 8] → current function's return address
This is a fundamental way for a debugger to reconstruct the call chain.
The ARM64 convention requires the frame record to contain the "previous FP" and the "LR upon entering the current function." The current FP points to this frame record, but the position of the frame record within the entire stack frame is decided by the compiler.
Why is the offset 48 here?
Entire stack frame: 64 bytes
frame record: 16 bytes
64 - 16 = 48
Here, the compiler placed the 16-byte frame record at the high-address end of the stack frame, leaving the lower 48 bytes for other data.
2. Prepare Parameters
mov w8, #10
mov x0, x8
Ultimately results in x0 = 10.
The calling convention specifies that the first ordinary integer parameter uses x0, so x0 is used here.
This corresponds to a = 10.
Then:
mov w8, #20
mov x1, x8
Results in: x1 = 20, corresponding to b = 20.
The transfer through w8/x8 here is because I used the -Onone compilation mode, and the compiler chose this method when generating code; it is not a requirement to use a transfer.
After this processing, the state before the call is:
x0 = 10
x1 = 20
3. Call add
bl _$s4main3addyS2i_SitF
This symbol, after demangling, is:
main.add(Swift.Int, Swift.Int) -> Swift.Int
bl does two things:
x30 = address of the instruction after the bl
pc = entry address of add
After add finishes executing, it needs to know where in main to continue, so the address of the next instruction must be written to x30.
It's important to note here:
ARM64's
bldoes not automatically establish a complete stack frame, nor does it automatically push everything onto the stack.
It is only responsible for jumping and recording the return address. How much stack space a function needs is determined by the function's own assembly.
4. Receive and Save the Return Value
After add returns: x0 = 30.
Then:
adrp x8, _$s4main5totalSivp@PAGE
Gets the base address of the memory page containing the top-level variable total.
Machine instructions ultimately need memory addresses; the compiler must first find the address of total before it can write to it.
Next:
str x0, [x8, _$s4main5totalSivp@PAGEOFF]
Writes the 30 from x0 into total. At this point, total = 30.
5. main Restores and Returns
ldp x29, x30, [sp, #48]
Restores from the frame record established when entering main:
x29 = caller's frame pointer
x30 = main's return address
This step corresponds to the earlier stp x29, x30, [sp, #48].
Then:
add sp, sp, #64
Restores the stack top to what it was before entering main: sp = S0.
So-called releasing the stack frame is not about clearing those 64 bytes, but moving sp to declare that the space no longer belongs to the current function.
Finally:
ret
Sends the return address in x30 back to pc, returning to the runtime that called main.
add
_$s4main3addyS2i_SitF:
sub sp, sp, #32
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
str x0, [sp, #24]
str x1, [sp, #16]
adds x8, x0, x1
str x8, [sp]
cset w8, vs
tbnz w8, #0, LBB1_2
b LBB1_1
LBB1_1:
ldr x0, [sp]
str x0, [sp, #8]
add sp, sp, #32
ret
LBB1_2:
brk #0x1
1. Allocate Stack Space
sub sp, sp, #32
Allocates 32 bytes of stack space for add.
The layout in the current compilation result is:
sp + 24: parameter a
sp + 16: parameter b
sp + 8 : local variable result
sp : temporary calculation result
This layout is decided by the compiler.
2. Initialize Stack Slots
str xzr, [sp, #24]
str xzr, [sp, #16]
str xzr, [sp, #8]
xzr always represents zero, so these three instructions initialize the related stack slots to zero.
3. Save Parameters
str x0, [sp, #24]
str x1, [sp, #16]
Upon entering the function:
x0 = 10
x1 = 20
So after execution:
[sp + 24] = 10
[sp + 16] = 20
4. Calculate Addition
adds x8, x0, x1
Calculates: x8 = 10 + 20 = 30
adds is used here because Swift's ordinary Int addition needs to check for signed integer overflow. In addition to calculating the result, adds also sets the CPU's status flags, including the overflow flag V.
Then:
str x8, [sp]
Temporarily stores the result 30 on the stack, because the upcoming overflow check will reuse w8. The compiler saves the calculation result first to prevent it from being overwritten by subsequent instructions.
5. Check Overflow
cset w8, vs
vs means Overflow Set:
Overflow occurred: w8 = 1
No overflow: w8 = 0
In this example, there is no overflow, so: w8 = 0.
Then:
tbnz w8, #0, LBB1_2
Checks bit 0 of w8. If it is not 0, jumps to the overflow handler LBB1_2.
Next:
b LBB1_1
Since the overflow branch was not entered, it jumps to the normal return path.
6. Prepare Return Value
Normal path:
LBB1_1:
ldr x0, [sp]
Retrieves the previously saved calculation result from the stack: x0 = 30.
The calling convention gets simple integer return values from x0.
Then:
str x0, [sp, #8]
Saves 30 to the stack slot corresponding to the source code's local variable result.
7. Release Stack Frame and Return
add sp, sp, #32
Restores the stack top to what it was before entering add.
sp must be restored first, because the caller main expects to see the stack state from before the call. If add does not restore it, main's stack positions will all be misaligned.
Then:
ret
Jumps to the address saved in x30, which is the position after bl add in main.
8. Overflow Path
LBB1_2:
brk #0x1
If an integer overflow occurs, brk triggers a processor exception and does not return normally.
This path does not continue execution following the normal function return flow but enters exception handling. Here, sp is not restored.
Leaf Functions
In the add function just shown, a new x29 was not set, because add did not call any other functions; it is a leaf function.
In add:
- There is no new
blto overwritex30 - The return address can stay in
x30 - The compiler does not need to save
x30to the stack - It can also choose not to establish a new
x29 frame record
Therefore:
Allocating stack space does not necessarily mean a new frame pointer record will be established.
At this point, x29 still points to main's frame record.
Stack Overflow
Each thread's stack space is limited. Every function call typically requires some stack space. For example, in the demo just shown, calling add:
sub sp, sp, #32
indicates that add needs to use 32 bytes of stack space.
Suppose a function continuously calls itself recursively. The call chain will keep growing until it exceeds the stack space available to that thread. When the code then accesses a stack address beyond the boundary, a memory access failure occurs, leading to a crash. This is a stack overflow.
Function Call Summary
Setting a breakpoint in the add function, the debugger will display:
0 add
1 main
But the function names add and main are not directly stored in memory.
What actually exists is:
pc: currently executingaddsp: the stack position currently used byaddx30: the address inmainto whichaddreturnsx29:main'sframe record[x29]: theframe recordofmain's caller[x29 + 8]:main's return address- CFI: how to restore the previous level's registers and stack position
The debugger maps these addresses back to functions and symbols in the Mach-O to finally display:
add
main
The key lies in stringing together the following elements:
bl is responsible for recording "where to return to"
sp is responsible for managing "which stack space the current function uses"
x29/frame record is responsible for linking to the previous stack frame
x0 is responsible for passing parameters and return values
ret is responsible for returning to the caller
CFI tells the stack unwinder how to restore the previous state
Ending
In the spirit of being concise, this article will not expand on the process of how to obtain a thread call stack and symbolication for now.