So far we have sketched an architecture for our CPU – it’s a 32-bit, register-based RISC machine. Now let’s design a set of instructions for our machine!
The registers
It’s common for RISC machines to provide a large number of registers (you can run out quickly
when simple operations require many instructions manipulating many registers). So let’s give
our machine, say, 64 registers – r0
… r63
. When we actually implement this thing
we’ll be careful not to hardcode this number, so we can spin up a machine with twice
that number of registers, or more, with ease.
We’ll also need an instruction pointer, so we know which instruction our machine should
be executing. We could use on of our general purpose registers, but for convenience when
talking about the machine and its state let’s add a new register, ip
, for this purpose.
(We won’t mess around with ip
explicitly though, so we won’t use it with any of our instructions –
instead our machine will handle incrementing ip
after each instruction).
Arithmetic
Now let’s toss in some instructions. For arithmetic, we’ll include add
, sub
, mul
, and
div
. And mod
, too, what the heck:
add r0 r4 r5
sub r2 r0 r3
mul r7 r7 r3
div r3 r4 r8
mod r2 r0 r3
Notice that instructions share a common form:
instruction dr sr0 sr1
Where dr
is the destination register, and stands in for any of our general purpose registers,
and both sr0
and sr1
are source registers, and also stand in for any general purpose register.
The idea is that the instruction performs the operation dr := sr0 <op> sr1
, updating the destination
register with the result of performing the operation on the source registers.
We’ll use this notation, called three address code,
going forward.
Now we can add and subtract values in registers, but how do we get values into registers in the
first place? Let’s allow ourselves to simply set a register to a constant value: constant dr 0x123
,
which sets dr
to be the literal (hexadecimal) value 0x123
– dr := 0x123
. (Sometimes
these literals are referred to as immediates). We also will want to be able to move these]
values freely between registers: mov dr sr0
, which sets dr
to be the value that is in sr
– dr := sr
.
With just these instructions we can write simple programs to calculate mathematical functions.
For instance, we can calculate 2 * 3 + 4
:
constant r0 2
constant r1 3
constant r2 4
mul r0 r0 r1
add r0 r0 r2
Now r0
will have the value 10
.
Halting
We aren’t even close to being able to display output – where would we even display it? –
but we’d like to be able to get some kind of output from our machine. For now, we’ll
introduce a simple instruction, halt
, that stops our machine. And we’ll follow the
convention that whatever value is in r0
when the machine halts should be taken to be
the “result” of running the machine.
Memory access
Next we’ll add some instructions for manipulating memory: load dr sr0
and store dr sr0
.
The load
instruction reads from memory (at the address indicated by sr0
) and writes to
the destination register: dr := memory[sr0]
. Store operates similarly, reading from a register and
writing to memory: memory[dr] := sr0
.
These operations implement a very simple addressing mode, one that only supports direct access to a specific, “absolute” memory location. Other architectures provide for a variety of addressing modes, for instance “relative” to the value of a particular register, but we’ll keep things simple.
Branching
We can’t yet actually do anything really useful, though, because we can’t yet loop!
Let’s add jz sr0 sr1
and jnz sr0 sr1
as conditional jumps; jz
will set the
instruction pointer to sr1
when sr0
is 0
, otherwise it will advance to the next
instruction as usual. jnz
will do the opposite, checking that sr0
is not 0
.
And jmp
will jump unconditionally:
jz sr0 sr1
–ip := sr0 == 0 ? sr1 : ip + 1
jnz sr0 sr1
–ip := sr0 != 0 ? sr1 : ip + 1
jmp sr0
–ip := sr0
In some sense we only really need a single, conditional looping instruction; for instance,
instead of jmp r0
we could write:
constant r1 0
jz r1 r0
And if mov
were allowed to apply to ip
, we could skip our conditional branching
instructions entirely! But explicit branching instructions are nicer to read and write by hand,
and later will be more convenient when compiling a higher level language to assembly.
Comparison
Our conditional jumps compare with zero, a common pattern in real architectures. But
how do we “get” a 0
in the first place? Well, let’s say we want to determine whether
r0
is equal to r1
, and if so branch to r2
. We could implement it thusly:
sub r3 r0 r1
jz r3 r2
That is, we subtract r0
from r1
– if they are equal, then we get 0
and we will branch,
otherwise we get non-zero and we will not branch. Getting to greater than or less than
with the instructions we’ve added thus far is a bit trickier, but
can be done.
These cute hacks to avoid explicit comparison instructions play fast and loose with overflow, too!
It’s annoying, though, so we’ll add eq
, neq
, lt
, and gt
operations,
all with the effect dr := sr0 <op> sr1 ? 0 : 1
. So if we compare two registers for equality,
and they are equal, then the result will be 0
. Then we can branch based on their
equality using jz
.
Bitwise operations
Most instructions support a variety of bitwise operations, like and
, or
, and
so on. We could require users of our machine to implement these out of the previously
describe primitive instructions, but that’ll be a pain. So add and
, or
, and not
as well. (Later we’ll probably even want to add bitwise shift instructions, shl
and shr
,
but we’ll leave them out for now).
Phew! This was supposed to be a RISC machine, and already we’re nearing 20 instructions? Considering x86 sports well over 1000 instructions, I think we’re doing ok.