Function calls seem simple from a high-level language perspective — you call a function, it does its thing, and control comes back to exactly where you left off. But “coming back to exactly where you left off” requires the CPU to remember a return address somewhere, and different architectures solve this problem in genuinely different ways. Understanding the Link Register was the moment ARM Assembly finally made sense to me, especially coming from an x86 background where the concept is handled completely differently.
Let’s dig into what the Link Register is, why ARM uses this approach, how it compares to x86’s stack-based method, and the practical implications for writing and debugging code.
What Is the Link Register?
The Link Register (LR), known formally as X30 in ARM64 (AArch64) or R14 in ARM32 (AArch32), is a dedicated general-purpose register that stores the return address whenever a function/subroutine call instruction executes. When a BL (Branch with Link) or BLR (Branch with Link to Register) instruction is executed, the CPU automatically stores the address of the instruction immediately following the call into the Link Register, then jumps to the target function.
When that function finishes and wants to return to the caller, it simply branches to the address held in the Link Register — typically via a RET instruction (which, under the hood, is just a branch to the address in LR), or explicitly via BR LR / MOV PC, LR on older ARM variants.
Why ARM Uses a Link Register Instead of Pushing to the Stack
This is where ARM’s design philosophy genuinely diverges from x86. On x86/x86-64, the call instruction automatically pushes the return address onto the stack, and ret pops it back off:
; x86-64
call my_function ; pushes return address onto the stack, then jumps
...
my_function:
; function body
ret ; pops return address off the stack and jumps there
On ARM, BL does not touch the stack at all — it simply writes the return address into LR:
; ARM64
BL my_function ; LR = address after this instruction, then branch
...
my_function:
; function body
RET ; branches to address in LR
This design choice reflects ARM’s RISC philosophy: avoid unnecessary memory accesses. For simple, non-recursive leaf functions that don’t call anything else, this means a function call and return can happen without touching memory or the stack at all — a meaningful performance win, since register access is dramatically faster than memory access.
The Catch: Nested Function Calls
The obvious problem is: what happens if a function itself calls another function? If Function A calls Function B, and Function B calls Function C, there’s only one Link Register — so if B overwrites LR when calling C, how does B know its own return address back to A?
The answer is that it’s the responsibility of the function itself to save LR onto the stack manually if it intends to call another function (“non-leaf” functions). This is typically done in the function’s prologue and restored in its epilogue.
my_function:
STP X29, X30, [SP, #-16]! ; save frame pointer and LR onto the stack
; ... function body, which may call other functions ...
BL some_other_function ; this overwrites LR
; ... more function body ...
LDP X29, X30, [SP], #16 ; restore frame pointer and LR
RET ; branch to the restored LR
This is a crucial distinction: leaf functions (functions that don’t call anything else) can skip this entirely and enjoy zero-overhead calls. Non-leaf functions must explicitly preserve LR on the stack, essentially recreating (manually, and only when needed) the behavior x86 gets automatically on every single call.
sequenceDiagram
participant A as Function A
participant B as Function B (non-leaf)
participant C as Function C
A->>B: BL B (LR = return address in A)
B->>B: STP X29, X30, [SP, #-16]! (save LR to stack)
B->>C: BL C (LR = return address in B, overwrites previous LR)
C->>B: RET (branch to LR, back in B)
B->>B: LDP X29, X30, [SP], #16 (restore LR from stack)
B->>A: RET (branch to restored LR, back in A)
Link Register vs. x86’s Stack-Based Return Address
| Aspect | ARM (Link Register) | x86/x86-64 (Stack-based) |
|---|---|---|
| Where return address is stored on call | Dedicated register (LR / X30) | Pushed onto the stack |
| Memory access on a leaf function call | None required | Always occurs (push on call, pop on ret) |
| Nested calls | Caller/callee must manually save LR to stack | Automatic — stack naturally supports arbitrary nesting |
| Return instruction | RET (branches to LR) | ret (pops address from stack, jumps there) |
| Performance for leaf functions | Faster — no memory access needed | Slightly slower — stack access required |
Neither approach is objectively “better” — they reflect different design tradeoffs. ARM’s approach optimizes for the common case of simple function calls at the cost of extra prologue/epilogue complexity for non-leaf functions. x86’s approach is uniformly simple (every call behaves the same way) but always incurs a stack memory access.
Practical Example: A Function That Calls Another Function
Here’s a fuller ARM64 example showing LR preservation in action:
.global main
.text
main:
BL compute_sum ; call compute_sum, LR = return address in main
; result is now in X0
B end_program
compute_sum:
STP X29, X30, [SP, #-16]! ; save frame pointer + LR
MOV X29, SP
MOV X0, #5
MOV X1, #10
BL add_two_numbers ; this call overwrites LR
; X0 now holds the result from add_two_numbers
LDP X29, X30, [SP], #16 ; restore frame pointer + LR
RET ; return to main using restored LR
add_two_numbers:
ADD X0, X0, X1 ; leaf function — no need to save LR
RET ; return using LR (untouched since entry)
end_program:
MOV X8, #93
MOV X0, #0
SVC #0
Notice add_two_numbers is a leaf function — it never calls anything else, so it never needs to touch the stack or save LR. compute_sum, on the other hand, calls add_two_numbers, so it must save and restore LR around that call.
The Link Register and Tail Calls
An interesting optimization opportunity arises specifically because of how the Link Register works: tail call optimization. If a function’s very last action is to call another function and immediately return its result without doing anything further, the compiler can sometimes avoid the usual save/restore dance around LR entirely, and simply branch directly to the target function without modifying LR at all — letting the target function return straight back to the original caller, skipping the intermediate function’s own return step.
; Without tail-call optimization
func_a:
STP X29, X30, [SP, #-16]!
BL func_b ; call func_b, LR updated
LDP X29, X30, [SP], #16
RET ; return to func_a's caller
; With tail-call optimization
func_a:
B func_b ; simply branch — LR still holds func_a's original caller's address
; func_b's own RET will go directly back to func_a's caller
This works because a plain B (unconditional branch, without “link”) doesn’t touch LR at all, so whatever return address was already sitting in LR from func_a‘s own caller remains valid and correct for func_b to return to directly.
Link Register Interactions with Exceptions and Interrupts
The Link Register concept extends beyond ordinary function calls into ARM’s exception-handling model. When an exception (interrupt, system call, fault) occurs, the processor automatically saves the current program counter into an exception-specific link register (distinct banked registers at each exception level, such as ELR_EL1 for returning from an EL1 exception), so that after the exception handler runs, execution can resume exactly where it left off. This is conceptually the same pattern as the ordinary Link Register — preserving a return address — just applied at the level of hardware exceptions rather than software function calls.
Comparing LR-based and Stack-based Returns Under Recursion
Recursive functions are a good stress test for understanding why non-leaf functions on ARM absolutely must save LR to the stack. Consider a simple recursive factorial function:
factorial:
STP X29, X30, [SP, #-16]! ; save frame pointer + LR — essential for recursion
MOV X29, SP
CMP X0, #1
B.LE base_case
SUB X1, X0, #1
MOV X19, X0 ; preserve X0 across the recursive call (callee-saved reg)
MOV X0, X1
BL factorial ; recursive call — overwrites LR each time
MUL X0, X0, X19
B factorial_end
base_case:
MOV X0, #1
factorial_end:
LDP X29, X30, [SP], #16
RET
Every recursive invocation pushes its own copy of LR (and the frame pointer) onto a growing stack, exactly mirroring how x86’s call instruction naturally builds up a chain of return addresses on its stack. Without this explicit save, every recursive call would clobber the previous LR value, making it impossible to correctly return back up the call chain — this is precisely why the “leaf function” optimization can never apply to a recursive function.
Debugging Implications
When debugging ARM code (in GDB, for example), the Link Register is one of the first things I check when trying to understand a call stack or trace back “how did we get here.” A corrupted LR — often caused by a stack overflow, buffer overrun clobbering saved LR values on the stack, or a missing save/restore in a hand-written function — typically manifests as a RET jumping to a garbage address, producing a crash that looks confusing until you realize the return address itself was corrupted.
This is also directly relevant to a well-known class of security vulnerabilities: stack buffer overflows can overwrite the saved LR value on the stack, allowing an attacker to redirect control flow when the function returns — the ARM equivalent of overwriting a saved return address in a classic x86 stack-smashing attack.
The Link Register in Compiled Code vs. Hand-Written Assembly
When I look at compiler-generated ARM64 output (from GCC or Clang), the LR save/restore pattern is almost always immediately visible at the very start and end of any non-trivial function, typically bundled together with the frame pointer in a single STP/LDP pair, exactly as shown in the examples above. Compilers are conservative by default here — they’ll save LR even in cases where careful manual analysis might prove it unnecessary, simply because determining “will this function definitely never call anything else, under any code path, including exception handling” is a much easier question for a human reading a small function than for a general-purpose optimizer trying to prove it safe in all cases. This is one of the areas where hand-written Assembly can sometimes outperform compiled code slightly, precisely because a human author often has more specific knowledge about whether a given function is truly a leaf function.
Common Mistakes
- Forgetting to save LR in a non-leaf function, causing the return address to be silently overwritten by a nested call, leading to a crash or incorrect control flow on return.
- Mismatched save/restore pairs — saving LR with
STP X29, X30, ...but restoring incorrectly, or not restoring the stack pointer to the same offset. - Assuming LR behaves like x86’s return address on the stack — it doesn’t persist automatically across nested calls without explicit programmer intervention.
- Clobbering LR accidentally by using X30 as a general-purpose scratch register in code that later still needs to return properly.
Why This Design Choice Still Matters Today
Even as ARM cores have grown enormously more complex — out-of-order execution, deep pipelines, multiple decode lanes — the fundamental Link Register model established decades ago remains unchanged at the architectural level. This stability is valuable: it means the mental model I’ve described here for a simple in-order ARM core applies just as directly to understanding a call stack on a high-end modern smartphone or server-grade ARM chip. The performance-oriented reasoning behind avoiding unnecessary stack traffic for leaf functions hasn’t gone away either — if anything, as memory latency has grown relatively larger compared to CPU clock speeds over the decades, avoiding unnecessary memory accesses for simple function calls remains just as relevant a design goal today as it was when ARM’s architecture was first conceived.
Best Practices
- Always save LR (typically alongside the frame pointer, X29) at the start of any function that itself makes function calls.
- Use the standard
STP/LDPpair pattern for prologue/epilogue LR handling — it’s efficient and idiomatic. - Avoid using X30 as a scratch register in functions that need to return correctly.
- When debugging a crash with a suspicious program counter value, check whether LR (or the stack location where it was saved) has been corrupted — it’s a common root cause.
FAQs
Does x86 have anything equivalent to the Link Register? Not directly — x86 stores return addresses exclusively on the stack via call/ret. There’s no dedicated register reserved for holding a return address the way ARM uses LR.
What happens if I call a function without saving LR, and that function calls another function? The second call will overwrite LR before the first function gets a chance to return using it, causing the first function to return to the wrong address (usually a crash, or worse, unpredictable behavior).
Is the Link Register used for anything besides function returns? Its primary architectural purpose is holding return addresses for BL/BLR instructions, though in some low-level or exception-handling contexts, its value may be inspected or manipulated directly for control-flow purposes (e.g., in interrupt/exception handlers or during context switching in an OS kernel).
What is tail-call optimization, and how does it relate to LR? Tail-call optimization lets a compiler skip the usual save/restore of LR when a function’s final action is simply to call another function and return its result directly. Since a plain branch doesn’t modify LR, the called function can return straight to the original caller, skipping an unnecessary intermediate return step.
Does recursion always require saving LR on ARM? Yes — any function that calls itself (or calls anything else) must save LR to the stack before making that call, since a fresh LR value gets written on every BL. Without saving it, each recursive call would overwrite the previous return address, making it impossible to unwind back up the call chain correctly.
Summary and Key Takeaways
- The Link Register (LR / X30 on ARM64, R14 on ARM32) holds the return address after a
BL/BLRbranch-with-link instruction. - Unlike x86, which always pushes return addresses to the stack, ARM’s leaf functions can call and return with zero memory access.
- Non-leaf functions must manually save and restore LR on the stack (usually via
STP/LDP) to support nested calls. - Corrupted or mismanaged LR values are a common source of crashes and a classic vector for stack-based security exploits.
- Understanding LR is essential for reading ARM disassembly, debugging call stacks, and writing correct hand-written ARM Assembly.
References
- Arm® Architecture Reference Manual for A-profile Architecture
- Arm® Procedure Call Standard for the Arm® 64-bit Architecture (AAPCS64)
- Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 1 (for comparison with x86 call/ret behavior)
- GNU Binutils / GAS Documentation (as.info)