PONYλM2Modula-2

Rust.CodeCompared.To/Assembly

An interactive executable cheatsheet comparing Rust and Assembly

Rust 1.97.1 x86-64 (NASM 3.01)
Output & Running It
Hello, World
An assembly program has no main. The linker looks for a symbol called _start, and that is where the process begins — there is no runtime that ran before it and no runtime that cleans up after it. db declares bytes; 10 is a newline. The equ $ - message line computes the length at assemble time: $ means "the address here", so subtracting the start of the string gives its byte count.
fn main() { println!("Hello, World!"); }
global _start section .data message: db "Hello, World!", 10 length: equ $ - message section .text _start: mov rax, 1 ; syscall 1 = write mov rdi, 1 ; fd 1 = stdout mov rsi, message ; the address of the bytes mov rdx, length ; how many bytes to write syscall mov rax, 60 ; syscall 60 = exit xor rdi, rdi ; status 0 syscall
Everything println! does is visible here as the thing it is missing. The macro locks stdout, formats into a buffer, writes, and flushes on exit; it also appends the newline that the assembly version has to spell out as the byte 10. The four movs are the entire write, and the two after it are the only reason the process stops rather than running off the end of the section.
Returning an Exit Status
Rust's main returning () means status 0; std::process::exit sets it explicitly and does not run destructors. In assembly the status is simply whatever is in rdi when the exit syscall happens, and nothing else is consulted.
use std::process; fn main() { println!("about to exit with 3"); process::exit(3); }
global _start section .data message: db "about to exit with 3", 10 length: equ $ - message section .text _start: mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, length syscall mov rax, 60 ; exit mov rdi, 3 ; ...with status 3 syscall
There is no way to "fall off the end" of an assembly program safely. Without that final exit syscall the processor keeps executing whatever bytes follow in the .text section and the process dies on an illegal instruction. Rust returning from main is a function return into runtime code that then calls exit for you.
What println!("{}", n) Hides
This is the only row that spells out number-to-text conversion in full; later rows print one or two digits so the conversion stays three lines. div rcx divides the 128-bit value rdx:rax by rcx, leaving the quotient in rax and the remainder in rdx — so rdx must be zeroed first, or the dividend is garbage. Digits come out least-significant first, which is why the buffer is filled backwards from its end.
fn main() { let value: u64 = 31415; println!("{}", value); }
global _start section .bss buffer: resb 20 section .text _start: mov rax, 31415 lea rsi, [buffer + 20] ; one past the end — we fill backwards mov rcx, 10 dec rsi ; make room for the newline first mov byte [rsi], 10 convert: xor rdx, rdx ; clear the high half of the dividend div rcx ; rax = rax / 10, rdx = rax % 10 add dl, '0' ; a digit 0-9 becomes the character '0'-'9' dec rsi mov [rsi], dl test rax, rax ; anything left? jnz convert lea rdx, [buffer + 20] sub rdx, rsi ; length = end - start mov rax, 1 mov rdi, 1 syscall mov rax, 60 xor rdi, rdi syscall
Sixteen instructions, one division per digit, and a buffer sized by hand. println!("{}", value) is a macro that expands into a formatting machine doing exactly this, plus width and padding and locale-independent handling of every integer width and sign. Notice also that nothing here reports failure — a buffer too small would simply write past it.
There Are No Bindings, Only Registers
let Becomes a Register
There are sixteen general-purpose registers and they have fixed names. A let does not exist down here: the value goes into a register, and remembering which register currently holds which of your ideas is your job, not the assembler's.
fn main() { let first = 10i64; let second = 32i64; let total = first + second; println!("{}", total); }
global _start section .bss output: resb 3 section .text _start: mov rax, 10 ; let first = 10 mov rbx, 32 ; let second = 32 add rax, rbx ; let total = first + second xor rdx, rdx ; print the two-digit result mov rcx, 10 div rcx ; rax = 4, rdx = 2 add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Three named bindings became three registers, and the names are gone. Rust's let also does not allocate anything by itself — an optimized build of this program keeps all three in registers exactly like the assembly does. The difference is that Rust will tell you when you use a binding that no longer holds what you think; nothing here will.
One Register, Four Widths
Rust has distinct types for distinct widths — u64, u32, u16, u8 — and as converts between them. Assembly has one register with four names: rax is all 64 bits, eax the low 32, ax the low 16, and al the low 8. They are not four registers; they are four windows onto the same storage.
fn main() { let mut value: u64 = 7; value += 1; let low_byte = value as u8; println!("{}", low_byte); }
global _start section .bss output: resb 2 section .text _start: mov rax, 7 add rax, 1 ; rax = 8 ; value as u8 is not a conversion and costs nothing. ; AL *is* the low byte of RAX — the same storage, read narrower. add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 2 syscall mov rax, 60 xor rdi, rdi syscall
A truncating as cast in Rust is usually free for the same reason: the bits were already there and the narrower type just stops looking at the rest. The direction that costs something is widening a signed value, where the sign bit has to be smeared across the new high bits — that is the movsx instruction, and it exists precisely because reading al into rax would otherwise leave the top 56 bits stale.
Ownership Leaves No Trace
A Move Is a mov, Or Nothing At All
A small struct is not passed as an address — the ABI splits it across registers, so a two-field Point arrives as two integers in rdi and rsi. Watch for what is not in the assembly: there is no flag recording that point was given away.
struct Point { x: i64, y: i64, } fn consume(point: Point) -> i64 { point.x + point.y } fn main() { let point = Point { x: 40, y: 2 }; let total = consume(point); println!("{}", total); }
global _start section .bss output: resb 3 section .text ; consume(x in rdi, y in rsi) -> rax consume: mov rax, rdi add rax, rsi ret _start: ; let point = Point { x: 40, y: 2 }; ; consume(point) — the entire "move" is these two movs. mov rdi, 40 mov rsi, 2 call consume xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Using point after the call is a compile error in Rust and is physically impossible to detect here — rdi and rsi still hold 40 and 2 after consume returns, and nothing marks them as spent. That is the clearest statement of what ownership is: a rule the compiler enforces on the source and then discards. The binary is not protected by it; the binary never needed to be, because the program that could violate it was rejected.
A Borrow Is an Address
Square brackets mean "the contents of" — add [rdi], rsi adds rsi into the eight bytes living at the address in rdi. This is the exact inverse of Rust, where a name means its value and & is what gets you the address.
fn add_to(target: &mut i64, amount: i64) { *target += amount; } fn main() { let mut total = 40i64; add_to(&mut total, 2); println!("{}", total); }
global _start section .data total: dq 40 ; an 8-byte value in memory section .bss output: resb 3 section .text ; add_to(target address in rdi, amount in rsi) add_to: add [rdi], rsi ; *target += amount ret _start: lea rdi, [total] ; &mut total — take the address mov rsi, 2 call add_to mov rax, [total] ; read it back: 42 xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
A &mut i64 and a &i64 compile to the identical thing: one address in one register. The distinction between them, the lifetime attached to them, and the rule that only one &mut may exist at a time are all compile-time fictions with no representation in the instruction stream. Two &muts to the same address here would work exactly as badly as you would expect, and nothing would say so.
References Are Addresses
lea: Address-Of Without a Memory Access
lea ("load effective address") computes the address a bracket expression would read from, and hands you the number instead of the contents. mov rax, [value] and lea rax, [value] differ by exactly one memory access — which is the difference between *reference and &value.
fn main() { let value: i64 = 7; let reference: &i64 = &value; println!("{}", *reference); }
global _start section .data value: dq 7 section .bss output: resb 2 section .text _start: lea rbx, [value] ; let reference = &value — the ADDRESS mov rax, [rbx] ; *reference — the CONTENTS add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 2 syscall mov rax, 60 xor rdi, rdi syscall
Rust makes you write & to take an address and * to follow one, and then quietly inserts most of the *s for you through auto-deref. Down here neither is implicit: a bare label is a number, brackets are a memory access, and forgetting the brackets gives you an address where you wanted a value with no type error to stop you.
Pointer Arithmetic Is Not Scaled For You
Rust's .add(1) on a raw pointer moves forward by one element — the compiler multiplies by size_of::<T>() on your behalf. Assembly moves forward by one byte, so an i64 array steps by 8 and you write the 8.
fn main() { let values: [i64; 3] = [10, 20, 30]; let pointer = values.as_ptr(); let second = unsafe { *pointer.add(1) }; println!("{}", second); }
global _start section .data values: dq 10, 20, 30 section .bss output: resb 3 section .text _start: lea rbx, [values] ; pointer.add(1) in Rust means "one element on". ; Here you say how many BYTES that is. mov rax, [rbx + 8] ; the second element xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
This is the clearest demonstration that a Rust pointer is an address plus a type. The type is what supplies the 8. Change the array to [i32; 3] and the Rust line does not change at all while the assembly must become [rbx + 4] — the knowledge moved from the code into the type system, which is where Rust keeps it.
Arithmetic, Overflow & The Flags Register
Wrapping Is What the Machine Already Does
Rust makes you name the behavior you want on overflow: wrapping_add, checked_add, saturating_add. The machine has only one of those, and it is the first — an 8-bit add keeps the low 8 bits of the answer and puts the bit that did not fit into the carry flag.
fn main() { let big: u8 = 250; let wrapped = big.wrapping_add(10); println!("{}", wrapped); }
global _start section .bss output: resb 2 section .text _start: mov al, 250 add al, 10 ; 260 does not fit in 8 bits — AL keeps 4 add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 2 syscall mov rax, 60 xor rdi, rdi syscall
A plain + in a Rust debug build would panic here, and that panic is not free: it is a conditional jump on the carry flag plus a call into the panic machinery, emitted after every arithmetic operation. A release build removes the check and the program behaves exactly like this assembly. That is the one place where "zero-cost" is a choice made by a build flag rather than a property of the language.
The Flags Nobody Declares
Every arithmetic instruction quietly updates a flags register that nothing in the source mentions. sub sets the carry flag when the subtraction borrowed, and jc jumps if it did — that pair is the whole of checked_sub.
fn main() { let smaller: u64 = 3; let larger: u64 = 10; match smaller.checked_sub(larger) { Some(value) => println!("{}", value), None => println!("underflow"), } }
global _start section .data underflow_message: db "underflow", 10 underflow_length: equ $ - underflow_message section .text _start: mov rax, 3 sub rax, 10 ; borrows, so CF = 1 — and nothing said CF existed jc report_underflow ; the Some(value) arm would print rax here mov rax, 60 xor rdi, rdi syscall report_underflow: mov rax, 1 mov rdi, 1 mov rsi, underflow_message mov rdx, underflow_length syscall mov rax, 60 xor rdi, rdi syscall
checked_sub returning an Option is this carry flag, lifted into a value the type system can see and force you to handle. The flag itself is invisible, unnamed, and overwritten by the very next arithmetic instruction — which is why the jump has to come immediately after the sub and why interleaving an unrelated add between them silently breaks the test.
Control Flow: cmp and jump
if / else
cmp is a subtraction that throws away the result and keeps only the flags. The conditional jump that follows reads those flags — so cmp and its jump are one thought split across two instructions, and the jump is named for the comparison you meant, not for the flag it reads.
fn main() { let value = 7; if value > 5 { println!("big"); } else { println!("small"); } }
global _start section .data big_message: db "big", 10 big_length: equ $ - big_message small_message: db "small", 10 small_length: equ $ - small_message section .text _start: mov rax, 7 cmp rax, 5 jle print_small ; jump if NOT greater — the condition is inverted mov rsi, big_message mov rdx, big_length jmp print print_small: mov rsi, small_message mov rdx, small_length print: mov rax, 1 mov rdi, 1 syscall mov rax, 60 xor rdi, rdi syscall
The condition is inverted, which is the single most common source of confusion when reading compiler output: source code says "if this is true, do the block", and the machine says "if this is false, skip the block". Note also that the two arms had to be arranged so control can rejoin — Rust's if is an expression with one value and one exit, and here you build that yourself out of a jump.
A Counted Loop
A for over a range has no counterpart here: there is a register holding a number, an instruction that raises it, and a jump backwards while a comparison still holds. rbx is used for the counter because syscall destroys rcx and r11 — a counter in rcx would be silently wrecked by the write inside the loop.
fn main() { for index in 0..5 { println!("{}", index); } }
global _start section .bss digit: resb 2 section .text _start: xor rbx, rbx ; index = 0 next: mov rax, rbx add al, '0' mov [digit], al mov byte [digit + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, digit mov rdx, 2 syscall ; destroys rcx and r11 — rbx survives inc rbx cmp rbx, 5 jl next mov rax, 60 xor rdi, rdi syscall
The range 0..5 is an iterator in Rust — a struct with a start and an end and a next method — and in a release build it compiles to precisely this: a counter, an increment, a compare, a jump. Nothing of the Iterator trait survives. The choice of rbx over rcx is the kind of thing the register allocator does for you thousands of times per program without mentioning it.
Functions, The Stack & The Calling Convention
call and ret
call pushes the address of the next instruction onto the stack and jumps; ret pops that address and jumps back. The return address is data sitting in memory, which is why the stack has to be exactly where the function expects when it returns.
fn double(value: i64) -> i64 { value * 2 } fn main() { println!("{}", double(21)); }
global _start section .bss output: resb 3 section .text ; double(value in rdi) -> rax double: mov rax, rdi imul rax, 2 ret ; pops the return address and jumps to it _start: mov rdi, 21 call double ; pushes the address of the next instruction xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
A function is a label plus an agreement. Nothing checks that double takes one argument, that it takes it in rdi, or that it returns anything at all — ret with garbage in rax assembles and links happily. Rust's signature is a machine-checked statement of an agreement that at this level is pure convention.
Passing Arguments
The System V ABI names six registers for integer and pointer arguments, in order: rdi, rsi, rdx, rcx, r8, r9. The return value comes back in rax. A seventh argument would go on the stack.
fn combine(first: i64, second: i64, third: i64) -> i64 { first + second * third } fn main() { println!("{}", combine(2, 5, 8)); }
global _start section .bss output: resb 3 section .text ; combine(first in rdi, second in rsi, third in rdx) -> rax combine: mov rax, rsi imul rax, rdx add rax, rdi ret _start: mov rdi, 2 mov rsi, 5 mov rdx, 8 call combine ; 2 + 5 * 8 = 42 xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
A parameter list is a promise about registers, and it is a promise nobody enforces. Calling combine having set only rdi is not an error — the function reads whatever rsi and rdx happened to contain. In Rust the same mistake cannot be spelled; the compiler will not emit a call whose arity is wrong.
A Stack Frame By Hand
When a function has more live values than registers, the extras go on the stack. The three-instruction opening — push the old frame pointer, point rbp at the current top, then lower rsp to reserve space — is a stack frame, and the reserved slots are addressed as negative offsets from rbp. The stack grows downward, which is why reserving space subtracts.
fn sum_of_three() -> i64 { let first = 20i64; let second = 14i64; let third = 8i64; first + second + third } fn main() { println!("{}", sum_of_three()); }
global _start section .bss output: resb 3 section .text sum_of_three: push rbp ; save the caller's frame pointer mov rbp, rsp ; this frame starts here sub rsp, 24 ; room for three 8-byte locals mov qword [rbp - 8], 20 ; let first mov qword [rbp - 16], 14 ; let second mov qword [rbp - 24], 8 ; let third mov rax, [rbp - 8] add rax, [rbp - 16] add rax, [rbp - 24] mov rsp, rbp ; discard the locals pop rbp ; restore the caller's frame pointer ret _start: call sum_of_three xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Returning a &i64 to [rbp - 8] from this function would "work" almost every time, because mov rsp, rbp does not erase anything — it moves a number, and the bytes sit there until the next call overwrites them. That is the entire hazard Rust's lifetimes exist to prevent, and it is why the borrow checker has to be a compile-time analysis: at runtime there is nothing left to check.
Slices Carry a Length, And Get Checked
A Slice Is a Pointer and a Length
A &[i64] is not one value but two: an address and a count, passed in two registers. [rdi + rcx * 8] is a single addressing mode that multiplies the index by 8 and adds it to the base — the scale is part of the instruction, so indexing costs nothing extra.
fn sum(values: &[i64]) -> i64 { let mut total = 0; for value in values { total += value; } total } fn main() { let numbers = [10i64, 20, 12]; println!("{}", sum(&numbers)); }
global _start section .data numbers: dq 10, 20, 12 count: equ 3 section .bss output: resb 3 section .text ; sum(address in rdi, length in rsi) -> rax sum: xor rax, rax ; total = 0 xor rcx, rcx ; index = 0 accumulate: cmp rcx, rsi jge sum_done add rax, [rdi + rcx * 8] inc rcx jmp accumulate sum_done: ret _start: lea rdi, [numbers] ; the pointer half of the slice mov rsi, count ; the length half call sum xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
This is why &[T] is called a fat pointer and why size_of::<&[i64]>() is 16 rather than 8. Passing the length alongside the address is the same discipline a C programmer follows by hand and forgets; Rust made the pair a single type so the two halves cannot drift apart.
The Bounds Check, Written Out
Indexing a slice in Rust emits a comparison against the length and a jump to a panic path. jae jumps when the unsigned comparison says "above or equal" — one test catches both a too-large index and, because negatives wrap to enormous unsigned values, a nonsensical one. black_box hides its argument from the optimizer, and it is needed here for a telling reason: with a literal index the compiler evaluates the whole thing at compile time and refuses to build the program at all.
use std::hint::black_box; fn main() { let numbers = [10i64, 20, 30]; let index = black_box(5usize); if index < numbers.len() { println!("{}", numbers[index]); } else { println!("out of range"); } }
global _start section .data numbers: dq 10, 20, 30 count: equ 3 out_of_range_message: db "out of range", 10 out_of_range_length: equ $ - out_of_range_message section .text _start: mov rbx, 5 ; the index cmp rbx, count jae out_of_range ; this compare is what Rust emits for you ; the in-range path would read [numbers + rbx * 8] here mov rax, 60 xor rdi, rdi syscall out_of_range: mov rax, 1 mov rdi, 1 mov rsi, out_of_range_message mov rdx, out_of_range_length syscall mov rax, 60 xor rdi, rdi syscall
Two instructions per index is the honest price of Rust's memory safety, and it is the one part of the story that is not literally zero. It is also the part the optimizer works hardest to remove: iterating with for value in slice rather than by index lets the compiler prove the bound once instead of testing it every time, which is why the idiomatic form is usually the faster one. The black_box above is the proof of how hard it works — without it, rustc const-evaluates the index, sees a guaranteed panic, and rejects the program under the deny-by-default unconditional_panic lint rather than emitting any check at all.
Option Is a Layout Trick
Option<&T> Is One Register
test rax, rax is an AND that keeps only the flags, so it is the standard way to ask "is this zero?". Read the assembly asking where the Some/None tag is stored — the answer is that there isn't one.
fn main() { let missing: Option<&i64> = None; match missing { Some(value) => println!("{}", value), None => println!("none"), } }
global _start section .data ; The whole Option is this one 8-byte slot. ; Zero is the address no valid reference can ever hold, so zero means None. maybe_pointer: dq 0 none_message: db "none", 10 none_length: equ $ - none_message section .text _start: mov rax, [maybe_pointer] test rax, rax ; the entire match is this jnz have_value mov rax, 1 mov rdi, 1 mov rsi, none_message mov rdx, none_length syscall mov rax, 60 xor rdi, rdi syscall have_value: ; the Some(value) arm would read [rax] here mov rax, 60 xor rdi, rdi syscall
This is the niche optimization, and it is why size_of::<Option<&i64>>() is 8 — exactly the size of the reference it wraps. Because a &i64 is guaranteed never to be null, the null pattern is a spare value the layout can spend on None. Wrapping a pointer in Option costs nothing at all, which is the reason returning Option<&T> is idiomatic rather than a performance compromise.
Option<i64> Needs a Tag
Same Rust shape as the row above, with &i64 changed to i64 — and the layout changes completely. movzx loads a narrow value into a wide register and zeroes the rest, which is how you read a single tag byte into a full register.
fn main() { let missing: Option<i64> = None; match missing { Some(value) => println!("{}", value), None => println!("none"), } }
global _start section .data ; Option<i64> laid out by hand. The tag needs storage of its own because ; EVERY 64-bit pattern is a legitimate i64 — there is no spare value left ; over to mean None. option_tag: db 0 ; 0 = None, 1 = Some option_payload: dq 0 none_message: db "none", 10 none_length: equ $ - none_message section .text _start: movzx rax, byte [option_tag] test rax, rax jnz have_value mov rax, 1 mov rdi, 1 mov rsi, none_message mov rdx, none_length syscall mov rax, 60 xor rdi, rdi syscall have_value: ; the Some(value) arm would read [option_payload] here mov rax, 60 xor rdi, rdi syscall
The two rows print the same word and cost different amounts. Option<i64> is 16 bytes, not 9, because the payload must stay 8-byte aligned and the tag gets padded out to preserve that. This is the whole of what people mean by a niche: Option is free exactly when the wrapped type has an unused bit pattern to donate, and costs a word when it does not.
Enums & match Become Compares
match Is a Chain of Compares
A fieldless enum is stored as a small integer — its discriminant — assigned in declaration order starting at zero. Matching on it is a sequence of compare-and-jump pairs, one per arm, until something hits.
enum Color { Red, Green, Blue, } fn name(color: Color) -> &'static str { match color { Color::Red => "red", Color::Green => "green", Color::Blue => "blue", } } fn main() { println!("{}", name(Color::Green)); }
global _start section .data red_message: db "red", 10 red_length: equ $ - red_message green_message: db "green", 10 green_length: equ $ - green_message blue_message: db "blue", 10 blue_length: equ $ - blue_message section .text _start: mov rbx, 1 ; Color::Green — the second variant, so 1 cmp rbx, 0 je arm_red cmp rbx, 1 je arm_green jmp arm_blue arm_red: mov rsi, red_message mov rdx, red_length jmp print arm_green: mov rsi, green_message mov rdx, green_length jmp print arm_blue: mov rsi, blue_message mov rdx, blue_length print: mov rax, 1 mov rdi, 1 syscall mov rax, 60 xor rdi, rdi syscall
With three arms a compare chain is cheapest; with a dozen dense arms the compiler switches to a jump table — an array of addresses indexed by the discriminant, reached with one indirect jump instead of twelve compares. Either way, exhaustiveness is gone: the final jmp arm_blue is reached by anything that is not 0 or 1, including a 7 that should never have existed. Rust's guarantee that a match covers every variant is what makes that fallthrough safe to write.
Zero-Cost, Demonstrated
An Iterator Chain Is One Loop
The Rust side builds three chained iterator structs and consumes them with sum. test rbx, 1 checks the lowest bit, which is the cheapest possible even/odd test. Count the loops in the assembly: there is one.
fn main() { let numbers = [1i64, 2, 3, 4, 5, 6]; let total: i64 = numbers .iter() .filter(|value| *value % 2 == 0) .map(|value| value * 3) .sum(); println!("{}", total); }
global _start section .data numbers: dq 1, 2, 3, 4, 5, 6 count: equ 6 section .bss output: resb 3 section .text _start: lea rsi, [numbers] xor rax, rax ; total xor rcx, rcx ; index next: mov rbx, [rsi + rcx * 8] test rbx, 1 ; .filter(|value| *value % 2 == 0) jnz skip imul rbx, 3 ; .map(|value| value * 3) add rax, rbx ; .sum() skip: inc rcx cmp rcx, count jl next xor rdx, rdx ; total is 36 mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
There is no Filter, no Map, no intermediate collection, and no closure call — the three adapters became one test, one imul, and one add inside a single pass. This is the claim "zero-cost abstraction" makes, stated in the only terms that can settle it. The optimized build of the Rust column produces the same shape as the column beside it, which is why the idiomatic version is not the slow version.
Generics Are Copies, Not Lookups
Monomorphization means the compiler stamps out one machine-code copy of a generic function per concrete type it is used with. There is no type parameter at runtime and nothing is looked up — which is why the assembly below has two separate functions that differ only in one instruction.
fn largest<T: PartialOrd + Copy>(first: T, second: T) -> T { if first > second { first } else { second } } fn main() { let bigger_integer = largest(4i64, 7i64); let bigger_byte = largest(2u8, 1u8); println!("{} {}", bigger_integer, bigger_byte); }
global _start section .bss output: resb 4 section .text ; largest::<i64> — the 64-bit stamping largest_i64: mov rax, rdi cmp rdi, rsi jg largest_i64_done mov rax, rsi largest_i64_done: ret ; largest::<u8> — the same source, a different instruction width largest_u8: mov al, dil cmp dil, sil ja largest_u8_done mov al, sil largest_u8_done: ret _start: mov rdi, 4 mov rsi, 7 call largest_i64 ; 7 add al, '0' mov [output], al mov byte [output + 1], ' ' mov rdi, 2 mov rsi, 1 call largest_u8 ; 2 add al, '0' mov [output + 2], al mov byte [output + 3], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 4 syscall mov rax, 60 xor rdi, rdi syscall
Note that the two copies are not identical: the signed comparison uses jg and the unsigned one uses ja, because "greater" means different things for i64 and u8. A single shared implementation could not do that without being told the type at runtime, which is exactly the cost dyn Trait pays and generics do not. The trade is binary size — every instantiation is another copy on disk.
Structs Are Just Offsets
A Struct Is a Set of Offsets
A field name is a compile-time constant added to a base address. [rbx + 8] reaches the second 8-byte field — the name score exists only in the source, and the number 8 is what it becomes.
struct Record { identifier: i64, score: i64, } fn main() { let record = Record { identifier: 7, score: 42 }; println!("{}", record.score); }
global _start section .data ; struct Record { identifier: i64, score: i64 } record: dq 7 ; + 0 identifier dq 42 ; + 8 score section .bss output: resb 3 section .text _start: lea rbx, [record] mov rax, [rbx + 8] ; record.score xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
Field access is free — it is part of the addressing mode, not an extra instruction. What is not free is assuming you know the offsets: Rust reorders struct fields by default to minimize padding, so writing the 8 by hand against a plain struct is undefined behavior waiting to happen. Adding #[repr(C)] is the promise that the fields stay in declaration order, and it is what makes hand-written offsets like these legitimate.
Strings Without a Terminator
A Length, Not a Terminator
The write syscall takes a byte count and never looks for a terminator, so it is happy to print a run of bytes from the middle of a larger string. equ $ - message computes each length at assemble time, which is the same information a &str carries at runtime.
fn main() { let message = "Hello, World!"; let greeting = &message[0..5]; println!("{}", greeting); println!("{}", greeting.len()); }
global _start section .data message: db "Hello, World!", 10 greeting_length: equ 5 section .bss output: resb 2 section .text _start: mov rax, 1 mov rdi, 1 mov rsi, message ; the same address... mov rdx, greeting_length ; ...a smaller count syscall mov rax, 1 mov rdi, 1 mov rsi, message + 13 ; just the newline mov rdx, 1 syscall mov rax, greeting_length add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 2 syscall mov rax, 60 xor rdi, rdi syscall
Slicing a &str is this: the same address with a smaller length, and no copying. It is also why .len() on a Rust string is instant while strlen in C is a loop — the count was carried along rather than rediscovered. The one thing Rust adds on top is a check that the slice boundaries land on character boundaries, since a &str promises valid UTF-8 and these raw bytes promise nothing.
The Heap Without an Allocator
Box::new Without an Allocator
There is no allocator here, so there is no malloc to call — you ask the kernel directly. The brk syscall moves the end of the data segment: called with 0 it reports where the break currently is, and called with a higher address it moves it, handing you everything in between.
fn main() { let boxed = Box::new(42i64); println!("{}", *boxed); }
global _start section .bss output: resb 3 section .text _start: mov rax, 12 ; brk xor rdi, rdi ; 0 = "just tell me where the break is" syscall mov rbx, rax ; the old break is the start of our new memory lea rdi, [rax + 4096] ; ask for one more page mov rax, 12 syscall mov qword [rbx], 42 ; Box::new(42) — store into the new memory mov rax, [rbx] ; *boxed — read it back xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
A Box is an address in a register plus a promise about who frees it. The promise is the part that does not survive to this level: nothing here will ever give the page back, and nothing would complain if the address were used after it had been. Rust's Drop is a call the compiler inserts at the exact point ownership ends — it is ordinary code, generated from a rule, and there is no garbage collector anywhere in this picture.
Gotchas For Rust Developers
There Is No Panic
A Rust panic unwinds the stack, runs destructors, prints a message with a file and line, and exits with status 101. None of that is machinery the processor provides — it is Rust library code, and here there is none of it.
fn main() { let numbers = [10i64, 20, 30]; let index = 5; if index >= numbers.len() { eprintln!( "index out of bounds: the len is {} but the index is {}", numbers.len(), index ); std::process::exit(101); } println!("{}", numbers[index]); }
global _start section .data ; Everything a panic would have done, written out by hand. panic_message: db "index out of bounds: the len is 3 but the index is 5", 10 panic_length: equ $ - panic_message section .text _start: mov rbx, 5 cmp rbx, 3 jae panic mov rax, 60 xor rdi, rdi syscall panic: mov rax, 1 mov rdi, 2 ; fd 2 = stderr, where a panic goes mov rsi, panic_message mov rdx, panic_length syscall mov rax, 60 mov rdi, 101 ; the status a real panic exits with syscall
The message had to be a fixed string because formatting the length and the index into it would mean writing the number conversion again. That is the honest shape of the trade: a panic is expensive in binary size — every call site carries its message, its location, and its unwinding tables — which is exactly why embedded Rust reaches for panic = "abort" and #![no_std] to get back to something like this column.
The Stack Must Be 16-Byte Aligned
The System V ABI requires rsp to be a multiple of 16 at the point a call is made. Nothing checks this and most code does not care — until something uses an SSE instruction that faults on a misaligned address, at which point the crash lands nowhere near the mistake.
fn main() { // Rust maintains the ABI's stack alignment for you on every call. // The nearest thing to thinking about it is asking what a value's // alignment requirement actually is: println!("{}", std::mem::align_of::<f64>()); println!("{}", std::mem::size_of::<[f64; 2]>()); }
global _start section .data ; align_of::<f64>() and size_of::<[f64; 2]>() are both constants the ; assembler could compute, so here they are simply written down. message: db "8", 10, "16", 10 length: equ $ - message section .text report: ret _start: ; At _start, rsp is 16-byte aligned. A call pushes 8 bytes of return ; address, so INSIDE a function rsp is 8 past alignment — which is why ; the standard prologue's push rbp brings it back to a multiple of 16. push rbp ; 8 + 8 = aligned again mov rbp, rsp call report ; safe to call from here mov rsp, rbp pop rbp mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, length syscall mov rax, 60 xor rdi, rdi syscall
Alignment is the clearest example of a rule that is real, load-bearing, and completely invisible in the source of every language above this one. Rust tracks the alignment of every type, pads every struct to satisfy it, and maintains the stack invariant on every call without ever mentioning it — and align_of is the only place the concept surfaces at all.
unsafe Removes a Check That Was Never Here
Reading past the end of an array is a panic in safe Rust, undefined behavior under unsafe, and simply a load in assembly. The row deliberately reads one element past a three-element array — in this layout that lands on the next declared value, which is why it prints something rather than crashing.
fn main() { let numbers = [10i64, 20, 30]; let sentinel = 7i64; // get_unchecked skips the bounds check. Index 2 is the last valid // element; reading index 3 would be undefined behavior, so the value // that lives past the end has to be named honestly instead. let last = unsafe { *numbers.get_unchecked(2) }; println!("{}", last + sentinel); }
global _start section .data numbers: dq 10, 20, 30 sentinel: dq 7 ; whatever happens to be declared next section .bss output: resb 3 section .text _start: lea rbx, [numbers] mov rax, [rbx + 16] ; the third element — index 2 add rax, [rbx + 24] ; one past the end: the machine does not object xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output], al mov [output + 1], dl mov byte [output + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 3 syscall mov rax, 60 xor rdi, rdi syscall
unsafe does not switch off a runtime guard, because at this level there was never a guard to switch off. It switches off the compiler's refusal to emit code it cannot prove correct, handing you the same latitude this column has always had. The keyword marks the places where the proof is yours to carry — which is why an unsafe block is a claim, not an escape.