PONYλM2Modula-2

Ruby.CodeCompared.To/Assembly

An interactive executable cheatsheet for Rubyists learning Assembly

Ruby 4.0 x86-64 (NASM 3.01)
Output & Running It
Hello, World
An assembly program has no main and no interpreter. The linker looks for a symbol called _start and that is where the process begins — nothing ran before it. db declares bytes and 10 is a newline. equ $ - message computes the length at assemble time: $ means "the address here", so subtracting the string's start gives its byte count.
puts "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
One line against fourteen, and the ratio is the point rather than an embarrassment. puts looks up a method on an object, converts its argument to a string, appends the newline it noticed was missing, and writes through a buffered IO object — and every one of those steps is machinery that has to exist somewhere. Here it does not exist at all, so the newline is the literal byte 10 and the write is four registers and an instruction.
Returning an Exit Status
A Ruby script that runs off the end exits 0; exit 3 raises SystemExit, unwinds, runs at_exit handlers, and only then stops. In assembly the status is simply whatever is in rdi when the exit syscall happens.
puts "about to exit with 3" 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 last syscall the processor keeps executing whatever bytes follow in the .text section and the process dies on an illegal instruction. Ruby's exit being an exception is what lets ensure blocks and at_exit run on the way out; nothing here gets a say.
What puts(number) Hides
This is the only row that spells number-to-text conversion out 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. Digits come out least-significant first, which is why the buffer fills backwards from its end.
value = 31415 puts 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 and one division per digit. puts value calls to_s, which allocates a new String object, which reserves heap memory, which the garbage collector will later have to account for — and the conversion inside it is this same division loop, written once in C and shared by every Ruby program ever run. Notice too that nothing here reports failure: a buffer too small would simply write past it.
Registers Hold Numbers, Not Objects
There Is No Object Here
In Ruby every value answers to methods, including integers — 42.class is Integer, and 42.even? is a real method call. A register holds sixty-four bits and answers to nothing at all.
value = 42 puts value.class puts value.even?
global _start section .data ; The whole "class" and "even?" question, answered at assemble time. class_message: db "Integer", 10 class_length: equ $ - class_message true_message: db "true", 10 true_length: equ $ - true_message section .text _start: mov rax, 1 mov rdi, 1 mov rsi, class_message mov rdx, class_length syscall ; there is no class to ask — this is a fixed string mov rbx, 42 test rbx, 1 ; "even?" is one instruction on the low bit jnz done mov rax, 1 mov rdi, 1 mov rsi, true_message mov rdx, true_length syscall done: mov rax, 60 xor rdi, rdi syscall
The Integer in the assembly column is a lie told with a string literal, and it has to be — there is nothing to ask. That is the honest shape of the difference: Ruby carries type information alongside every value at runtime so that value.class can be answered, while a register carries sixty-four bits and the knowledge of what they mean lives entirely in your head. even?, meanwhile, really is one instruction; the method call around it is the expensive part.
Dispatch Versus a Jump
A Ruby method call looks up the name on the receiver's class, walks the ancestor chain if it is not there, and caches the result. A call instruction jumps to an address decided when the program was assembled.
def double(value) = value * 2 puts double(21)
global _start section .bss output: resb 3 section .text ; double(value in rdi) -> rax double: mov rax, rdi imul rax, 2 ret ; no lookup, no cache, no ancestors _start: mov rdi, 21 call double ; the address was decided at assemble time 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 cost that makes Ruby Ruby. Reopening a class, defining method_missing, or swapping a method at runtime all work because the lookup happens at call time rather than at parse time — and the price is that every call is a lookup. The assembly column cannot be monkey-patched for exactly the same reason it is fast: the destination is baked into the instruction.
What One Line Hides
map Is a Loop and an Allocation
The Ruby line reads as one thought. Count what the assembly needs to express it: a source array, a destination buffer, an index, a loop, and a write. [rsi + rcx * 8] is one addressing mode that multiplies the index by 8 and adds it to the base.
numbers = [1, 2, 3, 4] doubled = numbers.map { |number| number * 2 } puts doubled.sum
global _start section .data numbers: dq 1, 2, 3, 4 count: equ 4 section .bss doubled: resq 4 ; map allocates a NEW array — here it is this buffer output: resb 3 section .text _start: lea rsi, [numbers] lea rdi, [doubled] xor rcx, rcx next: mov rax, [rsi + rcx * 8] imul rax, 2 mov [rdi + rcx * 8], rax inc rcx cmp rcx, count jl next ; .sum, which is a second pass over the second array xor rax, rax xor rcx, rcx total: add rax, [rdi + rcx * 8] inc rcx cmp rcx, count jl total xor rdx, rdx ; rax = 20 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
The buffer named doubled is the part worth staring at. map does not modify the array — it builds a second one, which in Ruby means asking the allocator for memory that the garbage collector will later have to visit. Chaining map.select.map allocates three arrays and walks the data three times, which is exactly what each_with_object and lazy exist to avoid.
What "#{name}!" Costs
String interpolation builds a new String from parts. In assembly a "string" is a region of bytes you own, so joining two means choosing where the result lives and copying into it one byte at a time. movsb copies the byte at [rsi] to [rdi] and advances both; rep repeats it rcx times.
name = "World" greeting = "Hello, #{name}!" puts greeting
global _start section .data prefix: db "Hello, " prefix_len: equ $ - prefix name: db "World" name_len: equ $ - name suffix: db "!", 10 suffix_len: equ $ - suffix section .bss greeting: resb 32 ; you decide where the result lives section .text _start: lea rdi, [greeting] lea rsi, [prefix] ; copy "Hello, " mov rcx, prefix_len rep movsb lea rsi, [name] ; copy "World" mov rcx, name_len rep movsb lea rsi, [suffix] ; copy "!\n" mov rcx, suffix_len rep movsb lea rax, [greeting] sub rdi, rax ; rdi advanced as we copied — that is the length mov rdx, rdi mov rax, 1 mov rdi, 1 lea rsi, [greeting] syscall mov rax, 60 xor rdi, rdi syscall
Three copies and a hand-sized buffer. The 32 is the interesting number: it is a guess, and a longer name would run past the end of it with nothing to complain. Ruby's String grows itself, which is why interpolation cannot overflow and why it allocates. This is also why Ruby 4.0 freezing string literals by default matters — a frozen literal can be shared rather than copied, and copying is what this column does three times.
Variables Are Registers
A Local Becomes a Register
There are sixteen general-purpose registers with fixed names. A local variable does not exist down here: the value goes into a register, and remembering which register holds which of your ideas is your job.
first = 10 second = 32 total = first + second puts total
global _start section .bss output: resb 3 section .text _start: mov rax, 10 ; first = 10 mov rbx, 32 ; second = 32 add rax, rbx ; 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 locals became three registers and the names are gone. Ruby's locals are not free either — they live in the current frame's slot table — but they are looked up by name at parse time rather than remembered by you. Run out of registers here and you must decide, by hand, which value gets pushed to memory; that decision is what a register allocator makes thousands of times per program without mentioning it.
One Register, Four Widths
Ruby has one Integer type that grows without limit. 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.
value = 7 value += 1 low_byte = value & 0xFF puts low_byte
global _start section .bss output: resb 2 section .text _start: mov rax, 7 add rax, 1 ; rax = 8 ; "value & 0xFF" is not a computation here. ; 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
Masking with & 0xFF in Ruby is a real operation on an object; naming al instead of rax is free, because the bits were already there and the narrower name simply stops looking at the rest. The direction that costs something is widening a signed value, where the sign bit must be smeared across the new high bits — that is the movsx instruction, and it exists precisely because reading al into rax would otherwise leave stale bits on top.
Arithmetic, And Where Fixnum Ends
Where Fixnum Ends
This is the clearest place the machine shows through Ruby. A small Ruby Integer is stored directly inside the value slot with a tag bit, so it fits in a register and costs nothing; past a threshold Ruby silently switches to a heap-allocated Bignum. 1 << 62 is on one side of that line and 1 << 63 is on the other.
small = 1 << 62 big = 1 << 63 puts small.class puts big.class puts (big - 1).bit_length
global _start section .data integer_message: db "Integer", 10 integer_length: equ $ - integer_message section .bss output: resb 3 section .text _start: ; Both are just numbers here. 1 << 63 sets the top bit of a 64-bit ; register — which is where the machine's capacity ends, full stop. mov rax, 1 shl rax, 63 ; the sign bit, and nothing above it exists mov rax, 1 mov rdi, 1 mov rsi, integer_message mov rdx, integer_length syscall mov rax, 1 mov rdi, 1 mov rsi, integer_message mov rdx, integer_length syscall ; bit_length of (1 << 63) - 1 is 63 mov rax, 63 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
Ruby reports Integer for both because the Fixnum/Bignum split was hidden behind one class in Ruby 2.4 — but the split is still there underneath, and it is a performance cliff you can fall off without noticing. The assembly column simply stops: sixty-four bits is all there is, and 1 << 64 would be zero. Ruby's arbitrary-precision integers are a data structure and a library, and this is the hardware they are built on top of.
Division, and the Remainder You Got For Free
One div instruction produces the quotient in rax and the remainder in rdx at the same time — they are not two operations. rdx must be zeroed first because it supplies the high half of the dividend.
quotient = 17 / 5 remainder = 17 % 5 puts quotient puts remainder
global _start section .bss output: resb 4 section .text _start: mov rax, 17 xor rdx, rdx mov rcx, 5 div rcx ; ONE instruction: rax = 3, rdx = 2 add al, '0' mov [output], al mov byte [output + 1], 10 add dl, '0' mov [output + 2], dl 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
Writing 17 / 5 and 17 % 5 as separate expressions asks the machine to divide twice unless something notices they are the same division — which is why Ruby has divmod, returning both at once. The instruction was always going to compute both; divmod is the interface that lets you keep the half you would otherwise throw away.
Control Flow: cmp and jump
if / else
cmp is a subtraction that throws away the result and keeps only the flags. The conditional jump after it 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.
value = 7 if value > 5 puts "big" else puts "small" end
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 commonest source of confusion when reading disassembly: your source 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 — Ruby's if is an expression with one value and one exit, and here you build that yourself out of a jump.
A Counted Loop
5.times is a method call on an Integer that yields to a block five times. Down here there is a register holding a number, an instruction that raises it, and a jump backwards while a comparison holds. rbx holds the counter because syscall destroys rcx, and the loop writes on every pass.
5.times do |index| puts index end
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
Five iterations in Ruby means five block invocations, each with its own frame and its own index binding — which is why while is measurably faster than times in tight Ruby loops and why nobody writes Ruby that way anyway. The choice of rbx over rcx here is the kind of detail a register allocator handles silently; get it wrong by hand and the loop counter is destroyed by the write in the middle of it.
Methods, The Stack & The Calling Convention
Passing Arguments
The System V ABI names six registers for integer arguments, in order: rdi, rsi, rdx, rcx, r8, r9, with the return value in rax. A seventh argument goes on the stack.
def combine(first, second, third) = first + second * third puts 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 nothing enforces it. Calling combine having set only rdi is not an error — the function reads whatever rsi and rdx happened to hold. Ruby raises ArgumentError with a count before the method body ever runs, and that check is code somebody had to write.
A Stack Frame By Hand
When a method 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, 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 subtracts.
def sum_of_three first = 20 second = 14 third = 8 first + second + third end puts 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 mov qword [rbp - 16], 14 mov qword [rbp - 24], 8 mov rax, [rbp - 8] add rax, [rbp - 16] add rax, [rbp - 24] mov rsp, rbp ; discard the locals pop rbp 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
This chain of saved frame pointers is what a Ruby backtrace is made of, one level down. Note that mov rsp, rbp erases nothing — it moves a number, and the locals sit there until the next call overwrites them. That is why a C extension can hand Ruby a pointer to a dead frame and get plausible garbage rather than an immediate crash, and why those bugs surface far from their cause.
Arrays Are Offsets
Indexing Is One Instruction
[rsi + rcx * 8] is a single addressing mode: it multiplies the index by 8 and adds it to the base as part of the instruction, not as extra arithmetic. The 8 is there because these are 8-byte values, and you supply it.
numbers = [10, 20, 30] puts numbers[1]
global _start section .data numbers: dq 10, 20, 30 section .bss output: resb 3 section .text _start: lea rsi, [numbers] mov rcx, 1 mov rax, [rsi + rcx * 8] ; numbers[1] — the scale is part of the 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
Indexing is genuinely free here, and it is also unchecked — numbers[9] reads whatever bytes follow. Ruby returns nil for an out-of-range index rather than raising, which surprises people coming from other languages; the reason it can offer any answer at all is that it knows the length and tests it, which is work this column does not do.
Why numbers[-1] Needs the Length
Ruby's -1 index means "one back from the end", which requires knowing where the end is. In assembly the length is a number you carry yourself, and "from the end" is arithmetic you do.
numbers = [10, 20, 30] puts numbers[-1] puts numbers.length
global _start section .data numbers: dq 10, 20, 30 count: equ 3 ; the length, carried by hand section .bss output: resb 5 section .text _start: lea rsi, [numbers] mov rcx, count dec rcx ; "-1" means count - 1 mov rax, [rsi + rcx * 8] ; 30 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 byte [output + 3], '3' mov byte [output + 4], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 5 syscall mov rax, 60 xor rdi, rdi syscall
A Ruby Array knows its own length, which is what makes -1, length, each and bounds checking all possible from the same stored number. Here the 3 appears twice — once as count and once as the literal character printed — and nothing connects them. Getting one wrong is the single most common way hand-written assembly reads memory it does not own.
Strings Are Bytes
A Length, Not a Terminator
The write syscall takes a byte count and never looks for a terminator, so it will happily print a run of bytes from the middle of a longer string. equ $ - message computes each length at assemble time — the same information a Ruby String carries at runtime.
message = "Hello, World!" puts message[0, 5] puts message.length
global _start section .data message: db "Hello, World!", 10 greeting_length: equ 5 section .bss output: resb 3 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, 13 ; message.length 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
Taking a substring is this: the same address with a smaller count, and no copying. It is also why message.length is instant in Ruby — the count was stored, not rediscovered. The thing Ruby adds on top is that length counts characters while this counts bytes, and for anything outside ASCII those are different numbers; bytesize is the one that matches this column.
pack and unpack Are This, With a Vocabulary
This is the row where Ruby is already speaking the machine's language. Array#pack("Q<") lays an integer out as eight little-endian bytes — exactly the layout dq produces — and String#unpack1 reads it back. The directive Q< names a decision the assembly makes silently.
packed = [42].pack("Q<") puts packed.bytes.first puts packed.bytesize puts packed.unpack1("Q<")
global _start section .data ; dq lays 42 out as eight little-endian bytes: 2A 00 00 00 00 00 00 00 packed: dq 42 section .bss output: resb 8 section .text _start: movzx rax, byte [packed] ; the FIRST byte is 42, not 0 — little-endian 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 byte [output + 3], '8' ; bytesize mov byte [output + 4], 10 mov rax, [packed] ; unpack1 — read all eight back xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output + 5], al mov [output + 6], dl mov byte [output + 7], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 8 syscall mov rax, 60 xor rdi, rdi syscall
The first byte being 42 rather than 0 is little-endianness, and it is the same fact in both columns. This is the strongest evidence that the machine is not hidden from Ruby so much as kept at arm's length: pack, unpack, bytesize and force_encoding are all Ruby admitting that underneath the String there is a byte buffer with a layout — the same buffer dq produces here.
The Bits Ruby Already Lets You Touch
Ruby's Bitwise Operators Are These Instructions
Another row where Ruby is already down here. &, |, ^, << and >> are methods on Integer whose entire implementation is one machine instruction each — and, or, xor, shl, shr.
flags = 0b1010 puts flags & 0b0010 puts flags | 0b0001 puts flags ^ 0b1111
global _start section .bss output: resb 7 section .text _start: mov rax, 0b1010 and rax, 0b0010 ; 2 add al, '0' mov [output], al mov byte [output + 1], 10 mov rax, 0b1010 or rax, 0b0001 ; 11 — two digits, so divide xor rdx, rdx mov rcx, 10 div rcx add al, '0' add dl, '0' mov [output + 2], al mov [output + 3], dl mov byte [output + 4], 10 mov rax, 0b1010 xor rax, 0b1111 ; 5 add al, '0' mov [output + 5], al mov byte [output + 6], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 7 syscall mov rax, 60 xor rdi, rdi syscall
The instruction names and the Ruby operators line up one to one, which they do for no other part of this page. Ruby wraps them in method dispatch and arbitrary precision, so huge_number & 1 still works past 64 bits — but for a Fixnum the work being wrapped really is a single and. When you write flags & MASK in Ruby you are not modeling the machine; you are using it.
Shifting Instead of Multiplying
Shifting left by one doubles a number and shifting right halves it, because the digits are binary. A shift is among the cheapest instructions there is, while a general multiply is not.
value = 6 puts value << 2 puts value >> 1
global _start section .bss output: resb 5 section .text _start: mov rax, 6 shl rax, 2 ; 6 * 4 = 24 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, 6 shr rax, 1 ; 6 / 2 = 3 add al, '0' mov [output + 3], al mov byte [output + 4], 10 mov rax, 1 mov rdi, 1 mov rsi, output mov rdx, 5 syscall mov rax, 60 xor rdi, rdi syscall
This is where the old "multiply by a power of two using a shift" advice comes from. It is worth knowing and not worth doing: any compiler turns value * 4 into a shift already, and in Ruby the method dispatch around either operator costs far more than the instruction inside. Write the multiplication that says what you mean.
nil, false, And The Address Zero
nil Is a Value; Zero Is an Address
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 nil is stored — the answer is that a zero address is doing the job.
found = nil if found puts "present" else puts "absent" end
global _start section .data ; There is no nil. Zero is the address no real object can live at, ; so zero is what "nothing here" has to mean. found: dq 0 absent_message: db "absent", 10 absent_length: equ $ - absent_message section .text _start: mov rax, [found] test rax, rax ; the entire truthiness test jnz present mov rax, 1 mov rdi, 1 mov rsi, absent_message mov rdx, absent_length syscall mov rax, 60 xor rdi, rdi syscall present: mov rax, 60 xor rdi, rdi syscall
Ruby's rule that only nil and false are falsy — that 0 and "" are true — is a deliberate departure from this, and the departure is the point. Here zero is the only thing that can mean "nothing", so zero and false and empty are forced to be the same idea. Ruby separates them by making nil a real object with its own address, which costs a comparison and buys you being able to say that zero is a perfectly good number.
Blocks Become Jumps
each_with_index Is Two Registers
A block receiving two parameters is, down here, two registers that both change on every pass. There is nothing to yield to and no block object — the "body" is simply the instructions between the label and the jump.
["a", "b", "c"].each_with_index do |letter, index| puts "#{index}#{letter}" end
global _start section .data letters: db "abc" count: equ 3 section .bss pair: resb 3 section .text _start: xor rbx, rbx ; index next: mov rax, rbx add al, '0' mov [pair], al ; the index digit lea rsi, [letters] mov al, [rsi + rbx] ; the letter mov [pair + 1], al mov byte [pair + 2], 10 mov rax, 1 mov rdi, 1 mov rsi, pair mov rdx, 3 syscall inc rbx cmp rbx, count jl next mov rax, 60 xor rdi, rdi syscall
Note the letters are indexed with [rsi + rbx] and no scale, because they are single bytes — the same loop over 8-byte values needed * 8. Ruby's block does not care: each_with_index works the same over strings, integers or arbitrary objects because the size is the collection's problem, not the caller's. That indifference is what a block buys, and it is bought with an object per iteration.
select Is a Conditional Jump
A predicate block returning true or false becomes a test and a jump that skips the accumulate. test rbx, 1 checks the lowest bit, which is the cheapest even/odd test available.
numbers = [1, 2, 3, 4, 5, 6] evens = numbers.select { |number| number.even? } puts evens.sum
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 ; the running total xor rcx, rcx ; index next: mov rbx, [rsi + rcx * 8] test rbx, 1 ; the block: number.even? jnz skip ; false — jump over the body add rax, rbx skip: inc rcx cmp rcx, count jl next xor rdx, rdx ; total is 12 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
The assembly never builds the intermediate array that select returns — it adds as it goes, which is what sum chained onto select makes unnecessary in Ruby too. That is the practical reading of this row: the allocation Ruby performs between select and sum exists only because the two steps were written as separate passes, and numbers.sum { ... } or each_with_object removes it.
Memory Without A Garbage Collector
Allocation, All the Way Down
There is no allocator here and 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 is, and called with a higher address it moves it, handing you everything in between.
buffer = Array.new(3, 0) buffer[0] = 42 puts buffer[0]
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 ; buffer[0] = 42 mov rax, [rbx] ; buffer[0] 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
Every object Ruby has ever made for you bottoms out in a syscall like this one, batched: the interpreter asks the kernel for large regions and hands out pieces of them, and the garbage collector is the bookkeeping that lets a piece be handed out twice. Nothing here will ever give the page back, and nothing would complain about using it afterward. Array.new(3, 0) is that entire apparatus, invoked by writing eleven characters.
Gotchas For Ruby Developers
There Are No Exceptions
A Ruby exception unwinds the stack, runs every ensure block on the way, and either reaches a rescue or prints a backtrace and exits 1. None of that is machinery the processor provides — it is interpreter code, and here there is none of it.
numbers = [10, 20, 30] index = 5 if index >= numbers.length warn "index #{index} is out of range for length #{numbers.length}" exit 1 end puts numbers[index]
global _start section .data ; Everything an exception would have done, written out by hand. error_message: db "index 5 is out of range for length 3", 10 error_length: equ $ - error_message section .text _start: mov rbx, 5 cmp rbx, 3 jae report_error ; unsigned: catches too-large and "negative" alike mov rax, 60 xor rdi, rdi syscall report_error: mov rax, 1 mov rdi, 2 ; fd 2 = stderr, where a backtrace goes mov rsi, error_message mov rdx, error_length syscall mov rax, 60 mov rdi, 1 syscall
The message is a fixed string because interpolating the numbers would mean writing the conversion loop again — which is itself the lesson about what warn "…#{index}…" costs. More importantly, the check had to be written. Ruby returns nil for numbers[5] and raises only on fetch; here, omitting the cmp reads whatever bytes follow the array and the program carries on with them, which is what a segfault in a C extension looks like from the inside.
A Syscall Destroys Registers
The syscall instruction always destroys rcx and r11 — the processor uses them to remember how to get back. Callee-saved registers (rbx, rbp, r12r15) survive. This has no Ruby counterpart at all; it is the kind of fact the interpreter exists to spare you.
# Ruby has no notion of a register surviving a call. # The nearest equivalent is that a local outlives a method call # made in the middle of the method: counter = 42 puts "writing" puts counter
global _start section .data message: db "writing", 10 length: equ $ - message section .bss output: resb 3 section .text _start: mov rbx, 42 ; rbx is callee-saved — it will survive mov rcx, 42 ; rcx will NOT mov rax, 1 mov rdi, 1 mov rsi, message mov rdx, length syscall ; rcx is now garbage; rbx is untouched mov rax, rbx ; read the one that survived 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
Reading rcx after that syscall would print whatever the kernel left behind, and the program would not crash or complain — it would simply be wrong, in a way that changes with the kernel version. The reason this page can be interesting rather than terrifying is that Ruby has an enormous amount of C standing between you and facts like this one, and the C was written by people who read the ABI document.
Reading a Segfault Dump
This is where a Ruby programmer actually meets assembly: a C extension crashes and Ruby prints a register dump and a disassembly around the faulting instruction. The row below is the shape of what caused it — a load through an address that is zero.
# A Ruby-side illustration of what the C extension did. # Ruby checks before it dereferences; the extension did not. pointer = nil if pointer.nil? puts "would have segfaulted" else puts pointer.fetch(0) end
global _start section .data ; The crash, and the check that would have prevented it. pointer: dq 0 caught_message: db "would have segfaulted", 10 caught_length: equ $ - caught_message section .text _start: mov rax, [pointer] test rax, rax ; the check a C extension forgets jnz dereference mov rax, 1 mov rdi, 1 mov rsi, caught_message mov rdx, caught_length syscall mov rax, 60 xor rdi, rdi syscall dereference: mov rbx, [rax] ; without the check above, this is the segfault mov rax, 60 xor rdi, rdi syscall
In a real dump the faulting line is a mov just like the one after dereference, and the register named in it holds 0x0000000000000000. That is the whole diagnosis: something handed the extension a null pointer and nothing tested it. Ruby raises NoMethodError on nil for the same situation because the interpreter performs the test that this column had to be told to perform.