DEV Community

Cover image for How C++ Actually Works? From Source Code to the Metal
Bhadra Mohit
Bhadra Mohit

Posted on

How C++ Actually Works? From Source Code to the Metal

Part 1 — From Source Code to a Running Program

1.1 The Four-Stage Build Pipeline

Unlike JavaScript or Python, C++ doesn't get read and run line by line. It goes through a strict pipeline before a single instruction executes:

  1. Preprocessing — handles everything starting with #. #include literally pastes the header file's text in. #define does text substitution. #ifdef/#ifndef strip out code conditionally. Output: a single expanded "translation unit."
  2. Compilation — the compiler (GCC, Clang, MSVC) parses the translation unit into an AST, performs type checking, and generates assembly, then object code (.o / .obj) — machine code specific to your CPU architecture, but with unresolved references to functions/variables defined elsewhere.
  3. Linking — the linker takes all your object files plus library code (.lib/.a/.so/.dll) and resolves every reference, producing a single executable. This is where you get the dreaded "undefined reference" error — the compiler was happy, but the linker couldn't find where a function is actually defined.
  4. Loading — the OS loader reads the executable, maps it into a new process's address space, sets up the stack/heap, and jumps to main().
// file: main.cpp
#include <iostream>   // preprocessor pastes iostream's contents here

int square(int);      // declaration — compiler trusts this exists

int main() {
    std::cout << square(5); // compiler emits a CALL instruction to an unresolved symbol
    return 0;
}

int square(int x) { return x * x; } // definition — linker resolves the CALL to here
Enter fullscreen mode Exit fullscreen mode

This is why C++ separates declaration (a promise: "this exists, here's its signature") from definition (the actual implementation) — and why header files traditionally hold declarations while .cpp files hold definitions.

1.2 Why This Matters in Practice

  • Compile-time errors (syntax, type mismatches) happen in stage 2.
  • Link-time errors (missing implementations, duplicate definitions) happen in stage 3.
  • Runtime errors (segfaults, division by zero) happen only after stage 4, while your program executes.

Interviewers love asking "is this a compile-time or runtime error?" — knowing this pipeline answers it instantly.


Part 2 — The Memory Model (the single most important section)

C++'s entire identity — performance, control, and its footguns — comes from the fact that you manage memory instead of a garbage collector. A running C++ process's memory is divided into distinct regions:

High addresses
+-------------------+
|       Stack        |  <- grows downward; local variables, function frames
|         |           |
|         v           |
|                     |
|         ^           |
|         |           |
|        Heap         |  <- grows upward; dynamic memory (new/malloc)
+-------------------+
|   BSS segment       |  <- uninitialized global/static variables
+-------------------+
|   Data segment      |  <- initialized global/static variables
+-------------------+
|   Text/Code segment |  <- your compiled machine instructions (read-only)
+-------------------+
Low addresses
Enter fullscreen mode Exit fullscreen mode

2.1 The Stack

  • Every function call pushes a stack frame: parameters, local variables, the return address.
  • Allocation/deallocation is just moving a pointer (the stack pointer) — this is why stack memory is extremely fast.
  • Memory is automatically reclaimed the instant a function returns — this is the mechanism behind RAII (see 2.4).
  • Fixed, limited size (often 1-8MB) — deep or infinite recursion causes a stack overflow.
void foo() {
    int x = 10;      // pushed onto foo's stack frame
    int arr[1000];   // also on the stack — large local arrays are a common overflow cause
} // frame popped automatically here — x and arr are gone
Enter fullscreen mode Exit fullscreen mode

2.2 The Heap (Free Store)

  • Memory you explicitly request with new (or malloc in C-style code) and must explicitly release with delete (or free).
  • Slower than the stack — the allocator has to search for a free block of the right size and track it.
  • Survives function returns — this is the entire reason it exists: to create data whose lifetime isn't tied to a single function call.
  • Not automatically cleaned up. Forgetting delete is a memory leak. Calling delete twice, or using memory after deleting it, is undefined behavior (a dangling pointer / use-after-free).
int* createOnHeap() {
    int* p = new int(42);  // allocated on heap, survives this function returning
    return p;
}

int main() {
    int* p = createOnHeap();
    std::cout << *p;   // valid — heap memory outlived createOnHeap()
    delete p;          // YOUR job — nobody does this for you
    p = nullptr;        // good practice: avoid a dangling pointer
}
Enter fullscreen mode Exit fullscreen mode

2.3 Pointers vs References — What's Actually Different

Both let you refer to another variable's memory, but they compile to very different things:

Pointer Reference
Can be reassigned Yes No — bound once, forever
Can be null Yes (nullptr) No — must be initialized to a real object
Needs dereferencing (*) Yes No — used exactly like the original variable
Supports arithmetic (p++) Yes No
What it is under the hood A variable holding a memory address Almost always compiled as a pointer internally, but the language guarantees it's never null and never reseated
int a = 10;
int* ptr = &a;   // ptr holds a's memory address
int& ref = a;    // ref IS a, just an alias

*ptr = 20;  // changes a to 20, via dereferencing
ref = 30;   // changes a to 30, directly — no dereference syntax needed
Enter fullscreen mode Exit fullscreen mode

2.4 RAII — The Idea That Makes C++ Safe(r)

Resource Acquisition Is Initialization is the core C++ idiom: tie a resource's lifetime (memory, file handles, network sockets, locks) to an object's lifetime. Acquire the resource in the constructor, release it in the destructor. Because the stack guarantees destructors run when an object goes out of scope — even during an exception unwind — resources get cleaned up automatically, deterministically, with zero garbage collector.

class FileHandle {
    FILE* f;
public:
    FileHandle(const char* path) { f = fopen(path, "r"); }  // acquire
    ~FileHandle() { if (f) fclose(f); }                     // release — always runs
};

void process() {
    FileHandle fh("data.txt");
    // ... do work, maybe throw an exception ...
} // fh's destructor runs HERE regardless of how the function exits — file always closed
Enter fullscreen mode Exit fullscreen mode

This single idiom is why modern C++ code rarely needs manual delete at all — see smart pointers in Part 6.


Part 3 — Object-Oriented C++, Down to the Bytes

3.1 What a class Actually Is in Memory

A class instance in memory is just its data members laid out contiguously (plus padding for alignment). Member functions are not stored per-object — there's exactly one copy of each function's machine code, shared by every instance, and it receives a hidden this pointer to know which object's data to operate on.

class Point {
    int x, y;
public:
    void print() { std::cout << x << "," << y; }
};
// sizeof(Point) == 8 bytes (two ints) — print() adds ZERO bytes per instance
Enter fullscreen mode Exit fullscreen mode

obj.print() is really compiler sugar for Point::print(&obj)this is just an implicit first parameter.

3.2 Constructors, Destructors, and the "Rule of Five"

Every class that manages a resource should define (or explicitly default/delete) five special functions:

class Buffer {
    int* data;
    size_t size;
public:
    Buffer(size_t n) : data(new int[n]), size(n) {}          // constructor
    ~Buffer() { delete[] data; }                              // destructor

    Buffer(const Buffer& other)                                // copy constructor
        : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }
    Buffer& operator=(const Buffer& other) { /* copy-assign */ return *this; }

    Buffer(Buffer&& other) noexcept                            // move constructor
        : data(other.data), size(other.size) {
        other.data = nullptr; // steal the pointer, leave source empty
    }
    Buffer& operator=(Buffer&& other) noexcept { /* move-assign */ return *this; }
};
Enter fullscreen mode Exit fullscreen mode

If you don't declare these, the compiler generates default versions — and the default copy constructor does a shallow copy (copies the pointer value, not the data it points to). Two Buffer objects would then both think they own the same heap memory, and both destructors would try to delete it — a double-free crash. This is the single most common source of C++ memory bugs in student code.

3.3 Inheritance and Virtual Functions — the vtable

When a class has at least one virtual function, the compiler adds a hidden pointer to every instance — the vptr — pointing to a virtual table (vtable): an array of function pointers, one per virtual function, specific to that class.

class Animal {
public:
    virtual void speak() { std::cout << "..."; }
};
class Dog : public Animal {
public:
    void speak() override { std::cout << "Woof"; }
};

Animal* a = new Dog();
a->speak(); // prints "Woof" — NOT "..."
Enter fullscreen mode Exit fullscreen mode

How a->speak() resolves at runtime:

  1. Follow a's hidden vptr to Dog's vtable (not Animal's — vptr was set to Dog's vtable when the Dog constructor ran).
  2. Look up the speak slot in that vtable → it points to Dog::speak.
  3. Call that function.

This indirection — one extra pointer dereference — is the entire mechanism behind runtime polymorphism. Without virtual, the compiler would resolve a->speak() at compile time based on a's declared type (Animal*), calling Animal::speak regardless of the actual object — this is why forgetting virtual on a base class destructor is a classic bug: deleting a Dog* through an Animal* pointer without a virtual destructor only runs ~Animal(), leaking Dog's resources.

class Animal {
public:
    virtual ~Animal() {}  // ALWAYS virtual if the class will be inherited from and deleted polymorphically
};
Enter fullscreen mode Exit fullscreen mode

3.4 Abstract Classes and Interfaces

A pure virtual function (virtual void f() = 0;) has no implementation in that class, making the class abstract — it can't be instantiated, only inherited from. This is C++'s equivalent of an "interface."


Part 4 — Templates: Compile-Time Code Generation

Templates let you write one function/class definition that the compiler generates real, type-specific code for at compile time — this is fundamentally different from generics in Java/C# (which are largely runtime type-erasure tricks).

template <typename T>
T maxOf(T a, T b) {
    return (a > b) ? a : b;
}

maxOf(3, 5);      // compiler generates a real int version: maxOf<int>
maxOf(3.1, 5.9);  // compiler generates a SEPARATE real double version: maxOf<double>
Enter fullscreen mode Exit fullscreen mode

This process — template instantiation — happens entirely at compile time. Each distinct type you call maxOf with produces its own compiled function, specialized and optimized for that type, with zero runtime overhead compared to hand-writing each version yourself. The cost: larger binaries (code bloat) if you instantiate with many types, and famously cryptic compiler errors when something goes wrong, since the error points into generated code you never wrote by hand.

This is also the mechanism behind the entire Standard Template Library.


Part 5 — The Standard Template Library (STL)

The STL is built on three cooperating pieces:

5.1 Containers

Data structures — vector, list, deque, map, unordered_map, set, stack, queue. Know their underlying implementation, because it drives every complexity guarantee:

Container Underlying structure Key trade-off
vector Contiguous dynamic array O(1) random access; O(n) insert/erase in the middle; amortized O(1) push_back
list Doubly linked list O(1) insert/erase anywhere (with an iterator); O(n) random access
map Red-black tree (balanced BST) O(log n) everything, always sorted by key
unordered_map Hash table O(1) average lookup/insert; O(n) worst case; no ordering
deque Array of fixed-size blocks O(1) push/pop at both ends
std::vector<int> v = {1, 2, 3};
v.push_back(4); // if capacity is exceeded, vector ALLOCATES A NEW, larger buffer,
                 // copies/moves every existing element over, then frees the old buffer
                 // — this is why push_back is "amortized" O(1), not strictly O(1)
Enter fullscreen mode Exit fullscreen mode

5.2 Iterators

A uniform way to traverse any container, abstracting away its internal structure — an iterator behaves like a generalized pointer (*it dereferences, ++it advances) regardless of whether the underlying container is an array or a tree.

5.3 Algorithms

Generic functions (sort, find, accumulate, transform...) that operate purely through iterators, so the same std::sort call works on a vector, a deque, or a raw array — this is templates + iterators working together.

std::vector<int> v = {5, 3, 1, 4};
std::sort(v.begin(), v.end()); // introsort: quicksort, falls back to heapsort if recursion gets too deep, insertion sort for small partitions
Enter fullscreen mode Exit fullscreen mode

Part 6 — Modern C++: Smart Pointers and Move Semantics

6.1 Smart Pointers — RAII Applied to Pointers

Modern C++ (C++11 onward) strongly discourages raw new/delete in application code. Smart pointers wrap a raw pointer in a RAII object:

  • unique_ptr<T> — sole ownership. Cannot be copied, only moved. Zero overhead vs. a raw pointer. Deletes its object automatically when it goes out of scope.
  • shared_ptr<T> — shared ownership via reference counting. Every copy increments an atomic counter; every destruction decrements it; the object is deleted when the count hits zero.
  • weak_ptr<T> — a non-owning observer of a shared_ptr's object, used to break reference cycles (two shared_ptrs pointing at each other would otherwise never hit zero and leak forever).
std::unique_ptr<Buffer> b1 = std::make_unique<Buffer>(100);
// b1 automatically deletes its Buffer when it goes out of scope — no manual delete, ever

std::shared_ptr<Buffer> s1 = std::make_shared<Buffer>(100);
std::shared_ptr<Buffer> s2 = s1; // refcount now 2
// object freed only when the LAST shared_ptr referencing it is destroyed
Enter fullscreen mode Exit fullscreen mode

6.2 Move Semantics and Rvalue References

Before C++11, copying a large object (like a vector with a million elements) always meant allocating new memory and copying every element — even when the source object was a temporary about to be destroyed anyway. Move semantics let you steal the source's internal pointers instead of copying data, then leave the source in a valid-but-empty state.

std::vector<int> makeBigVector() {
    std::vector<int> v(1'000'000, 42);
    return v; // modern compilers move (or elide entirely) this — no 1M-element copy
}

std::vector<int> a = makeBigVector(); // just pointer-swapping, not copying
Enter fullscreen mode Exit fullscreen mode

An rvalue reference (T&&) is how the compiler lets you write a function overload specifically for "this argument is a temporary/about-to-die object, so it's safe to cannibalize it" — that's exactly what the move constructor in section 3.2 does.


Part 7 — Exceptions and Error Handling

C++ exceptions unwind the stack when thrown — and RAII means every object on that stack gets its destructor called during unwinding, so resources are released even mid-exception, without explicit catch/cleanup blocks everywhere.

void risky() {
    FileHandle fh("data.txt");   // acquired
    throw std::runtime_error("failure");
    // fh's destructor STILL runs during stack unwinding — file still gets closed
}

int main() {
    try {
        risky();
    } catch (const std::exception& e) {
        std::cerr << e.what();
    }
}
Enter fullscreen mode Exit fullscreen mode

Exceptions have real runtime cost only when actually thrown (the "zero-cost" model used by most modern compilers) — a try block with no exception thrown costs essentially nothing at runtime, which is why exceptions are preferred over manual error-code checking in performance-sensitive C++ when errors are the exceptional case.


Part 8 — Compilation Details Every Professional Should Know

  • Undefined Behavior (UB): things like signed integer overflow, reading uninitialized memory, or out-of-bounds array access don't have a guaranteed result — the compiler is legally allowed to assume they never happen, and can optimize your code in surprising (even "impossible-looking") ways if they do. This is not the same as a runtime crash — UB can silently produce wrong results.
  • const correctness: marking a method const (e.g., int getX() const) is a compile-time promise it won't modify the object, enforced by the compiler, catching entire classes of bugs before runtime.
  • Inline and optimization: the compiler, not you, ultimately decides what to inline — inline today is more about allowing multiple translation units to define the same function (e.g., in headers) than a performance directive.
  • Name mangling: because C++ supports overloading, the compiler encodes parameter types into each function's symbol name internally — this is why extern "C" exists, to disable mangling when interfacing with C code/libraries.

Part 9 — Putting It All Together: One Program, Traced

#include <iostream>
#include <memory>

class Counter {
    int count;
public:
    Counter() : count(0) {}
    void increment() { count++; }
    int get() const { return count; }
};

int main() {
    auto c = std::make_unique<Counter>(); // heap alloc, RAII-managed
    for (int i = 0; i < 3; ++i) {
        c->increment(); // stack frame for increment() pushed/popped each iteration
    }
    std::cout << c->get() << std::endl;
} // c goes out of scope -> unique_ptr's destructor runs -> Counter freed automatically
Enter fullscreen mode Exit fullscreen mode

What happens, stage by stage:

  1. Preprocessing expands <iostream> and <memory>.
  2. Compilation type-checks everything, generates machine code, leaves std::cout's implementation as an unresolved symbol.
  3. Linking resolves that symbol against the standard library.
  4. Loading: OS creates the process, sets up stack and heap, jumps into main.
  5. main's stack frame is pushed.
  6. make_unique<Counter>() allocates a Counter on the heap, wraps it in a unique_ptr on the stack.
  7. Each loop iteration pushes/pops increment()'s own tiny stack frame.
  8. get() is called — being const, the compiler guarantees it can't mutate count.
  9. When main returns, c's destructor (part of unique_ptr) runs automatically — RAII — freeing the heap-allocated Counter, no delete written anywhere.
  10. main's stack frame pops, the process exits, OS reclaims all its memory.

Why This Matters for Students vs. Professionals

For students: this is exactly the mental model examiners and interviewers are testing when they ask "what's the difference between stack and heap," "what does virtual do," or "why is the Rule of Five needed" — these aren't trivia, they're checks that you understand what your code actually does to memory.

For professionals: this model is what you're debugging against every time you chase a segfault, a memory leak in a long-running service, or unexpected performance cliffs from cache-unfriendly data structures. Tools like Valgrind, AddressSanitizer, and perf all report findings in exactly these terms — stack vs. heap, vtable dispatch, allocation patterns.


Next in this series: a deep dive into concurrency in C++ — std::thread, mutexes, atomics, and the memory model that makes multi-threaded C++ notoriously hard to get right. Follow along if that'd be useful.

Top comments (0)