C or Rust — closer to the metal
You only need a low-level language once. But you need it once. Pointers, memory, ownership — the ground truth that makes the rest of CS make sense.
Prerequisites
01.1
Stack
Rust toolchain via rustup, OR gcc/clangmake, cmake (if C)valgrind (linux/wsl) or lldb
By the end of this module
- Hold a working mental model of the stack, the heap, and pointers — the kind that makes garbage collectors stop feeling magical.
- Build a small data structure (Vec or hand-rolled memcpy) from scratch in your chosen language.
- Read a segfault or borrow-checker error and know what your program actually did wrong.
- Have a sense of when a higher-level language is fine and when 'drop a layer' is the right move.
Most CS curricula make you write C in your first year and then never use it again. Most modern bootcamps skip low-level entirely. Both are wrong. You only need a low-level language once, but if you skip it, the rest of computer science stays a little bit hazy forever — garbage collectors feel magical, performance discussions feel like incantations, and you’ll never be quite sure what’s actually happening when you call a function.
This module is the once. You will pick one of C or Rust, you will spend ten to fifteen hours with it, and you will build one small thing that forces you to think about memory directly. Then you’ll go back to whatever high-level language you write for a living, and the rest of computer science will quietly come into focus.
The opinionated take, before you pick: most students should pick Rust in 2026. It teaches you the same hard lessons as C — pointers, memory, lifetimes, what the stack and heap are — but the compiler tells you when you’re wrong instead of letting you ship a segfault to production six months later. C is still worth learning if you want to read the Linux kernel, write firmware, or take a serious OS class. For “I want a low-level language so the rest of CS makes sense” — Rust is the better tool for the job.
The case for each
Rust
- Compiler catches memory bugs at build time. The single biggest jump in language design in 30 years.
- Modern tooling: cargo, rustup, clippy, rustfmt — all included, all good.
- The ownership/borrow model teaches you the same lessons as C, but you learn them by reading errors instead of by debugging crashes.
- Real production use: compilers, browsers, databases, infrastructure. Not just academic.
- The pain is upfront — first 20 hours hurt — and then it stops hurting.
C
- 50 years old, still the lingua franca of systems software.
- Smaller language. You can read the entire spec in a long weekend.
- If you ever want to seriously read a kernel, a JVM, or a database engine, you need C.
- The pain is forever spread across small papercuts, but each papercut teaches you something specific.
- K&R is one of the great programming books and worth reading regardless.
If you are still on the fence, default to Rust. The rest of this module will give instructions for both, but the build at the end is more interesting in Rust.
Set up
Rust
# rustup is the official toolchain manager
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# follow the default install
source "$HOME/.cargo/env"
rustc --version
cargo --version
# Useful additions
rustup component add clippy rustfmt
cargo install cargo-watch # `cargo watch -x test` is a great loop
C
# macOS — Xcode command line tools
xcode-select --install
# Debian / Ubuntu / WSL
sudo apt install build-essential gdb valgrind
# Verify
gcc --version # or clang --version on macOS
make --version
Read these first
Pick the section for your language. In each, four sources, in order, then stop.
Rust
- The Rust Book. link · 8 hrs total · the canonical free book. Read chapters 1–10 cover to cover. They are short.
- Rustlings. repo · 6–10 hrs · small exercises that fail to compile until you fix them. The single best way to internalize ownership.
- Jon Gjengset — Crust of Rust. playlist · advanced; skim once you have basics.
- Rust by Example. link · use as a reference once you’re building.
C
- K&R — The C Programming Language. book · 200 pages · still the best CS book ever written. Yes, in 2026.
- Beej’s Guide to C Programming. link · free, modern, friendly companion to K&R.
- Eli Bendersky — Memory Layout of C Programs. post · the picture of what’s actually happening.
- Beej’s Guide to Network Programming. link · the natural follow-on once you’re comfortable. Sockets in C are a rite of passage.
The mental model: stack, heap, pointers
The thing high-level languages hide from you, that this module forces you to see.
A program's memory looks roughly like:
+-------------------+
| Stack | <- function frames, local variables
| ↓ | grows down on most systems
| |
| ↑ |
| Heap | <- malloc / Box / new — dynamic allocation
+-------------------+
| BSS | <- uninitialized globals
+-------------------+
| Data | <- initialized globals, string literals
+-------------------+
| Text | <- the actual machine code
+-------------------+
When you call a function, a frame gets pushed on the stack with the locals and return address. When the function returns, the frame is popped — those locals are gone. When you allocate dynamically (malloc in C, Box::new in Rust), the data lives on the heap and you hold a pointer to it.
A pointer is just a memory address. In C: int *p = &x; reads as “p points at x.” In Rust: let p: &i32 = &x; is the same idea, but the borrow checker tracks how long p is allowed to point at x.
Every weird bug you’ve ever read about — segfault, double-free, use-after-free, dangling pointer, data race — is some variation of “the program looked at memory it didn’t own, or owned memory it shouldn’t have.” Once you see this, you stop being mystified by performance discussions.
A tiny build, by language
Each of these takes 3–6 hours and is the right size for the lesson. Pick one (the one matching your language).
Rust: roll your own Vec
A Vec<T> is a growable array that owns a heap buffer. Build one. The Rust standard library version is ~3000 lines because it’s hyper-optimized; your version is going to be ~80 lines and that is fine.
cargo new --lib myvec
cd myvec
Sketch (most of this you’ll fill in yourself, the borrow checker will yell at you, that’s the lesson):
// src/lib.rs
use std::alloc::{alloc, dealloc, realloc, Layout};
use std::ptr;
pub struct MyVec<T> {
ptr: *mut T,
len: usize,
cap: usize,
}
impl<T> MyVec<T> {
pub fn new() -> Self {
Self { ptr: ptr::null_mut(), len: 0, cap: 0 }
}
pub fn push(&mut self, value: T) {
if self.len == self.cap {
self.grow();
}
unsafe { ptr::write(self.ptr.add(self.len), value); }
self.len += 1;
}
pub fn pop(&mut self) -> Option<T> {
if self.len == 0 { return None; }
self.len -= 1;
Some(unsafe { ptr::read(self.ptr.add(self.len)) })
}
fn grow(&mut self) {
let new_cap = if self.cap == 0 { 4 } else { self.cap * 2 };
let layout = Layout::array::<T>(new_cap).unwrap();
let new_ptr = if self.cap == 0 {
unsafe { alloc(layout) as *mut T }
} else {
let old_layout = Layout::array::<T>(self.cap).unwrap();
unsafe { realloc(self.ptr as *mut u8, old_layout, layout.size()) as *mut T }
};
self.ptr = new_ptr;
self.cap = new_cap;
}
}
impl<T> Drop for MyVec<T> {
fn drop(&mut self) {
while self.pop().is_some() {}
if self.cap > 0 {
let layout = Layout::array::<T>(self.cap).unwrap();
unsafe { dealloc(self.ptr as *mut u8, layout); }
}
}
}
This is one of the few pieces of Rust where you’ll write unsafe. The point: when you wrap unsafe primitives into a safe API, every other Rust program that uses Vec is depending on someone having written exactly this kind of code, exactly correctly. You’re now that person, even if just for an afternoon.
Add tests:
// src/lib.rs (continued)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_and_pop() {
let mut v = MyVec::new();
for i in 0..1000 { v.push(i); }
for i in (0..1000).rev() { assert_eq!(v.pop(), Some(i)); }
assert_eq!(v.pop(), None);
}
}
Run with cargo test. Run with cargo miri test if you want — Miri is a Rust interpreter that catches undefined behavior in your unsafe code. It will find bugs in your first attempt. That is exactly the point.
C: hand-roll memcpy and a tiny dynamic array
mkdir mylib && cd mylib
// myvec.h
#ifndef MYVEC_H
#define MYVEC_H
#include <stddef.h>
typedef struct {
int *data;
size_t len;
size_t cap;
} myvec;
void myvec_init(myvec *v);
void myvec_push(myvec *v, int value);
int myvec_pop(myvec *v, int *out); /* returns 0 on success, -1 if empty */
void myvec_free(myvec *v);
void my_memcpy(void *dst, const void *src, size_t n);
#endif
// myvec.c
#include <stdlib.h>
#include <string.h>
#include "myvec.h"
void my_memcpy(void *dst, const void *src, size_t n) {
unsigned char *d = dst;
const unsigned char *s = src;
while (n--) *d++ = *s++;
}
void myvec_init(myvec *v) {
v->data = NULL;
v->len = 0;
v->cap = 0;
}
void myvec_push(myvec *v, int value) {
if (v->len == v->cap) {
size_t new_cap = v->cap == 0 ? 4 : v->cap * 2;
int *new_data = realloc(v->data, new_cap * sizeof(int));
if (!new_data) abort();
v->data = new_data;
v->cap = new_cap;
}
v->data[v->len++] = value;
}
int myvec_pop(myvec *v, int *out) {
if (v->len == 0) return -1;
*out = v->data[--v->len];
return 0;
}
void myvec_free(myvec *v) {
free(v->data);
v->data = NULL;
v->len = 0;
v->cap = 0;
}
// test.c
#include <assert.h>
#include <stdio.h>
#include "myvec.h"
int main(void) {
myvec v; myvec_init(&v);
for (int i = 0; i < 1000; i++) myvec_push(&v, i);
for (int i = 999; i >= 0; i--) {
int x; assert(myvec_pop(&v, &x) == 0);
assert(x == i);
}
int y; assert(myvec_pop(&v, &y) == -1);
myvec_free(&v);
char a[6] = "hello"; char b[6] = {0};
my_memcpy(b, a, 6);
printf("%s\n", b);
return 0;
}
cc -Wall -Wextra -O2 -g myvec.c test.c -o test
./test
# Linux/WSL only:
valgrind --leak-check=full ./test
If valgrind reports zero leaks and zero invalid reads, you’ve earned this exercise. If it reports something — read it, understand it, fix it. That feedback loop is the whole lesson.
Concurrency, briefly
Both languages take you to a place high-level languages won’t.
In C, threads via pthreads. Two pthreads, a shared counter, no lock — watch the count come out wrong, learn what a data race actually looks like in your hands.
In Rust, you literally cannot write that data race. The compiler refuses with a Send/Sync error. The first time it does, that error is the lesson — Rust has encoded the rule “data shared across threads must be safe to share” directly into its type system.
Don’t go deep into concurrency this module. Just write one program with two threads in your chosen language so you’ve felt the shape.
Reading linker errors
The third skill that distinguishes someone who has used a low-level language from someone who has only “completed a tutorial” — they can read linker output.
undefined reference to `myvec_push'
That’s not the compiler. That’s the linker. The compiler converted each .c to an object file, and now the linker is trying to glue them together. undefined reference to X means: someone called X, but no object file defined it. Either you forgot to compile/include the file that contains X, or X is in a library you forgot to link with -l<libname>.
multiple definition of `foo'
Two object files both contain foo. Almost always a header-file bug — you defined a function in a header (instead of declaring it).
ld: library not found for -lcrypto
The linker tried to find libcrypto and couldn’t. Install the dev package, or pass -L/path/to/lib.
In Rust, cargo hides most of this from you, but when you eventually link to a C library via FFI, the same vocabulary applies. Knowing it once carries forever.
You don’t need to write this in production
The opinionated take to leave you with: most of the time, you should not write C or Rust in production at your first job. You will have a Python or TypeScript codebase, you’ll be shipping features, and reaching for a low-level language for the wrong reason will cost the team weeks of pain.
What you need is the intuition. The intuition that a Python list is a heap-allocated buffer that doubles in size when full. The intuition that a function call has a fixed cost in stack frames and register saves. The intuition that “memory leak” is a real, mechanical thing, not a vague metaphor. That intuition is what this module gives you, and it pays off in every other language you’ll write for the rest of your career.
Going deeper
When you have specific questions, in this order:
Rust
- Rustonomicon. link · the dark arts of unsafe Rust. Once you’ve built MyVec, this is the next chapter of the same story.
- Programming Rust — Blandy, Orendorff, Tindall. book · the encyclopedic second-pass book.
- Crust of Rust — Jon Gjengset. playlist · advanced topics, taught well.
C
- The Linux Programming Interface — Kerrisk. book · what to read after K&R.
- Computer Systems: A Programmer’s Perspective. book · how the machine actually works under your C.
- CS:APP Lab assignments. link · bomb lab, malloc lab, shell lab — the real CMU labs, free.
Checkpoints
If any one wobbles, the corresponding section above is what to reread.
- Why pick Rust over C in 2026 for a learner? Give the concrete trade-off.
- Draw the memory layout of a process from memory. Where does
mallocallocate? Where does a localint x = 5live? - What is a pointer, in one sentence? What’s the difference between a pointer in C and a reference in Rust?
- Walk through the cost of
MyVec::push(ormyvec_push) when the buffer has to grow. What happens, in order? - Show your build (MyVec or myvec.c) running cleanly —
cargo testandcargo miri testfor Rust, or./testplusvalgrind --leak-check=fullfor C.
If all five hold and your build runs clean, you’ve earned 01.5. Next: 02.1 — Data structures, built not memorized. Now that you’ve felt memory directly, the data-structures module hits much harder.