DEV Community

Ilean Monterrubio Jr
Ilean Monterrubio Jr

Posted on Originally published at ilean.me on

Common Embedded Patterns: Memory Pool

This is the second part of the series re-exploring common embedded patterns. This one focuses on the memory pool. This pattern is often used as an alternative to malloc() and free().

The complete, tested source lives in the companion repo: ileanmjr88/tetzontli. Each pattern in this series is its own module with a full GoogleTest suite, so you can clone it and run the tests yourself.

Basic Concept

Let's start with a quick comparison to the ring buffer pattern. Both patterns are used to store data, but they differ in their point of view of going about it. We refer to the ring buffer as a container. Each item inserted into the data structure is homogeneous and uniform, and it owns the order of that data: you push in, you pop out, following FIFO. The buffer is aware of what's inside it, at least in terms of size and sequence.

The memory pool takes the opposite point of view. It isn't a container, it's custom memory management. It doesn't know the structure of the data being stored, it doesn't care what's in the data, and it doesn't track the sequence. You ask for a block, it checks if one is available, and if it is, it hands you a pointer to that block. From the memory pool's point of view it only sees blocks as occupied or free, with no reference to their structure or the order in which they were handed out.

At this point you're probably wondering what a memory pool is in the simplest terms. It's a buffer of equal-sized memory blocks, typically implemented as an array, so we have a chunk or pool of memory we can grab from. Our init determines the size of our blocks, and initializes the manager structure that keeps track of where the next available block is located.

A Quick Look at malloc() and free()

Before diving into the memory pool itself, it's worth stepping back and recalling what malloc() and free() actually do. This is important since we'll be implementing similar functionality. The difference is that we pre-allocate a fixed buffer up front, usually a static array, while these functions dynamically allocate and deallocate memory on the heap.

malloc() asks the heap allocator for a chunk of memory, sized for the item you're trying to store. If the allocation is successful, meaning the heap allocator found space, it returns a pointer. If it's unable to find any space it returns NULL, indicating failure. The heap allocator manages this by keeping track of free and used chunks internally. Each time we use malloc() it keeps track of how much we used, and the same goes for free(): each time we free memory it marks it as available. free() does the reverse of malloc(). We call it with the pointer we want to give back to the heap allocator, it marks that chunk as free, and moves on.

These two functions for dynamically allocating and deallocating memory work great on modern computers and in server farms where they have large amounts of memory and a large address space. If a malloc() takes a microsecond longer to find a chunk of memory it's not a big deal. Embedded systems don't get to make those same assumptions, and that's where the cracks start to show:

  • Fragmentation : as we allocate and deallocate chunks of mixed sizes, the free memory left behind stops being contiguous. Eventually a request fails even though there are more than enough free bytes in total, just not enough of them next to each other. The failure is late, intermittent, and not reproducible on the bench.
  • Non-deterministic timing : not every embedded system needs deterministic timing, but for the ones that do, malloc() is a problem. The allocator searches for a chunk that fits, so how long it takes depends on the state of the heap. That's hard to accept in a hard-deadline loop, and impossible to budget for.
  • Late failure : you learn you're out of memory at the exact moment you need memory, which is usually the moment you can least do anything about it. There's no boot-time or link-time answer to "will this fit."

Keeping track of the available blocks

While we aren't using malloc() or free(), it also means we aren't using the built-in heap manager that kept its own record of which chunks were free and which were handed out. Our implementation needs to do that bookkeeping itself. There are really only two things it has to know: whether any block is available at all, and if so, where one is.

Before we get into the implementation used in the tetzontli repo, let's briefly explore two common approaches to this bookkeeping. The first is the bitmap, where each bit of an integer is used to indicate whether a block is in use or free. The number of blocks determines the integer type used. The alternative is the free list method, which threads the available blocks into a linked list, keeping a tracker pointer aimed at the next block that's available. When we deallocate, the block goes back onto the tracker pointer. There is one clear advantage to the free list method: it never has to search. Taking a block means grabbing whatever the tracker pointer is aimed at, and giving one back means putting it at the front. Both are a single pointer update, no matter how full the pool is. The bitmap has to scan for a spare bit, and how long that takes depends on how many blocks are already in use. The bitmap does have one advantage we are giving up though: because it records the state of every block, it can tell a double free from a legitimate one. The free list cannot, and we will come back to what that costs us.

The tetzontli implementation uses the intrusive free list approach. It's a common variation on the free list where each free block itself knows where the next available chunk of memory is located. The reason for this approach is that there's no need to keep a bitmap to keep track of the available blocks, since our next pointer already knows the next available block. Also a reminder, since we aren't allocating memory dynamically, we're using an array as the container to create the pool of memory. That makes traversal easier after init. Once we start to allocate and deallocate, we want the most recently deallocated blocks to be the ones we hand out first, keeping it like a stack, which is friendlier to cache.

Implementation

Memory pool structure

Similar to the ring buffer, we will create a struct. This is a simple way to keep all the parameters together and accessible from a single variable pointer.

// modules/memory_pool/memory_pool.h
typedef struct {
  uint8_t *base;
  size_t block_size;
  size_t capacity;
  size_t used;
  size_t high_water;
  void *free_head;
} memory_pool_t;

Enter fullscreen mode Exit fullscreen mode
  • uint8_t *base : the address of block 0 , and the anchor the entire pool is measured from. Every block lives at base + i * block_size . It is tempting to read this as the first byte of the buffer you hand to the pool, and most of the time that's exactly what it is, but the two can come apart, for a reason we'll get into in memory_pool_init().
  • size_t block_size: this is what we will often call the stride. It defines the size of each memory block and how far apart they are from block to block.
  • size_t capacity: this is the number of blocks in the pool. Fixed at memory_pool_init() and never changes. How many are free at any moment is capacity - used.
  • size_t used: the number of blocks currently handed out. Incremented by memory_pool_alloc(), decremented by memory_pool_free(). Note handed out, not in use. The pool has no idea what you're doing with a block, only whether it has given it away and not gotten it back.
  • size_t high_water : the peak value used has ever reached since memory_pool_init(). This is the number you size the pool from: run the system under worst-case load, read it back, and you know how many blocks you actually needed. It deliberately survives memory_pool_reset(). Handing every block back doesn't change the fact that you once needed that many at once, and clearing the peak would throw away the measurement. Only a fresh memory_pool_init() starts it over.
  • void *free_head: the head of the free list. The first available block to hand out, or NULL when every block is outstanding. The list is intrusive, meaning its links live inside the free blocks themselves rather than in separate node structs. Each free block stores a pointer to the next one in its own first bytes. That's what makes the free list cost zero extra memory, and it's why memory_pool_init() refuses a block_size smaller than a pointer.

Round-up function

We will leverage the is_power_of_two() in this pattern as well but we will also add a new method to round up to the next multiple of a. We covered is_power_of_two() in detail in the ring buffer post, so we won't repeat it here. It rejects zero as well as non-powers of two, which is what keeps block_align = 0 from ever reaching round_up().

// modules/memory_pool/memory_pool.c
static size_t round_up(size_t x, size_t a) {
  return (x + (a - 1u)) & ~(a - 1u);
}

Enter fullscreen mode Exit fullscreen mode
  • (x + (a - 1u)) : This takes the variable x, the number we want to round up, and the variable a, the alignment we want to round it up to. Adding a - 1 first is what pushes any value that isn't already on a boundary past the next one.
  • ~(a - 1u) : this takes the number we want to align by, subtracts one, and does a bitwise not, which gives us a mask that clears the low bits and leaves everything above them alone.
  • Taking both and doing the bitwise & drops the value back down onto a boundary. On its own that would round down. Adding a - 1 first is what turns it into a round up. This can be confusing at first glance.
caller: round_up(x = 12, a = 16);

- (x + (a - 1u)) = (12 + (16 - 1u)) = (12 + 15) = 27 = 0b00011011
- (a - 1u) = (16 - 1u) = 15

Taking the evaluation further on the right hand side of the &
- ~(15) = ~(0b00001111) = 0b11110000

Now putting both sides together 
- 0b00011011 & 0b11110000 = 0b00010000 = 16

Meaning we need to round up 12 to 16

And when the value is already on a boundary, nothing moves:
- round_up(16, 16) = (16 + 15) & ~15
- 0b00011111 & 0b11110000 = 0b00010000 = 16

Enter fullscreen mode Exit fullscreen mode

In the ring buffer, the masking trick only worked because the capacity was a power of two. The same rule applies here, except it lands on a, the alignment we are rounding up to, and not on the capacity. a needs to be a power of two, which is why memory_pool_init() runs is_power_of_two() on it before doing any rounding at all.

Example when a is not a power of two.

- round_up(12, 12) = (12 + 11) & ~11 = 23 & ~11
- 0b00010111 & ~0b00001011 = 0b00010111 & 0b11110100 = 0b00010100 = 20

It should yield 12, since 12 is already a multiple of 12 and a correct
round up would leave it alone. The reason it breaks is that 11 is
0b00001011, not a clean run of ones, so the mask clears the wrong bits.
This is why the power of two is used here to be able to optimize the round_up.

Enter fullscreen mode Exit fullscreen mode

Read next and write next functions

This pair of functions are the ones that will read which is the next available block and update the next available block of memory. Let's start with read_next(), for each allocation we will need to read where the next block to allocate is.

// modules/memory_pool/memory_pool.c
static void *read_next(const void *block) {
  void *const *slot = (void *const *)block;
  void *next;
  memcpy(&next, slot, sizeof next);
  return next;
}

Enter fullscreen mode Exit fullscreen mode

This function takes the pointer block, which arrives as a const void *, and casts it to void *const *. That cast reads right to left: slot is a pointer to a constant pointer to void. The const applies to the pointer stored inside the block, not to slot itself, and it's there so the cast doesn't quietly throw away the qualifier the parameter already carried. We create a variable void *next as a container to copy into. We do a memory copy of sizeof next bytes out of the slot and into next. This is how we return a memory pointer of the next block of data that is available.

The memcpy is worth pausing on, because the obvious version is a single line, return *(void **)block;. The problem is that those bytes live inside a uint8_t array, so reading them through a void** means accessing an object through a pointer of a different type, which is what the aliasing rules forbid. The compiler is allowed to assume we didn't do that. memcpy is the sanctioned way to say copy these bytes as they are without making a claim about types, and it costs nothing at any optimization level above -O0: every compiler recognizes a pointer-sized memcpy and emits a single load or store. In a debug build it stays a real call, which is worth knowing but not worth changing the code over.

Next, is write_next():

// modules/memory_pool/memory_pool.c
static void write_next(void *block, void *next) {
  void **slot = (void**)block;
  memcpy(slot, &next, sizeof next);
}

Enter fullscreen mode Exit fullscreen mode

This function mirrors read_next(). The difference is that we take in void *block and declare slot as a void **, assigning it the cast of block, which tells the compiler to treat those first bytes as a place where a pointer lives. Then we memory copy sizeof next bytes from &next, the address of our local, into the slot. What lands in the block is the pointer value itself. Neither of these two functions ever looks at block_size, they only touch the first sizeof(void *) bytes, which is why the free list costs the same no matter how large the blocks are.

Reset function

Before we get to the init function, it's worth talking about how we reset the memory pool. This does the job of relinking the blocks of memory in sequential order, which makes it handy to reuse inside init.

// modules/memory_pool/memory_pool.c
void memory_pool_reset(memory_pool_t *mp) {
  if (mp == NULL) {
    return;
  }

  mp->free_head = NULL;
  for (size_t i = mp->capacity; i-- > 0;) {
    uint8_t *blk = mp->base + i * mp->block_size;
    write_next(blk, mp->free_head);
    mp->free_head = blk;
  }
  mp->used = 0u;
}

Enter fullscreen mode Exit fullscreen mode

We start with a quick check that we didn't pass a NULL pointer to the function. Without it, the very next line, mp->free_head = NULL, would dereference that null handle. On a desktop that's a segmentation fault. On most microcontrollers there's no MMU to catch it, so address zero is real memory, often the vector table, and the write quietly succeeds and corrupts something instead. If mp is an actual memory address, we set mp->free_head = NULL so we know the state we're starting from before we assign the next pointer in each block.

Then we iterate with for (size_t i = mp->capacity; i-- > 0;). The decrement lives in the comparison so the check runs first and the decrement second, which means the body sees capacity - 1 down to 0, and the loop exits after 0 has run. The obvious version, for (size_t i = capacity - 1; i >= 0; i--), never terminates. size_t is unsigned, so i >= 0 is always true, and decrementing 0 wraps around to SIZE_MAX rather than going negative. This is the same unsigned wraparound we leaned on in the ring buffer's count, working against us here instead of for us.

The direction matters for a second reason. Each pass writes the current head into a block and then makes that block the new head, so walking the array backwards produces a list that comes out forwards. After a reset the first memory_pool_alloc() returns block 0, the next returns block 1, and so on in address order. Build the list the other way and the pool works identically, it just hands out descending addresses, which is harder to read when you are staring at pointer values in a debugger.

memory_pool_reset() rebuilds the whole list without asking who is holding what, so it invalidates every outstanding pointer. used goes back to zero, while high_water deliberately does not.

Initialization function

The initialization function is our entry point of actually starting to use this pattern. It will accept as parameters: memory_pool_t *mp, void *storage, size_t storage_size, size_t block_size, size_t block_align.

// modules/memory_pool/memory_pool.c
bool memory_pool_init(memory_pool_t *mp, void *storage, size_t storage_size, size_t block_size, size_t block_align) {
  if (mp == NULL || storage == NULL || !is_power_of_two(block_align) || block_size < sizeof(void *)) {
    return false;
  }

  if (block_align < _Alignof(void *)) {
    block_align = _Alignof(void *);
  }

  size_t stride = round_up(block_size, block_align);
  if (stride < block_size) {
    return false;
  }

  uintptr_t addr = (uintptr_t)storage;
  uintptr_t aligned = (uintptr_t)round_up((size_t)addr, block_align);
  uint8_t *base = (uint8_t *)aligned;

  size_t offset = (size_t)(base - (uint8_t *)storage);
  if (offset >= storage_size) {
    return false;
  }

  size_t usable = storage_size - offset;
  size_t capacity = usable / stride;
  if (capacity == 0u) {
    return false;
  }

  mp->base = base;
  mp->block_size = stride;
  mp->capacity = capacity;
  mp->used = 0u;
  mp->high_water = 0u;
  memory_pool_reset(mp);
  return true;
}

Enter fullscreen mode Exit fullscreen mode

The first thing when passing or dealing with pointers is to make sure they are not NULL. A null pointer means we would be writing to memory address zero. As stated before, on a computer that's a segmentation fault, but here we could write at address zero and corrupt an unintended address. The check also verifies explicitly that block_align is a power of two, since round_up is called twice further down and only works under that guarantee. The final check is block_size < sizeof(void *). Any block we hand out has to be big enough to hold a free list link while it's free, so a block smaller than a pointer would have its link spill into the block next to it.

  • Alignment clamp: block_align gets raised to _Alignof(void *) if you ask for less. You're allowed to request 1-byte alignment, but the free list stores a pointer inside every free block, so the pool quietly gives you pointer alignment instead. Same reason as the block_size guard, from the other direction. It is worth spelling out what falls out of this: because base is aligned and the stride is a multiple of block_align, every block in the pool is aligned, not just the first one. That is the guarantee you are leaning on when you put a struct in a block, so pass _Alignof(T) for a pool of T. The clamp only promises pointer alignment on its own.
  • Overflow guard: if (stride < block_size) looks unreachable, since rounding up can't make a number smaller. It catches wraparound: round_up adds a - 1 before masking, and if block_size is near SIZE_MAX that addition wraps. That is unsigned wraparound biting us again, this time caught on purpose.
  • Base bump: the caller's buffer may not start on an aligned address, so base gets rounded up and the lead-in bytes are lost. That's offset, and it's why base and storage can differ.
  • Capacity math: usable / stride, with the remainder discarded. Between the bump and the truncation, capacity is usually less than storage_size / block_size, which is why memory_pool_capacity() exists rather than the caller computing it. Align your storage yourself, with alignas on the array set at least as large as the block_align you pass, and the bump costs you nothing. Leave it to chance and you can quietly lose a block.

Allocate and free functions

Now that we have initialized the memory pool we can start using it. To memory_pool_alloc() we will need to pass the parameter of memory_pool_t *mp:

// modules/memory_pool/memory_pool.c
void *memory_pool_alloc(memory_pool_t *mp) {
  if (mp == NULL || mp->free_head == NULL) {
    return NULL;
  }

  void *p = mp->free_head;
  mp->free_head = read_next(p);
  mp->used++;
  if (mp->used > mp->high_water) {
    mp->high_water = mp->used;
  }
  return p;
}

Enter fullscreen mode Exit fullscreen mode

We do our standard check that mp is not NULL, and alongside it we check mp->free_head. That second one is how an exhausted pool announces itself: the free list being empty is the head being NULL, so there is no need for a separate is_empty helper the way the ring buffer needed one. Returning NULL here also matches malloc(), so a caller that already handles a failed allocation doesn't have to learn a new convention. If both checks pass, we create a new pointer p and point it at mp->free_head. That is the block we are about to hand back to the caller, and before we let go of it we call read_next(p) to pull the link out of its first bytes, which is what becomes the new head. The last thing we do is bookkeeping: mp->used++ records that one more block is outstanding, and if that pushes used past mp->high_water we move the peak up with it. That comparison is the only place high_water ever changes.

Next, memory_pool_free():

// modules/memory_pool/memory_pool.c
bool memory_pool_free(memory_pool_t *mp, void *block) {
  if (!memory_pool_owns(mp, block)) {
    return false;
  }
  if (mp->used == 0u) {
    return false;
  }

  write_next(block, mp->free_head);
  mp->free_head = block;
  mp->used--;
  return true;
}

Enter fullscreen mode Exit fullscreen mode

Giving a block back is the mirror image. The first check hands the pointer to memory_pool_owns(), the subject of the next section, which answers whether this is actually the start of a block in this pool. The second, mp->used == 0u, rejects a free into a pool with nothing outstanding. Both run before we write anything, so a rejected free leaves the pool completely untouched, free list included. The order of those two is not interchangeable either, since mp->used dereferences mp and is only safe because memory_pool_owns() already turned away a null pool. Swap them to fail on the cheaper check first and you have written a null dereference.

Then we push. write_next(block, mp->free_head) writes the current head into the returned block, mp->free_head = block makes that block the new head, and mp->used-- brings the count down. This is the stack behavior from earlier: the block you just gave back is the one the next memory_pool_alloc() hands out.

There is one case these checks cannot catch, freeing the same block twice. We will come back to that after the implementation.

Memory pool owns memory address

// modules/memory_pool/memory_pool.c
bool memory_pool_owns(const memory_pool_t *mp, const void *p) {
  if (mp == NULL || p == NULL) {
    return false;
  }

  uintptr_t addr = (uintptr_t)p;
  uintptr_t base = (uintptr_t)mp->base;
  uintptr_t end = base + ((uintptr_t)mp->capacity * (uintptr_t)mp->block_size);

  if (addr < base || addr >= end){
    return false;
  }
  return (addr - base) % (uintptr_t)mp->block_size == 0u;
}

Enter fullscreen mode Exit fullscreen mode

This is the validation memory_pool_free() leans on, and it asks two questions. The first is whether the pointer is inside the arena at all, comparing it against base and end.

The second is the interesting one: (addr - base) % mp->block_size == 0u checks the pointer is exactly on a block boundary. A pointer into the middle of a block passes the range test but is not a block, and freeing it would splice a bogus node into the free list at an offset, so the next allocation would hand out a block overlapping its neighbor. This modulo is the one division in the module, and it is the same cost we avoided in the ring buffer. Making the stride a power of two turns it back into a mask.

Capacity function

Knowing the capacity is important because from that we can identify how much of the memory pool we have to hand out to be used.

// modules/memory_pool/memory_pool.c
size_t memory_pool_capacity(const memory_pool_t *mp) {
  return (mp == NULL) ? 0u : mp->capacity;
}

Enter fullscreen mode Exit fullscreen mode

We do our NULL check on mp. If it is null we return 0u, otherwise we return the capacity derived back in memory_pool_init().

Helper functions

These are the functions to access the data members of the memory_pool_t without modifying them. We will get started with memory_pool_used():

// modules/memory_pool/memory_pool.c
size_t memory_pool_used(const memory_pool_t *mp) {
  return (mp == NULL) ? 0u : mp->used;
}

Enter fullscreen mode Exit fullscreen mode

Here we just check that the mp passed as an argument is not NULL, otherwise we return the field we are asking for, in this case mp->used.

// modules/memory_pool/memory_pool.c
size_t memory_pool_available(const memory_pool_t *mp) {
  return (mp == NULL) ? 0u : mp->capacity - mp->used;
}

size_t memory_pool_high_water(const memory_pool_t *mp) {
  return (mp == NULL) ? 0u : mp->high_water;
}

Enter fullscreen mode Exit fullscreen mode

Here we have the remainder of the helper functions, following the same NULL check and returning the value we asked for. Note that memory_pool_available() works out capacity - used rather than walking the free list to count what's left, which is the same answer without the traversal.

Example usage

Now that we have explained the full implementation, let's see how we will use the pattern.

typedef struct {
  uint32_t timestamp;
  int16_t value;
  uint8_t sensor_id;
} sample_t;

static alignas(void *) uint8_t sample_storage[16 * sizeof(sample_t)];
static memory_pool_t sample_pool;
static sample_t *current;

bool sensor_init(void) {
  return memory_pool_init(&sample_pool, sample_storage, sizeof sample_storage,
                          sizeof(sample_t), _Alignof(sample_t));
}

bool sensor_take_reading(uint8_t sensor_id) {
  current = memory_pool_alloc(&sample_pool);
  if (current == NULL) {
    return false;
  }

  current->timestamp = now();
  current->value = read_adc(sensor_id);
  current->sensor_id = sensor_id;
  return true;
}

void sensor_flush(void) {
  if (current == NULL) {
    return;
  }

  transmit(current);
  memory_pool_free(&sample_pool, current);
  current = NULL;
}

Enter fullscreen mode Exit fullscreen mode

This example shows we will be using the memory pool to store sample_t, which is made up of timestamp, value and sensor_id. Notice the block is allocated in one function and freed in another. That is the point of the pool, the memory outlives the call that asked for it, which is what lets a reading sit around until something else is ready to deal with it. One slot keeps the example short; a real caller holds several at once.

Now alignas(void *) on sample_storage. We pass _Alignof(sample_t) to init, but init clamps that up to _Alignof(void *), because every free block has to hold a pointer. The storage has to satisfy whichever of the two ends up larger, and here that's void *, on a 64-bit host it's 8 against sample_t's 4, and on a 32-bit target the two are equal, so aligning to the pointer covers both. Get it wrong and base gets bumped forward and you quietly lose a block. Notice we declare sample_storage as uint8_t so the array is raw bytes and we can size it by multiplying the block count by sizeof(sample_t). Note that alignas needs <stdalign.h> in C11, and the snippet leaves out the includes.

Also notice the pool has no idea which sensor a block belongs to. sensor_id is a field we put in the struct ourselves, not something the pool tracks. It only ever knew the block size and alignment we handed it at init.

Notice that alloc, free, and the way we use the memory in between are the same shape as malloc() and free(). Ask for memory, check for NULL, use it, hand it back. That is deliberate, code written against the heap ports over by changing two call sites. Two differences worth knowing: memory_pool_free() returns a bool instead of nothing, and freeing the same block twice does not behave the way your heap allocator would. We will get to that next.

Double-free edge case

Back in memory_pool_free() we said there was one case the checks cannot catch. Freeing into an empty pool is rejected by mp->used == 0u, and a pointer that isn't a block start is rejected by memory_pool_owns(). But freeing a block that is already on the free list passes both. It is a legitimate block start, so owns says yes, and other blocks are still outstanding, so used is nonzero.

So the push runs. We write free_head into the block and then make the block the new head. If that block was already the head, it now points at itself.

Before:
  free_head -> [B] -> [C] -> NULL

memory_pool_free(mp, B) a second time:
  write_next(B, free_head) B's next now points at B
  free_head = B

After:
  free_head -> [B] -> [B] -> [B] -> ...

  alloc() returns B
  alloc() returns B again, to a different caller

Enter fullscreen mode Exit fullscreen mode

Nothing reports this. memory_pool_free() returns true, used looks plausible, and every query says the pool is healthy. You find it later, in whatever code was unlucky enough to own the block second.

There are ways to catch it and both cost something we chose this pattern for. We could walk the free list on every free and reject a block already on it, which is correct and turns a constant-time free into a scan. We could keep a side bitmap of allocated blocks, which costs memory and gives up the zero-overhead property that motivated the intrusive list in the first place. For a small firmware codebase where the allocation sites are few and reviewable, documenting it is a defensible trade, and the limitation is written into the header right next to the function that cannot catch it. For a larger codebase it would be worth paying for a debug-build check.

Wrap-up

That's a complete memory pool. It does no dynamic allocation, hands blocks out and takes them back in constant time with no search, carries zero bytes of per-block overhead, cannot fragment by construction, and tells you afterwards how much of it you actually needed.

The trades, all deliberate:

  • One size per pool. Four object types means four pools. This is what buys away fragmentation, and it makes the pool a poor fit for genuinely variable-sized data.
  • Not thread-safe or ISR-safe. Unlike the ring buffer, there is no lock-free path here. Every mutating call touches free_head, and there is no way to split that between two contexts the way head and tail split. A shared pool needs a critical section around every mutating call, which is also why there is no volatile anywhere in memory_pool_t.
  • Blocks aren't zeroed. These are malloc semantics, not calloc. A fresh block holds a stale free list pointer in its first bytes and whatever the last owner left in the rest.
  • Double free is only partly detectable. Covered above, and the most important limitation in the module.
  • Capacity is derived, not given. Stride rounding, the alignment bump, and the tail remainder all eat into it. Read it back with memory_pool_capacity().

Two patterns in and they have started to lean on each other. The ring buffer gave us fixed storage for bytes, the pool gives us fixed storage for objects, and the event queue later in this series will want both: a ring of pointers to blocks handed out by a pool. That is how you get a queue of structs without a single call to malloc.

Next in the series is the state machine. The ring buffer and the pool both dealt in storage, bytes in one and blocks in the other, and neither cared what any of it meant. The state machine is where it starts to mean something.

Top comments (0)