Usage guide

A task-oriented tour of the API. Every snippet assumes an initialized instance (see Getting started) and, for brevity, omits some error checks that production code should keep. The full, compilable versions live in the examples/ directory.

The calling convention

A few conventions run through the whole API and are worth internalizing once:

  • Everything returns an error code. Functions return a ds_err_code_t; ERROR_DS_OK (which is 0) means success. Nothing is returned through the function value except status.

  • Pointers are passed by address. Allocating and freeing functions take a void ** so they can both read your current pointer (as a safety guard) and write the new one back. On success ds_malloc() writes the block address into *p_memory; ds_free() writes NULL.

  • On failure, outputs are left untouched. If a call returns anything other than ERROR_DS_OK, your pointer and the instance state are unchanged. That makes it safe to branch on the error without cleaning up half-done work.

A robust way to handle the return code is a small helper macro:

#define CHECK(expr)                                            \
    do {                                                       \
        ds_err_code_t err_ = (expr);                           \
        if (err_ != ERROR_DS_OK) {                             \
            fprintf(stderr, "%s -> %u\n", #expr, (unsigned)err_); \
            return EXIT_FAILURE;                               \
        }                                                      \
    } while (0)

Allocating and freeing

ds_malloc() allocates at least size bytes and writes the block address into *p_memory. The input pointer must be NULL (or a valid prior pointer you are done with) — never uninitialized — because the function reads it to reject overwriting a live block:

int *numbers = NULL;
CHECK(ds_malloc(&ds_buffer, (void **)&numbers, 4 * sizeof(int)));
/* ... use numbers[0..3] ... */
CHECK(ds_free(&ds_buffer, (void **)&numbers));
/* numbers is now NULL again */

ds_free() accepts only a pointer to the start of a live block. Interior pointers, foreign pointers and double frees are rejected (ERROR_DS_ALLOCATOR_NOT_FOUND or ERROR_DS_MEMORY_OUT_OF_DS) without corrupting anything. On success it NULLs your pointer, which is what makes reusing the same variable safe.

Zero-initialized arrays with ds_calloc

ds_calloc() allocates len * size_of_elem bytes and zero-fills them. The multiplication is overflow-checked, and the zeroing is unconditional — it happens regardless of the DS_ZERO_ON_FREE setting.

int *values = NULL;
CHECK(ds_calloc(&ds_buffer, (void **)&values, 4, sizeof(int)));
/* values[0..3] are guaranteed 0 */

Resizing with ds_realloc

ds_realloc() resizes a block, preserving its contents up to the smaller of the old and new sizes. It follows C realloc semantics with a few deliberate, safer deviations:

  • size == 0 behaves like ds_free().

  • *p_memory == NULL behaves like ds_malloc().

  • Shrinking happens in place, the pointer is unchanged, and the original capacity is retained (occupancy does not drop).

  • Growing the most recently placed block extends it in place when the trailing space allows — no copy, pointer unchanged.

  • Growing otherwise allocates a new block, copies the contents, and frees the old one; the pointer changes. This transiently needs a spare allocator record for the old+new pair.

  • A pointer that is not the start of a live block is rejected, unlike C realloc’s undefined behaviour.

Always assign the result back through the same pointer, because in the general case the block moves:

/* Grow a 4-int array to 8 ints, preserving the first four. */
CHECK(ds_realloc(&ds_buffer, (void **)&values, 8 * sizeof(int)));
for (int i = 4; i < 8; ++i) {
    values[i] = i + 1;      /* bytes beyond the old size are indeterminate */
}

On any failure the original block and pointer are left fully intact, so the common p = realloc(p, ...) footgun (losing the only pointer to the old block on failure) does not apply here — but assigning back is still the right habit.

Inspecting an instance

Three read-only getters let you reason about capacity before committing to an allocation. Each returns a snapshot that is only valid until the next mutating call.

uint8_t usage = 0;
size_t free_slots = 0, max_alloc = 0;
CHECK(ds_get_memory_usage(&ds_buffer, &usage));
CHECK(ds_get_free_allocator_cnt(&ds_buffer, &free_slots));
CHECK(ds_get_max_new_allocation_size(&ds_buffer, &max_alloc));
printf("usage=%u%%  free_slots=%zu  max_new_alloc=%zu\n",
       usage, free_slots, max_alloc);

A max_new_alloc of 0 means nothing can be allocated at any size; use the other two getters to tell whether it is the memory or the allocator slots that are exhausted.

Bounds-checked writes

ds_safe_memory_set() and ds_safe_memory_copy() are guarded alternatives to memset/memcpy. Before writing, they verify that the destination is part of a live allocation in the instance and that the allocation is large enough. An over-long write is rejected with ERROR_DS_NO_MEMORY and the destination is left untouched — a cheap guard against the stray writes that are so painful to debug on embedded targets.

char *text = NULL;
CHECK(ds_malloc(&ds_buffer, (void **)&text, 16));

CHECK(ds_safe_memory_set(&ds_buffer, text, 0, 16));         /* clear safely */
CHECK(ds_safe_memory_copy(&ds_buffer, text, "hello", 6));   /* incl. the NUL */

/* This would overrun the 16-byte block, so it is refused and `text` is
 * left intact instead of being corrupted. */
ds_err_code_t err = ds_safe_memory_copy(&ds_buffer, text,
    "this string is definitely longer than sixteen bytes", 52);
/* err == ERROR_DS_NO_MEMORY */

Putting it together

The four ideas above compose into the typical pattern for a constrained system: initialize once into static storage, allocate with the pointer-by-address convention, check every error code, optionally introspect before large requests, and tear down with ds_deinit_allocation(). For the exact semantics of every parameter and return value, continue to the API reference reference.