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:
.textputs the following instructions into the executable code section..global _mainmakes_mainvisible to the linker so the OS knows where to start the program (on macOS, C function names get a leading_)..p2align 2aligns the next instruction to2^2 = 4bytes, since every ARM64 instruction is 4 bytes wide.
Then we declare the _main label and define what _main does with two instructions:
- putting
0(success) tow0register. - returning
retfrom main (coming back to OS)
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:
add w0, w0, #1takes the value inw0(41), adds1to it, and writes the result (42) back tow0. On ARM64, the first integer argument and the return value both usew0.retjumps back to the return address stored in thex30register (set by_mainwhen calling_foo).
_main calls another function, so it needs the extra instructions:
stp x29, x30, [sp, #-16]!saves two registers at once.x29is the frame pointer.x30is the link register, the address we should return to.[sp, #-16]!subtracts 16 bytes from the stack pointer (8 bytes per register) and stores that new address insp.mov x29, sppoints the frame pointer at those saved values. Follow that chain and you get the stack trace.mov w0, #41is the argument to_foo.bl _foojumps to_fooand overwritesx30with the address of the next instruction, theldp. If we had not savedx30, we would not know how to get back to our caller._fooreturns here with42inw0.ldp x29, x30, [sp], #16loads the saved registers back and adds 16 tosp.retfollows the restored link register out of_main.
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:
bl _foosetsx30to the instruction right after it—which is now_main’sret._foorunsretand jumps tox30(_main’sret)._mainrunsret, which jumps tox30… which is still_main’s ownret! 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: