Linux Heap Memory Internals: brk() and sbrk() Explained

 

 

Linux Heap Memory Internals: brk() and sbrk() Explained

Understand how the Linux kernel manages the program break and how heap memory really grows — with hands-on C examples

⏱️ 12 min read
🎯 Beginner–Intermediate
💻 Linux / C Programming

Why Should You Care About brk() and sbrk()?

Every C programmer uses malloc(), but few know what actually happens underneath. The Linux kernel manages a process’s heap through a concept called the program break — a boundary pointer that marks where the heap ends. Moving this boundary up allocates more virtual memory; moving it down releases it. The two system calls brk() and sbrk() are the raw primitives that do exactly this.

As a systems or embedded engineer, understanding these calls helps you debug memory growth issues, write custom allocators, and reason clearly about how your process uses virtual memory.

🔑 Topics Covered

Linux Program Break brk() system call sbrk() system call Heap Memory Layout Virtual Memory Custom Memory Tracking Linux C Programming Memory Debugging

1. What Is the Program Break?

A Linux process’s virtual memory is divided into well-defined segments. From low address to high, you have the text segment (code), initialized data, uninitialized data (BSS), and then the heap. The heap grows upward as your program needs more memory at runtime.

The program break is simply the address that marks the current top of the heap. Everything below this address belongs to the process’s heap region. Everything above is unmapped — accessing it triggers a segmentation fault.

Process Virtual Memory Layout

High Address → Stack (grows downward)
↓ Stack grows here
… (Shared Libraries, mmap regions)
← Program Break (current top of heap)
Heap (dynamically allocated memory)
BSS (uninitialized global/static vars)
Data Segment (initialized globals)
Text Segment (program code)
Low Address (0x00000000)

When brk() or sbrk() move the program break upward, the kernel does not immediately assign physical RAM. It maps virtual pages; physical pages are allocated only when your code first touches those addresses (demand paging). This is why allocating a huge chunk of memory does not immediately consume RAM.

2. The brk() and sbrk() API

brk() sets the program break to an absolute address. sbrk() moves it by a relative increment. Both are declared in <unistd.h>.

/* brk() - set program break to an ABSOLUTE address */
#include <unistd.h>

int brk(void *end_data_segment);
/* Returns 0 on success, -1 on error */

/* sbrk() - move program break by a RELATIVE increment */
void *sbrk(intptr_t increment);
/* Returns previous program break on success, (void*)-1 on error */
/* sbrk(0) returns current break WITHOUT moving it */

The key distinction: brk() takes a target address (absolute), while sbrk() takes a byte delta (relative). On Linux, sbrk() is actually a glibc wrapper around brk(). The return value of sbrk() is the previous program break — useful because it becomes the start address of the newly allocated region.

💡 Example 1: Heap Growth Monitor for a Sensor Data Logger

Imagine you are writing a sensor data logging daemon on an embedded Linux board. You suspect your data buffers are causing unexpected heap growth. By wrapping sbrk(0) calls around allocation points, you can print exactly how much the heap grew.

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

/* Sensor reading structure */
typedef struct {
    int sensor_id;
    float temperature;
    float humidity;
    long timestamp;
} SensorReading;

int main(void)
{
    void *heap_start, *heap_before, *heap_after;
    SensorReading *readings;
    int num_readings = 500;

    /* Capture the very first program break */
    heap_start = sbrk(0);
    printf("[INIT] Program break starts at: %p\n", heap_start);

    /* --- First allocation: 500 sensor readings --- */
    heap_before = sbrk(0);
    readings = malloc(num_readings * sizeof(SensorReading));
    heap_after = sbrk(0);

    if (readings == NULL) {
        perror("malloc failed");
        return 1;
    }

    printf("[ALLOC-1] Requested: %zu bytes\n",
           num_readings * sizeof(SensorReading));
    printf("[ALLOC-1] Heap before: %p\n", heap_before);
    printf("[ALLOC-1] Heap after : %p\n", heap_after);
    printf("[ALLOC-1] Heap grew by: %ld bytes\n",
           (long)(heap_after - heap_before));

    /* --- Second allocation: another batch --- */
    heap_before = sbrk(0);
    SensorReading *more = malloc(200 * sizeof(SensorReading));
    heap_after = sbrk(0);

    printf("\n[ALLOC-2] Requested: %zu bytes\n",
           200 * sizeof(SensorReading));
    printf("[ALLOC-2] Heap grew by: %ld bytes\n",
           (long)(heap_after - heap_before));

    /* NOTE: malloc internally asks sbrk() for a LARGER chunk  */
    /* than you requested, and serves future small allocations  */
    /* from that internal free-list. So heap growth != request  */

    printf("\n[TOTAL] Heap growth from start: %ld bytes\n",
           (long)(sbrk(0) - heap_start));

    free(readings);
    free(more);
    return 0;
}

What to observe: The heap often grows in larger chunks (page-aligned multiples, typically 4096 bytes or more) even when you request a small block. This is because glibc’s malloc() calls sbrk() in bulk and then sub-divides internally. You will often see that the second small allocation causes zero heap growth because the first call already reserved extra space.

sbrk(0) for monitoring Heap growth tracking malloc internal batching

💡 Example 2: A Minimal Bump Allocator Using sbrk()

To really understand how heap memory works, write a primitive bump allocator. This allocator never frees memory — it simply moves the program break forward each time you request memory. This technique is used in bootloaders and very constrained embedded environments where you allocate once and never release.

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <string.h>

/* Alignment helper: round up to 8-byte boundary */
#define ALIGN8(x)  (((x) + 7) & ~7)

/* Our bump allocator — never frees, just moves the break forward */
void *bump_alloc(size_t size)
{
    size_t aligned = ALIGN8(size);  /* always align to 8 bytes */
    void *ptr = sbrk(aligned);

    if (ptr == (void *)-1) {
        return NULL;  /* out of virtual address space */
    }
    return ptr;
}

int main(void)
{
    printf("Initial break: %p\n", sbrk(0));

    /* Allocate a small integer array */
    int *counters = bump_alloc(10 * sizeof(int));
    printf("counters[] at: %p (break now: %p)\n", (void*)counters, sbrk(0));

    /* Allocate a char buffer for a device name */
    char *dev_name = bump_alloc(32);
    strncpy(dev_name, "uart0", 31);
    printf("dev_name at: %p, value: \"%s\"\n", (void*)dev_name, dev_name);

    /* Fill counters */
    for (int i = 0; i < 10; i++) counters[i] = i * 100;
    printf("counters[5] = %d\n", counters[5]);

    /* Allocate a struct */
    typedef struct { uint32_t id; uint8_t status; } DeviceInfo;
    DeviceInfo *dev = bump_alloc(sizeof(DeviceInfo));
    dev->id = 0xDEAD;
    dev->status = 1;
    printf("DeviceInfo at: %p, id=0x%X\n", (void*)dev, dev->id);

    printf("Final break: %p\n", sbrk(0));

    /* NOTE: We never call free() — this is intentional for a bump allocator */
    /* Suitable ONLY when the entire allocation lifetime equals the process   */
    return 0;
}

Key insight: Notice how each bump_alloc() call directly calls sbrk(). Each allocation’s address is precisely the previous program break, proving that consecutive allocations are contiguous in virtual memory. The 8-byte alignment ensures that structures are always safely aligned for any data type.

Bump allocator pattern Alignment with sbrk() Contiguous heap memory

💡 Example 3: Detecting a Heap Overflow Boundary Using brk()

In some diagnostic scenarios, you want to deliberately test what happens when heap expansion is restricted. By calling brk() to set an upper limit, you can simulate a low-memory embedded environment and see how your program handles allocation failures.

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

int main(void)
{
    void *current_break;
    void *restricted_limit;
    char *buf;

    current_break = sbrk(0);
    printf("Current program break: %p\n", current_break);

    /* Set the program break exactly 64KB above the current position */
    /* This restricts the heap to 64KB from this point               */
    restricted_limit = (char *)current_break + (64 * 1024);

    if (brk(restricted_limit) == -1) {
        perror("brk failed — cannot set limit");
        return 1;
    }
    printf("Program break set to: %p (64KB window)\n", sbrk(0));

    /* Try allocating within the limit — should succeed */
    buf = malloc(32 * 1024);  /* 32KB — fits inside 64KB window */
    if (buf != NULL) {
        printf("32KB allocation: SUCCESS at %p\n", (void*)buf);
        free(buf);
    }

    /* Now attempt to allocate beyond the restricted limit */
    /* malloc() will try sbrk() which will try brk() and fail */
    char *big = malloc(128 * 1024);  /* 128KB — exceeds our 64KB window */
    if (big == NULL) {
        printf("128KB allocation: FAILED (errno set, as expected)\n");
        /* In a real daemon, you would log this and take a fallback action */
    } else {
        printf("128KB allocation: SUCCEEDED (OS allowed it)\n");
        free(big);
    }

    /* IMPORTANT: brk() constraints are enforced per-process and subject  */
    /* to RLIMIT_DATA resource limit. The kernel may not honor a strict    */
    /* downward brk() if data is already mapped in the region.             */

    return 0;
}

Note: In practice, directly using brk() this way is uncommon in application code. This example is purely diagnostic. Also note that on modern Linux systems, the kernel applies ASLR (Address Space Layout Randomization), so the exact addresses you see will differ on each run.

brk() absolute addressing Simulating memory constraints Allocation failure handling

3. Heap Layout: Before and After sbrk()

State Program Break Physical Pages What Happened
Process starts &end (past BSS) None in heap OS sets initial break just past uninitialized data
sbrk(4096) +4096 bytes Virtual only Virtual address space reserved; no RAM yet
First access to new page Unchanged 1 page (4KB) assigned Page fault → kernel maps physical RAM
sbrk(-4096) -4096 bytes Page unmapped Virtual region returned; accessing it = SIGSEGV

🎯 Interview Questions: brk() and sbrk()

# Question Answer / Key Points
1 What is the program break in Linux? It is the address that marks the current end of the heap. Memory below it belongs to the process heap; above it is unmapped virtual memory.
2 What is the difference between brk() and sbrk()? brk() sets the program break to an absolute address. sbrk() adjusts it by a relative byte delta. On Linux, sbrk() is implemented as a glibc wrapper over brk().
3 What does sbrk(0) return and why is it useful? It returns the current program break without moving it. Useful for measuring heap size growth or checking whether malloc() actually called sbrk().
4 Does calling sbrk() immediately allocate physical RAM? No. It only extends the virtual address space. Physical pages are allocated lazily by the kernel on the first access (demand paging via page faults).
5 What happens if you call brk() with an address below the initial program break? It is likely to cause a segmentation fault (SIGSEGV) because you would be removing the mapping of the initialized or uninitialized data segments.
6 Why do modern programs use malloc() instead of sbrk() directly? malloc() is standardized, thread-safe, handles alignment, maintains a free-list to recycle memory, and minimizes the number of expensive system calls. sbrk() is a raw kernel interface with none of these features.
7 What resource limit governs how high the program break can go? RLIMIT_DATA — the maximum size of the process’s data segment. Also affected by memory mappings, shared libraries, and available virtual address space.
8 What is a bump allocator and when is it appropriate? A bump allocator simply moves the program break forward on each allocation, never freeing. Appropriate only in very constrained environments (bootloaders, arena-based allocators) where the entire allocation lifetime matches the program lifetime.

Summary

  • The program break marks the top of the heap; moving it controls how much heap memory a process has.
  • brk() takes an absolute target address; sbrk(increment) takes a relative offset. Both manipulate the program break.
  • sbrk(0) is your lightweight heap profiler — it returns the current break without side effects.
  • Memory allocation via sbrk() is purely virtual at first; physical pages are assigned on first access.
  • In application code, always prefer malloc() / free() over direct brk()/sbrk() calls. The latter are low-level tools for allocator implementers and diagnostics.

Continue the Series

Next up: How malloc() and free() really work, including their internal free-list and common programming mistakes.

Next: malloc() and free() Deep Dive → Back to EmbeddedPathashala

Leave a Reply

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