stack_array is a small C helper library for fixed-capacity arrays and strings with inline storage and tracked length.
The goal is to remove some of the repetitive, error-prone parts of normal C code. Length and capacity are tracked for you, bounds are checked, and string helpers keep the buffer null-terminated.
It is mainly a small bookkeeping layer for fixed-capacity data. You can use it as-is, or as a base for your own small helpers.
The win is small but real: less friction at call sites and in function signatures, especially when these arrays and strings are fields inside larger structs.
- fixed-capacity stack arrays with tracked length
- fixed-capacity stack strings with tracked length
- checked push/pop/peek/index operations
- checked append for arrays and strings
- ss_appendf and ss_sprintf for strings
- optional custom overflow / underflow handlers
(Note: This is not a heap-backed dynamic array. Capacity is fixed at declaration time.)
stack_array reduces some of the friction of working with fixed-capacity buffers in C. Two common alternatives are plain C, or a small container that points at a separate fixed buffer.
With plain C, you keep the buffer, current length, and capacity in sync yourself.
char name[32];
size_t len = 0;
size_t cap = sizeof(name);
const char *src = "abc";
size_t n = strlen(src);
if (n > cap - len - 1) {
fprintf(stderr, "overflow\n");
abort();
}
memcpy(name + len, src, n);
len += n;
name[len] = '\0';This is simple and fully explicit. The downside is that every operation has to deal with len, cap, and bounds checks somehow.
In real code, people usually write helpers for this, which leads to APIs like:
void str_append_n(char *dst, size_t *len, size_t cap,
const char *src, size_t n);That works fine, but you end up passing the bookkeeping around everywhere.
Another option is to keep the raw array and wrap it.
char name_buf[32];
typedef struct {
char *ptr;
size_t len;
size_t cap;
} Str;
Str name = { name_buf, 0, sizeof(name_buf) };Then your helpers operate on Str *:
void str_append(Str *s, const char *src);This is a good conventional design. It keeps ptr, len, and cap together, and it is a reasonable alternative to this library.
The tradeoff is that the storage and the descriptor are now separate things. For locals that is usually fine. For embedded fields, one logical string often becomes “buffer plus wrapper,” which is a little more awkward.
It also means the wrapper type becomes part of the API. For strings that may just be Str *, but other fixed-capacity arrays need their own container types as well.
This library keeps the storage inline, but stores len and cap in hidden metadata immediately before the array.
stack_string(name, 32);
ss_append(name, "abc");
ss_pushc(name, '!');You work with the data directly, so helpers and other functions take name, not name.ptr or a wrapper type.
The helpers can still recover the current length and capacity from that pointer, so you do not have to pass them separately.
Compared to plain C, this avoids threading len and cap through every helper.
Compared to a separate wrapper like struct { ptr, len, cap }, this avoids keeping a second object paired with the buffer and keeps wrapper type names out of function signatures.
This is particularly useful when these arrays or strings are fields inside larger structs.
#include "stack_array.h"
void example(void) {
stack_array(values, int, 4);
sa_push(values, 10);
sa_push(values, 20);
sa_push(values, 30);
int x = sa_pop(values); // 30
int top = sa_peek(values); // 20
// len/cap are tracked in the hidden header
size_t len = sa_len(values); // 2
size_t cap = sa_cap(values); // 4
}#include "stack_array.h"
void example(void) {
stack_array(dst, int, 8);
int src[] = { 1, 2, 3, 4 };
sa_append(dst, src, 4);
// dst now contains 1, 2, 3, 4
// sa_len(dst) == 4
}#include "stack_array.h"
void example(void) {
stack_string(msg, 32);
ss_append(msg, "hello");
ss_append(msg, " world");
ss_pushc(msg, '!');
ss_appendf(msg, " value=%d", 42);
// msg is always null-terminated after successful string operations
// ss_len(msg) matches strlen(msg)
}#include "stack_array.h"
void example(void) {
stack_string(path, 64);
// formatted writes only modify the buffer if the full result fits
ss_sprintf(path, "/tmp/%s_%d.txt", "log", 7);
// path == "/tmp/log_7.txt"
}You can also embed these arrays directly inside a struct.
#include "stack_array.h"
struct Label {
int id;
ss_field(name, 32);
};
void example(void) {
struct Label label = {0};
label.id = 7;
ss_field_init(label, name); // init required to set capacity
ss_append(label.name, "player");
ss_pushc(label.name, '_');
char *name = label.name; // derived pointer works normally after ss_field_init(...)
ss_appendf(name, "%d", label.id);
}The field lives inside the struct, and after initialization it can be used through a normal pointer.
Each array reserves a fixed metadata prefix that sits immediately before the data buffer. The prefix always stores cap and len, and in debug builds an additional guard word is stored just before them for corruption detection. The prefix is padded as needed so that the data buffer still meets the element’s alignment requirement.
Because the layout is always [optional padding][guard (debug only)][cap][len][data], recovering the header is trivial: subtract sizeof(SA_Header) from the array pointer (and, in debug builds, check the guard word). No extra indirection or offset decoding is required, so lookups are simple and cheap.
-
stack_array(name, T, N) -
sa_len(arr) -
sa_cap(arr) -
sa_clear(arr) -
sa_push(arr, val) -
sa_pop(arr) -
sa_peek(arr) -
sa_at(arr, i) -
sa_append(dst, src, count)
-
stack_string(name, N) -
ss_len(str) -
ss_cap(str) -
ss_clear(str) -
ss_append(str, src) -
ss_append_n(str, src, n) -
ss_pushc(str, c) -
ss_appendf(str, fmt, ...) -
ss_sprintf(str, fmt, ...)
-
sa_field(name, T, N) -
sa_field_init(obj, field) -
ss_field(name, N) -
ss_field_init(obj, field)
Use the macros/functions only with buffers created by:
-
stack_array -
stack_string -
sa_field -
ss_field
They are not general-purpose wrappers for arbitrary C arrays.
Buffers declared with sa_field(...) or ss_field(...) must be initialized before use.
Example:
struct Label {
ss_field(name, 32);
};
void example(void) {
struct Label label = {0};
ss_field_init(label, name); // sets capacity
}For strings, capacity is the total buffer size, including the null terminator.
So:
stack_string(str, 32);means:
-
up to 31 non-null characters
-
plus the trailing
'\0'
Also:
-
ss_len(str)is the logical string length -
ss_len(str)matchesstrlen(str) -
the string helpers write a trailing
'\0'after each successful modification
Overflow and underflow call installed handlers.
By default:
-
overflow prints an error and aborts
-
underflow prints an error and aborts
You can replace them:
sa_set_overflow_handler(my_overflow_handler);
sa_set_underflow_handler(my_underflow_handler);sa_at, sa_peek, and sa_pop are expression-style checked macros.
They are written with the expectation that the error handlers do not return. If a handler does return, the resulting value of those expressions is unspecified and should not be relied on.
For sa_push, the return value is a pointer to the inserted element on success, and NULL only if the overflow handler returns.
-
The API relies heavily on macros.
-
The helpers depend on a hidden-header layout and therefore only work with arrays declared using this library's macros.
-
sa_field(...)/ss_field(...)require explicit initialization withsa_field_init(...)/ss_field_init(...). -
Error handlers are intended to abort or otherwise not return.
-
This is a fixed-capacity container. It does not grow dynamically.
- C99 or later
- Tested on MSVC, clang-cl, GCC, and Clang
Build the library and run tests:
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
Use from another CMake project:
add_subdirectory(path/to/stack_array)
target_link_libraries(my_target PRIVATE stack_array)
If you want to install the library and header:
cmake -S . -B build
cmake --build build
cmake --install build --prefix ./install
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.