Skip to content

Build a Memory Allocator

A memory allocator is code that gives memory to the rest of a program.

A memory allocator is code that gives memory to the rest of a program.

When a program needs space for data, it asks an allocator:

const buffer = try allocator.alloc(u8, 1024);

That means: give me 1024 bytes.

Later, when the program is done with that memory, it gives it back:

allocator.free(buffer);

Zig makes allocators explicit. A function that needs heap memory usually receives an allocator from the caller. That makes memory behavior visible.

In this project, we will build a small fixed buffer allocator. It will allocate memory from a byte array that we control.

The Goal

We will create a fixed-size memory area:

var memory: [1024]u8 = undefined;

Then we will build an allocator that hands out pieces of that array.

Example:

var allocator = SimpleAllocator.init(&memory);

const a = try allocator.alloc(100);
const b = try allocator.alloc(200);

allocator.free(b);
allocator.free(a);

This allocator will be simple. It will allocate forward through the buffer. It will support freeing only in reverse order. That makes it a stack allocator.

This is not a general-purpose allocator. It is a teaching allocator.

The Basic Idea

Imagine the buffer as a row of bytes:

[........................................]

At the beginning, nothing is used.

The allocator keeps an index called offset:

offset = 0

When we allocate 8 bytes, the allocator returns the first 8 bytes and moves the offset forward:

[xxxxxxxx................................]
        ^
        offset = 8

When we allocate 12 more bytes:

[xxxxxxxxxxxxxxxxxxxx....................]
                    ^
                    offset = 20

The allocator does not call the operating system. It only slices a buffer that already exists.

Define the Allocator

Start with this struct:

const SimpleAllocator = struct {
    buffer: []u8,
    offset: usize,

    fn init(buffer: []u8) SimpleAllocator {
        return .{
            .buffer = buffer,
            .offset = 0,
        };
    }
};

The allocator stores two fields.

buffer is the memory area.

offset is the next free byte.

Allocate Bytes

Now add an alloc method:

fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
    if (self.offset + len > self.buffer.len) {
        return error.OutOfMemory;
    }

    const start = self.offset;
    const end = start + len;

    self.offset = end;

    return self.buffer[start..end];
}

This function checks whether enough memory remains.

If yes, it returns a slice.

If no, it returns error.OutOfMemory.

Try It

Put this in src/main.zig:

const std = @import("std");

const SimpleAllocator = struct {
    buffer: []u8,
    offset: usize,

    fn init(buffer: []u8) SimpleAllocator {
        return .{
            .buffer = buffer,
            .offset = 0,
        };
    }

    fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
        if (self.offset + len > self.buffer.len) {
            return error.OutOfMemory;
        }

        const start = self.offset;
        const end = start + len;

        self.offset = end;

        return self.buffer[start..end];
    }
};

pub fn main() !void {
    var memory: [64]u8 = undefined;
    var allocator = SimpleAllocator.init(&memory);

    const a = try allocator.alloc(10);
    const b = try allocator.alloc(20);

    @memset(a, 1);
    @memset(b, 2);

    std.debug.print("a.len = {d}\n", .{a.len});
    std.debug.print("b.len = {d}\n", .{b.len});
    std.debug.print("used = {d}\n", .{allocator.offset});
}

Run:

zig build run

Output:

a.len = 10
b.len = 20
used = 30

The allocator gave out 30 bytes from a 64-byte buffer.

Why This Allocator Cannot Reuse Memory Yet

Right now, there is no free.

If you allocate 10 bytes and then 20 bytes, the offset moves to 30.

If you no longer need the first 10 bytes, the allocator does not know how to reuse them.

[aaaaaaaaaabbbbbbbbbbbbbbbbbbbb..........]
                              ^
                              offset = 30

A general allocator tracks free regions. That requires more bookkeeping.

For a first allocator, we will use a simpler rule: free must happen in reverse order.

Add Stack-Style Free

A stack allocator works like a stack of plates.

The last allocation must be freed first.

This is allowed:

const a = try allocator.alloc(10);
const b = try allocator.alloc(20);

allocator.free(b);
allocator.free(a);

This is not allowed:

const a = try allocator.alloc(10);
const b = try allocator.alloc(20);

allocator.free(a); // wrong
allocator.free(b);

To support this, free checks whether the slice being freed is at the end of the used area.

fn free(self: *SimpleAllocator, memory: []u8) void {
    const buffer_start = @intFromPtr(self.buffer.ptr);
    const memory_start = @intFromPtr(memory.ptr);

    const start = memory_start - buffer_start;
    const end = start + memory.len;

    if (end == self.offset) {
        self.offset = start;
    }
}

If the freed block is the most recent allocation, we move offset backward.

If it is not, we do nothing.

Complete Stack Allocator

const std = @import("std");

const SimpleAllocator = struct {
    buffer: []u8,
    offset: usize,

    fn init(buffer: []u8) SimpleAllocator {
        return .{
            .buffer = buffer,
            .offset = 0,
        };
    }

    fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
        if (self.offset + len > self.buffer.len) {
            return error.OutOfMemory;
        }

        const start = self.offset;
        const end = start + len;

        self.offset = end;

        return self.buffer[start..end];
    }

    fn free(self: *SimpleAllocator, memory: []u8) void {
        const buffer_start = @intFromPtr(self.buffer.ptr);
        const memory_start = @intFromPtr(memory.ptr);

        const start = memory_start - buffer_start;
        const end = start + memory.len;

        if (end == self.offset) {
            self.offset = start;
        }
    }
};

pub fn main() !void {
    var memory: [64]u8 = undefined;
    var allocator = SimpleAllocator.init(&memory);

    const a = try allocator.alloc(10);
    const b = try allocator.alloc(20);

    std.debug.print("after alloc: used = {d}\n", .{allocator.offset});

    allocator.free(b);
    std.debug.print("after free b: used = {d}\n", .{allocator.offset});

    allocator.free(a);
    std.debug.print("after free a: used = {d}\n", .{allocator.offset});
}

Output:

after alloc: used = 30
after free b: used = 10
after free a: used = 0

Now memory can be reused, but only in stack order.

Alignment

Real allocators must care about alignment.

Some values must start at addresses divisible by 2, 4, 8, or more.

For example, an i64 usually needs 8-byte alignment.

This address is aligned to 8:

0x1000

This one is not:

0x1003

If an allocator returns badly aligned memory, the program may be slower, incorrect, or crash on some targets.

Add an aligned allocation helper:

fn alignForward(value: usize, alignment: usize) usize {
    return std.mem.alignForward(usize, value, alignment);
}

Then update alloc:

fn allocAligned(self: *SimpleAllocator, len: usize, alignment: usize) ![]u8 {
    const start = alignForward(self.offset, alignment);
    const end = start + len;

    if (end > self.buffer.len) {
        return error.OutOfMemory;
    }

    self.offset = end;
    return self.buffer[start..end];
}

Now callers can request memory with a required alignment.

Testing Out of Memory

Add this test:

test "allocator returns out of memory" {
    var memory: [8]u8 = undefined;
    var allocator = SimpleAllocator.init(&memory);

    _ = try allocator.alloc(8);

    try std.testing.expectError(error.OutOfMemory, allocator.alloc(1));
}

The buffer has 8 bytes. After allocating all 8, one more byte should fail.

Testing Stack Free

Add this test:

test "stack free moves offset backward" {
    var memory: [64]u8 = undefined;
    var allocator = SimpleAllocator.init(&memory);

    const a = try allocator.alloc(10);
    const b = try allocator.alloc(20);

    try std.testing.expectEqual(@as(usize, 30), allocator.offset);

    allocator.free(b);
    try std.testing.expectEqual(@as(usize, 10), allocator.offset);

    allocator.free(a);
    try std.testing.expectEqual(@as(usize, 0), allocator.offset);
}

Run:

zig build test

Why Zig Uses Allocator Interfaces

Our allocator has custom methods:

allocator.alloc(10)
allocator.free(buffer)

Zig’s standard library uses the common type:

std.mem.Allocator

That allows many data structures to accept any allocator.

For example:

var list = std.ArrayList(u8).init(allocator);

The list does not care whether the allocator is a general-purpose allocator, arena allocator, fixed buffer allocator, or custom allocator.

That is the design lesson: pass allocation policy into the code instead of hardcoding it.

What This Allocator Is Good For

A stack allocator is useful when allocations naturally happen in phases.

Example:

start request
  allocate temporary buffers
  parse data
  build response
end request
  free all temporary memory

It is also useful in games, compilers, parsers, and batch processing.

The limitation is strict lifetime order. If memory must be freed in arbitrary order, use a different allocator design.

What You Learned

You built a small allocator from a byte buffer.

You tracked used memory with an offset.

You returned slices into the buffer.

You handled out-of-memory errors.

You added stack-style freeing.

You saw why alignment matters.

You connected the project to Zig’s explicit allocator style.

Allocators look mysterious at first, but the first idea is simple: an allocator owns a region of memory and decides which parts are currently available.