Understanding Stack, Heap, and Thread Memory: A Beginner's Journey

C code snippet:Understanding Stack, Heap, and Threads in Memory.

I saw this snippet of C code on X with the caption “Are you a 1% dev? Can you explain the output?”. I most definitely couldn not. So I typed it up and ran and wrote this blog along.

#include <stdio.h>

char *getMessage(void);

int main(void)
{
    puts(getMessage());
    return 0; 
}
char *getMessage(void)
{
    char buffer[80];
    sprintf(buffer, "Hello from getMessage subroutine!");
    return buffer;
}

When I tried to compile this, gcc was like:

checking-out.c:16:12: warning: function returns address of local variable [-Wreturn-local-addr]

And when I ran it anyway? Boom! Segmentation fault. Ok.

The Stack Problem

Turns out, I already forgot something very basic from Computer Architecture. So, when you create local variables in a function (like the buffer array in the getMessage function), they get stored in the stack. And when your function returns, all it’s variables on the stack gets cleaned up - like it never existed.

So in the above code, I was basically returning the address of something that was about to get deleted. It’s like giving someone directions to your house after you’ve moved out. Not gonna work!

The Fix?

There are a few ways:

  1. Make the buffer static (it’ll stick around):
char *getMessage(void) 
{
    static char buffer[80];  // This sticks around after getMessage returns!
    sprintf(buffer, "Hello from getMessage subroutine!");
    return buffer;
}
  1. Use the heap (manually manage memory):
char *getMessage(void) 
{
    char *buffer = malloc(80);  // Gets memory from the heap, save address in the stack
    sprintf(buffer, "Hello from getMessage subroutine!");
    return buffer;
}

Heap?

I really needed a refresher on this.

The heap is a space in memory that works like when you reserve a table at a restaurant. The table is there for you, but weird enough for this restaurant, you have to clean up your table after you’re done. Else you will leave security vulnerabilities. You will use the free()function for cleaning up in C.

Unlike the stack which works like ordering a drink at a counter, you have to drink it right there and then, and the counter gets cleaned up when you leave. That’s the equivalent of a function returning to it’s caller.

When you use malloc, you get memory from the heap. But the address of that memory is stored on the stack.

This tripped me up a bit. So if we dynamically allocate memory with malloc, the pointer to that memory (like the buffer variable) is on the stack! It’s just storing the address to the memory space on the heap. And when the function returns that same buffer variable, it’s cleared from the stack but returns the address to the memory on the heap. So main can access that memory on the heap. This is because the address (buffer) was stored in the stack, now the function returns that same address to main, so main can access the memory on the heap as well. To make this clearer for myself, I did a timeline thing for the lifetime of the variable in the dynamic memory solution:

char *getMessage(void) 
{
    char *buffer = malloc(80);
    sprintf(buffer, "Hello from getMessage subroutine!");
    return buffer;
}

int main(void)
{
    char *msg_ptr = getMessage(); // this gets the address of the memory on the heap and storeds it back on the stack.
}

Timeline for Dynamic Memory

1. At char *buffer = malloc(80);:

  • buffer (variable): On the stack, holding the address of the allocated heap memory
  • Heap memory: Allocated but uninitialized

2. After sprintf(buffer, ...):

  • buffer: Still on the stack, pointing to the heap memory
  • Heap memory: Contains the string “Hello from getMessage subroutine!”

3. After return buffer;:

  • buffer (stack variable): Destroyed when the function returns
  • Heap memory: Remains allocated and is accessible via the returned pointer
  • Like
int main(void)
{
    char *msg_ptr = getMessage(); // this gets the address of the memory on the heap and storeds it back on the stack. now we can use it in main here.
}

4. If free(buffer); is called in the caller:

  • Heap memory: Deallocated and returned to the system
  • Returned pointer: Becomes invalid (dangling pointer)

Stacks and Threads

While at this stack-heap thing, I remembered a regular question in my OS class asking whether threads share the same stack and code? Same code, yes but different/private stacks. Each thread gets its own private stack! So each thread can have its own local variables without messing with other threads.

The heap though is shared between all threads. Processes - which can have multiple threads - do have their own heap space. It’s like: - Stacks: Each thread gets their own - Heap: Everyone shares one - Code: Everyone shares one

But everyone sharing the heap introduces a problem, race conditions. If two threads try to access the same heap memory at the same time, things can get weird. We need a way to restrict access to the heap.

Synchronization

We can use mutexes, semaphores, and other synchronization primitives - basically like padlocks - to prevent race conditions. Here’s a simple example:

#include <pthread.h>
#include <stdio.h>

int shared_counter = 0;           // Shared variable on heap
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // Our padlock

void* increment_counter(void* arg) {
    for(int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&mutex);   // Lock the padlock
        shared_counter++;             // Safe to modify shared data
        pthread_mutex_unlock(&mutex); // Unlock for others
    }
    return NULL;
}

int main(void) {
    pthread_t thread1, thread2;
    
    // Create two threads that increment the same counter
    pthread_create(&thread1, NULL, increment_counter, NULL);
    pthread_create(&thread2, NULL, increment_counter, NULL);
    
    // Wait for both threads to finish
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    
    printf("Final counter value: %d\n", shared_counter);
    // Without mutex: unpredictable, less than 2000000
    // With mutex: always 2000000
    return 0;
}

Without the mutex, both threads might try to increment shared_counter at the same time, leading to lost updates. The mutex ensures only one thread can modify the counter at a time.

Memory Layout Visualization

Here’s a visualization of how our program’s memory is organized:

Memory Layout
+------------------------------------------+
|                                          |
|              Program Text                 | → Shared between threads
|         (Instructions/Code)               |
|                                          |
+------------------------------------------+
|                                          |
|           Global/Static Data             |
|     (Initialized & Uninitialized)        |
|                                          |
+------------------------------------------+
|                                          |
|               Heap ↓                     | → Grows downward
|         (Dynamic Memory)                 |
|                                          |
|    +--------------------------------+    |
|    |     Allocated Block (80 bytes) |    |
|    |   "Hello from getMessage..."   |    |
|    |   Address: 0x55f842c56260     |    |
|    +--------------------------------+    |
|                                          |
|                                          |
+------------------------------------------+
|                  ↕                       |
|         (Available Memory)               |
|                                          |
+------------------------------------------+
|                                          |
|               Stack ↑                    | → Grows upward
|         (Automatic Memory)               |
|                                          |
|    +--------------------------------+    |
|    | main()                         |    |
|    |   msg_ptr: 0x55f842c56260     |    |
|    +--------------------------------+    |
|                                          |
|    +--------------------------------+    |
|    | getMessage()                   |    |
|    |   buffer: 0x55f842c56260      |    |
|    |   (temporary stack frame)      |    |
|    +--------------------------------+    |
|                                          |
+------------------------------------------+

When getMessage() returns: 1. Its entire stack frame is popped off 2. The heap memory (80 bytes) stays allocated 3. main() can still access the heap data through msg_ptr 4. Only when free() is called will the heap memory be released

What I Learned

  1. Local variables live on the stack and die when the function returns
  2. If you need data to outlive your function, use static variables or the heap
  3. Each thread gets its own stack, but they share the heap and code
  4. When threads share resources, you need synchronization to prevent chaos

Wild how one simple segmentation fault led me to all of this.


Note: I’m still learning this stuff, so if you notice something I got wrong, feel free to yell at me