C From the Metal
Types, pointers, arrays, structs, and functions — as memory you can see
C has a reputation for being hard and scary. It isn't — it's honest. C is a thin, human-readable skin over the machine, and everything confusing about it becomes obvious the moment you stop treating it as syntax and start seeing the memory underneath. This subject teaches you to do exactly that. By the end you won't just 'know C syntax' — you'll look at a line of C and see the bytes it moves, and you'll prove it by building a growable array from nothing, the same data structure behind Python's lists and C++'s vector. Read it start to finish; each part is built from the one before.
- › Read a line of C and picture the exact memory it touches
- › Use pointers, arrays, and structs correctly — and explain why the tricky cases behave as they do
- › Reason about the heap, and manage memory without leaks
- › Build a dynamic array in C from scratch, and understand every line of it
1 · A type is just a size
People talk about C types like they're a deep mystery. They're not. A C type is two facts: how many bytes the value takes, and how those bytes are read (as an unsigned number, a signed number, or a float). That's the whole thing. Everything else follows.
| Type | Size (bytes) | Read as |
|---|---|---|
| char | 1 | one byte (a small integer, or a character) |
| int | 4 (usually) | signed whole number |
| long | 8 (Linux/mac) | signed whole number |
| a pointer (T*) | 8 (on 64-bit) | an address |
| double | 8 | a floating-point number |
"How many bytes" is why a char holds 0–255 and an int holds billions — more bytes, more room.
"How it's read" is why the same byte pattern can be 255 or −1 depending on whether the type is
unsigned or signed. When you later hear "this variable is 4 bytes," that means it occupies 4 of the
memory cells the machine addresses. A type is a *label on some bytes that says how to treat them* —
nothing more.
sizeof(int) gives the real byte count here. And when the exact width
matters (it will), use the precise names from <stdint.h>: uint8_t is unmistakably one unsigned
byte, everywhere.2 · A pointer is an address
Here's the thing everyone fears, made simple. Memory is one giant row of numbered boxes (bytes), each
with an address. A pointer is just a variable that holds one of those addresses. It's 8 bytes
holding "the location of something else." Its type (int*, char*) records *what kind of thing*
lives at that address — how many bytes to read there.
Two operators, and both are ordinary once you see them as memory:
- `&x` ("address of x") gives you the address where
xlives — the number of its box. *p** ("what p points at") means "go to the address inpand use the value there." Reading-
*preads those bytes; writing*p = 5writes into them.
1int x = 42;2int *p = &x; // p holds the address of x3int y = *p; // y = the value at that address = 424*p = 99; // write 99 into x's box, through the pointer5// now x == 99, because p pointed at x name address value
x 0x7ffe1c -> 42 <-- *p reads/writes HERE
p 0x7ffe10 -> 0x7ffe1c (p's value is x's address)
&x = 0x7ffe1c (the address of x)
*p = the value at 0x7ffe1c = 42
*p = 99 changes x to 99, because they are the same box*p = ...** changes *what's in the box it points to*. That single distinction is
most of what trips people up. This is also how a function changes its caller's variable: pass &x, and
the function writes through the pointer into your x.3 · Arrays are pointer arithmetic
An array is a block of equal-sized values sitting back-to-back in memory. The array's name, used in an expression, is really *the address of its first element*. And here is the definition that explains everything about arrays:
a[i] means exactly *(a + i).** Take the array's starting address, add i, and read what's
there. The subscript brackets are just sugar for pointer addition and a dereference. (This is why
a[i] even equals i[a] — addition doesn't care about order.)The one subtlety that makes it work: adding to a pointer counts in elements, not bytes. On a
4-byte int array, a + 1 moves 4 bytes to the next int; a + i moves i × 4 bytes. The
compiler multiplies by the element size for you — that's why the pointer's type matters.
a[0] a[1] a[2] a[3]
┌──────┬──────┬──────┬──────┐
│ 10 │ 20 │ 30 │ 40 │ each box = 4 bytes
└──────┴──────┴──────┴──────┘
0x1000 0x1004 0x1008 0x100c
a + 2 = 0x1000 + 2*4 = 0x1008 (NOT 0x1002)
*(a+2) = a[2] = 30a[7] on a 4-element array cheerfully computes *(a+7) and reads
memory past the end of your array — no error, just wrong (or dangerous). This is fast and unforgiving,
and it's the root of a whole class of bugs. When you pass an array to a function it decays to a bare
pointer with no length attached, which is why C functions always take a size argument.4 · Structs: fields in a row, with gaps
A struct groups several values into one object. In memory it's just its fields laid out in order,
each at some offset from the start. To read a field, the compiler adds that field's offset to the
struct's address. Simple — with one twist: padding.
Hardware reads a value fastest when it sits at an address that's a multiple of its size (a 4-byte int at an offset divisible by 4). So the compiler quietly inserts unused padding bytes to line each field up — which is why a struct's size can be *bigger* than the sum of its fields.
1struct S {2 char a; // 1 byte, at offset 03 int b; // 4 bytes, wants an offset divisible by 44 char c; // 1 byte5}; // sizeof == 12, not 6 offset: 0 1 2 3 4 5 6 7 8 9 10 11
┌────┬─────────┬────────────┬────┬────────┐
│ a │ PAD PAD │ b │ c │ PAD... │
└────┴─────────┴────────────┴────┴────────┘
a@0. b needs a multiple of 4, so 3 pad bytes -> b@4.
c@8. Struct padded up to a multiple of 4 -> size 12.sizeof(struct S) for the total,
offsetof(struct S, b) for a field's offset. Derive, then confirm.struct T { char a; double d; short s; }; — write each field's offset and the struct's total size, using the two rules (align each field; pad the whole struct to its largest alignment). Show the padding gaps.5 · Function pointers: code is data too
One more idea, and it's a mind-opener: functions live in memory and have addresses, exactly like data. So you can store a function's address in a variable and call through it later. That variable is a *function pointer* — a pointer whose pointee is code.
1int add(int a, int b) { return a + b; }2int sub(int a, int b) { return a - b; }3 4int (*op)(int, int); // op: a pointer to a function taking (int,int) returning int5op = add; // a function name decays to its address (like an array name)6int r = op(3, 4); // call through the pointer -> add(3,4) -> 77op = sub;8r = op(3, 4); // now -> sub(3,4) -> -1Read int (*op)(int,int) from the inside out: op is a pointer, to a function taking two ints,
returning an int. Assigning op = add stores add's *address* (a function name decays to its address,
just like an array name decays to its first element). Then op(3,4) calls whatever address op
holds. This is how dispatch tables, callbacks, and plug-in behavior work — you turn "which code runs"
into data you choose at runtime.
The Project
Build a growable array of ints in C — a Vec — from nothing. It's the data structure behind Python's list and C++'s vector: it starts small and doubles its storage as you push more in. You'll use pointers, the heap, arrays, and structs together, and end with something real you understand completely.
- 01
A
Vecstruct holding: anint *data(a heap block), asize_t len(elements in use), and asize_t cap(elements the block can hold). - 02
void vec_init(Vec *v)— start empty (data = NULL, len = 0, cap = 0). - 03
void vec_push(Vec *v, int x)— append x; whenlen == cap, grow by DOUBLING capacity (0→4→8→16…) withrealloc, then store. - 04
int vec_get(Vec *v, size_t i)— return element i. - 05
void vec_free(Vec *v)— free the block and reset the struct. - 06
A
main()that pushes 20+ ints and printscapon each push, so the doubling (4, 8, 16, 32) is visible; then reads a few back and frees.
- •
Only the C standard library (
<stdlib.h>for malloc/realloc/free,<stdio.h>for printing). No external libraries. - •
Every function that changes the vector takes
Vec *v(a pointer) so it modifies the caller's struct — not a copy. - •
You MUST reassign
datafromrealloc's return value (it may move the block); never drop it. - •
Compiles clean with
-Wall -Wextra, and reports zero leaks and zero errors under valgrind.
vec.cEverything this needs was taught above: a struct (section 4) holds the state; a pointer to the heap block (section 2) is the storage; indexing it is array arithmetic (section 3); and the sizes are just types (section 1). The one new fact — that `realloc` may move your block and returns its new address — is stated right in the constraints. Nothing here is a leap. If you get stuck, reread the section the stuck part comes from.