C / CAPSTONE PROJECTS
Where to go next as a C developer
Plan a next step in C: pin a standard, encode assumptions as static_assert, keep a sanitizer build, and read real codebases to pick a specialization.
What you will learn
- Pin a standard with -std=c11 and verify it by printing __STDC_VERSION__
- Encode platform assumptions as static_assert so bad targets fail to build
- Keep two builds: -Wall -Wextra -Werror, plus -fsanitize=address,undefined for tests
- Read declarators like int (*const ops[])(int, int) to follow real C codebases
Understanding Where to go next as a C developer
Nothing inside a running C program checks whether you stayed inside the language. The compiler assumes you never write undefined behavior and optimizes on that assumption, which is why a shift by 32, a signed overflow, or a one-past-the-end write can look correct at -O0 and misbehave at -O2 or under another compiler. The practical answer is to stop relying on observed behavior and start collecting evidence: name the standard you target with -std=c11 or -std=c17 instead of accepting the compiler's gnu dialect default, keep -Wall -Wextra -Wpedantic -Werror on, and build a second binary with -fsanitize=address,undefined for your tests. Each tool covers a different failure class (warnings for suspicious constructs, ASan for bounds and lifetime bugs, UBSan for overflow and misalignment, valgrind for uninitialized reads), so none of them substitutes for another.
The second thing that changes is what you read. A large C program is a small set of repeated conventions: who owns an allocation and who frees it, how failures travel back (an int return plus errno, or a goto cleanup ladder), an opaque struct pointer plus a table of function pointers where another language would use a class, and macros that stamp out repetitive code. Once you can name those conventions in a project, tens of thousands of lines become skimmable, so read one file end to end from something small and disciplined, such as musl's string routines, the Lua interpreter, or xv6, rather than collecting more tutorials. For questions no codebase can answer, like whether a particular pointer cast or aliasing trick is defined, the standard itself is the reference, and the free committee drafts are what C programmers usually cite.
Beyond that, C is a specialization tool rather than a destination, and the useful move is to pick one direction and redo work you already understand under its constraints. POSIX systems programming (fork, exec, pipes, sockets, mmap, pthreads) is the natural continuation of the shell project; freestanding and embedded work removes malloc, hosted headers, and the OS while adding volatile hardware registers, linker scripts, and cross-compilers; performance work replaces guessing with perf, cache-aware layout, restrict, and SIMD intrinsics; FFI work makes C the fast layer under Python, Rust, or WebAssembly. Whichever you pick, tracking the standard stays part of the job: C23 brings nullptr, typeof, constexpr, bool and static_assert as real keywords, and #embed, and newer compilers already default close to it.
/* built with: cc -std=c11 -Wall -Wextra -Wpedantic -Werror env.c
output below is from x86-64 Linux with glibc */
<limits.h>
<stdio.h>
int main(void)
{
printf("__STDC_VERSION__ = %ld -> C%02ld\n",
__STDC_VERSION__, __STDC_VERSION__ / 100 % 100);
puts("compiled as C89: __STDC_VERSION__ does not exist here");
printf("hosted implementation: %d\n", __STDC_HOSTED__);
printf("CHAR_BIT = %d, plain char is %s\n",
CHAR_BIT, CHAR_MIN < 0 ? "signed" : "unsigned");
printf("sizeof(int) = %zu, sizeof(long) = %zu, sizeof(void *) = %zu\n",
sizeof(int), sizeof(long), sizeof(void *));
__STDC_NO_VLA__
puts("variable-length arrays: not provided");
puts("variable-length arrays: provided");
return 0;
}
The step after learning C's syntax is learning to produce evidence that your C is correct: a chosen standard, compile-time assertions, sanitizer builds, and code you can read.
Worked examples
Turn assumptions into build failures
Uses static_assert to reject hostile targets at compile time, then inspects an object's bytes the only aliasing-safe way.
/* cc -std=c11 -Wall -Wextra -Werror assume.c */
<assert.h>
<limits.h>
<stdint.h>
<stdio.h>
int main(void)
{
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
static_assert(sizeof(uint32_t) == 4, "this code needs an exact 32-bit type");
uint32_t word = 0x0A0B0C0Du;
const unsigned char *bytes = (const unsigned char *)&word;
printf("0x%08lX is stored as", (unsigned long)word);
for (size_t i = 0; i < sizeof word; i++)
printf(" %02X", bytes[i]);
putchar('\n');
printf("low byte at the low address: %s\n", bytes[0] == 0x0D ? "yes" : "no");
return 0;
}
Example explained
Line 1static_assert is evaluated by the compiler, so a target without 8-bit bytes or without a real uint32_t fails the build with your message instead of shipping wrong output.
Line 2Casting the address to const unsigned char * is legal aliasing: any object may be read through unsigned char, while reading it through a float * or int * pointer would not be.
Line 3bytes[0] == 0x0D means the least significant byte sits at the lowest address, so this is little-endian; the same source prints 0A 0B 0C 0D on a big-endian machine.
Line 4(unsigned long)word with %08lX avoids assuming uint32_t and unsigned int are the same type, which is exactly the class of assumption %X would hide.
Read the dispatch pattern real C uses
Shows the function-pointer table that stands in for classes and virtual methods across large C projects.
/* cc -std=c11 -Wall -Wextra -Werror dispatch.c */
<stdio.h>
static int add(int a, int b) { return a + b; }
static int sub(int a, int b) { return a - b; }
static int mul(int a, int b) { return a * b; }
/* ops: array of constant pointers to functions (int, int) -> int */
static int (*const ops[])(int, int) = { add, sub, mul };
static const char *const names[] = { "add", "sub", "mul" };
int main(void)
{
for (size_t i = 0; i < sizeof ops / sizeof ops[0]; i++)
printf("%s(9, 4) = %d\n", names[i], ops[i](9, 4));
return 0;
}
Example explained
Line 1Read int (*const ops[])(int, int) outward from the name: ops is an array, of const pointers, to functions taking two ints and returning int.
Line 2ops[i](9, 4) calls through the pointer with no dereference operator; (*ops[i])(9, 4) is the same call, which is why both spellings appear in real code.
Line 3Marking the table const lets the linker place it in read-only memory, so a stray write faults instead of silently rewriting which function gets called.
Line 4sizeof ops / sizeof ops[0] ties the loop bound to the table, so adding a fourth operation cannot leave a stale count behind.
Important notes
__STDC_VERSION__ does not exist in C89 mode, so guard it with #if defined(__STDC_VERSION__) before comparing it against a value.
The ISO C standard is a paid document, but the final committee drafts (N1570 for C11, the N3220-era drafts for C23) are free and differ only editorially, which is why they are the versions people cite.
Common mistakes
Leaving the standard to the compiler default: GCC picks a GNU dialect (gnu17, gnu23 in newer releases), so extensions like nested functions or void * arithmetic compile silently and the project breaks the first time someone builds it with -std=c11 or a different compiler.
Treating a clean sanitizer run as proof of correctness. ASan and UBSan check instructions that execute, so the out-of-bounds write in an untested error path ships anyway and reappears as heap corruption on someone else's machine.
Running valgrind on an AddressSanitizer binary. Both tools replace the allocator, so you get a startup crash or nonsense report instead of a real finding; keep them in separate builds.
Try it yourself
Change, predict, then run
In a browser editor, print __STDC_VERSION__ decoded with C%02ld, then add three static_assert lines claiming CHAR_BIT == 8, sizeof(int) == 4, and sizeof(void *) == sizeof(size_t). Change one claim to something false and confirm the compiler stops the build with your message instead of the program running.
Open the C workspaceCheck your understanding
Your test suite passes with no reports under a -fsanitize=address,undefined build. What have you actually established about the program?
- It contains no undefined behavior, because UBSan proves the absence of UB across the whole translation unit.
- It is portable to any conforming compiler, since the sanitizers implement the ISO C rules.
- Only that the code paths your tests executed were clean; the sanitizers are runtime checks and say nothing about code that never ran.
- It cannot leak memory, because AddressSanitizer accounts for every allocation the program could make.
Show answer
Both sanitizers work by injecting checks into the generated code, so a fault is reported only when the offending instruction executes; an overflow in an error path your tests never reach stays invisible. The first option is tempting because sanitizer reports look like compiler output, but nothing there is decided at compile time, and even static analyzers cannot prove UB-freedom in general.