Heap Memory Allocation: malloc(),calloc(), realloc(), free()

Chapter 84: Heap Memory Allocation: malloc()calloc()realloc()free()

Chapter Objectives

Upon completing this chapter, you will be able to:

  • Understand the concept of the heap and its role in dynamic memory management within a Linux process.
  • Implement dynamic memory allocation in C programs using the standard library functions: malloc()calloc()realloc(), and free().
  • Analyze and explain the causes and consequences of memory fragmentation, including both internal and external fragmentation.
  • Debug common memory-related errors such as memory leaks, dangling pointers, and double-frees using tools like Valgrind.
  • Evaluate the trade-offs of different memory allocation strategies in resource-constrained embedded systems.
  • Develop robust C applications for the Raspberry Pi 5 that manage memory efficiently and safely.

Introduction

In the world of embedded systems, memory is a finite and precious resource. Unlike desktop systems where gigabytes of RAM are commonplace, an embedded device might operate with only a few megabytes. Every byte counts. In previous chapters, we explored static and stack-based memory allocation, which are predictable and fast but lack flexibility. Static variables have a fixed size and lifetime, determined at compile time. Stack variables, while dynamically created at runtime, are confined to the scope of a function and must have their size known at compile time. But what happens when a program’s memory needs are unknown until it is running? How does an embedded web server handle a variable number of incoming connections, or a data logger store sensor readings of unpredictable length?

The answer lies in dynamic memory allocation, a mechanism that allows a program to request memory from the operating system at runtime. This memory is allocated from a large pool known as the heap. This chapter delves into the fundamental C library functions that serve as the gateway to the heap: malloc()calloc()realloc(), and free(). These functions provide the power to create data structures that can grow and shrink as needed, enabling the development of complex, adaptive applications. However, this power comes with significant responsibility. Improper use of dynamic memory is one of the most common sources of bugs in C programming, leading to crashes, performance degradation, and security vulnerabilities. We will explore the mechanisms behind heap management, the persistent challenge of memory fragmentation, and the critical importance of disciplined memory deallocation. By the end of this chapter, you will not only know how to use these essential functions but also understand the underlying principles required to build reliable and efficient embedded Linux applications on your Raspberry Pi 5.

Technical Background

The Process Memory Map and the Heap

To truly understand dynamic memory allocation, we must first visualize where the memory comes from. When the Linux kernel loads a program into memory, it doesn’t just place the executable code at a random location. Instead, it organizes the process’s virtual address space into several distinct segments. This organization is known as the process memory map.

At the lowest addresses, we find the Text segment, which contains the compiled, executable machine code of the program. This segment is typically marked as read-only to prevent a program from accidentally or maliciously modifying its own instructions. Just above the Text segment are the Data and BSS segments. The Data segment stores global and static variables that are initialized with a specific value in the source code. The BSS (Block Started by Symbol) segment holds global and static variables that are uninitialized or initialized to zero. The kernel initializes this entire segment to zeros when the program starts.

Above the BSS segment is where our focus lies: the heap. The heap is a region of memory available for dynamic allocation. Unlike the fixed-size segments below it, the heap is dynamic; it can grow and shrink throughout the life of the process. As a program requests more memory using functions like malloc(), the heap grows upwards into higher memory addresses. Conversely, when memory is released, the heap can, in principle, shrink. The top of the heap is marked by a pointer known as the program break or brk.

At the very top of the user-space memory map is the stack. The stack is used for static memory allocation, storing local variables, function parameters, and return addresses. It operates on a Last-In, First-Out (LIFO) basis. Every time a function is called, a new “stack frame” is pushed onto the stack. When the function returns, its frame is popped off. The stack grows downwards, towards the heap. The large, empty space between the heap and the stack provides room for both to grow without colliding—a condition that would be catastrophic for the program.

The Kernel’s Role: brk() and sbrk()

The C standard library functions like malloc() are not, in themselves, system calls. They are library functions that act as an interface to the heap. The actual management of the process’s address space, including the expansion of the heap, is handled by the kernel. The C library’s memory allocator acts as a middleman. When a program requests a large chunk of memory, the allocator might not have a suitable block available in its currently managed space. In this case, it must ask the kernel for more memory.

It does this using the brk() and sbrk() system calls. The brk() system call sets the program break to a specific address. If this new address is higher than the current break, the heap expands, and the process’s virtual memory space grows. If the address is lower, the heap shrinks. The sbrk() call is a relative version of brk(); it increments the program break by a specified amount, effectively extending the heap.

This interaction is a crucial performance optimization. System calls involve a context switch from user mode to kernel mode, which is a relatively expensive operation. If every single malloc() call resulted in a system call, the performance overhead would be immense, especially for programs that perform many small allocations. Instead, the C library’s allocator requests large chunks of memory from the kernel using sbrk() and then manages this chunk internally. It carves out smaller pieces from this chunk to satisfy individual malloc() requests from the application. This amortizes the cost of the system call over many allocations, making the process much more efficient.

The C Library Allocator: malloc() and free()

When you call malloc(size), you are not talking directly to the kernel. You are making a request to the memory allocator, which is part of the C standard library (like glibc). The allocator’s job is to manage the memory region obtained via sbrk() and find a contiguous block of at least size bytes for you.

To do this, the allocator must keep track of which parts of the heap are in use and which are free. A common technique is to maintain a free list, which is a linked list of all the free memory blocks. Each block in this list contains a small header with metadata, such as the size of the block and a pointer to the next free block.

When malloc() is called, the allocator traverses this free list, looking for a block that is large enough to satisfy the request. How it chooses a block is determined by its placement algorithm. Common algorithms include:

  • First-Fit: The allocator scans the free list from the beginning and chooses the first block that is large enough. This approach is fast but can lead to fragmentation at the beginning of the heap.
  • Next-Fit: Similar to first-fit, but the allocator starts its search from where the last search left off. This can be slightly faster as it distributes the search effort across the list.
  • Best-Fit: The allocator searches the entire free list to find the smallest block that is large enough for the request. This minimizes wasted space in the chosen block but can be slower due to the exhaustive search. It can also lead to many tiny, unusable free blocks.

Once a suitable block is found, the allocator may need to split it. If the found block is larger than the requested size, the allocator will divide it into two parts. One part, exactly the size requested by the user (plus a little for metadata), is marked as “in use” and a pointer to it is returned to the application. The remaining part is added back to the free list as a new, smaller free block.

The free(ptr) function performs the reverse operation. When you pass a pointer to free(), the allocator looks at the metadata header associated with that memory block. It marks the block as “free” and adds it back to the free list. A crucial step here is coalescing. If the newly freed block is adjacent to another free block (or two, one on each side), the allocator will merge them into a single, larger free block. This is vital for combating fragmentation, as it reclaims larger contiguous memory regions that can be used for future large allocations.

The Peril of Fragmentation

Fragmentation is the bane of dynamic memory allocation, especially in long-running embedded systems. It refers to a state where there is enough total free memory available on the heap, but it is divided into small, non-contiguous blocks, rendering it unable to satisfy a request for a large contiguous block. There are two main types of fragmentation.

External fragmentation occurs when the free memory is scattered across the heap in many small chunks. Imagine a heap with 1MB of total free space, but the largest contiguous free block is only 1KB. In this scenario, a request for malloc(2048) would fail, even though there is plenty of memory available in total. This is the direct result of the allocation and deallocation pattern of the application. Coalescing adjacent free blocks helps mitigate this, but it cannot solve the problem entirely if allocated blocks are interspersed with free ones.

Internal fragmentation, on the other hand, is waste within an allocated block. It arises from several sources. First, the allocator itself needs to store metadata (like the block size) with each allocation, which consumes a few extra bytes. Second, for alignment purposes, allocators often round up the requested size to the nearest multiple of 8 or 16 bytes. If a user requests 21 bytes, the allocator might provide a 24-byte or even 32-byte block. The unused bytes within that block constitute internal fragmentation. While this might seem minor, in a system making thousands of small allocations, this overhead can add up to a significant amount of wasted memory.

Variations on a Theme: calloc() and realloc()

Besides malloc() and free(), the C library provides two other important functions for dynamic memory management.

calloc(num, size) stands for “contiguous allocation”. It serves a similar purpose to malloc() but takes two arguments: the number of elements (num) and the size of each element (size). It allocates a block of memory large enough to hold an array of num elements, each of size bytes. The key difference from malloc() is that calloc() initializes the allocated memory to zero. This is a valuable safety feature, preventing bugs that arise from using uninitialized memory. malloc(), in contrast, leaves the contents of the allocated memory indeterminate.

realloc(ptr, new_size) is used to change the size of a previously allocated memory block. This is incredibly useful when you need a data structure to grow or shrink. realloc() is a complex function with several possible outcomes:

  1. Shrinking: If new_size is smaller than the original size, the block is often shrunk in place. The excess memory at the end of the block is returned to the free list.
  2. Growing in Place: If new_size is larger than the original size, the allocator first checks if there is enough free space immediately following the current block. If so, it can expand the block in place by consuming that adjacent free space. This is the most efficient outcome.
  3. Moving: If the block cannot be expanded in place, realloc() will allocate a completely new block of new_size bytes somewhere else on the heap. It then copies the contents of the old block to the new one and frees the old block. This operation can be slow due to the memory copy. Crucially, the pointer returned by realloc() may be different from the one you passed in. It is essential to always update your pointer variable, like this: my_ptr = realloc(my_ptr, new_size);.
Function Signature Purpose Memory Initialization Key Considerations
malloc() void* malloc(size_t size); Allocates a single block of memory of a specified size in bytes. None. The allocated memory contains indeterminate garbage values. Fastest and most common allocation function. Always check the return value for NULL.
calloc() void* calloc(size_t num, size_t size); Allocates memory for an array of elements. Zero-initialized. All bytes in the allocated block are set to zero. Slightly slower than malloc() due to initialization. Useful for preventing bugs from uninitialized data. Helps prevent integer overflow on num * size calculation.
realloc() void* realloc(void* ptr, size_t new_size); Resizes a previously allocated memory block. Partial. If the block is grown, the new portion is uninitialized. The old data is preserved. Can be slow if the block must be moved. Always assign the result to a temporary pointer to avoid losing the original pointer on failure.
free() void free(void* ptr); Deallocates a memory block, returning it to the heap. N/A Must be called once for every successful allocation to prevent memory leaks. After freeing, set the pointer to NULL to avoid dangling pointers.

If realloc() is called with a NULL pointer, it behaves exactly like malloc(new_size). If it’s called with a new_size of zero, it behaves like free(ptr).

graph TD
    subgraph "realloc(ptr, new_size)"
        A[Start] --> B{ptr == NULL?};
        B -- Yes --> C["Behave like malloc(new_size)"];
        B -- No --> D{new_size == 0?};
        D -- Yes --> E["Behave like free(ptr)<br>Return NULL"];
        D -- No --> F{new_size < old_size?};
        F -- Yes --> G[Shrink block in place<br>Return original ptr];
        F -- No --> H{Enough free space<br>adjacent to current block?};
        H -- Yes --> I[Expand block in place<br>Return original ptr];
        H -- No --> J[1. Allocate new block of new_size];
        J --> K[2. Copy data from old block to new block];
        K --> L[3. Free the old block];
        L --> M[Return pointer to <b>new</b> block];
    end


    
    A --> End_Success(End);
    C --> End_Success(End);
    E --> End_Success(End);
    G --> End_Success(End);
    I --> End_Success(End);
    M --> End_Success(End);

    classDef start fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef success fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff;
    classDef decision fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef process fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;

    class A,End_Success start;
    class C,E,G,I,M,Success,End success;
    class B,D,F,H,Decision decision;
    class J,K,L,Process process;

Understanding these functions and the underlying heap mechanics is the first step. The next is applying this knowledge in practice, navigating the potential pitfalls, and building robust systems.

Practical Examples

Theory provides the foundation, but true understanding comes from hands-on practice. In this section, we will use the Raspberry Pi 5 to explore dynamic memory allocation in a real embedded Linux environment. We will write, compile, and execute C code that demonstrates the core memory functions, and we will use standard Linux tools to observe their effects.

Setup: Cross-Compilation Toolchain

While you can compile code directly on the Raspberry Pi 5, professional embedded development almost always uses a cross-compilation toolchain. This allows you to compile code on your powerful desktop development machine for the target architecture (in our case, ARM64 for the Pi 5).

Tip: Using a cross-compiler is significantly faster and keeps your development tools off the target device, which should be kept as clean as possible to mirror a production environment.

For this chapter, we assume you have a working ARM64 cross-compilation toolchain set up on your Linux development host. A common toolchain can be obtained from ARM or through your distribution’s package manager (e.g., gcc-aarch64-linux-gnu).

Example 1: Basic malloc() and free()

Let’s start with the fundamentals. Our first program will allocate a small block of memory, store some data in it, print the data, and then release the memory.

File: basic_malloc.c

C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    char *buffer;
    const char *message = "Hello from the heap!";

    // 1. Allocate memory
    // We request enough space for our message, plus one byte for the null terminator.
    printf("Attempting to allocate %zu bytes...\n", strlen(message) + 1);
    buffer = (char *)malloc(strlen(message) + 1);

    // 2. Check for allocation failure
    // malloc() returns NULL if it cannot satisfy the request. This is a critical check!
    if (buffer == NULL) {
        fprintf(stderr, "Error: Failed to allocate memory.\n");
        return 1; // Exit with an error code
    }

    printf("Memory allocated successfully at address: %p\n", (void *)buffer);

    // 3. Use the memory
    // Copy our message into the newly allocated buffer.
    strcpy(buffer, message);

    // Print the contents of the buffer to verify.
    printf("Message stored in buffer: '%s'\n", buffer);

    // 4. Free the memory
    // This returns the memory block to the heap for reuse.
    printf("Freeing memory at address: %p\n", (void *)buffer);
    free(buffer);

    // After freeing, the pointer 'buffer' is now a "dangling pointer".
    // It's good practice to set it to NULL to prevent accidental reuse.
    buffer = NULL;

    printf("Program finished successfully.\n");
    return 0;
}

Build and Run Steps:

1. Cross-compile on your host machine:

Bash
aarch64-linux-gnu-gcc -o basic_malloc basic_malloc.c -Wall


The -Wall flag enables all compiler warnings, which is excellent practice.

2. Transfer the executable to the Raspberry Pi 5:Use scp (secure copy) to move the compiled binary to your Pi. Replace pi_ip_address with your Pi’s actual IP.

Bash
scp basic_malloc pi@pi_ip_address:~/

Run on the Raspberry Pi 5:Connect to your Pi using ssh and execute the program.ssh pi@pi_ip_address ./basic_malloc

Expected Output:

Plaintext
Attempting to allocate 19 bytes...
Memory allocated successfully at address: 0xaaaaf5a010
Message stored in buffer: 'Hello from the heap!'
Freeing memory at address: 0xaaaaf5a010
Program finished successfully.

The exact memory address will vary each time you run the program due to Address Space Layout Randomization (ASLR), a security feature in modern Linux systems. This example demonstrates the complete, disciplined lifecycle of dynamic memory: allocate, check, use, and free.

Example 2: calloc() and realloc() in Action

This example demonstrates how to create a dynamic array that can grow as more data is added. We’ll use calloc() for safe, zero-initialized allocation and realloc() to expand the array’s capacity.

File: dynamic_array.c

C
#include <stdio.h>
#include <stdlib.h>

void print_array(int *arr, size_t count) {
    printf("Array contents: [ ");
    for (size_t i = 0; i < count; ++i) {
        printf("%d ", arr[i]);
    }
    printf("]\n");
}

int main() {
    int *numbers = NULL;
    size_t capacity = 5;
    size_t count = 0;

    // 1. Initial allocation with calloc()
    // Allocate space for 5 integers. calloc will initialize them all to 0.
    printf("Initial allocation for %zu integers.\n", capacity);
    numbers = (int *)calloc(capacity, sizeof(int));
    if (numbers == NULL) {
        fprintf(stderr, "Initial allocation failed.\n");
        return 1;
    }
    print_array(numbers, capacity);

    // 2. Fill the array
    for (count = 0; count < capacity; ++count) {
        numbers[count] = (count + 1) * 10;
    }
    printf("\nAfter filling:\n");
    print_array(numbers, count);

    // 3. Grow the array with realloc()
    // Let's double the capacity.
    size_t new_capacity = capacity * 2;
    printf("\nAttempting to reallocate to a new capacity of %zu integers.\n", new_capacity);

    // IMPORTANT: Use a temporary pointer for realloc.
    // If realloc fails, it returns NULL, but the original pointer is still valid.
    int *temp = realloc(numbers, new_capacity * sizeof(int));
    if (temp == NULL) {
        fprintf(stderr, "Failed to reallocate memory. Original data is safe.\n");
        // In a real application, you might try to continue with the old array
        // or clean up and exit gracefully.
        free(numbers);
        return 1;
    }

    // If realloc succeeded, update the main pointer.
    numbers = temp;
    capacity = new_capacity;
    printf("Reallocation successful. New address: %p\n", (void *)numbers);

    // The new part of the array is uninitialized. Let's fill it.
    for (size_t i = count; i < capacity; ++i) {
        numbers[i] = (i + 1) * 100;
    }

    printf("\nAfter reallocating and filling:\n");
    print_array(numbers, capacity);

    // 4. Clean up
    free(numbers);
    numbers = NULL;

    return 0;
}

This example highlights the safe way to use realloc() by assigning its return value to a temporary pointer. If you were to write numbers = realloc(numbers, ...) directly and it failed, you would lose your only pointer to the original memory, creating a memory leak.

Example 3: Detecting a Memory Leak with Valgrind

One of the most common dynamic memory errors is the memory leak, where a program allocates memory but forgets to free it. Over time, this can consume all available memory and crash the system. Valgrind is an indispensable tool for detecting such issues.

graph TD
    A[Start: Write C Code] --> B(Compile with -g debug symbols<br><i>aarch64-linux-gnu-gcc -g -o my_app my_app.c</i>);
    B --> C{Run with Valgrind<br><i>valgrind --leak-check=full ./my_app</i>};
    C --> D{Valgrind Report:<br>Memory Errors Found?};
    D -- No --> E[Success!<br>No leaks detected.];
    D -- Yes --> F["Analyze Report:<br>Identify error type (leak, invalid read/write)<br>and location (file, line number)"];
    F --> G["Edit Code to Fix Bug<br>e.g., Add missing free(), fix loop bounds"];
    G --> B;

    classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef successNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff;
    classDef decisionNode fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef checkNode fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff;

    class A startNode;
    class E successNode;
    class C,D decisionNode;
    class B,F,G processNode;

Let’s write a program with an intentional leak.

File: leaky.c

C
#include <stdlib.h>
#include <string.h>

void create_leak() {
    char *leaky_buffer = (char *)malloc(128);
    // We allocate memory but never free it before the function returns.
    // The 'leaky_buffer' pointer is lost, and the memory is leaked.
    strcpy(leaky_buffer, "This memory is now lost forever.");
}

int main() {
    create_leak();
    // The program exits without freeing the memory allocated in create_leak().
    return 0;
}

Build and Debug Steps:

1. Cross-compile with debugging symbols:The -g flag includes debugging information that Valgrind uses to give you precise line numbers.

Bash
aarch64-linux-gnu-gcc -o leaky leaky.c -g

2. Transfer to the Pi:

Bash
scp leaky pi@pi_ip_address:~/

2. Install and run Valgrind on the Pi:You’ll need to install Valgrind on the Raspberry Pi itself.

Bash
sudo apt-get update 
sudo apt-get install valgrind


3. Now, run your program under Valgrind’s memcheck tool.

Bash
valgrind --leak-check=full ./leaky

Expected Valgrind Output (abbreviated):

Plaintext
==5785== Memcheck, a memory error detector
==5785== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==5785== Using Valgrind-3.19.0 and LibVEX; rerun with -h for copyright info
==5785== Command: ./leaky
==5785== 
==5785== 
==5785== HEAP SUMMARY:
==5785==     in use at exit: 128 bytes in 1 blocks
==5785==   total heap usage: 1 allocs, 0 frees, 128 bytes allocated
==5785== 
==5785== 128 bytes in 1 blocks are definitely lost in loss record 1 of 1
==5785==    at 0x48850C8: malloc (vg_replace_malloc.c:381)
==5785==    by 0x120763: create_leak (in /home/ishak/projects/ch84/leaky)
==5785==    by 0x12079F: main (in /home/ishak/projects/ch84/leaky)
==5785== 
==5785== LEAK SUMMARY:
==5785==    definitely lost: 128 bytes in 1 blocks
==5785==    indirectly lost: 0 bytes in 0 blocks
==5785==      possibly lost: 0 bytes in 0 blocks
==5785==    still reachable: 0 bytes in 0 blocks
==5785==         suppressed: 0 bytes in 0 blocks
==5785== 
==5785== For lists of detected and suppressed errors, rerun with: -s
==5785== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

Valgrind’s report is incredibly clear. It tells us that 128 bytes are definitely lost. More importantly, it provides a stack trace showing exactly where the leaked memory was allocated: line 5 of leaky.c, inside the create_leak() function, which was called by main. This level of detail makes tracking down and fixing leaks much more manageable.

Common Mistakes & Troubleshooting

Dynamic memory management is a double-edged sword. While powerful, it opens the door to a class of subtle and destructive bugs. Understanding these common pitfalls is the first step toward avoiding them.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Memory Leak
Forgetting to free() allocated memory.
Steadily increasing memory usage over time. Eventually, the application or system crashes when memory is exhausted. Solution: For every malloc(), ensure there is a corresponding free(). Use Valgrind to automatically detect and pinpoint the allocation site of leaked blocks.
Dangling Pointer
Using a pointer after the memory it pointed to has been freed.
Unpredictable behavior. Immediate crash (segmentation fault), silent data corruption, or seemingly random bugs. Solution: After calling free(ptr), immediately set the pointer to NULL. This ensures any accidental reuse will cause a predictable crash instead of data corruption.
Double-Free
Calling free() on the same pointer twice.
Often leads to a hard crash. Can corrupt the heap’s internal metadata, causing subsequent malloc() calls to fail or behave erratically. Solution: Setting the pointer to NULL after the first free() also solves this, as free(NULL) is a safe, guaranteed no-op.
Allocation Failure
Not checking if malloc() returned NULL.
Immediate segmentation fault and program crash when the NULL pointer is used. Solution: Always check the return value of any allocation function. Implement graceful error handling if memory cannot be allocated.
ptr = malloc(size); if (ptr == NULL) { /* handle error */ }
Heap Buffer Overflow
Writing past the end of an allocated block.
Can overwrite heap metadata, leading to bizarre crashes that are difficult to trace. May also introduce security vulnerabilities. Solution: Use size-bounded functions like strncpy() and snprintf(). Be meticulous with size calculations. Valgrind is excellent at detecting these out-of-bounds writes.

Exercises

  1. Heap Exhaustion Simulator:Objective: Understand how memory leaks lead to allocation failure.Task: Write a C program that repeatedly allocates a small chunk of memory (e.g., 1MB) in an infinite loop but never frees it. Print a message after each successful allocation, including a counter. Run this program on your Raspberry Pi 5 and monitor its memory usage using the top or htop command in another terminal.Verification: Observe the program’s memory consumption (the RES or VIRT column in top) steadily increase until malloc() eventually fails. Your program should detect this failure (by checking for NULL) and print a final “Allocation failed!” message before exiting.
  2. Dangling Pointer Detector:Objective: Experience the undefined behavior of a dangling pointer.Task: Write a C program that allocates an integer on the heap, assigns it a value (e.g., 42), and prints it. Then, free() the memory but do not set the pointer to NULL. After the free() call, attempt to print the value from the pointer again. Next, allocate several other small blocks of memory. Finally, print the value from the original (dangling) pointer one last time.Verification: What happens when you print the value immediately after freeing? What happens after you make other allocations? Compile and run the program several times. Does the output change? Explain why the value might sometimes appear to be correct, and other times is garbage. This demonstrates the non-deterministic nature of the bug.
  3. Custom realloc() Implementation:Objective: Deepen your understanding of how realloc() works by implementing a simplified version.Task: Write a function void* my_realloc(void* ptr, size_t new_size) that mimics the basic functionality of realloc(). Your function should handle three cases:a. If ptr is NULL, it should behave like malloc(new_size).b. If new_size is 0, it should behave like free(ptr) and return NULL.c. Otherwise, it should allocate a new block of new_size, copy the data from the old block to the new one (being careful not to copy more than the original size), free() the old block, and return a pointer to the new one. You will need a way to know the original size; for this exercise, you can simply allocate a little extra space to store the size just before the block you return to the user.Verification: Write a main function that uses your my_realloc to grow and shrink an array of characters and prints the result at each stage to confirm it works correctly.
  4. Fragmentation Analysis:Objective: Visualize the effect of external fragmentation.Task: Write a program that performs the following sequence of operations:a. Allocate ten 1KB blocks and store their pointers in an array.b. Free every other block (blocks 0, 2, 4, 6, 8). At this point, you have 5KB of free memory, but it is not contiguous.c. Attempt to allocate a single 3KB block.Verification: Does the 3KB allocation succeed or fail? Use printf to log the address of each initial allocation and the result of the final allocation attempt. Explain why the allocation fails, even though there is more than enough total free memory on the heap. This is a practical demonstration of external fragmentation.

Summary

This chapter provided a deep dive into the critical topic of dynamic memory management in Embedded Linux. We transitioned from the theory of process memory layout to the practical application and potential pitfalls of heap allocation.

  • Core Concepts: The heap is a region of a process’s memory used for dynamic allocation, managed by the C library’s allocator, which in turn requests memory from the kernel via the sbrk() system call.
  • Key Functions: We mastered the use of the four primary heap management functions:
    • malloc(): For general-purpose memory allocation.
    • calloc(): For allocating zero-initialized array memory.
    • realloc(): For resizing existing allocations.
    • free(): For returning memory to the heap.
  • Fragmentation: We learned the difference between internal fragmentation (wasted space within allocated blocks) and external fragmentation (inability to use free memory because it’s not contiguous), a major concern in long-running embedded systems.
  • Error Handling: We identified the most common and dangerous memory errors, including memory leaks, dangling pointers, double-frees, and buffer overflows.
  • Debugging: We gained hands-on experience using the Valgrind memcheck tool on the Raspberry Pi 5 to diagnose memory leaks and other heap-related errors with precision.
  • Best Practices: The importance of disciplined memory management was emphasized: always check for NULL after allocation, always free() what you malloc(), and set pointers to NULL after freeing to prevent them from dangling.

The ability to manage memory dynamically, efficiently, and safely is not just a useful skill; it is a prerequisite for any serious embedded systems programmer. The flexibility it provides is essential for modern applications, but it demands a level of discipline and understanding greater than any other aspect of C programming.

Further Reading

  1. The GNU C Library (glibc) Manual: The official documentation for the C library used on most Linux systems. The section on memory allocation is authoritative.
  2. Valgrind User Manual: The definitive guide to using Valgrind and its suite of tools, including memcheck.
  3. The C Programming Language, 2nd Edition by Brian W. Kernighan and Dennis M. Ritchie: The classic “K&R” book. Its chapter on the standard library provides a concise and foundational overview of the memory management functions.
  4. Understanding the Linux Kernel, 3rd Edition by Daniel P. Bovet & Marco Cesati: For those who want to go deeper, this book explains the kernel’s memory management subsystems, including how system calls like brk() are actually implemented.
  5. Anatomy of a Program in Memory by Gustavo Duarte: A well-regarded and detailed article that explains the process memory map in great detail.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top