The Stack, The Heap, the Dynamic Memory Allocation
The Stack
So far, all the data we’ve used in our C programs has been stored in local variables. These variables exist for the duration of the function call—and as soon as the function returns, the variables disappear. All this per-call local-variable storage is part of the function call stack, also known as just the stack.
Don’t confuse the stack with the abstract data type (ADT) that is also called a stack. The stack works like a stack, in the sense that you push and pop elements on one end of the stack. But it’s not just any stack; it’s a special one that the compiler manages for you.
You may have visualized the function call stack when you learned other programming languages. You can draw it with a box for every function call, which gets created (pushed) when you call the function and destroyed (popped) when the function returns. These boxes are called stack frames, or just frames for short (or sometimes, an activation record). For reasons that will become clear soon, when thinking about C programs, it’s important that we draw the stack growing “downward,” so the first call’s frame is at the top of the page.
Here is a mildly interesting C program that uses the stack:
#include <stdio.h>
const float EULER = 2.71828f;
const int COUNT = 10;
// Fill an array, `dest`, with `COUNT` values from an exponential series.
void fill_exp(float* dest) {
dest[0] = 1.0f;
for (int i = 1; i < COUNT; ++i) {
dest[i] = dest[i - 1] * EULER;
}
}
// Print the first `n` values in a float array.
void print_floats(float* vals, int n) {
for (int i = 0; i < n; ++i) {
printf("%f\n", vals[i]);
}
}
int main() {
float values[100];
fill_exp(values);
print_floats(values, 10);
return 0;
}
The values
array is part of main
’s stack frame.
The calls to fill_exp
and print_floats
have pointer variables in their stack frames that point to the first element of this array.
Limitations of the Stack
The key limitation of putting your data on the stack comes from this observation: variables only live as long as the function call. So if you want data to remain after a function call returns, local variables (data in stack frames) won’t suffice.
The consequence of this observation is the following rule: never return a pointer to a local variable. When you do, you’re returning a pointer to data that is about to be destroyed. So it will be a mistake (undefined behavior in C) to use that pointer.
On the other hand, both of these things are perfectly safe:
- Passing a pointer to a local variable as an argument to a function. Our example above does this. This is fine because the data exists in the caller’s stack frame, which still exists as long as the callee is running (and longer).
- Returning a non-pointer value stored in a local variable. The compiler takes care of copying return values into the caller’s stack frame if necessary.
To get a sense for why this is limiting, consider our example above.
It’s kinda weird that we have to write a fill_exp
function that fills in an exponential series into an array that already exists.
It seems like it would be a little more natural to have a create_exp
function that returns an array.
Something like this:
#include <stdio.h>
const float EULER = 2.71828f;
const int COUNT = 10;
// This function has a bug! Do not return pointers to local variables!
float* create_exp() {
float dest[COUNT];
dest[0] = 1.0f;
for (int i = 1; i < COUNT; ++i) {
dest[i] = dest[i - 1] * EULER;
}
return dest;
}
// Print the first `count` values in a float array.
void print_floats(float* vals, int count) {
for (int i = 0; i < count; ++i) {
printf("%f\n", vals[i]);
}
}
int main() {
float* values = create_exp();
print_floats(values, 10);
return 0;
}
That API looks nicer; we can rely on the create_exp
function to both create the array and to fill it up with the values we want.
But this program has a serious bug—in C, it has undefined behavior.
When I ran it on my machine, it just hung indefinitely; of course, subtler and worse consequences are also possible.
To see what’s wrong, let’s think about what might happen with the stack in memory.
All the stack frames, and all the local variables, exist at addresses in memory.
When the call create_exp
returns, its memory doesn’t literally get destroyed; the memory, literally speaking, still exists in my computer.
But when we do the next call to print_floats
, its stack frame takes the space previously occupied by the create_exp
frame.
So its local variables, vals
and count
take up the same space that was previously occupied by the dest
array.
The Heap
This create_exp
example is not en edge case;
in practice, real programs often need to store data that “outlives” a single function call.
C has a separate region of memory just for this purpose.
This region is called the heap.
As above, don’t confuse the heap with the data structure called a heap, which is useful for implementing priority queues. The heap is not a heap at all. It is just a region of memory.
The key distinction between the heap and the stack is that you, the programmer, have to manage data on the heap manually. The compiler takes care of managing data on the stack: it allocates space in stack frames for all your local variables automatically. Your code needs to explicitly allocate and deallocate regions of memory on the heap whenever it needs to store data that lasts beyond the end of a function call.
C comes with a library of functions for managing memory on the heap,
which live in a header called stdlib.h
.
The two most important functions are:
malloc
, for memory allocate: Allocate a new region of memory on the heap, consisting of a number of bytes that you choose. Return a pointer to the first byte in the newly allocated region.free
: Take a pointer to some memory previously allocated withmalloc
and deallocate it, freeing up the memory for use by some future allocation.
Here’s a version of our create_exp
program that (correctly) uses the heap:
#include <stdio.h>
#include <stdlib.h>
const float EULER = 2.71828f;
const int COUNT = 10;
// Allocate a new array containing `COUNT` values from an exponential series.
float* create_exp() {
float* dest = malloc(COUNT * sizeof(float)); // New!
dest[0] = 1.0f;
for (int i = 1; i < COUNT; ++i) {
dest[i] = dest[i - 1] * EULER;
}
return dest;
}
// Print the first `count` values in a float array.
void print_floats(float* vals, int count) {
for (int i = 0; i < count; ++i) {
printf("%f\n", vals[i]);
}
}
int main() {
float* values = create_exp();
print_floats(values, 10);
free(values); // Also new!
return 0;
}
Let’s look at the new lines in more detail. First, the allocation:
float* dest = malloc(COUNT * sizeof(float));
The malloc
function takes one argument: the number of bytes of memory you want.
We want COUNT
floating-point values, so we can compute that size in bytes by multiplying that array length by sizeof(float)
(which gives us the number of bytes occupied by a single float
).
You almost always want to use sizeof
in the argument of your malloc
calls; this is clearer and more portable than trying to remember the size of a given type yourself.
Next, the deallocation:
free(values);
The free
function also takes one argument:
a pointer to memory that you previously allocated with malloc
.
This illustrates the cost of manual memory management:
whenever you allocate memory, you take responsibility for deallocating it yourself.
That’s unlike the stack, where the compiler takes care of managing the lifecycle of the memory for you.
(You should never call free
on a pointer to the stack.)
The Heap Commandments
Because you manually manage the memory on the heap, it’s possible to make mistakes. There are four big things you must avoid:
- Use after free.
After you
free
memory, you are no longer allowed to use it. Your program may not load or store through any pointers into the freed memory. - Double free.
You may only
free
memory once. Do not callfree
on already-freed memory. - Memory leak.
You must pair every call to
malloc
with a corresponding call tofree
. Otherwise, your program will never “recycle” its memory, so the data will grow until you run out of memory. - Out-of-bounds access.
You must only use the pointer returned from
malloc
to access data inside the allocated range of bytes. You can use pointer arithmetic (or array subscripting) to read and write bytes in the range, but nothing before the beginning or after the end of the range.
Even if they seem simple,
C programmers find in practice that these rules are extremely hard to follow consistently.
As software gets more complex, it can be hard to keep track of when memory has been free
d, when it still needs to be free
d, and what to check to ensure that accesses are within bounds.
Personally, I think following these rules is the hardest part of programming in C (and C++).
And these problems, because they trigger undefined behavior in C, can have extremely serious consequences—not just crashes and misbehavior, but security vulnerabilities.
As an example to illustrate the severity of the problem, a 2019 study by Microsoft found that 70% of all the security vulnerabilities they tracked in their software stemmed from these kinds of memory bugs.
Please reflect on the fact that these problems are really only possible in languages like C and C++, where you are responsible for managing the heap yourself. In contrast, Python, Java, OCaml, Rust, and Swift are all memory safe languages, meaning that they manage the heap automatically for you. This is not just a convenience; these languages can rule out out these extremely dangerous memory bugs altogether. While they give up some performance or control to do so, programmers in these languages find these downside to be an acceptable trade-off to avoid the extreme challenge posed by memory bugs.
Catching Memory Bugs
Let’s try writing a program that intentionally violates the commandments.
Specifically, let’s try adding out-of-bounds reads to our create_exp
program:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
const float EULER = 2.71828f;
const int COUNT = 10;
// Allocate a new array containing `COUNT` values from an exponential series.
float* create_exp() {
float* dest = malloc(COUNT * sizeof(float)); // New!
dest[0] = 1.0f;
for (int i = 1; i < COUNT; ++i) {
dest[i] = dest[i - 1] * EULER;
}
return dest;
}
// Print the first `count` values in a float array.
void print_floats(float* vals, int count) {
for (int i = 0; i < count; ++i) {
printf("%f\n", vals[i]);
}
// Let's see what's nearby...
char* ptr = (char*)vals;
for (int j = 0; j < 100; ++j) {
char* byte = ptr - j;
printf("%p: %d %c\n", byte, *byte, *byte);
}
}
// Generate a secret.
char* gen_secret() {
char* secret = malloc(16);
strcpy(secret, "seekrit!");
return secret;
}
int main() {
char* password = gen_secret();
float* values = create_exp();
print_floats(values, 10);
free(values);
free(password);
return 0;
}
This program takes a pointer to our values
array, and it first safely walks forward from there to print out the floats it contains.
Then, it does something sneaky: it starts walking backward from the beginning of the array, immediately leaving the range of legal bytes it’s allowed to read.
Because this program violates the commandments, it might do anything:
it might crash, corrupt memory, or just give nonsense results.
But when I ran this on my machine once, it walked all the way into the memory pointed to by password
and printed out its contents.
Spooky!
This kind of out-of-bounds read is the basis for many real-world security vulnerabilities.
Since I’m telling you that these bugs are extremely easy to create, is there any way of catching them?
Fortunately, GCC has a built-in mechanism for catching some memory bugs, called sanitizers.
To use them, compile your program with the flags -g -fsanitize=address -fsanitize=undefined
:
$ gcc -Wall -Wextra -Wpedantic -Wshadow -Wformat=2 -std=c17 -g -fsanitize=address -fsanitize=undefined heap_bug.c -o heap_bug
Sanitizers check your code dynamically, so this won’t print an error at compile time. Try running the resulting code:
$ qemu heap_bug
If everything works, the sanitizer will print out a long, helpful message telling you exactly what the program tried to do.
Crashing with a useful error is a much more helpful thing to do than behave unpredictably. So whenever you suspect your program might have a memory bug, try enabling the sanitizers to check.
Memory Layout
The stack and the heap are both regions in the giant metaphorical array that is memory.
Both of them need to grow and shrink dynamically:
the program can always malloc
more memory on the heap, or it can call another function to push a new frame onto the stack.
Computers therefore need to choose carefully where to put these memory segments so they have plenty of room to grow as the program executes.
In general:
- The heap starts at a low memory address and grows upward as the program allocates more memory.
- The stack starts at a high memory address and grows downward as the program calls more functions.
By starting these two segments at opposite “ends” of the address space, this strategy maximizes the amount of room each one has to grow.
There are also other common memory segments. These ones typically have a fixed size, so “room to grow” is not an issue:
- The data segment holds global variables and constants, which exist for the entire duration of the program. Aside from the global variables you declare yourself, string literals from your program go here.
- The text segment contains the program, as machine code instructions. Much more discussion of these instructions is coming in a couple of weeks.