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
syscallEverything
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
syscallThere 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
syscallSixteen 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
syscallThree 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
syscallA 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
syscallUsing
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
syscallA
&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
syscallRust 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
syscallThis 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
syscallA 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
syscallchecked_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
syscallThe 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
syscallThe 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
syscallA 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
syscallA 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
syscallReturning 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
syscallThis 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
syscallTwo 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
syscallThis 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
syscallThe 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
syscallWith 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
syscallThere 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
syscallNote 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
syscallField 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
syscallSlicing 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
syscallA
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
syscallThe 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
syscallAlignment 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
syscallunsafe 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.