python-pdp1170/unibus.py
2024-05-03 07:48:18 -05:00

305 lines
13 KiB
Python

# MIT License
#
# Copyright (c) 2023 Neil Webber
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from enum import Enum
from interrupts import InterruptManager
from pdptraps import PDPTraps, PDPTrap
BusCycle = Enum('BusCycle', ('READ16', 'WRITE16', 'WRITE8', 'RESET'))
BusWrites = {BusCycle.WRITE16, BusCycle.WRITE8} # convenience
class UNIBUS:
def __init__(self, cpu):
self.cpu = cpu
self.intmgr = InterruptManager()
self.logger = cpu.logger
self.mmiomap = [self.__nodev] * (self.cpu.IOPAGE_SIZE >> 1)
def resetbus(self):
# this isn't especially efficient but it really doesn't matter.
# Construct a sequence of tuples: (a, f)
# where a is the address (really offset) in the I/O page
# and f is the callback
# but only for callbacks that are not __nodev
# and then invoke those callbacks w/BusCycle.RESET
resets = ((x << 1, f)
for x, f in enumerate(self.mmiomap) if f != self.__nodev)
for ioaddr, f in resets:
f(ioaddr, BusCycle.RESET)
# register() -- connect a callback function to an I/O page address
#
# A convenient reference for addresses:
# https://gunkies.org/wiki/UNIBUS_Device_Addresses
#
# memory-mapped I/O is just a 4K array of function callbacks, one
# entry per each I/O WORD (even) I/O offset. Most entries, of course,
# remain set to an initial default "non-existent address" callback.
#
# Callbacks must be declared like this:
# def f(ioaddr, cycle, /, *, value=None):
#
# and should contain code dispatching on cycle and (potentially) ioaddr.
# If cycle is WRITE8 (byte) or WRITE16 (word), the 'value' argument will
# be supplied. Otherwise 'value' is not supplied.
#
# If cycle is READ16 (word), the read value should be returned. Note that
# no READ8 exists as the UNIBUS is not capable of expressing a byte read.
#
# The processor RESET instruction generates calls with a RESET cycle.
#
# The ioaddr will always be relative to the base of the I/O page:
# (ioaddr & 8191) == ioaddr will always be True
#
# If a device's semantics are simple enough to allow synthesizing a
# WRITE8 as a READ16 / byte-update / WRITE16 sequence it can use
# the autobyte wrapper and omit a WRITE8 cycle handler. If the underlying
# address is write-only the autobyte wrapper substitutes a zero value
# for the READ16 part of the synthesis. If this is not correct, the
# callback must handle WRITE8 itself directly.
#
# Callback functions that need context or other data can, of course,
# be bound methods (automatically receiving self) or also can use
# functools.partial() to get additional arguments passed in.
#
#
# ODD I/O ADDRESSES, BYTE OPERATIONS:
#
# The physical UNIBUS always *reads* full 16-bit words; there is no
# capability for a byte read at the electrical bus level.
#
# Oddly (haha) the bus *can* specify byte access for writes.
# Even odder (hahaha), devices that provide full-word access at
# odd addresses exist, the best example being the CPU itself -- though
# as it turns out only very few models allow **CPU** access to cpu
# registers via the Unibus (vs allowing other devices, e.g., the console
# switches/display, to access them that way).
#
# There are a lot of subtleties to take into account accessing I/O
# addresses via bytes (and possibly-odd addresses) but so be it.
# Such byte operations in I/O space abound in real world code.
#
# Devices that respond to a block of related addresses can register
# one callback to cover more than one word of Unibus address space.
# The callback gets the ioaddr which it can further decode. Again,
# the CPU register block is a good example of this.
#
def register(self, iofunc, offsetaddr, nwords=1, /):
"""register a callback for an I/O address.
Arguments:
iofunc -- callback function. See documentation for signature.
offsetaddr -- Offset within the 8K I/O page.
nwords -- Optional. Default=1. Number of words to span.
iofunc may be None in which case a dummy is supplied that ignores
all write operations and always reads as zero.
"""
if offsetaddr >= self.cpu.IOPAGE_SIZE:
raise ValueError(f"UNIBUS: offset too large {oct(offsetaddr)}")
# None is a shorthand for "this is a dummy always-zero addr"
if iofunc is None:
iofunc = self.autobyte(self.__ignoredev)
idx, odd = divmod(offsetaddr, 2)
if odd != 0:
# See discussion of odd I/O addrs in block comment elsewhere
raise ValueError("cannot register odd (byte) address in IO space")
for i in range(nwords):
self.mmiomap[idx+i] = iofunc
# this is a convenience routine for devices that want to signal things
# such as a write to a read-only register (if they don't simply just
# ignore them). Devices can, of course, just raise the traps themselves.
def illegal_cycle(self, addr, /, *, cycle=BusCycle.WRITE16, msg=None):
if msg is None:
msg = f"Illegal cycle ({cycle}) at {oct(addr)}"
self.cpu.logger.info(msg)
raise PDPTraps.AddressError(cpuerr=self.cpu.CPUERR_BITS.UNIBUS_TIMEOUT)
# the default entry for unoccupied I/O: cause an AddressError trap
def __nodev(self, addr, cycle, /, *, value=None):
self.illegal_cycle(addr, cycle=cycle,
msg=f"Non-existent I/O @ offset {oct(addr)}")
# Devices may have simple "dummy" I/O addresses that always read zero
# and ignore writes; See "if iofunc is None" in register() method.
# NOTE: register() byteme-wraps this so opsize not needed.
def __ignoredev(self, addr, cycle, /, *, value=None):
self.cpu.logger.debug(f"dummy zero device @ {oct(addr)}, {value=}")
return 0
# wrap an I/O function with code that automatically handles byte writes.
# CAUTION: Some devices may have semantics that make this ill-advised.
# Such devices should handle WRITE8 explicitly themselves.
# This is essentially a decorator but typically is invoked explicitly
# rather than by '@' syntax.
def autobyte(self, iofunc):
def byteme(ioaddr, cycle, /, **kwargs):
if cycle != BusCycle.WRITE8:
return iofunc(ioaddr, cycle, **kwargs)
# A byte write to I/O space. Synthesize it.
# 'value' is present (by definition) in kwargs
value = kwargs['value']
self.cpu.logger.debug(f"Byte write to {oct(ioaddr)} {value=}")
try:
wv = iofunc(ioaddr & 0o177776, BusCycle.READ16)
except PDPTrap: # this can happen on write-only registers
wv = 0
if ioaddr & 1:
wv = (wv & 0o377) | (value << 8)
else:
wv = (wv & 0o177400) | value
iofunc(ioaddr & 0o177776, BusCycle.WRITE16, value=wv)
return byteme
# Convenience method -- registers simple attributes (or properties) into
# I/O space in the obvious way: Make this attr (of obj) show at this addr
#
# BusCycle.RESET handling: If a device wants RESET to zero the attribute,
# it should specify reset=True. Otherwise RESET will be ignored.
#
# BusCycle.WRITE8 handling: autobyte() is used. If those semantics are not
# suitable, the device must create its own i/o func instead of this.
#
# readonly: If True (default is False) then writes to this address will
# cause an AddressError trap. The device implementation can
# safely (if it wants to) implement the attribute directly
# (i.e., without using @property) and no write to the attribute
# will ever originate from here.
#
def register_simpleattr(self, obj, attrname, addr, /, *,
reset=False, readonly=False):
"""Create and register a handler to read/write the named attr.
obj - the object (often "self" for the caller of this method)
attrname - the attribute name to read/write
addr - the I/O address to register it to
If attrname is None, the address is registered as a dummy location
that ignores writes and will always read as zero. This is sometimes
useful for features that have to exist but are emulated as no-op.
reset - if True, attrname will be set to zero on BusCycle.RESET
readonly - if True, attrname is readonly and will never be written.
Write cycles to the addr will cause AddressError.
"""
# could do this with partial, but can also do it with this nested
# func def. One way or another need this func logic anyway.
if attrname is None:
def _rwattr(_, cycle, /, *, value=None):
if cycle == BusCycle.READ16:
return 0
if cycle in BusWrites and readonly:
self.illegal_cycle(addr, cycle)
# all other operations just silently ignored
else:
def _rwattr(_, cycle, /, *, value=None):
if cycle == BusCycle.READ16:
return getattr(obj, attrname)
elif cycle in BusWrites:
if readonly:
self.illegal_cycle(addr, cycle)
else: # autobyte assumed ... see register() below
setattr(obj, attrname, value)
elif cycle == BusCycle.RESET:
if reset:
setattr(obj, attrname, 0)
else:
assert False, f"Unknown {cycle=} in simpleattr"
# NOTES:
# * it's a new defn/closure of _rwattr each time through, so the
# individual (per registration) addr/etc values are closure'd
# * Do not autobyte if readonly, simply so that the correct
# (unmolested) BusCycle.WRITE8 will be seen in the trap/errors
# * _rwattr ASSUMES autobyte() wrapper if not readonly
if readonly:
self.register(_rwattr, addr)
else:
self.register(self.autobyte(_rwattr), addr)
def wordRW(self, ioaddr, value=None, /):
"""Read (value is None) or write the given I/O address."""
if value is None:
value = self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.READ16)
else:
self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.WRITE16, value=value)
return value
def byteRW(self, ioaddr, value=None, /):
"""UNIBUS byte R/W - only write is legal."""
if value is None:
raise ValueError("Unibus cannot read bytes")
else:
self.mmiomap[ioaddr >> 1](ioaddr, BusCycle.WRITE8, value=value)
return None
class UNIBUS_1170(UNIBUS):
UBMAP_OFFS = 0o10200
UBMAP_N = 62
def __init__(self, cpu):
super().__init__(cpu)
# UBAs being 32-bit (well, really 22 bit) values, they
# are just stored natively that way and broken down
# into 16-bit components by the mmio function as needed.
self.ubas = [0] * (self.UBMAP_N // 2)
self.register(
self.autobyte(self.uba_mmio), self.UBMAP_OFFS, self.UBMAP_N)
def uba_mmio(self, addr, cycle, /, *, value=None):
ubanum, hi22 = divmod(addr - self.UBMAP_OFFS, 4)
uba22 = self.ubas[ubanum]
self.logger.debug(f"UBA addr={oct(addr)}, {value=}")
self.logger.debug(f"{ubanum=}, {hi22=}")
if cycle == BusCycle.READ16:
if hi22 == 0:
return (uba22 >> 16) & 0o077
else:
return uba22 & 0o177777
elif cycle == BusCycle.WRITE16:
# the low bit is enforced to be zero
if hi22:
uba22 = ((value & 0o077) << 16) | (uba22 & 0o177776)
else:
uba22 = (uba22 & 0o17600000) | (value & 0o177776)
self.ubas[ubanum] = uba22
elif cycle == BusCycle.RESET:
pass
def busRW(self, ubaddr, value=None):
pass