Phase 1 Projects¶
Two projects. Project 1 (dynamic string / strsplit) locks in your grasp of strings, memory, and the safe <string.h> patterns. Project 2 (bytecode VM) exercises structs, unions, switch dispatch, and gives you a small artifact you’ll keep referring back to for years. Both should be ASan/UBSan-clean; both go under Git with a proper README.
Do Project 1 in weeks 1-2, Project 2 in weeks 3-4. If Project 2 slips into M3, that’s acceptable — don’t compromise on quality to hit the deadline.
Project 1: dstr — Dynamic String + strsplit¶
Goal: implement a length-prefixed heap-allocated dynamic string type with the essential operations, plus a strsplit that returns an owned array of dynamic strings. Everything ASan-clean and no use of unsafe <string.h> functions.
API (include/dstr.h)¶
#ifndef DSTR_H
#define DSTR_H
#include <stddef.h>
#include <stdbool.h>
typedef struct dstr dstr; // opaque; you decide the layout in dstr.c
dstr *dstr_new(void); // empty string
dstr *dstr_from_cstr(const char *cstr); // copy of C string
void dstr_free(dstr *s);
size_t dstr_len(const dstr *s);
const char *dstr_cstr(const dstr *s); // null-terminated view
bool dstr_append_cstr(dstr *s, const char *cstr);
bool dstr_append_char(dstr *s, char c);
bool dstr_appendf(dstr *s, const char *fmt, ...); // snprintf-based
bool dstr_eq(const dstr *a, const dstr *b);
int dstr_cmp(const dstr *a, const dstr *b);
/* Splits input by `delim` character. Returns array of dstr* of length *n_out.
* Caller frees the array via dstr_split_free(). */
dstr **dstr_split(const char *input, char delim, size_t *n_out);
void dstr_split_free(dstr **arr, size_t n);
#endif
Implementation Constraints¶
No
strcpy,strcat,sprintf,gets,strtok. Usesnprintf,memcpy, and your own bounded copies.Handle allocation failure. Every function returning a pointer or
boolmust correctly propagate OOM.dstr_append*returnsfalseon failure; the string must remain in a valid state (either unchanged, or with the fields consistent sodstr_freeworks).Grow the buffer geometrically (typically 2×) on append, capped by an implementation limit. Track
capseparately fromlen.Flexible array member is encouraged for the struct layout — that’s exactly the FAM pattern from 03_structs_unions_bitfields.md. Alternative: struct with
char *data— also fine, more allocations.The struct must be opaque — the type
dstrshould be forward-declared in the header and defined only indstr.c. Users touch it only through the API.
Tests (tests/test_dstr.c)¶
Minimum coverage:
dstr_new,dstr_free— no leak.dstr_from_cstr("hello")— length 5, cstr matches.Append 10000 characters one at a time — length correct.
dstr_appendf("%d-%s", 42, "x")— produces"42-x".dstr_split("a,,b,c", ',', &n)— returns 4 strings including one empty. Free correctly.dstr_split("", ',', &n)— what should this return? Document and test.Trigger OOM by wrapping
malloc(a#define malloc my_mallocin the test file that fails on a counter) — verify no leaks and no crashes.
Acceptance Criteria¶
make clean && makewith the strict flag set +-fsanitize=address,undefined→ zero warnings../build/test_dstrprints test results, exits 0, ASan/UBSan clean.README.mddocuments the API, the ownership rules, and one “design decision” section explaining your struct layout choice.compile_commands.jsonpresent (viabear -- makeor CMake).dstrstruct is opaque; nothing outsidedstr.creaches into its fields.
Time Estimate¶
10-15 hours across ~2 weeks. Move fast on the API, slower on the tests and edge cases.
Project 2: tinyvm — A Bytecode VM in <phone_number_or_numberic_id_or_random_id_15> LOC¶
Goal: build a tiny stack-based virtual machine that executes bytecode you hand-assemble. This exercises structs, tagged unions, switch dispatch, and file I/O in a coherent artifact. It also plants a seed for what you’ll do in Phase 8-9 (SIMD kernels and JITs).
The ISA (Instruction Set)¶
Stack-based, integer only. About 16 opcodes:
Opcode |
Args |
Stack effect |
Description |
|---|---|---|---|
|
— |
— |
Stop execution |
|
int32 imm |
— → i |
Push immediate |
|
— |
v → — |
Discard top |
|
— |
v → v v |
Duplicate top |
|
— |
a b → b a |
Swap top two |
|
— |
a b → (a+b) |
Signed add |
|
— |
a b → (a-b) |
|
|
— |
a b → (a*b) |
|
|
— |
a b → (a/b) |
Trap on zero |
|
— |
a → -a |
|
|
— |
a b → (a==b) |
Push 0 or 1 |
|
— |
a b → (a<b) |
|
|
int32 offset |
— |
Unconditional jump |
|
int32 offset |
v → — |
Jump if top is 0 |
|
— |
v → — |
Print top as int |
|
— |
— → v |
Read int from stdin |
Structure¶
tinyvm/
├── Makefile
├── include/
│ ├── vm.h
│ └── opcodes.h # X-macro of opcodes
├── src/
│ ├── vm.c # execution loop
│ ├── loader.c # load a bytecode file
│ └── main.c
├── programs/
│ ├── fact.bc # factorial
│ ├── fib.bc # fibonacci
│ └── echo.bc # read int, print it
├── tools/
│ └── assemble.py # tiny assembler: text .asm → binary .bc
└── README.md
Core Data Types¶
Use the X-macro pattern from 04_preprocessor_and_macros.md:
// opcodes.h
#define OPCODES \
X(HALT, 0) \
X(PUSH_I, 4) \
X(POP, 0) \
X(DUP, 0) \
/* ... */
typedef enum {
#define X(name, argsz) OP_##name,
OPCODES
#undef X
OP__COUNT
} opcode;
And for the VM:
typedef struct {
const uint8_t *code;
size_t code_len;
int32_t *stack;
size_t sp; // stack pointer (top-of-stack index)
size_t stack_cap;
size_t pc; // program counter (byte offset)
} vm;
typedef enum {
VM_OK, VM_HALT, VM_ERR_STACK_OVERFLOW, VM_ERR_STACK_UNDERFLOW,
VM_ERR_DIV_ZERO, VM_ERR_BAD_OPCODE, VM_ERR_BAD_PC
} vm_result;
The Execution Loop (vm.c)¶
A switch on opcode inside a for loop. About 200-300 lines. The core:
for (;;) {
if (vm->pc >= vm->code_len) return VM_ERR_BAD_PC;
uint8_t op = vm->code[vm->pc++];
switch (op) {
case OP_HALT: return VM_HALT;
case OP_PUSH_I: {
if (vm->pc + 4 > vm->code_len) return VM_ERR_BAD_PC;
int32_t v;
memcpy(&v, vm->code + vm->pc, 4); // avoid aliasing / alignment UB
vm->pc += 4;
if (vm->sp >= vm->stack_cap) return VM_ERR_STACK_OVERFLOW;
vm->stack[vm->sp++] = v;
break;
}
/* ... */
default: return VM_ERR_BAD_OPCODE;
}
}
The Assembler (tools/assemble.py)¶
A Python script (~50 lines) that reads .asm:
; factorial of 5
PUSH_I 5
PUSH_I 1 ; result = 1
loop:
SWAP
DUP
JZ end
SWAP
DUP
/* ... */
end:
PRINT
HALT
and writes a binary .bc. Python is allowed here because writing an assembler in C would double the project size for no learning gain.
Acceptance Criteria¶
makebuildstinyvmwith strict flags + ASan../tinyvm programs/fact.bcprints120(factorial of 5) and exits 0../tinyvm programs/fib.bcprints the 10th Fibonacci number../tinyvm programs/echo.bcreads an int from stdin and prints it.Feeding a malformed
.bcfile (truncated, bad opcode, out-of-range jump) produces a clean error message and non-zero exit; no crash, no ASan diagnostics.Total C code excluding tests and comments is under 700 LOC (measure with
cloc src/).README.mddocuments the ISA, the file format, how to assemble and run, and one “what would I do differently” reflection paragraph.
Stretch¶
Add local variables (
LOAD_LOCAL k/STORE_LOCAL kwith a frame pointer).Add function call/return (
CALL/RET— requires a call stack).Write the VM tagged-union style with
valuesupporting int + float + string.Benchmark: how many instructions per second? Compare
-O0vs-O2.
Time Estimate¶
15-20 hours. This is meaty. It’s also, per the community consensus, one of the highest-signal projects in intermediate C education — you touch memory, structs, dispatch, I/O, and error handling in one artifact.
What Most People Get Wrong About This Project¶
They try to be clever with computed-goto dispatch or JIT ideas before the basic switch-dispatch VM even works. Ship the boring version first, verify it runs three programs, then consider optimizations. The whole point is depth on the basics.
The second failure mode is ignoring the malformed-input case. Robust bytecode loaders check every jump target, every opcode, every immediate. Real VMs (LuaJIT, JVM, Wasm) have entire bytecode-verifier passes for this. You don’t need a verifier, but you do need to validate as you execute. That’s the muscle you’re building.
Definition of Done for Phase 1¶
dstrproject: API works, all tests pass, ASan/UBSan clean, opaque struct discipline held.tinyvmproject: three sample programs run correctly, malformed inputs handled cleanly, under 700 LOC insrc/.You can explain, in a five-minute whiteboard session, integer promotion, the padding of a mixed-size struct, the difference between UB and unspecified behavior, and why
strncpyis not a safestrcpy.
Close Phase 1. Move to Phase 2 (pointers, memory, data structures).
Return to README.md · Next: ../03_pointers_and_memory/README.md (Phase 2, to be built by the M3-M4 agent)