If I had to keep one language from which to rebuild a software stack, I would keep C. It puts the machine’s bill on my desk: bytes, addresses, allocation, lifetime, and calling conventions. I can see what a program asks the machine to do.

You can build a kernel, a compiler, a database engine, a device driver, a media library, or the narrow interface through which several languages share a native system. The language does not supply most of the architecture. You have to make it. That is precisely why C is such a good teacher.

Its small vocabulary also travels well. I can read a C function in a device driver, a database extension, and a graphics library and recognise the same basic machinery: a pointer, a length, a state object, a return value. The domain changes. The questions about ownership and representation stay familiar.

Small Language, Large Responsibility

An array parameter shows how much C leaves in the programmer’s hands. The function receives a pointer. Length, ownership, mutability, alignment, and lifetime are separate facts. A useful interface has to establish the contract:

#include <stddef.h>
#include <stdint.h>

typedef struct {
    uint32_t type;
    size_t payload_size;
} PacketHeader;

int decode_header(const unsigned char *bytes, size_t length,
                  PacketHeader *result);

Who owns bytes? Does result remain valid if decoding fails? Can the decoder retain either pointer? What happens when length describes less memory than the pointer actually reaches? The compiler will not settle these questions. A design review must.

Memory is part of the design in C. A buffer that outlives its owner may crash somewhere far from the mistake, but the investigation takes me through real questions about storage and lifetime. I have learned more from tracing one such failure than from memorising an API. The lesson carries into C++, Rust, Python extensions, graphics APIs, and operating system calls.

The function signature above gives a reviewer a starting point. bytes is read-only for the duration of the call. length bounds the input. result is caller-owned storage. The return value reports whether decoding produced a complete header. Those sentences would belong beside the declaration in a real header. Without them, two correct-looking callers can make incompatible assumptions about the same function.

Build an Allocator and the Questions Become Obvious

A byte arena is one of my favourite small C exercises. Give it a buffer and a cursor. Each allocation returns the next slice and moves the cursor forward:

#include <stddef.h>

typedef struct {
    unsigned char *data;
    size_t capacity;
    size_t used;
} ByteArena;

unsigned char *take_bytes(ByteArena *arena, size_t count) {
    if (arena == NULL || arena->data == NULL ||
        arena->used > arena->capacity ||
        count > arena->capacity - arena->used) {
        return NULL;
    }

    unsigned char *start = arena->data + arena->used;
    arena->used += count;
    return start;
}

The code is short. The design is larger. Who owns the backing buffer? When can the whole arena be reset? What happens to pointers handed out before reset? If I want to place typed objects in it, what alignment does each object need? If two threads use it, who advances used? I can answer those questions by changing the structure and its contract, rather than hunting for an allocator setting in a framework.

An arena can make a useful lifetime visible. Parse a file, build temporary objects, then release them together when the operation ends. It can also waste memory if the workload keeps long-lived objects among short-lived ones. C makes the trade-off concrete: I choose the buffer and the lifetime, then measure the result.

The Interface That Outlives the Implementation

C also has an unusual strength as a boundary language. A small C interface can hide a far more complicated implementation:

typedef struct engine engine;

engine *engine_create(void);
int engine_run(engine *, const char *scenario_path);
void engine_destroy(engine *);

The client sees an opaque handle and a few functions. It does not need the engine’s object layout, allocator, templates, exceptions, or internal dependency graph. The implementation could be C, C++, or another language that exposes a compatible interface. Ownership is still a serious contract: the caller must know who creates and destroys the handle, what an error means, and whether calls may overlap. Yet those questions are small enough to write down and test.

sequenceDiagram
    participant Client
    participant Library as C interface
    Client->>Library: engine_create()
    Library-->>Client: opaque handle
    Client->>Library: engine_run(handle, scenario_path)
    Library-->>Client: status code
    Client->>Library: engine_destroy(handle)

The handle has one owner and a clear end to its life. The scenario path belongs to the caller for the duration of engine_run. A Rust wrapper can call engine_destroy when its owner is dropped. A Python binding can do the equivalent when its wrapper closes. Both clients can use the library while its C++ representation remains private.

I saw the value of this boundary in FERS. Its simulation core is C++23, while command-line and desktop clients reach it through a narrow public C interface. Rust owns the desktop-side handle and confines foreign calls to one bridge. The C layer gives all three parts a common place to meet.

The return path matters as much as the call. A C function can return a status and place an error message in caller-provided storage, or hand back a library-owned string with a matching release function. Either choice is workable when ownership is explicit. An exception crossing a foreign-language boundary leaves that contract to accident. C encourages me to decide how an error crosses before I write the happy path.

Data Layout Is Part of the Conversation

C puts representation close enough to the surface that I can reason about it. Fields in a structure have an order and alignment. Arrays place elements next to one another. Passing a large structure by value may copy it; passing a pointer shares access to existing storage. These choices affect cache locality, serialization, and whether a foreign caller can interpret the data.

That visibility is valuable even when the best implementation uses a higher-level container. A profiler can show that a hot loop spends its time waiting on memory rather than doing arithmetic. Once I understand the data path, I can change a layout, remove a copy, or group work differently and measure again. Guessing that C code is fast because it is C is a poor substitute for this kind of investigation.

This is also why I like reading operating-system and library interfaces written in C. They expose the shape of a resource: acquire a handle, operate on it, check the result, release it. Higher-level libraries often improve that workflow. C shows me the contract they are wrapping.

Freedom Has a Bill

The cost of C is real. An out-of-bounds write, a dangling pointer, or a data race can invalidate every reassuring local test. The same freedom that lets you represent an unusual hardware contract also lets you express a broken one. A good C codebase needs tight interfaces, ownership rules, sanitizers, fuzzing where inputs are untrusted, static analysis, and tests that exercise failure paths. Even then, a successful build proves far less than a successful build in a language that checks ownership.

When I work near raw memory, I want the checks close to the code. A parser gets inputs that are truncated, oversized, and malformed. An allocator gets requests near its capacity. A lifecycle API gets create/destroy sequences in the wrong order. Sanitizers and fuzzers make those cases cheap to explore. They are part of the engineering practice that lets C’s freedom remain useful.

C is especially demanding when agents contribute code. A compiler accepts many lifetime mistakes that Rust would reject. An agent can write a convincing pointer manipulation and a test that only exercises the easy path. I want small interfaces, failure cases chosen independently, and a reviewer who can explain who owns every returned address. The language rewards that attention with a view of the system few abstractions can offer.

I use C to learn the substrate and to define small native boundaries. For a new service maintained by a team, I often choose stronger safety defaults. Understanding ownership and failure gives me the freedom to make both choices well.

Why I Keep Coming Back

C is god-tier to me because the fundamental bargain stays visible. A program has bytes, addresses, ownership, time, and interfaces. C gives me enough language to build the layers above them. Once I understand those layers, I can look through a much larger system and see what it is asking the machine to do.

That ability stays useful even when the code I ship is written in Rust, C++, or Python. C is the foundation I keep returning to.