“If you wish to make an apple pie from scratch, you must first invent the universe.”
― Carl Sagan
An instruction set architecture, or ISA, describes the instruction set of a processor and its behavior. This is not enough to know how a processor works but this is the information needed to operate it.
I made my own ISA named Reflet which you can check out on the Reflet GitHub repository.
Why would one design a new ISA? With RISC-V and MIPS being open ISA with great support, there is not much point in doing so. Personally, I mostly did this for fun and artistic aspiration. I had a few ideas trotting in my head about what a fun ISA could be. I did want wanted to make a simple ISA, but rather a family of ISA for 8-bit, 16-bit, 32-bit, 64-bit, or even more bit-having processors that all share the same instruction set. At first, I even wanted to be able to make a processor with exotic word sizes but I scrapped that idea. I also wanted a Von Newman architecture and having instructions being a single indexable amount of memory. Those three needs combined meant that I needed to have instruction on only 8 bits. This is quite low, even some 8-bit computers use 16 bits instructions.
On the other hand, I really liked the idea of instructions being 8 bits wide. This meant that I would probably end up with every possible byte being a valid instruction (and this ended up being the case). I find it quite beautiful that any binary file would be a valid program in my instruction set.
Another thing I wanted is to have a lot of general-purpose registers. I really like the idea of running a processor only from its ROM and registers, without using any RAM, like the HP nanoprocessor. This need does not mix well with having 8-bits wide instruction as indexing this register takes space.
To solve those issues, I needed to have an accumulator-like register. Indeed, had I used only general-purpose registers, when using two registers in each instruction, I would have needed to use less than 3 bits to access each register. This would have meant having less than 8 general-purpose registers. I could have had different categories of registers that are addressed differently but I really did not like this idea.
The solution is to have an accumulator-like register that is implicitly used by (almost) all instruction. Some instructions have a 4-bit opcode and a 4-bit operand that let me index 16 registers. Those instructions use both the working register (name of my accumulator-like register) and another register. Some other instructions only have an 8-bit opcode and don't interact with other registers than the working register. In the end, I ended up with the following instruction set:
Mnemonic | Opcode | Operand | Effect |
---|---|---|---|
slp | 0x00 | Nothing | Does nothing, wait for the next instruction |
set | 0x1 | A 4 bits number | Put the number in the working register |
read | 0x2 | A register | Copies the value in the argument register into the working register |
cpy | 0x3 | A register | Copies the value of the working register into the argument register |
add | 0x4 | A register | Add the value in the working register to the value in the argument register and put the result in the working register |
sub | 0x5 | A register | Subtract to the value in the working register the value in the argument register and put the result in the working register |
and | 0x6 | A register | Do a bit-wise and between the working register and the argument register |
or | 0x7 | A register | Do a bit-wise or between the working register and the argument register |
xor | 0x8 | A register | Do a bitwise xor between the working register and the argument register |
not | 0x9 | A register | Put in the working register the bit-wise not of the argument register |
lsl | 0xA | A register | Shit the bits in working register to the left n times, where n is the content of the argument register |
lsr | 0xB | A register | Shit the bits in working register to the right n times, where n is the content of the argument register |
eq | 0xC | A register | If the content of the working register is the same as the one in the argument registers, sets the comparison bit of the status register to 1. Otherwise, sets it to 0 |
les | 0xD | A register | If the content of the working register is less than the one in the argument registers, sets the comparison bit of the status register to 1. Otherwise, sets it to 0 |
str | 0xE | A register with an address | Store the value in the working register to the address given in the argument register |
load | 0xF | A register with an address | Put in the working register the value at the address given in the argument register |
cc2 | 0x08 | Nothing | Put in the working register the opposite in two-complement of the working register. |
jif | 0x09 | Nothing | Jump to the address in the working register if the comparison register is not equal to 0, does not affect the stack |
pop | 0x0A | Nothing | Put the content of the working register on the stack and updates the stack pointer. |
push | 0x0B | Nothing | Put the value on top of the stack in the working register and updates the stack pointer. |
call | 0xC | Nothing | Put the current address in the stack and jump to the address in the working register. |
ret | 0x0D | Nothing | Jump to the address just after the address on top of the stack. |
quit | 0x0E | Nothing | Reset the processor or stop it. |
debug | 0x0F | Nothing | Does not modify any registers but the processor sends a signal to tell it is executing a debug instruction. |
cmpnot | 0x01 | Nothing | Flip the comparison bit of the status register. |
retint | 0x02 | Nothing | Return from an interruption context. |
setint | b000001 | A two-bit number | Set the routine of the interruption of the given number to the address in the working register. |
tbm | 0x03 | Nothing | Toggle byte mode. Toggle the memory accesses from the size specified by the status register to 8 bit and back. |
One could notice that if I needed to squeeze a bit more instruction, I could. For example, I could replace the slp
instruction with read WR
or cpy WR
which are two instructions that do not change the state of the processor. Furthermore, I could fuse the debug
, quit
, and maybe some other instructions with a single instruction that uses the state of the working register to control its behavior. I might do that in the future if I feel like the instruction set needs more instructions.
As far as registers go, I have the working registers, a status register, a stack pointer, and a program counter as special registers. This left 12 general-purpose registers which is quite handy. As far as the status register go, as I want the behavior to be very similar with all word size, only the 8 LSB are used for status.
The complete (but still very small) documentation is available on the Reflet GitHub repository.
Before writing the hardware description for my processor, I made a simulator. The point of the simulator is to test easily if programs I compiled to Reflet machine code work.
The simulator can only do very simple I/O, reading chars from stdin one at a time and writing chars to stdout one at a time. Fortunately, this is just enough to write a Hello, world! program. Outputting character works by writing the desired character to at the address 0x1 and then, writing 0 to the address 0x0. Reading characters works in quite a similar way.
The simulator also has some monitoring capacities to help in the debugging process and can simulate hardware interrupts.
Writing machine code by hand is doable, especially with an ISA as simple as Reflet. Indeed, the opcodes being either 4 or 8 bits, they are quite easy to read or write in hexadecimal. But for our sanity's sake and to use some labels and macro, I made an assembler/linker that converts the assembly language into Reflet machine code.
This assembler is not very well written, it needs some optimizations and it does not work with proper tokenizer-parser, instead, it works with a Let's read each lines-I will wing it technology stack.
Nevertheless, as writing macros for it is quite easy and it comes with very convenient default macros, it is perfectly usable as an assembler.
Now that we have both an assembler and a simulator, we can write and execute a program. Let's write an Hello, world!:
label msg
@string Hello, world!
@rawbytes A D 0
label start
set 15
cpy R1 ; Length of the message to print in R1
setlab msg
cpy R2 ; Pointer to the character to print in R2
label print-loop
; Loading a character from R2 and storing it in R3
tbm
load R2
cpy R3
tbm
; Function call made thanks to the `callf` macro
callf putc
; Incrementing the pointer to the desired character
set 1
add R2
cpy R2
; Decrementing the number of characters left to print
set 1
sub R1
cc2
cpy R1
; If the number of characters left to print is not 0, jump back to the start of the loop
set 0
eq R1
cmpnot
setlab print-loop
jif
quit
; Function that prints the character in R3
label putc
; Preparing data_tx address
set 1
cpy R4
tbm
; Writing the content of R3 into data_tx
read R3
str R4
; Writing 0 into data_cmd
set 0
cpy R4
str R4
tbm
ret
We can observe that the program does not make any assumption regarding the word size of the processor. Thus, we can compile it and run it for a Reflet processor of any word size.
Let's compile it and run it on an 8-bit simulated processor:
$ reflet-asm Hello.asm -o Hello.bin -word-size 8
$ reflet-sim Hello.bin
Hello, world!
Yay! It works. Let's have a look at the generated machine code:
00000000 41 53 52 4d 3c 2d 3b 2c 10 3d 11 3c 12 4c 4e 3e |ASRM<-;,.=.<.LN>|
00000010 a2 13 4c 08 4e f0 3c 2b 3d 2c 3f 3c 2d 3b 2c 10 |..L.N.<+=,?<-;,.|
00000020 3d 11 3c 12 4c 4e 3e 42 13 4c 08 4e f0 3c 2b 3d |=.<.LN>B.L.N.<+=|
00000030 2c 3e 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a |,>Hello, world!.|
00000040 0d 00 1f 31 3c 2d 3b 2c 10 3d 11 3c 12 4c 4e 3e |...1<-;,.=.<.LN>|
00000050 32 13 4c 08 4e f0 3c 2b 3d 2c 32 03 f2 33 03 3c |2.L.N.<+=,2..3.<|
00000060 2d 3b 2c 10 3d 11 3c 12 4c 4e 3e 98 13 4c 08 4e |-;,.=.<.LN>..L.N|
00000070 f0 3c 2b 3d 2c 0c 11 42 32 11 51 08 31 10 c1 01 |.<+=,..B2.Q.1...|
00000080 3c 2d 3b 2c 10 3d 11 3c 12 4c 4e 3e 5b 13 4c 08 |<-;,.=.<.LN>[.L.|
00000090 4e f0 3c 2b 3d 2c 09 0e 11 34 03 23 e4 10 34 e4 |N.<+=,...4.#..4.|
000000a0 03 0d |..|
At first, it doesn't look like much but by taking a closer look, we can read the machine code. For example, we can see that the function putc
starts at address 98 and we can read the instruction we rote in assembly. 0x11 is set 1
, 0x34 is cpy R4
, 0x03 is tbm
...
Now that we have designed the ISA and that we have made tools to write machine code for it, the next step is to implement it in a hardware description language such as Verilog.