Part VIII · Chapter 21
x86-64 Assembly (with RISC-V)
Every program on your computer, whatever language it was written in, ends up as machine code: the bytes the processor actually runs. Assembly language is machine code with names instead of numbers. In this chapter you'll learn to read it and write it, and by the end you'll build a real program from nothing but instructions.
Since Chapter 7 we've met bits of assembly here and there. Each line names one instruction, like add rax, rbx, and a program called an assembler turns it into the matching bytes, here 48 01 d8. There's almost nothing hidden: one line of assembly is one machine instruction. That makes assembly tedious to write, but it's the only language that shows you exactly what the processor does.
A warning before we start: x86 assembly comes in two spellings. This book uses Intel syntax, where the destination comes first: add rax, rbx means "rax = rax + rbx". Many Linux tools default to AT&T syntax, which writes the same instruction as addq %rbx, %rax, with the destination last. Same instruction, same bytes, different spelling. RISC-V has only one spelling.
Registers: the processor's own variables
Registers are the tiny, fast storage spaces inside the processor (Chapter 5). Almost every instruction works on them. x86-64 has 16 general-purpose registers of 64 bits each. The first eight keep names that go back to the 8086, where each had a favourite job:
rax accumulator rsi source index r8 … r15 rbx base rdi destination index (numbered, added rcx count rbp base pointer with x86-64) rdx data rsp stack pointer
Today the jobs are mostly just habits, except for rsp, which always points to the stack (Chapter 22). There are also two special registers: rip, the instruction pointer (the program counter of Chapter 5), and rflags, which holds the flags we'll meet shortly.
Because x86 grew from 16 bits to 32 and then to 64, each register has smaller names for its lower parts. That's how old 16-bit code can still run unchanged:
RISC-V is tidier: 32 registers, x0 to x31, all 64 bits, with no smaller parts. x0 is special: it always reads as zero, and writing to it does nothing, which turns out to be surprisingly handy. Programmers use nicknames that describe each register's role: a0–a7 for function arguments and results, t0–t6 for temporary values, s0–s11 for values that must be kept, plus sp for the stack pointer and ra for the return address. Chapter 22 explains why.
A small vocabulary
x86-64 has well over a thousand instructions, and RISC-V's basic set has about 50, but most code uses just a few dozen. Here are the ones you'll see most:
x86-64 RISC-V
copy a register mov rax, rbx mv a0, a1
set a number mov rax, 42 li a0, 42
read memory mov rax, [rbx] ld a0, 0(a1)
write memory mov [rbx], rax sd a0, 0(a1)
add, subtract add rax, rbx add a0, a0, a1
sub rax, rbx sub a0, a0, a1
multiply imul rax, rbx mul a0, a0, a1
and, or, xor and rax, rbx and a0, a0, a1
shift left, right shl rax, 3 slli a0, a0, 3
shr rax, 3 srli a0, a0, 3
compare and jump cmp rax, rbx beq a0, a1, done
je done
jump jmp loop j loop
Two big differences stand out:
- Two operands or three. In x86, the destination is also one of the inputs:
add rax, rbxoverwritesrax. RISC-V names a separate destination, soadd a0, a1, a2keeps both inputs. - Memory everywhere, or only in loads and stores. Many x86 instructions can read memory directly, like
add rax, [rbx]. On RISC-V only loads (ld,lw,lb…) and stores (sd,sw,sb…) touch memory; everything else works on registers. This is the "RISC" idea from Chapter 7.
Decisions: flags and branches
A program decides things by jumping: changing the instruction pointer so that the processor continues somewhere else. A loop is just a jump backwards.
On x86, arithmetic instructions also set flags in the rflags register, describing the result: ZF (zero flag) if it was zero, SF (sign flag) if it was negative, CF (carry flag) if an unsigned calculation carried out of the top, and OF (overflow flag) if a signed one overflowed (Chapter 2). cmp a, b works out a − b just to set the flags, then throws the answer away. A conditional jump then looks at the flags: je (jump if equal), jne (if not equal), jl and jg (less or greater, for signed numbers), jb and ja (below or above, for unsigned ones).
RISC-V has no flags at all. Its branches compare two registers themselves: beq a0, a1, done jumps if they're equal, and there's bne, blt, bge, plus bltu and bgeu for unsigned numbers. One instruction instead of two.
Here's a complete program in both languages. It adds up five numbers stored in memory, then exits, using the total as its exit status:
The two versions are written in the style that suits each instruction set. x86 keeps an index in rcx and lets the processor work out the address. RISC-V moves a pointer along the list and counts down to zero, because its branch can test for zero directly. It runs a few more instructions, but each one is simpler.
Addressing memory
To read an array element, the processor needs its address: where the array starts, plus the element number times the size of each element. x86 can do all of that inside a single instruction, using its richest addressing mode: [base + index × scale + displacement], where the scale can be 1, 2, 4 or 8. RISC-V loads and stores only allow a register plus a small fixed number, so the rest is done with ordinary arithmetic first.
x86 has a handy trick based on this: lea ("load effective address") works out the address but doesn't read memory. Compilers use it as a fast calculator: lea rax, [rbx + rbx*4] sets rax to rbx × 5 in one instruction.
Meanwhile, inside a real CPU
Here's the program from the demo, in a file called sum.s. The lines starting with a dot are directives: instructions for the assembler, not for the processor.
.intel_syntax noprefix .global _start # make _start visible: it's where the program begins .text # what follows is code _start: lea rbx, [rip + numbers] # rbx = address of the array xor eax, eax # rax = 0: the running total xor ecx, ecx # rcx = 0: which number we're on next: add rax, [rbx + rcx*8] # add numbers[rcx] to the total inc rcx # move on to the next number cmp rcx, 5 # have we done all five? jne next # if not, go round again mov edi, eax # exit status = the total mov eax, 60 # system call 60: exit syscall .data # what follows is data numbers: .quad 10, 20, 30, 40, 50 # five 8-byte numbers
On the computer this book was written on, the assembler as turns it into an object file, the linker ld (Chapter 23) turns that into a program, and the shell shows the exit status:
$ as -o sum.o sum.s $ ld -o sum sum.o $ ./sum $ echo $? 150
The disassembler objdump does the reverse, turning bytes back into assembly. Here's our program as it sits in the file. The whole of the code is just 33 bytes:
$ objdump -d -M intel sum 0000000000401000 <_start>: 401000: 48 8d 1d f9 0f 00 00 lea rbx,[rip+0xff9] # 402000 <numbers> 401007: 31 c0 xor eax,eax 401009: 31 c9 xor ecx,ecx 000000000040100b <next>: 40100b: 48 03 04 cb add rax,QWORD PTR [rbx+rcx*8] 40100f: 48 ff c1 inc rcx 401012: 48 83 f9 05 cmp rcx,0x5 401016: 75 f3 jne 40100b <next> 401018: 89 c7 mov edi,eax 40101a: b8 3c 00 00 00 mov eax,0x3c 40101f: 0f 05 syscall
Look at the jump: 75 f3. The f3 is −13, a distance rather than an address: "go back 13 bytes from the next instruction", from 0x401018 to 0x40100b.
We had no RISC-V computer to run the other version on, but the assembler built it, and it disassembles like this. Thanks to RISC-V's optional 2-byte compressed instructions (the ones shown with two bytes), it's 34 bytes, almost exactly the same size:
0: 97 05 00 00 auipc a1, 0x0 # la a1, numbers (the linker fills in 4: 93 85 05 00 addi a1, a1, 0x0 # the real distance later) 8: 01 45 li a0, 0x0 a: 95 42 li t0, 0x5 c: 03 b3 05 00 ld t1, 0x0(a1) 10: 1a 95 add a0, a0, t1 12: a1 05 addi a1, a1, 0x8 14: fd 12 addi t0, t0, -0x1 16: e3 9b 02 fe bnez t0, 0xc <next> 1a: 93 08 d0 05 li a7, 0x5d 1e: 73 00 00 00 ecall
Finally, here's what a compiler makes of the same idea written in C (Chapter 23), for both instruction sets. It's almost exactly what we wrote by hand, down to the choice of an index on x86 and a moving pointer on RISC-V:
long sum(long *a, long n) { // clang -O1
long total = 0;
for (long i = 0; i < n; i++)
total += a[i];
return total;
}
# x86-64 # RISC-V
sum: sum:
test rsi, rsi li a2, 0
jle .LBB0_1 blez a1, .LBB0_3
xor ecx, ecx slli a1, a1, 3
xor eax, eax add a1, a1, a0
.LBB0_4: .LBB0_2:
add rax, qword ptr [rdi + 8*rcx] ld a3, 0(a0)
inc rcx addi a0, a0, 8
cmp rsi, rcx add a2, a2, a3
jne .LBB0_4 bne a0, a1, .LBB0_2
ret .LBB0_3:
.LBB0_1: mv a0, a2
xor eax, eax ret
ret
Try it yourself
-
RAX holds
0x1122334455667788. What does it hold aftermov al, 0? And aftermov eax, 1?Show answer
Aftermov al, 0:0x1122334455667700, since only the lowest byte changes. Aftermov eax, 1:0x0000000000000001, because a 32-bit write clears the top half. -
Write the RISC-V instruction for "a0 = a1 − a2", and an x86-64 version of "rax = rax − rbx".
Show answer
sub a0, a1, a2on RISC-V, andsub rax, rbxon x86-64. -
In the stepper demo, the x86 loop takes 4 instructions per number, and the RISC-V loop takes 5. Why?
Show answer
x86'sadd rax, [rbx + rcx*8]reads memory and adds in one instruction, while RISC-V needs a separateld. RISC-V saves one back with its compare-and-branchbnez, but it updates both a pointer and a counter, so it ends up one instruction longer. -
RBX is
0x402000and RCX is 3. Which address doesmov rax, [rbx + rcx*8 + 8]read from?Show answer
0x402000+ 3 × 8 + 8 =0x402000+ 32 =0x402020. -
mov rax, 0is 7 bytes of machine code, andxor eax, eaxis 2. Why do both set the whole of RAX to zero?Show answer
mov rax, 0writes all 64 bits directly.xor eax, eaxsets EAX to 0 (anything XOR itself is 0), and because it's a 32-bit write, the top 32 bits are cleared too. Same result, in less than a third of the space.
What we learned
- Assembly is machine code with names; an assembler turns it into bytes, and a disassembler turns bytes back into it.
- x86-64 has 16 general-purpose registers with smaller named parts (RAX, EAX, AX, AL); RISC-V has 32, with
x0always zero. - x86 instructions take two operands and can use memory directly; RISC-V uses three and touches memory only with loads and stores.
- x86 decides with flags and conditional jumps; RISC-V branches compare registers directly.
- x86's
[base + index × scale + displacement]finds array elements in one instruction; RISC-V builds the address with arithmetic.
Our program never called a function. Real programs are made of thousands of functions calling each other, and they all need to agree on where to put arguments, results and return addresses. Next: the stack and calling conventions.