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:

Run the instructions in any order and watch which of RAX's 8 bytes change. Look out for the 32-bit one.

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:

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:

Step through it one instruction at a time, then switch to the other instruction set. Watch the registers that change, and which number is read from memory.

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.

Pick an index, a scale and a displacement, and see which 8 bytes are read, along with the real machine code on each instruction set.

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

  1. RAX holds 0x1122334455667788. What does it hold after mov al, 0? And after mov eax, 1?
    Show answerAfter mov al, 0: 0x1122334455667700, since only the lowest byte changes. After mov eax, 1: 0x0000000000000001, because a 32-bit write clears the top half.
  2. Write the RISC-V instruction for "a0 = a1 − a2", and an x86-64 version of "rax = rax − rbx".
    Show answersub a0, a1, a2 on RISC-V, and sub rax, rbx on x86-64.
  3. In the stepper demo, the x86 loop takes 4 instructions per number, and the RISC-V loop takes 5. Why?
    Show answerx86's add rax, [rbx + rcx*8] reads memory and adds in one instruction, while RISC-V needs a separate ld. RISC-V saves one back with its compare-and-branch bnez, but it updates both a pointer and a counter, so it ends up one instruction longer.
  4. RBX is 0x402000 and RCX is 3. Which address does mov rax, [rbx + rcx*8 + 8] read from?
    Show answer0x402000 + 3 × 8 + 8 = 0x402000 + 32 = 0x402020.
  5. mov rax, 0 is 7 bytes of machine code, and xor eax, eax is 2. Why do both set the whole of RAX to zero?
    Show answermov rax, 0 writes all 64 bits directly. xor eax, eax sets 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

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.