A char is one byte — eight bits, 256 possible states. The ASCII
standard assigns a meaning to each: 65 is A, 32 is a space, 10 is
a newline. You saw how bits and voltage produce those states in
f03a/01.
What follows here is what happens when you start chaining bytes
together — and how the number of pointers you need reflects the shape
of the data.
One pointer: a string
A single char * gives you a start address. Pointer arithmetic steps
you one byte forward at a time — one character per step. The \0
terminator is the agreed end: when the byte at the current address is
zero, the string is over. One axis of movement, one pointer.
The information is a vector. It has a beginning — the address the
pointer holds — and an end — wherever \0 sits. You traverse it in
one direction. Here is "hello" laid out in memory, one byte per
cell, with the null terminator at the end:
| [0] | [1] | [2] | [3] | [4] | \0 |
|---|---|---|---|---|---|
h | e | l | l | o | \0 |
Two pointers: a table
A string is a char *. An array of strings is a block of char *
values sitting next to each other in memory. To point at the start of
that block, you need a pointer to a char * — which is char **.
char *words[3];
words[0] = "hello";
words[1] = "world";
words[2] = NULL;The subscript notation is shorthand for pointer arithmetic. These two forms are equivalent at every level:
char *words[3]; /* subscript notation — fixed-size block */
char **words; /* pointer notation — pointer to char* */words[0] = "hello"; /* subscript notation */
*(words + 0) = "hello"; /* pointer notation */The outer pointer selects the row. The inner pointer walks the
characters in that row. Two axes, two pointers. Think of a spreadsheet
where each row is a string. The same two words in binary — each byte
is eight bits, the final 00000000 is \0:
| [0] | [1] | [2] | [3] | [4] | [5] | |
|---|---|---|---|---|---|---|
words[0] | 01101000 | 01100101 | 01101100 | 01101100 | 01101111 | 00000000 |
words[1] | 01110111 | 01101111 | 01110010 | 01101100 | 01100100 | 00000000 |
In decimal — the integer each byte encodes:
| [0] | [1] | [2] | [3] | [4] | [5] | |
|---|---|---|---|---|---|---|
words[0] | 104 | 101 | 108 | 108 | 111 | 0 |
words[1] | 119 | 111 | 114 | 108 | 100 | 0 |
In ASCII — the character each decimal value represents:
| [0] | [1] | [2] | [3] | [4] | [5] | |
|---|---|---|---|---|---|---|
words[0] | h | e | l | l | o | \0 |
words[1] | w | o | r | l | d | \0 |
argv in main is exactly this. argc tells you how many rows there
are; \0 terminates each string on the inner axis.
Three pointers: a cube
Add another layer. If a table is a block of strings, a cube is a block
of tables. char *** is a pointer to a char ** — the start of an
array of arrays of strings. Three axes, three pointers. The geometry
still holds: you can picture height, width, and depth.
The limit
Beyond the cube, physical intuition fails. There is no fourth direction you can point your finger in.
The word "dimension" encodes this — it describes a direction in which things can be separated. In Greek, διάσταση [thee-AH-stah-see] literally means "standing apart." Beyond three, the vocabulary runs out.
Beyond char ***, the pointer axes no longer map to anything you can
picture. You are managing bookkeeping labels, not directions in space.
Data can genuinely be higher-dimensional. A video frame has three axes: x, y, and colour channel. Add a frame index and you have four. Add a batch index — as machine learning models process groups of images — and you have five.
C programmers do not reach for float ***** to represent that. They
use a single flat array and a set of integers describing the shape:
width, height, channels, frame count, batch size. Access becomes an
index calculation instead of a chain of dereferences.
The pointer nesting communicates structure to the compiler. When the structure has more axes than geometry allows, the integers carry it instead. The geometry is replaced by arithmetic.
Where the curriculum goes
The immediate next step is tci_printf. It needs tci_strlen to
measure each string argument, tci_memchr to scan the format string
for the next % specifier, and the digit-extraction loop — the same
logic tci_atoi uses in reverse — to convert %d and %x to
characters. Every piece is already in libtci. The next chapter
assembles them into a function that puts text on a screen.
Every arcade cabinet that ever displayed "HIGH SCORE" solved this problem by hand. The Pac-Man score counter, the NES tile display, the PlayStation save screen — all of it is printf, written on platforms that had no standard library to call. You now have what those programmers had.
You will reach char *** further along. A pipeline like
ls -la | grep foo breaks down naturally into three layers:
char *word— a single token:"ls"char **argv— one command's arguments:["ls", "-la", NULL]char ***cmds— the full pipeline:[["ls", "-la", NULL], ["grep", "foo", NULL], NULL]
Three layers, each null-terminated, each mapping to one axis. The cube
arrives by necessity, not by design. libtci stays at char **. The third dimension appears when the
problem genuinely needs it.
A note on building this
A library is invisible until something uses it. Right now libtci.a
is an archive sitting in your working directory. It does nothing you
can see.
It is tempting to read through c01 without writing a single function. Reading and building are different skills, and you can mistake one for the other for a surprisingly long time.
The magic is real — it is the result. But you only reach it by writing. Write every function. The tester exists for this reason.
Write the comments while the function is fresh. A comment that explains
why tci_memmove copies backwards when dst > src costs thirty seconds to
write and saves thirty minutes to rediscover. The compiler discards every
byte of it — the binary is identical — so the program pays nothing. But the
next time you open that file cold, six months from now, the reason is already
there.
The tester tells you whether your function is correct. It does not tell you whether you own it. The bar for ownership: write every function without any external help — no AI, no autocomplete, no reference tab open. Memorisation counts. Looking it up does not.
The clock is a second check. Open a blank file and write a function from whatever you carry in your head:
start=$(date +%s); vim -u NONE tci_strlen.c; elapsed=$(($(date +%s) - start)); printf "%s %ds\n" "$(date +%Y-%m-%d)" "$elapsed" | tee -a tci_strlen.scoreThe score is appended to tci_strlen.score alongside the date.
Come back a week later and do it again. Read the history for any function:
cat tci_strlen.score2026-05-12 184s
2026-05-19 76s
2026-05-26 48sThe tester is the correctness check; your times are the ownership check. When you can write the whole library in one sitting, from what you carry in your head, without asking anyone or anything — that is what it means to know it.
Memorising a function and writing it once is better than reading it ten times. Writing it ten times is better than writing it once. Repetition is the mechanism, not a redundancy. Typing speed increases, muscle memory forms, and what currently requires thought becomes automatic. Hours at the keyboard compound.
There is a view that says: primitives do not matter; AI writes those; what counts is speed and architecture. It is wrong in the direction that matters.
You cannot architect what you have never built. Speed in a codebase —
knowing where time is spent, knowing what to avoid, knowing when a
loop is tight enough — comes from hours of writing the loops. The
programmer who has written tci_memmove knows why it exists and when
it matters. The one who has only called it does not.