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
syscallOne 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 3global _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 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 valueglobal _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 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
syscallThe
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
syscallThis 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.sumglobal _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
syscallThe 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 greetingglobal _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
syscallThree 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 totalglobal _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
syscallThree 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_byteglobal _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
syscallMasking 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_lengthglobal _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
syscallRuby 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 remainderglobal _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
syscallWriting
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"
endglobal _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 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
endglobal _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
syscallFive 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
syscallA 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_threeglobal _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
syscallThis 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
syscallIndexing 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.lengthglobal _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
syscallA 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.lengthglobal _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
syscallTaking 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
syscallThe 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 ^ 0b1111global _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
syscallThe 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 >> 1global _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
syscallThis 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"
endglobal _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
syscallRuby'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}"
endglobal _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
syscallNote 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.sumglobal _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
syscallThe 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
syscallEvery 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
syscallThe 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, r12–r15) 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 counterglobal _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
syscallReading
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)
endglobal _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
syscallIn 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.