Learning Assembly: function calls

I always wanted to learn assembly language but never had a chance to do it. Finally, I started working on my own programming language where, in the end, I write assembly code and clang compiles it into machine code.

This article is an attempt to explain to you (and me) how a function call works in Assembly. Today, I’m focusing on ARM64 (Apple silicon). We’ll start with a function that only returns, then make _main call _foo.

Below, you can find a minimal Assembly that does nothing but returning the whole process with 0 exit code.

.text
.global _main

.p2align 2
_main:
    mov     w0, #0
    ret

You can compile the code yourself with clang like shown below where main.s is the content of the Assembly above.

clang -o main main.s

Before _main, there are three assembler directives:

Then we declare the _main label and define what _main does with two instructions:

The w0 register is used by sys_exit to return the exit code. Simple, isn’t it?

The program above calls nothing. It can ret straight away. When we want to call another function, we have to save the way back first, make the call, and restore it afterwards.

Save this as main.s in place of the first program:

.text
.global _main
.p2align 2

_foo:
    add     w0, w0, #1
    ret

_main:
    stp     x29, x30, [sp, #-16]!
    mov     x29, sp
    mov     w0, #41
    bl      _foo
    ldp     x29, x30, [sp], #16
    ret

Compile it the same way, run it, and print the exit status:

clang -o main main.s
./main
echo $?

You should see 42. _main puts 41 in w0, _foo adds 1, and that result is what the process exits with.

_foo does the same kind of job as the first _main. Because it doesn’t call any other function, it doesn’t need to save anything on the stack:

_main calls another function, so it needs the extra instructions:

Here is how the stack and registers look at each stage:

1. Enter _main             2. Inside _foo (after bl)      3. After ldp in _main

High addr                  High addr                      High addr
+------------------+       +------------------+           +------------------+
|       ...        | <--sp |       ...        |           |       ...        | <--sp
+------------------+       +------------------+           +------------------+
|                  |       | x30: caller (OS) | +8        |                  |
|     (unused)     |       +------------------+           |     (popped)     |
|                  |       | x29: old fp      | <--sp,x29 |                  |
+------------------+       +------------------+           +------------------+
Low addr                   Low addr                       Low addr

Registers:                 Registers:                     Registers:
  w0  = ?                    w0  = 41 -> 42                 w0  = 42
  x30 = caller (OS)          x30 = &ldp (in _main)          x30 = caller (OS)

Fun fact: on ARM64 the stack grows down, and that’s why we subtract 16 bytes. Even if we only wanted to save x30 (8 bytes), ARM64 hardware requires sp to always be 16-byte aligned whenever it accesses memory—so we move sp by 16 bytes at a time.

You might also notice the code mixes w0 with x29 and x30. On ARM64, x0–x30 are the full 64-bit registers (8 bytes), which we need for memory addresses like pointers. w0–w30 are the lower 32 bits (4 bytes) of those exact same registers, used for 32-bit integers like int main().

What if we don’t save x30?

If you remove stp and ldp from _main:

  1. bl _foo sets x30 to the instruction right after it—which is now _main’s ret.
  2. _foo runs ret and jumps to x30 (_main’s ret).
  3. _main runs ret, which jumps to x30… which is still _main’s own ret! The program gets stuck in an infinite loop.

Passing more than one argument

ARM64 passes the first eight integer arguments in registers w0–w7 (or x0–x7 for 64-bit values). For a two-argument function, we put the arguments in w0 and w1 and return the result in w0:

_add:
    add     w0, w0, w1
    ret

_main:
    stp     x29, x30, [sp, #-16]!
    mov     x29, sp
    mov     w0, #40
    mov     w1, #2
    bl      _add
    ldp     x29, x30, [sp], #16
    ret

Exploring further with clang -S

Whenever you are unsure what assembly to emit, write a tiny C snippet and ask clang to output the assembly for you:

clang -S -O1 main.c -o -

References:

Buy me a coffeeBuy me a coffee