DEV Community

Neural Download
Neural Download

Posted on

Stack vs Heap: Be Sure You Know Where Your Variables Live

https://www.youtube.com/watch?v=TP3_ZWncjqI

Look at this function:

int* make_int(void) {
    int x = 42;
    return &x;          // returning a pointer to a local
}
Enter fullscreen mode Exit fullscreen mode

Now look at the caller:

int* p = make_int();
foo();                  // some other function
printf("%d\n", *p);     // ?
Enter fullscreen mode Exit fullscreen mode

It builds. The compiler may print a warning, but it builds. And then it lies — sometimes printing garbage, sometimes printing 42, sometimes crashing. Welcome to undefined behavior: the C standard makes no promises about what runs.

The variable looks fine in the source. Five lines, perfectly clear. There's nothing wrong with the syntax. But the variable's lifetime ended when make_int returned, and the pointer didn't get the memo.

That bug has a name. By the end of this post, you'll know it, why the language allows it, and the rule that prevents it.

What "stack allocation" actually is

Here's how make_int actually compiled (gcc, x86-64, no optimization):

make_int:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16          ; <-- this line
    mov     DWORD PTR [rbp-4], 42
    lea     rax, [rbp-4]
    leave
    ret
Enter fullscreen mode Exit fullscreen mode

Look at the marked line. sub rsp, 16. That's the entire stack allocation.

rsp is a register. The stack pointer. The compiler subtracts 16 from it. Now there are 16 bytes of memory between where the pointer was and where it is now. That's where the function's locals live.

That's all stack allocation means. Move a register. One instruction.

When the function returns, the compiler emits the opposite — add rsp, 16 (or leave, which does the same thing). The pointer slides back up. The bits down there are still there. The bytes are still there. But the program no longer thinks they belong to anything.

The lifetime ended. The pointer didn't.

Then the next function runs. It needs space too. sub rsp, 16. Same instruction. Same memory. The pointer you took home from make_int still points at that address — but that address is now somebody else's variable. Read it later, and you'll most likely see whatever the next function wrote there.

The local didn't get freed. Its lifetime simply ended. The slot moved on without it.

The heap doesn't work like that

Heap memory is not a register you can move. It's a region the allocator manages on your behalf — and the allocator has to actually do work.

Think of going to a restaurant. You tell the host how many people. The host walks the room, finds an empty table that fits, and leads you to it. That walking — that searching — that's heap allocation.

The allocator tracks which blocks of memory are in use and which are free. When you ask for memory, it searches its bookkeeping data, finds a block big enough, marks it taken, and hands you back a pointer.

Stack allocation is one instruction.
Heap allocation is bookkeeping.

That's most of the performance gap, in one sentence: stack allocation is the bookkeeping. The CPU just moves a register. Heap allocation needs bookkeeping. The allocator has to think about it.

So why bother with the heap at all?

The rule

Because of one rule:

If a value's lifetime is bounded by a single function call, it can live on the stack. If anything has to outlive the function, the stack can't hold it.

The frame is going away. The slot coming back becomes somebody else's storage the moment you return. So if the data has to live longer than that, it has to live somewhere durable — somewhere the function leaving doesn't kill it. For most allocations, that's the heap. Bookkeeping is the price of outliving your scope.

Lifetime determines location. Not size. Not speed. Lifetime.

Different languages enforce this differently. C trusts you to get it right. Rust refuses to compile when lifetimes don't add up. Same rule. Different enforcement.

The bug, named

So look back at the bug from the start.

make_int allocated an int on its stack frame. It returned a pointer to it. The frame popped. The next function reused those bytes. The pointer was now pointing at lies.

That bug has a name. Dangling pointer. When the storage was on the stack and the function returned, it's called use-after-return. (When the storage was on the heap and you called free, it's called use-after-free. Same shape, different storage.)

Why did the language allow it? Because C decided to trust you. The compiler may warn, but the standard doesn't refuse. The lifetime rule is real — it just isn't enforced.

The recap, in order

  • Stack allocation is moving the stack pointer. One instruction.
  • Heap allocation is bookkeeping. The allocator has to find space.
  • Lifetime decides which one you need.
  • A dangling pointer is a pointer that outlived its storage.

So next time you write return &local, or any pointer that might outlive its source — ask one question:

Does this data outlive the frame?

If yes, it can't live on the stack.

Top comments (0)