student@ubuntu:~$
Guide

Deep Dive: The C Machine Model

What a C program actually looks like in memory: ELF binaries, the loader, the stack, address-of, pointer decay, and variadic promotion

Based on content from Dr. Stu Steiner, Eastern Washington University.

This page is optional reading. The Introduction to C and Variables & I/O lessons tell you how to write correct C for Lab 1. This page tells you what the machine is doing underneath, for readers who learn better when every abstraction is unpacked to its mechanical basis.

Nothing here is required for the lab, the quiz, or the final. It is for the part of your brain that asks “but why?”


From .c source to a running process

When you run gcc hello.c -o hello, you get a file on disk. That file is not yet a program; it is a blueprint. Four things have to happen before your code runs:

  1. Compile and link (what gcc does) produces an ELF file (Executable and Linkable Format) on Linux, or Mach-O on macOS. You can look inside with file hello and readelf -h hello or otool -h hello.
  2. The shell forks and execs. When you type ./hello, the shell calls fork() to make a child process, then the child calls execve("./hello", argv, envp). That is the system call that replaces the child’s memory with the contents of your binary.
  3. The kernel’s loader sets up memory. It maps the ELF file’s code and data segments into fresh virtual memory, creates a stack for the process, puts argv and the environment on the stack, and jumps to the entry point registered in the ELF header.
  4. C runtime (crt0) runs first, then calls your main. The runtime sets up the standard streams (stdin, stdout, stderr), installs exit handlers, and finally invokes main. When main returns, the runtime calls exit(status), which ends the process with that status. The kernel then delivers the exit code to the parent shell via wait().

So ./hello is not “the CPU runs the file.” It is a short kernel-scripted setup followed by “the CPU now executes code mapped from the file, starting at main, with argv and the stack already laid out.”


The four memory regions

Every Unix process gets the same rough layout in virtual memory:

 High addresses

  +---------------------+
  |    command-line     |   argv, envp live up here
  |    args + env       |
  +---------------------+
  |                     |
  |       stack         |   grows downward; one frame per function call
  |                     |
  |         v           |
  |                     |
  |         ^           |
  |                     |
  |        heap         |   grows upward; malloc / free work here
  |                     |
  +---------------------+
  |    bss (zero-init)  |   uninitialized globals
  +---------------------+
  |    data             |   initialized globals, string literals
  +---------------------+
  |    text (code)      |   your compiled instructions
  +---------------------+

 Low addresses

The stack and heap grow toward each other from opposite ends of the address space. The kernel keeps them separated and refuses to let either run into the other (stack overflow if the stack runs into the heap or a guard page).

What lives where:

  • Text: your machine code. Read-only.
  • Data / bss: global variables. int g = 5; at file scope lives in data; int g; at file scope lives in bss (zeroed at load time).
  • Heap: everything you get from malloc, calloc, realloc. You are responsible for free.
  • Stack: every function call allocates a frame here for its parameters, locals, return address, and saved registers. The frame is reclaimed on return.

Stack frames and what &x resolves to

When main calls readLength(), the CPU pushes a stack frame for readLength on top of main’s frame. Each frame contains:

  • The return address (where to jump when the function returns)
  • Saved registers the caller wants preserved
  • Space for parameters the caller passed
  • Space for the callee’s local variables
  • (Sometimes) a saved frame pointer

A local variable like int age has a specific location in its frame. On x86-64 the compiler commonly assigns it something like [rbp - 12] (12 bytes below the saved base pointer). The expression &age in C source compiles to exactly that address computation: “take the current frame pointer, subtract 12, that is the address of age.”

That is why scanf("%d", &age) works. scanf receives a pointer (an address). It parses digits from stdin into an int, then stores the result at that address. The function has no way to know whether the address points into a stack frame, a global, or a heap allocation; addresses look the same. All scanf needs is the location.

The reason C has an explicit & operator (and Java does not) is that C is pass-by-value. Every argument is copied into the callee’s frame. If you just wrote scanf("%d", age), the callee would receive a copy of age’s value and have no way to store anything back in the caller’s variable. & gives you the address, which you can meaningfully pass by value because the address itself is a number.


Array-to-pointer decay

There is a rule in C called “array-to-pointer decay” or “array decay.” It says: in almost every expression, the name of an array is automatically converted to a pointer to its first element.

char name[50];       /* 50 bytes of storage, in a stack frame */
scanf("%s", name);   /* name here is treated as `&name[0]` */

The two expressions name and &name[0] have the same type and the same numerical value in this context. This is why scanf("%s", name) does not need &: the array name is already giving you the address you need.

There is one place decay does not happen: inside sizeof. At the point of declaration, sizeof(name) gives 50 (the full array size in bytes) because the compiler knows you mean the array, not a pointer. But inside a function that receives char name[] as a parameter, the parameter has already decayed to char *, and sizeof(name) gives you the size of a pointer (typically 8).

void bad_length(char name[])
{
    /* name is already char*. This gives 8, not the caller's array size. */
    size_t n = sizeof(name);
}

That is why functions that work on arrays always take an explicit length parameter: by the time you are inside the function, the array’s length is gone.


Why printf("%f", float) works but scanf("%f", &double) does not

printf and scanf are variadic functions. Their prototype looks like:

int printf(const char *fmt, ...);
int scanf(const char *fmt, ...);

The ... means “zero or more arguments whose types the compiler does not know at the call site.” When you call a variadic function, the compiler applies two default argument promotions to each argument in the ...:

  • char, short, and their unsigned variants get promoted to int.
  • float gets promoted to double.

This happens before the call. So printf("%f", my_float) physically passes a double on the stack, not a float. Inside printf, the %f handler reads 8 bytes assuming a double, and finds the promoted value. No corruption.

scanf is a different story. It receives pointers, not values, so promotion does not apply. The specifier has to match exactly what the pointer points to:

  • scanf("%f", float_ptr) means “write 4 bytes (a float) starting at the address float_ptr.”
  • scanf("%lf", double_ptr) means “write 8 bytes (a double) starting at the address double_ptr.”

If you pass scanf("%f", &my_double) by mistake, scanf writes only 4 bytes into an 8-byte slot. The low half of my_double holds the parsed input; the high half holds whatever was sitting next to my_double on the stack. Neither the compiler nor scanf can catch this because the type information is erased in a variadic call. This is exactly what -Wall -Wformat looks for by examining the format string at compile time.


Where to go next