[{"content":"Post 1 of the Dynamic Arrays in C series · Full source code on GitHub\nThe Problem No One Starts With You have five integers. You put them in an array:\n1 int numbers[5] = {10, 20, 30, 40, 50}; Done. C gives you a contiguous chunk of 20 bytes on the stack, indexed from 0 to 4, and life is good.\nNow your user wants to add a sixth integer. What do you do?\nYou can\u0026rsquo;t resize a stack array. Its size was baked into the binary at compile time — the compiler saw 5, calculated 20 bytes, and that\u0026rsquo;s the space your function\u0026rsquo;s stack frame has. There\u0026rsquo;s no negotiation. You could declare int numbers[1000] and hope it\u0026rsquo;s big enough, but hope is not a memory management strategy.\nYou could use a variable-length array (int numbers[n]), but that just shifts the problem: n is still fixed once you enter the function. Worse, VLAs live on the stack, which is limited to a few megabytes. Store a million integers and you blow the stack with no graceful recovery.\nThe real solution lives on the heap. malloc lets you ask the operating system for a chunk of memory at runtime — any size you want, limited only by available RAM. But malloc gives you raw bytes and a pointer. No size tracking. No bounds checking. No \u0026ldquo;how full am I?\u0026rdquo; bookkeeping. You get the memory and the responsibility.\nThis is where every dynamic array begins: not with a clever data structure, but with a basic question of bookkeeping. Who tracks how many elements you\u0026rsquo;ve stored? Who tracks how many you could store? Who makes sure the memory gets freed when you\u0026rsquo;re done?\nIn C, the answer is always the same: you do.\nThis post builds the simplest possible dynamic array — one that holds integers, has a fixed capacity, and does exactly three things: create, push, and destroy. No automatic growth (that\u0026rsquo;s Post 2), no generics (Post 4), no error recovery (Post 6). Just the raw skeleton that everything else builds on.\nBy the end, you\u0026rsquo;ll understand two things most C tutorials skip: why the metadata struct exists, and why the order in which you call free matters.\nLet\u0026rsquo;s allocate some memory.\nThe Struct: What an Array Knows About Itself A raw malloc call returns void * — a pointer to bytes with no meaning attached. If you allocate space for 10 integers, nobody remembers that number except you. The instant you lose track of the capacity, you\u0026rsquo;re writing bugs.\nSo the first thing a dynamic array needs isn\u0026rsquo;t data. It\u0026rsquo;s metadata: a small struct that sits alongside the data and remembers the bookkeeping details.\n1 2 3 4 5 typedef struct { int *data; /* Pointer to the heap buffer holding elements */ size_t size; /* How many elements have been stored */ size_t capacity; /* How many elements the buffer can hold */ } IntArray; Three fields. This is the minimum viable bookkeeping:\ndata is a pointer to the actual heap allocation where elements live. It\u0026rsquo;s the result of a malloc(capacity * sizeof(int)) call. When you access arr-\u0026gt;data[3], you\u0026rsquo;re reading the fourth integer in that allocation.\nsize tracks how many elements the user has actually pushed. It starts at 0 and increments with every array_push. It is not the same as capacity — this distinction is the single most important concept in dynamic array design.\ncapacity tracks how many elements the allocation can hold. If you malloced space for 10 integers, capacity is 10, even if size is only 3. The gap between size and capacity is wasted memory — we allocated it but aren\u0026rsquo;t using it yet. Managing that gap is the art of dynamic arrays.\nThink of it like a parking garage. capacity is the number of parking spots. size is the number of cars currently parked. The garage exists at a specific address (data). You can have an empty garage (size=0, capacity=100) or a full one (size=100, capacity=100), but you can never park more cars than spots — unless you build a bigger garage.\nDiagram showing the IntArray metadata struct with a pointer to a contiguous heap buffer of 5 integer slots.\nWhat malloc Actually Does Before looking at the implementation, it\u0026rsquo;s worth understanding what happens when you call malloc(20).\nYou\u0026rsquo;re not asking the OS for exactly 20 bytes. The C runtime\u0026rsquo;s allocator (glibc\u0026rsquo;s ptmalloc2 on Linux, jemalloc on FreeBSD, etc.) maintains pools of pre-allocated memory called arenas. When you call malloc(20), the allocator finds a free block in one of its pools, marks it as used, and returns its address. If no pool has room, the allocator requests more memory from the kernel via sbrk or mmap — but this is the expensive path, and it happens rarely.\nThe returned pointer has a hidden header just before it (typically 8–16 bytes) where the allocator stores metadata: the block\u0026rsquo;s size, whether it\u0026rsquo;s in use, and pointers to adjacent free blocks. This is how free knows how many bytes to release — you never tell it the size, because the allocator already recorded it.\nWhat malloc returns: What actually exists in memory: ┌──────────────────┐ │ allocator header │ (hidden, 8-16 bytes) ptr ──────────────────► ├──────────────────┤ │ │ │ your 20 bytes │ │ │ └──────────────────┘ This has practical consequences. Every malloc call costs not just the bytes you asked for, but also the overhead of that header. If you allocate a million 4-byte blocks, you\u0026rsquo;re actually using 12–20 bytes per block — the 4 bytes you wanted plus the hidden header. For our dynamic array, this is why we make two allocations (one for the struct, one for the buffer) instead of millions of individual malloc(sizeof(int)) calls: fewer allocations means less overhead.\nIt also means that free doesn\u0026rsquo;t need to know the size of the allocation — it reads the header. But free doesn\u0026rsquo;t zero the memory or return it to the OS. The block is just marked as available in the allocator\u0026rsquo;s free list. The bytes remain there, with their old values, until something else overwrites them. This is why use-after-free bugs are insidious: the data looks valid long after you\u0026rsquo;ve freed it.\nThe Code The implementation compiles with zero warnings under gcc -Wall -Wextra -Wpedantic -std=c11 and runs without leaks.\nThe full source — including a main() that demonstrates every operation step by step — is available on GitHub. Below are the essential pieces.\nLifecycle: Create and Destroy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 IntArray *array_create(size_t capacity) { if (capacity == 0) { fprintf(stderr, \u0026#34;array_create: capacity must be \u0026gt; 0\\n\u0026#34;); return NULL; } IntArray *arr = malloc(sizeof(IntArray)); /* allocation #1 */ if (!arr) { fprintf(stderr, \u0026#34;array_create: failed to allocate struct\\n\u0026#34;); return NULL; } arr-\u0026gt;data = malloc(capacity * sizeof(int)); /* allocation #2 */ if (!arr-\u0026gt;data) { fprintf(stderr, \u0026#34;array_create: failed to allocate buffer\\n\u0026#34;); free(arr); /* Don\u0026#39;t leak the struct if the buffer fails */ return NULL; } arr-\u0026gt;size = 0; arr-\u0026gt;capacity = capacity; return arr; } Notice the error handling between the two allocations. If the first malloc succeeds but the second fails, we have a partially-constructed object: a struct on the heap with an invalid data pointer. If we returned NULL without freeing the struct, those 24 bytes (three fields on a 64-bit system: one pointer + two size_t) would be leaked. The free(arr) call before the return NULL prevents that. This pattern — clean up everything you\u0026rsquo;ve allocated so far when a later step fails — is the foundation of resource cleanup in C. You\u0026rsquo;ll see it scaled up in Post 6 when we discuss error handling strategies.\n1 2 3 4 5 6 7 void array_destroy(IntArray *arr) { if (!arr) return; free(arr-\u0026gt;data); /* 1. free the element buffer */ arr-\u0026gt;data = NULL; /* (defensive: prevent use-after-free) */ free(arr); /* 2. free the struct itself */ } Two allocations in array_create, two frees in array_destroy. The symmetry is intentional and the order is not negotiable — more on that below.\nOperations: Push and Get 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int array_push(IntArray *arr, int value) { if (!arr) { fprintf(stderr, \u0026#34;array_push: NULL array\\n\u0026#34;); return -1; } if (arr-\u0026gt;size \u0026gt;= arr-\u0026gt;capacity) { fprintf(stderr, \u0026#34;array_push: full (size=%zu, capacity=%zu). \u0026#34; \u0026#34;Cannot add %d.\\n\u0026#34;, arr-\u0026gt;size, arr-\u0026gt;capacity, value); return -1; } arr-\u0026gt;data[arr-\u0026gt;size] = value; arr-\u0026gt;size++; return 0; } int array_get(const IntArray *arr, size_t index, int *out) { if (!arr || index \u0026gt;= arr-\u0026gt;size) return -1; *out = arr-\u0026gt;data[index]; return 0; } size_t array_size(const IntArray *arr) { return arr ? arr-\u0026gt;size : 0; } size_t array_capacity(const IntArray *arr) { return arr ? arr-\u0026gt;capacity : 0; } Push writes to arr-\u0026gt;data[arr-\u0026gt;size] and increments size. Two lines of actual logic; the rest is validation. The expression arr-\u0026gt;data[arr-\u0026gt;size] works because array indexing in C is pointer arithmetic: arr-\u0026gt;data[n] is equivalent to *(arr-\u0026gt;data + n), which means \u0026ldquo;start at the address in data, move forward n * sizeof(int) bytes, and dereference.\u0026rdquo; As long as n \u0026lt; capacity, that address is within our allocation.\narray_get returns the value through an output pointer (out) instead of returning it directly. This is because we need two channels: the value itself and whether the operation succeeded. Returning int for both the value and the error code would be ambiguous — is -1 an error or a legitimate stored value? The output pointer pattern separates these concerns cleanly.\nDriving It All 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 int main(void) { IntArray *arr = array_create(5); if (!arr) return 1; int values[] = {10, 20, 30, 40, 50}; for (int i = 0; i \u0026lt; 5; i++) array_push(arr, values[i]); /* This will fail — array is full */ int rc = array_push(arr, 60); /* returns -1 */ /* Read back */ int val; for (size_t i = 0; i \u0026lt; array_size(arr); i++) { array_get(arr, i, \u0026amp;val); printf(\u0026#34;arr[%zu] = %d\\n\u0026#34;, i, val); } array_destroy(arr); return 0; } Compile and run it yourself:\n1 2 gcc -Wall -Wextra -Wpedantic -std=c11 -o post_01 post_01.c ./post_01 Walking Through the Code Two Allocations, Two Frees The most important pattern in this file is the symmetry between array_create and array_destroy.\narray_create performs two allocations:\nmalloc(sizeof(IntArray)) → the metadata struct (24 bytes on 64-bit) malloc(capacity * sizeof(int)) → the element buffer (capacity × 4 bytes) array_destroy performs two frees, in reverse order:\nfree(arr-\u0026gt;data) → element buffer first free(arr) → metadata struct second This reverse order is not a stylistic preference — it\u0026rsquo;s a correctness requirement. Let\u0026rsquo;s trace what happens if you swap them:\n1 2 3 4 /* WRONG — undefined behavior */ free(arr); /* struct memory returned to allocator */ free(arr-\u0026gt;data); /* arr is now a dangling pointer — reading arr-\u0026gt;data is an invalid memory access */ After free(arr), the memory at arr has been returned to the allocator. It could be reused by the very next malloc call — even one happening on another thread. Reading arr-\u0026gt;data at that point might return the original pointer value (if the memory hasn\u0026rsquo;t been touched yet), or it might return garbage (if the allocator has overwritten those bytes with its own bookkeeping data for the free list). Either way, it\u0026rsquo;s undefined behavior. Valgrind would flag this as \u0026ldquo;Invalid read of size 8\u0026rdquo; (the size of a pointer).\nThe general principle: when you have nested allocations (a struct that owns pointers to other allocations), free from the inside out — innermost allocations first, outermost last.\nThe Defensive NULL Assignment After freeing the data buffer, we set arr-\u0026gt;data = NULL:\n1 2 3 free(arr-\u0026gt;data); arr-\u0026gt;data = NULL; /* defensive */ free(arr); In this simple code, nothing touches arr-\u0026gt;data between the two frees, so the NULL assignment does nothing. But in more complex code — with error handlers, callbacks, or cleanup functions that might run between those two lines — the NULL acts as a safety net. If anything tries to dereference arr-\u0026gt;data after the first free, it hits a NULL pointer dereference instead of a use-after-free. A NULL dereference is a loud, immediate crash with a clear stack trace. A use-after-free is a silent corruption that might not manifest until thousands of lines later. You trade one bug for a more debuggable bug.\nThis pattern is sometimes called defensive clearing or poisoning. Some codebases go further and zero the entire struct before the final free (memset(arr, 0, sizeof(*arr))), though that has a cost: the compiler might optimize it away if it can prove nothing reads the struct afterward (since reading freed memory is UB). Post 6 addresses this in the context of a broader error-handling strategy.\nPointer Arithmetic Inside Push The line that does the actual work in array_push is:\n1 arr-\u0026gt;data[arr-\u0026gt;size] = value; This single line involves three dereferences:\narr is dereferenced to access the struct on the heap arr-\u0026gt;data is dereferenced to get the base address of the buffer The result is indexed by arr-\u0026gt;size to compute the write address On a 64-bit system, the write address is calculated as: write_address = arr-\u0026gt;data + (arr-\u0026gt;size * sizeof(int)) = arr-\u0026gt;data + (arr-\u0026gt;size * 4) The size \u0026gt;= capacity check before this line guarantees that write_address falls within our allocated buffer. Without it, we\u0026rsquo;d be writing past the end of our allocation — a heap buffer overflow. Depending on what lives past our allocation, this could corrupt the allocator\u0026rsquo;s metadata (causing a crash in a later, unrelated malloc or free), overwrite another variable\u0026rsquo;s data (causing impossible-looking bugs), or hit a guard page (causing an immediate segfault). AddressSanitizer (gcc -fsanitize=address) catches these instantly, and it\u0026rsquo;s worth compiling with it during development.\nKey Concepts and Tradeoffs Stack vs Heap for the Metadata Struct Our array_create allocates the IntArray struct on the heap:\n1 IntArray *arr = malloc(sizeof(IntArray)); Why not put it on the stack? You could write:\n1 2 3 4 5 6 7 IntArray array_create_stack(size_t capacity) { IntArray arr; arr.data = malloc(capacity * sizeof(int)); arr.size = 0; arr.capacity = capacity; return arr; /* returns a copy */ } This works, and it\u0026rsquo;s actually slightly faster — stack allocation is just a pointer decrement on the stack pointer register, while malloc navigates free lists. But it changes the API in subtle and dangerous ways.\nThe caller receives a copy of the struct. If they pass that copy to array_push, the function modifies its own copy of size — the caller\u0026rsquo;s size remains unchanged. You\u0026rsquo;d need to pass by pointer everywhere: array_push(\u0026amp;arr, value). That\u0026rsquo;s workable, but easy to forget, and the compiler won\u0026rsquo;t warn you.\nMore seriously, the stack struct\u0026rsquo;s lifetime is tied to its scope. If you return it from a function, you\u0026rsquo;re returning a copy (fine). If you store a pointer to it and the function returns, that pointer is dangling (catastrophic). Heap allocation gives you a stable pointer that survives function boundaries, can be stored in other data structures, and has a clear ownership model: whoever holds the pointer is responsible for calling array_destroy.\nFor a library API, heap allocation is the standard choice. You\u0026rsquo;ll see this pattern in virtually every C library: thing_create() returns a pointer, thing_destroy() frees it. The tradeoff is explicit: you trade a few nanoseconds of malloc overhead and the burden of manual cleanup for an API that\u0026rsquo;s unambiguous about ownership and lifetime.\nMemory Waste: The Capacity Problem When you create an array with capacity 10 and store 3 elements, you\u0026rsquo;re wasting 7 slots × 4 bytes = 28 bytes. That\u0026rsquo;s a 70% waste ratio. For our post, where we push 5 elements into a capacity-5 array, waste drops to 0% by the end — but there\u0026rsquo;s a window where we\u0026rsquo;re paying for memory we haven\u0026rsquo;t used yet.\nIs this bad? It depends on scale. For a single array, 28 bytes is negligible. For a million small arrays in a memory-constrained embedded system, it might matter. For one large array, the waste percentage drops to near zero as you fill it.\nThe real question is: what capacity should you start with? If you pick too small (capacity=1), you\u0026rsquo;ll need to reallocate on every push once we add growth in Post 2 — each reallocation involves copying all existing elements. If you pick too large (capacity=10000), you waste memory on arrays that only hold 5 elements. The tension between time (fewer reallocations) and space (less waste) is the central tradeoff of dynamic arrays, and it leads directly to the growth factor analysis in Post 3.\nWhy Return Codes, Not Assertions array_push returns 0 on success and -1 on failure. It doesn\u0026rsquo;t abort the program. This is a conscious design decision: the caller should decide what to do when a push fails. Maybe the caller wants to log and continue. Maybe they want to resize and retry. Maybe they want to exit. By returning an error code, we give the caller that choice.\nThe alternative — assert(arr-\u0026gt;size \u0026lt; arr-\u0026gt;capacity) — kills the program with no recovery. That\u0026rsquo;s appropriate for programmer errors (invariants that should never be violated if the code is correct), but not for runtime conditions like \u0026ldquo;the array is full.\u0026rdquo; A full array isn\u0026rsquo;t a bug — it\u0026rsquo;s a foreseeable state that the program should handle.\nThere\u0026rsquo;s a subtlety here about errno and error reporting that\u0026rsquo;s worth flagging. Our current approach — printing to stderr and returning -1 — is fine for a learning exercise, but production code would typically set errno or return a richer error type. We\u0026rsquo;ll discuss the full spectrum of error handling strategies in Post 6.\nPointer Ownership: The Contract There\u0026rsquo;s an implicit contract in our API that no comment or type annotation enforces: the pointer returned by array_create is owned by the caller. Ownership means exactly one thing: the owner is responsible for calling array_destroy on it. Nobody else should free it. Nobody should free parts of it (arr-\u0026gt;data) independently. And once array_destroy has been called, every copy of that pointer becomes invalid.\nC has no language-level mechanism to enforce this. Rust has Box\u0026lt;T\u0026gt; and affine types. C++ has std::unique_ptr. In C, ownership is a convention — one you communicate through documentation, naming (create/destroy pairs), and discipline.\nThe bugs that arise from violating ownership are among the worst in C:\nDouble free: two parts of the code both think they own the pointer and both call array_destroy. The second free corrupts the allocator\u0026rsquo;s metadata. Use after free: one part of the code frees the pointer while another still holds a copy and keeps using it. Memory leak: nobody frees the pointer because everyone assumes someone else will. In this simple post, ownership is obvious — main creates, main destroys. In larger programs with shared data structures, callbacks, and multithreading, ownership becomes the hardest problem in C. We\u0026rsquo;ll revisit this in Post 7 when we add function pointers and destructors for element types. Try This and Watch It Fail Before moving on, try these experiments with the code:\nExperiment 1: The Memory Leak. In array_destroy, comment out free(arr-\u0026gt;data). Compile and run under valgrind (valgrind --leak-check=full ./post_01). You\u0026rsquo;ll see \u0026ldquo;definitely lost: 20 bytes\u0026rdquo; — that\u0026rsquo;s the orphaned buffer. The struct was freed, but the buffer it pointed to was not. This is exactly the bug the knowledge test asks about.\nExperiment 2: Use After Free. In main, add printf(\u0026quot;%d\\n\u0026quot;, arr-\u0026gt;data[0]); after array_destroy(arr). Compile and run. It might print 10. It might print garbage. It might crash. That\u0026rsquo;s undefined behavior — the data is freed, but the memory hasn\u0026rsquo;t necessarily been overwritten yet. Valgrind would flag this as \u0026ldquo;Invalid read of size 4.\u0026rdquo;\nExperiment 3: Buffer Overflow. Remove the size \u0026gt;= capacity check in array_push. Push 100 elements into a capacity-5 array. Compile with AddressSanitizer (gcc -fsanitize=address) and watch it detect the heap-buffer-overflow.\nExperiment 4: The Double Free. In main, call array_destroy(arr) twice. On many systems the second call will crash with \u0026ldquo;double free or corruption.\u0026rdquo; Some allocators detect this immediately; others corrupt silently and crash later. This is why our array_destroy accepts NULL gracefully — if you set arr = NULL after the first destroy, the second call becomes a no-op.\nKnowledge Test What happens if you call free(arr) but forget to call free(arr-\u0026gt;data) first?\nThe struct is returned to the heap allocator, but the buffer it pointed to — the capacity * sizeof(int) bytes at arr-\u0026gt;data — remains allocated. No pointer to it exists anymore (the struct that held the pointer is freed), so the memory is leaked. It will never be freed for the rest of the program\u0026rsquo;s lifetime. On a long-running program, repeated leaks like this accumulate and eventually exhaust memory. Valgrind would report \u0026ldquo;definitely lost: N bytes in 1 blocks.\u0026rdquo;\nWhat\u0026rsquo;s Next Our array works, but it has a crippling limitation: when it\u0026rsquo;s full, push fails. The user has to guess the right capacity upfront, and if they guess wrong, they\u0026rsquo;re stuck.\nIn Post 2: \u0026ldquo;Growing Pains: realloc and Automatic Capacity Management\u0026rdquo;, we remove this limitation. We\u0026rsquo;ll introduce realloc — the call that says \u0026ldquo;give me more space, and copy my data to the new location if needed.\u0026rdquo; You\u0026rsquo;ll learn why old pointers become invalid after a realloc, why the growth factor matters (spoiler: it determines your amortized cost), and why you must never write arr-\u0026gt;data = realloc(arr-\u0026gt;data, new_size) directly.\nThe fixed-capacity array you built today is the foundation. Everything from here builds on it.\nFull source code on GitHub · Compile: gcc -Wall -Wextra -Wpedantic -std=c11 -o post_01 post_01.c · Next post: Growing Pains: realloc and Automatic Capacity Management\n","permalink":"https://pablogs.dev/posts/post-01-hello-array/","summary":"\u003cp\u003e\u003cem\u003ePost 1 of the Dynamic Arrays in C series · \u003ca href=\"https://github.com/ansuzgs/dynamic-arrays-c/blob/main/src/post_01.c\"\u003eFull source code on GitHub\u003c/a\u003e\u003c/em\u003e\u003c/p\u003e\n\u003ch2 id=\"the-problem-no-one-starts-with\"\u003eThe Problem No One Starts With\u003c/h2\u003e\n\u003cp\u003eYou have five integers. You put them in an array:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cdiv class=\"chroma\"\u003e\n\u003ctable class=\"lntable\"\u003e\u003ctr\u003e\u003ctd class=\"lntd\"\u003e\n\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode\u003e\u003cspan class=\"lnt\"\u003e1\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\n\u003ctd class=\"lntd\"\u003e\n\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-c\" data-lang=\"c\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kt\"\u003eint\u003c/span\u003e \u003cspan class=\"n\"\u003enumbers\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"mi\"\u003e5\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"mi\"\u003e10\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e20\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e40\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e50\u003c/span\u003e\u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/td\u003e\u003c/tr\u003e\u003c/table\u003e\n\u003c/div\u003e\n\u003c/div\u003e\u003cp\u003eDone. C gives you a contiguous chunk of 20 bytes on the stack, indexed from 0 to 4, and life is good.\u003c/p\u003e\n\u003cp\u003eNow your user wants to add a sixth integer. What do you do?\u003c/p\u003e\n\u003cp\u003eYou can\u0026rsquo;t resize a stack array. Its size was baked into the binary at compile time — the compiler saw \u003ccode\u003e5\u003c/code\u003e, calculated 20 bytes, and that\u0026rsquo;s the space your function\u0026rsquo;s stack frame has. There\u0026rsquo;s no negotiation. You could declare \u003ccode\u003eint numbers[1000]\u003c/code\u003e and hope it\u0026rsquo;s big enough, but hope is not a memory management strategy.\u003c/p\u003e","title":"Hello, Array: malloc, free and Manual Bookkeeping"},{"content":"Post 1 of 13 — Series: Memory Allocation and Garbage Collection from Scratch\nEvery time you write malloc(128), something apparently magical happens: the runtime hands you a pointer to 128 bytes of memory nobody else is using. You didn\u0026rsquo;t ask the operating system for permission. You didn\u0026rsquo;t specify where those bytes should live. They simply appeared. And when you call free(), they vanish back into the void.\nThe magic is a lie.\nUnderneath malloc() there\u0026rsquo;s nothing sophisticated. There\u0026rsquo;s a syscall that moves a number upward. There\u0026rsquo;s a pointer that advances. There\u0026rsquo;s a data structure keeping track. Your program, any C program, is running code very much like what we\u0026rsquo;re going to write today.\nIn this post, we\u0026rsquo;ll build a working memory allocator in approximately 80 lines of C. It\u0026rsquo;s small. It\u0026rsquo;s readable. It doesn\u0026rsquo;t do everything malloc() does, it\u0026rsquo;s missing important pieces we\u0026rsquo;ll add in later posts. But what it does do is real: it asks the operating system for memory, hands it out to callers, and manages it with a struct and three functions.\nThe promise of this series is simple: by the end of the 13 posts, you\u0026rsquo;ll have built, with your own hands, a complete allocator with free lists, coalescing, mmap()-based arenas, and a working garbage collector. All compilable, all runnable, all explained.\nWe start with the basics. We start with the bump pointer.\nSection 1: How malloc() Really Works The Process Address Space When the Linux kernel loads your program, it assigns it a virtual address space. On x86-64, that space is enormous (48 bits of usable addresses), but the logical structure is the same as on any architecture: a handful of well-defined regions with fixed roles.\nThe two that matter to us:\nHigh addresses (0x7fff...) ┌─────────────────────────┐ │ STACK │ ← grows downward (each function call) │ │ │ │ ▼ │ │ │ │ (free space) │ │ │ │ ▲ │ │ │ │ │ HEAP │ ← grows upward (each malloc) ├─────────────────────────┤ │ program break (brk) │ ← boundary: below is yours, above is not ├─────────────────────────┤ │ BSS / Data │ │ Text (code) │ └─────────────────────────┘ Low addresses (0x0000...) The stack grows downward with each function call. The heap grows upward with each dynamic memory request. Between them lies an ocean of unmapped virtual space.\nThe critical point is the program break (brk). It\u0026rsquo;s a per-process variable the kernel maintains. Everything below the break is memory your process can read and write. Everything above is kernel territory, touch it and you get a SIGSEGV.\nThe sbrk(2) Syscall sbrk() is the tool that moves that break. Its conceptual signature is trivial:\n1 void *sbrk(intptr_t increment); What it does: moves the program break increment bytes upward (or downward if increment is negative). Returns the previous break position, that is, the base address of the new region.\nCalling sbrk(0) without moving anything tells you where the break is right now. It\u0026rsquo;s the way to \u0026ldquo;ask\u0026rdquo; without \u0026ldquo;requesting\u0026rdquo;.\nBefore sbrk(256): ┌───────────────────┐ │ free space │ brk ──►├───────────────────┤ │ existing heap │ └───────────────────┘ After sbrk(256): ┌───────────────────┐ │ free space │ brk ──►├───────────────────┤ │ 256 new bytes │ ← sbrk returns this address ├───────────────────┤ │ existing heap │ └───────────────────┘ Once you call sbrk(n), those n bytes are yours. The kernel has mapped the corresponding virtual pages to physical memory (or at least promised to do so when you touch them, demand paging). You can read and write them until the process ends.\nThere\u0026rsquo;s an important invariant here: sbrk() doesn\u0026rsquo;t give you isolated blocks. It gives you a contiguous extension of the heap. Everything you\u0026rsquo;ve requested so far forms a continuous byte array. This property is simultaneously its greatest strength (simplicity) and its greatest weakness (rigidity). In Post 7, when we migrate to mmap(), we\u0026rsquo;ll see how to overcome this limitation.\nThe Mental Model: The Linear Heap With what we know, the life of malloc() boils down to this:\nAt startup, the heap is an empty array. sbrk(0) tells us where it starts. Someone requests n bytes. We move the break n bytes upward with sbrk(n). We return the base address. Someone requests m more bytes. We move the break again. We return the new base. Repeat. In pseudocode:\n1 2 3 4 5 allocate(size): old_break = sbrk(size) if old_break == error: return NULL return old_break That\u0026rsquo;s a bump allocator, an allocator that only advances a pointer. It never goes back. It doesn\u0026rsquo;t need to remember which blocks are free because no block is ever freed. It\u0026rsquo;s the simplest possible allocation strategy, and it\u0026rsquo;s exactly what we\u0026rsquo;re going to implement.\nWhy start with something so limited? Because it makes the fundamental mechanics visible without noise. The bump allocator shows you what sbrk() is doing, how memory is organized, and where your data lives. Those intuitions survive intact when, in later posts, we add complexity on top.\nSection 2: The Bump Allocator — Code The heap_t Struct All of our allocator\u0026rsquo;s state lives in one struct:\n1 2 3 4 5 6 typedef struct { void *start; /* base address of the sbrk\u0026#39;d region */ void *brk; /* current program break (start + capacity) */ size_t capacity; /* total bytes requested from the OS */ size_t used; /* bytes allocated (bump pointer offset) */ } heap_t; Four fields. Each one is there for a concrete reason, and each one will pay dividends in future posts:\nstart is the base address sbrk() gave us at initialization. It never changes. It\u0026rsquo;s the heap\u0026rsquo;s \u0026ldquo;zero\u0026rdquo;, all internal positions are calculated as offsets from here. In Post 7, when we have multiple arenas with mmap(), each arena will have its own start.\nbrk is the current program break. Always equal to start + capacity. We keep it explicit rather than computing it each time because, in future posts, brk will decouple from start + capacity when we have non-contiguous regions.\ncapacity is how many bytes we\u0026rsquo;ve requested from the OS in total. Not how many we\u0026rsquo;ve handed out — how many we have available. When a heap_alloc() doesn\u0026rsquo;t fit, we request more with sbrk() and update capacity.\nused is the bump pointer itself: how many bytes of capacity we\u0026rsquo;ve already handed out. Whenever someone requests memory, used advances. It never goes back. The current free position is always start + used.\nWhy a struct instead of four global variables? It\u0026rsquo;s a design decision that seems unnecessary now, but avoids a full rewrite later. In Post 7, heap_t will evolve into an allocator_t that can manage multiple arenas, each with its own start and capacity. Starting with globals would make that transition painful. With a struct, the refactor will be mechanical.\nheap_init() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 heap_t heap_init(size_t initial_size) { heap_t h; h.start = sbrk(0); /* where is the break right now? */ if (h.start == (void *)-1) { h.start = NULL; h.brk = NULL; h.capacity = 0; h.used = 0; return h; } void *result = sbrk((intptr_t)initial_size); /* move the break */ if (result == (void *)-1) { h.start = NULL; h.brk = NULL; h.capacity = 0; h.used = 0; return h; } h.brk = (char *)h.start + initial_size; h.capacity = initial_size; h.used = 0; return h; } The flow is straightforward. First we ask where the break is with sbrk(0). If that fails (returns (void *)-1), the situation is catastrophic enough that we return a null heap, the caller must check it. Then we move the break initial_size bytes with sbrk(initial_size). If that fails, same treatment.\nWhy two calls instead of one? Because sbrk(n) returns the previous break, not the new one. We need to know where our region started, not just that it grew. The first call with sbrk(0) gives us that base; the second does the real work.\nError handling here is deliberately simple. A production allocator would do more sophisticated things, jemalloc, for example, has multiple fallbacks. We return NULL and trust that the caller checks. It\u0026rsquo;s a pedagogical hack, and we acknowledge it. In later posts, error handling will improve.\nheap_alloc() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void *heap_alloc(heap_t *h, size_t size) { if (h-\u0026gt;start == NULL) return NULL; if (size == 0) return NULL; /* Does it fit in current capacity? */ if (h-\u0026gt;used + size \u0026gt; h-\u0026gt;capacity) { size_t grow = size; if (grow \u0026lt; h-\u0026gt;capacity) grow = h-\u0026gt;capacity; /* double as a heuristic */ void *result = sbrk((intptr_t)grow); if (result == (void *)-1) return NULL; h-\u0026gt;capacity += grow; h-\u0026gt;brk = (char *)h-\u0026gt;start + h-\u0026gt;capacity; } void *ptr = (char *)h-\u0026gt;start + h-\u0026gt;used; h-\u0026gt;used += size; return ptr; } This is the central function, and it fits in 15 meaningful lines.\nThe first two guards are defensive: if the heap wasn\u0026rsquo;t initialized correctly or if someone requests zero bytes, we return NULL without doing anything. The zero-byte case deserves a note, malloc(0) has implementation-defined behavior per the C standard. It can return NULL or a unique pointer you must not dereference. We choose NULL for simplicity.\nThe growth block is interesting. If what\u0026rsquo;s requested doesn\u0026rsquo;t fit in the current capacity, we need more memory. The heuristic: request at least what we need, but if the current capacity is larger, double it. Doubling capacity is a classic amortized strategy, the same one std::vector uses in C++, and for the same reason: avoid O(n) calls to sbrk() for n allocations.\nThe final two lines are the bump: we compute the current address (start + used), advance used, and return the address. That\u0026rsquo;s all. No metadata, no lists, no per-block bookkeeping. That simplicity is the point, and also the limitation future posts will resolve.\nNote the complete absence of alignment. If you request 3 bytes followed by an int*, the int* will start at a misaligned address. On x86-64 this works (misaligned accesses are legal but slow), but on ARM or RISC-V it can cause a trap. Post 2 will introduce block headers that enforce 8 or 16 byte alignment.\nheap_dump() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void heap_dump(const heap_t *h) { if (h-\u0026gt;start == NULL) { printf(\u0026#34; [heap not initialized]\\n\u0026#34;); return; } double pct_used = (double)h-\u0026gt;used / (double)h-\u0026gt;capacity * 100.0; size_t free_bytes = h-\u0026gt;capacity - h-\u0026gt;used; const int bar_width = 50; int used_bar = (int)((double)h-\u0026gt;used / (double)h-\u0026gt;capacity * bar_width); int free_bar = bar_width - used_bar; printf(\u0026#34;╔══════════════════════════════════════════════════════╗\\n\u0026#34;); printf(\u0026#34;║ HEAP DUMP — Post 1 (Bump Allocator) ║\\n\u0026#34;); printf(\u0026#34;╠══════════════════════════════════════════════════════╣\\n\u0026#34;); printf(\u0026#34;║ Heap base: %p ║\\n\u0026#34;, h-\u0026gt;start); printf(\u0026#34;║ Current break: %p ║\\n\u0026#34;, h-\u0026gt;brk); printf(\u0026#34;║ Capacity: %-6zu bytes ║\\n\u0026#34;, h-\u0026gt;capacity); printf(\u0026#34;║ Used: %-6zu bytes (%5.1f%%) ║\\n\u0026#34;, h-\u0026gt;used, pct_used); printf(\u0026#34;║ Free: %-6zu bytes (%5.1f%%) ║\\n\u0026#34;, free_bytes, 100.0 - pct_used); printf(\u0026#34;╠══════════════════════════════════════════════════════╣\\n\u0026#34;); printf(\u0026#34;║ [\u0026#34;); for (int i = 0; i \u0026lt; used_bar; i++) printf(\u0026#34;=\u0026#34;); printf(\u0026#34; USED ][\u0026#34;); for (int i = 0; i \u0026lt; free_bar; i++) printf(\u0026#34;-\u0026#34;); printf(\u0026#34; FREE ]║\\n\u0026#34;); printf(\u0026#34;╚══════════════════════════════════════════════════════╝\\n\u0026#34;); } The dump function is longer than the other two combined, and that\u0026rsquo;s intentional. Visualization is pedagogy, it\u0026rsquo;s what transforms abstract numbers into spatial understanding.\nSection 3: Visualization — Understanding Your Heap Example 1: Basic Allocations 1 2 3 4 5 6 7 8 9 heap_t h = heap_init(1024); int *nums = heap_alloc(\u0026amp;h, sizeof(int) * 10); /* 40 bytes */ char *msg = heap_alloc(\u0026amp;h, 128); for (int i = 0; i \u0026lt; 10; i++) nums[i] = i * i; strcpy(msg, \u0026#34;Hello from our custom heap!\u0026#34;); heap_dump(\u0026amp;h); printf(\u0026#34;nums[5] = %d\\n\u0026#34;, nums[5]); printf(\u0026#34;msg = \\\u0026#34;%s\\\u0026#34;\\n\u0026#34;, msg); Output (addresses will vary):\n╔═════════════════════════════════════════════════════╗ ║ HEAP DUMP — Post 1 (Bump Allocator) ║ ╠═════════════════════════════════════════════════════╣ ║ Heap base: 0x555555576000 ║ ║ Current break: 0x555555576400 ║ ║ Capacity: 1024 bytes ║ ║ Used: 168 bytes ( 16.4%) ║ ║ Free: 856 bytes ( 83.6%) ║ ╠═════════════════════════════════════════════════════╣ ║ [======== USED ][--------------------- FREE ] ║ ╚═════════════════════════════════════════════════════╝ nums[5] = 25 msg = \"Hello from our custom heap!\" 168 bytes used out of 1024 available. nums and msg live side by side with no gap: nums starts at offset 0 and occupies 40 bytes, msg starts at offset 40 and occupies 128 bytes. No padding, no headers, no holes. Raw, contiguous memory.\nExample 2: Multiple Mixed Allocations 1 2 3 4 5 6 heap_t h = heap_init(1024); void *a = heap_alloc(\u0026amp;h, 16); /* offset 0 */ void *b = heap_alloc(\u0026amp;h, 32); /* offset 16 */ void *c = heap_alloc(\u0026amp;h, 400); /* offset 48 */ void *d = heap_alloc(\u0026amp;h, 50); /* offset 448 */ heap_dump(\u0026amp;h); The offsets are predictable: 0, 16, 48, 448. Each block starts exactly where the previous one ends. And here\u0026rsquo;s the problem the dump implicitly reveals: if we called free(b) (the 32 bytes of the second block), how would we recover that space? The bump allocator has no way to know. Post 2 adds block headers to solve exactly this.\nExample 3: Automatic Growth 1 2 3 4 heap_t h = heap_init(100); /* small heap */ printf(\u0026#34;Initial capacity: %zu\\n\u0026#34;, h.capacity); void *big = heap_alloc(\u0026amp;h, 500); /* doesn\u0026#39;t fit → grows */ heap_dump(\u0026amp;h); The capacity jumps from 100 to 600. This demonstrates the growth heuristic: since 500 \u0026gt; 100 (the current capacity), we request exactly 500 rather than doubling. If we had requested 80 bytes with a heap of 100, the capacity would have jumped to 200 (doubling).\nSection 4: Tests and Edge Cases What Happens if You Allocate More Than the Capacity? The heap grows automatically. heap_alloc() calls sbrk() to request more bytes. This works because sbrk() extends the heap contiguously. Is there a limit? Yes. The kernel enforces a per-process limit (configurable with ulimit -v), and eventually virtual pages run out. When sbrk() fails, it returns (void *)-1 and our heap_alloc() returns NULL.\nWhat Happens if You Allocate 0 Bytes? 1 2 void *zero = heap_alloc(\u0026amp;h, 0); // zero == NULL Our implementation returns NULL. The C standard says malloc(0) may return NULL or a unique non-dereferenceable pointer. Both are legal. We choose NULL because it\u0026rsquo;s simpler and safer.\nWhat Happens When the Program Exits Without free? Nothing bad. When a process terminates, the kernel reclaims all of its virtual memory, heap, stack, everything. It doesn\u0026rsquo;t matter whether you called free() or not. For short-lived programs (command-line utilities, scripts, tools), not freeing memory explicitly is a legitimate strategy. The problem appears in long-running programs (servers, daemons) where unfreed memory accumulates until resources are exhausted.\nContiguous Allocation: An Invariant That Matters 1 2 3 4 5 6 7 8 9 heap_t h = heap_init(1024); char *a = heap_alloc(\u0026amp;h, 100); char *b = heap_alloc(\u0026amp;h, 200); char *c = heap_alloc(\u0026amp;h, 300); printf(\u0026#34;a-base: %zu\\n\u0026#34;, (size_t)(a - (char *)h.start)); /* 0 */ printf(\u0026#34;b-base: %zu\\n\u0026#34;, (size_t)(b - (char *)h.start)); /* 100 */ printf(\u0026#34;c-base: %zu\\n\u0026#34;, (size_t)(c - (char *)h.start)); /* 300 */ printf(\u0026#34;b-a: %zu\\n\u0026#34;, (size_t)(b - a)); /* 100 */ printf(\u0026#34;c-b: %zu\\n\u0026#34;, (size_t)(c - b)); /* 200 */ The pointer differences are exactly the block sizes. This behavior will change in Post 2, where each block carries an 8–16 byte header.\nSection 5: Explicit Simplifications This allocator has serious limitations. We list them explicitly, because each one is a Post 1 feature that becomes a later-post fix:\nNo freeing. The bump pointer only advances. In Post 3, we\u0026rsquo;ll add a free list: a linked list of freed blocks that heap_alloc() consults before requesting more memory from the OS.\nNo alignment. A 3-byte alloc followed by a double* alloc produces a misaligned pointer. In Post 2, every allocation will be automatically aligned to 8 or 16 bytes.\nNo block metadata. The allocator doesn\u0026rsquo;t know where each individual block starts or ends. In Post 2, each block will carry a block header: a small struct at the start storing the size, state (free/occupied), and a pointer to the next block.\nContiguous heap. sbrk() can only extend the heap upward. In Post 7, we\u0026rsquo;ll migrate to mmap(), which can request memory regions anywhere in the address space.\nSingle-threaded. No locks, no thread-safety. In Post 7, we\u0026rsquo;ll acknowledge this problem; the full solution (per-thread arenas) is out of scope for this series, but we\u0026rsquo;ll point to how jemalloc and mimalloc solve it.\nLimitation State in Post 1 Resolved in No freeing Advances only Post 3 (free list) No alignment Raw offsets Post 2 (block headers) No metadata Anonymous blocks Post 2 (block headers) Contiguous heap sbrk() only Post 7 (mmap + arenas) Single-threaded No locks Post 7+ (acknowledged) Section 6: Where We\u0026rsquo;re Going In Post 2, we\u0026rsquo;ll add block headers. Each allocation will carry a small struct at the start with the block size and status flags. This transforms the heap from an opaque blob into a sequence of self-describing blocks we can traverse, inspect, and eventually free.\nIn Post 3, we\u0026rsquo;ll implement heap_free() and a free list. When a block is freed, it\u0026rsquo;s marked free and inserted into a linked list. Future allocations search the free list first before requesting more memory from the OS.\nIn Post 4, we\u0026rsquo;ll tackle coalescing and splitting: when two free blocks are adjacent, we merge them into one large block; when a free block is much larger than requested, we split it. These two operations are what separates a toy allocator from a functional one.\nPost 5 explores allocation policies, first-fit, best-fit, next-fit, and how each affects fragmentation and performance.\nIn Post 7 comes the big transition: we replace sbrk() with mmap(), refactor heap_t into an allocator_t with multiple arenas, and lay the groundwork for thread-safety.\nPosts 8–12 build a garbage collector progressively: reference counting, mark-and-sweep, conservative GC, and incremental GC.\nPost 13 is the capstone: copying collector, generational GC, and references to production implementations (Go GC, jemalloc, mimalloc).\nConclusion Today we built a working memory allocator in ~80 lines of C. It requests memory from the operating system with sbrk(), hands it out with a bump pointer, and visualizes it with an ASCII dump. It\u0026rsquo;s incomplete, it can\u0026rsquo;t free, it doesn\u0026rsquo;t align, it carries no metadata, but those limitations are pedagogical features, not bugs.\nWhat matters isn\u0026rsquo;t the code itself, but what it reveals: that malloc() isn\u0026rsquo;t magic. It\u0026rsquo;s a struct, a couple of syscalls, and some bookkeeping. The C runtime you use every day does exactly this, more sophisticated, more optimized, more robust, but the fundamental mechanics are the same.\nIn Post 2, we take the next step: block headers and alignment. The heap stops being an opaque blob and becomes a sequence of self-describing blocks. It\u0026rsquo;s the first step toward free().\nKey Takeaways sbrk() is the syscall underlying malloc(): it moves the program break to request contiguous memory from the OS. A bump allocator is the simplest possible strategy: a pointer that only advances. No freeing, no fragmentation, no complexity. heap_t is an architectural decision: using a struct instead of globals prepares the refactor to arenas in Post 7. The limitations are deliberate: each simplification (no freeing, no alignment, no metadata) is resolved in a specific later post. Unfreed memory is reclaimed by the OS on exit: for short-lived programs, not freeing is a legitimate strategy; for servers, it\u0026rsquo;s a memory leak. ","permalink":"https://pablogs.dev/posts/post-01-no-malloc/","summary":"\u003cp\u003e\u003cem\u003ePost 1 of 13 — Series: Memory Allocation and Garbage Collection from Scratch\u003c/em\u003e\u003c/p\u003e\n\u003cp\u003eEvery time you write \u003ccode\u003emalloc(128)\u003c/code\u003e, something apparently magical happens: the runtime hands you a pointer to 128 bytes of memory nobody else is using. You didn\u0026rsquo;t ask the operating system for permission. You didn\u0026rsquo;t specify \u003cem\u003ewhere\u003c/em\u003e those bytes should live. They simply appeared. And when you call \u003ccode\u003efree()\u003c/code\u003e, they vanish back into the void.\u003c/p\u003e\n\u003cp\u003eThe magic is a lie.\u003c/p\u003e","title":"What malloc() does not want you to know"},{"content":"I\u0026rsquo;m Pablo, a telecommunications engineer with a PhD background in electromagnetic simulation (waveguide filters, multiplexers, CST/FEST3D). My day job lives somewhere between Maxwell\u0026rsquo;s equations and S-parameters. This blog is what happens when I point that same obsessive energy at C.\nWhat this blog is about Low-level C programming: the kind where you think about memory layouts, write your own allocators, and reach for void * without apology. Topics I keep coming back to:\nData structures and allocators: how things actually sit in memory OOP and design patterns in C: vtables, tagged unions, the whole mess Functional idioms: closures, higher-order functions, iterators, all without a GC Zinc: a minimal language I\u0026rsquo;m building: interpreter, bytecode VM, and eventually a compiler, written in C The code Everything shown on this blog compiles. If it doesn\u0026rsquo;t, that\u0026rsquo;s a bug, open an issue on GitHub.\nContact GitHub: @ansuzgs\n","permalink":"https://pablogs.dev/about/","summary":"\u003cp\u003eI\u0026rsquo;m Pablo, a telecommunications engineer with a PhD background in electromagnetic\nsimulation (waveguide filters, multiplexers, CST/FEST3D). My day job lives somewhere\nbetween Maxwell\u0026rsquo;s equations and S-parameters. This blog is what happens when I point\nthat same obsessive energy at C.\u003c/p\u003e\n\u003ch2 id=\"what-this-blog-is-about\"\u003eWhat this blog is about\u003c/h2\u003e\n\u003cp\u003eLow-level C programming: the kind where you think about memory layouts, write your\nown allocators, and reach for \u003ccode\u003evoid *\u003c/code\u003e without apology. Topics I keep coming back to:\u003c/p\u003e","title":"About"},{"content":"Every C project starts with a printf(\u0026quot;Hello, World!\\n\u0026quot;);, and this blog is no exception. Welcome to pablogs.dev.\nI\u0026rsquo;ve been wanting to create a personal space for a while now to document my projects, organize my thoughts, and most importantly, share what I learn about systems programming and low-level C. Often, when coding in higher-level languages, we take the underlying magic for granted: how memory is allocated, how resources are cleaned up, or how data structures grow.\nMy goal with this blog is to demystify that magic by implementing these tools from scratch.\nWhat to expect?\nIn the coming weeks and months, I\u0026rsquo;ll be publishing article series where we\u0026rsquo;ll get our hands dirty with raw C code. Some of the things I have on the roadmap include:\nDynamic Structures: How to build your own dynamic arrays and linked lists in C without pulling your hair out. Memory Management: A comprehensive series on writing a Custom Allocator (building our own versions of malloc and free). Garbage Collectors: Yes, we are going to write a garbage collector for C, exploring how to track pointers and automatically sweep the heap. Thoughts and Tricks: Environment setups, Makefiles, and side projects. Why bilingual?\nI decided to build this blog using Hugo, configured to support both English and Spanish. I want to share these projects with the global programming community, while also making low-level engineering resources more accessible to the Spanish-speaking community. You can switch languages at any time using the toggle in the header.\nWarm up your favorite compiler, watch out for Segmentation Faults, and I\u0026rsquo;ll see you in the next post.\n","permalink":"https://pablogs.dev/posts/hello-world/","summary":"\u003cp\u003eEvery C project starts with a \u003ccode\u003eprintf(\u0026quot;Hello, World!\\n\u0026quot;);\u003c/code\u003e, and this blog is no exception. Welcome to \u003cstrong\u003epablogs.dev\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eI\u0026rsquo;ve been wanting to create a personal space for a while now to document my projects, organize my thoughts, and most importantly, share what I learn about systems programming and low-level C. Often, when coding in higher-level languages, we take the underlying magic for granted: how memory is allocated, how resources are cleaned up, or how data structures grow.\u003c/p\u003e","title":"Hello World: Pointers, Memory, and Low-Level C"},{"content":"Active projects I\u0026rsquo;m building or maintaining, mostly in C.\nZinc A minimal, statically-typed language targeting a safe subset of C semantics.\nCurrent state: interpreter + bytecode VM. Compiler in progress.\nGoals:\nNo undefined behavior by construction Explicit ownership without a runtime GC Readable output C as a compilation target (initially) Written entirely in C99, no dependencies Status: Active — posts on this blog track the development.\nRepo: github.com/ansuzgs/zinc\nbitstream.h A single-header C library for reading and writing packed bitstreams. Useful for protocol parsers, compression codecs, and anything that deals with sub-byte data.\nStatus: Stable / maintenance mode\nRepo: github.com/ansuzgs/bitstream.h\nnz A terminal-first Markdown note-taking CLI. Written in Bash.\nNot a C project, but it lives in the same workflow.\n","permalink":"https://pablogs.dev/projects/","summary":"\u003cp\u003eActive projects I\u0026rsquo;m building or maintaining, mostly in C.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"zinc\"\u003eZinc\u003c/h2\u003e\n\u003cp\u003eA minimal, statically-typed language targeting a safe subset of C semantics.\u003c/p\u003e\n\u003cp\u003eCurrent state: interpreter + bytecode VM. Compiler in progress.\u003c/p\u003e\n\u003cp\u003eGoals:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eNo undefined behavior by construction\u003c/li\u003e\n\u003cli\u003eExplicit ownership without a runtime GC\u003c/li\u003e\n\u003cli\u003eReadable output C as a compilation target (initially)\u003c/li\u003e\n\u003cli\u003eWritten entirely in C99, no dependencies\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eStatus:\u003c/strong\u003e Active — posts on this blog track the development.\u003cbr\u003e\n\u003cstrong\u003eRepo:\u003c/strong\u003e \u003ca href=\"https://github.com/ansuzgs/zinc\"\u003egithub.com/ansuzgs/zinc\u003c/a\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003c!----\u003e\n\u003c!-- ## liblog --\u003e\n\u003c!----\u003e\n\u003c!-- A structured logging library for C, designed as a vehicle for exploring every major --\u003e\n\u003c!-- C design pattern: vtable-based polymorphism, arena allocators, tagged unions, and --\u003e\n\u003c!-- compile-time configuration via macros. --\u003e\n\u003c!----\u003e\n\u003c!-- Features: --\u003e\n\u003c!-- - Multiple backends (stderr, file, ring buffer) --\u003e\n\u003c!-- - Log levels, filters, and per-module configuration --\u003e\n\u003c!-- - Zero dynamic allocation in the hot path (arena-backed) --\u003e\n\u003c!-- - Single-header option (`liblog.h`) --\u003e\n\u003c!----\u003e\n\u003c!-- **Status:** Active   --\u003e\n\u003c!-- **Repo:** [github.com/YOUR_HANDLE/liblog](https://github.com/YOUR_HANDLE/liblog) --\u003e\n\u003c!----\u003e\n\u003c!-- --- --\u003e\n\u003ch2 id=\"bitstreamh\"\u003ebitstream.h\u003c/h2\u003e\n\u003cp\u003eA single-header C library for reading and writing packed bitstreams. Useful for\nprotocol parsers, compression codecs, and anything that deals with sub-byte data.\u003c/p\u003e","title":"Projects"}]