← Back to Course
# Leaving `std`: `no_std` and Bare-Metal Rust ## CS 326 Operating Systems L09 · Raw pointers, `unsafe`, volatile MMIO, and the cliff --- ## Learning Objectives - Distinguish a raw pointer from a reference by the guarantees each carries - Enumerate the five operations `unsafe` unlocks — and what it does **not** change - Explain why making a pointer is safe but dereferencing (and `.add`) is not - Predict the machine code emitted for volatile vs non-volatile device access - Describe the `core` / `alloc` / `std` layering and the `no_std` skeleton - Decode `riscv64gc-unknown-none-elf` field by field --- ## The Transition Everything so far ran **as a process**: something loaded it, gave it a stack, called `main`, and caught its mistakes. | Before | After | |--------|-------| | a red test with a diff | a machine that prints nothing and never stops | | `panic!` prints a backtrace | `panic!` prints what *your* handler prints | | the debugger is `println!` | the debugger is GDB attached to QEMU | | the OS catches your bad pointer | your bad pointer **is** the OS | Today's exercises: **21r_unsafe_bridge**, then **30k_kernel_basics**. --- ## What Rust Actually Promises Not "Rust programs cannot crash". It is:
A program containing no
unsafe
cannot exhibit undefined behavior —
provided the unsafe code beneath it is correct
.
- Safe Rust is a proof; every proof rests on axioms - The axioms are `unsafe` blocks somewhere underneath - `Vec` = raw pointer + len + cap, held together by `unsafe` - A kernel is the same shape — except the unsafe core is large, and **you** write it --- ## Some Memory Is Not Yours to Name ```text a store instruction: sb a1, 0(a0) | address bus | +--------------+--------------+ | | | 0x0200_0000 0x1000_0000 0x8000_0000 CLINT UART RAM (timer regs) (serial port) (128 MiB) | | | a knob on a a knob on a a byte that timer chip serial chip remembers ``` Same instruction. Only the **number** differs. That is memory-mapped I/O. --- ## Why a Reference Cannot Name the UART A `&mut u8` is a compile-time claim that the compiler *knows*: - non-null, aligned, points at a live initialized `u8` - **no other reference to that byte exists** while this one lives The compiler can only claim that about memory it can account for — a `let`, a field, an allocation it can see.
It cannot account for a chip. So Rust offers a second, humbler pointer that promises nothing.
--- ## Raw Pointers ```rust const UART0: usize = 0x1000_0000; // memlayout.rs:17 let p = UART0 as *mut u8; // safe: nothing happened let n: *mut Run = ptr::null_mut(); // kalloc.rs:11 — safe ``` - `*mut u8` is **one type name**: "raw pointer to a `u8` I may write through" - The `*` is spelling, not an operation - `ptr::null_mut()` is a `const fn` → works in `static` initializers - Test with `.is_null()` (`kalloc.rs:42`, `vm.rs:63`) --- ## Reference vs Raw Pointer | | `&T` / `&mut T` | `*const T` / `*mut T` | |---|---|---| | Guaranteed non-null | yes | no | | Guaranteed aligned | yes | no | | Points at live data | yes | no | | Aliasing restricted | yes | no | | Carries a lifetime | yes | no | | Borrow checker sees it | yes | never | | Creating one is safe | yes | **yes** | | Dereferencing is safe | yes | **no** | Memorize the last two rows. --- ## `.add(n)` Scales by the Pointee ```rust const fn px(level: usize, va: usize) -> usize { // vm.rs:44 (va >> (12 + level * 9)) & 0x1ff // an index, 0..=511 } let pte = table.add(px(level, va)); // vm.rs:55 ``` - `Pte` is `#[repr(transparent)]` over `usize` → one element = **8 bytes** - `table.add(511)` → byte offset 4088, the last entry of a 4 KiB page table - `(table as *mut u8).add(511)` → 4081 bytes too early, mid-entry, silent corruption --- ## `.add` Is Itself Unsafe: Provenance
The result must stay inside the
same allocated object
as the input. Computing an out-of-object address is UB even if you never load or store through it.
- A pointer carries an invisible tag naming the allocation it came from - For the kernel: "same object" usually means "same page" or "same register block" - Dereferencing: `(*p).field` — Rust has no `->`, and auto-deref does not apply --- ## `unsafe` Unlocks Exactly Five Operations 1. Dereference a raw pointer 2. Call an `unsafe fn` or an `extern` function 3. Read or write a `static mut` 4. Implement an `unsafe` trait 5. Read a `union` field In rv6: #1 and #2 constantly (`vm.rs:56`, `swtch.rs:35`), #3 in the allocator and console (`kalloc.rs:37`, `console.rs:20-24`), #4 twice (`spinlock.rs:12`, `kheap.rs:22`), #5 never. --- ## What `unsafe` Does **Not** Do | It does NOT | Consequence | |---|---| | Disable the borrow checker | `E0499` still fires inside `unsafe { }` | | Disable lifetimes | outliving data is still a compile error | | Disable type checking | you still need `as` casts | | Turn off bounds checks | `v[i]` still panics | | Make UB legal | it makes UB *possible* | | Mean "dangerous code" | it means "I checked what the compiler cannot" | --- ## Proof: Two Errors, One Block ```rust let p = base.add(5); // (1) unsafe { *p = 0x20; let a = &mut regs[0]; let b = &mut regs[1]; // (2) *a = 1; *b = 2; } ``` - **(1)** `error[E0133]: call to unsafe function ...add is unsafe` — nothing was dereferenced - **(2)** `error[E0499]: cannot borrow regs[_] as mutable more than once` — *inside* `unsafe` --- ## `unsafe` Is a Promise, Not a Switch - `unsafe fn f(..)` — *"calling me has a precondition; you must satisfy it"* - `kalloc::kfree` (`kalloc.rs:34`), `vm::walk` (`vm.rs:52`) - `unsafe { ... }` — *"I have satisfied it"*
If you cannot state the promise in one sentence, you do not know whether it is true. Keep the block as small as the operation.
--- ## C Has All of This — Invisibly ```c void kfree(void *pa) { struct run *r = (struct run*)pa; r->next = kmem.freelist; kmem.freelist = r; } ``` Every line is *exactly* as unsafe as rv6's `kfree`. C has no way to say so.
Rust does not make kernel programming safe. It makes the unsafe parts
greppable
: "show me every place that could corrupt memory" has a finite answer.
--- ## The Shape: Safe Wrapper, Unsafe Core
flowchart TD A["Safe caller code\nconsole.rs, shell.rs, fs.rs"] --> B["Safe wrapper\nuart::putc (uart.rs:48)"] B --> C["Unsafe core\nreg_write (uart.rs:22)"] C --> D["write_volatile to 0x1000_0000"] D --> E["NS16550A UART"]
**Sound** = no legal call from safe code can cause UB. --- ## Evidence 1: The Deleted Store
The optimizer's one assumption: memory changes only when your program changes it. True of RAM.
False of a device.
```rust pub unsafe fn plain_hi() { *THR = b'h'; *THR = b'i'; } pub unsafe fn volatile_hi() { write_volatile(THR, b'h'); write_volatile(THR, b'i'); } ``` ```asm plain_hi: volatile_hi: lui a0, 65536 lui a0, 65536 li a1, 105 li a1, 104 # 'h' sb a1, 0(a0) sb a1, 0(a0) ret li a1, 105 # 'i' sb a1, 0(a0) ret ``` One store survives. The `'h'` is a dead store — deleted, and gone off the wire. --- ## Evidence 2: The Hoisted Load ```asm plain_wait: volatile_wait: lui a0, 65536 lui a0, 65536 lbu a1, 5(a0) .LBB3_1: andi a1, a1, 32 lbu a1, 5(a0) bnez a1, .LBB1_2 andi a1, a1, 32 .LBB1_1: beqz a1, .LBB3_1 j .LBB1_1 li a1, 120 .LBB1_2: sb a1, 0(a0) ... ret ```
j .LBB1_1
— a branch to itself. Loop-invariant code motion turned "wait until ready" into "if not ready now, hang forever". No panic. No fault.
--- ## What `volatile` Means `read_volatile` / `write_volatile` declare an access **observable**: - perform it exactly once, exactly where written - do not delete, duplicate, merge, or reorder past another volatile access Some reads *do something*: the PLIC claim register returns the pending IRQ **and marks it claimed** (`plic.rs:33`). The rule is mechanical: **device register → volatile; ordinary memory → plain `*p`.** Every MMIO touch in rv6 obeys it — `uart.rs:19`, `plic.rs:24-28`, `start.rs:61-62`, `testdev.rs:19`.
Not atomicity. Not ordering against normal memory. Not synchronization between harts — that is
core::sync::atomic
and exercise 37k's locks.
--- ## The Cliff: `#![no_std]` - Removes the standard library and every assumption of an OS underneath - `main.rs:1` in rv6 — the first line of the kernel - You keep `core`; you lose files, threads, time, `println!`, `HashMap` - The kernel **is** the operating system; there is nothing below it to ask --- ## `core` / `alloc` / `std`
flowchart TD S["std — requires an OS\nprintln!, HashMap, std::fs, std::thread"] A["alloc — requires a global allocator\nBox, Vec, String, Arc, BTreeMap"] C["core — requires nothing\nOption, Result, slices, iterators, ptr, asm, atomics"] S --> A --> C C --> H["the bare machine"]
`std::ptr::write_volatile` **is** `core::ptr::write_volatile`. --- ## Getting `alloc` Back ```rust unsafe impl GlobalAlloc for KernelHeap { ... } // kheap.rs:22 #[global_allocator] static ALLOCATOR: KernelHeap = KernelHeap; // kheap.rs:40 extern crate alloc; // main.rs:26 ``` - `no_std` does not forbid a heap — it refuses to invent one for you - rv6's heap is built on the page allocator you write in exercise 32k - `HashMap` still never returns: it seeds its hasher from OS entropy --- ## The Skeleton ```rust #![no_std] // main.rs:1 — drop std, keep core #![no_main] // main.rs:2 — drop the C-runtime entry shim #[panic_handler] // main.rs:281 — exactly one, returns ! fn panic(_info: &PanicInfo) -> ! { uart::puts("OSLINGS:FAIL (panic)\n"); testdev::exit_failure(1); } #[no_mangle] // keep the symbol name verbatim pub extern "C" fn kmain() -> ! { ... } // main.rs:96 — RISC-V C ABI ``` Entry point comes from the linker script: `ENTRY(_entry)` (`kernel.ld:12`). --- ## Learn the Errors, Not the Incantations | Omission | What `rustc` says | |---|---| | no `#![no_std]` | `error[E0463]: can't find crate for std` | | no panic handler | `#[panic_handler] function required, but not found` | | no `#![no_main]`, has `main` | `using fn main requires the standard library` | | no `#![no_main]`, no `main` | `error[E0601]: main function not found in crate` | | unwinding enabled | `language item required, but not found: eh_personality` | --- ## Reading the Target Triple ```text riscv64 gc -unknown -none -elf | | | | | | | | | +-- bare ELF objects, no libc | | | +----------- OPERATING SYSTEM: none. | | | You are about to be it. | | +-------------------- vendor: unspecified | +------------------------------- ISA extensions: G and C +-------------------------------------- base ISA: 64-bit RISC-V ``` Set once in `rv6/.cargo/config.toml:4`. --- ## Decoding `gc` | Letter | Extension | What it gives you | |---|---|---| | `I` | base integer | loads, stores, branches, ALU | | `M` | mul/div | `mul`, `div`, `rem` | | `A` | atomics | `lr`/`sc`, `amoswap` — exercise 37k | | `F`, `D` | float/double | plus the `lp64d` ABI | | `C` | compressed | 16-bit encodings | | `Zicsr` | CSR access | `csrr`/`csrw` — `satp`, `mstatus`, `mepc` | | `Zifencei` | `fence.i` | after writing code into memory | Without **Zicsr** there is no paging, no traps, no privilege transitions. --- ## Ask the Compiler ```bash $ rustc --print cfg --target riscv64gc-unknown-none-elf panic="abort" target_arch="riscv64" target_env="" target_os="none" target_pointer_width="64" target_vendor="unknown" ```
target_os="none"
— and no
target_family="unix"
line at all. No family, no libc, and the target metadata records
"std": false
.
`rustup target add` installs `core` and `alloc`. It cannot install `std`. --- ## Two QEMUs — Only One Works
qemu-system-riscv64
emulates a
machine
: CPU, RAM, UART, timer, PLIC. Your kernel is the only software on it.
- `qemu-riscv64` (user mode) emulates a **process** and translates Linux syscalls - A `-none-` binary makes no syscalls — nothing to translate - It also does not exist on macOS - We use `qemu-system-riscv64 ... -bios none -kernel` — *we* are the firmware --- ## Friday, October 2: Two Exercises **21r_unsafe_bridge** — raw pointers, `unsafe`, `.add`, volatile register access, and a safe wrapper. Still `cargo test` on your laptop. **30k_kernel_basics** — two inner attributes and a panic handler; the reward is a binary that compiles for `riscv64gc-unknown-none-elf`. No QEMU yet — booting is `31k_boot` on Thursday, October 8; L10, Thursday's reading, tells the story.
rv6's
unsafe
stays concentrated in the allocator, page-table walk, drivers, context switch, and trap path. Before you write it:
name the promise in one sentence.
--- ## Summary 1. **Safe Rust is a proof resting on unsafe axioms** — and you write the axioms now 2. **A raw pointer is an address**; a reference is an address plus enforced claims 3. **Making one is safe; deref and `.add` are not** — `.add(n)` scales by `size_of::
()` 4. **`unsafe` permits five operations and changes nothing else** — `E0499` still fires 5. **MMIO without `volatile` is meaningless** — deleted stores, `j` to itself 6. **`#![no_std]`, `#![no_main]`, `#[panic_handler]`** — and the OS field is `none` because you are about to be it