Harry Porter's relay computer is the ultimate retrocomputing project. He designed and constructed a simple CPU using a meager 415 four-pole double-throw relays.
I decided build a relay-level simulation of his machine using Tofu. On his web page, you’ll find a PowerPoint presentation describing the relay circuits he used and his computer’s architecture. You’ll also find detailed commentary on the machine’s instruction set including sample programs. Unfortunately, the documents leave many gaps, the most significant of which are the circuits he used to construct his state machine. But, I built the simulation as a learning experience and filling in the gaps was part of the fun. Due to this lack of an exact specification, my machine is not a perfect clone of his, but it’s able to run his programs.
You can download the latest resources here:harryporter2007.02.03.zip
.
I started out by creating an assembly language compiler. I defined my own assembly language mnemonics and syntax, but the compiler generates the same machine language described in Harry Porter’s documents. Here’s the instruction set accepted by my compiler:
ADD [destination register]
The ADD
instruction puts the sum of B
and C
into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the addition caused a carry-out, the carry flag is set. If the sum is 0, the zero flag is set. Here are some examples:
ADD A ADD D
AND [destination register]
The AND instruction puts the bitwise-and of B
and C
into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the result is 0, the zero flag is set. Here are some examples:
AND A AND D
BE [label]
The BE
(branch equal) instruction causes program execution to continue at the specified location if the zero flag is set. It is equivalent to BZ
. Labels are discussed below. The following example compares B
and C
and jumps to a label if they are equal:
XOR A BE B_AND_C_MATCH
BNE [label]
The BNE
(branch not equal) instruction causes program execution to continue at the specified location if the zero flag is not set. It is equivalent to BNZ
. Labels are discussed below. The following example compares B
and C
and jumps to a label if they are not equal:
XOR A BNE B_AND_C_DO_NOT_MATCH
BNG [label]
The BNG
(branch negative) instruction causes program execution to continue at the specified location if the sign flag is set. Labels are discussed below. The following example puts B+1
into A
and jumps to a label if the sum is negative:
INC A BNG A_IS_NEGATIVE
BNZ [label]
The BNZ
(branch not zero) instruction causes program execution to continue at the specified location if the zero flag is not set. It is equivalent to BNE
. Labels are discussed below. The following example puts B+1
into A
and jumps to a label if the sum is not 0:
INC A BNZ A_IS_NOT_ZERO
BZ [label]
The BZ
(branch zero) instruction causes program execution to continue at the specified location if the zero flag is set. It is equivalent to BE
. Labels are discussed below. The following example puts B+1
into A
and jumps to a label if the sum is 0:
INC A BZ A_IS_ZERO
CAL [label]
The CAL
(call) instruction causes program execution to continue at the specified location. In addition, the address of the next instruction after the CAL
instruction is saved in the XY
register. Labels are discussed below. Here’s an example:
CAL MULTIPLY_ROUTINE
CLR [register]
The CLR
(clear) instruction sets the specified register to 0. The register can be one of the following: A
, B
, C
, D
, M1
, M2
, X
, Y
. Here are some examples:
CLR A CLR C CLR M1 CLR X
HLT
The HLT
(halt) instruction suspends program execution. Here’s the only way to use it:
HLT
INC [register]
The INC
(increment) instruction adds 1 to either A
, D
or XY
; however, incrementing XY
does not affect the status register flags. For A
and D
, the following rules apply: If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the addition of 1 caused a carry-out, the carry flag is set. If the result is 0, the zero flag is set. Here are some examples:
INC A INC D INC XY
JMP [label]
The JMP
(jump) instruction causes program execution to continue at the specified location. Labels are discussed below. Here’s an example:
JMP LOOP
LD [destination register]
The LD
(load) instruction uses the 16-bit value in the M
register as an address and reads a byte from main memory into either the A
, B
, C
, or D
registers. Main memory is organized as 32K bytes, addressed from hex 0000
through 7FFF
. Here are some examples:
LD A LD B LD C LD D
MOV [destination register] [source register]
The MOV
(move) instruction copies the value in the source register into the destination register. There’s an 8-bit version and a 16-bit version of this instruction. In the 8-bit version, the source and destination registers can be any of the following: A
, B
, C
, D
, M1
, M2
, X
, Y
. Here’s a few examples:
MOV A B MOV C D MOV M1 X MOV M2 Y
If the source register and the destination register are the same, the register will be set to 0.
The 16-bit version copies the value of a 16-bit source register into a 16-bit destination register; however, it’s less flexible than the 8-bit version. It can only move values between the following register pairs:
MOV PC XY MOV PC M MOV PC J MOV XY M MOV XY J
NOT [destination register]
The NOT
instruction puts the bitwise-inversion (i.e. it flips each bit) of B
into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the result is 0, the zero flag is set. Here are some examples:
NOT A NOT D
OR [destination register]
The OR
instruction puts the bitwise-or of B
and C
into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the result is 0, the zero flag is set. Here are some examples:
OR A OR D
RET
The RET
(return) instruction causes program execution to continue at the address stored in the XY
register. It is meant to be used in conjunction with CAL
. Here’s the only way to use it:
RET
SET [destination register] [immediate or label]
There are 3 version of the SET
instruction. The first version assigns a 5-bit immediate (a value inclusively between -16 and 15) to the specified destination register, which can be either A
or B
. Negative immediate values are sign extended to 8 bits. The second version assigns a 16-bit immediate to M
or J
. For both versions, if the immediate ends with H, it is interpreted as a hexadecimal value. Otherwise, the immediate is assumed to be a decimal value. The third version is equivalent to the second version, but instead of a 16-bit immediate, it accepts a label, which is effectively a 16-bit address value. Here are some examples:
SET A 7 SET B -2 SET M 1234h SET J 4660 SET M SOME_LOCATION
SHL [destination register]
The SHL
(shift left) instruction performs a circular left-shift (a.k.a. a left rotation) on B
and puts the result into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the result is 0, the zero flag is set. Here are some examples:
SHL A SHL D
STO [source register]
The STO
(store) instruction uses the 16-bit value in the M
register as an address and stores the value from either A
, B
, C
, or D
into a byte in main memory. Main memory is organized as 32K bytes, addressed from hex 0000
through 7FFF
. Here are some examples:
STO A STO B STO C STO D
XOR [destination register]
The XOR
instruction puts the bitwise-xor of B
and C
into either A
or D
. If the result is negative (i.e. the left-most bit of the result is 1), the sign flag is set. If the result is 0, the zero flag is set. Here are some examples:
XOR A XOR D
My assembly language is case-insensitive. It supports the following features and directives:
BYTE [8-bit value]
The BYTE
data definition directive lets the programmer reserve storage for a byte of data. If the value ends with an H, it is assumed to be a hexadecimal value; otherwise, it is assumed to be decimal. Here are some examples:
BYTE 123 BYTE 7Bh
Comments begin with #
. The #
and everything to the right is ignored by the compiler.
# This is what a comment looks like. SET A 3 # This is another comment.
DATA [hex pair] [hex pair] ... [hex pair]
The DATA
directive lets the programmer reserve storage for one or more bytes of data. The values are hex pairs. Here’s an example:
DATA 48 65 6C 6C 6F 20 57 6F 72 6C 64 21
LOOP:
Labels provide a convenient way of moving immediate values into registers as shown here:
# Move multiplier into Y SET M MULTIPLIER LD A MOV Y A # ... MULTIPLIER: BYTE 179
STRING “...”
The STRING
directive is functionally equivalent to the DATA
directive, but more convenient for character data. Strings start and end with quotes. A string cannot be wrapped onto multiple lines; however, you can always declare multiple STRING
directives on successive lines. There is no notation for escape characters. But, the end of a string is marked the last quote on the line. This means that you can put quote characters inside of strings without escaping them. Also, #
’s are allowed within strings; they will not cause part of the string to be interpreted as a comment. Strings are not automatically null-terminated. To null terminate a string, follow the STRING
directive with a BYTE 0
directive.
HELLO_WORLD: STRING "Hello World!" DATA 0D 0A 00
WORD [16-bit value]
The WORD
data definition directive lets the programmer reserve storage for a 16-bit word of data. If the value ends with an H, it is assumed to be a hexadecimal value; otherwise, it is assumed to be decimal. Here are some examples:
WORD 1234h WORD 4660
Here’s an example of running the assembler with Java 5:
java -jar assembler.jar Args: [input file] [output file] java -jar assembler.jar multiply.asm multiply.tape
Notice that I named the output file with an extension of “tape”. The output file is a binary file containing the machine language instructions generated from the assembly language statements in the input file. However, the output file represents data stored on punched paper tape, a storage technology available in the era when Harry Porter’s computer would have been considered cutting-edge technology.
The Main Memory Unit is organized as 32K bytes, addressed from hex 0000
through 7FFF
; however, 64K is potentially addressable. With 32K’s worth of addresses unused, I decided to reserve 2 addresses to support printing characters. Printing a character is a 2 step process. First, to prepare the printer for new data, write any byte to address FFFE
. Then, to print a character, write the character’s value to address FFFF
. For example, to print a character stored in register B, do this:
SET M FFFEh STO B SET M FFFFh STO B
Here’s a program containing a subroutine that will print null-terminated strings:
# HELLO WORLD PROGRAM SET M HELLO_WORLD CAL PRINT_STRING HLT # PRINT STRING FUNCTION # SET M TO ADDRESS OF NULL TERMINATED STRING. # MODIFIES A, B, C, D, XY, M, J PRINT_STRING: # STORE XY IN C,D FOR RET MOV C X MOV D Y PRINT_STRING_LOOP: # B = STRING CHARACTER LD B # IF B == 0 GOTO PRINT_STRING_DONE SHL A BZ PRINT_STRING_DONE # XY = M MOV XY M # PRINT STRING CHARACTER IN B SET M FFFEh STO B SET M FFFFh STO B # M = XY + 1 INC XY MOV M1 X MOV M2 Y JMP PRINT_STRING_LOOP # RESTORE XY FROM C,D FOR RET PRINT_STRING_DONE: MOV X C MOV Y D RET HELLO_WORLD: STRING "Hello World!" DATA 0D 0A 00
After writing the assembler, I created an emulator to test out the compiled programs. The emulator runs until the HLT
instruction is encountered. Before terminating, it displays the values in the registers. Prior to HLT
, nothing is display unless you use the aforementioned printer. Here’s what happens when you run the hello world program:
java -jar assembler.jar helloworld.asm helloworld.tape java -jar emulator.jar Args: [input file] java -jar emulator.jar helloworld.tape Hello World! A: 00000000 00 0 0 B: 00000000 00 0 0 C: 00000000 00 0 0 D: 00000110 06 6 6 J: 0000000000011101 001D 29 29 M: 0000000000101110 002E 46 46 PC: 0000000000000000 0000 0 0 XY: 0000000000000110 0006 6 6
The register values are displayed in 4 ways: binary, hexadecimal, unsigned decimal and signed decimal. In this case, none of the values were negative.
To construct the Tofu simulation of Harry Porter’s computer, I needed to build a few components in Java. First, to display the registers, I created bulb components that accept 8-bit and 16-bit values. Here’s the first 2 lines of harryporter.config
:
tofu.harryporter.Bulb8 bulb8 <init> i7 i6 i5 i4 i3 i2 i1 i0 tofu.harryporter.Bulb16 bulb16 <init> i15 i14 i13 i12 i11 i10 i9 i8 i7 i6 i5 i4 i3 i2 i1 i0
The update()
method of each bulb generates a string to be displayed reflecting the value across the input terminals. Those strings are only printed when the HLT
instruction is encountered. The HLT
instruction actually signals another Java component:
tofu.harryporter.Halter halter input
When its single input terminal is set to 1, the Halter
’s update()
method prints the bulb strings and then it calls System.exit()
to terminate the simulator.
The 32K memory component acts like an addressable latch. It’s also responsible for handling the 2 addresses involved in printing.
tofu.harryporter.Memory memory select load d7 d6 d5 d4 d3 d2 d1 d0 a15 a14 a13 a12 a11 a10 a9 a8 a7 a6 a5 a4 a3 a2 a1 a0
Finally, to read in the punched paper tape programs, I needed a tape reader:
tofu.harryporter.TapeReader tapereader <init> select advance d7 d6 d5 d4 d3 d2 d1 d0 end
The initialization parameter is the file name representing the paper tape to read. Unfortunately, this means to point the simulated computer to a different program, you need to edit the Tofu source file and alter this parameter. The 8 D
terminals connect the tape reader to the data bus. When the select
terminal is asserted, a channel opens between the data bus and the tape reader. The process of reading a paper tape begins by asserting the advance
signal. When the advance
signal is raised from 0 to 1, the tape reader internally advances to the next byte on the paper tape. When the advance signal drops back to 0, the paper tape byte is maintained across the D
terminals. Or, if it reached the end of the tape, the end
signal will be asserted instead.
With those components, I constructed the Tofu file, which you can view online here: harryporter.tofu
.
If you point the tape reader to the hello world program described above, you can slowly watch it print out “Hello World!” character-by-character:
java -cp tofu.jar;harryporter.jar tofu.Main harryporter.tofu harryporter.config Build complete: 1092 components, 1592 nodes. Hello World! A: 00000000 00 0 0 B: 00000000 00 0 0 C: 00000000 00 0 0 D: 00000110 06 6 6 J: 0000000000011101 001D 29 29 M: 0000000000101110 002E 46 46 PC: 0000000000000000 0000 0 0 XY: 0000000000000110 0006 6 6
At this time, you might be asking yourself, if Harry Porter’s computer required 415 relays, then why does this simulation of it use 1,092 components?! That’s a good question. First, Harry Porter used four-pole relays, which means he used the equivalent of somewhere between 415 and 1,660 single-pole relays, the type used by Tofu. Second, I needed extra states in my state machine to load programs from tape. The actual Harry Porter computer requires the operator to tediously key in each byte of the program into main memory. Finally, Harry Porter certainly optimized his design to use the lowest number of relays possible while I all I cared about was getting the thing working since I never built anything like this before.
So, what can this puppy do? In the zip file above, you’ll find several sample programs including the examples from Harry Porter’s webpage translated into my assembly language. The most interesting example is probably dragoncurve.asm
which prints out the Dragon Curve Fractal.