Hello, dear reader! Recently a YouTube video came across my recommendations: Multitasking on the Z80. Without watching the video, I started wondering: can it? I mean, it certainly can do multi-threading, but multi-tasking? So let's discuss. What would it take to get a Z80 truly be able to multi-task?
Requirements
What do I mean by multi-tasking? Let's consider what it would take to be able to run multiple applications side-by-side on a process without one interfering with another. We would need to:
- Allocate physical memory for each application, and prevent an application from seeing or modifying memory belonging to another application
- Ideally we want logical addresses, seen by an application to be fixed, while the corresponding physical memory be different each time the application loads. There might be ways around this, if needed, but those workarounds are painful.
- We want to prevent an application from manipulating memory allocated for the operating system.
- At the same time we want the application to be able to interact with the operating system: issue system calls with arguments and get results
- We want an application to be interruptable so we can implement pre-emptive multi-tasking
- We want to prevent applications from messing with the operating system, including disabling interrupts
- We want to prevent applications from accessing I/O resources
- We want the operating system of course to be able to access everything
- We want the operating system to be able to set up access rights and memory mapping for applications
Modern processors of course have built-in facilities to do all of this and more, but our friendly Z80 has none of this; we need to provide it externally.
The schematic
Even though I don't ever intend to implement this, I've drawn up a schematic for the ideas I want to discuss below.
Buyer, be warned!! This is a theoretical schematic. I haven't tried it out, even in simulation. There are probably bugs and omissions that would need to be fixed. Take everything in here with a giant grain of salt..

I will use this schematic in the subsequent sections to discuss the various sections and how they accomplish our set-out goals.
Operating mode and access protection
We'll need a bit somewhere that says whether the current operating mode is user or system mode. In system mode all operations are permitted. In user mode, the MMU protection bit is consulted and writes are denied. I/O access is completely disabled.
Implementing this mode bit is rather simple: it's an I/O mapped register. Let's say this bit is 1 for user mode and 0 for system mode. We have to ensure the reset state is 0 (system mode), but that's also easy to implement. This is what U19 is doing. I will talk more about how it's set and reset, but for now, it's just a flip-flop, that stores the mode state.
To be useful of course, we'll have to incorporate this mode bit into the bus control logic. If we 'or' this bit with the Z80-s nIORQ signals, we effectively disabled all I/O in user mode, while enabling them in system mode. U1 is doing that.
This is great, because it also means that user mode code can't touch the mode bit itself: it can't change itself to system mode.
We also want to disable writes to restricted memory sections when the CPU executes user-mode code, but let's hold on to that until we understand how the MMU work.
MMU
There are many MMUs designed for the Z80 (and other 8-bit microprocessors). One example is here. There were even integrated circuits that in essence did the same thing, namely the 74LS610. Even though it's not in production anymore, you can still get this puppy, even on DigiKey.
With this chip, we can replace the top 4 address bits with up to 12 programmable bits. We can't use all 12 for addressing though, we need access rights as well. For reasons to be discussed later, I'm going to reserve two bits for write protection, which leaves us with 10 address extension bits. The MMU replaces the original top 4 address bits with 10, so the total number of address bits on the bus is now 12+10=22 bits. That's 4MByte of addressable physical memory, pretty nice. The fact that we replace four address bits means that we have 16 memory pages, each being 4kByte large. From here on, we'll treat the original 16-bit address coming from the Z80 process as our logical address and the 22 bit full address the physical address. Notice that the bottom 12 bits of the logical and physical address is the same.
The 74LS610 has a bypass capability. I don't intend to use this, in other words, the MMU will be enabled in both user and system modes. However, it doesn't have a reset input, which means that its mapping registers are unknown after power-up (and reset for that matter). So, I had to invent another control bit that disables the MMU after reset and allows SW to enable it after the first set of pages are programmed into it. This is the role of U18. Again, let's come back to how we get to control its value, for now, it is just a piece of state.
It is important to note that MMU bypass is not automatically controlled by the mode bit, which in turn means that it's not trivial to use it for anything else then the reset behavior. This has a consequence: page 0 (corresponding to address range 0x0000-0x0fff) needs to be mapped to system code and made read-only for all user-mode code. There are more reasons for this (for instance, this is were interrupt vectors live) but the important one here is that the MMU configuration doesn't change when we switch from user to system mode. So, we need to be able to execute system mode code in the same logical address space as we do user code while at the same time we of course want to prevent user code from modifying this system only code. (A side-note: in a more complex system, we could prevent user code to even read this system code section, but let's not be greedy. Things are complicated enough already.)
The MMU programming interface - mostly the nCS signal needs to be mapped to I/O space. This immediately means that user mode code will not be able to mock around with the MMU settings, while system mode code will have no issues what so ever.
There is a small problem: the programming interface uses a 12-bit data-bus, but we only have eight bits on the Z80. There are two ways to deal with this issue.
For a minimalistic system, we can decide that we only use 8 of the 12 available MMU bits, which leaves us with 6 for address extension (one is still needed for write-protection). This means that our physical address space is reduced to 256kB. That's not that much.
The second idea is to use an extra (I/O-mapped) register to drive these extra data bits and - if read-back is also needed - an (I/O-mapped) three-state buffer to capture their values curing read cycles. I used the 74LS574 for the purpose, there are other options. U30 and U31 are used for this purpose.
Register interface and address mapping
Before moving forward, let's talk about how all this 'state' and the MMU itself is interfaced to the processor. As I have said, all access is through the I/O space, something that's completely blocked for user mode code.
We start by decoding the top address bits (I/O only uses the the bottom-most eight address bits) using U8 and U18. They decode the address space 0xE?. Then, we use U16, a 74LS138 to decode the rest of the address bits, while mixing nIORQ into the selection criteria as well. Overall, we get individual enable signals for I/O space 0xE8-0xEF. Of these, we have the following mapping:
| I/O address | Name | Notes |
|---|---|---|
0xE8 |
nMMU_DH |
High order 4 bits for MMU read/writes. |
0xE9 |
nCTRL_REG |
Control register. Bit 0 enables the MMU. Bit 1 (when read) returns the mode, while bit 2 clears (on write) or returns (on read) the nSYSCALL status. Finally bit 3 returns whether interrupts are enabled (1) or not (0) |
0xEA |
nINTEN_REG |
Write-only: when written enables interrupts. The written value is ignored. |
0xEB |
nINTDIS_REG |
Write-only: when written disables interrupts. The written value is ignored. |
0xEC |
nSET_MODE_REG |
Write-only: when written, sets the mode bit. The written value is ignored. |
0xED |
nRETN_REG |
Write-only: when written, enables interrupts and sets the mode bit. The written value is ignored. |
The MMU is decoded into I/O space 0xF0-0xFF. U42, U15 and U24 is responsible for that, but U8 contributes here as well.
It is important to note that U16 decodes both read and write transactions. If a differentiation is needed, extra logic needs to be used. In the case of the CTRL_REG, this is achieved by U21 and U22.
Interrupts
We've come a long way: we implemented memory protection, memory mapping, access rights and I/O protection. We can tick off 1, 2, 3, 7, 8 and 9 from our list. Sort of. We have to talk about how the various state bits are controlled, but for that we'll have to talk about interrupts. So let's shift our focus on that topic!
The Z80 has two main interrupt mechanisms: maskable and non-maskable interrupts.
Maskable interrupts
These are the interrupts that most systems used. They are relatively easy to deal with in HW, but for us, they pose significant challenges. First, they can be disabled (using the DI instruction), second the Z80 has three different ways of dealing with interrupts. These modes are controlled by the IM0 IM1 and IM2 instructions. To keep using maskable interrupts, we need to neutralize all four of these instructions when executing in user mode. Luckily the Z80 tells us when it fetches an instruction (asserting both nM1 and nMREQ at the same time), unluckily the Z80 supports multi-byte instructions.
This page gives a good overview of the various access cycles: https://floooh.github.io/2021/12/06/z80-instruction-timing.html#dd-and-fd-prefixes. It clearly shows that multi-byte opcodes have multiple M1-cycles.
This means that we would not only need to decode and optionally override the data coming from memory during M1 cycles, but we would need to identify which M1 cycle corresponds to which byte of a multi-byte instruction. Or, in other words, we would need to know where the instruction boundaries are. This is not easy to do at all: there is no clear indication from the processor. We would essentially need to duplicate the instruction decode logic, or at least parts of it, externally.
Non-maskable interrupts (NMIs)
Our other option is to use non-maskable interrupts. These, as the name suggests cannot be masked by instructions, so there's no way for user code to disable them. That's the good news. However there's no way for system mode to disable them either, which is the bad news. What we need to create externally then is a way to disable and enable non-maskable interrupts.
Another quick side-note: In this design, I'm assuming that all interrupts are routed to the nNMI signal. The nINT pin is simply tied high. It also simplifies a little our subsequent logic as we don't have to deal with interrupt acknowledge cycles (when both nM1 and nIORQ is asserted). For us, nM1 only signals instruction fetches.
NMIs on the Z80 are edge-driven: a falling edge will trigger an NMI execution in the subsequent instruction fetch. That is, the execution of the current instruction finishes, but instead of executing the next instruction, the CPU saves the PC register to the stack, disables (in a complicated fashion) maskable interrupts and jumps to address 0x66. The timing of each clock cycle is documented here: https://floooh.github.io/2021/12/06/z80-instruction-timing.html#nmi-timing
Converting a more customary level-driven interrupt signal into an edge-driven one is not that complicated. There are even analog(ish) solutions to this. In here, I implemented a digital solution: a 3-bit shift-register which handles re-synchronizing the interrupt signal to the CPU clock and creating nice, controlled edges. Then, the proper edge-detector is a simple matter of decoding the right state of this shift register. I've hooked up U9 to be this shift register and U3 and U5 are responsible for the decoding. While I was at it, I incorporated the 'interrupt enable' logic as well; U3 has an extra input for that.
Now let's discuss how this 'interrupt enable' (INT_EN) signal is generated. Implementing a simple (I/O mapped) bit that can control whether interrupts are enabled is easy enough. The problem though is this: If we want to avoid nested interrupts, we need a way to automatically disable interrupts when an interrupt is registered by the Z80 and a way to safely re-enabled them upon returning from the interrupt handler.
Let's talk about the second problem first: how to re-enable interrupts when we need them? Let's say we try to do this with the following sequence:
OUT <INTEN_REG>, A ; value of A doesn't matter, all writes to this address enable interrupts
RETN
Now let's examine what happens if there is a pending interrupt before we execute our OUT instruction! Since we are in our interrupt handler, interrupts are disabled, the pending interrupt is not propagated to nNMI. As soon as the OUT instruction is executed, we re-enable interrupts so the nNMI line gets pulled low. This happens during the execution of the OUT instruction, which means that the Z80 will register it as a new NMI request and starts a new NMI handling sequence before executing the RETn instruction. This is problematic for two reasons: if we constantly get this unlucky, we won't make any forward progress on application code, we just keep on looping around in the interrupt handler. The machine freeze. Secondarily, we didn't remove the return address from the stack (RETn wasn't executed) before pushing a new one on it. This means that we consume and - eventually - exhaust our stack space.
Let's learn from the masters! How did Zilog deal with this? You might notice the same problem exists with interrupt handlers as well (those would use an EI/RETI instruction sequence). Zilogs solution to the problem was to delay the side-effects of EI by one instruction. We need to do the same here as well.
The implementation registers the write request from SW to enable interrupts (the pulse on nINTEN_REG) in U6: nINT_EN_WR goes low. This register is reset by the subsequent instruction fetch (nM1 asserted). In other words, the rising edge of the output on U6 gets delayed until the next instruction fetch, so in our case RETN. We use U14 to hold the 'interrupt enabled' state, so the rising edge of nINT_EN_WR sets this register. This takes care of enabling interrupts and their delay, but there is one remaining problem: since we only delay enabling of interrupts until the next instruction fetch, a pending interrupt can register during the execution of RETN, and take affect afterwards. What this means is that we've solved the stack overflow problem but we haven't necessarily solved the 'no forward progress' issue. For that, we would need to delay the enablement of interrupts even further, needing more logic. I decided to forgo that; this problem is minor enough to overlook, I think.
Now, let's consider the other half of the equation: disabling interrupts. We need to solve three situations:
- When an interrupt is served, we should disable further interrupts
- When the computer is reset, we should start executing with interrupts disabled
- We need a SW-controlled way to disable interrupts.
All these three sources drive the reset pin of U14 through U11 and U44. I think only the first needs a little more explanation: the reset of the 74LS74 is an asynchronous pin and it controls the state of the nNMI signal through pure combinatorial logic. We can't tie the reset pin directly to nNMI: that would create a combinatorial loop and a very short NMI signal. So, instead we delay nNMI by one clock cycle using another register in U9 and use that delayed variant to control the reset input.
Modes and interrupts
There is one more thing to take care of. Let's follow through the processing of an interrupt:
- Interrupt fires, this lowers the
nNMIsignal (for a clock cycle) - Interrupts get disabled, which releases the
nNMIsignal as a side-effect - Processor starts executing from address 0x66.
The question is: in what mode does the processor execute? So far, our logic haven't touched the mode bit, it's in whatever mode it was; most likely application mode. This means that while we successfully vectored our CPU to the system code handling interrupts, this system code is executing in user mode, which means it doesn't have access to I/O or the MMU, so it can't change memory mapping, or even re-enable interrupts. This might not seem hard to remedy at first: we just need to make sure that when the an NMI is fired (falling edge of nNMI) we clear the mode bit. There is a problem though: the interrupt fires in the middle of an instruction. This instruction might access memory or I/O, in general trying to do something nasty. If we flip the mode bit mid-execution, we would potentially allow some mischief. To fix this, we need to delay the clearing of the mode bit to the subsequent M1 cycle. This is safe: we know that NMIs can't be masked, so the M1 cycle after NMI fire is an ignored instruction fetch on the Z80 and that subsequent instructions are part of the NMI handler routine, which should always be executed in system mode.
From https://floooh.github.io/2021/12/06/z80-instruction-timing.html#nmi-timing:
NMI timing (starting with last clock cycle of instruction
where NMI was detected):
┌─────┬────┬──────┬──────┬──────┬────┬────┬─────┬──────┬──────┬────┬──────┬──────┐
│ T │ M1 │ MREQ │ IORQ │ RFSH │ RD │ WR │ INT │ HALT │ AB │ DB │ PC │ IFF1 │
├─────┼────┼──────┼──────┼──────┼────┼────┼─────┼──────┼──────┼────┼──────┼──────┤
│ X/0 │ │ MREQ │ │ RFSH │ │ │ │ HALT │ 0002 │ 00 │ 0002 │ IFF1 │ <== NMI detected
│ X/1 │ │ │ │ RFSH │ │ │ │ │ 0002 │ 00 │ 0002 │ │ <== IFF1 and HALT cleared
├─────┼────┼──────┼──────┼──────┼────┼────┼─────┼──────┼──────┼────┼──────┼──────┤
│ 1/0 │ M1 │ │ │ │ │ │ │ │ 0002 │ 00 │ 0002 │ │ <== NMI 'opcode fetch' (ignored)
│ 1/1 │ M1 │ MREQ │ │ │ RD │ │ │ │ 0002 │ 00 │ 0002 │ │ <== PC not incremenred!
│ 2/0 │ M1 │ MREQ │ │ │ RD │ │ │ │ 0002 │ 00 │ 0002 │ │
│ 2/1 │ M1 │ MREQ │ │ │ RD │ │ │ │ 0002 │ 00 │ 0002 │ │
│ 3/0 │ │ │ │ RFSH │ │ │ │ │ 0003 │ 00 │ 0002 │ │
│ 3/1 │ │ MREQ │ │ RFSH │ │ │ │ │ 0003 │ 00 │ 0002 │ │
│ 4/0 │ │ MREQ │ │ RFSH │ │ │ │ │ 0003 │ 00 │ 0002 │ │
│ 4/1 │ │ │ │ RFSH │ │ │ │ │ 0003 │ 00 │ 0002 │ │
This delay is implemented by U7. Now we can use it's output (nINT_PENDING) to clear the mode bit.
Programmatic control of the mode bit
We need a way of course to return from system to user mode. We need a way to set the mode bit. This is done by writing to the USER_MODE_REG I/O location. The actual value is ignored, only the fact that a write was issued matters.
Note: it being an I/O location, which is not available in user mode, clearing this bit is rather pointless. Also note that the application has no way of influencing the instruction at location 0x66 if the MMU is set up properly and it has no way of changing how the MMU is set up from user mode.
With this however we need to change our way of returning from interrupts:
OUT <INTEN_REG>, A ; value of A doesn't matter, all writes to this address enable interrupts
OUT <SET_MODE_REG>, A ; enter user mode
RETN
There is a huge problem here: we enable interrupts before RETN. That means that we're back to the race where a pending interrupt can overflow the stack. We can't reverse the order of the two OUT-s either: we can't enable interrupts from user mode. We can work around this problem by a programmed jump instead of a RETN:
POP HL
LD (return_loc+1), HL
... continue restoring registers
OUT <INTEN_REG>, A
OUT <SET_MODE_REG>, A ; We can get interrupted after this, but that's OK: the stack is cleared already
return_loc:
JP 0x0000 ; We don't need the functionality of RETN: we don't have interrupts and the stack is taken care of already
This is self-modifying code: we write the return address into the immediate value of the JP instruction.
Note, that the JP instruction executes in user mode, but that's fine: it only does reads which are always allowed.
After counting up all the gates used, it turns out that we can do one better: we can add another I/O register (RETN_REG) which, when written combines the two functions we need into a single instruction: enables interrupts and sets the mode bit. With that, we can go back to our original, cheaper handling of the return to user mode:
OUT <RETN_REG>, A ; value of A doesn't matter, all writes to this address enable interrupts and sets the mode bit
RETN
We need two extra AND gates but as it so happens, we have exactly two extra, unused one. On the schematic, these are U12 and U45.
This is probably as good a time as any to talk about why the written data is ignored for these operations: the way we return from system to user mode is by restoring the CPU state (that is, all the registers) to what the user mode process expects. I'm not showing this in the code-snippets, but it needs to happen. The point is, after restoring this state, we can't touch any of the registers. This means that the instruction we use to enable interrupts, return to user mode (or both) has to be one that doesn't depend on any registers. EI for instance is such an instruction, that's why it can be used as the next-to-last instruction in an interrupt handler. We need something similar, but for our external interrupt-enable logic. An OUT n, A instruction that doesn't depend on the output data, just the address is one such option: the address is part of the instruction and we ignore the value in A.
System calls
We're getting there: we created a mechanism to deal with interrupts and disallow applications from mocking with the interrupt state. We can tick of requirements 5 and 6. This leaves us with point 4: communication with the operating system.
This involves a controlled way of getting from user to system mode and back. In x86 for instance, this is handled by 'traps': operations that are guaranteed to generate some sort of exception that can later be disambiguated by the exception handler (in system mode) and recognized as a system call. Other architecture provide specific instructions for the same purpose, but they function in a similar fashion.
The Z80 has none of this. It doesn't even have a way to generate SW interrupts (something that even the 8086 had).
It would be tempting to say that we should provide an I/O address that - when written to - triggers an NMI. Then, once in system mode we can determine that the interrupt came from writing that particular register (another sticky-bit is needed for this) and handle the situation accordingly. The problem however is that we disabled I/O access from user mode. A variation of this idea would work though: we can recognize I/O operations from user mode and trigger interrupts from that condition. This is easy to do: if mode is set to 1 and we see an nIOREQ becoming low, we can treat that as our 'trap' condition, trigger an interrupt (of course an NMI in our system).
The handling of this interrupt would enter system mode with the return address being the instruction after the I/O operation (IN or OUT for instance). Since these operations contain the address to which the I/O was directed to, simply looking at the byte before the return address gives us the system call number. This gives us 256 possible system calls (512 if we distinguished between IN and OUT operations). This should be plenty. Further arguments can be passed in registers.
Returning from a system call happens the same way as from any other interrupt with the possible exception that the registers are not set to their original values, but carry information returned from the syscall.
To detect whether a syscall was invoked though we need a way to detect that it was in fact an I/O operation in user-mode that caused the interrupt. This status information is stored in U23 and can be cleared by (system-mode) SW.
CP/M compatibility
The most popular operating system of the time was CP/M. That implemented system calls as follows:
LD DE,parameter
LD C,function
CALL 5
In our system address '5' maps to the first page, something that's read only for the user-mode application. We can put code there that switches over to system mode as described above, handle the system call and return to the caller as CP/M normally would.
CP/M normally loads applications to address (would be logical address for us) 0x0100, which is problematic for us: our first page ends at logical address 0x0fff and we keep our first page read-only for user-mode.
Reducing our page size to 256 bytes is not an option: we need way too many pages (256 in total) which is too expensive for HW and slow for context-switching.
We could special case page-0 and implement a custom write-enable logic: the write-enable bit in the MMU is only considered for addresses 0x0100-0x0fff. For the first 256 bytes, it's the mode bit that controls write-enable. This works, but is ugly and requires some extra address decoding logic.
So, I decided to reserve two write-enable bits: one for the first 256-byte section within a page and one for the rest. This is why we only have 10 address extension bits. We also need logic to decide which of the two write-enable bits to use. This logic is done by U32, U37, U33, U38, U39, U40 and U41. Looks like a formidable list, but it can be implemented in just two chips plus a spare inverter from somewhere else.
Conclusion
So, can the Z80 multi-task? Yes it can, but only with significant help. As far as implementation complexity, the MMU was available as a singe-chip solution albeit an expensive one. The rest of the logic can be implemented, as drawn in 15 chips.
I'm sure the circuit can be optimized to reduce chip-count somewhat, but I'm reluctant to do that without testing. Also, those optimizations would almost certainly make the circuit harder to read, so I decided to keep things simple.
With a handful of PALs which can replace most AND/OR/NOT gates, the chip-count can be reduced even more; especially if registered variants are used.
One could even envision a ULA implementation for all but the MMU part, resulting in a two-chip solution. This is not that crazy: by the time Z80 systems regularly came with more than 64k of memory, most of them were built around ULAs of sort anyway.
One could even imagine a progression: early on, ship a model with a discrete solution. Large and expensive, but this is for a 'Cadillac' machine. Over time as the design matures, it can be cost-reduced into a few PALs, eventually rolling it into a ULA-based design when follow-on models are released.
This is of course just a game of imagination. Yes, doable, no didn't happen. At least not that I'm aware of. If you know of examples, let me know!
Leave a comment
Comments are reviewed before they are published, so yours will not show up right away.
You can use
**strong**and*italic*text,`code`, and lists (lines starting with-or1.). Everything else, HTML included, is shown as it was typed.