conceptC from the Metal~7 min readUpdated 2026-06-22#c#systems-programming#memory-layout#abi#undefined-behavior

Why C still matters — a thin layer over the machine

C matters because it names the machine-level facts most languages hide: bytes, addresses, object layout, lifetimes, integer widths, function calls, and the boundary between your code and the OS. It is not assembly, and it is not a safe systems language; it is a small contract that compilers can map efficiently onto many real machines. That is why C is still the lingua franca of kernels, embedded firmware, language runtimes, libraries, and foreign-function interfaces. The trade-off is severe: every guarantee the runtime used to provide — bounds, ownership, initialization, overflow discipline, thread-safety — is now your responsibility.

The reset: C does not make the machine friendly. It makes the machine reachable. The closer you stand to bytes and addresses, the less the language can protect you from lying about them.

The thin layer

"Thin" means C gives you direct, inspectable mechanisms before it gives you policy. A struct is not an object with a hidden header; it is storage with fields at offsets, possibly with padding. A pointer is not a capability with lifetime tracking; it is a typed way to refer to an object, and it becomes invalid the instant the object's lifetime ends. An array is contiguous elements; pointer arithmetic is scaled by element size; an integer has a width, a representation, and overflow rules.

C construct What it usually maps to What C does not do for you
Automatic local stack storage in a call frame prove the pointer cannot escape
malloc block allocator-managed heap bytes free it or prevent use-after-free
struct field byte offset plus alignment serialize it portably across machines
Function call target ABI calling convention make all ABIs identical
Unsigned integer modulo-2^N arithmetic make signed overflow safe

Thin does not mean "whatever my current CPU does." C is specified as an abstract machine: objects have storage duration, effective type, alignment, and representation; expressions have sequencing rules; implementations choose widths, endian details, and ABIs. The compiler maps that abstract machine to the target. When your program stays inside the contract, the mapping is often direct and predictable. When it steps outside the contract, the compiler is free to optimize as if the impossible never happened.

How it really works

C gives you a portable spelling for operations that are close to hardware, then asks the implementation to define the machine-specific pieces. The same source can compile on x86-64, ARM64, a microcontroller, or a freestanding kernel toolchain because the source talks in C terms first: objects, storage duration, integer types, expressions, and translation units. The target then supplies sizes, alignment, calling convention, object-file format, and the actual instructions.

That division is the reason C is still useful. You can write:

  • layout-aware code with sizeof, offsetof, <stdint.h>, and explicit padding checks instead of pretending representation does not exist;
  • lifetime-aware code with automatic storage, static storage, and manual heap allocation instead of one universal GC-managed heap;
  • ABI-aware code that exports functions and data structures other languages can call through a stable C interface;
  • freestanding code for kernels and firmware, where there may be no hosted libc, filesystem, process, or OS underneath you.

The price is that C's low-level names are not proof. A uint8_t * does not prove you are inside the buffer. A char * string does not carry its length. A pointer does not say whether it points to the stack, heap, static storage, or dead memory. A successful compile does not prove your integer math cannot overflow. The language gives you control; the discipline has to come from code structure, tests, compiler warnings, sanitizers, and review.

Executable artifact: object layout is real bytes

This program shows the thin layer honestly: a C struct has a size, field offsets, and an object representation you can inspect with unsigned char *. It also writes raw bytes back into the first field. That byte view is defined by C; doing the same trick through an incompatible typed pointer would be a strict-aliasing bug.

// demo.c — C as a thin layer: a struct has memory, offsets, bytes, and alignment.
// gcc -O0 -Wall -Wextra demo.c -o demo && ./demo
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

struct PacketHeader {
    uint32_t magic;
    uint16_t version;
    uint8_t flags;
    uint8_t checksum;
};

static unsigned checksum_before_checksum(const struct PacketHeader *header) {
    const unsigned char *bytes = (const unsigned char *)header;
    unsigned sum = 0;

    for (size_t i = 0; i < offsetof(struct PacketHeader, checksum); i++) {
        sum += bytes[i];
    }

    return sum & 0xFFu;
}

static void dump_bytes(const void *object, size_t size) {
    const unsigned char *bytes = (const unsigned char *)object;

    for (size_t i = 0; i < size; i++) {
        printf("%02X%s", (unsigned)bytes[i], i + 1 == size ? "\n" : " ");
    }
}

int main(void) {
    struct PacketHeader header = {
        .magic = 0x4C4C4154u,
        .version = 0x0102u,
        .flags = 0xA5u,
        .checksum = 0u,
    };

    header.checksum = (uint8_t)checksum_before_checksum(&header);

    printf("sizeof(PacketHeader) = %zu\n", sizeof header);
    printf("offsets: magic=%zu version=%zu flags=%zu checksum=%zu\n",
           offsetof(struct PacketHeader, magic),
           offsetof(struct PacketHeader, version),
           offsetof(struct PacketHeader, flags),
           offsetof(struct PacketHeader, checksum));
    printf("fields: magic=0x%08X version=0x%04X flags=0x%02X checksum=0x%02X\n",
           (unsigned)header.magic,
           (unsigned)header.version,
           (unsigned)header.flags,
           (unsigned)header.checksum);

    printf("bytes : ");
    dump_bytes(&header, sizeof header);

    unsigned char *raw = (unsigned char *)&header;
    raw[0] = 0xEF;
    raw[1] = 0xBE;
    raw[2] = 0xAD;
    raw[3] = 0xDE;

    printf("after raw byte writes, magic = 0x%08X\n", (unsigned)header.magic);
    return 0;
}

Compile and run:

gcc -O0 -Wall -Wextra demo.c -o demo && ./demo

A real run on this machine:

sizeof(PacketHeader) = 8
offsets: magic=0 version=4 flags=6 checksum=7
fields: magic=0x4C4C4154 version=0x0102 flags=0xA5 checksum=0xD5
bytes : 54 41 4C 4C 02 01 A5 D5
after raw byte writes, magic = 0xDEADBEEF

The fields are not abstract boxes. They occupy bytes at offsets. On this little-endian machine, 0x4C4C4154 appears as 54 41 4C 4C, and writing EF BE AD DE into the first four bytes makes the uint32_t field read back as 0xDEADBEEF. The exact byte order and padding are implementation details; sizeof, offsetof, and the dumped bytes are how you stop guessing.

Why C remains the lingua franca

C survives because it sits at the boundary where software has to agree with hardware, operating systems, linkers, and other languages.

  • Kernels and firmware need no hidden runtime. A freestanding C environment can boot before there is a process, allocator, filesystem, or standard output. You can still express memory-mapped registers, interrupt tables, packed protocols, and explicit storage.
  • Runtimes are often written below the language they implement. Garbage collectors, JITs, interpreters, extension APIs, and standard libraries need control over layout, allocation, and ABI boundaries.
  • The C ABI is the default handshake. Even languages that do not want C's semantics often expose a C-compatible foreign-function interface because C symbols, structs, and calling conventions are the common denominator for linkers and system libraries.
  • The toolchain is mature and everywhere. gcc, clang, ld, objdump, gdb, lldb, sanitizers, QEMU, and Compiler Explorer all understand C as a first-class systems language.

This does not mean every system should be written in C. It means C is the layer you must understand when the abstractions above you leak: a crash in native code, a corrupted buffer, an ABI mismatch, a kernel boundary, a performance cliff, or a runtime that needs to own memory explicitly.

Failure modes & trade-offs

  • Undefined behavior is optimization fuel. Out-of-bounds access, use-after-free, returning a pointer to a local, signed overflow, violating effective type, and many sequencing mistakes do not merely "do the hardware thing." The compiler may assume they never happen.
  • Portability is work, not magic. Integer widths, alignment, padding, endianness, char signedness, and ABI details vary. Portable C asks the implementation with sizeof, offsetof, <stdint.h>, feature macros, and tests.
  • Manual lifetime scales poorly without ownership rules. C lets one function allocate and another free, but the type system does not record that contract. Write ownership in API names and docs, then enforce it with tests and review.
  • Thin runtime means thin diagnostics. A buffer overflow, null dereference, or double-free may corrupt memory long before it crashes. Use -Wall -Wextra, sanitizers, fuzzing, and debuggers as part of the workflow.
  • Control has opportunity cost. If a problem is mostly business rules, UI state, or high-level concurrency, C's manual guarantees may be the wrong trade. Reach for C when layout, ABI, latency, portability to small targets, or OS boundaries are central.

In practice

  • Measure representation instead of assuming it. Use sizeof, offsetof, static assertions, and byte dumps when a layout matters. Never ship a protocol by trusting the accidental layout of a host struct.
  • Make ownership visible. Pair create with destroy, document who frees returned pointers, and keep raw pointer lifetimes small enough to audit.
  • Compile as if warnings are defects. -Wall -Wextra is the floor; add -Wconversion, -Wshadow, -Werror, ASan, UBSan, and static analysis as the codebase hardens.
  • Read the generated code. For performance, ABI, and "what did C become?" questions, Compiler Explorer or objdump is often faster than guessing.
  • Remember the contract. C is close to the machine through the C abstract machine, not around it. The standard, the compiler, the ABI, and the hardware all matter.

Connects to: Machine Model · You are the runtime now · Stack vs heap · Bits, bytes, words & addresses · Two's complement & integer representation · Endianness · Pointers & Memory · Assembly & Compiler Output · Toolchain & Linking

Sources

  • Jens Gustedt — Modern C — the spine for modern C as a small, explicit systems language: objects, storage duration, initialization, and disciplined interfaces. https://gustedt.gitlabpages.inria.fr/modern-c/
  • ISO/IEC 9899 (WG14 C standard working drafts) — the authority for the C abstract machine, object representation, storage duration, effective type, and undefined behavior. https://www.open-std.org/jtc1/sc22/wg14/
  • cppreference — Objects and alignment in C — concise reference for object representation, effective type, alignment, and how character types may inspect bytes. https://en.cppreference.com/w/c/language/object
  • System V AMD64 ABI — concrete example of the ABI layer C compilers target on x86-64 Unix-like systems: calling convention, data layout, and object interface. https://gitlab.com/x86-psABIs/x86-64-ABI
  • Linux kernel coding style — real-world C constraints from a large freestanding-adjacent kernel codebase where layout, ownership, and toolchain discipline matter. https://docs.kernel.org/process/coding-style.html
  • Beej's Guide to C — a practical, free companion for C syntax, pointers, allocation, and how ordinary C programs are built and run. https://beej.us/guide/bgc/