Now that we have the required stubs in place, it’s time to implement some opcodes! For reference, those are available here.
Clear Display - 0x00e0
This will be the first instruction we’ll implement because it’s relatively simple (it doesn’t require being split in various ways)x. Our processOp
function is an empty stub:
void processOp(State *state, uint8_t memory[])
{
}
So it’ll do, well, nothing. Which is perfect! Let’s write a test for the functionality first. In test/test_a.c
we can start with the below. There’s a bit of set up (and oops - pc
needs to be 16 bits wide, not 8) required:
static void test_clear_display(void **state)
{
/*
Let's assume we have some pixels set in the display section
Calling 0x00e0 should zero those out
*/
// init
State chip8State = {.pc = ROM_OFFSET};
uint8_t memory[MEM_SIZE];
memset(memory, 0x0, MEM_SIZE*sizeof(uint8_t));
// let's turn on some pixels
memset(memory + MEM_DISPLAY_START, 0xa, 256*sizeof(uint8_t));
// add a "clear display" instruction
memory[ROM_OFFSET] = 0x0;
memory[ROM_OFFSET+1] = 0xe0;
// process
processOp(&chip8State, memory);
// validate
uint8_t expected[256];
memset(expected, 0, 256*sizeof(uint8_t));
// ensure we have incremented the program counter to point to the next instruction
assert_int_equal(ROM_OFFSET+2, chip8State.pc);
// we should set the draw flag since we (presumably) modified the pixels
assert_true(chip8State.draw);
// and that the memory is indeed zero
assert_memory_equal(memory+MEM_DISPLAY_START, expected, 256);
}
And running it fails - as expected (never trust a test you haven’t seen fail - ever):
❯ ./bin/test_a
[==========] Running 1 test(s).
[ RUN ] test_clear_display
[ ERROR ] --- 0x202 != 0x200
[ LINE ] --- /home/axiomiety/repos/fish8/test/test_a.c:30: error: Failure!
[ FAILED ] test_clear_display
[==========] 1 test(s) run.
[ PASSED ] 0 test(s).
[ FAILED ] 1 test(s), listed below:
[ FAILED ] test_clear_display
1 FAILED TEST(S)
Now let’s fix this one failure at a time.
First, we need to switch
on the opcode. Generally speaking, the first 4 bits indicate the “type” of the code so let’s split accordingly:
void processOp(State *state, uint8_t memory[])
{
// memory is byte-addressable, but opcodes are 2-bytes long
// for simplicity, we break this as such:
// 0x0123
// opCodeLeft = 0x01
// opcodeRight = 0x23
// opCodeA,B,C,D = 0x0, 0x1, 0x2, 0x3
uint8_t opCodeLeft, opCodeRight, opCodeA, opCodeB, opCodeC, opCodeD;
opCodeLeft = memory[state->pc];
opCodeRight = memory[state->pc + 1];
opCodeA = opCodeLeft >> 4;
opCodeB = opCodeLeft & 0x0f;
opCodeC = opCodeRight >> 4;
opCodeD = opCodeRight & 0x0f;
bool error = false;
SDL_Log("Decoding %02x%02x (A:%x, B:%x, C:%x, D:%x)", opCodeLeft, opCodeRight, opCodeA, opCodeB, opCodeC, opCodeD);
switch (opCodeA)
{
case (0x0):
{
switch (opCodeB)
{
case (0x0):
{
switch (opCodeRight)
{
case (0xe0):
clearDisplay(state, memory);
break;
default:
error = true;
break;
}
}
break;
default:
error = true;
break;
}
}
break;
default:
error = true;
break;
}
if (error)
{
// we could do a bit more like dumping the state/memory
SDL_Log("Unknown/unimplemented opcode %x%x", opCodeLeft, opCodeRight);
exit(1);
}
}
And let’s make sure clearDisplay
increments the program counter:
void clearDisplay(State *state, uint8_t memory[])
{
state->pc += 2;
}
Re-running the tests, we now get:
[==========] Running 1 test(s).
[ RUN ] test_clear_display
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
[ ERROR ] --- chip8State.draw
[ LINE ] --- /home/axiomiety/repos/fish8/test/test_a.c:32: error: Failure!
[ FAILED ] test_clear_display
[==========] 1 test(s) run.
[ PASSED ] 0 test(s).
[ FAILED ] 1 test(s), listed below:
[ FAILED ] test_clear_display
1 FAILED TEST(S)
Progress! We forgot to set the draw
flag - easy fix.
void clearDisplay(State *state, uint8_t memory[])
{
state->draw = true;
state->pc += 2;
}
Lather, rinse, repeat:
[==========] Running 1 test(s).
[ RUN ] test_clear_display
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
[ ERROR ] --- difference at offset 0 0x0a 0x00
difference at offset 1 0x0a 0x00
difference at offset 2 0x0a 0x00
difference at offset 3 0x0a 0x00
difference at offset 4 0x0a 0x00
difference at offset 5 0x0a 0x00
difference at offset 6 0x0a 0x00
difference at offset 7 0x0a 0x00
difference at offset 8 0x0a 0x00
difference at offset 9 0x0a 0x00
difference at offset 10 0x0a 0x00
difference at offset 11 0x0a 0x00
difference at offset 12 0x0a 0x00
difference at offset 13 0x0a 0x00
difference at offset 14 0x0a 0x00
difference at offset 15 0x0a 0x00
...
256 bytes of 0x7ffe04c81ea0 and 0x7ffe04c80ea0 differ
[ LINE ] --- /home/axiomiety/repos/fish8/test/test_a.c:34: error: Failure!
[ FAILED ] test_clear_display
[==========] 1 test(s) run.
[ PASSED ] 0 test(s).
[ FAILED ] 1 test(s), listed below:
[ FAILED ] test_clear_display
1 FAILED TEST(S)
And finally, let’s reset all those pixels:
void clearDisplay(State *state, uint8_t memory[])
{
memset(memory+MEM_DISPLAY_START, 0, 256*sizeof(uint8_t));
state->draw = true;
state->pc += 2;
}
And voila:
[==========] Running 1 test(s).
[ RUN ] test_clear_display
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
[ OK ] test_clear_display
[==========] 1 test(s) run.
[ PASSED ] 1 test(s).
Flow control
With an instruction under out belt, let’s tackle flow control. This is what happens when we either call a subroutine or perform a jump. As with the previous one, let’s start with a test to ensure we understand the expected behaviour.
static void test_flow(void **state)
{
/*
When performing a call, we need to:
- push the next address on the stack
- increment the stack pointer
- set the PC to the address of the subroutine
Returning is essentially those in reverse:
- decrement the stack pointer
- load the address pointed to by the stack pointer into the PC
The test ROM will look like this
0x0200 0x00e0 # clear the screen
0x0202 0x00ee # return
0x0204 0x2200 # call the subroutine at 0x0200
0x0206 0x1204 # jump to 0x204
So after processing 4 operations, we should have PC set back to 0x0204
*/
// here we're starting at the jump instruction
State chip8State = {.pc = ROM_OFFSET + 4};
uint8_t memory[MEM_SIZE];
memset(memory, 0x0, MEM_SIZE * sizeof(uint8_t));
uint8_t rom[] = {0x0, 0xe0, 0x0, 0xee, 0x22, 0x00, 0x12, 0x04};
memcpy(memory + ROM_OFFSET, rom, sizeof(rom));
// process the jump
processOp(&chip8State, memory);
assert_int_equal(ROM_OFFSET, chip8State.pc);
// we should have incremented our stack pointer by 1
assert_int_equal(1, chip8State.sp);
// and the first entry should be the return address (the one after the call)
assert_int_equal(ROM_OFFSET + 6, chip8State.stack[0]);
// clearing the screen - we test this elswhere
processOp(&chip8State, memory);
assert_int_equal(ROM_OFFSET + 2, chip8State.pc);
// the return
processOp(&chip8State, memory);
// validate the return address is set correctly
assert_int_equal(ROM_OFFSET + 6, chip8State.pc);
// and that our stack pointer is back to 0
assert_int_equal(0, chip8State.sp);
// the unconditional jump
processOp(&chip8State, memory);
// we should have updated the program counter
assert_int_equal(ROM_OFFSET + 4, chip8State.pc);
// no change to the stack - this isn't a subroutine call
assert_int_equal(0, chip8State.sp);
}
This is a bit lengthly but it’s also very explicit. We handcraft our test ROM and use memcpy
to copy this into memory
- which is just what is described in the comments but split in bytes.
Let’s run our tests:
[==========] Running 2 test(s).
[ RUN ] test_clear_display
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
[ OK ] test_clear_display
[ RUN ] test_flow
INFO: Decoding 2200 (A:2, B:2, C:0, D:0)
INFO: Unknown/unimplemented opcode 2200
We know what to do next! For brievty I won’t bother posting the output of the tests after each op code implementation but it’s strongly recommended you do this if you’re following along.
0x2NNN
This one is interesting because we need to push the return address, which is a PC+2
, onto our in-memory “stack”:
void callSubroutine(State *state, uint8_t opCodeB, uint8_t opCodeRight) {
// we need to combine opCodeB and opCodeRight to form a 12-bit address
uint16_t address = (opCodeB << 8) | opCodeRight;
// push the return address onto the stack
state->stack[state->sp] = state->pc + 2;
state->sp += 1;
state->pc = address;
}
We also had to combine each of the N
s (technically the left-most and the right pair) into a single address.
0x00ee
The other (but no less important) half of the instruction above, the return - which decrements the stack pointer, fetches whatever address we had stored in there and pushes it into PC
:
void returnFromSubroutine(State *state) {
state->sp -= 1;
state->pc = state->stack[state->sp];
}
0x1NNN
The easiest one for last, tackling the unconditional jump:
void jumpToAddress(State *state, uint8_t opCodeB, uint8_t opCodeRight) {
// we need to combine opCodeB and opCodeRight to form a 12-bit address
uint16_t address = (opCodeB << 8) | opCodeRight;
state->pc = address;
}
Putting it together
The main switch
block of processOpCode
now looks like this:
switch (opCodeA)
{
case (0x0):
{
switch (opCodeB)
{
case (0x0):
{
switch (opCodeRight)
{
case (0xe0):
clearDisplay(state, memory);
break;
case (0xee):
returnFromSubroutine(state);
break;
default:
error = true;
break;
}
}
break;
default:
error = true;
break;
}
}
break;
case (0x1):
jumpToAddress(state, opCodeB, opCodeRight);
break;
case (0x2):
callSubroutine(state, opCodeB, opCodeRight);
break;
default:
error = true;
break;
}
if (error)
{
// we could do a bit more like dumping the state/memory
SDL_Log("Unknown/unimplemented opcode %02x%02x", opCodeLeft, opCodeRight);
exit(1);
}
Let’s run our tests:
[==========] Running 2 test(s).
[ RUN ] test_clear_display
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
[ OK ] test_clear_display
[ RUN ] test_flow
INFO: Decoding 2200 (A:2, B:2, C:0, D:0)
INFO: Decoding 00e0 (A:0, B:0, C:e, D:0)
INFO: Decoding 00ee (A:0, B:0, C:e, D:e)
INFO: Decoding 1204 (A:1, B:2, C:0, D:4)
[ OK ] test_flow
[==========] 2 test(s) run.
[ PASSED ] 2 test(s).
Success!
Conclusion
That’s 4 op codes done! We can’t run anything meaningful with it yet, but it already gives us an idea as to how to implement the rest - see you in part 5.