Part II — Intermediate: memory, modules, and tooling
Part I's programs lived comfortably inside one function's worth of stack memory. This Part is where C stops being safe by default: dynamic allocation and the ownership rules that go with it, splitting a program across multiple files, file I/O, and the debugging tools (sanitizers, a minimal test harness) you need once a bug is no longer obvious from reading the code. Ends with a vector library, a CSV parser, and a JSON parser — real tools, not toy examples.
10. Dynamic memory: the malloc family, ownership, and the bugs it enables
10.1 The four functions and what they actually do
#include <stdlib.h>
void *malloc(size_t size); // allocate size bytes, uninitialized
void *calloc(size_t n, size_t size); // allocate n*size bytes, zeroed, overflow-checked
void *realloc(void *ptr, size_t size); // resize, may move, contents preserved up to min(old,new)
void free(void *ptr); // return memory to the allocator
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *arr = malloc(10 * sizeof(int));
if (!arr) { perror("malloc"); return 1; } // ALWAYS check — allocation can fail
for (int i = 0; i < 10; i++) arr[i] = i * i;
int *bigger = realloc(arr, 20 * sizeof(int));
if (!bigger) { // realloc failing does NOT free the original
free(arr); // so you still must free the original on failure
return 1;
}
arr = bigger; // only reassign after confirming success
for (int i = 10; i < 20; i++) arr[i] = i * i;
for (int i = 0; i < 20; i++) printf("%d ", arr[i]);
printf("\n");
free(arr);
return 0;
}
Why calloc(n, size) over malloc(n * size): calloc checks for
multiplication overflow internally before allocating; n * size computed
by hand can silently overflow and allocate a tiny buffer that you then
write far past the end of. This exact bug class (integer overflow feeding
an allocation size) is CWE-190 and shows up in real CVEs regularly.
10.2 The four classic memory bugs, each demonstrated and fixed
// 1. Memory leak — allocated, never freed
void leak(void) {
char *buf = malloc(100);
(void)buf;
} // buf goes out of scope; the 100 bytes are now unreachable and unfreeable
// 2. Use-after-free — reading/writing memory you already returned
void use_after_free(void) {
char *buf = malloc(100);
free(buf);
buf[0] = 'x'; // UB — the allocator may have already reused this memory
}
// 3. Double free — freeing the same pointer twice corrupts the allocator's internal metadata
void double_free(void) {
char *buf = malloc(100);
free(buf);
free(buf); // UB — often crashes, sometimes exploitable
}
// 4. Buffer overflow — writing past what was allocated
void overflow(void) {
char *buf = malloc(10);
buf[10] = 'x'; // UB — index 10 is one past the 10 valid bytes (0..9)
free(buf);
}
Section 14 shows how AddressSanitizer catches every one of these instantly, with a stack trace pointing at the exact allocation and the exact bad access — this is why no serious C project ships without running its test suite under ASan.
10.3 Ownership: the discipline that replaces a garbage collector
C has no automatic memory management, so every project needs an explicit ownership convention — a rule for who is responsible for freeing what. The three that cover almost all real code:
- Caller allocates, caller frees (most common): a function fills a buffer the caller provided.
- Callee allocates, caller frees (
strdup,malloc-returning functions): document this in the function's name or a comment —create_,_new,_dupprefixes/suffixes are a strong convention. - Ownership transfer: a function takes a pointer and becomes responsible for freeing it later (e.g., adding a node to a linked list that owns its nodes).
// Convention: functions named *_new return heap memory the caller must
// free with the matching *_free. This pairing is the backbone of every
// C library's public API — see Part VI's SQLite/Redis/curl walkthroughs.
typedef struct { char *data; size_t len; } Buffer;
Buffer *buffer_new(size_t capacity) {
Buffer *b = malloc(sizeof(Buffer));
if (!b) return NULL;
b->data = malloc(capacity);
if (!b->data) { free(b); return NULL; }
b->len = 0;
return b;
}
void buffer_free(Buffer *b) {
if (!b) return; // free(NULL) is always safe — mirror that here
free(b->data);
free(b);
}
Real-world example. This exact _new/_free pairing, with a NULL
check at the top of _free so foo_free(NULL) is always safe, is the
pattern you'll see in Section 33's SQLite walkthrough (sqlite3_close),
Section 34's Redis walkthrough (sdsfree), and Section 35's curl
walkthrough (curl_easy_cleanup).