There’s something beautifully honest about assembly language. Unlike the high-level languages that wrap everything in abstractions and sugar-coating, assembly lays it all bare: every byte of memory, every register, every CPU cycle. Today we’re diving deep into one of the most fundamental aspects of systems programming — how functions talk to each other through parameter passing, and how they manage their own private scratch space through local variables.
If you’ve ever wondered what actually happens when you call a function in C, or why your carefully crafted assembly code crashes mysteriously when you call a C function, this article is for you. We’re going to peel back every layer of the onion, from the conceptual foundations to the gritty implementation details that separate the amateurs from the pros.
How High-Level Languages Pass Parameters: By Value, By Reference, By Name — and How Assembly Implements Each
Let’s start with a fundamental question: when you call a function and pass it some arguments, what exactly gets sent? This question has multiple answers, and each one maps to different assembly language patterns.
Pass by Value
In pass by value, the function receives a copy of the argument. Modifying the parameter inside the function doesn’t affect the original variable in the caller. This is the default in C for basic types.
Here’s what it looks like in C:
void increment(int x) {
x = x + 1; // This modifies the COPY, not the original
}
int main() {
int a = 5;
increment(a);
// a is still 5 here
}
In assembly, pass by value is implemented by pushing the value onto the stack (or loading it into a register) before the CALL instruction. The called function receives a copy that it can modify freely without affecting the caller’s data.
; Caller side - pass by value
mov eax, [a] ; Load the value of 'a' into EAX
push eax ; Push the VALUE onto the stack
call increment ; Call the function
add esp, 4 ; Clean up the stack (cdecl convention)
; Callee side - increment function
increment:
push ebp
mov ebp, esp
mov eax, [ebp + 8] ; Get the parameter (the copy)
add eax, 1 ; Modify the copy
mov [ebp + 8], eax ; Store back to the copy (doesn't affect caller's 'a')
pop ebp
ret
The key insight here: the caller’s a variable lives at some memory address (let’s say [ebp - 4] in main’s stack frame). When we pass by value, we load that value into a register and push the register — not the address. The called function gets a completely independent copy.
Pass by Reference
Pass by reference gives the function the address of the variable, allowing it to modify the original. In C, this is done with pointers.
void increment_ptr(int* x) {
*x = *x + 1; // This modifies the ORIGINAL variable
}
int main() {
int a = 5;
increment_ptr(&a);
// a is now 6
}
In assembly, pass by reference means pushing the address of the variable onto the stack:
; Caller side - pass by reference
lea eax, [a] ; Load the ADDRESS of 'a' into EAX
push eax ; Push the ADDRESS onto the stack
call increment_ptr
add esp, 4
; Callee side
increment_ptr:
push ebp
mov ebp, esp
mov eax, [ebp + 8] ; Get the pointer (address of 'a')
mov ecx, [eax] ; Dereference: get the value at that address
add ecx, 1 ; Increment
mov [eax], ecx ; Store back to the original variable
pop ebp
ret
Notice the extra level of indirection. The parameter itself is a pointer (an address), and we need to dereference it to get/set the actual value. This is why pointer bugs in C are so dangerous — one wrong dereference and you’re reading/writing arbitrary memory.
Pass by Name (The Forgotten Cousin)
Pass by name is a parameter passing mechanism you’ll rarely encounter in modern programming, but it’s worth understanding because it illustrates the full spectrum of possibilities. In pass by name, the argument expression is not evaluated at the call site. Instead, it’s evaluated each time the corresponding parameter is used inside the function, in the context of the caller.
This was famously used in ALGOL 60 and is similar to lazy evaluation in functional languages like Haskell. In assembly terms, pass by name would be implemented by passing a thunk — a small piece of code that evaluates the expression and returns the value.
// Conceptual example (not valid C)
void tricky(func) {
int x = 5;
func(); // Each time func is called, it re-evaluates the expression
}
int main() {
int a = 10;
tricky(a + 1); // Passes the EXPRESSION "a + 1", not the value 11
}
In assembly, implementing pass by name would require pushing the address of a thunk function onto the stack. When the called function wants to “use” the parameter, it calls the thunk, which evaluates the expression in the caller’s context. This is powerful but expensive — every parameter access becomes a function call.
Modern C and C++ don’t support pass by name directly, but you can simulate it with function pointers or lambdas (in C++11 and later).



