If you’ve come from languages like Python, JavaScript, or Java, memory management in C and C++ will feel unfamiliar (and maybe even a little bit scary). Those languages handle memory for you. In C and C++, you’re the one in charge.
This direct control over memory is why these languages are still used for operating systems, game engines, and embedded devices. It’s also why they have a reputation for being difficult. But once you understand where your data lives and who’s responsible for cleaning it up, most of the difficulty disappears.
The Two Places Your Data Can Live
In C and C++, your data essentially lives in one of two places: the stack or the heap. Understanding the difference is fundamental to everything else.
The Stack
The stack is where local variables live. When you declare a variable inside a function, it goes on the stack. When that function returns, the variable is automatically destroyed. You don’t have to think about it.
void greet() {
int count = 5; // Lives on the stack
char message[20]; // Also on the stack
// Do things with count and message...
} // count and message are automatically destroyed hereThe stack is fast, predictable, and self-cleaning. It’s called a “stack” because it works like a stack of plates: the last thing you put on is the first thing that comes off. Each function call pushes a new “frame” onto the stack containing its local variables, and returning from that function pops the frame off.
The catch? Stack space is limited (typically a few megabytes), and the size of stack allocations must be known at compile time. You can’t decide at runtime that you need an array of a million integers and put it on the stack. Well, you can try, but you’ll get a stack overflow.
TIPYou might hear the terms FIFO and LIFO when people talk about data structures. FIFO stands for “first in, first out.” Like a queue at a shop, where the first person to arrive is the first person served. LIFO stands for “last in, first out”, and that’s how a stack works. The most recent item you added is the first one you remove. When you call a function, its stack frame goes on top. When that function returns, its frame comes off first, before any of the frames that were already there. This is why local variables from a calling function are still intact when a called function returns — they were lower in the stack and never got touched.
The Heap
The heap is where dynamic memory lives. It’s much larger than the stack, and you can allocate whatever size you need at runtime. The trade-off is that you’re responsible for cleaning up after yourself.
In C, you use malloc to allocate and free to deallocate:
#include <stdlib.h>
void process_data(int num_items) {
// Allocate an array of integers on the heap
int* data = malloc(num_items * sizeof(int));
if (data == NULL) {
// malloc returns NULL if allocation fails
return;
}
// Use the data...
for (int i = 0; i < num_items; i++) {
data[i] = i * 2;
}
// We're done, so we must free the memory
free(data);
}In C++, you use new and delete:
void process_data(int num_items) {
// Allocate an array of integers on the heap
int* data = new int[num_items];
// Use the data...
for (int i = 0; i < num_items; i++) {
data[i] = i * 2;
}
// We're done, so we must delete the array
delete[] data; // Note the [] for arrays
}The heap gives you flexibility, but it demands discipline. Forget to free your memory, and you’ve got a memory leak. Free it twice, and you’ve got undefined behaviour. Use it after freeing, and you’ve got a use-after-free bug. These are the classic memory errors that have caused countless security vulnerabilities over the decades.
Pointers: Addresses in Memory
You can’t talk about memory in C/C++ without talking about pointers. A pointer is simply a variable that holds a memory address. Instead of containing a value directly, it contains the location where a value is stored.
int value = 42;
int* ptr = &value; // ptr now holds the address of value
printf("Value: %d\n", value); // Prints 42
printf("Address: %p\n", ptr); // Prints something like 0x7ffd5e8c3a4c
printf("Via pointer: %d\n", *ptr); // Prints 42 (dereferencing)The & operator gives you the address of something. The * operator (when used on a pointer) gives you what’s at that address. This is called dereferencing.
Pointers are how we work with heap memory. When you call malloc or new, you get back a pointer to the allocated memory. You use that pointer to access and manipulate the data, and eventually you pass that same pointer to free or delete to release the memory.
Pointers vs Arrays in C
This equivalence between arrays and pointers is one of C’s more confusing aspects for newcomers. An array name is essentially a pointer to its first element, and array indexing is just pointer arithmetic with nicer syntax.
int* numbers = malloc(3 * sizeof(int));
// These three lines do exactly the same thing
numbers[0] = 10;
*(numbers + 0) = 10;
*numbers = 10;
// Array indexing is just pointer arithmetic in disguise
numbers[2] = 30; // Same as *(numbers + 2) = 30When you write numbers[2], you’re asking for “the element at index 2”. But what the compiler actually does is take the address stored in numbers, add 2 * sizeof(int) bytes to it, and then read the value at that resulting address. That’s pointer arithmetic: starting from a base address and moving forward by a certain number of elements.
The syntax *(numbers + 2) makes this explicit. numbers + 2 means “the address two integers past where numbers points”, and the * dereferences that address to get the value. The square bracket notation numbers[2] is just a more readable way of writing exactly the same thing.
This also explains why numbers[0], *(numbers + 0), and *numbers are all identical. Index zero means no offset from the base address, so you’re just dereferencing the pointer directly.
Here’s where it gets interesting: because numbers + 2 and 2 + numbers are mathematically equivalent, C actually allows you to write 2[numbers] instead of numbers[2]. They compile to the same thing. You’d never want to write code like that, but it demonstrates just how literally array indexing maps to pointer arithmetic.
The key insight is that C doesn’t really have arrays in the way higher-level languages do. What it has are contiguous blocks of memory and pointers to the start of those blocks. The array syntax is a convenience layer on top of that.
The Dangers: What Can Go Wrong
Let me walk you through the classic memory errors. Knowing what they look like is the first step to avoiding them.
Memory Leaks
A memory leak occurs when you allocate memory but never free it. The memory remains allocated until your program exits, even though you’ve lost all references to it.
void leaky_function() {
int* data = malloc(1000 * sizeof(int));
// Do some work...
if (some_error_condition) {
return; // Oops! We forgot to free data
}
free(data);
}In a short-running program, leaks might not matter much. In a long-running server or an embedded system that runs for months, leaks accumulate and eventually exhaust available memory.
Use After Free
This is when you access memory after you’ve already freed it. The memory might have been reallocated for something else, so you could be reading or writing garbage, or worse, corrupting unrelated data.
int* ptr = malloc(sizeof(int));
*ptr = 42;
free(ptr);
// ptr still holds the old address, but the memory is no longer ours
*ptr = 100; // Undefined behaviour! Anything could happenDouble Free
Freeing the same memory twice corrupts the memory allocator’s internal data structures. This can cause crashes, or worse, exploitable security vulnerabilities.
int* ptr = malloc(sizeof(int));
free(ptr);
free(ptr); // Undefined behaviour!Buffer Overflow
Writing beyond the bounds of an allocated buffer. This is the source of countless security exploits throughout computing history.
char buffer[10];
strcpy(buffer, "This string is way too long for the buffer");
// We've just written past the end of buffer, corrupting whatever was next to itModern C++: Smart Pointers
If you’re writing C++, you have access to smart pointers, which automate memory management while still giving you control when you need it. They’re part of the standard library and should be your default choice for heap allocation in modern C++.
unique_ptr: Single Ownership
A unique_ptr owns its memory exclusively. When the unique_ptr goes out of scope, it automatically deletes the memory. You can’t copy a unique_ptr, but you can move it to transfer ownership.
#include <memory>
void process() {
// Memory is automatically managed
std::unique_ptr<int[]> data = std::make_unique<int[]>(100);
data[0] = 42;
// When data goes out of scope, the memory is automatically freed
// No explicit delete needed, no leaks possible
}This handles most use cases well. The memory is tied to a specific scope, and cleanup happens automatically even if exceptions are thrown.
shared_ptr: Shared Ownership
Sometimes multiple parts of your code need to hold references to the same data, and you want the memory freed only when everyone’s done with it. That’s what shared_ptr is for. It keeps a reference count and deletes the memory when the count reaches zero.
#include <memory>
std::shared_ptr<Widget> create_widget() {
return std::make_shared<Widget>();
}
void use_widgets() {
std::shared_ptr<Widget> w1 = create_widget();
std::shared_ptr<Widget> w2 = w1; // Both now point to the same Widget
// Reference count is 2
} // Both w1 and w2 go out of scope, ref count hits 0, Widget is deletedShared pointers have slightly more overhead than unique pointers due to the reference counting, but they solve real problems when ownership genuinely needs to be shared.
When to Use Raw Pointers
Even in modern C++, raw pointers aren’t obsolete. They’re still the right choice for non-owning references, where you need to point to something but aren’t responsible for its lifetime. Function parameters that observe but don’t own are a common example.
// This function doesn't own the data, it just uses it
void print_value(const int* value) {
if (value != nullptr) {
std::cout << *value << std::endl;
}
}
void example() {
auto data = std::make_unique<int>(42);
print_value(data.get()); // Pass raw pointer, ownership stays with unique_ptr
}The rule of thumb: use smart pointers for ownership, raw pointers for observation.
RAII: The Pattern Behind the Magic
Smart pointers are an example of a broader C++ pattern called RAII: Resource Acquisition Is Initialisation. The idea is that resource management (memory, file handles, network connections, mutexes) should be tied to object lifetimes.
You acquire the resource in the constructor and release it in the destructor. Since destructors are called automatically when objects go out of scope, cleanup happens automatically and reliably, even when exceptions are thrown.
class FileHandle {
public:
FileHandle(const char* path) {
file = fopen(path, "r");
}
~FileHandle() {
if (file) {
fclose(file); // Automatically closed when FileHandle is destroyed
}
}
// Prevent copying (or implement it properly)
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
private:
FILE* file;
};
void read_config() {
FileHandle config("settings.txt");
// Use config...
} // config goes out of scope, destructor runs, file is closedOnce you understand RAII, you’ll start seeing opportunities to apply it everywhere. It’s one of C++‘s most useful patterns.
Practical Tips
After working with C and C++ for a while, here are the habits that have helped me avoid the most problems:
Initialise your pointers. An uninitialised pointer contains garbage, which is worse than a null pointer because it might look valid. In C, initialise to NULL. In C++, use nullptr. A null pointer will crash predictably when dereferenced; a garbage pointer might corrupt memory silently.
int* ptr = NULL; // Safe defaultSet pointers to null after freeing. This prevents use-after-free bugs from silently corrupting memory. If you accidentally use the pointer, you’ll get a crash instead of subtle corruption.
free(ptr);
ptr = NULL;Match your allocations and deallocations. malloc pairs with free. new pairs with delete. new[] pairs with delete[]. Mixing them is undefined behaviour.
Use tools to catch errors. Valgrind on Linux will detect memory leaks, use-after-free, and buffer overflows. Address Sanitizer (ASan) is built into modern compilers and catches similar issues with less overhead. Use them during development.
In C++, prefer stack allocation when possible. If the object’s lifetime matches a scope and it’s not too large, put it on the stack. It’s faster and there’s nothing to clean up.
// Prefer this when possible
void process() {
std::vector<int> data(100); // vector manages its own heap memory internally
// ...
} // data is destroyed, its internal memory is freed
// Over this
void process() {
std::vector<int>* data = new std::vector<int>(100);
// ...
delete data;
}Wrapping Up
Memory management in C and C++ comes down to understanding two things: where your data lives (stack vs heap) and who’s responsible for cleaning it up (automatic vs manual).
In C, you’re always working manually. You call malloc, you call free, and you’re responsible for getting it right.
In C++, you have choices. Smart pointers and RAII let you automate cleanup while retaining control. Modern C++ code should rarely contain explicit new and delete calls outside of low-level infrastructure code.
The key is building good habits: initialise your pointers, use the right tools to catch errors, and in C++, let smart pointers do the work for you. Get these fundamentals right, and memory management becomes straightforward.
As always, thanks for joining me on this journey into embedded development!
