跪拜 Guibai
← Back to the summary

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:

stack_1.png

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.

stack_2.png

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:

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:

Additionally, there are some special registers:

In an exception state, it may also include:

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:

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:

  1. Establish the stack frame
  2. Prepare the first parameter
  3. Prepare the second parameter
  4. Call add
  5. 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:

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:

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 bl does 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:

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:

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.