What is the significance of the link register in subroutine calls

What is the significance of the link register in subroutine calls

The first time I worked on ARM assembly after years of x86, I kept looking for a return address on the stack after a function call — and it just wasn’t there. That confusion led me straight to the link register, one of the cleverest small design decisions in RISC architecture. In this post, I’ll explain what the link register is, why it exists, how it differs from x86’s stack-based approach, and how it shapes performance, debugging, and even security.

What Is the Link Register?

The link register (LR, called X30 in AArch64 or R14 in AArch32) is a dedicated CPU register whose job is to hold the return address — the instruction address the processor should jump back to after a subroutine finishes executing.

When you call a function using the BL (Branch with Link) instruction, the processor automatically stores the address of the instruction immediately following the BL into the link register, then jumps to the subroutine.

BL      my_function      ; LR is set to the address of the next instruction
; execution continues here after my_function returns

Inside my_function, returning is as simple as:

my_function:
    ; ... function body ...
    RET                  ; or BX LR / MOV PC, LR — jumps to address in LR

Why Does the Link Register Exist?

On CISC architectures like x86, a CALL instruction pushes the return address onto the stack, and RET pops it back off. That’s simple, but it means every single function call requires a memory write, even for the smallest, most frequently called leaf functions.

ARM’s RISC design philosophy avoids unnecessary memory traffic. By storing the return address in a register instead of memory, a simple, non-nested function call needs zero memory accesses for its call/return mechanism. This is a meaningful performance win, especially for leaf functions (functions that don’t call other functions) — which make up a large share of real-world code.

x86 vs. ARM: A Side-by-Side Comparison

Aspectx86/x86-64ARM
Return address storagePushed onto the stack automatically by CALLStored in the Link Register (LR/X30) by BL
Return mechanismRET pops the stackRET/BX LR jumps to the LR value
Memory access on callAlways at least one stack writeNone, unless the function needs to preserve LR for nested calls
Nested callsStack naturally supports arbitrary nestingRequires manually pushing LR before another BL
Simplicity for leaf functionsSame cost regardless of nestingFaster — no memory access required

What Happens with Nested Calls?

The link register holds exactly one return address. If a subroutine itself calls another subroutine, it must first save the current LR value somewhere (typically the stack), because the next BL will overwrite it.

outer_function:
    STP     X29, X30, [SP, #-16]!   ; save frame pointer and LR to the stack
    BL      inner_function           ; LR is now overwritten with return-to-outer address
    LDP     X29, X30, [SP], #16     ; restore frame pointer and LR
    RET                              ; return using the restored LR

STP (Store Pair) here saves both the frame pointer (X29) and the link register (X30) in a single instruction — an efficient, idiomatic ARM64 prologue pattern.

Internal Working: Call and Return Flow

sequenceDiagram
    participant PC as Program Counter
    participant LR as Link Register
    participant Stack as Stack Memory
    participant Func as Subroutine

    PC->>LR: BL instruction stores return address into LR
    PC->>Func: Jump to subroutine entry point
    Func->>Stack: (if nested) STP saves LR and frame pointer
    Func->>Func: Executes subroutine body
    Func->>LR: BL to nested function overwrites LR
    Func->>Stack: (on return from nested call) LDP restores LR
    Func->>PC: RET jumps to address held in LR
    PC->>PC: Execution resumes after original BL

x86-64 Equivalent Mental Model

Even though x86-64 doesn’t have a link register, understanding the contrast helps clarify why ARM’s design matters:

call    my_function        ; pushes return address onto the stack, jumps to my_function
; ...
my_function:
    ; function body
    ret                     ; pops return address from stack, jumps there

Here, the stack is doing exactly what the link register does on ARM, except it’s a memory-based mechanism rather than a register-based one. This is why x86-64 stack frames always contain a return address, while ARM leaf functions often don’t touch the stack at all.

Practical Use Cases

  1. Leaf function optimization: Compilers detect when a function doesn’t call anything else and skip saving LR entirely, since it will never be overwritten.
  2. Tail-call optimization: Some ARM compilers use the LR directly to implement efficient tail calls, avoiding a stack frame altogether.
  3. Interrupt and exception handling: On exception entry, ARM automatically saves the current execution state (including the return address) into a banked link register (e.g., LR_irq in AArch32), separate from the “normal” LR, so an interrupt handler can return cleanly.
  4. Stack unwinding for debuggers: Debuggers and unwinders (like those used in gdb or crash reporting tools) walk the chain of saved frame pointers and link registers to reconstruct a call stack, similar to how x86 debuggers walk saved return addresses.

Debugging with the Link Register

In GDB on an ARM target:

(gdb) info registers lr
lr             0x400620  0x400620 <main+56>
(gdb) bt
#0  inner_function () at file.c:12
#1  0x00400620 in outer_function () at file.c:20
#2  0x004006a0 in main () at file.c:30

The backtrace (bt) command relies heavily on correctly saved LR values on the stack. If a function corrupts its saved LR (a classic stack-buffer-overflow bug), the backtrace becomes garbage — which is actually one reason link-register corruption is a well-known security-relevant bug class on ARM systems, closely related to return-address overwrites on x86.

Security Considerations

Because the return address lives in a single register rather than being embedded automatically in every stack frame, certain classes of stack-based exploitation differ between architectures:

  • On x86, overwriting the return address on the stack is the classic buffer-overflow attack vector.
  • On ARM, an attacker must specifically target the saved copy of LR on the stack (since the live register itself isn’t normally reachable via a buffer overflow) — but once LR is saved via STP, it becomes just as vulnerable to stack-smashing techniques.

This is part of why mitigations like Pointer Authentication (PAC) on ARMv8.3+ specifically sign the link register value before it’s pushed to the stack, making tampering detectable.

Optimization Considerations

  • Avoiding unnecessary LR save/restore in leaf functions is one of the most common and effective ARM compiler optimizations (-O2 and above typically apply this automatically via leaf function detection).
  • Excessive nested calls without inlining can lead to repeated STP/LDP pairs, adding memory traffic that a flatter call structure (or aggressive inlining) can avoid.
  • On some cores, the link register is also used with the return address predictor / return stack buffer, a hardware structure that predicts where a RET will jump, based on push/pop symmetry with BL/RET pairs — mismatches can cause pipeline flushes.

Common Mistakes

  • Forgetting to save LR before a nested BL call, silently corrupting the return path of the outer function.
  • Assuming ARM functions always use the stack for return addresses (they don’t, for leaf functions).
  • Confusing the link register with the frame pointer — they serve completely different purposes (LR is the return address; the frame pointer anchors local variable/stack-frame access).

Best Practices

  • Always pair STP/LDP (or equivalent PUSH/POP) around LR whenever a function makes further calls.
  • When hand-writing assembly, use BL for calls that need a return address recorded, and plain B for tail calls/jumps that don’t.
  • Trust the compiler’s leaf-function detection rather than manually second-guessing whether to save LR — modern compilers are very good at this.

FAQs

Does x86-64 have anything like the link register? Not directly — x86-64’s CALL/RET pair always uses the stack. Some architectures like x86’s older ancestors or certain embedded chips do use link-register-like schemes, but mainstream x86-64 relies purely on the stack.

What happens if I overwrite LR without saving it first? The function loses its way home — attempting RET afterward jumps to whatever garbage address is currently in LR, typically causing a crash.

Is the link register the same as X30? Yes, on AArch64, X30 is architecturally defined as the link register; it’s a general-purpose register that also has this special role.

Summary and Key Takeaways

  • The link register stores the return address for a subroutine call, set automatically by BL/BLX.
  • It avoids a memory write on every function call, unlike x86-64’s stack-based CALL/RET.
  • Nested calls require manually saving and restoring LR, typically alongside the frame pointer via STP/LDP.
  • Understanding LR is essential for debugging (stack backtraces), security (return-address integrity), and performance (leaf function optimization).

References

  • Arm® Architecture Reference Manual for A-profile architecture, Chapter on the Procedure Call Standard (AAPCS64)
  • Arm Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) documentation
  • Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 1 (CALL/RET semantics, for contrast)
  • GNU Binutils documentation for ARM assembly syntax (GAS)
Total
1
Shares

Leave a Reply

Previous Post
Describe the role of the instruction cache in Assembly language programming

Describe the role of the instruction cache in Assembly language programming

Next Post
How are conditional flags set in Assembly language

How Are Conditional Flags Set in Assembly Language? A Deep Dive

Related Posts