Systems Programming Foundations

What "close to the metal" means: compilation, the stack and the heap, pointers, the C ABI, and why these languages exist.

Close to the Metal: Compilation and Why These Languages Exist

What a systems language is, how source becomes machine code, and why seven different languages all chose "no garbage collector."

Every program you run is, in the end, a stream of numbers the CPU executes directly - machine code. The job of a systems programming language is to let you write that machine code in something readable while staying brutally honest about what the hardware does: how much memory you use, when it is freed, exactly which instructions run. There is no virtual machine interpreting your code (like the JVM runs Java, or CPython runs Python), and crucially no garbage collector pausing your program to reclaim memory behind your back. You are, as the C folklore puts it, writing a "portable assembler."

This track is about the ideas every systems language shares, illustrated across seven of them: C (the original), C++ (C plus zero-overhead abstractions), HolyC (Terry Davis's TempleOS dialect of C), Zig, Hare, Odin, and Forth. They differ wildly in age and syntax - Forth predates C, HolyC is a one-man OS language - but they all make the same core bet: give the programmer direct control over memory and trust them with it. The price of that control, and how each language tries to make it survivable, is the story of the next six lessons.

From source to a running process

An ahead-of-time compiled language turns your whole program into a native executable before it runs. The classic pipeline has four stages: the preprocessor expands #includes and macros, the compiler translates each source file into an object file of machine code (with unresolved references to functions defined elsewhere), the linker stitches the object files and libraries into one executable, and finally the loader (part of the OS) maps that executable into memory and jumps to its entry point. C makes every stage visible:

#include <stdio.h>   /* preprocessor pulls in the declaration of printf */

int main(void) {     /* the entry point the loader jumps to */
    printf("hello from native code\n");
    return 0;        /* exit status handed back to the OS */
}

Compile and link it in one command - cc hello.c -o hello - and you get a standalone binary. Nothing interprets it at runtime; the OS loads it and the CPU runs your instructions. That is the whole point.

Same idea, seven dialects

Here is the same "compile to a native binary" story in the other languages, so you can see how little ceremony separates them from C. Zig is its own compiler and toolchain:

const std = @import("std");

pub fn main() void {
    std.debug.print("hello from native code\n", .{});
}

Hare reads almost like a leaner C, compiling through the small QBE backend instead of LLVM:

use fmt;

export fn main() void = {
	fmt::println("hello from native code")!;
};

Odin shares the same flavor, with a package declaration and a main procedure:

package main

import "core:fmt"

main :: proc() {
	fmt.println("hello from native code")
}

HolyC breaks the mold: its compiler is a JIT that doubles as the TempleOS shell, so top-level statements just run - there is no required main, and Print is a built-in, not a library call:

// HolyC: this line at the prompt compiles and executes immediately
"hello from native code\n";
Print("and so does this\n");

Forth is the wildest of all: it is interactive like a REPL but compiles each new word (its name for a procedure) to native code as you define it. Code is a stream of words operating on a stack, written in Reverse Polish order:

: HELLO  ( -- )  ." hello from native code" CR ;
HELLO

Why give up the safety net?

Garbage-collected languages are easier and safer - so why do these exist? Because some software cannot tolerate the costs a GC imposes: unpredictable pauses (a GC can stop your program for milliseconds at an arbitrary moment, fatal for an operating-system kernel, an audio engine, or a flight controller), memory overhead (a tracing GC typically wants headroom well beyond what your data actually occupies), and loss of control (you cannot place a struct at a specific hardware address, or guarantee a buffer is freed right now). Kernels, device drivers, bootloaders, game engines, databases, language runtimes, and embedded firmware all need that determinism. The deal these languages offer is the same one across all seven: you decide when memory is allocated and freed, and in exchange you get speed and predictability you cannot get any other way. The rest of this track is about how to hold up your end of that deal.

Further reading: Compiler Explorer - watch source become machine code.

The Stack and the Heap: Two Ways to Get Memory

Automatic stack storage is fast and freed for you; the heap is flexible but you own its lifetime - the central distinction in all systems programming.

When a systems program runs, the operating system hands its process a chunk of address space divided into regions. Two of them matter constantly: the stack and the heap. Understanding the difference is the single most important idea in this track, because who frees the memory and when is decided entirely by which one you use.

The stack: automatic and free (in both senses)

The stack is a region that grows and shrinks as functions call and return. Every time you call a function, the runtime pushes a stack frame holding that call's local variables, parameters, and the return address; when the function returns, the frame is popped and all of it vanishes automatically. Allocation is essentially free - just moving a pointer - and you never free anything yourself. The catch is the lifetime: a stack value lives exactly as long as the function call that created it.

int sum_to(int n) {
    int total = 0;        /* 'total' and 'n' live in this call's stack frame */
    for (int i = 1; i <= n; i++) total += i;
    return total;         /* frame is popped here; total is gone */
}

The danger is keeping a reference to stack memory after the frame is gone - a dangling pointer. Returning the address of a local is the classic bug:

int *broken(void) {
    int x = 42;
    return &x;   /* BUG: x dies when broken() returns; caller gets a dangling pointer */
}

The heap: flexible, but the lifetime is yours

The heap is a large region for memory whose size or lifetime is not known at compile time, or that must outlive the function that created it. You explicitly request a block, use it for as long as you like - across function calls, returned to callers, stored in data structures - and then explicitly release it. Nothing reclaims heap memory for you (there is no GC). This is where every memory bug in this track comes from: forget to release and you leak; release twice and you double-free; use after releasing and you have a use-after-free.

In C the verbs are malloc (allocate) and free (release):

#include <stdlib.h>

int *make_array(int n) {
    int *a = malloc(n * sizeof(int));  /* heap: survives this function returning */
    if (a == NULL) return NULL;        /* allocation can fail - always check */
    for (int i = 0; i < n; i++) a[i] = i;
    return a;                          /* fine to return: heap memory outlives the frame */
}
/* ... and somewhere later, the OWNER must: */
/*     free(a);   <- exactly once */

Stack vs heap, across the languages

Every language in this track has both regions; they differ only in how you ask the heap for memory. The dichotomy is universal. In Zig, stack locals are ordinary var/const; heap memory comes from an allocator you pass in, and defer schedules the matching free:

fn makeArray(alloc: std.mem.Allocator, n: usize) ![]i32 {
    const a = try alloc.alloc(i32, n);  // heap
    for (a, 0..) |*slot, i| slot.* = @intCast(i);
    return a;                           // caller now owns it and must free it
}
// a local like `var total: i32 = 0;` is on the stack and needs no freeing

Hare is the same shape - alloc for the heap, plain locals for the stack:

let total = 0;              // stack: vanishes at scope exit
let buf: *[4]u8 = alloc([0u8...]); // heap: you must free(buf) yourself
defer free(buf);           // schedule the release now, run it at scope exit

Odin routes heap requests through the in-scope allocator via new/make, while locals stay on the stack:

x := 42                       // stack
a := make([]int, 100)         // heap, via context.allocator
defer delete(a)               // release it at end of scope

Even Forth has the split, though it calls them by other names: the data stack holds transient operands (the ultimate "automatic" storage), while data space and the optional ALLOCATE word set provide longer-lived memory you manage by hand:

42                 \ pushed on the data stack - transient, like a stack local
100 CELLS ALLOCATE \ request heap memory ( -- addr ior )
THROW              \ raise if the allocation failed
\ ... use the block ...
FREE THROW         \ release it; pair every ALLOCATE with a FREE

The rule that organizes everything

Prefer the stack: it is faster, it cannot leak, and its lifetime is checked by the shape of your code. Reach for the heap only when you genuinely need a value to outlive its creating scope or to be sized at runtime - and the moment you do, you have taken on a responsibility the compiler will not discharge for you. Who owns this heap block, and who will free it? is the question every subsequent lesson is really about.

Further reading: cppreference - Storage duration and the lifetime of objects.

Pointers: Variables That Hold Addresses

A pointer is just a memory address you can read, write, and do arithmetic on - the fundamental tool, and the fundamental hazard, of systems code.

Memory is a giant array of bytes, and every byte has a numeric address. A pointer is simply a variable whose value is an address - it "points at" another piece of memory. Pointers are how systems languages share data without copying it, build linked structures, talk to hardware, and reach the heap. They are also where the sharpest edges live, because a pointer is just a number and the hardware will happily follow a wrong one.

Taking an address and following it

Two operations define pointers in the C family: & takes the address of a variable, and * dereferences a pointer - reads or writes the memory it points at. The type tells the compiler how many bytes to read and how to interpret them.

int x = 10;
int *p = &x;     /* p holds the address of x */
printf("%d\n", *p);   /* dereference: prints 10 */
*p = 20;         /* write THROUGH the pointer; x is now 20 */
printf("%d\n", x);    /* prints 20 */

Because a pointer aliases the original storage, passing a pointer lets a function modify its caller's data - this is how C does "pass by reference":

void increment(int *n) { (*n)++; }   /* mutates whatever n points at */

int count = 0;
increment(&count);   /* count is now 1 */

Pointer arithmetic and arrays

Pointers can be added to. Adding 1 to an int* advances it by sizeof(int) bytes - to the next integer, not the next byte. This is why arrays and pointers are nearly the same thing in C: an array name decays to a pointer to its first element, and a[i] is defined as *(a + i).

int a[4] = {10, 20, 30, 40};
int *p = a;          /* points at a[0] */
printf("%d\n", *(p + 2));  /* same as a[2]: prints 30 */

There are no bounds checks. Writing a[4] or *(p + 99) compiles fine and corrupts whatever happens to live there - the root of buffer overflows. A null pointer (NULL, address 0) dereferenced typically crashes; a dangling or uninitialized pointer is worse, because it may seem to work while quietly trashing memory.

The same machinery, slightly tamed

C++ has raw pointers identical to C's, but adds references (int&) - a pointer that can't be null or reseated, with cleaner syntax - and steers you toward smart pointers (lesson 4) so you rarely touch raw * for ownership:

int x = 10;
int &r = x;     // a reference: an alias for x, no '*' needed to use it
r = 20;         // x is now 20
int *p = &x;    // raw pointer still available when you need an address

Zig keeps pointers explicit but distinguishes a single-item pointer *T from a many-item pointer [*]T, and prefers slices ([]T, a pointer plus a length) so bounds are known and checked:

var x: i32 = 10;
const p = &x;     // *i32, a single-item pointer
p.* = 20;         // dereference with .*

var a = [_]i32{ 10, 20, 30, 40 };
const s: []i32 = &a;   // a slice: carries .ptr AND .len
// s[9] panics in safe builds instead of corrupting memory

Hare and Odin make the same move: raw pointers exist (*T / ^T), but the everyday tool is a length-carrying slice so the size of a buffer travels with it:

let x = 10;
let p = &x;        // *int
let buf: []u8 = ['h', 'i'];  // a slice knows its own length: len(buf) == 2
x := 10
p := &x            // ^int  (Odin spells "pointer to" as ^)
p^ = 20            // dereference with ^
s := []int{10, 20, 30, 40}  // slice with a known len(s)

Forth: pointers with the gloves fully off

Forth has no types at all - everything is a raw machine cell - so a "pointer" is just an address on the stack, and you read and write memory with @ (fetch) and ! (store). There is nothing between you and the bytes:

VARIABLE X      \ reserve a cell; X pushes its address
10 X !          \ store 10 at that address  ( store: value addr -- )
X @ .           \ fetch and print: 10       ( fetch: addr -- value )
X @ 1+ X !      \ read, add one, write back: now 11

C@/C! do the same for single bytes, and +! adds to a cell in place. No checks, no types, no help - which is exactly the Forth philosophy: the language gives you the mechanism and trusts you completely. Across all seven languages the pointer is the same fundamental object - an address you can follow - and the only thing that varies is how much the language is willing to remember for you about how big the thing at that address is, and whether it is still alive.

Further reading: Beej's Guide to C - Pointers.

Seven Memory Models: malloc/free, RAII, Allocators, and defer

The core of the track - exactly how each language asks for heap memory and ensures it gets released, from C's manual pairs to Odin's context allocator.

Every language here gives you the heap and refuses to garbage-collect it. The interesting differences are in the discipline each one offers for making sure every allocation is eventually - and exactly once - released. This is the heart of systems programming, so let's be precise about each model.

C: manual malloc/free pairs

C is the baseline and the most spartan. You call malloc (or calloc for zeroed memory, realloc to resize) to get a block, and you must call free exactly once when done. There are no destructors, no scope hooks - nothing runs automatically. Every allocation creates an obligation you discharge by hand, on every path including early returns and errors.

char *dup_upper(const char *s) {
    size_t n = strlen(s);
    char *out = malloc(n + 1);      /* + 1 for the trailing '\0' */
    if (!out) return NULL;
    for (size_t i = 0; i < n; i++) out[i] = toupper((unsigned char)s[i]);
    out[n] = '\0';
    return out;                     /* caller must free() this */
}
/* caller: char *u = dup_upper("hi"); ...; free(u); */

HolyC is C's memory model with TempleOS's vocabulary: MAlloc() and Free() (capitalized), drawing from a per-task heap rather than a process-wide one. When a task dies, its heap is reclaimed wholesale; Free(NULL) is allowed, and MSize() reports the real block size. Otherwise the discipline is identical to C - and on a ring-0 system with no memory protection, a mistake can corrupt the whole machine:

// HolyC
U8 *buf = MAlloc(256);   // from this task's data heap
// ... use buf ...
Free(buf);               // exactly once; Free(NULL) is a harmless no-op

C++: RAII and smart pointers

C++ keeps new/delete (and C's malloc/free) available but makes idiomatic code rely on RAII - Resource Acquisition Is Initialization. The idea: bind a resource's lifetime to an object, so the destructor runs automatically when that object goes out of scope, releasing the resource deterministically (no GC, but no manual free either). Smart pointers apply RAII to heap memory: std::unique_ptr owns a block and frees it when it dies; std::shared_ptr reference-counts shared ownership and frees when the last owner drops.

#include <memory>
#include <vector>

std::unique_ptr<int[]> make_array(int n) {
    auto a = std::make_unique<int[]>(n);  // heap-allocated, owned by the unique_ptr
    for (int i = 0; i < n; i++) a[i] = i;
    return a;                             // ownership MOVES to the caller
}   // if we hadn't returned it, delete[] would run automatically here

void use() {
    auto a = make_array(10);  // when 'a' goes out of scope, memory is freed - no delete needed
    std::vector<int> v{1, 2, 3};  // vector is RAII too: frees its buffer on scope exit
}

The payoff: you almost never write delete, yet there is no garbage collector and freeing is deterministic - it happens at a known point (scope exit), not at the GC's whim.

Zig: explicit allocators + defer

Zig's rule is "no hidden allocations." There is no global malloc you reach for implicitly; instead any code that allocates takes an Allocator parameter explicitly, so allocation is always visible at the call site. There are no destructors, so you pair each allocation with a defer that runs the matching free at scope exit (and errdefer for the error path only). The allocator is pluggable - swap a GeneralPurposeAllocator (which detects leaks and double-frees) for an ArenaAllocator without touching the code that uses it.

const std = @import("std");

fn process(alloc: std.mem.Allocator) !void {
    const buf = try alloc.alloc(u8, 1024);  // allocation is visible: it took `alloc`
    defer alloc.free(buf);                  // runs at scope exit, on every path
    // ... use buf ...
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();                 // reports leaks when the program ends
    try process(gpa.allocator());
}

Hare: manual alloc/free + defer

Hare sits between C and Zig. Heap memory comes from the built-in alloc expression and is released with free - manual, like C - but it borrows defer to keep the allocation and its release next to each other in the source. There is no GC and no RAII; allocation (when linked against libc) goes straight to malloc.

use fmt;

export fn main() void = {
	let nums: []int = alloc([1, 2, 3, 4]);  // heap slice
	defer free(nums);                        // release at scope exit
	fmt::printfln("len = {}", len(nums))!;
};

Odin: the implicit context.allocator + defer

Odin's distinctive twist is the context - an implicit value passed as a hidden argument to every Odin-convention procedure. It carries a context.allocator, so new, make, free, and delete route through whichever allocator is in scope without you threading it through every call. Swap context.allocator for an arena and you can free an entire subsystem's memory in one reset instead of tracking individual frees. As elsewhere, defer handles per-allocation cleanup; there is no GC and no RAII.

package main

import "core:fmt"
import "core:mem"

main :: proc() {
	a := make([]int, 100)   // uses context.allocator implicitly
	defer delete(a)         // release at scope exit

	// Bulk strategy: an arena, freed all at once
	arena: mem.Arena
	buf := make([]byte, 1 << 16)
	mem.arena_init(&arena, buf)
	context.allocator = mem.arena_allocator(&arena)
	x := new(int)           // now allocated from the arena
	_ = x
	// no per-object free needed: free the whole arena's backing buffer at once
	fmt.println(len(a))
}

Forth: raw data space - HERE, ALLOT, @, !

Forth is the most primitive model: there is no malloc in the core language, only data space - a contiguous region with a bump pointer called HERE that marks the next free address. ALLOT reserves bytes by simply moving HERE forward; , (comma) appends one cell and advances HERE; you then read and write with @ and !. Allocation is literally advancing a pointer - there is no general way to free an individual block, you just reclaim back to a saved HERE. (A standard optional word set adds C-style ALLOCATE/FREE/RESIZE for true dynamic heap memory.)

CREATE BUF  10 CELLS ALLOT   \ carve a 10-cell buffer in data space
42 BUF !                     \ store 42 at BUF[0]
BUF @ .                      \ fetch and print: 42
7 BUF 1 CELLS + !            \ store 7 at BUF[1] (pointer arithmetic by hand)
HERE .                       \ print the current top of data space

The spectrum

Line them up and a clear gradient appears. At one end, fully manual pairs you must balance yourself: C's malloc/free, HolyC's MAlloc/Free, Forth's ALLOT/@/!. In the middle, manual but scope-assisted: Hare, Zig, and Odin keep allocation explicit but use defer (and Zig/Odin's pluggable allocators) so the release sits beside the request and bulk strategies like arenas become easy. At the far end, automatic-but-deterministic: C++ RAII and smart pointers run the free for you at scope exit - the closest thing to safety here, still with zero garbage collector. None of them trace and reclaim memory behind your back; they only differ in how much help you get making sure you do.

Further reading: Zig - Choosing an Allocator.

The C ABI: The Universal Language of Binaries

Why the C calling convention and data layout became the lingua franca every systems language speaks to talk to libraries and the OS.

You have seen how source becomes a binary. But for two separately-compiled binaries to call each other - your program calling a library, any program calling the operating system - they must agree on low-level details the source code never mentions: which registers hold arguments, who cleans up the stack, how a struct is laid out in memory, how an integer is sized. That contract is the ABI (Application Binary Interface). And in practice there is one ABI everything agrees on: the C ABI.

ABI vs API

An API is a source-level contract: function names, types, signatures - what you write against. An ABI is the binary-level contract the compiled code obeys: the calling convention (e.g. the System V AMD64 convention passes the first integer arguments in registers rdi, rsi, rdx, rcx, r8, r9, returns in rax, and aligns the stack a certain way), the sizes and alignments of types, and the byte layout of structs (including the padding the compiler inserts so fields land on aligned addresses). Get the API wrong and it won't compile; get the ABI wrong and it compiles but crashes or corrupts data at runtime.

Why C, specifically?

C arrived with Unix and became the language the operating system's own interfaces were written in. Every OS exposes its system calls and system libraries with C signatures; every major platform defines its calling convention in C terms. So the C ABI became the lingua franca: the one interface every language can target. When a language wants to call a library written in another language, the near-universal trick is for both to expose a C-compatible interface and meet in the middle. This is why "FFI" (Foreign Function Interface) almost always means "C FFI."

Speaking C from each language

Because of this, every systems language in this track can both call C and be called as if it were C. C++ marks functions extern "C" to suppress name-mangling so they present a plain C symbol:

// Expose a C++ function under the C ABI so anything can link to it
extern "C" int add(int a, int b) {
    return a + b;
}
// And call a C library function by declaring it extern "C":
extern "C" double sqrt(double);

Zig has first-class C interop: @cImport parses real C headers directly (no hand-written bindings), and export/callconv(.C) produce C-ABI symbols:

const c = @cImport(@cInclude("math.h"));  // parse the C header at compile time

export fn add(a: c_int, b: c_int) c_int {  // exported with the C ABI
    return a + b;
}

pub fn main() void {
    _ = c.sqrt(2.0);  // call libc's sqrt directly
}

Hare declares external C symbols and can be built to expose its own; Odin uses foreign blocks naming the library, with "c" calling convention:

// Hare: bind to a C library symbol
@symbol("sqrt") fn c_sqrt(_: f64) f64;
// Odin: import a C library and call into it
foreign import libc "system:c"
foreign libc {
	sqrt :: proc(x: f64) -> f64 ---
}

HolyC is special: it is the system language of TempleOS, so it calls the kernel's own functions directly without an FFI layer - the language and the OS share one address space and one ABI. Forth typically reaches the outside world through implementation-specific bindings (Gforth's libcc C interface, for example) rather than a standard FFI, but the meeting point is still the C ABI.

Struct layout: the silent half of the ABI

Matching the calling convention is only half of interop; the two sides must also agree on how a struct is laid out, byte for byte. Compilers insert padding so each field is aligned, which is why sizeof a struct can exceed the sum of its fields:

struct Point {
    char  tag;   /* 1 byte ...                              */
    /* 3 bytes of padding here so 'x' is 4-byte aligned     */
    int   x;     /* 4 bytes                                 */
    int   y;     /* 4 bytes                                 */
};               /* sizeof == 12, not 9, on a typical ABI   */

When you pass this struct across an FFI boundary, the other language must reproduce exactly this layout - same field order, same sizes, same padding - or it will read garbage. Languages provide extern struct / #packed / layout directives precisely to match C's rules. This is the deep reason the C ABI matters: it is not just how to call, but a shared agreement about what memory looks like - and that agreement is what lets a fifty-year-old C library, an OS kernel, and a brand-new Zig or Odin program all operate on the same bytes.

Further reading: System V AMD64 ABI specification.

Undefined Behavior and the Footguns of Freedom

The four classic memory bugs, why undefined behavior exists, and the spectrum of discipline - from C's none to RAII and leak-detecting allocators.

The power these languages give you - direct memory, no GC, no runtime watching over you - is exactly the power to make catastrophic mistakes. This final lesson catalogs the classic ways memory goes wrong, explains the concept of undefined behavior that underlies them, and surveys how each language tries to keep you alive.

The four classic memory bugs

Almost every memory bug in a manually-managed language is one of these four, and all four stem from the same root question - who frees this, and is it still alive?

  • Memory leak - you allocate and never free; the program's memory grows without bound. Not immediately fatal, but deadly for long-running services.
  • Use-after-free (dangling pointer) - you free a block, then read or write through a pointer that still points at it. The memory may have been handed to something else; you corrupt unrelated data.
  • Double free - you call free twice on the same block, corrupting the allocator's bookkeeping.
  • Buffer overflow / out-of-bounds - you read or write past the end of an allocation (no bounds checks!), trampling adjacent memory.
int *p = malloc(sizeof(int));
free(p);
*p = 5;        /* USE-AFTER-FREE: p is dangling */
free(p);       /* DOUBLE FREE */

int a[4];
a[4] = 99;     /* OUT-OF-BOUNDS: a[4] doesn't exist; index 0..3 are valid */
/* and never calling free() on a kept pointer is a LEAK */

What "undefined behavior" actually means

Crucially, the language standard does not say these mistakes crash. It says they are undefined behavior (UB): the standard imposes no requirements at all on what happens. The program may crash, may appear to work, may corrupt data, may differ between compilers or optimization levels. UB is not an accident of the spec - it is a deliberate design choice. By declaring out-of-bounds access, signed overflow, use-after-free, and uninitialized reads to be "your problem," the standard frees the compiler to optimize aggressively, assuming you never trigger them. That is a big part of why C is fast - and why a UB bug can manifest as a baffling failure far from its cause. The same trade runs through HolyC (unchecked, ring-0, no memory protection at all, so a wild pointer can take down the whole OS), Hare, Odin, and Zig's fast-release mode.

The spectrum of discipline

What differs between these languages is how much help you get avoiding UB - and it is a genuine spectrum.

C, HolyC, Forth - essentially none. The language gives you the mechanism and trusts you completely. Your safety net is external: discipline, code review, and tooling. For C/C++ the standard tools are sanitizers - compile with AddressSanitizer (-fsanitize=address) and use-after-free, double-free, and overflows are caught at runtime with a precise report:

/* cc -fsanitize=address bug.c && ./a.out
   ==ERROR: AddressSanitizer: heap-use-after-free ...
   reads/writes to freed memory now ABORT with a stack trace */

Zig, Hare, Odin - safer defaults, opt-in checks. These newer languages keep manual memory but file down the sharpest edges. They favor slices that carry their length, so the most common overflow simply cannot be silent - in a Zig safe build, an out-of-bounds index or a null-pointer dereference is a checked panic, not UB:

var a = [_]i32{ 1, 2, 3 };
const i: usize = 5;
_ = a[i];  // safe build: 'index out of bounds' panic, not silent corruption

Zig goes further with a debug allocator that detects leaks and double-frees, and errdefer to free correctly on error paths. Odin ships a tracking allocator that reports leaks; both lean on defer so frees are hard to forget. They still have UB if you misuse raw pointers, but you have to work harder to reach it.

C++ - move the bug out of existence with RAII. Rather than detect leaks, idiomatic C++ makes them structurally unlikely: if every heap block is owned by a unique_ptr, shared_ptr, or a container (lesson 4), the destructor frees it automatically and exactly once, so leaks, double-frees, and most use-after-frees in ownership simply do not arise. The discipline is encoded in the type system instead of left to vigilance:

{
    auto p = std::make_unique<int>(5);  // owns the allocation
    *p = 10;
}   // freed here, automatically, once - no leak, no double free possible

The bargain, restated

Set against a garbage-collected language, all seven of these are "unsafe" by default - the compiler will not stop you from following a dangling pointer. But that framing misses the point of the track: they exist precisely because manual control buys determinism and speed that a GC cannot, and they range from C's "you and your tools are the safety net" to C++'s RAII and Zig's checked safe builds, which recover much of that safety without ever giving up control. Mastering systems programming is mastering this bargain: understand the stack and the heap, respect pointers, know exactly who owns each allocation and when it dies, and lean on whatever discipline - RAII, defer, slices, sanitizers - your language offers to make sure you uphold your end.

Further reading: LLVM/Clang AddressSanitizer documentation.