Functions in Assembly
Learn how functions work in x86 assembly with CALL/RET, the stack, calling conventions, and recursion using NASM and Docker
In high-level languages, a function is a built-in abstraction: you write def, fn, or void name() and the compiler arranges everything behind the scenes. In assembly, there is no function keyword. A “function” is simply a label you can jump to and return from — and you are responsible for every detail the compiler would normally handle: where arguments live, where the return value goes, which registers must be preserved, and how the stack is managed.
The two instructions that make this work are call and ret. call label pushes the address of the next instruction onto the stack and jumps to label. ret pops that saved address back off the stack and jumps to it, resuming execution right after the original call. This push/pop dance is why functions can be nested and recursive — each call stacks a return address, and each ret unwinds one.
Because assembly is untyped and low-level, “calling conventions” are agreements, not rules enforced by the language. This tutorial uses simple, consistent conventions: arguments and return values in registers for the early examples, then the stack-frame style (ebp/esp) that C compilers use. All examples use 32-bit x86 with Linux int 0x80 syscalls, matching the Hello World page.
Calling a Subroutine with CALL and RET
The simplest function takes no arguments and returns nothing — it just does work and returns. Here greet is a leaf function (it calls nothing but the kernel), while print_string is a reusable helper that performs the actual sys_write.
Create a file named functions.asm:
| |
Notice that greet itself calls print_string. This nesting works because each call pushes its own return address: the ret inside print_string returns into greet, and the ret inside greet returns into _start. The stack keeps the two return addresses in order automatically.
Passing Arguments and Returning Values in Registers
Real functions need inputs and outputs. The fastest convention is to pass arguments in registers and return the result in eax (the traditional “accumulator” register). Here add_numbers reads two operands and leaves the sum in eax, and a print_int helper converts that integer to text and prints it.
Create a file named arguments.asm:
| |
print_int shows why assembly functions take effort: there is no built-in way to print a number. We repeatedly divide by 10, collecting remainders as ASCII digits from right to left in a buffer, then write the buffer with a single syscall. div is unsigned division — it divides the 64-bit value edx:eax by the operand, so we must zero edx before each div.
Stack Frames and the C Calling Convention
Registers run out quickly, so most compiled code passes arguments on the stack using a stack frame. In the cdecl convention, the caller pushes arguments (right to left) and cleans them up afterward; the callee sets up a frame with ebp so it can reference arguments by fixed offsets.
Create a file named stack_frame.asm:
| |
After call sum runs, the stack holds (from the top): the return address at [ebp+4], the first argument at [ebp+8], and the second at [ebp+12]. The push ebp / mov ebp, esp prologue and the matching pop ebp epilogue are the standard frame setup you will see in nearly all compiler-generated code.
Recursion with the Stack
Because every call saves its own return address and each invocation can push its own data, recursion works naturally. The classic example is factorial: factorial(n) = n * factorial(n - 1), with factorial(1) = 1. Each recursive call saves n on the stack before descending, then multiplies on the way back up.
Create a file named recursion.asm:
| |
Each level of recursion pushes its own copy of n. When the base case is reached, the ret instructions unwind the call chain, and each level multiplies its saved n into the accumulating result. The stack is doing the bookkeeping a high-level language would hide from you — here it is fully visible.
Running with Docker
Each example is a complete, standalone program. Assemble and run them with the same NASM image used throughout this series:
| |
Expected Output
functions.asm:
Calling a function...
Hello from inside the function!
Hello from inside the function!
arguments.asm:
12
stack_frame.asm:
42
recursion.asm:
120
Key Concepts
callandretare the whole mechanism:callpushes the return address and jumps;retpops it and jumps back. There is nofunctionkeyword — a function is just a reachable label.- The stack enables nesting and recursion: each
callstores its own return address, so functions can call other functions (and themselves) without losing their place. - Calling conventions are agreements, not rules: assembly won’t enforce where arguments go. We used registers for speed and
eaxfor return values, then the cdecl stack-frame style for compatibility with compiled code. - Stack frames use
ebpas an anchor: thepush ebp/mov ebp, espprologue lets a function reference arguments at fixed offsets ([ebp+8],[ebp+12]) even asespmoves. - Caller vs. callee responsibilities: in cdecl, the caller pushes arguments and cleans them up afterward (
add esp, 8), while the callee preserves and restoresebp. - Leaf vs. non-leaf functions: a leaf function calls nothing else and needs minimal setup; a non-leaf function must protect any registers and return data it relies on across nested calls.
- You build your own abstractions: even printing an integer requires a hand-written routine (
print_int) — assembly gives you total control precisely because it gives you nothing for free.
Running Today
All examples can be run using Docker:
docker pull esolang/x86asm-nasm:latest
Comments
Loading comments...
Leave a Comment