Processor born

Today I provide an implementation of yesterday’s instruction set. It also comes with an example program.

Let’s dive in.

The memory

Nothing really changed here from the previous implementation. We have an undefined amount of memory. Each memory cell is of arbitrary size, but instructions will be 16 bit wide. The memory can be saved.

class Memory:
    def __init__(self, mem = {}):
        # We hold the memory content in a dictionary
        self.mem = mem
        print(f"{len(self.mem)} memory cells initialized")

    def peek(self, adr):
        if adr in self.mem:
            return self.mem[adr]
        else:
            print(f"!! Read from undefined memory at address {adr}")
            return 'X'

    def poke(self, adr, val):
        self.mem[adr] = val

The processor

The machine consists of eight registers, some of them have a special purpose.

Register Nick name Purpose
R[0] (PC) program counter
R[1] (SP) (data) stack pointer
R[2] (RS) return stack pointer
R[3] (IP) (Forth) instruction pointer
R[4] (A) accumulator/active word
R[5] (W) work register
R[6] (DB) data stack base pointer
R[7] (RB) return stack base pointer
PC = 0
SP = 1
RS = 2
IP = 3
A  = 4
W  = 5
DB = 6
RB = 7
class VirtualMachine:

This is a nifty helper function that allows us to execute arbitrary Python code from within our machine language programs.

    def callPython(self):
        pycode = self.mem.peek(self.R[PC] + 1)
        self.R[PC] = self.R[PC] + 1
        pycode()

We introduce the registers and a HALT flag and initialize the memory.

    def __init__(self, mem = {}):
        self.HALT = False
        self.R    = [0,0,0,0,0,0,0,0]
        self.mem = Memory(mem)

This is the heart of the machine, the decoder. It takes the opcode and cuts it into its individual nibbles. Then it checks the type of instruction and executes it according to its description.

    def decode_execute(self, opcode):
        n3 = (opcode & 0xF000) >> 12
        n2 = (opcode & 0x0F00) >>  8
        n1 = (opcode & 0x00F0) >>  4
        n0 = (opcode & 0x000F) >>  0

        # Special section
        if   opcode == 0x0000: pass                         # NOP
        elif opcode == 0x0001: self.HALT = True             # HALT
        elif opcode == 0x0002: callPython()                 # PYTHON
        elif (n3 == 0x0 and n2 == 0x0 and n1 == 0x1):       # EMIT reg
            print(self.R[n0])
        # Data transfer
        elif (n3 == 0x1):                                   # LDI reg, val
            self.R[n2] = (n1 << 4) + n0
        elif (n3 == 0x2 and n2 == 0x0):                     # LD reg0, *(reg1)
            self.R[n1] = self.mem.peek(self.R[n0])
        elif (n3 == 0x2 and n2 == 0x1):                     # LD reg0, reg1
            self.R[n1] = self.R[n0]
        # Arithmetic
        elif (n3 == 0x7 and n2 == 0x0 and n1 == 0x0):       # INC reg
            self.R[n0] = self.R[n0] + 1
        elif (n3 == 0x7 and n2 == 0x8):                     # MUL reg0, reg1
            self.R[n1] = self.R[n0] * self.R[n1]
        # Combinations
        elif (n3 == 0x8):                                   # PUSH reg0[reg1], reg2
            self.mem.poke(self.R[n2] + self.R[n1], self.R[n0])
            self.R[n1] = self.R[n1] + 1
        elif (n3 == 0x9):                                   # PUSH reg0[reg1], *(reg2)
            self.mem.poke(self.R[n2] + self.R[n1], self.mem.peek(self.R[n0]))
            self.R[n1] = self.R[n1] + 1
        elif (n3 == 0xB):                                   # POP reg0, reg1[reg2]
            self.R[n0] = self.R[n0] - 1
            self.R[n2] = self.mem.peek(self.R[n1] + self.R[n0])
        else:
            print(f'!! {hex(opcode)} - unknown opcode')
            self.HALT = True            

For debugging it is essential to be able to trace the state of the processor, this is what the trace function is for. I normally adjust this one as needed in my experiments.

    def trace(self):
        # Print an overview of all registers, and part of the stacks
        print(f'#:{self.cycles:04}', end=' ')
        print(f'PC:{self.R[PC]:03}', end=' ')
        print(f'SP:{self.R[SP]:02X}', end=' ')
        print(f'RS:{self.R[RS]:02X}', end=' ')
        print(f'IP:{self.R[IP]:03}', end=' ')
        print(f'A:{self.R[A]:03}', end=' ')
        print(f'W:{self.R[W]:02X}', end=' ')
        print(f'DB:{self.R[DB]:02X}', end=' ')
        print(f'RB:{self.R[RB]:02X}', end=' | ')
        print(f'DS[0…]:{self.mem.peek(self.R[DB] + 0):04X}', end=' ')
        print(f'{self.mem.peek(self.R[DB] + 1):04X}', end=' ')
        print(f'{self.mem.peek(self.R[DB] + 2):04X} …', end=' ')
        print(f'RB[0…]:{self.mem.peek(self.R[RB] + 0):04X}', end=' ')
        print(f'{self.mem.peek(self.R[RB] + 1):04X}', end=' ')
        print(f'{self.mem.peek(self.R[RB] + 2):04X} …', end=' ')
        print()
        #print(f'{self.mem.peek(self.R[PC]):04X}')

The next function executes one instruction by a) fetching it from memory, b) incrementing the program counter and c) calling the decoder and execute logic. (The order is important.) In addition it counts the execution cycles and has some logic to stop execution which again is helpful in the debugging and experimentatino phase.

    def step(self):
        opcode = self.mem.peek(self.R[PC])  # FETCH
        self.R[PC] = self.R[PC] + 1
        self.decode_execute(opcode)
        self.trace()
        self.cycles = self.cycles + 1
        # Prevent runaway code exeuction due to bugs
        if self.R[PC] > 100: 
            print('!! Out of memory')
            self.HALT = True
        if self.cycles > 50:
            print('!! Out of cycles')
            self.HALT = True

The run function continues to execute commands until the HALT flag is set to True.

    def run(self, pc = 0):
        self.R[PC] = pc
        self.HALT  = False
        self.cycles = 0
        self.trace()
        while not self.HALT:
            self.step()

Example

I provide the example program as machine code only. Can you find out what it does?

mem = {}
mem[0]  = 0x173C
mem[1]  = 0x1639
mem[2]  = 0x1200
mem[3]  = 0x1100
mem[4]  = 0x1432
mem[5]  = 0x1333
mem[6]  = 0x1008
mem[7]  = 0x0008
mem[8]  = 0x9613
mem[9]  = 0x7003
mem[10] = 0x2043
mem[11] = 0x7003
mem[12] = 0x2004
mem[13] = 0x000E
mem[14] = 0xB461
mem[15] = 0xB561
mem[16] = 0x7845
mem[17] = 0x8614
mem[18] = 0x2043
mem[19] = 0x7003
mem[20] = 0x2004
mem[21] = 0x0016
mem[22] = 0xB372
mem[23] = 0x2043
mem[24] = 0x7003
mem[25] = 0x2004
mem[26] = 0x8723
mem[27] = 0x7004
mem[28] = 0x2134
mem[29] = 0x2043
mem[30] = 0x7003
mem[31] = 0x2004
mem[32] = 0x0021
mem[33] = 0xB461
mem[34] = 0x0014
mem[35] = 0x2043
mem[36] = 0x7003
mem[37] = 0x2004
mem[38] = 0x0000
mem[39] = 0x0028
mem[40] = 0xB461
mem[41] = 0x8614
mem[42] = 0x8614
mem[43] = 0x2043
mem[44] = 0x7003
mem[45] = 0x2004
mem[46] = 0x001A
mem[47] = 0x0027
mem[48] = 0x000D
mem[49] = 0x0015
mem[50] = 0x0007
mem[51] = 0x0003
mem[52] = 0x002E
mem[53] = 0x0020
mem[54] = 0x0037
mem[55] = 0x0038
mem[56] = 0x0001
mem[57] = 0xFFFF
mem[58] = 0xFFFF
mem[59] = 0xFFFF
mem[60] = 0x9999
mem[61] = 0x9999
mem[62] = 0x9999

Last step: create the machine and run it.

vm = VirtualMachine(mem)
vm.run()

And of course you can directly load the complete program text.