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)):

  1. Argument Evaluation and Copying: The caller evaluates the expression. If the argument fits into hardware registers (such as general-purpose registers rdi, rsi, rdx in 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 (memcpy or a sequence of mov instructions) to duplicate the entire structure.
  2. 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.
  3. 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]).
  4. Side Effects: Writes to the parameter alter only the local copy. Once the function executes a ret instruction, 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)):

  1. 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.
  2. 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).
  3. 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.
  4. 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