Skip to content
VLSI Mentor

C++ Part I · Module 10 · Dynamic Memory and Object Lifetime

Automatic Storage and the Call Stack

Chapter 10.1 established three limits on automatic storage and explained none of them. Two came from the same mechanism: a local object ceases to exist at its closing brace, and the space for every local in the program at once is about eight megabytes. Both are consequences of how a function call is implemented, and that implementation is worth being able to picture rather than recite — because the most dangerous bug in the previous chapter printed the correct answer at one optimisation level and nonsense at another, and no amount of testing distinguishes those two cases. This chapter draws it. A call reserves a block of memory for the function's locals, a return gives that block straight back, and nested calls stack those blocks in a strict discipline that gives the whole arrangement its name. The addresses here are measured from four nested calls on this machine, and they show the direction the stack grows, which is the opposite of the one most people guess. With the picture in place the chapter's second half becomes almost obvious rather than a rule to memorise: a pointer to a local, handed back to a caller, points into a block the caller is about to reuse for something else. That is measured too, at two optimisation levels and under a sanitizer, because the version that appears to work is the one you need to be able to recognise.

Intermediate18 min readC++Part IFundamentalsMemory

Part I · Module 10 · Chapter 10.2 · Automatic Storage and the Call Stack

1. Two Limits, One Mechanism

Chapter 10.1 measured two facts and explained neither:

A local object ends at its closing brace — so returning its address warned, and printed 42 at -O0.

All locals together get about 8 MB — so an 8192 KB array crashed with exit 139.

These are the same fact seen twice. Both follow from how a call is implemented, and that is this chapter.

2. What a Call Actually Does

When a function is called, it needs somewhere to put its locals. It cannot know in advance how many times it will be called or from where, so the space cannot be reserved once at a fixed place. What it can rely on is a discipline:

A function always returns before its caller does.

That is what makes a stack the right structure. On a call, a block of memory — a stack frame — is reserved for that function's locals. On return, the block is released. Because returns happen in the exact reverse order of calls, releasing is always a matter of giving back the most recently taken block.

Three consequences, which are the whole chapter:

Reserving is nearly free. It is an adjustment of one register, not a search. This is why Chapter 10.1 §6 could say automatic storage costs essentially nothing.

Release is automatic and exact. Nothing can forget it. The frame goes when the function returns, which is where the term automatic storage comes from.

And the space is reused immediately. The block a returning function gives back is the block the next call takes. That sentence is §6's bug.

3. The Frames, Measured

Four nested functions, each printing the address of one local:

Azvya Education Pvt. Ltd.VLSI Mentor
frames.cpp — complete program
#include <iostream>

static void level3()
{
    int x = 3;
    std::cout << "    level3   local at " << &x << '\n';
}

static void level2()
{
    int x = 2;
    std::cout << "  level2     local at " << &x << '\n';
    level3();
}

static void level1()
{
    int x = 1;
    std::cout << "level1       local at " << &x << '\n';
    level2();
}

int main()
{
    int m = 0;
    std::cout << "main         local at " << &m << '\n';
    level1();
    return 0;
}

Measured output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
main         local at 0x16bd22728
level1       local at 0x16bd226fc
  level2     local at 0x16bd226dc
    level3   local at 0x16bd226bc

Each deeper call sits at a lower address. main's local is at …728, and by the time we are three calls deep we are at …6bc — 108 bytes lower.

Two things to note before the picture.

The stack grows downward, on this and on most mainstream platforms. It is a property of the ABI, not of C++, and the standard says nothing about it — so use it to understand the mechanism, never to write code that depends on it. Chapter 8.4 §7 made the matching point about comparing unrelated pointers.

The absolute numbers move between runs. The same binary, run again:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
main         local at 0x16d262728

…22728 became …62728. The stack's base is randomised by the operating system, so if you reproduce this you will get different digits. The differences are what mean something.

4. The Picture

Four stack frames drawn as boxes in a single column, highest address at the top. The top box is main's frame, holding a local m at address 0x16bd22728. An arrow labelled calls points down to the second box, level1's frame, holding a local x at 0x16bd226fc, which is a lower address. A second arrow labelled calls points down to level2's frame at 0x16bd226dc, and a third to level3's frame at 0x16bd226bc, the lowest address and the most recently created frame. Each call adds a frame below the previous one and each return removes the bottom frame, so frames are always released in the exact reverse order of their creation.main()local m at 0x16bd22728level1()local x at 0x16bd226fclevel2()local x at 0x16bd226dclevel3()local x at 0x16bd226bccallscallscalls12
Figure 1 — the four frames from the measured run above, drawn in address order with the highest address at the top. Each call reserves a new frame below the caller's, so the innermost function occupies the lowest addresses; each return releases the bottom frame and nothing else. The addresses are from one run on this machine and move between runs, but the direction and the strict nesting do not.

Read it downward for calls and upward for returns. When level3 returns, its frame — the bottom one — is released, and level2's becomes the innermost again. There is never a question of which frame to release, which is precisely why no bookkeeping is needed.

And the thing the picture makes visible: the released block is the next block handed out. When level3 returns and level2 calls something else, that something else gets the same addresses level3 was using.

5. How Big Is a Frame?

The differences above were 44, 32 and 32 bytes, which vary because the functions differ. A recursive function gives a uniform answer:

Azvya Education Pvt. Ltd.VLSI Mentor
step.cpp — the relevant part
static std::uintptr_t addr_at_depth(int depth)
{
    volatile int local = depth;
    std::uintptr_t here = reinterpret_cast<std::uintptr_t>(&local);
    if (depth > 0) {
        std::uintptr_t deeper = addr_at_depth(depth - 1);
        std::printf("  depth %d local at 0x%llx   step to next frame: %lld bytes\n",
                    depth, (unsigned long long)here,
                    (long long)here - (long long)deeper);
    }
    return here;
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  depth 0 local at 0x16d8a6608   (deepest)
  depth 1 local at 0x16d8a6648   step to next frame: 64 bytes
  depth 2 local at 0x16d8a6688   step to next frame: 64 bytes
  depth 3 local at 0x16d8a66c8   step to next frame: 64 bytes
  depth 4 local at 0x16d8a6708   step to next frame: 64 bytes

64 bytes per frame, perfectly uniform, for a function holding one int and one pointer-sized value. The rest is the saved return address, the saved frame pointer, alignment padding and the compiler's working space — at -O0, where nothing is optimised away.

That number explains Chapter 10.1's third limit arithmetically. With an 8176 KB stack and 64-byte frames, this function can nest roughly 130,000 deep before the stack is exhausted. A recursion with no base case does not run forever; it runs about 130,000 times and then the process dies the same way Chapter 10.1's 8192 KB array did. Chapter 10.6 will contrast that with how a dynamic failure announces itself.

6. Why Returning a Pointer to a Local Is Always Wrong

Now the rule half of the chapter, and with the picture in place it is a one-line argument.

Azvya Education Pvt. Ltd.VLSI Mentor
outlive.cpp — complete program
#include <cstdio>

int* make()
{
    int local = 42;
    return &local;
}

int main()
{
    int* p = make();
    std::printf("%d\n", *p);
    return 0;
}

make's frame is released the instant it returns. p holds an address inside a block that no longer belongs to anyone — and that will be handed to the very next call. There is nothing subtle about it: the object is gone and the address is not.

The compiler sees this one:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
outlive.cpp:4:13: warning: address of stack memory associated with local
              variable 'local' returned [-Wreturn-stack-address]

The reference form is the identical bug and warns identically:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
outlive_ref.cpp:4:12: warning: reference to stack memory associated with local
              variable 'local' returned [-Wreturn-stack-address]

This is Chapter 8.8 §3's measurement seen from the other side. There, the subject was a pointer that outlived its target; here, the mechanism that makes the target disappear.

7. And Why It Appears to Work

This is the part worth the Critical rating. Same source file, two optimisation levels:

BuildOutput
clang++ -O0 outlive.cpp42
clang++ -O2 outlive.cpp-273071872

At -O0 it prints the right answer. Not a plausible wrong answer — the right one.

The reason is §4's last observation. At -O0 nothing happens between make returning and printf reading *p, so the bytes at that address still hold 42: the frame was released but nobody has reused it yet. At -O2 the compiler arranges the code differently, something else occupies those bytes, and the read produces whatever is there now.

Three conclusions, and the third is the one that matters:

Testing does not find this. A test suite built at -O0 passes.

Nor does "it works on my machine". It worked at one optimisation level on one machine on one run.

The behaviour is undefined, which Chapter 1.3 §5 established means the standard imposes no requirement at all — not "returns garbage", but anything. 42 is a permitted outcome of a broken program, and the most misleading one.

The same reference version shows the same split: 42 at -O0, -273071872 at -O2.

8. What the Sanitizer Says

AddressSanitizer names it, with one wrinkle worth knowing because I hit it: this check is not on by default.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ clang++ -std=c++17 -fsanitize=address -g outlive.cpp -o outlive_asan
$ ./outlive_asan                                   # reports nothing
$ ASAN_OPTIONS=detect_stack_use_after_return=1 ./outlive_asan
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
==72612==ERROR: AddressSanitizer: stack-use-after-return on address
0x0001030b1020 at pc 0x000100cdca34 ...
Address 0x0001030b1020 is located in stack of thread T0 at offset 32 in frame
SUMMARY: AddressSanitizer: stack-use-after-return outlive.cpp:8 in main

stack-use-after-return — the name is the diagnosis. Note it points at line 8, the printf in main, not at line 4 where the bug was written. The sanitizer reports where the invalid access happened; the compiler warning reports where the invalid address was produced. You generally want both.

My first run of this produced no output at all, and the reason was the missing option rather than a clean program. A sanitizer that reports nothing is only evidence if you know which checks were enabled.

9. What Is Safe to Return

For completeness, since §6 is phrased as a prohibition:

By value — anything. The object is copied into the caller's frame, or constructed there directly. Always safe.

A pointer or reference to something the caller already owns, typically passed in as a parameter. The lifetime belongs to the caller and the function is only borrowing it. This is what const T& parameters (Chapter 6.5) and the view types of Chapter 9.6 rely on.

A pointer or reference to a static object, with the single-shared-instance caveat from the callout.

A pointer or reference to a member of an object the caller owns — Chapter 11.6's this makes this routine for member functions.

A pointer to something dynamically allocated — safe from a lifetime standpoint, and the thing that then needs an owner.

The pattern: it is safe when the object's lifetime is governed by something that outlives the call. A frame never is.

10. What to Actually Do

Picture the frame. A call reserves a block, a return releases it, releases are always in reverse order.

Never return a pointer or reference to a local or a by-value parameter. No exceptions.

Return by value first. It is correct, usually free, and what the containers are designed for.

Treat -Wreturn-stack-address as an error. It is reporting a certainty, not a risk.

Do not trust -O0 results for lifetime bugs. Measured: the broken program printed the right answer there.

State which sanitizer checks were on. stack-use-after-return needs detect_stack_use_after_return=1; without it the run is silent.

Keep large objects and deep recursion off the stack. 64-byte frames against 8176 KB is roughly 130,000 levels.

Never depend on the growth direction or on comparing addresses from different frames — the direction is an ABI detail, and the absolute values move between runs.

11. Mistakes Worth Making Once

Returning &local. Warns; prints 42 at -O0; garbage at -O2.

Returning a reference to a local. Identical bug, identical warning, identical split.

Returning &parameter. A by-value parameter is a local.

Concluding a lifetime bug is fixed because the test passed. The test was probably built at -O0.

Making it static to silence the warning. Now there is one shared object for the whole program.

Trusting a silent sanitizer. The check may not be enabled — mine was not.

Assuming the sanitizer points at the bug. It points at the invalid access (line 8); the warning points at the invalid address (line 4).

Writing deep recursion without a bound. About 130,000 frames here, then the same SIGSEGV as an oversized array.

Reading the measured addresses as fixed. They moved between two runs of the same binary.

12. Interview Relevance

"What happens when a function is called?" A frame is reserved for its locals; on return the frame is released; nesting guarantees releases are in reverse order, which is why reserving and releasing are each a single register adjustment. Mentioning that this is why automatic storage is nearly free is the complete answer.

"Why can't you return a pointer to a local?" Because the return releases the frame, so the address is invalid before the caller gets it — and the released block is the next one handed out. This is a very common question and the strong answer includes why it often appears to work.

"Have you ever seen undefined behaviour that looked correct?" This is the ideal example: the same source printing 42 at -O0 and -273071872 at -O2.

"How would you find this in a large codebase?" -Wreturn-stack-address at compile time for the direct cases, and AddressSanitizer with detect_stack_use_after_return=1 at runtime for the indirect ones. Knowing that the option is not on by default is a genuine detail.

"Which way does the stack grow?" Downward on mainstream platforms — measured here, each deeper frame 32 to 64 bytes lower — and the right follow-up is that C++ guarantees nothing about it.

13. What You Should Be Able to Say Now

  1. A call reserves a frame for the locals; a return releases it — and nesting means releases are always in reverse order.
  2. That is why automatic storage is nearly free and why its release cannot be forgotten.
  3. Measured: four nested calls at …728, …6fc, …6dc, …6bc — each deeper frame at a lower address.
  4. The stack grows downward on this platform, which is an ABI property and not a C++ guarantee.
  5. The absolute addresses move between runs — the same binary gave …22728 then …62728.
  6. Measured: a uniform 64 bytes per frame at -O0 for one int, which against 8176 KB is about 130,000 levels of recursion.
  7. The released block is the next block handed out — the single fact behind the rule.
  8. Never return a pointer or reference to a local, including a by-value parameter; both forms warn [-Wreturn-stack-address].
  9. Measured: the broken program printed 42 at -O0 and -273071872 at -O2 — undefined behaviour's most misleading outcome.
  10. ASan calls it stack-use-after-return and needs detect_stack_use_after_return=1, or it reports nothing.
  11. The sanitizer points at the access; the compiler warning points at the address.
  12. Return by value first; a pointer is safe only to something whose lifetime outlives the call.

14. Exercises

Exercise 1 — read the picture. Given the four measured addresses, say which frame is released first when level3 returns, and what happens to those addresses if level2 then calls a different function.

Exercise 2 — predict the diagnostic. Which warn, and with what: (a) int* f() { int x = 0; return &x; } (b) int& g() { int x = 0; return x; } (c) int* h(int p) { return &p; } (d) int* k(int* p) { return p; } (e) int* m() { static int x = 0; return &x; }.

Exercise 3 — explain the 42. Write the explanation you would give in a code review for why the -O0 build prints the correct value, in terms of frame release and reuse.

Exercise 4 — the depth limit. With 64-byte frames and an 8176 KB stack, compute the approximate recursion depth, and say what the failure looks like.

Exercise 5 — fix four ways. For int* make() { int local = 42; return &local; }, give the static fix, the output-parameter fix, the dynamic fix and the return-by-value fix. Rank them and justify the ranking using the callout.

Exercise 6 — design the detection. You inherit a codebase suspected of this bug. Describe the compile-time and run-time steps, and state the one option without which the runtime step is silent.

Exercise 7 — argue it. A colleague says the growth direction lets them check whether one object is "deeper" than another. Explain what is wrong, citing two measurements from this chapter.

15. Summary

  • A call reserves a frame; a return releases it, always in reverse order — which is why it is nearly free and cannot be forgotten.
  • Measured: four nested frames at …728 / …6fc / …6dc / …6bc — deeper is lower, by 44, 32 and 32 bytes.
  • Downward growth is an ABI detail, not a guarantee, and the absolute addresses moved between runs.
  • Measured: 64 bytes per frame at -O0 → roughly 130,000 recursion levels against an 8176 KB stack.
  • The released block is the next one handed out — the whole reason for the rule.
  • Never return a pointer or reference to a local or by-value parameter. Both forms warn [-Wreturn-stack-address].
  • Measured: 42 at -O0, -273071872 at -O2 — the same source.
  • ASan names it stack-use-after-return, only with detect_stack_use_after_return=1, and points at the access rather than the declaration.
  • Return by value first; allocate only when Chapter 10.1's limits apply, and answer Chapter 10.9's question before you do.

The picture is now complete for everything whose lifetime is decided by a brace, and that is where this module's subject properly begins. If a frame is the wrong place for an object because the object must outlive the call, and a static object is the wrong place because there would only ever be one of it, then what is needed is a way to ask for storage that belongs to no scope at all — storage that begins when you say so and ends when you say so, with the compiler declining to decide either. C++ has two operators for exactly that, and the next chapter takes them apart carefully, because the thing most readers get wrong about them is not the syntax. Each operator does two jobs rather than one, in opposite orders, and seeing the four steps separately — measured, by intercepting the storage half so it prints while it happens — is what makes the rest of the module legible.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.