Daniel Liezrowice
ESL, eswlab.com
answering

What Really Happens When You Call a Function in C?

Same C code, same Cortex-M4, real arm-none-eabi-gcc 14.2 output. The only thing that changed is the -O flag.
A reply to the post by
Embedded Systems World
"Call a Function in C"
(the -O0 view)

1 -O0: the textbook call

This is what the original infographic describes. Args in R0/R1, BL add, result in R0, return with BX LR. Plus a lot of stack traffic nobody asked for.
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O0 -c add.c
<add>:
  push  {r7}
  sub   sp, #12
  add   r7, sp, #0
  str   r0, [r7, #4]      ; spill a
  str   r1, [r7, #0]      ; spill b
  ldr   r2, [r7, #4]      ; reload a
  ldr   r3, [r7, #0]      ; reload b
  add   r3, r2
  mov   r0, r3            ; return value
  adds  r7, #12
  mov   sp, r7
  pop   {r7}
  bx    lr
<main>:
  push  {r7, lr}
  sub   sp, #8
  add   r7, sp, #0
  movs  r3, #5
  str   r3, [r7, #4]      ; x = 5
  movs  r1, #20           ; b
  movs  r0, #10           ; a
  bl    add
  str   r0, [r7, #0]      ; result = R0
  ldr   r3, [r7, #4]
  adds  r3, #1
  str   r3, [r7, #4]      ; x = x + 1
  movs  r3, #0
  mov   r0, r3
  adds  r7, #8
  mov   sp, r7
  pop   {r7, pc}
30instructions executed
1BL + 1 BX LR
8stack loads/stores
  • Leaf function add never saves LR; only non-leaf main pushes it.
  • Every variable lives on the stack, so a and b are stored and immediately reloaded.

2 -O2: the call is gone

add(10, 20) has constant arguments, so the compiler computes 30 at build time. result and x are never read, so they are deleted. What remains of main is "return 0".
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -c add.c
<add>:
  add   r0, r1            ; a + b, straight into R0
  bx    lr

<main>:
  movs  r0, #0            ; return 0
  bx    lr



; No BL. No LR write. No stack.
; No 10, no 20, no 30.
; add() is still emitted only because
; it has external linkage and something
; else might call it.

; -O1 gives the identical result.
2executed (4 emitted)
0BL executed
0stack accesses
  • Constant folding + dead-store elimination + inlining make the "6 steps" disappear.
  • This is why breakpoints on add never hit in a release build, and why result shows "optimized out" in the debugger.
  • The poster is not wrong. It is a picture of -O0, and production firmware is almost never built at -O0.

3 -O2 when the call is forced

Make the inputs volatile (unknown at build time), mark add noinline, and store the outputs. Now a real call survives optimization. This is what production code looks like.
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -c add_forced.c
<add>:
  add   r0, r1
  bx    lr

<main>:
  push  {r3, lr}          ; r3 only pads SP to 8 bytes
  ldr   r3, =in_a
  ldr   r0, [r3, #0]      ; a = in_a
  ldr   r1, [r3, #4]      ; b = in_b
  ldr   r3, =out          ; kept live ACROSS the call (!)
  bl    add
  movs  r2, #6            ; x = x + 1 already folded
  str   r0, [r3, #0]      ; out = result
  movs  r0, #0            ; return 0 (before 2nd store)
  str   r2, [r3, #0]      ; out = x
  pop   {r3, pc}          ; return via epilogue, not BX LR
13instructions executed
1BL executed
2stack accesses
  • push {r3, lr}: r3 is not being preserved, it is filler so SP stays 8-byte aligned at the call (AAPCS rule).
  • The instruction after BL is movs r2, #6, which belongs to the C line after result = .... Execution resumes at an instruction, not at a C statement.
  • r3 is scratch per AAPCS, yet GCC loads it before BL and uses it after. Not a bug: -fipa-ra (on at -O2) saw that add only writes R0, so it kept r3 live. Build with -fno-ipa-ra, or move add to another .c file, and the load moves after the call. The ABI is a contract for code the compiler cannot see.
  • pop {r3, pc} loads PC straight from the stack. That single instruction is the ROP attack surface on ARMv7-M.
💡

Key Takeaways

  1. "What really happens" depends on the -O level more than on the CPU. At -O2 the call in the original example does not exist.
  2. A leaf function never touches LR or the stack; the first nested call turns BX LR into push {lr} ... pop {pc}.
  3. The register moves are free. The cost of a call is the pipeline flush on BL and on the return, which is exactly why the compiler inlines.
  4. R0 to R3 and R12 are scratch: assume they are garbage after any call. R4 to R11 are preserved by the callee.
  5. The return address only maps to an instruction. Optimized code returns into the middle of a reordered statement, which is why single-stepping -O2 code "jumps around".
Ship at -O2.
Debug at -Og.
Teach at -O0.

Now watch the CPU do it

These are not drawings. The same add.c is compiled with arm-none-eabi-gcc 14.2 for Cortex-M4, the machine code runs in the Unicorn CPU emulator, and every register, stack word and PC value is read back after each instruction. Yellow = instruction just executed, blue = PC, red = state changed, grey stack rows = dead frames.

Three builds in lockstep: -O0, -O2, and -O2 with a call the optimizer cannot remove

Lockstep animation of the -O0, -O2 and forced -O2 builds

-O0 in detail: the textbook call, spills and all

Detailed animation of the -O0 build

Reproduce it: git clone https://github.com/zuwasi/cortex-m-call-anatomy, pip install -r requirements.txt, python callviz.py --compare O0 O2 forced. Space plays and pauses, the arrow keys step one instruction at a time. Standalone animation page.