Skip to main content

Microcode

The microcode is stored in the four ROM chips. As mentioned in the documentation on the µISA, there is a sequence of up to 16 microinstructions (control words) executed for each possible combination of flags, interrupt pending state, and instruction opcode. Most opcodes do not care about the flags, and thus have the same microcode repeated 16 times for each possible combination of flags. The microcode for handling interrupts is repeated very often, since it is almost always executed when the interrupt pending flag is enabled, irrespective of the other inputs. While this seems to waste a lot of space, it also radically simplifies the design of the control unit.

The file microcode.py defines the microcode instructions for each ISA instruction. We then have two additional programs (microcode_to_bin.py and microcode_to_csv.py) that export these as a binary or CSV file. The binary files then can be flashed on the ROM chips. The CSV file is used by the µArch Simulator.

The microcode maintains several invariants across the execution:

  • The PC value is always stored in the address latch. If an instruction needs to otherwise use the address latch, it first needs to move the PC from the address latch to the proper register.
  • The PC value (in the address latch) does not actually point to the current instruction’s opcode when it starts executing it, but to the byte immediately after it (which might be the immediate or a new instruction). This is because the instruction fetch is initiated by the previous instruction, which already increases the address latch.
  • The stack pointer points to the last valid byte on the stack.
  • The shadow registers W, X, Y, and Z are used to save the PC, the accu and flags during an interrupt. They may not be used as temporaries.
  • The shadow registers U and V are available as temporaries.

We now document the actual microcode, to be found in microcode.py.

Writing Microcode

A microcode word has 32 bits, of which 25 directly drive control lines or internally modify the control unit (compare with the µISA documentation). Four other control lines are decoded through a 4-to-16 decoder (consisting of two 74-138, 3-to-8 decoders). When writing microcode, this decoder is abstracted away, and we can write our microcode as if the outputs were individual control lines. A static analysis pass ensures that these control lines are always mutually exclusive. The static analyzer also checks for other simple errors like whether all instructions end in an instruction fetch.

The three remaining control word bits drive six control lines, each driving two at a time, where one of those is always “don’t care.” When writing microcode, a control line can have three assigned values: “positive” (or p, i.e. active), “negative” (or n, i.e. not active), or “don’t care.” We then have code that looks for such pairs where one is always “don’t care” when another is active, so that we could merge them.

A control word is then specified by describing which control lines are p or n. This is added to a baseline of control words, so that we have a default that does not need to be specified all the time (e.g. reset_uinst_counter is almost never enabled).

nop

instruction("nop", opcode(0xff), [])

That’s it, that’s the instruction. It seems like a nop actually does nothing. Yet, there is more to the picture here, than meets the eye.

The instruction function takes a name ("nop") to ease debugging, a generator which yields the start addresses of the instruction (opcode(0xff)) and a list of control words ([]). opcode(0xff) yields addresses with opcode 0xff, all different SZVC-flag combinations and no interrupts.

The empty list here indeed specifies that the instruction does not do anything useful. However, the instruction still needs to advance the PC and fetch the next instruction. This is so common that we usually append instruction_fetch to the array of control words. Only if auto_if=False is passed to the instruction function, this is disabled. The instruction_fetch control word is defined as follows:

postincrement_addr = p(CLine.reg_latch_count | CLine.reg_latch_up)
instruction_fetch = p(CLine.reset_uinst_counter) | postincrement_addr

An instruction fetch works by asserting reset_uinst_counter, which also implicitly loads from memory to the opcode register, and by setting reg_latch_count and reg_latch_up, so that the address latch counts up. This counting-up is delayed so that it happens after the load, hence it is a post-increment. As noted above, the latch was already pointing at the next instruction.

Setting auto_if=False can be useful in a few cases to perform actions along with the instruction fetch. Note however that the data bus is already used to fetch the instruction. This is why most instructions cannot be further optimized by manually extending the instruction fetch control word.

reset

# reset initializes, the PC, SP and accu to 0, clears all flags and disables
# interrupts
instruction("reset", (
0x00 << OPCODE_LSB | flags << FLAGS_LSB for flags in range(1 << 5)
), [
# just a µNOP to ensure that we do not start with only a half cycle
CState(),
# load zero into accu ("reset" interrupt code)
clear_alu_latch | CState(
# clear flags
p=(CLine.alu_set_flags_raw | CLine.alu_update_flags),
n=(CLine.alu_use_shadow_carry |
CLine.alu_update_just_carry | CLine.alu_zero_flag_and)
),
clear_alu_accu | p(CLine.set_interrupt_inhibit) | CState(
# clear shadow carry
p=(CLine.alu_set_flags_raw | CLine.alu_update_flags |
CLine.alu_use_shadow_carry),
n=(CLine.alu_update_just_carry | CLine.alu_zero_flag_and)
),
# clear PC and SP
UarchReg8.PC_LO.from_dbus() | zero_to_dbus,
UarchReg8.PC_HI.from_dbus() | zero_to_dbus,
UarchReg16.PC.to_addr() | UarchReg16.SP.from_addr() | instruction_fetch,
], auto_if=False)

The reset instruction is special because it is not blocked by interrupts. Its opcode is 0x00, since this is what the hardware reset resets the opcode register to. Since it also needs to happen on interrupts, we manually specify all addresses at which it needs to happen.

When coming out of a reset, the control unit thus starts executing the control word at 0x00000 (17-bit address), which is a control no-op, such that no control lines are asserted (line 7). The reason for this is that the instruction and flags register, as well as the microinstruction counter have an asynchronous reset.
When operating at a low clock speed, the reset signal might well be active only during the second half cycle. In this case, the actions that would normally happen at a rising clock edge will not happen.

We then continue:

  1. Line 9-14: We clear the ALU latch, and set the flags from the data bus. The dbus is always set to zero if no one else is writing to it, so this zeros the flags, which might still be undefined since they have not been cleared yet.
  2. Line 15-20: We zero out the accu, inhibit interrupts, and also set the shadow flags. Note that here, alu_use_shadow_carry is p instead of n, so it is cleared as well.
  3. Line 21-22: We clear the PC.
  4. Line 23: We continue to clear the PC.
  5. Line 24: We move the PC to the address latch, and the SP from the address latch. Since these registers are all level-triggered, the 0 from the PC propagates to both the SP and the address latch. Also, we run an instruction fetch afterwards.
  6. Line 25: Since we already ran the instruction fetch while also zeroing the PC, we do not need to automatically add it.

call imm16

load_imm = p(CLine.imm_load) | postincrement_addr
postdecrement_addr = CState(p=CLine.reg_latch_count, n=CLine.reg_latch_up)

instruction("call imm16", opcode(0b00_001_001), [
# set the PC register to the immediate ("old" PC remains in address latch)
load_imm | UarchReg8.PC_LO.from_dbus(),
load_imm | UarchReg8.PC_HI.from_dbus(),
UarchReg16.UV.from_addr(), # PC to shadow register 1
UarchReg16.SP.to_addr() | postdecrement_addr,
# U corresponds to PC_HI, push hi first to have LE
UarchReg8.U.to_dbus() | dbus_to_mem | postdecrement_addr,
UarchReg8.V.to_dbus() | dbus_to_mem | UarchReg16.SP.from_addr(),
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False)
  1. Line 6: We use load_imm (defined in line 1) to load an immediate from memory, and increment the PC (which is already pointing to the immediate and stored in the address latch) right after loading. The immediate is loaded into the lower half of the PC register. Note that program execution is not diverted yet, we're not fetching the next instruction yet.
  2. Line 7: We do the same for the upper half. Immediately afterwards, the address latch will point at the next instruction, i.e. where the return should go to.
  3. Line 8: We save the return address in the temporary shadow register
  4. Line 9: We move the SP, which currently points at the last valid stack entry, to the address latch, and decrement it immediately afterwards. The latch now points at the first free stack address.
  5. Line 12: We store U, i.e. the old upper PC, in memory. Since the stack grows down, this corresponds to little-endian. Also, we decrement the latch right after.
  6. Line 13: We store V, and move the latch back to the stack pointer, so that its register contents have effectively decreased by two.
  7. Line 14: We move the PC, which contains the call target, to the latch, and execute an instruction fetch.

Interestingly, storing UV on the stack means that they will be stored in IO memory if this instruction is executed with the prefix_a16 latch set. We do not consider this a bug.

push

for i, reg in enumerate(IsaReg8):
instruction(f"push {reg.name}", opcode(0b00_000_100 | i << 3), [
UarchReg16.PC.from_addr(),
UarchReg16.SP.to_addr() | postdecrement_addr,
reg.value.to_dbus() | dbus_to_mem | UarchReg16.SP.from_addr(),
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False)

The push instruction is defined several times, once for each of the ISA registers. Two special commands working similarly can push the accu, or the flags.

  1. Line 3: We move the PC out of the address latch, so that we can use it otherwise.
  2. Line 4: We move the SP to the address latch, and decrement it, so that it points at the first free stack address
  3. Line 5: We move the register to be pushed onto the data bus, and execute a store.
  4. Line 6: We move the PC back and execute an instruction fetch.

jcc imm8s16

def matches_jmp_cond(flags: int, cond: int) -> bool:
sf = (flags >> SF_NUM) & 1
zf = (flags >> ZF_NUM) & 1
of = (flags >> OF_NUM) & 1
cf = (flags >> CF_NUM) & 1
return [
of, # overflow
cf, # below
zf, # equal
cf | zf, # below or equal
sf, # sign
1, # unconditional
sf ^ of, # less
(sf ^ of) | zf, # less or equal
][cond] == 1


jcc_jump_start = [
# imm8 to latch
load_imm | dbus_to_alu_latch,
# save (next) pc to regs
UarchReg16.PC.from_addr(),
# add immediate lower
UarchReg8.PC_LO.to_dbus() | alu_add_shadow,
alu_latch_to_dbus | UarchReg8.PC_LO.from_dbus()
| p(CLine.reload_flags | CLine.alu_use_shadow_carry),
]

jcc_fallthrough = [
postincrement_addr, # skip imm8s16
instruction_fetch,
]

assert len(jcc_fallthrough) <= len(jcc_jump_start)

instruction("jcc imm8s16", (
(0b00_000_110 | c << 3 | n) << OPCODE_LSB | f << FLAGS_LSB
for c in range(8)
for f in range(16)
for n in (0, 1)
if matches_jmp_cond(f, c) != bool(n) and not (c == 5 and n == 1)
), jcc_jump_start, auto_if=False)

instruction("jcc imm8s16 sext0", (
(0b00_000_110 | c << 3 | n) << OPCODE_LSB | f << FLAGS_LSB
for c in range(8)
for f in range(16)
for n in (0, 1)
if do_sext0(f) and not (c == 5 and n == 1)
), [
clear_alu_latch,
UarchReg8.PC_HI.to_dbus() | alu_adc_shadow,
alu_latch_to_dbus | UarchReg8.PC_HI.from_dbus(),
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False, ctr_start=len(jcc_jump_start))

instruction("jcc imm8s16 sext1", (
(0b00_000_110 | c << 3 | n) << OPCODE_LSB | f << FLAGS_LSB
for c in range(8)
for f in range(16)
for n in (0, 1)
if do_sext1(f) and not (c == 5 and n == 1)
), [
preset_alu_latch,
UarchReg8.PC_HI.to_dbus() | alu_adc_shadow,
alu_latch_to_dbus | UarchReg8.PC_HI.from_dbus(),
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False, ctr_start=len(jcc_jump_start))

instruction("jcc imm8s16 no_sext", (
(0b00_000_110 | c << 3 | n) << OPCODE_LSB | f << FLAGS_LSB
for c in range(8)
for f in range(16)
for n in (0, 1)
if no_sext(f) and not (c == 5 and n == 1)
), [
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False, ctr_start=len(jcc_jump_start))

instruction("jmp imm8s16 fallthrough", (
(0b00_000_110 | c << 3 | n) << OPCODE_LSB | f << FLAGS_LSB
for c in range(8)
for f in range(16)
for n in (0, 1)
if matches_jmp_cond(f, c) == bool(n) and not (c == 5 and n == 1)
), jcc_fallthrough, auto_if=False)

def do_sext0(flags: int) -> bool:
sf = flags & (1 << SF_NUM) != 0
cf = flags & (1 << CF_NUM) != 0
return cf and not sf


def do_sext1(flags: int) -> bool:
sf = flags & (1 << SF_NUM) != 0
cf = flags & (1 << CF_NUM) != 0
return sf and not cf


def no_sext(flags: int) -> bool:
sf = flags & (1 << SF_NUM) != 0
cf = flags & (1 << CF_NUM) != 0
return sf == cf

There is a lot going on here, because jcc and sign-extends are some of the most complex operations in our instruction set.
First, lines 1-15 define a helper for decoding condition codes. There are 8 possible jump condition, and each can occur negated or not. So we have e.g. a “jump zero” and a “jump not zero.” Also, one should remember that condition code 5 is a “jump always.” Since a “jump never” does not make sense, we instead re-use the corresponding opcode for jmp imm16. The microcode for this is not shown, but this explains why and not (c == 5 and n == 1) is found everywhere, since this excludes jcc imm16.

The general idea behind a conditional sign-extending jump is that it is just a NOP if the condition does not hold. This can be handled relatively quickly.
To sign-extend the immediate, we use the reload_flags mechanism, which allows changing the flags (and thus switching to a different microcode snippet) while executing.
For this, it is crucial that the “no jump” case is shorter than part before the reload_flags of the microcode for an actual jump. This is because when the flags reload, they might change so that the jump would no longer be applicable, shifting us into the fall-through case.
The solution here is that the fall-through case is short, so that parts of it can be repurposed. The following table shows the fall-through and the actually-jump case for js imm16, which corresponds to a jump-signed, i.e. a jump if negative. The red line indicates where we can switch from the one case to the other. Blue lines indicate where we advance to the next instruction (which might be the jump target).
Note that this simplifies things a bit by assuming that sf != cf after the switch, to elide the third case of “no sign-extend necessary” described below.

The two lanes in a jump

Note that after the fall-through case is handled, it contains code that actually does the jump. It is never executed in the fall-through case, as the fall-through case is already finished by then.
Also note that reload_flags uses the sign bit of the last value read from memory instead of the sign flag from the ALU.

Let’s look at the fall-through case:

  1. Line 30: We increment the address latch, to skip over the immediate
  2. Line 31: We do a normal instruction fetch
  • Line 80-86: This specifies when the fall-through code should be generated. It enumerates all jump opcodes, placing the code whenever the condition matches (by comparing to bool(n), we emit the fall-through code when the jump matches and we're in “negated” mode, which means that we don’t actually match). Again, we already have the code for the instruction fetch, so auto_if is False.

Now, the more complicated case where we actually jump:

  1. Line 20: We load the immediate to the latch. We update the imm_sign state depending on the sign of the immediate.
  2. Line 22: The latch, pointing at the next instruction (a jump by 0 would jump there, i.e. be a nop. A jump -2 is an endless loop) is saved in the PC register
  3. Line 24: We add the PC_LO to the latch, storing the carry in the shadow carry flag.
  4. Line 25-26: We move the latch back to PC_LO. The lower PC now already has the imm8 added to it. We now need to add-with-carry either 0x00 or 0xff to the upper half of the PC. Thus, we reload_flags, and then implement the sign-extending. Since the last memory access was for the immediate, the sign bit input to the control word ROM will be uses, since it is still stored in imm_sign.

For sign-extending, we look at the flags set by the addition of PC_LO and the immediate. The signed flag will be set depending on the sign of the immediate, while the carry flag is the carry resulting from the addition.

  • If the signed flag is not set (i.e. a sign extension is not necessary, as the immediate is non-negative), and the carry-flag is not set, the upper PC does not actually need to change, since we would just add 0x00, and the carry is also 0.
  • If the signed flag is set (i.e. a sign extension is necessary), and the carry flag also is set, then we would need to add 0xff to the upper PC, and additionally add 1 because the carry flag is set. This will actually be a no-op, as wrap around to exactly the value we started ad, so we can also do nothing in this case.
  • If the signed flag is set, and the carry flag is not, then we actually need to do a sign-extension, i.e. add 0xff to the upper PC and thus subtract 1 from the upper PC.
  • If the signed flag is not set, but the carry flag is, then we do not need to do a sign extension, but still handle the carry by increasing the upper PC by 1.

Note that the reason for the first two cases in the case distinction is just that this makes the common case where we do not need to adjust the upper PC faster. We could always simply look at the signed flag, and do an add-with-carry, which then actually performs no-op additions.

Let’s continue from where we stopped after reload_flags. We are now in one of three tracks, depending on whether the upper PC needs to be decreased, increased or not changed. The framework supports this by letting the user specify a start value for the microinstruction counter (ctr_start). Hence, the instruction function does not necessarily define entire instructions, but instruction snippets which compose to the entire instruction.
In our case, the snippets for the three tracks are called jcc imm8s16 sext0, jcc imm8s16 sext1 and jcc imm8s16 no_sext. The no_sext case is simpler, we describe it first:

  1. Line 77: As the upper PC does not need to change, and the lower PC is already computed, we just move the PC to the latch, and fetch and continue to our next opcode. This can happen in one cycle, as the address latch is level-triggered and will immediately propagate the PC once the control lines are stable. Thus, the jump is done.

The sext0 and sext1 cases are similar:

  1. Line 51 / 64: If we need to increase the PC, we clear the ALU latch, which sets all bits to 0. Otherwise, we do a PRESET, which sets all bits to 1.
  2. Line 52 / 65: We add the PC_HI to the latch, using the shadow carry. Note that this could just be a regular add without carry in line 65. The result is saved in the latch.
  3. Line 53 / 66: We move the result back to the PC_HI.
  4. Line 54 / 67: We move the PC to the latch and fetch the next instruction.

As can be seen in line 55, 68, and 78, we put the code after the reload_flags at all opcodes, but with a specific temporal offset, so that it is placed exactly as indicated in the diagram above.

prefix_a16

instruction("prefix_a16", opcode(0b10_000_000), [
p(CLine.set_addr16) | instruction_fetch,
], auto_if=False)

This is a seemingly normal instruction fetch. However, the prefix_a16 latch will be active during the next instruction. This causes memory instructions to access IO devices instead of normal memory. This may result in a bus request, which is described along with hardware interrupts.

add acc, [pi]


class AluOperation(IntEnum):
CLEAR = 0
# HACK ALERT:
# 74382 implements A-B as operation 1 and B-A as operation 2.
# We've mistakenly switched A and B around on the ALU bread board,
# and thus we're simply switching the operands around here.
DBUS_MINUS_LATCH = 2
LATCH_MINUS_DBUS = 1
ADD = 3
XOR = 4
OR = 5
AND = 6
PRESET = 7

def state(self) -> CState:
return CState.set_int(self.value, [
CLine.alu_operation_0,
CLine.alu_operation_1,
CLine.alu_operation_2,
])


class AluCarrySelect(IntEnum):
CARRY = 0
NONE = 1
SIGN = 2
SERIAL = 3

def state(self) -> CState:
return CState.set_int(self.value, [
CLine.alu_carry_select_0, CLine.alu_carry_select_1,
])

alu_cmp_base = CState(
p=(CLine.alu_update_flags),
n=(CLine.alu_shift_right | CLine.alu_use_shadow_carry
| CLine.alu_update_just_carry | CLine.alu_set_flags_raw
| CLine.alu_zero_flag_and)
)
alu_op_base = alu_cmp_base | p(CLine.alu_accu_set)

for i, op in enumerate(IsaBinOp):
instruction(f"{op.value} acc, [pi]", opcode(0b01_000_110 | i << 3), [
accu_to_latch | UarchReg16.PC.from_addr(),
UarchReg16.PI.to_addr(),
op.state() | mem_to_dbus,
UarchReg16.PC.to_addr() | instruction_fetch,
], auto_if=False)

This instruction is actually generalized over all for i, op in enumerate(IsaBinOp).
For ADD, the state() method returns alu_op_base | AluOperation.ADD.state() | AluCarrySelect.NONE.state().
As can be seen, this sets the alu_carry_select and alu_operation control line bundles appropriately.

  1. Line 45: We save the address latch (i.e. the next instruction) in the PC register.
  2. Line 46: We move PI to the latch.
  3. Line 47: We execute the specified ALU operation between the latch and the bus. Concretely, this means that for ADD
    • The alu_operation control line is set to 0x3
    • The alu_carry_select control line is set to 0x1
    • alu_update_flags is set, so the flags get updated
    • alu_shift_right is not set, as this is not a shift
    • alu_use_shadow_carry is also not set, as we want to affect the proper carry
    • alu_update_just_carry is also not set, as we do want to set all flags
    • alu_set_flags_raw is also not set, because this would load the flags from the bus
    • alu_zero_flag_and is also not set. If true, it would AND the old and the new zero flag, instead of just overwriting.
    • alu_accu_set is set, so that the result gets written back to the accu.
    • mem_to_dbus is set, so that the other operand is read from memory. Interestingly, this means that the operation is performed twice in case of a bus request. However, since the inputs remain stable, in particular the latch does not change, this is not an issue. We just overwrite the accu’s first, incorrect result with the second, correct result at the second execution.
  4. Line 48: We move the PC back and do an instruction fetch.

add ab, imm8s16

alu_add = AluOperation.ADD.state() | AluCarrySelect.NONE.state() | CState(
p=(CLine.alu_latch_set | CLine.alu_update_flags),
n=(CLine.alu_zero_flag_and | CLine.alu_set_flags_raw |
CLine.alu_update_just_carry | CLine.alu_shift_right |
CLine.alu_use_shadow_carry),
)
# Intended for upper 8 bits
alu_adc = AluOperation.ADD.state() | AluCarrySelect.CARRY.state() | CState(
p=(CLine.alu_latch_set | CLine.alu_update_flags | CLine.alu_zero_flag_and),
n=(CLine.alu_set_flags_raw | CLine.alu_update_just_carry |
CLine.alu_shift_right | CLine.alu_use_shadow_carry),
)
for w, dest in enumerate(IsaReg16):
add_imm8s16_start = [
dest.value.lo().to_dbus() | dbus_to_alu_latch,
load_imm | alu_add,
alu_latch_to_dbus | dest.value.lo().from_dbus()
| CState(p=CLine.reload_flags, n=CLine.alu_use_shadow_carry),
]
instruction(f"add {dest.name}, imm8s16", opcode(0b11_10_10_00 | w),
add_imm8s16_start, auto_if=False)

# Note that we cannot skip dealing with the upper byte because we care about
# the flags.

instruction(f"add {dest.name}, imm8s16 sext0", (
(0b11_10_10_00 | w) << OPCODE_LSB | f << FLAGS_LSB
for f in range(1 << NUM_FLAGS) if f & (1 << SF_NUM) == 0
), [
clear_alu_latch,
dest.value.hi().to_dbus() | alu_adc,
alu_latch_to_dbus | dest.value.hi().from_dbus(),
instruction_fetch,
], auto_if=False, ctr_start=len(add_imm8s16_start))

instruction(f"add {dest.name}, imm8s16 sext1", (
(0b11_10_10_00 | w) << OPCODE_LSB | f << FLAGS_LSB
for f in range(1 << NUM_FLAGS) if f & (1 << SF_NUM) != 0
), [
preset_alu_latch,
dest.value.hi().to_dbus() | alu_adc,
alu_latch_to_dbus | dest.value.hi().from_dbus(),
instruction_fetch,
], auto_if=False, ctr_start=len(add_imm8s16_start))

Here, we've got a sign-extending instruction again. Compared to a conditional jump, things are a bit different though.
In particular

  • We do not have to initially look at the condition code to see whether the operation is really necessary. The add is always executed.
  • Since we care about the flags for the result, we cannot elide the addition of the upper 8 bits as done in the no_sext case above.

Let’s look at the execution:

  1. Line 15: We move the lower part of the 16-bit register we want to add to the latch. This sets the sign_imm latch.
  2. Line 16: We add the immediate to the latch, using the normal flags (not the shadow carry). The sign bit is now set to sign_imm, i.e. the sign of the immediate.
  3. Line 17-18: We move the latch back to the lower part of the register we wanted to add to. Again, the lower part is now proper. We now consider the sign bit, to figure out if we need to add 0x00 or to 0xff to the higher part of the registers. Thus, we reload the flags and continue execution depending on whether the signed flag was set or not set.
  4. Line 30 / 40: We set the latch to 0x00 if the signed flag is not set (line 30), and to 0xff if it is set (line 40).
  5. Line 31 / 41: We add the higher part of the register to the latch, storing the result in the latch. We also AND the current zero flag with the new zero flag, so that it is zero iff the entire 16 bits of the result are zero. The other flags are correct, since they only look at the carry flag and the highest bit of the result.
  6. Line 32 / 42: We move the latch back to the higher part of the register.
  7. Line 33 / 43: We do a normal instruction fetch.

Interrupts

instruction("hardware interrupt", (
opcode << OPCODE_LSB | 1 << INT_BIT | flags << FLAGS_LSB
for opcode in range(0x01, 0x100) # do not override reset
for flags in range(1 << NUM_FLAGS)
), [
# Save acc in Z
accu_to_dbus | UarchReg8.Z.from_dbus() | clear_alu_latch
# As the PC points to the byte after the opcode where we want to continue
# the execution, we need to decrement the PC by one first.
| postdecrement_addr,
# Save the PC in WX
UarchReg16.WX.from_addr() | p(CLine.set_addr16),
# Jump to 0x0000, turn on INTACK
UarchReg8.PC_LO.from_dbus() | zero_to_dbus,
UarchReg8.PC_HI.from_dbus() | p(CLine.toggle_intack) | zero_to_dbus,
# Load the interrupt code into acc
mem_to_dbus | UarchReg16.PC.to_addr() | dbus_to_accu_with_empty_latch,
# Save the alu flags in Y
p(CLine.alu_flags_to_dbus) | UarchReg8.Y.from_dbus(),
# turn off INTACK (conflicts with *_to_dbus)
p(CLine.toggle_intack),
# Turn off interrupts, fetch instruction at 0x0000
p(CLine.set_interrupt_inhibit) | UarchReg16.PC.to_addr()
| instruction_fetch,
], auto_if=False)

Line 1 defines the “opcode”. In this case, the code executing is of course not responsible for a specific instruction, but the code still expects a name here. The next line defines all opcodes for which this is mapped, which are many. Importantly, the instruction with opcode 0 (reset) is not mapped, since reset is also executed when an interrupt is pending.
Apart from this, the interrupt code will be executed for all other opcodes and all other flag combinations, as long as the interrupt flag is on (line 2, 3).

Then, we define the actual instructions:

  1. Line 6-10: First, we save the accu in register Z (this is a shadow register). We also clear the ALU latch, to later move things into the accu. Finally, we decrement the latch (which contains the PC). This is necessary, since we have the invariant that the address stored in the latch always is the address of the current instruction +1 when starting.
  2. Line 11-12: Next, we save the PC in WX, which is another shadow register. The actual PC will remain in WX until we execute an iret, when it will be restored. This means that nested interrupts are not supported. Further, we set the addr16 latch. This will later allow us to read the interrupt number, which is canonically “stored” in IO memory, address 00.
  3. Line 13-14: We set PC_LO to zero.
  4. Line 15: We set PC_HI to zero. Also, we toggle intack, which is now high. This is part of the interrupt procedure and tells the device which caused this interrupt that it can pull the interrupt signal low again.
  5. Line 16-17: We load from memory. Since mem_to_dbus is used, which just desugars to regular_load, and since addr16 is set, we execute a bus request, since the corresponding latch is set. This means that BUS_REQUEST becomes high. Further, the address needs to be 0x0000, because this is what IO devices expect. This is the most complicated part of the control unit and further documented here.
  6. Line 16-17: Since we are doing a bus request, we freeze the microinstruction counter for one cycle. This means that we basically load into the accu a second time, but this time the interrupting device has had time to write the interrupt number to the bus.
  7. Line 18-19: The accu now contains the interrupt number. We still need to save the flags, so that we can later restore them.
  8. Line 20-21: We disable interrupt_acknowledge. Since this control line is mutually exclusive with the control lines directing access to the bus, this has to happen separately.
  9. Line 22-24: We now set interrupt_inhibit, and move 0000 (which was previously stored to PC) to the address latch. We then execute an instruction fetch, which means that we load the next instruction and continue from there.

Line 25 further specifies that we do not want the automatic instruction fetch that this python framework would otherwise append to the “instruction.” We additionally (to the normal instruction_fetch) have to set the interrupt inhibit flag. We also move the PC to the latch again, but this is actually unnecessary since the latch did not change since line 17.

Note that the precise output of all ADDR16-related control lines for step 5 to 6 is discussed here.