Part II · Chapter 7

Instructions: From Tiny-8 to x86-64 and RISC-V

Tiny-8 taught us how a processor works. Now we meet the real thing. Real instruction sets answer the same questions Tiny-8 did (which registers, which operations, how to point at memory, how to pack it all into bits), but they answer them in very different ways.

The list of instructions a processor understands, together with its registers and the rules for encoding them, is called its instruction set architecture, or ISA. The ISA is a contract between hardware and software. Any chip that honors the contract can run the same programs. Intel and AMD build very different chips, but both implement x86-64, so the same Windows or Linux runs on either. Dozens of companies make RISC-V chips, and the same RISC-V program runs on all of them.

In this chapter we'll compare our two real ISAs on four questions: registers, operands, encoding, and the overall philosophy behind them.

Registers: more, and wider

Tiny-8 had one register. Real programs juggle many values at once, and registers are by far the fastest place to keep them, so real ISAs have plenty:

x86 carries 45 years of history in its registers. When the 8086 went to 32 bits, ax grew into eax ("extended"), and when x86 went to 64 bits it grew again into rax. But the old names never went away: they're still there, as smaller pieces of the big register.

Click the instructions and watch which bytes change. Pay attention to mov eax: writing the 32-bit part clears the upper half, while writing ax, al or ah leaves the other bytes alone.

That odd rule for 32-bit writes was a deliberate choice by the designers of x86-64. If a write only changed part of a register, the processor would have to merge the new bits with the old ones, which forces it to wait for the old value. Clearing the top half makes the result independent of whatever was there before, so modern processors can run such instructions more quickly. The 16- and 8-bit registers kept their old behavior, for compatibility.

Where operands come from

An instruction's operands can come from three places, in both ISAs:

x86 lets you build quite a fancy address inside a single instruction: [base + index × scale + displacement], where base and index are registers and scale is 1, 2, 4 or 8. That's tailor-made for arrays: [rbx + rcx*8] means "element number rcx of an array of 8-byte numbers starting at rbx". And x86 lets most instructions use memory directly. add rax, [rbx + rcx*8] reads memory and adds, all in one instruction.

RISC-V allows exactly one addressing mode: a register plus a small offset, written 8(sp). And only two kinds of instruction may touch memory at all: loads (lw, ld…) copy from memory into a register, and stores (sw, sd…) copy from a register into memory. Everything else, including add, works only on registers. This design is called a load/store architecture. (Tiny-8 was neither: its ADD could read memory, but only by address.)

Encoding RISC-V: neat boxes

Every RISC-V instruction is exactly 32 bits, divided into fixed fields, just like Tiny-8's two hex digits but with more of them. There are only six layouts, called formats. The opcode always sits in the lowest 7 bits, and when an instruction names a register, it's always in the same place: the destination rd in bits 7–11, the sources rs1 and rs2 in bits 15–19 and 20–24. Each register field is 5 bits, which is exactly enough to number 32 registers.

Choose an instruction, its registers and its number. The colored boxes show each field's bits. Try lw and sw, notice that the number moves to a different place, and try a number that's too big, like 5000.

Because the number has to fit into a 12-bit field, addi can only add numbers from −2048 to 2047. A bigger number takes two instructions: lui ("load upper immediate") sets the top 20 bits, and addi fills in the rest. Assemblers do this for you when you write li a0, 5000.

The branch and jump formats look strange: their number's bits are scattered in an odd order. It's deliberate. It keeps the sign bit of every number in bit 31 and keeps as many bits as possible in the same position across formats, which makes the decoding hardware smaller. RISC-V's designers counted gates.

Encoding x86-64: layers of history

x86 instructions are anything from 1 to 15 bytes long, built from optional pieces in a fixed order:

  1. Prefixes, which modify the instruction (for example, LOCK).
  2. A REX byte, added for x86-64. It turns on 64-bit operation and supplies a fourth bit for register numbers, so they can reach r8–r15.
  3. The opcode: one, two or three bytes.
  4. A ModRM byte, which says what the operands are: two registers, or a register and memory.
  5. An SIB byte (scale, index, base), for fancy addresses.
  6. A displacement, the offset in an address, of 1 or 4 bytes.
  7. An immediate number, of 1, 2, 4 or even 8 bytes.
Pick an instruction from the list and click each byte. These are the exact bytes a real assembler produces. Notice how add rax, 1 and add r8, 1 differ in just one bit of the REX byte.

The variable length makes x86 code compact: common instructions like ret and push take a single byte. But it makes decoding hard. The processor can't even know where the next instruction starts until it has worked out how long this one is. That's part of why modern x86 chips spend so much effort on their front end, and keep a cache of already-decoded instructions.

The same program, twice

Here's a small function in C that adds up an array of numbers:

long sum(long *a, long n) {
    long s = 0;
    for (long i = 0; i < n; i++)
        s += a[i];
    return s;
}

And here it is in both assembly languages, much as a compiler would produce it. By agreement, the array's address arrives in the first argument register (rdi on x86-64, a0 on RISC-V), the count in the second, and the answer goes back in rax or a0.

; x86-64 — 9 instructions, 22 bytes
sum:    xor   eax, eax              ; s = 0
        test  rsi, rsi              ; is n ≤ 0?
        jle   .done                 ;   then skip the loop
        xor   ecx, ecx              ; i = 0
.loop:  add   rax, [rdi + rcx*8]    ; s += a[i]   (load AND add in one go)
        inc   rcx                   ; i++
        cmp   rcx, rsi              ; i < n ?
        jl    .loop
.done:  ret
# RISC-V — 9 instructions, 36 bytes
sum:    li    a2, 0                 # s = 0
        blez  a1, .done             # if n ≤ 0, skip the loop
.loop:  ld    a3, 0(a0)             # load a[i]  (a separate instruction)
        add   a2, a2, a3            # s += it
        addi  a0, a0, 8             # move the pointer to the next 8-byte number
        addi  a1, a1, -1            # one fewer to go
        bnez  a1, .loop
.done:  mv    a0, a2                # return s
        ret

Look at the inner loops. x86 needs 4 instructions, thanks to that powerful add with a scaled address. RISC-V needs 5, because loading and adding are separate. On the other hand, every RISC-V instruction is the same simple shape, easy to decode and easy to overlap. The whole x86 function fits in 22 bytes against RISC-V's 36, though RISC-V's optional 16-bit "compressed" instructions would close much of that gap.

CISC and RISC

These two styles have names. In the 1970s, memory was expensive and slow, and many programs were still written by hand in assembly. So designers built complex instruction set computers (CISC): rich, compact instructions that each did a lot of work, implemented with microcode like Tiny-8's. x86 is the most famous survivor.

Around 1980, researchers led by David Patterson at Berkeley and John Hennessy at Stanford noticed that compilers mostly used the simple instructions anyway. They proposed reduced instruction set computers (RISC): fewer, simpler, fixed-length instructions that could be decoded quickly and overlapped in a pipeline. RISC designs went on to power workstations, then game consoles, and today, as ARM, almost every phone, as well as Apple's Mac processors. Hennessy and Patterson shared the 2017 Turing Award, the "Nobel prize" of computing, for this work. RISC-V, started at Berkeley in 2010, is the latest in that line. Its specification is open, so anyone may build a RISC-V chip without paying a licence fee.

Today the line between the two is blurry. As we saw in Chapter 6, modern x86 processors translate their complex instructions into simple RISC-like micro-ops inside. And RISC designs have gained plenty of specialized instructions. The real difference now is mostly in the front door: how instructions are encoded, and how hard they are to decode.

Try it yourself

  1. Use the encoder to find the 32-bit word for addi a0, a0, 1.
    Show answer0x00150513. The number 1 sits in the top 12 bits, a0 (register 10) appears in both rs1 and rd, and the opcode is 0010011.
  2. Why can't a single addi add 5000? How does li a0, 5000 manage it?
    Show answerThe immediate field is only 12 bits, so it holds −2048 to 2047. The assembler splits 5000 into lui a0, 1 (which puts 1 × 4096 into a0) followed by addi a0, a0, 904: 4096 + 904 = 5000. (On 64-bit RISC-V, assemblers use addiw, a close cousin of addi that works on 32-bit values, but the idea is the same.)
  3. add rax, rbx is 48 01 D8. What are the bytes for add rax, rcx?
    Show answer48 01 C8. Only the reg field of the ModRM byte changes: rbx is 011 and rcx is 001, so 11 011 000 (D8) becomes 11 001 000 (C8).
  4. How many bytes does "return from a function" take on each ISA?
    Show answerx86-64: 1 byte (C3). RISC-V: 4 bytes. ret is shorthand for jalr zero, 0(ra), a normal 32-bit instruction. With the compressed extension it shrinks to 2 bytes.
  5. After mov rax, -1 and then mov eax, 1, what's in rax? What if the second instruction were mov al, 1 instead?
    Show answerWith mov eax, 1, rax = 1: writing the 32-bit register clears the upper half. With mov al, 1, rax = 0xFFFFFFFFFFFFFF01: only the lowest byte changes.

What we learned

That completes Part II: you now know how a processor runs a program, from the transistor to the instruction set. But every program we've seen has lived in a tiny memory that magically answers instantly. In Part III we look at real memory: how billions of bytes are organized and addressed, why memory is so much slower than the processor, and the clever tricks (caches and virtual memory) that hide it.