Pass-by-Value vs Pass-by-Reference in C Explained
The C programming language strictly evaluates all function arguments using pass-by-value semantics, meaning the runtime always duplicates the supplied argument into the callee’s execution context. To achieve "pass-by-reference," C developers pass pointer values, allowing a called function to manipulate memory allocated in the caller's frame. This article breaks down the precise low-level execution differences between standard pass-by-value and simulated pass-by-reference, examining how the compiler and CPU handle stack allocation, register usage, indirection costs, and memory mutations during execution.
The Underlying Model: C Is Strictly Pass-by-Value
From an architectural standpoint, C does not possess a native pass-by-reference mechanism like C++ or Fortran. Every parameter passed to a function is evaluated, and its instantaneous value is copied into either a machine register or a new slot on the call stack, governed by the target platform's Application Binary Interface (ABI).
The distinction between "true" pass-by-value and "simulated" pass-by-reference lies entirely in what value is being copied: the actual data itself, or the memory address representing where that data resides.
Execution of Standard Pass-by-Value
When passing an object directly by value (e.g.,
void func(int x) or
void func(struct Data s)):
- Argument Evaluation and Copying: The caller
evaluates the expression. If the argument fits into hardware registers
(such as general-purpose registers
rdi,rsi,rdxin System V AMD64 ABI), the value is loaded directly into a register. If it exceeds register capacity—such as a large struct—the CPU allocates stack space in the caller's outgoing parameter area and executes a memory block copy (memcpyor a sequence ofmovinstructions) to duplicate the entire structure. - Callee Stack Frame Isolation: The callee operates exclusively on this isolated copy. The memory allocated for the parameter lives strictly within the lifetime of the callee's stack frame or local register set.
- Execution Cost:
- Data Transfer: Proportional to the size of the data
type (
sizeof(T)). Copying primitives (e.g.,int,double) is virtually instantaneous (single-cycle register moves), but copying large aggregates consumes CPU cycles and increases cache churn. - Access Speed: Accessing the parameter is extremely
fast because it requires no pointer indirection. The CPU performs direct
register reads or direct stack-relative addressing
(
[rbp - offset]).
- Data Transfer: Proportional to the size of the data
type (
- Side Effects: Writes to the parameter alter only
the local copy. Once the function executes a
retinstruction, that local state is invalidated or overwritten by subsequent stack allocations. The caller’s original state remains unchanged.
Execution of Simulated Pass-by-Reference
When simulating pass-by-reference (e.g.,
void func(int *ptr) called via
func(&x)):
- Address Calculation and Copying: Instead of reading
the variable's payload, the caller calculates the memory address of the
target variable using the address-of operator (
&). In machine code, this frequently translates to a Load Effective Address instruction (e.g.,lea rax, [rbp - offset]). The calculated address—a fixed-size pointer (typically 4 bytes on 32-bit systems or 8 bytes on 64-bit systems)—is then passed by value via register or stack. - Callee Indirection Execution: The callee receives a
copy of the address, not the original variable. Whenever the callee
reads or updates the referenced data, it must perform pointer
dereferencing (e.g.,
*ptr = 10). In assembly, this requires indirect addressing: the CPU loads the address from the pointer register and issues a memory read or write targeting that address (e.g.,mov [rax], 10). - Execution Cost:
- Data Transfer: Minimal and deterministic
(
sizeof(uintptr_t)), making it efficient for passing large structures. - Access Speed: Every access to the underlying variable incurs the overhead of indirection. Because the data resides elsewhere in memory, dereferencing can cause CPU pipeline stalls, cache misses, and prevents certain compiler optimizations (like register allocation) due to potential pointer aliasing.
- Data Transfer: Minimal and deterministic
(
- Side Effects: Mutations bypass the callee’s local activation record. The CPU writes values directly across the call boundary into the caller’s stack frame or heap allocation, producing persistent side effects that outlive the callee's execution.
Summary of Differences
| Execution Phase | Standard Pass-by-Value | Simulated Pass-by-Reference |
|---|---|---|
| Call Site Action | Value copied (sizeof(T)) |
Address calculated (lea) and
copied (sizeof(void*)) |
| Data Access | Direct (register or stack-offset) | Indirect (memory dereference via address) |
| Caller State Mutation | Impossible (complete isolation) | Direct mutation across function boundaries |
| Hardware Overhead | High copy cost for large types; zero indirection | Low copy cost; added indirection and potential cache penalties |