skylib index
A first lesson · C · verified on gcc 13.3 · x86-64 Linux

The Box and the Address

C asks you to hold two things apart that every other language lets you blur: what a value is and where it lives. Almost everything difficult about C, and everything powerful about it, comes from that one distinction. Every program on this page was compiled and run — the addresses you'll see are real ones from this machine.

ValueThe bytes actually sitting in memory. What you normally think of as "the data."
AddressWhere those bytes live. A number, like a house number on a street.
TypeHow many bytes, and how to read them. Exists only while compiling — never at runtime.
PointerA value that is an address. The idea the whole language turns on.
UndefinedWhere C stops protecting you and the machine does something arbitrary.
01

What C is

1972 · Dennis Ritchie · Bell Labs

C was not designed as a general-purpose language that later turned out to be good for operating systems. It was designed to write one specific operating system, and turned out to be good at everything else.

Ritchie and Ken Thompson had written Unix in assembly language, which meant it only ran on the machine it was written for and had to be rewritten by hand for every new one. They wanted something portable enough to move between machines but close enough to the hardware that it could still be an operating system. C is the result, and that origin explains nearly every design decision in it — including the uncomfortable ones.

A note on where you are. You have already read a great deal of C without writing any. The interpreter that runs Python is a C program; its evaluation loop, its memory allocator and its object headers are all C. When you looked at a Python integer's ob_refcnt and ob_type at byte offsets 0 and 8, you were reading a C structure. This page is about the language that declares it.

02

Why operating systems are written in it

Five reasons · and one serious caveat

There is no runtime underneath it

This is the big one. When you run a Python script, the thing actually executing is the CPython interpreter — a large C program with a garbage collector, an object system and a bytecode loop. Something has to load and run that interpreter, and that something is the operating system. You cannot write the OS in Python, because Python needs the OS to already exist.

C compiles to a bare sequence of machine instructions that require essentially nothing beneath them. A C function can be the literal first thing that executes after the bootloader hands over control.

It maps closely to what the machine does

C's model of the world is: memory is one large flat array of bytes, variables are locations in it, and operations are things a CPU can do in a handful of instructions. People call it "portable assembly," which is glib but useful. When you write a + b you can predict roughly what instructions come out — and that predictability matters enormously when you are writing an interrupt handler with a hard deadline.

You manage memory yourself

An operating system is the thing that hands out memory. It cannot be sitting on top of an automatic memory manager; that is backwards. And garbage collection pauses are unacceptable in a kernel — you cannot tell a disk controller to hold on while you run a collection.

You can talk to hardware directly

A great deal of device control works by writing bytes to specific physical addresses, a technique called memory-mapped I/O. "Write 0x1 to address 0xB8000" is a single line of C and is not expressible in Python at all. C structures also have a defined byte layout, so you can lay a struct directly over a hardware register block or a network packet and read the fields by name.

Everything speaks C

The C calling convention — the ABI — is the lingua franca of computing. Python calls into C, Rust calls into C, your GPU driver exposes a C interface. Even languages designed specifically to replace C use its conventions to talk to the rest of the world.

The honest caveat. C is not safe. It will let you write past the end of an array, free the same pointer twice, or read memory that has already been released — all without complaint, at compile time or at run time. Decades of security vulnerabilities come from exactly this, which is why Rust is now being adopted in both the Linux kernel and Windows. C's dominance is partly technical merit and partly historical momentum: Unix spread through universities for free, and everyone learned C. But if you want to read kernel source, C is still the language you need.

03

Hello, world

And what "compiling" actually does to it

Python runs your source file. C does not — it converts your source into a standalone executable file first, and then you run that. Those are two separate commands, and the gap between them is where most first-day confusion lives.

hello.c
#include <stdio.h>

int main(void) {
    printf("Hello, world\n");
    return 0;
}
Five lines, four ideas. Read on for each one — but note first that nothing here is optional. C has no top-level statements; all executable code lives inside a function, and the program must have one called main.
two commands, not one
$ gcc -Wall -Wextra -g hello.c -o hello
  ↑    ↑            ↑         ↑
  |    |            |         └─ name the output file "hello"
  |    |            └─ keep debug information
  |    └─ turn on all the warnings
  └─ the compiler

$ ./hello
Hello, world
Turn on -Wall -Wextra on day one and never turn them off. C will let you do a great many stupid things in complete silence. Warnings are the only thing standing between you and an afternoon of confusion — you will see one save you in section 04.

Line by line

  • #include <stdio.h> — this is a preprocessor directive, not an import. Before compiling, a separate pass literally pastes the entire contents of stdio.h into your file. That header declares what printf looks like so the compiler knows how to call it.
  • int main(void) — every C program starts at main. It returns an int, which becomes the process's exit code. void in the parameter list means "takes no arguments."
  • \n — there is no automatic newline. printf prints exactly the characters you give it and nothing more. That \n is the byte 0x0a.
  • return 0 — zero means success. Every shell and script in existence depends on this convention.

What actually happens to your file

"Compiling" is four separate programs run in sequence. You almost never see the middle steps, but knowing they exist turns a whole category of baffling error messages into obvious ones.

The four stages · real output from this machine
$ gcc -E hello.c -o hello.i     # stop after preprocessing

hello.c   :   6 lines
hello.i   : 819 lines   ← stdio.h has been pasted in

# somewhere in those 819 lines, the thing you needed:
extern int printf (const char *__restrict __format, ...);
The preprocessor is a dumb text substitution pass. It does not understand C at all — it pastes files, expands macros, and strips comments. Your six-line file becomes eight hundred lines before the compiler even starts. This is why a typo in a header produces errors pointing at your file.
$ gcc -S -masm=intel hello.c -o hello.s   # emit assembly, readable syntax
#
# -masm=intel is doing real work here. gcc defaults to AT&T
# syntax, which writes the same instruction as:
#     leaq  .LC0(%rip), %rax
# Same machine code, different notation — operands reversed
# and decorated with % and $. Intel syntax is what Intel's
# own manuals use, so it is the one worth reading first.

main:
        endbr64
        push    rbp
        mov     rbp, rsp
        lea     rax, .LC0[rip]      # address of "Hello, world"
        mov     rdi, rax            # put it in the first argument register
        call    puts@PLT            # ← wait, that says puts, not printf
        mov     eax, 0              # return 0
        pop     rbp
        ret
Look at the highlighted line. You wrote printf and the compiler emitted a call to puts — a simpler function that prints a string and a newline. Because your format string had no % in it and ended in \n, gcc recognised that the cheaper function does the identical job and quietly substituted it. This is the level at which C compilers operate, and it is worth seeing on day one.
$ gcc -c hello.c -o hello.o     # assemble into machine code

# hello.o now contains real x86-64 instructions, but it is
# NOT runnable. It has a hole in it where puts should be:

$ nm hello.o
0000000000000000 T main              ← defined here
                 U puts              ← U = Undefined. Someone else must supply it.
An object file is machine code with unfilled blanks. Your main exists; puts is a promise that something, somewhere, will provide it. Nothing has checked that promise yet.
$ gcc hello.o -o hello           # link

$ ldd hello
    linux-vdso.so.1
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6   ← puts lives here
    /lib64/ld-linux-x86-64.so.2

$ ls -l hello
17000 bytes   # for a five-line program
The linker fills the holes. It goes looking for puts, finds it in the C standard library, and wires up the connection. This is the stage that produces "undefined reference to..." — it means you used a function nobody ever supplied, usually because you forgot to link a library. Knowing that this stage exists saves weeks.
04

Everything has a size

Declared up front · fixed at compile time · never negotiable

In Python a variable is a name loosely attached to an object of whatever kind. In C, every variable has a type you declare, and that type decides exactly how many bytes it occupies — permanently, before the program ever runs.

types.c
#include <stdio.h>

int main(void) {
    int    count  = 42;
    char   letter = 'A';
    double ratio  = 0.5;

    printf("%d %c %f\n", count, letter, ratio);
    return 0;
}
Three declarations, three sizes fixed forever. You cannot later put a string in count — not because it would be rude, but because there are only four bytes there and the compiler has already generated instructions that assume it.
Type explorer · real sizeof values from this machine
Size4 bytes
Bits32
Distinct values4,294,967,296
Range−2,147,483,648 … 2,147,483,647

Every pointer is the same size. Set the explorer to int * and note that it is eight bytes, exactly like long. It would be eight bytes for char * or double * too. A pointer holds an address, and on a 64-bit machine an address is 64 bits regardless of what is parked there. This is the same fact as "2⁶⁴ is the address space of a 64-bit pointer" from the powers-of-two table.

printf trusts you completely

Those %d, %c and %f markers are format specifiers, and here is your first real taste of C's philosophy: printf has no idea what you actually passed it. It reads the format string, sees %d, and goes to fetch an integer — whether or not you gave it one. If you were wrong, it fetches whatever happens to be lying around.

Format specifier sandbox

What "undefined behaviour" really means. Printing a double with %d is not an error that gets caught — it is a question the language declines to answer. Running that exact program twice on this machine printed 1684821160 and then -899375520. Worse, printing an int with %f printed 0.500000 — a plausible-looking number that was simply left over in a floating-point register from the previous line. The dangerous failures in C are the ones that look like they worked. This is what -Wall is for; the compiler catches this one and tells you exactly which argument is wrong.

05

Memory is a numbered street

One flat run of bytes · every one with an address

Before pointers make any sense, you need C's picture of memory — and it is much simpler than you might expect.

Memory is one enormous row of byte-sized boxes, numbered from zero upwards. That number is the byte's address. Nothing else is going on. There are no objects, no names, no structure — the structure is entirely in your program's head. A variable is nothing more than an agreement between you and the compiler that a particular stretch of those boxes, starting at a particular address, is to be treated as an int.

peek.c — the same eight bytes, read two ways
long long v = 42;
void *addr = &v;

printf("%p", addr);             0x7fff19ffc870
printf("%lld", *(long long *)addr);  42

// now read the very same bytes one at a time:
unsigned char *b = (unsigned char *)addr;
for (int i = 0; i < 8; i++) printf("%02x ", b[i]);

 2a 00 00 00 00 00 00 00
Two things worth stopping on. First, 2a is hexadecimal for 42 — the value is right there in the bytes. Second, it comes first, followed by seven zeros. That is little-endian ordering: x86 stores the least significant byte at the lowest address. The number is not stored "backwards"; the machine simply counts from the small end.

You have done this exact operation before, in Python. ctypes.c_ssize_t.from_address(addr) — the line that read a Python object's reference count straight out of memory — is precisely *(long long *)addr in C. Take a raw address, declare what type lives there, and read it. Python needed a library to reach through the abstraction; in C it is the ordinary way to say things.

06

Pointers

The concept the whole language turns on

A pointer is a variable whose value is an address. That is the entire definition. Everything difficult about it is notation, not concept.

Three pieces of syntax do all the work, and they are genuinely confusing at first because the * symbol means two different things depending on where it appears:

SyntaxWhere it appearsWhat it means
int *pa declaration"p is a variable that holds the address of an int." The * is part of the type.
&xan expression"the address of x." Read & as "address of."
*pan expression"the value stored at the address p holds." Read * as "the thing at." This is called dereferencing.

Step through the program below one line at a time and watch memory change. The addresses are real — captured from an actual run on this machine.

Pointer stepper · ptr.c
Source
Memory
0x7ffcb3878bcc 4 bytes x int 42 0x7ffcb3878bd0 8 bytes p int * 0x7ffcb3878bcc points to
Terminal
$ ./ptr

The moment that matters is *p = 99; — the line where x changes without x ever appearing on the left of an assignment. You reached into memory through an address and modified what was there. That single capability is what makes it possible to write a device driver, a memory allocator or a scheduler, and it is exactly what Python's ctypes was borrowing when it read an object header.

07

C always passes copies

The exercise where failing first is the lesson

Write a function that swaps two integers. Try it the obvious way first and watch it silently do nothing — that failure teaches the rule better than any explanation.

When you call a function in C, every argument is copied into the function's own variables. The function gets its own private boxes. It can scribble in them all it likes, and the caller's originals are untouched, because they were never involved. There is no exception to this rule — which means the only way to let a function modify your variable is to hand it the address and let it reach back.

Swap · two attempts
main — the caller
swap — the function

Why this is not a wart. Copying is fast, predictable and safe from the caller's point of view — a function cannot secretly mutate your data unless you explicitly hand over an address. When you see & at a call site in C, you are reading a signal: this function may modify this variable. That convention carries a lot of weight in a language with no other guarantees.

08

Strings are just arrays of bytes

There is no string type · there never was

C has no string type. A "string" is an array of char with a zero byte on the end, and that single decision is the ancestor of an enormous share of the security bugs in computing history.

A char is one byte and is genuinely just a small integer — 'A' is 65, and you can do arithmetic on it. To know where a string stops, C does not store a length. It puts a zero byte at the end and every string function scans forward until it hits one.

String viewer

str.c — actually run
char s[] = "Hello";

printf("strlen(s) = %zu\n", strlen(s));   5
printf("sizeof(s) = %zu\n", sizeof(s));   6   ← one more

  s[0] =  72  0x48  'H'
  s[1] = 101  0x65  'e'
  s[2] = 108  0x6c  'l'
  s[3] = 108  0x6c  'l'
  s[4] = 111  0x6f  'o'
  s[5] =   0  0x00  the terminator
Five characters, six bytes. strlen counts up to the zero; sizeof reports the whole array including it. Confusing those two is a rite of passage. Those hex values will look familiar — they are the same ASCII codes from the bits-and-bytes page, and 0x48 is 0100 1000.

Why this causes so much damage. Nothing checks that the zero byte is there. If it goes missing, strlen keeps counting through whatever memory follows until it stumbles onto a zero somewhere else — reporting a wild length, and functions that trust it will read or write far past the end of your array. There is no bounds checking anywhere in C. The array does not know how long it is; only the terminator says, and only if it is still there.

09

Structs — and what you were already reading

A named layout over a run of bytes · the payoff

A struct groups several values into one thing and — crucially — guarantees where each one sits. Once you can read a struct, you can read the source of nearly everything.

This matters more in C than the equivalent does elsewhere, because a struct is not an object with hidden internals. It is a map of byte offsets. If you know a struct's definition and you have its address, you can compute exactly where any field lives and read it.

Which is what you were doing, in Python, several pages ago. Here is the actual layout of a CPython integer, declared in C and compiled to confirm the offsets. Click any field.

Struct layout · CPython's integer object

Each square is one byte. Hatched squares are padding the compiler inserted.

Those offsets are not a coincidence. 0, 8, 16, 24 — the same numbers from the memory diagram on the Python page, verified here with C's offsetof. When you ran ctypes.c_void_p.from_address(id(x) + 8) and got back something equal to id(int), you were reading the ob_type field of this struct. The + 8 was not magic; it was this table.

One wrinkle: padding

The struct above is 28 bytes of actual fields but sizeof reports 32. The compiler inserts unused bytes so each field starts at an address that is a multiple of its own size — CPUs read aligned data considerably faster, and some refuse unaligned reads outright. The consequence is that field order changes how much memory your struct uses:

Field order matters

Same three fields, same data, 50% more memory — purely from the order they were declared. In a kernel structure allocated millions of times, that is a real number, and it is why you will see kernel structs written largest-field-first.

──

Where to go next

A learning order, and four resources worth the time

The order to learn things in

  1. Pointers — you have started. Keep going until & and * read as words rather than punctuation.
  2. Arrays, and how they decay into pointers — an array's name silently becomes the address of its first element in almost every context. This surprises everyone once.
  3. Strings and the standard string functionsstrlen, strcpy, strcmp, and why the safer variants exist.
  4. Structs — section 09 was an introduction; there is more, particularly pointers to structs and the -> operator.
  5. malloc and free — asking the OS for memory and giving it back. This is where C gets genuinely dangerous and genuinely powerful.
  6. Header files and multi-file compilation — how real programs are organised.

Somewhere in there, learn what the linker actually does. Section 03 gave you the outline; without it, "undefined reference to..." is a mystery people fight for weeks.

Resources

  • Beej's Guide to C Programming — free online, modern, and genuinely pleasant to read. The best starting point.
  • K&R (Kernighan & Ritchie, The C Programming Language) — the canonical book, co-written by the man who made the language. Short, dense, dated in places, still worth it.
  • Operating Systems: Three Easy Pieces — free, excellent, and the right book if OS internals are the actual motivation. It teaches OS concepts with C throughout.
  • xv6 — MIT's teaching operating system. A complete Unix-like kernel in about 6,000 lines of readable C. Once pointers and structs are comfortable, reading it is the fastest way to see how the pieces fit.

Your actual exercise. Everything on this page compiles. Save section 06's program as ptr.c, run gcc -Wall -Wextra -g ptr.c -o ptr, and look at the address it prints — it will not match the one here, because your machine lays out memory differently and modern systems randomise it deliberately. Then try section 07's swap the broken way, watch it fail, and fix it. That failure is the whole lesson, and you should feel it once yourself.