These Memories Can’t Wait

Basics

Memory access is one of the most critical parts of an emulation. If memory access is slow, the emulation is slow. The Z80 does at least one memory read for each iteration of its fetch-execute loop.

I chose to take the same approach as I had with the 6502 emulation, which is to say: simple. One of the great things about emulating processors with a 16 bit address bus is that the entire memory can be modeled with a mere 64 kilobyte array, so that is what I did.

 private var memory = [UInt8](repeating: 0, count: 0x10000)

This is using a standard Swift array. There are already potential optimisations to be done because Swift arrays need not be contiguous and they have bounds checking on all accesses where the index cannot be proved to be less than the length. However, we’ll leave that until later.

Having created the memory, we need to access. As a first approximation, we could simply subscript it. However, in reading about the way the Z80 implements interrupts, I found out that in one mode, the interrupting device can place a byte on the data bus to determine the interrupt vector. Thus, I thought, it might make it easier to create a property to model the data bus and do reads and writes through it. For convenience, therefore, memory accesses are done by a pair of functions as follows:

private func readData(address: UInt16)
{
    dataBus = memory[Int(address)]
}
private func writeData(address: UInt16)
{
    memory[Int(address)] = dataBus
}

Devices

There is another consideration, which is “how do we model memory mapped devices?” The 6502 does not have a separate IO space so all its devices are memory mapped so I had to find a solution. The problem is not as serious on the Z80, particularly with the Spectrum which seems to do all of its IO via the ULA chip using its IO operations. However, it’s still useful to have the ability to map devices because they can do things like emulate operating systems or provide debugging probes, a bit like watch points.

The scheme I use is lifted directly from the 6502 implementation. readData and writeData have hooks in them to call a method on an object that conforms to the MemoryMappedDevice protocol.

 private func readData(address: UInt16)
{
    let index = Int(address)
    if let device = devices[index]
    {
       device.willRead(address &- device.baseAddress, byte: &memory[index])
    }
    dataBus = memory[index]
}

private func writeData(address: UInt16)
{
    let index = Int(address)
    memory[Int(address)] = dataBus
    if let device = devices[index]
    {
        device.didWrite(address &- device.baseAddress, byte: &memory[index])
    }
}

There is an array called devices that contains the memory mapped device for every single addressable location. Most of them are `nil` but, if there is a device, it calls the hook function before a read or after a write. Note that the byte is passed as an inout parameter. This allows the device to modify the byte before the read or after the write. So, for example, a crude device that emulates ROM might change the byte back to its correct value after the processor has written to it.

Here is the protocol for memory mapped devices.

///
/// Protocol that allows a device to be mapped to a memory address
///
/// A memory mapped device consists of a number of contiguous ports that should
/// be mapped starting from a particular base address.  Once mapped, the device
/// will be notified before each read and after each write.
///
public protocol MemoryMappedDevice: class
{
    /// Number of ports the
    var portCount: Int { get }
    /// Base address at which the ports should be mapped.
    var baseAddress: UInt16 { get }
    ///
    /// The CPU wrote to the given port.
    ///
    /// - Parameter portNumber: The port which was written to.
    /// - Parameter byte: The byte that was written.  This is an inout parameter
    /// to allow the device to modify the memory address if need be.
    ///
    func didWrite(_ portNumber: UInt16, byte: inout UInt8)
    ///
    /// The CPU is about to read from the given port.
    ///
    /// - Parameter portNumber: The port which will be read.
    /// - Parameter byte: The byte that will be read.  This is an inout parameter
    /// to allow the device to modify the memory address if need be.
    ///
    func willRead(_ portNumber: UInt16, byte: inout UInt8)
    ///
    /// Push a device for chaining.
    ///
    /// This allows a device to map to a port that is already mapped.  If the
    /// device doen't support chaining (for example, it has a performance cost)
    /// this function should fatalError.
    ///
    /// There is no built in support for device chaining.  Devices that do it
    /// must implement it in some way in their willRead and didWrite methods.
    ///
    /// - Parameter previousDevice: The device to push.
    /// - Parameter port: The port that device to push is on.
    ///
    func pushDevice(_ device: MemoryMappedDevice, port: UInt16)
}

In addition to the two hook functions, the device must know its location in memory (base address) and how many ports it has. The reason it needs to know its location in memory is that it has to be kept somewhere and it seemed faster to put it here than create a struct with an extra level of indirection.

Here is an example of a memory mapped device. This is the device that emulates the CPM output functions for the zexall and zexdocs test programes. The CPM simulator is its own memory mapped device and it maps itself to address 0x0005 which is the entry point for CPM. If the CPU reads from this address and the program counter points to the next address (0x0006), it assumes, the z80 has just jumped there and is trying to execute a CPM BIOS call. It then decodes and executes the call and finally makes sure that the read will return bye 0xc9 which is the Z80 return instruction.

A Small Optimisation

Both the memory array and the device map are exactly 216 elements in size. Furthermore, if we access them through readData and writeData exclusively, we cannot get an array bounds error. So it was a trivial exercise to replace the Swift arrays with unsafe buffers.

var memory = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(UInt16.max) + 1)
var devices = UnsafeMutablePointer<MemoryMappedDevice?>.allocate(capacity: Int(UInt16.max) + 1)

By itself, this small tweak changes the nominal speed doing zexall from about 220 MHz to over 300 MHz.

That’s it for memory. Next time we’ll talk about instruction decoding.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.