First release

This commit is contained in:
Neil Webber 2023-09-04 12:49:58 -05:00
parent 0a9c8f617d
commit 7760907dff
17 changed files with 4595 additions and 0 deletions

45
boot.py Normal file
View file

@ -0,0 +1,45 @@
def boot_hp(p, addr=0o10000):
# this is the sort of thing that would be keyed in from
# the console switches (if the machine was not equipped
# with a boot rom option to hold it instead)
#
# It is a minimalist program, with lots of assumptions, to read 1K
# from block zero of drive 0 into location 0. The execution start
# at zero is done elsewhere.
#
# NOTE WELL: THIS ASSUMES THE MACHINE IS IN RESET CONDITION WHICH
# MEANS MANY OF THE DEVICE REGISTERS ARE KNOWN TO BE ZERO
#
# MOV #176704,R0 -- note how used
# MOV #177000,-(R0) -- word count - read 1K though boot really 512
# MOV #071,-(R0) -- go!
program_insts = (
0o012700, # MOV #0176704,R0
0o176704,
0o012740, # MOV #177000,-(R0)
0o177000,
0o012740, # MOV #071, -(R0)
0o000071,
0o0, # HALT
)
for o, w in enumerate(program_insts):
p.physRW(addr + o + o, w)
return addr
if __name__ == "__main__":
import time
from machine import PDP1170
p = PDP1170(loglevel='INFO')
pc = boot_hp(p)
print("starting PDP11; telnet/nc to localhost:1170 to connect to console")
print("There will be no prompt; type 'boot' to start boot program")
p.run(pc=pc)
# technically need to confirm the drive is RDY, i.e., the read
# completed, but using a delay is a lot simpler and works fine.
# In real life, humans would have manipulated console switches to
# start execution at location 0, which is also a source of delay. :)
time.sleep(0.05)
p.run(pc=0)

61
branches.py Normal file
View file

@ -0,0 +1,61 @@
# 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.
# keyed by masked "base code" (upper byte), not shifted
brconds = {
# NOTE: 000400 case is handled in op000 dispatch separately
# 0o000400: lambda n, z, v, c: True, # BR
0o001000: lambda n, z, v, c: not z, # BNE
0o001400: lambda n, z, v, c: z, # BEQ
0o100000: lambda n, z, v, c: not n, # BPL
0o100400: lambda n, z, v, c: n, # BMI
0o102000: lambda n, z, v, c: not v, # BVC
0o102400: lambda n, z, v, c: v, # BVS
0o103000: lambda n, z, v, c: not c, # BCC
0o103400: lambda n, z, v, c: c, # BCS
# CAUTION: Python XOR ("^") is bitwise; hence bool() != for ^
0o002000: lambda n, z, v, c: bool(n) == bool(v), # BGE
0o002400: lambda n, z, v, c: bool(n) != bool(v), # BLT
0o003000: lambda n, z, v, c: (bool(n) == bool(v)) and not z, # BGT
0o003400: lambda n, z, v, c: (bool(n) != bool(v)) or z, # BLE
0o101000: lambda n, z, v, c: not (c or z), # BHI
0o101400: lambda n, z, v, c: c or z, # BLOS
# NOTE: These two are the same as BCC/BCS respectively
# 0o103000: lambda n, z, v, c: not c, # BHIS
# 0o103400: lambda n, z, v, c: c, # BLO
}
def branches(cpu, inst):
branch(cpu, inst, brconds[inst & 0o177400])
def branch(cpu, inst, condition):
if condition(cpu.psw_n, cpu.psw_z, cpu.psw_v, cpu.psw_c):
offset = (inst & 0o377) * 2
if offset >= 256: # i.e., was a negative 8-bit value
offset -= 512
cpu.r[7] = cpu.u16add(cpu.r[7], offset)

246
interrupts.py Normal file
View file

@ -0,0 +1,246 @@
# 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 collections import namedtuple
from functools import partial
import threading
from pdptraps import PDPTrap
# an interrupt is, at the cpu implementation level, just a flavor of trap.
class InterruptTrap(PDPTrap):
def __init__(self, pri, vector):
super().__init__()
self.pri = pri
self.vector = vector
# contains the details for a pending interrupt (see discussion)
PendingInterrupt = namedtuple(
'PendingInterrupt', ('pri', 'vector', 'callback'))
# To cause an interrupt, a device creates a PendingInterrupt containing:
# pri -- priority
# vector -- the interrupt vector
# callback -- see discussion
#
# and then calls pend_interrupt() to get things going.
#
# The interrupt does not, of course, occur right away; it pends until
# the processor is willing to accept it. The processor only accepts
# interrupts at instruction boundaries, and even then only if the current
# processor priority level is below the interrupt pri.
#
# When those conditions occur, the interrupt is accepted by the processor
# (via a get_pending() method called from the processor). At that time the
# interrupt request is "granted" and the callback function, if any is
# provided, is invoked (from the cpu thread).
#
# The callback is invoked with no arguments and the return value is ignored.
# Use partial() or other python techniques if the callback function requires
# arguments for more context information (most will not).
#
# The purpose of this callback protocol is that some devices have internal
# operations they want to perform when the interrupt is acknowledged, not
# just when it is first made pending. Callbacks allow for that to happen.
# CAUTION: The callback obviously executes in a separate thread and
# will be asynchronous to any device-internal threads.
#
# In the simplest/common cases where none of this is needed, the
# method simple_irq() bundles all this minutia up for the caller.
class InterruptManager:
def __init__(self):
self.pri_pending = 0
self.requests = []
self.condition = threading.Condition()
def simple_irq(self, pri, vector):
"""Pend an interrupt at the given pri/vector."""
self.pend_interrupt(PendingInterrupt(pri, vector, callback=None))
def pend_interrupt(self, irq):
"""Pend a request for interrupt 'irq'."""
with self.condition:
# special case to accelerate zero-to-one common transition
if not self.requests:
self.requests = [irq]
self.pri_pending = irq.pri
else:
# multiple identical requests are not pended
# (it works this way in the hardware too of course --
# if a device has asserted the interrupt request line
# but that request hasn't been acknowledged/cleared by
# by the bus signal protocol yet, you can't assert the
# same interrupt line again ... it's already asserted)
if irq not in self.requests:
self.requests = sorted(
self.requests + [irq], key=lambda q: q.pri)
self.pri_pending = self.requests[-1].pri
self.condition.notify_all()
# called by the processor, to get one pending interrupt (if any).
# An InterruptTrap with the highest priority is returned, IF it is
# above the given processor priority. Else None.
def get_pending(self, processor_pri):
"""Returns an InterruptTrap, or None."""
with self.condition:
try:
if self.pri_pending > processor_pri:
irq = self.requests.pop()
else:
return None
except IndexError:
return None
else:
if self.requests:
self.pri_pending = self.requests[-1].pri
else:
self.pri_pending = 0
if irq.callback:
irq.callback()
return InterruptTrap(irq.pri, irq.vector)
def waitstate(self, processor_pri):
"""Sit idle until any interrupt happens."""
with self.condition:
if self.pri_pending > processor_pri:
return
self.condition.wait_for(lambda: self.pri_pending)
if __name__ == "__main__":
import unittest
class TestMethods(unittest.TestCase):
def test__init__(self):
IM = InterruptManager()
# initial state starts with no pending interrupts
self.assertEqual(IM.pri_pending, 0)
# verify get_pending still "works" (returns None)
self.assertEqual(IM.get_pending(0), None)
def test_queue1(self):
IM = InterruptManager()
test_pri = 4 # arbitrary
test_vec = 17 # arbitrary
IM.simple_irq(test_pri, test_vec)
self.assertEqual(IM.pri_pending, test_pri)
iinfo = IM.get_pending(0)
self.assertEqual(IM.pri_pending, 0)
self.assertEqual(iinfo.pri, test_pri)
self.assertEqual(iinfo.vector, test_vec)
# support function for test cases, do a bunch of actions on an IM
def _actions(self, IM, prog):
cpupri = 0
for action in prog:
match action[0], action[1]:
case 'RQ', t:
IM.simple_irq(*t)
case 'PRI', cpupri:
pass
case 'GET', xt:
t = IM.get_pending(cpupri)
if t is None:
self.assertEqual(t, xt)
else:
xpri, xvec = xt
# If the vector position is a tuple then that
# means to accept anything in that tuple
try:
_ = (t.vector in xvec)
except TypeError:
pass
else:
xvec = t.vector # i.e., it's ok
self.assertEqual(t.pri, xpri)
self.assertEqual(t.vector, xvec)
case 'CHK', pri:
self.assertEqual(IM.pri_pending, pri)
case _:
raise ValueError("bad action", action)
def test_mixedops(self):
testprogs = (
# (ACTION, ACTION-INFO)
(('RQ', (4, 44)), # request IRQ 4
('RQ', (5, 55)), # request IRQ 5
('GET', (5, 55)), # get one, check that it is 5
('CHK', 4), # check that pri_pending is 4
('RQ', (3, 33)), # request IRQ 3
('CHK', 4), # check that pri_pending is 4
('RQ', (6, 66)), # request IRQ 6
('CHK', 6), # check that pri_pending is 6
('GET', (6, 66)), # get one, check that it is 6
('CHK', 4), # check that pri_pending is 6
('GET', (4, 44)), # get one, check that it is 4
('CHK', 3), # check that pri_pending is 3
('GET', (3, 33)), # get one, check that it is 3
('CHK', 0), # check that pri_pending is 0
('GET', None), # check that getting from empty works
),
# check priority filtering
(('RQ', (4, 44)), # request IRQ 4
('RQ', (5, 55)), # request IRQ 5
('PRI', 7), # spl7
('GET', None), # shouldn't see anything
('PRI', 5), # spl5
('GET', None), # still shouldn't see anything
('RQ', (6, 66)), # request IRQ 6
('RQ', (7, 77)), # request IRQ 7
('RQ', (6, 666)), # request IRQ 6
('RQ', (7, 777)), # request IRQ 7
('PRI', 6), # spl6
('GET', (7, (77, 777))), # should get one of these
('GET', (7, (77, 777))), # should get the other
('GET', None), # no more
('PRI', 0), # spl0
('GET', (6, (66, 666))), # should get one of these
('GET', (6, (66, 666))), # should get one of these
('GET', (5, 55)),
('GET', (4, 44)),
('GET', None)),
)
for tp in testprogs:
IM = InterruptManager()
self._actions(IM, tp)
def test_vectorcallback(self):
def foo(d):
d['foo'] = 1234
foodict = {}
pfoo = partial(foo, foodict)
IM = InterruptManager()
IM.pend_interrupt(PendingInterrupt(4, 888, pfoo))
iinfo = IM.get_pending(0)
self.assertEqual(foodict['foo'], 1234)
unittest.main()

192
kl11.py Normal file
View file

@ -0,0 +1,192 @@
# 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.
# simulation of a KL-11 console interface
#
# Trivial TCP server that accepts connections on port 1170 (cute)
# and simply proxies character traffic back and forth.
#
import sys
import socket
import time
import threading
import queue
from pdptraps import PDPTraps
class KL11:
KL11_DEFAULT = 0o17560 # offset within I/O page
BUSY = 0o400 # probably no reason to ever set this
RCDONE = 0o200 # means character available in buf
TXRDY = 0o200 # same bit on tx side is called "RDY"
IENABLE = 0o100
RDRENA = 0o001
_SHUTDOWN_SENTINEL = object()
SERVERHOST = ''
SERVERPORT = 1170
def __init__(self, ub, baseaddr=KL11_DEFAULT):
self.ub = ub
self.addr = ub.mmio.register(self.klregs, baseaddr, 4)
ub.mmio.devicereset_register(self.reset)
# output characters are just queued (via tq) to the output thread
# input characters have to undergo a more careful 1-by-1
# dance to properly match interrupts to their arrival
self.tq = queue.Queue()
self.rxc = threading.Condition()
# bits broken out of virtualized KL11 Reader Status Register
self.rcdone = False
self.r_ienable = False
# reader buffer register (address: baseaddr + 2)
self.rdrbuf = 0
# transmit buffer status (address: baseaddr + 4)
self.t_ienable = False
# The socket server connection/listener
self._t = threading.Thread(target=self._connectionserver, daemon=True)
self._t.start()
def reset(self, ub):
"""Called for UNIBUS resets (RESET instruction)."""
self.rcdone = False
self.r_ienable = False
self.r_tenable = False
def klregs(self, addr, value=None, /):
match addr - self.addr:
case 0: # rcsr
if value is None:
# *** READING ***
value = 0
if self.r_ienable:
value |= self.IENABLE
if self.rcdone:
value |= self.RCDONE
else:
# *** WRITING ***
if value & self.RDRENA:
with self.rxc:
# a request to get one character, which only
# has to clear the rcdone bit here.
self.rcdone = False
self.rdrbuf = 0
self.r_ienable = (value & self.IENABLE)
self.rxc.notify()
case 2 if value is None: # rbuf
# *** READING ***
with self.rxc:
value = self.rdrbuf
self.rcdone = False
self.rxc.notify()
# transmit buffer status (sometimes called tcsr)
case 4:
if value is None:
# *** READING ***
value = self.TXRDY # always ready to send chars
if self.t_ienable:
value |= self.IENABLE
else:
# *** WRITING ***
prev = self.t_ienable
self.t_ienable = (value & self.IENABLE)
if self.t_ienable and not prev:
self.ub.intmgr.simple_irq(pri=4, vector=0o64)
# transmit buffer
case 6 if value is not None: # tbuf
# *** WRITING ***
value &= 0o177
if (value != 0o177):
s = chr(value)
self.tq.put(s)
if self.t_ienable:
self.ub.intmgr.simple_irq(pri=4, vector=0o64)
case _:
raise PDPTraps.AddressError
return value
def _connectionserver(self):
"""Server loop daemon thread for console I/O."""
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serversocket.bind((self.SERVERHOST, self.SERVERPORT))
serversocket.listen(1)
def _outloop(q, s):
while True:
try:
c = q.get(True, timeout=2)
if c is self._SHUTDOWN_SENTINEL:
break
except queue.Empty:
pass
else:
s.sendall(c.encode())
def _inloop(s):
while len(b := s.recv(1)) != 0:
with self.rxc:
self.rxc.wait_for(lambda: not self.rcdone)
self.rdrbuf = ord(b)
self.rcdone = True
if self.r_ienable:
self.ub.intmgr.simple_irq(pri=4, vector=0o60)
while True:
s, addr = serversocket.accept()
outthread = threading.Thread(target=_outloop, args=(self.tq, s))
inthread = threading.Thread(target=_inloop, args=(s,))
outthread.start()
inthread.start()
inthread.join()
self.tq.put(self._SHUTDOWN_SENTINEL)
outthread.join()
# debugging tool
def statestring(self):
s = ""
if self.r_ienable:
s += " RINT"
if self.t_ienable:
s += " TINT"
if self.rdrbuf:
s += f" LC={self.rdrbuf}"
if self.rcdone:
s += f" RCDONE"
return s

66
kw11.py Normal file
View file

@ -0,0 +1,66 @@
# 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.
# line frequency clock
import time
import threading
class KW11:
KW11_OFFS = 0o17546
def __init__(self, ub):
interrupt_manager = ub.intmgr
self._t = threading.Thread(
target=self._cloop, args=(0.05, interrupt_manager), daemon=True)
self.running = False
self.monbit = 0
ub.mmio.register_simpleattr(self, 'LKS', self.KW11_OFFS, reset=True)
# clock loop
def _cloop(self, interval, imgr):
while self.running:
time.sleep(interval)
# there are inherent races here (in the hardware too) but
# seek to make the hazard smaller than the full interval
# by testing self.running again here.
if self.running:
imgr.simple_irq(pri=6, vector=0o100)
@property
def LKS(self):
return (int(self.monbit) << 7) | (int(self.running) << 6)
@LKS.setter
def LKS(self, value):
if not self.running:
if value & 0o100:
self.running = True
self._t.start()
self.monbit = (value & 0o200)
if self.running and not (value & 0o100):
# this never happens in unix but ...
self.running = False
self._t.join()

969
machine.py Normal file
View file

@ -0,0 +1,969 @@
# 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.
import logging
import itertools
from types import SimpleNamespace
from pdptraps import PDPTrap, PDPTraps
from mmu import MemoryMgmt
from unibus import UNIBUS, UNIBUS_1170
from kl11 import KL11
from rp import RPRM
# from rpa import RPRM_AIO as RPRM
from kw11 import KW11
from op4 import op4_dispatch_table
# A note about the various opNxxx files:
#
# Conceptually all of those are part of the PDP11 class. But having one
# monolithic/large class file seemed less than ideal. Python does not
# allow multiple files for a single class.
#
# Some parts of the implementation, like mmu and mmio, and various devices,
# made sense as separate component classes. The opcodes, however, are
# basically additional methods in separate files. Since they are not real
# methods they get passed a "cpu" argument manually instead of "self".
#
# Was there a better way? Just give in and have one huge file??
#
# The opcode parsing/dispatch starts with the top 4 bits of the opcode;
# thus the names "op4" and "op4_dispatch_table". Further decoding from
# there is as defined by the pdp11 operation code encoding tree.
class PDP11:
# Architectural constants, generally common across the whole family
SIGN8 = 0o200 # sign bit mask for 8 bits
SIGN16 = 0o100000 # sign bit mask for 16 bits
SIGN32 = 0x80000000 # for 32 bits; no one wants to see this in octal
# index this by opsize (1 or 2) to get corresponding sign bit mask
SIGN816 = (None, SIGN8, SIGN16)
MASK8 = 0o377
MASK16 = 0o177777
MASK32 = 0xFFFFFFFF
# index this by opsize (1 or 2) to get corresponding byte/word mask
MASK816 = (None, MASK8, MASK16)
# I/O page size is 8K (bytes), and equivalent mask
IOPAGE_SIZE = 8192 # bytes
IOPAGE_MASK = IOPAGE_SIZE - 1
UNIBUS_MASK = 0o777777 # 18 bits
UNIBUS_SIZE = UNIBUS_MASK + 1
# General register(s) block(s), relative to I/O page base
IOPAGE_REGSETS_OFFS = 0o17700
IOPAGE_REGSET_SIZE = 0o10 # 11/70 overrides w/more registers
# PSW modes, though not all processors implement all
KERNEL = 0 # 00 in PSW bits
SUPERVISOR = 1 # 01 in PSW bits
UNDEFINED_MODE = 2 # 10 is undefined and will cause a trap
USER = 3 # 11 in PSW bits
# sometimes nice to use these for clarity; r6 == SP, r7 == PC
SP = 6
PC = 7
# the processor status word I/O page offset
PS_OFFS = 0o17776
# the stack limit register I/O page offset
STACKLIM_OFFS = 0o17774
# the console switches (read) and LEDs (write)
SWLEDS_OFFS = 0o17570
# the CPU error register and some useful bit values
CPUERROR_OFFS = 0o17766
# not an Enum because ... need to do bitwise efficiently.
CPUERR_BITS = SimpleNamespace(
REDZONE=0o004, YELLOW=0o010, UNIBUS_TIMEOUT=0o020,
NXM=0o040, ODDADDR=0o100, ILLHALT=0o200)
# halt types. These are not architectural, but are helpful to see.
# They go into self.halted as "truthy" values
HALTED_INST = 1 # halt instruction
HALTED_VECTORS = 2 # vectors not mapped into kernel dataspace (!!)
HALTED_STACK = 3 # fatal kernel stack condition
# "straps" are synchronous traps. They are trap operations that occur
# AFTER an instruction has completely executed. Examples include the
# stack-limit violation traps, and the MMU's "management trap" which
# allows the OS to monitor page usage without a full-on page fault.
# [ see the distinction between "traps" and "aborts" in the MMU ]
#
# STRAPBITS are set by the emulation code when their condition obtains
# and a trap should be generated at the completion of the instruction.
# If multiple are requested in a single instruction, only the highest
# priority will fire. These STRAPBITS values are an implementation
# detail (presumably the PDP11 microarchitecture does something similar).
# They are never seen outside the processor implementation.
#
# NOTE: mid-instruction aborts essentially are the same as a strap, but
# abort an instruction midway rather than letting it continue. They
# are implemented by raising a PDPTrap exception, which is caught at
# the instruction loop top-level, and then turned into the same thing
# as a strap -- just one that gets there mid-instruction (and highest
# priority). See HIGHEST_ABORTTRAP in STRAPBITS and the try/except
# in the main instruction processing loop.
# there is no significant to the specific values, other than
# they must be single bits and they must sort in this order
STRAPBITS = SimpleNamespace(
HIGHEST_ABORTTRAP=0o100000, # absolutely MUST be the highest
MEMMGT=0o010000,
YELLOW=0o004000,
PIR=0o000040)
# this is just a handy thing to have, and this is as good a place
# for it as anywhere else
@staticmethod
def u16add(a, b):
return (a + b) & 0o177777
def __init__(self, *,
physmem=None, # default will be 512KB
unibus=None, # subclasses may want to supply variant
console=True, # automated tests need to turn this off
logger="pdp11", loglevel='INFO',
instlog=False, pswlog=False):
# logging is enabled by default and will go to
# a file logger + ".log" (i.e., "pdp11.log" by default).
# If logging is not a str instance it will just be used as is.
# (so logging can be configured by caller that way)
try:
logfname = logger + ".log"
except TypeError:
self.logger = logger
else:
loglevel = logging.getLevelNamesMapping().get(loglevel, loglevel)
logger = logging.getLogger(logger)
if not logger.hasHandlers(): # XXX is this the right/best way?
logger.propagate = False
logger.setLevel(loglevel)
logger_fh = logging.FileHandler(logfname)
formatter = logging.Formatter(
'%(name)s.%(levelname)s[%(asctime)s]: %(message)s',
datefmt='%H%M%S')
logger_fh.setFormatter(formatter)
logger.addHandler(logger_fh)
self.logger = logger
self.logger.info(f"{self.__class__.__name__} started;"
f" Logging level={logging.getLevelName(loglevel)}.")
# instruction logging and/or PSW logging - HUGE LOGS but
# sometimes helpful. Often the best way to use this is to insert
# custom code into the run loop to trigger these as desired.
self.instlog = instlog
self.pswlog = pswlog
self.ub = unibus(self) if unibus else UNIBUS(self)
self.mmu = MemoryMgmt(self)
# default physical memory is 256K WORDS (512KB)
self.physmem = physmem or ([0] * (256*1024))
# The 16-bit view of the PSW is synthesized when read; the
# essential parts of it are split out internally like this:
self.psw_curmode = self.KERNEL
self.psw_prevmode = self.KERNEL
self.psw_regset = 0 # this is not in all processors
self.psw_pri = 7
self.psw_trap = 0
self.psw_n = 0
self.psw_z = 0
self.psw_v = 0
self.psw_c = 0
# some attributes ("registers") that appear in I/O page
for attrname, offs in (('psw', self.PS_OFFS),
('stack_limit_register', self.STACKLIM_OFFS),
('swleds', self.SWLEDS_OFFS),
('error_register', self.CPUERROR_OFFS)):
self.ub.mmio.register_simpleattr(self, attrname, offs)
# console switches (read) and blinken lights (write)
self.swleds = 0
self.error_register = 0 # CPU Error register per handbook
# NOTE: The cold machine starts out in stack limit violation.
# However, the semantics are that no check happens until something
# stack-related occurs. Boot programs need to establish a valid
# stack early in their instruction sequence.
self.stack_limit_register = 0
# straps: keeps track of requests for synchronous traps
# during an instruction. Note that only one will really happen,
# whichever is the highest priority, though some might persist
# and recur
# - stack limit
# - mmu management traps (note: these are not aborts)
# ... others?
#
self.straps = 0
# start off in halted state until .run() happens
self.halted = True
# The console, the disk drive, and the clock are never really
# accessed directly (everything is triggered through mmio I/O)
# but of course must be instantiated
if console: # it's helpful to disable for tests
self._KL = KL11(self.ub)
self._RP = RPRM(self.ub)
self._KW = KW11(self.ub)
def physRW(self, physaddr, value=None):
"""like MMU.wordRW but takes physical addresses."""
if (physaddr & 1):
raise PDPTraps.AddressError(cpuerr=self.CPUERR_BITS.ODDADDR)
physaddr >>= 1 # physical mem is an array of WORDs
try:
if value is None: # i.e., reading
return self.physmem[physaddr]
else:
# sanity check should be taken out eventually
if (value & 0xFFFF) != value:
raise ValueError(f"{value} is out of range")
self.physmem[physaddr] = value
return value # generally ignored
except IndexError:
raise PDPTraps.AddressError(
cpuerr=self.CPUERR_BITS.NXM) from None
def physRW_N(self, physaddr, nwords, words=None):
"""Like physRW but for nwords at a time."""
if (physaddr & 1):
raise PDPTraps.AddressError(cpuerr=self.cpu.CPUERR_BITS.ODDADDR)
physaddr >>= 1 # physical mem is an array of WORDs
try:
if words is None:
return self.physmem[physaddr:physaddr+nwords]
else:
self.physmem[physaddr:physaddr+nwords] = words
except IndexError:
raise PDPTraps.AddressError(
cpuerr=self.CPUERR_BITS.NXM) from None
# this the heart of all things related to 6-bit instruction operands.
# If value is not given this will be a read
# If value is given and not None, this will be a write
#
# If justEA is True, the address that would be used to access the
# operand is returned, vs the operand itself. This is not valid for
# register direct. See JMP/JSR for examples of how/when this happens.
#
# If rmw is True, this will return a tuple:
# value, extendedB6
# otherwise it returns just the value (read, or written)
def operandx(self, b6, value=None, /, *,
opsize=2, altmode=None, altspace=None,
rmw=False, justEA=False):
"""Parse a 6-bit operand and read it (value is None) or write it.
By default the value (read, or written) is returned.
Some instructions need the operand address, not the value
(JSR is the best example of this). Specify justEA=True for that.
Note that justEA=True will trap for register-direct mode.
Some opcodes use a single addressing mode twice:
val = read the operand
do something to val (i.e., INC)
write modified val to the operand
The problem is side-effects, which are executed here for
modes like (Rn)+ (pc-relative is also a problem)
For this case, specify rmw=True ("read/modify/write") on the read
call (value=None) in which case the return value will be a tuple:
(value, EXTENDED_B6)
and the EXTENDED_B6 should be passed back in as the "b6" for the
write call. Callers should treat it as opaque. It is encoded to
allow the same operand to be re-used but without side effects
the second time.
"""
# EXTENDED_B6 ENCODING -- it is a 32-bit value:
# Bits 31-24 = 0xFF or 0x00
# If 00: The entire value is just a native b6. The low 6 bits
# are a pdp11 b6 value and all other bits are zero.
# If FF:
# bits 23-8: 16-bit effective address
# bits 7-6: mmu.ISPACE or mmu.DSPACE value
# bits 5-0: 0o47 which is an illegal b6; just to avoid
# looking like an optimizable case and
# to catch bugs if somehow used
#
# NOTE: real PDP-11 implementations vary in corner cases.
# For example:
# MOV R5,-(R5)
# what value gets stored? This turns out to vary. In fact, DEC
# documented the variations across processors. FWIW, the MACRO-11
# assembler generates warnings for such cases. Given all that,
# the assumption here is that getting those tricky semantics
# "correct to the specific processor variations" is unnecessary.
# optimize addr mode 0 - register. 8 or 16 bits.
# Note that in all READ cases b6 will be the newb6 (reusable)
if (b6 & 0o70) == 0:
if justEA:
raise PDPTraps.AddressError
match b6 & 0o07, value, opsize:
case Rn, None, 2:
value = self.r[Rn]
case Rn, wv, 2:
self.r[Rn] = wv
case Rn, None, 1:
value = self.r[Rn] & 0o377
case Rn, bv, 1:
self.r[Rn] = bv
if bv > 127:
self.r[Rn] |= 0xFF00
return (value, b6) if rmw else value
# harder cases
autocrement = 0 # increment/decrement
space = self.mmu.DSPACE # gets changed in various cases
extendedb6 = b6 # will be altered as necessary
match b6 & 0xFF_0000_00, (b6 & 0o70), (b6 & 0o07):
# (Rn) -- register deferred
case 0, 0o10, Rn:
addr = self.r[Rn]
if Rn == 7:
space = self.mmu.ISPACE
# both autoincrement addrmodes: (Rn)+ and @(Rn)+
case 0, 0o20 | 0o30 as addrmode, Rn:
addr = self.r[Rn]
if Rn == self.PC:
space = self.mmu.ISPACE
autocrement = 2 # regardless of opsize
elif Rn == self.SP:
autocrement = 2 # regardless of opsize
else:
autocrement = opsize
if addrmode == 0o30:
addr = self.mmu.wordRW(addr, space=space)
space = self.mmu.DSPACE
extendedb6 = None # force update below
# both autodecrement addrmode, PC - NOPE.
case 0, 0o40 | 0o50, 7:
# ... did the pdp11 fault on this?
raise PDPTraps.ReservedInstruction
# both autodecrement addrmodes, not PC
# note that bytes and -(SP) still decrement by 2
case 0, 0o40 | 0o50 as addrmode, Rn:
autocrement = -2 if Rn == self.SP else -opsize
extendedb6 = None # force update below
addr = self.u16add(self.r[Rn], autocrement)
if addrmode == 0o50:
addr = self.mmu.wordRW(addr, space=self.mmu.DSPACE)
if Rn == self.SP:
self.strapcheck = True
# X(Rn) and @X(Rn)
case 0, (0o60 | 0o70) as addrmode, Rn:
x = self.mmu.wordRW(self.r[self.PC], space=self.mmu.ISPACE)
self.r[self.PC] = self.u16add(self.r[self.PC], 2)
addr = self.u16add(self.r[Rn], x)
extendedb6 = None # force update below
if addrmode == 0o70:
addr = self.mmu.wordRW(addr, space=self.mmu.DSPACE)
case 0xFF_0000_00, _, _:
# the address was shifted up 8 bits (to get it away
# from the mode-0 optimization tests) and the space
# was encoded shifted up 6 bits (again, get away from mode 0)
addr = (b6 >> 8) & 0xFFFF
space = (b6 >> 6) & 3
case _: # should be unreachable
raise TypeError("internal error")
if autocrement != 0:
# the autoincrement/decrements have to be recorded into the MMU
# for instruction recovery if there is a page error.
self.mmu.MMR1mod(((autocrement & 0o37) << 3) | Rn)
self.r[Rn] = self.u16add(self.r[Rn], autocrement)
if rmw and (value is None) and (extendedb6 is None):
extendedb6 = 0xFF_0000_27 | (addr << 8) | (space << 6)
# use alternate space (e.g. forced ISPACE) if requested.
if altspace is not None:
space = altspace
if justEA:
val = addr
elif opsize == 2:
val = self.mmu.wordRW(addr, value, mode=altmode, space=space)
else:
val = self.mmu.byteRW(addr, value, mode=altmode, space=space)
return (val, extendedb6) if rmw else val
def run(self, *, steps=None, pc=None, stopat=None, loglevel=None):
"""Run the machine for a number of steps (instructions).
If steps is None (default), the machine runs until a HALT instruction
is encountered. It may run forever and the method might never return.
Otherwise, it runs for that many instructions (or until a HALT).
If pc is None (default) execution begins at the current pc; otherwise
the pc is set to the given value first.
"""
if loglevel is not None:
loglevel = logging.getLevelNamesMapping().get(loglevel, loglevel)
self.logger.setLevel(loglevel)
if pc is not None:
self.r[self.PC] = pc
# Breakpoints (and step limits) are in the critical path.
# To keep overhead to a minimum, breakpointfunc creates a
# custom function to evaluate breakpoint criteria. When there
# are no breakpoints or step limits at all, stop_here will be None.
# Hence the test construction:
#
# if stop_here and stop_here()
#
# which is as fast as it can be when there are no execution limits.
# When there ARE breakpoints etc, stop_here is a callable that
# evaluates all stop criteria and returns True if the inner loop
# should break.
stop_here = self.breakpointfunc(stopat, steps)
# some shorthands for convenience
interrupt_mgr = self.ub.intmgr
mmu = self.mmu
abort_trap = None # a mid-instruction abort (vs strap)
self.halted = False
# NOTE WELL: everything in this loop is per-instruction overhead
while not self.halted: # stop_here function will also break
# SUBTLETY: Trap handlers expect the PC to be 2 beyond the
# instruction causing the trap. Hence "+2 then execute"
thisPC = self.r[self.PC]
self.r[self.PC] = (thisPC + 2) & 0o177777 # "could" wrap
mmu.MMR1_staged = 0 # see discussion in go_trap
mmu.MMR2 = thisPC # per handbook
try:
inst = mmu.wordRW(thisPC)
if self.instlog:
self.instlogging(inst, thisPC)
op4_dispatch_table[inst >> 12](self, inst)
except PDPTrap as trap:
abort_trap = trap
self.straps |= self.STRAPBITS.HIGHEST_ABORTTRAP
# pri order:abort traps (encoded as a strap), straps, interrupts
if self.straps:
self.go_trap(self.get_synchronous_trap(abort_trap))
elif interrupt_mgr.pri_pending > self.psw_pri:
self.go_trap(interrupt_mgr.get_pending(self.psw_pri))
if stop_here and stop_here():
break
# fall through to here if self.halted or a stop_here condition
# log halts (stop_here was already logged)
if self.halted:
self.logger.debug(f".run HALTED: {self.machinestate()}")
def breakpointfunc(self, stopat, steps):
# create a custom function that returns True if execution
# meets the stop criteria. The returned function MUST be
# called EXACTLY ONCE per instruction execution.
#
# If steps is not None, then at most that many invocations can
# occur before execution will be halted (i.e., True returned).
#
# stopat can be a tuple: (pc, mode) or just a naked pc value.
# Execution will halt when the processor reaches that pc
# (in the given mode, or in any mode if not given).
#
# If both stopat and steps are None, then this returns None,
# which allows the run() loop to optimize out the check.
if stopat is None and steps is None:
return None
if steps is None:
stepsgen = itertools.count()
else:
stepsgen = range(steps)
try:
stoppc, stopmode = stopat
except TypeError:
stoppc = stopat
stopmode = None
def _evalstop():
for icount in stepsgen:
# this is sneaky ... it's can be handy in debugging to
# know the instruction count; stuff it into the cpu object
self.xxx_instcount = icount
if self.r[self.PC] == stoppc:
if stopmode is None or self.psw_curmode == stopmode:
self.logger.info(f".run: breakpt at {oct(stoppc)}")
break
yield False
else:
self.logger.info(f".run: ran {icount+1} steps")
yield True
g = _evalstop()
return lambda: next(g)
def get_synchronous_trap(self, abort_trap):
"""Return a synchronous trap, or possibly None.
For notational convenience in the instruction loop, the
abort_trap argument, if not None, represents a mid-instruction
abort which is the highest priority trap and it is just returned.
The corresponding straps bit is cleared.
After that, finds the highest priority strap if any, and returns it.
"""
# as described above... this is how aborts work
if self.straps & self.STRAPBITS.HIGHEST_ABORTTRAP:
self.straps &= ~self.STRAPBITS.HIGHEST_ABORTTRAP
return abort_trap
# Synchronous traps are events that are caused by an instruction
# but happen AFTER the instruction completes. The handbook shows
# eight of them, in this priority order (high to low)
#
# HIGHEST -- Parity error
# Memory Management violation
# Stack Limit Yellow
# Power Failure
# Floating Point
# Program Interrupt Request
# Bus Request
# LOWEST Trace Trap
#
# If there are multiple, only the highest priority will fire,
# though some types of them are persistent (in their root cause)
# and would therefore come back with the next instruction and
# (potentially) fire there instead.
# no synchronous traps honored in certain error states
ignores = self.CPUERR_BITS.REDZONE | self.CPUERR_BITS.YELLOW
if self.error_register & ignores:
return None
# The stack limit yellow bit is a little different ... it gets
# set when there is the *possibility* of a stack limit violation.
# (because the stack pointer changed, or because the limits changed).
# This is where the actual limit test gets checked.
if self.straps & self.STRAPBITS.YELLOW:
# Note special semantic of zero which means 0o400
# (as defined by hardware book)
lim = self.stack_limit_register or 0o400
if self.r[self.SP] >= lim:
self.straps &= ~self.STRAPBITS.YELLOW # never mind, all good!
else:
self.logger.info(f"YELLOW ZONE, {list(map(oct, self.r))}")
# yup definitely in at least a yellow condition
self.error_register |= self.CPUERR_BITS.YELLOW
# how about red?
if self.r[self.SP] + 32 < lim: # uh oh - below the yellow!
# this is a red zone trap which is immediate
# the stack pointer is set to location 4
# and this trap is executed
self.r[6] = 4 # !! just enough room for...
return PDPTraps.AddressError(
cpuerr=self.CPUERR_BITS.REDZONE)
# note that only the first (should be highest) will fire
for bit, trapcl in ((self.STRAPBITS.MEMMGT, PDPTraps.MMU),
(self.STRAPBITS.YELLOW, PDPTraps.AddressError)):
if self.straps & bit:
self.straps &= ~bit
return trapcl()
return None
def go_trap(self, trap):
"""Control transfer for all types of traps, INCLUDING interrupts."""
# it's convenient to allow trap to be None meaning "never mind"
if trap is None:
return
self.logger.debug(f"TRAP: {trap}:\n{self.machinestate()}")
self.error_register |= trap.cpuerr
# get the vector information -- always from KERNEL/DSPACE
try:
newpc = self.mmu.wordRW_KD(trap.vector)
newps = self.mmu.wordRW_KD(trap.vector+2)
except PDPTrap:
# this is an egregious kernel programming error -- the vectors
# are not mapped into KERNEL/DSPACE. It is a fatal halt.
self.logger.info(f"Trap accessing trap vectors")
self.halted = self.HALTED_VECTORS
return
# From the PDP11 processor book:
# The old PS and PC are then pushed onto the current stack
# as indicated by bits 15,14 of the new PS and the previous
# mode in effect is stored in bits 13,12 of the new PS.
# Thus:
# easiest to get the "previous" (currently current) mode this way:
saved_curmode = self.psw_curmode
saved_psw = self.psw
# note: this (likely) switches SP and of course various psw_xxx fields
self.psw = newps
self.psw_prevmode = saved_curmode # i.e., override newps<13:12>
prepushSP = self.r[6]
try:
self.stackpush(saved_psw)
self.stackpush(self.r[self.PC])
except PDPTrap as e:
# again this is a pretty egregious error it means the kernel
# stack is not mapped, or the stack pointer is odd, or similar
# very bad mistakes by the kernel code. It is a fatal halt
# NOTE: The stack register is restored
self.logger.info(f"Trap pushing trap onto stack")
self.r[6] = prepushSP
self.halted = self.HALTED_STACK
# The error register records (accumulates) reasons (if given)
self.error_register |= trap.cpuerr
# alrighty then, can finally jump to the PC from the vector
self.r[self.PC] = newpc
# This is called when the run loop wants to log an instruction.
# Pulled out so can be overridden for specific debugging sceanrios.
def instlogging(self, inst, pc):
try:
logit = self.instlog(self, inst, thisPC)
except TypeError:
logit = True
if logit:
m = "KS!U"[self.psw_curmode]
self.logger.debug(f"{oct(thisPC)}/{m} :: {oct(inst)}")
@property
def swleds(self):
return 0 # no switches implementation, yet
@swleds.setter
def swleds(self, v): # writing to the lights is a no-op for now
pass
# technically not all -11's have this, but ... meh do it here anyway
@property
def stack_limit_register(self):
return self._stklim
@stack_limit_register.setter
def stack_limit_register(self, v):
# at __init__ time it's important to NOT indicate the need
# for a stack check or else the first instruction executed
# will fail the stack limit.
#
# Any other time, set the bit so the main instruction loop
# will know it needs to examine the stack limit status.
#
# This could also have been fixed by initializing _stklim in
# __init__ and not "stack_limit_register = 0" , or it could
# have been fixed by slamming strapcheck back to false after that.
# But this way ensures The Right Thing happens no matter what.
# Performance is no issue in setting the stack limit obviously.
if hasattr(self, '_stklim'):
self.straps |= self.STRAPBITS.YELLOW
self._stklim = v & 0o177400
def stackpush(self, w):
# XXX YELLOW CHECK ???
self.r[6] = self.u16add(self.r[6], -2)
self.mmu.wordRW(self.r[6], w, space=self.mmu.DSPACE)
def stackpop(self):
w = self.mmu.wordRW(self.r[6], space=self.mmu.DSPACE)
self.r[6] = self.u16add(self.r[6], 2)
return w
class PDP1170(PDP11):
# some 1170-specific values
IOPAGE_REGSET_SIZE = 0o20 # 11/70 has two sets of registers
def __init__(self, *, physmem=None, **kwargs):
super().__init__(physmem=physmem, unibus=UNIBUS_1170, **kwargs)
# there are two register files, though r6 and r7 are special
self.registerfiles = [[0] * 8, [0] * 8]
# There are four stack pointers, but only 3 are legal.
# This can be indexed by self.KERNEL, self.SUPERVISOR, etc
self.stackpointers = [0, 0, 0, 0]
# The 16-bit view of the PSW is synthesized when read; the
# essential parts of it are split out internally like this:
self.psw_curmode = self.KERNEL
self.psw_prevmode = self.KERNEL
self.psw_regset = 0
self.psw_pri = 7
self.psw_trap = 0
self.psw_n = 0
self.psw_z = 0
self.psw_v = 0
self.psw_c = 0
# self.r points to the current register set
self.r = self.registerfiles[self.psw_regset]
# how the registers appear in IOPAGE space
self.ub.mmio.register(self._ioregsets,
self.IOPAGE_REGSETS_OFFS,
self.IOPAGE_REGSET_SIZE)
@property
def r_alt(self):
"""The other set of registers (the one that is not self.r)."""
return self.registerfiles[1 - self.psw_regset]
def _ioregsets(self, addr, value=None, /):
# NOTE that the encoding of the register addresses is quite funky
# and includes ODD addresses (!!!)
# [ addresses given relative to I/O page base ]
# REGISTER SET ZERO
# 17700 : R0
# 17701 : R1 -- this being at ODD address is not a typo!
# 17702 : R2
# 17703 : R3 -- not a typo
# 17704 : R4
# 17705 : R5 -- not a typo
# 17706 : KERNEL SP
# 17707 : PC
#
# REGISTER SET ONE
# 17710 : R0
# 17711 : R1
# 17712 : R2
# 17713 : R3
# 17714 : R4
# 17715 : R5
# 17716 : SUPERVISOR SP
# 17717 : USER SP
regset = addr & 0o10
regnum = addr & 0o07
# copy the stack pointer out of its r6 "cache" and dup the pc
self._syncregs()
# regset regnum r/w (value None or not)
match ((addr & 0o10) >> 3, addr & 0o07, value):
case (0, 6, None):
return self.stackpointers[self.KERNEL]
case (0, 6, newksp):
self.stackpointers[self.KERNEL] = newksp
case (1, 6, None):
return self.stackpointers[self.SUPERVISOR]
case (1, 6, newssp):
self.stackpointers[self.SUPERVISOR] = newssp
case (1, 7, None):
return self.stackpointers[self.USER]
case (1, 7, newusp):
self.stackpointers[self.USER] = newusp
case (regset, regnum, None):
return self.registerfiles[regset][regnum]
case (regset, regnum, _):
self.registerfiles[regset][regnum] = value
# if the stack pointer for the current mode was updated
# then reestablish it as r[6]. Can just do this unconditionally
# because syncregs copied out the active r[6] above
self.r[6] = self.stackpointers[self.psw_curmode]
def _syncregs(self):
# When there is a register set change, a mode change, or when
# the registers are being examined via their I/O addresses then
# the "cached" stack pointer in R6 has to be synced up to its
# real home, and the PC (R7) has to be duplicated into the other set.
self.stackpointers[self.psw_curmode] = self.r[6]
# sync the PC into the other register set
self.r_alt[self.PC] = self.r[self.PC]
@property
def psw(self):
# NOTE: to simplify/accelerate condition code handling during
# instructions, the NZVC bits are broken out into individual
# attributes, and are stored as truthy/falsey not necessarily
# 1/0 or True/False.
# so, to reconstitute NZVC bits ...
NZVC = 0
if self.psw_n:
NZVC |= 0o10
if self.psw_z:
NZVC |= 0o04
if self.psw_v:
NZVC |= 0o02
if self.psw_c:
NZVC |= 0o01
return (((self.psw_curmode & 3) << 14) |
((self.psw_prevmode & 3) << 12) |
((self.psw_regset & 1) << 11) |
((self.psw_pri & 7) << 5) |
((self.psw_trap & 1) << 4) |
NZVC)
# Write the ENTIRE processor word, without any privilege enforcement.
# The lack of privilege enforcement is necessary because, e.g., that's
# how traps get from user to kernel mode. Generally speaking, the
# only way for user mode programs to modify the PSW is via its I/O
# address, which (obviously) an OS should not put into user space.
@psw.setter
def psw(self, value):
"""Set entire PSW. NOTE: no privilege enforcement."""
# could test if necessary but it's just easier to do this every time
self._syncregs() # in case any mode/regset changes
# prevent UNDEFINED_MODE from entering the PSW
m = (value >> 14) & 3
if m == self.UNDEFINED_MODE:
raise PDPTraps.ReservedInstruction
self.psw_curmode = m
# prevent UNDEFINED_MODE from entering the PSW
m = (value >> 12) & 3
if m == self.UNDEFINED_MODE:
raise PDPTraps.ReservedInstruction
self.psw_prevmode = m
prevregset = self.psw_regset
self.psw_regset = (value >> 11) & 1
newpri = (value >> 5) & 7
if self.pswlog and newpri != self.psw_pri:
self.logger.debug(f"PSW pri change: {self.spsw()} -> "
f"{self.spsw(value)}")
self.psw_pri = newpri
self.psw_trap = (value >> 4) & 1
self.psw_n = (value >> 3) & 1
self.psw_z = (value >> 2) & 1
self.psw_v = (value >> 1) & 1
self.psw_c = value & 1
# set up the correct register file and install correct SP
self.r = self.registerfiles[self.psw_regset]
self.r[6] = self.stackpointers[self.psw_curmode]
# the PC was already sync'd in syncregs()
# this is convenient to have for debugging and logging
def spsw(self, v=None):
"""Return string rep of a psw value."""
if v is None:
v = self.psw
cm = (v >> 14) & 3
pm = (v >> 12) & 3
m2s = "KS!U"
s = f"CM={m2s[cm]} PM={(m2s[pm])}"
if v & 0o04000:
s += " Rx=1"
s += f" PRI={(v >> 5) & 0o07}"
if v & 0o020:
s += " T"
if v & 0o017:
s += " "
if v & 0o010:
s += "N"
if v & 0o004:
s += "Z"
if v & 0o002:
s += "V"
if v & 0o001:
s += "C"
return s
# logging/debugging convenience
def machinestate(self, brief=False):
s = self.spsw() + '; '
stacknames = ("KSP", "SSP", "!X!", "USP")
regnames = (* (f"R{i}" for i in range(6)),
stacknames[self.psw_curmode], "PC")
for i in range(8):
s += f"{regnames[i]}: {oct(self.r[i])} "
for m in (0, 1, 3):
name = stacknames[m]
if m == self.psw_curmode:
name = name[0] + "xx"
s += f"{name}: {oct(self.stackpointers[m])} "
return s

289
mmio.py Normal file
View file

@ -0,0 +1,289 @@
# 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 pdptraps import PDPTraps
class MMIO:
"""Memory Mapped I/O handling for the 8K byte I/O page."""
#
# 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.
#
# See register() for setting a callback on a specific offset or range.
#
# Reads and writes to any offset within the I/O page invoke the
# callback function. Assuming f is the callback function:
#
# READS: v = f(ioaddr)
# The function f must return a value 0 .. 65535
#
# WRITES: f(ioaddr, v)
# v will be an integer 0 .. 65535
# The return value is ignored.
#
# Callbacks must be declared like this:
# def f(ioaddr, value=None, /):
# if value is None:
# ... this is the read case
# else:
# ... this is the write case
#
# The ioaddr will always be relative to the base of the I/O page:
# (ioaddr & 8191) == ioaddr will always be True
#
# Callbacks may also optionally receive a byte operation indicator,
# but only if they register for that. See BYTE OPERATIONS, below.
# Most callbacks will instead rely on the framework to synthesize
# byte operations from words; see _byte_wrapper and byteme.
#
# 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. In some
# models the registers are available at a block of UNIBUS addresses,
# and some of the 16-bit registers have ODD addresses.
# For example, UNIBUS address 777707 is the PDP-11 cpu PC, as a
# full 16-bit word, while 777706 is the PDP-11 cpu KSP.
#
# This creates potential for havoc if programmers use byte operations
# on I/O addresses. Consider a typical sequence to read consecutive
# bytes from an address in R0:
#
# MOVB (R0)+,R1 # get the low byte of the word R0 points to
# MOVB (R0)+,R2 # get the high byte of that word
#
# If executed with R0 = 177706 (the KSP register virtual address
# with a typical 16-bit mapping of the upper page to I/O space)
# then R1 will be the low byte of KSP but the next access, which will
# be seen by the UNIBUS as a word read on an odd address, will pull
# the PC value (at 777707) as a word and extract the upper byte
# from THAT (PC upper byte in R2; KSP lower byte in R1). This is
# probably an unexpected result, which is a good argument against
# using byte operations in I/O space. Nevertheless, byte operations
# in I/O space abound in real world code.
#
# Thus:
# * DEVICES THAT DIRECTLY SUPPORT BYTE-WRITE OPERATIONS:
# Specify "byte_writes=True" in the registration call:
#
# mmio.register(somefunc, someoffset, somesize, byte_writes=True)
#
# and declare the somefunc callback like this:
# def somefunc(ioaddr, value=None, /, *, opsize=2):
# ...
#
# The opsize argument will be 2 for word operations and 1 for bytes.
# NOTE: Byte READS will never be seen; only byte WRITES.
#
# * DEVICES THAT DON'T CARE ABOUT BYTE-WRITES:
# The common/standard case. Let byte_writes default to False
# (i.e., leave it out of the registration call). The callback
# will be invoked with the simpler signature: f(ioaddr, v)
#
# * DEVICES THAT SUPPLY WORDS AT ODD ADDRESSES
# The cpu being the canonical example of this... register the
# I/O callback at the corresponding even address and use the ioaddr
# determine which address (the even or the odd) was requested.
#
# 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.
#
#
# The PDP-11 RESET INSTRUCTION
#
# The PDP-11 RESET instruction causes the UNIBUS to be reset.
# Devices that want to know about this should:
#
# mmio.devicereset_reigster(resetfunc)
#
# And then resetfunc will be invoked as:
# resetfunc(mmio.ub)
# (i.e., passed a single argument, the UNIBUS object).
#
# For dead-simple cases, the optional reset=True argument can be
# supplied, which casues register() to also arrange for the iofunc
# to be called at RESET time, like this:
#
# iofunc(baseaddr, 0)
#
# which, it should be noted, is indistinguishable from a program
# merely setting that I/O address to zero. Note too that if iofunc
# was registered once for an N-word block a RESET will still only call
# it ONCE, on the baseaddr of that block.
#
# If this convenience, with its limitations, is insufficient for a
# device then it must use the devicereset registration instead.
#
def __init__(self, cpu):
self.cpu = cpu
self.mmiomap = [self.__nodev] * (self.cpu.IOPAGE_SIZE >> 1)
self.device_resets = set()
# the default entry for unoccupied I/O: cause an AddressError trap
def __nodev(self, addr, value=None, /):
self.cpu.logger.info(f"Access to non-existent I/O {oct(addr)}")
raise PDPTraps.AddressError(
cpuerr=self.cpu.CPUERR_BITS.UNIBUS_TIMEOUT)
# Devices may have simple "dummy" I/O addresses that always read zero
# and ignore writes; See "if iofunc is None" in register() method.
def __ignoredev(self, addr, value=None, /):
self.cpu.logger.debug(f"dummy zero device @ {oct(addr)}, {value=}")
return 0
# register a call-back for an I/O address, which MUST be an offset
# within the 8K I/O page (which itself may reside at three different
# physical locations depending on configurations, thus explaining
# why this routine deals only in the offsets).
#
# Variations:
# iofunc=None -- implement a dummy: reads as zero, ignores writes
# reset=True -- also registers iofunc to be called at RESET time
# byte_writes=True -- Request byte writes be sent to the iofunc
# vs hidden/converted into word ops.
#
def register(self, iofunc, offsetaddr, nwords, *,
byte_writes=False, reset=False):
if offsetaddr >= self.cpu.IOPAGE_SIZE:
raise ValueError(f"MMIO: I/O offset too large {oct(offsetaddr)}")
# None is a shorthand for "this is a dummy always-zero addr"
if iofunc is None:
iofunc = self.__ignoredev
# register this (raw/unwrapped) iofunc for reset if so requested
if reset:
self.devicereset_register(lambda ub: iofunc(offsetaddr, 0))
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")
if not byte_writes:
# wrap the supplied I/O function with this code to implement
# byte write operations automatically in terms of words.
iofunc = self._byte_wrapper(iofunc)
for i in range(nwords):
self.mmiomap[idx+i] = iofunc
return offsetaddr
def _byte_wrapper(self, iofunc):
def byteme(ioaddr, value=None, /, *, opsize=2):
if (value is None) or (opsize == 2):
return iofunc(ioaddr, value)
else:
# value is not None, and opsize is not 2
# In other words: a byte write to I/O space. Synthesize it.
self.cpu.logger.debug(f"Byte write to {oct(ioaddr)} {value=}")
wv = self.wordRW(ioaddr)
if ioaddr & 1:
wv = (wv & 0o377) | (value << 8)
else:
wv = (wv & 0o177400) | value
self.wordRW(ioaddr, 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
#
# If a device just needs some attributes set to zero on a RESET,
# it can specify them here with reset=True and they will be automatically
# set to zero by reset() (no need to devicereset_register).
def register_simpleattr(self, obj, attrname, addr, reset=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.
"""
# could do this with partial, but can also do it with this nested
# func def. One way or another need this func logic anyway.
def _rwattr(_, value=None, /):
"""Read/write the named attr via the I/O callback protocol."""
if attrname is None:
value = 0
else:
if value is None:
value = getattr(obj, attrname)
else:
setattr(obj, attrname, value)
return value
# NOTE: Registers a different ("closure of") rwattr each time.
self.register(_rwattr, addr, 1, reset=reset)
# In the real hardware, the PDP-11 RESET instruction pulls a reset line
# that all devices can see. In the emulation, devices that need to know
# about the RESET instruction must register themselves here:
def devicereset_register(self, func):
"""Register func to be called whenever a RESET happens."""
self.device_resets.add(func)
# The PDP-11 RESET instruction eventually ends up here, causing
# a bus reset to be sent to all known registered devices.
def resetdevices(self):
for f in self.device_resets:
self.cpu.logger.debug(f"RESET callback: {f}")
f(self.cpu.ub)
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)
else:
self.mmiomap[ioaddr >> 1](ioaddr, 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.cpu.logger.debug(
f"UB: byte write {oct(ioaddr)}={oct(value)}"
f" {self.cpu.machinestate()}")
self.mmiomap[ioaddr >> 1](ioaddr, value, opsize=1)
return None

561
mmu.py Normal file
View file

@ -0,0 +1,561 @@
# 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 functools import partial
from pdptraps import PDPTraps
from types import SimpleNamespace
from collections import namedtuple
class MemoryMgmt:
ISPACE = 0
DSPACE = 1
# I/O addreses for various registers relative to I/O page base
# From the pdp11/70 (and others) 1981 processor handbook, Appendix A
#
# Each block is:
# I PDR (8 16-bit registers)
# D PDR (8 16-bit registers)
# I PAR (8 16-bit registers)
# D PAR (8 16-bit registers)
#
# so each block is 64 bytes total, octal 100 in size
#
APR_SUPER_OFFS = 0o12200 # offset within I/O page
APR_KERNEL_OFFS = 0o12300
APR_USER_OFFS = 0o17600 # 0o17 vs 0o12 is not a typo
# expressed as offsets within the I/O page
MMR0_OFFS = 0o17572
MMR1_OFFS = 0o17574
MMR2_OFFS = 0o17576
MMR3_OFFS = 0o12516
# not an Enum because ... need to do bitwise efficiently.
MMR0_BITS = SimpleNamespace(
ABORT_NR=0o100000, ABORT_PLENGTH=0o040000, ABORT_RDONLY=0o020000,
TRAP_MGMT=0o010000, TRAP_ENABLE=0o001000, INST_COMPLETED=0o000200,
RELO_ENABLE=0o000001, FREEZER_TRAPS=0o160000,
)
# memory control (parity, etc) is not implemented but needs to respond
MCR_OFFS = 0o17746
# encodes read vs write cycles
CYCLE = SimpleNamespace(READ='r', WRITE='w')
TransKey = namedtuple('TransKey', ('segno', 'mode', 'space', 'cycle'))
def __init__(self, cpu, /, *, nocache=False):
self.cpu = cpu
self.ub = cpu.ub
mmio = self.ub.mmio
# The "segment cache" dramatically speeds up address translation
# for the most common MMU usage scenarios.
#
# To preserve correct semantics, modifications to the mapping
# parameters must (of course) dump some, or all, of this cache.
self.segcache = {}
self.MMR0 = 0
self.MMR1 = 0
self.MMR2 = 0
self.MMR3 = 0
self.MMR1_staged = 0
self.nocache = nocache
# per the architecture manual, there are six (!) sets of
# eight 32-bit Active Page Registers (APR0 ... APR7) where
# each APR can be thought of as a 16-bit page address register (PAR)
# and a 16 bit page descriptor register (PDR).
#
# A set (of 8) APRs is selected by a combination of two PSW bits
# for kernel/supervisor/illegal/user modes and, if I/D separation
# is enabled, I vs D space.
#
# To simplify the mapping, 8 sets (instead of 6) are provided here
# but one of them is never used because one of the four combinations
# of the psw mode bits is illegal (enforced elsewhere).
self.APR = [[[0, 0] for _ in range(8)] for setno in range(8)]
# register I/O space PDR/PARs: SUPER/KERNEL/USER blocks of 32 regs.
#
# It turns out all of this context is encoded (cleverly and
# not entirely obviously) in the ioaddr bits, but it just seems
# better to make it explicit here via extra args and partial():
for mode, base in (
(cpu.SUPERVISOR, self.APR_SUPER_OFFS),
(cpu.KERNEL, self.APR_KERNEL_OFFS),
(cpu.USER, self.APR_USER_OFFS)):
# Each block is:
# I PDR (8 16-bit registers)
# D PDR (8 16-bit registers)
# I PAR (8 16-bit registers)
# D PAR (8 16-bit registers)
for parpdr, space, offset in (
(1, self.ISPACE, 0),
(1, self.DSPACE, 16),
(0, self.ISPACE, 32),
(0, self.DSPACE, 48)):
ioaddr = base+offset
iofunc = partial(self.io_parpdr, parpdr, mode, space, ioaddr)
mmio.register(iofunc, ioaddr, 8) # 8 words / 16 bytes
# register the simple attrs MMR0 etc into I/O space:
mmio.register_simpleattr(self, 'MMR0', self.MMR0_OFFS, reset=True)
mmio.register_simpleattr(self, 'MMR1', self.MMR1_OFFS)
mmio.register_simpleattr(self, 'MMR2', self.MMR2_OFFS)
mmio.register_simpleattr(self, 'MMR3', self.MMR3_OFFS, reset=True)
mmio.register_simpleattr(self, None, self.MCR_OFFS)
def io_parpdr(self, parpdr, mode, space, base, addr, value=None, /):
"""mmio I/O function for MMU PARs and PDRs.
NOTE: parpdr/mode/space/base args provided via partial() as
supplied at registration time; see __init__.
The mmio module calls this simply as f(addr, value)
"""
aprnum = (addr - base) >> 1
aprfile = self.APR[(mode * 2) + space]
if value is None:
return aprfile[aprnum][parpdr]
else:
# dump any matching cache entries in both reading/writing form.
for w in (self.CYCLE.READ, self.CYCLE.WRITE):
if (aprnum, mode, space, w) in self.segcache:
del self.segcache[(aprnum, mode, space, w)]
# --- XXX THIS IS JUST INFORMATIONAL / REASSURING FOR DEBUGGING
# --- take this entire block out when satisfied
if parpdr == 1:
pdr = aprfile[aprnum][1]
# various PDR mods are of interest for logging for debug
if ((value & 4) == 0) and (pdr & 4):
self.cpu.logger.debug(
f"MMU: Write perm being removed "
f"{aprnum=} {mode=} {space=}")
if ((pdr >> 8) & 0xFF) > ((value >> 8) & 0xFF):
self.cpu.logger.debug(
f"MMU: segment being shortened "
f"pdr={oct(pdr)} value={oct(value)}"
f" {aprnum=} {mode=} {space=}")
# --- XXX END XXX
aprfile[aprnum][parpdr] = value
# Per the handbook - the A and W bits in a PDR are reset to
# zero when either the PAR or PDR is written.
aprfile[aprnum][1] &= ~0o0300
@property
def MMR0(self):
return self._mmr0
@MMR0.setter
def MMR0(self, value):
self.cpu.logger.debug(f"MMR0 being set to {oct(value)}")
self._mmr0 = value
self._mmu_relo_enabled = (value & self.MMR0_BITS.RELO_ENABLE)
self._mmu_trap_enabled = (value & self.MMR0_BITS.TRAP_ENABLE)
self._mmr12_frozen = (value & self.MMR0_BITS.FREEZER_TRAPS)
# XXX
if self._mmr12_frozen:
self.cpu.logger.debug(f"MMR12 FROZEN {self.MMR1=} {self.MMR2=}")
self.segcache = {}
self.__rebaseIO()
# MMR1 records any autoincrement/decrement of the general purpose
# registers, including explicit references through the PC. MMR1 is
# cleared at the beginning of each instruction fetch. It is really
# two subregisters each 8 bits, that record:
# Bits <7:3> two's complement amount changed
# Bits <2:0> register number (0 .. 7)
#
# Register set must be determined from appropriate PSW field(s)
#
# This is in the critical path for instruction performance, so
# there is an optimization. At the beginning of every instruction
# self.MMR1_staged is set to zero. Then "MMR1mod()" is used to
# record any modifications (they still go into MMR1_staged) and
# only when an MMU trap is generated are the staged values potentially
# transferred into MMR1.
#
# This keeps the overhead down to a single self.MMR1_staged = 0
# assignment for instructions that do not auto inc/dec and do not
# cause MMU faults.
def MMR1mod(self, value):
# record the given 8-bit register modification
if value == 0 or value > 255: # this should never happen
raise ValueError(f"bogus MMR1mod {value=}")
if self.MMR1_staged == 0:
self.MMR1_staged = value
else:
self.MMR1_staged |= (value << 8)
def _MMR1commit(self):
if not self._mmr12_frozen:
self.MMR1 = self.MMR1_staged
@property
def MMR2(self):
return self._mmr2
@MMR2.setter
def MMR2(self, value):
if not self._mmr12_frozen:
self._mmr2 = value
@property
def MMR3(self):
cpu = self.cpu
return (
((self._unibusmap & 1) << 5) |
((self._22bit & 1) << 4) |
(int(self._Dspaces[cpu.KERNEL] == self.DSPACE) << 2) |
(int(self._Dspaces[cpu.SUPERVISOR] == self.DSPACE) << 1) |
(int(self._Dspaces[cpu.USER] == self.DSPACE)))
@MMR3.setter
def MMR3(self, value):
self._unibusmap = (value >> 5) & 1
self._22bit = (value >> 4) & 1
self.segcache = {}
self.__rebaseIO() # because 22bit affects where it ends up
# rather than store the kernel/super/user D-space enables,
# store which space to use for D-space lookups
self._Dspaces = {mode: [self.ISPACE, self.DSPACE][bool(value & mask)]
for mode, mask in ((self.cpu.KERNEL, 4),
(self.cpu.SUPERVISOR, 2),
(self.cpu.USER, 1))}
def __rebaseIO(self):
"""Where oh where has my little I/O page gone?"""
# whenver relo_enabled or _22bit change, which inconveniently
# are in separate MMR registers, the I/O potentially moves.
# Figure out where to put it.
self.iopage_base = 0o160000 # end of the 16 bit space
if self._mmu_relo_enabled:
self.iopage_base |= (3 << 16) # 2 more bits (18 total)
if self._22bit:
self.iopage_base |= (15 << 18) # ... and 4 more
def v2p(self, vaddr, mode, space, cycle):
"""Convert a 16-bit virtual address to physical.
NOTE: Raises traps, updates A/W bits, & sets straps as needed.
"""
if not self._mmu_relo_enabled:
return vaddr
if mode is None: # the normal (not mtpi etc) case
mode = self.cpu.psw_curmode
# fold I/D together (into I) if separation not on
space = self._foldspaces(mode, space)
# the virtual address is broken down into three fields:
# <15:13> APF active page field. Selects the APR = par,pdr pair
# This is sometimes called the "segment number"
# <12:6> The "block number"
# <5:0> The displacement in block
#
# The block number will be added to the page address field in the par.
# That whole thing is shifted left 6 bits and or'd with the
# displacement within block. All this is per the PDP11 manuals.
segno = vaddr >> 13
# the segment number and these other parameters form
# a "translation key" used in several places
xkey = self.TransKey(segno, mode, space, cycle)
# All this translation code takes quite some time; caching
# dramatically improves performance.
#
# I/O space mappings are not cached (not performance-critical).
#
if self.nocache and self.segcache:
self.segcache = {}
try:
xoff, validation_func = self.segcache[xkey]
if validation_func(vaddr):
return vaddr + xoff
except KeyError:
pass
# not cached; do the translation...
par, pdr = self._getapr(xkey)
# In 22bit mode, the full 16 bits of the PAR are used.
# In 18bit mode, the top four have to be masked off here.
if not self._22bit:
par &= 0o7777
# access checks:
# "Aborts" (per the processor handbook) raise PDPTraps.MMU and
# do not return from accesschecks()
#
# If there are "memory management traps" (which are to occur
# at the *end* of instruction execution) they are returned as
# bits suitable for OR'ing into cpu.straps; note that this
# condition also prevents caching the APR. If the mgmt trap
# handler modifies the APR to disable the management trap then
# of course future lookups will be eligible for the cache then.
straps = self._v2p_accesschecks(pdr, vaddr, xkey)
# the actual translation...
bn = (vaddr >> 6) & 0o177
plf = (pdr >> 8) & 0o177
if (pdr & 0o10) and bn < plf:
self._raisetrap(self.MMR0_BITS.ABORT_PLENGTH, vaddr, xkey)
elif (pdr & 0o10) == 0 and bn > plf:
self._raisetrap(self.MMR0_BITS.ABORT_PLENGTH, vaddr, xkey)
# "Access" and "Written" bits updates. Subtle note: if this entry
# gets cached, then by definition the corresponding AW updates
# already happened (here). So the "found it in cache" logic up top
# of this function needn't worry about AW bit updates.
AW_update = 0o300 if cycle == self.CYCLE.WRITE else 0o200
# XXX ^^^^^ not sure if a write should be 0o300 or naked 0o100
if (pdr & AW_update) != AW_update:
self._putapr(xkey, (par, pdr | AW_update))
dib = vaddr & 0o77
pa = ((par + bn) << 6) | dib
self.cpu.straps |= straps
# only non-trapping non-io results can be cached:
if straps == 0 and pa < self.iopage_base:
self._encache(xkey, pdr, pa - vaddr)
return pa
def _encache(self, k, pdr, offs):
# the validation function (lambdas) is constructed for cases
# where the segment is not full-length and therefore one more
# check has to happen even on cache hits.
plf = (pdr >> 8) & 0o177
if pdr & 0o10 and plf > 0:
self.segcache[k] = (offs, lambda a: ((a >> 6) & 0o177) >= plf)
elif (pdr & 0o10) == 0 and plf < 0o177:
self.segcache[k] = (offs, lambda a: ((a >> 6) & 0o177) <= plf)
else:
self.segcache[k] = (offs, lambda a: True) # full segment
def _foldspaces(self, mode, space):
"""Folds DSPACE back into ISPACE if DSPACE not enabled for mode"""
return space if space == self.ISPACE else self._Dspaces[mode]
def _getapr(self, xkey):
"""CAUTION: xkey must already be space-folded."""
nth = (xkey.mode * 2) + xkey.space
return self.APR[nth][xkey.segno]
def _putapr(self, xkey, apr):
"""CAUTION: xkey must already be space-folded."""
nth = (xkey.mode * 2) + xkey.space
self.APR[nth][xkey.segno] = list(apr)
def _v2p_accesschecks(self, pdr, vaddr, xkey):
"""Raise traps or set post-instruction traps as appropriate.
Returns straps flags (if any required).
"""
straps = 0
# There are aborts and "memory management traps".
# As the handbook says:
# """Aborts are used to catch "missing page faults," prevent
# illegal access, etc.; traps are used as an aid in
# gathering memory management information
# """
#
# Thus, an "abort" raises a vector 4 (AddressError) exception and
# a "management trap" sets a cpu bit to cause a vector 250 (MMU)
# trap at the *completion* of the instruction.
#
# The 7 possible access control modes (pdr & 7) are:
#
# 000 -- abort all accesses
# 001 -- read-only + mgmt trap (read)
# 010 -- read-only no mgmt traps
# 011 -- RESERVED/ILLEGAL, abort all accesses
# 100 -- writable + mgmt trap (any)
# 101 -- writable + mgmt trap if write
# 110 -- writable no mgmt traps
# 111 -- RESERVED/ILLEGAL abort all accesses
# Things that are not decoded in the match are accesses that
# cause no traps or aborts. So, for example, control mode 6
# is not in the cases; nor is control mode 5 if reading.
cycle = xkey.cycle
match pdr & 7:
# control modes 0, 3, and 7 are always aborts
case 0 | 3 | 7:
self.cpu.logger.debug(f"ABORT_NR trap, regs: "
f"{list(map(oct, self.cpu.r))}"
f", {oct(self.cpu.psw)}"
f", PDR={oct(pdr)} {cycle=}")
self._raisetrap(self.MMR0_BITS.ABORT_NR, vaddr, xkey)
# control mode 1 is an abort if writing, mgmt trap if read
case 1 if cycle == self.CYCLE.READ:
straps = self.cpu.STRAPBITS.MEMMGT
case 1 | 2 if cycle == self.CYCLE.WRITE:
self._raisetrap(self.MMR0_BITS.ABORT_RDONLY, vaddr, xkey)
# control mode 4 is mgmt trap on any access (read or write)
case 4:
straps = self.cpu.STRAPBITS.MEMMGT
# control mode 5 is mgmt trap if WRITING
case 5 if cycle == self.CYCLE.WRITE:
straps = self.cpu.STRAPBITS.MEMMGT
return straps
def wordRW(self, vaddr, value=None, /, *, mode=None, space=ISPACE):
"""Read/write a word at virtual address vaddr.
If value is None, perform a read and return a 16-bit value
If value is not None, perform a write; return None.
"""
cycle = self.CYCLE.READ if value is None else self.CYCLE.WRITE
pa = self.v2p(vaddr, mode, space, cycle)
if pa >= self.iopage_base:
return self.ub.mmio.wordRW(pa & self.cpu.IOPAGE_MASK, value)
else:
return self.cpu.physRW(pa, value)
def byteRW(self, vaddr, value=None, /, mode=None, space=ISPACE):
"""Read/write a byte at virtual address vaddr.
If value is None, perform a read and return an 8-bit value
If value is not None, perform a write; return None.
"""
cycle = self.CYCLE.READ if value is None else self.CYCLE.WRITE
pa = self.v2p(vaddr, mode, space, cycle)
# Physical memory is represented as an array of 16-bit word
# values, and byte operations are synthesized from that in
# the obvious manner.
#
# However, the UNIBUS is different. At the physical electrical
# signal level, the UNIBUS cannot perform byte reads, but CAN
# perform byte writes.
#
# Given that - any byte read is synthesized from corresponding
# word read operations, I/O or physical as appropriate.
#
# But byte write operations are dispatched as byte operations
# to the unibus, while still being synthesized here for memory.
odd = (pa & 1)
if value is None:
# *** READ ***
#
# Synthesized from containing word in the obvious way.
# Note little-endianness.
pa &= ~1
if pa >= self.iopage_base:
wv = self.ub.mmio.wordRW(pa & self.cpu.IOPAGE_MASK)
else:
wv = self.cpu.physRW(pa)
return ((wv >> 8) if odd else wv) & 0o377
else:
# *** WRITE ***
# This sanity check should be taken out eventually
if (value & 0xFF) != value:
raise ValueError(f"{value} is out of range")
# I/O byte writes are handled by Unibus;
# Memory byte writes are synthesized.
if pa >= self.iopage_base:
return self.ub.mmio.byteRW(pa & self.cpu.IOPAGE_MASK, value)
else:
wv = self.cpu.physRW(pa & ~1)
if odd:
wv = (wv & 0o377) | (value << 8)
else:
wv = (wv & 0o177400) | value
self.cpu.physRW(pa & ~1, wv)
return None
def wordRW_KD(self, a, v=None, /):
"""Convenienence; version of wordRW for kernel/dspace."""
return self.wordRW(a, v, mode=self.cpu.KERNEL, space=self.DSPACE)
def _raisetrap(self, trapflag, vaddr, xkey):
"""Raise an MMU trap. Commits regmods and updates reason in MMR0."""
if trapflag == self.MMR0_BITS.ABORT_PLENGTH:
self.cpu.logger.debug(f"PLF trap @ {oct(vaddr)}, {xkey=}")
self._MMR1commit()
self.MMR0 |= (trapflag |
xkey.segno << 1 | # bits <3:1>
xkey.space << 4 | # bit 4
xkey.mode << 5) # bits <6:5>
# XXX gotta figure out how to set this for Odd Addresses and
# T bit conditions, but otherwise Bit 7 is not set. From handbook:
# Bit 7 indicates that the current instruction has·been completed.
# It will be set to a during T bit, Parity, Odd Address, and
# Time Out traps and interrupts. Bit 7 is Read-Only (it cannot
# be written). It is initialized to a 1. Note that EMT, TRAP,
# BPT, and lOT do not set bit 7.
raise PDPTraps.MMU()
# handy for logging / debugging
def scstr(self):
"""Return a string representation of the segment cache."""
s = ""
for xkey, v in self.segcache.items():
ms = "KS!U"[xkey.mode]
ds = "ID"[xkey.space]
s += f"{oct(xkey.segno << 13)}:{ms}{ds}{xkey.cycle} :"
s += f" {oct(v[0])}\n"
return s

325
op00.py Normal file
View file

@ -0,0 +1,325 @@
# 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 pdptraps import PDPTraps
from op000 import op000_dispatcher
from branches import branches
def op00_4_jsr(cpu, inst):
Rn = (inst & 0o700) >> 6
if Rn == cpu.SP:
raise PDPTraps.ReservedInstruction
# according to the PDP11 handbook...
# R7 is the only register that can be used for
# both the link and destination, the other GPRs cannot.
# Thus, if the link is R5, any register except R5 can be used
# for one destination field.
#
# Does the PDP11 Trap if this is violated, or is it just "undefined"??
if Rn != cpu.PC and Rn == (inst & 0o07):
raise PDPTraps.ReservedInstruction
# Note that the computed b6 operand address IS the new PC value.
# In other words, JSR PC,(R0) means that the contents of R0 are
# the subroutine address. This is one level of indirection less
# than ordinary instructions. Hence the justEA for operandx().
# Corollary: JSR PC, R0 is illegal (instructions cannot reside
# in the registers themselves)
tmp = cpu.operandx(inst & 0o77, justEA=True)
# NOTE: no condition code modifications
# cpu.logger.debug(f"JSR to {oct(tmp)} from {oct(cpu.r[cpu.PC])}")
cpu.stackpush(cpu.r[Rn])
cpu.r[Rn] = cpu.r[cpu.PC] # this could be a no-op if Rn == 7
cpu.r[cpu.PC] = tmp
def op00_50_clr(cpu, inst, opsize=2):
"""CLR(B) (determined by opsize). Clear destination."""
cpu.psw_n = cpu.psw_v = cpu.psw_c = 0
cpu.psw_z = 1
cpu.operandx(inst & 0o77, 0, opsize=opsize)
def op00_51_com(cpu, inst, opsize=2):
"""COM(B) (determined by opsize). 1's complement destination."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
# Have to be careful about python infinite length integers
# For example, ~0xFFFF == -65536 whereas the desired result is zero.
# Hence the explicit masking
val = (~val) & cpu.MASK816[opsize]
cpu.psw_n = val & cpu.SIGN816[opsize]
cpu.psw_z = (val == 0)
cpu.psw_v = 0
cpu.psw_c = 1
cpu.operandx(xb6, val, opsize=opsize)
def op00_52_inc(cpu, inst, opsize=2):
"""INC(B) (determined by opsize). Increment destination."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
newval = (val + 1) & cpu.MASK816[opsize]
cpu.psw_n = newval & cpu.SIGN816[opsize]
cpu.psw_z = (newval == 0)
cpu.psw_v = (newval == cpu.SIGN816)
# C bit not affected
cpu.operandx(xb6, newval, opsize=opsize)
def op00_53_dec(cpu, inst, opsize=2):
"""DEC(B) (determined by opsize). Decrement destination."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
newval = (val - 1) & cpu.MASK816[opsize]
cpu.psw_n = newval & cpu.SIGN816[opsize]
cpu.psw_z = (newval == 0)
cpu.psw_v = (val == cpu.SIGN816[opsize])
# C bit not affected
cpu.operandx(xb6, newval, opsize=opsize)
def op00_54_neg(cpu, inst, opsize=2):
"""NEG(B) (determined by opsize). Negate the destination."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
newval = (-val) & cpu.MASK816[opsize]
cpu.psw_n = newval & cpu.SIGN816[opsize]
cpu.psw_z = (newval == 0)
cpu.psw_v = (val == newval) # happens at the maximum negative value
cpu.psw_c = (newval != 0)
cpu.operandx(xb6, newval, opsize=opsize)
def op00_55_adc(cpu, inst, opsize=2):
"""ADC(B) (determined by opsize). Add carry."""
# NOTE: "add carry" (not "add with carry")
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
if cpu.psw_c:
oldsign = val & cpu.SIGN816[opsize]
val = (val + 1) & cpu.MASK816[opsize]
cpu.psw_v = (val & cpu.SIGN816[opsize]) and not oldsign
cpu.psw_c = (val == 0) # because this is the NEW (+1'd) val
else:
cpu.psw_v = 0
cpu.psw_c = 0
cpu.psw_n = (val & cpu.SIGN816[opsize])
cpu.psw_z = (val == 0)
cpu.operandx(xb6, val, opsize=opsize)
def op00_56_sbc(cpu, inst, opsize=2):
"""SBC(B) (determined by opsize). Subtract carry."""
# NOTE: "subtract carry" (not "subtract with carry/borrow")
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
if cpu.psw_c:
oldsign = val & cpu.SIGN816[opsize]
val = (val - 1) & cpu.MASK816[opsize]
cpu.psw_v = oldsign and not (val & cpu.SIGN816[opsize])
cpu.psw_c = (val == cpu.MASK816[opsize]) # bcs this is the (-1'd) val
else:
cpu.psw_v = 0
cpu.psw_c = 0
cpu.psw_n = (val & cpu.SIGN816[opsize])
cpu.psw_z = (val == 0)
cpu.operandx(xb6, val, opsize=opsize)
def op00_57_tst(cpu, inst, opsize=2):
"""TST(B) (determined by opsize). Test destination."""
dst = inst & 0o77
val = cpu.operandx(dst, opsize=opsize)
cpu.psw_n = (val & cpu.SIGN816[opsize])
cpu.psw_z = (val == 0)
cpu.psw_v = 0
cpu.psw_c = 0
def op00_60_ror(cpu, inst, opsize=2):
"""ROR(B) - rotate one bit right."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
signmask = cpu.SIGN816[opsize]
vc = signmask if cpu.psw_c else 0
cpu.psw_c, val = (val & 1), ((val >> 1) | vc) & cpu.MASK816[opsize]
cpu.psw_n = val & signmask
cpu.psw_z = (val == 0)
cpu.psw_v = cpu.psw_n ^ cpu.psw_c
cpu.operandx(xb6, val, opsize=opsize)
def op00_61_rol(cpu, inst, opsize=2):
"""ROL(B) - rotate one bit left."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
signmask = cpu.SIGN816[opsize]
vc = 1 if cpu.psw_c else 0
cpu.psw_c, val = (val & signmask), ((val << 1) | vc) & cpu.MASK816[opsize]
cpu.psw_n = val & signmask
cpu.psw_z = (val == 0)
cpu.psw_v = cpu.psw_n ^ cpu.psw_c
cpu.operandx(xb6, val, opsize=opsize)
def op00_62_asr(cpu, inst, opsize=2):
"""ASR(B) - arithmetic shift right one bit."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
signbit = (val & cpu.SIGN816[opsize])
cpu.psw_c = (val & 1)
val >>= 1
val |= signbit
cpu.psw_n = (val & cpu.SIGN816[opsize])
cpu.psw_z = (val == 0)
cpu.psw_v = cpu.psw_n ^ cpu.psw_c
cpu.operandx(xb6, val, opsize=opsize)
def op00_63_asl(cpu, inst, opsize=2):
"""ASL(B) - arithmetic shift left one bit."""
val, xb6 = cpu.operandx(inst & 0o77, opsize=opsize, rmw=True)
cpu.psw_c = (val & cpu.SIGN816[opsize])
val = (val << 1) & cpu.MASK816[opsize]
cpu.psw_n = (val & cpu.SIGN816[opsize])
cpu.psw_z = (val == 0)
cpu.psw_v = cpu.psw_n ^ cpu.psw_c
cpu.operandx(xb6, val, opsize=opsize)
def op00_64_mark(cpu, inst):
raise ValueError
def op00_65_mfpi(cpu, inst, opsize=2):
"""MFPI - move from previous instruction space.
The "opsize" -- which really is just the top bit of the instruction,
encodes whether this is mfpi or mfpd:
opsize = 2 mfpi (top bit was 0)
opsize = 1 mfpd (top bit was 1)
"""
# There are some wonky special semantics. In user mode if prevmode
# is USER (which it always is in Unix) then this refers to DSPACE
# (despite the MFPI name) protect the notion of "execute only" I space
prvm = cpu.psw_prevmode
curm = cpu.psw_curmode
if prvm == cpu.USER and (curm == prvm):
space = cpu.mmu.DSPACE
else:
space = (cpu.mmu.DSPACE, cpu.mmu.ISPACE)[opsize - 1]
# MFPx SP is a special case, it means get the other SP register.
if (inst & 0o77) == 6 and (prvm != curm):
pival = cpu.stackpointers[prvm]
else:
pival = cpu.operandx(inst & 0o77, altmode=prvm, altspace=space)
cpu.psw_n = pival & cpu.MASK16
cpu.psw_z = (pival == 0)
cpu.psw_v = 0
cpu.stackpush(pival)
def op00_66_mtpi(cpu, inst, opsize=2):
"""MTPI - move to previous instruction space.
The "opsize" encodes whether this is mtpi or mtpd:
opsize = 2 mtpi
opsize = 1 mtpd
"""
# there are some wonky semantics ... this instruction is NOT restricted
# to privileged modes and is potentially a path to writing into
# a privileged space (!!). Unix (and probably all others) deals with
# this by ensuring psw_prevmode is also USER when in USER mode.
targetspace = (cpu.mmu.DSPACE, cpu.mmu.ISPACE)[opsize - 1]
w = cpu.stackpop()
cpu.psw_n = w & cpu.MASK16
cpu.psw_z = (w == 0)
cpu.psw_v = 0
prvm = cpu.psw_prevmode
curm = cpu.psw_curmode
# note the special case that MTPx SP writes the other mode's SP register
if (inst & 0o77) == 6 and (prvm != curm):
cpu.stackpointers[prvm] = w
else:
cpu.operandx(inst & 0o077, w, altmode=prvm, altspace=targetspace)
def op00_67_sxt(cpu, inst):
if cpu.psw_n:
val = cpu.MASK16
else:
val = 0
cpu.psw_z = not cpu.psw_n
cpu.operandx(inst & 0o0077, val)
ops56tab = (
op00_50_clr,
op00_51_com,
op00_52_inc,
op00_53_dec,
op00_54_neg,
op00_55_adc,
op00_56_sbc,
op00_57_tst,
op00_60_ror,
op00_61_rol,
op00_62_asr,
op00_63_asl,
op00_64_mark,
op00_65_mfpi, # note: "byte" variant is really MFPD
op00_66_mtpi, # note: "byte" variant is really MTPD
op00_67_sxt)
op00_dispatch_table = (
op000_dispatcher,
branches,
branches,
branches,
op00_4_jsr,
lambda cpu, inst: ops56tab[((inst & 0o7700) >> 6) - 0o50](cpu, inst),
lambda cpu, inst: ops56tab[((inst & 0o7700) >> 6) - 0o50](cpu, inst),
None)

133
op000.py Normal file
View file

@ -0,0 +1,133 @@
# 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 pdptraps import PDPTraps
# ... and even further down the op decode rabbit hole we go!
# These are the decodes for opcodes starting 0o000
from branches import branch
def op_halt(cpu, inst):
if cpu.psw_curmode != cpu.KERNEL:
# strange trap, but that's what it says
raise PDPTraps.AddressError(cpu.CPUERR_BITS.ILLHALT)
cpu.halted = True
def op_reset(cpu, inst):
cpu.logger.debug("RESET INSTRUCTION")
cpu.ub.resetbus()
def op_wait(cpu, inst):
cpu.logger.debug("WAIT")
cpu.ub.intmgr.waitstate(cpu.psw_pri) # will pend until an interrupt
def op_rtt(cpu, inst):
cpu.r[cpu.PC] = cpu.stackpop()
cpu.psw = cpu.stackpop()
def op_02xx(cpu, inst):
x5 = (inst & 0o70)
if x5 == 0o00:
op_rts(cpu, inst)
elif x5 == 0o30:
op_spl(cpu, inst)
elif x5 >= 0o40:
op_xcc(cpu, inst)
else:
raise PDPTraps.ReservedInstruction
def op_spl(cpu, inst):
"""SPL; note that this is a no-op (!) not a trap in non-kernel mode."""
if cpu.psw_curmode == cpu.KERNEL:
cpu.psw = (cpu.psw & ~ (0o07 << 5)) | ((inst & 0o07) << 5)
def op_rts(cpu, inst):
Rn = (inst & 0o07)
cpu.r[cpu.PC] = cpu.r[Rn] # will be a no-op for RTS PC
cpu.r[Rn] = cpu.stackpop()
def op_jmp(cpu, inst):
# same justEA/operand non-indirection discussion as in JSR (see)
cpu.r[cpu.PC] = cpu.operandx(inst & 0o77, justEA=True)
def op_swab(cpu, inst):
"""SWAB swap bytes."""
val, xb6 = cpu.operandx(inst & 0o77, rmw=True)
val = ((val >> 8) & cpu.MASK8) | ((val & cpu.MASK8) << 8)
cpu.psw_n = val & cpu.SIGN16
# note this screwy definition, per the handbook
cpu.psw_z = ((val & cpu.MASK8) == 0)
cpu.psw_v = 0
cpu.psw_c = 0
cpu.operandx(xb6, val)
def op_xcc(cpu, inst):
"""XCC - all variations of set/clear condition codes."""
setclr = inst & 0o020 # set it or clear it
if inst & 0o10:
cpu.psw_n = setclr
if inst & 0o04:
cpu.psw_z = setclr
if inst & 0o02:
cpu.psw_v = setclr
if inst & 0o01:
cpu.psw_c = setclr
def op000_dispatcher(cpu, inst):
match (inst & 0o0700):
case 0o0000:
if inst == 0:
op_halt(cpu, inst)
elif inst == 6 or inst == 2: # RTI and RTT are identical!!
op_rtt(cpu, inst)
elif inst == 1:
op_wait(cpu, inst)
elif inst == 5:
op_reset(cpu, inst)
case 0o0100:
op_jmp(cpu, inst)
case 0o0200:
op_02xx(cpu, inst)
case 0o0300:
op_swab(cpu, inst)
# note that 2 bits of the branch offset sneak into this match
case 0o0400 | 0o0500 | 0o0600 | 0o0700:
branch(cpu, inst, lambda n, z, v, c: True)

224
op07.py Normal file
View file

@ -0,0 +1,224 @@
# 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.
# op07 instructions
# 6 simple instructions appear in the op07 space: 070 .. 074 and 077.
# In these the first operand is restricted to being only a register
# (because the three bits usually used for 'mode' are part of
# these opcodes). The destination is still a full 6-bit specification.
#
# op075 is used for floating point instruction encoding (FP instructions
# are also found in other nooks and crannies)
#
# op076 is the commercial instruction set
#
def op070_mul(cpu, inst):
dstreg = (inst & 0o000700) >> 6
r = cpu.r[dstreg]
src = cpu.operandx(inst & 0o077)
# unlike add/subtract, need to explicitly treat as signed.
# The right results require sign extending both 16 bit operands to
# 32 bits, multiplying them, then taking the bottom 32 bits of the result.
# It may not be obvious why this works; see google.
if r >= 32768:
r |= 0xFFFF0000
if src >= 32768:
src |= 0xFFFF0000
m = (src * r) & 0xFFFFFFFF
# the result is stored:
# high 16 bits in dstreg
# low 16 bits in dstreg|1
# and if dstreg is odd ONLY the low 16 bits are stored
# This just stores both but in careful order
cpu.r[dstreg] = (m >> 16) & 0xFFFF
cpu.r[dstreg | 1] = m & 0xFFFF
cpu.psw_n = m & 0x80000000
if cpu.psw_n:
cpu.psw_c = (m < 0xFFFF8000)
else:
cpu.psw_c = (m >= 32768)
cpu.psw_z = (m == 0)
cpu.psw_v = 0
def op071_div(cpu, inst):
dstreg = (inst & 0o000700) >> 6
if (dstreg & 1):
raise PDPTraps.ReservedInstruction # dstreg must be even
dividend = (cpu.r[dstreg] << 16) | cpu.r[dstreg | 1]
divisor = cpu.operandx(inst & 0o077)
if divisor == 0:
cpu.psw_n = 0
cpu.psw_z = 1
cpu.psw_v = 1
cpu.psw_c = 1
elif divisor == 0o177777 and dividend == 0x80000000:
# maxneg / -1 == too big
cpu.psw_n = 0
cpu.psw_z = 0
cpu.psw_v = 1
cpu.psw_c = 0
else:
# convert both numbers to positive equivalents
# and track sign info manually
if dividend & cpu.SIGN32:
dividend = 4*1024*1024*1024 - dividend
ddendposneg = -1
else:
ddendposneg = 1
posneg = ddendposneg
if divisor & cpu.SIGN16:
divisor = 65536 - divisor
posneg = -posneg
q, rem = divmod(dividend, divisor)
q *= posneg
if q > 32767 or q < -32768:
cpu.psw_n = 0
cpu.psw_z = 0
cpu.psw_v = 1
cpu.psw_c = 0
else:
if ddendposneg < 0:
rem = -rem
cpu.psw_n = (q < 0)
cpu.psw_z = (q == 0)
cpu.psw_v = 0
cpu.psw_c = 0
cpu.r[dstreg] = q & cpu.MASK16
cpu.r[dstreg | 1] = rem & cpu.MASK16
def op072_ash(cpu, inst):
dstreg = (inst & 0o000700) >> 6
r = cpu.r[dstreg]
shift = cpu.operandx(inst & 0o077) & 0o077
r = _shifter(cpu, r, shift, opsize=2)
cpu.r[dstreg] = r
def op073_ashc(cpu, inst):
dstreg = (inst & 0o000700) >> 6
r = (cpu.r[dstreg] << 16) | cpu.r[dstreg | 1]
shift = cpu.operandx(inst & 0o077) & 0o077
r = _shifter(cpu, r, shift, opsize=4)
cpu.r[dstreg] = (r >> 16) & cpu.MASK16
cpu.r[dstreg | 1] = r & cpu.MASK16
# this is the heart of ash and ashc
def _shifter(cpu, value, shift, *, opsize):
"""Returns shifted value and sets condition codes."""
signmask = cpu.SIGN16
signextend = 0xFFFFFFFF0000
if opsize == 4:
signmask <<= 16
signextend <<= 16
vsign = value & signmask
if shift == 0:
cpu.psw_n = vsign
cpu.psw_z = (value == 0)
cpu.psw_v = 0
# C is not altered
return value
elif shift > 31: # right shift
# sign extend if appropriate, so the sign propagates
if vsign:
value |= signextend
# right shift by 1 less, to capture bottom bit for C
value >>= (63 - shift) # yes 63, see ^^^^^^^^^^^^^^^
cbit = (value & 1)
value >>= 1
else:
# shift by 1 less, again to capture cbit
value <<= (shift - 1)
cbit = value & signmask
value <<= 1
value &= (signmask | (signmask - 1))
cpu.psw_n = (value & signmask)
cpu.psw_z = (value == 0)
cpu.psw_v = (cpu.psw_n != vsign)
cpu.psw_c = cbit
return value
def op074_xor(cpu, inst):
srcreg = (inst & 0o000700) >> 6
r = cpu.r[srcreg]
s, xb6 = cpu.operandx(inst & 0o077, rmw=True)
r ^= s
cpu.psw_n = (r & cpu.SIGN16)
cpu.psw_z = (r == 0)
cpu.psw_v = 0
cpu.operandx(xb6, r)
def op077_sob(cpu, inst):
srcreg = (inst & 0o000700) >> 6
r = cpu.r[srcreg]
if r == 1:
r = 0
else:
if r > 0:
r -= 1
else:
r = 0o177777 # 0 means max, that's how SOB is defined
# technically if this instruction occurs low enough in memory
# this PC subtraction could wrap, so be technically correct & mask
cpu.r[cpu.PC] = (cpu.r[cpu.PC] - 2 * (inst & 0o077)) & cpu.MASK16
cpu.r[srcreg] = r
# dispatch on the next digit after the 07 part...
op07_dispatch_table = (
op070_mul,
op071_div,
op072_ash,
op073_ashc,
op074_xor,
None, # various float instructions, not implemented
None, # CIS instructions, not implmented
op077_sob)

60
op10.py Normal file
View file

@ -0,0 +1,60 @@
# 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 branches import branches
from op00 import ops56tab # these have byte variants in op10
from pdptraps import PDPTraps
# dispatch MOST (but not all) of the 105x and 106x instructions
# to the op00 routines but with byte variation (opsize=1). But
# there are exceptions: 1064 unused, 1065 MFPD, 1066 MTPD, 1067 unused
def op156(cpu, inst):
i56x = (inst & 0o7700) >> 6
# 64 (mark) and 67 (sxt) do NOT have byte variants
if i56x in (0o64, 0o67):
raise PDPTraps.ReservedInstruction
try:
opf = ops56tab[i56x - 0o50]
except IndexError:
raise PDPTraps.ReservedInstruction
opf(cpu, inst, opsize=1)
def op10_4_emttrap(cpu, inst):
# bit 8 determines EMT (0) or TRAP(1)
if (inst & 0o000400):
raise PDPTraps.TRAP
else:
raise PDPTraps.EMT
op10_dispatch_table = (
branches,
branches,
branches,
branches,
op10_4_emttrap,
op156,
op156,
None)

226
op4.py Normal file
View file

@ -0,0 +1,226 @@
# 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.
#
# TOP LEVEL OP CODE DISPATCH AND INSTRUCTION IMPLEMENTATION
#
# NOTES:
#
# DISPATCH
# 2-operand instructions are implemented here and are dispatched
# off the top-4 bits of the instruction (hence "op4" name).
#
# The other instructions are encoded into the 0o00, 0o07, and 0o10
# portions of this top-4 bit address space. They are dispatched
# via d3dispatch and respective tables from other modules.
#
# BYTE vs WORD operations:
# Most of them come in two flavors - word and byte, with the
# top-bit distinguishing. This is communicated to the implementation
# functions via "opsize=1" or "opsize=2" when a single function can
# implement both variations. Note that MOV/MOVB are specifically
# optimized, separately, for performance.
#
# PSW updates:
# All instructions must be careful to do their final result writes
# AFTER setting the PSW, because the PSW is addressible via memory
# (a write to unibus 777776) and such a write is supposed to override
# the otherwise-native instruction CC results.
#
# dispatchers to next level for 00, 07, and 10 instructions:
from op00 import op00_dispatch_table
from op07 import op07_dispatch_table
from op10 import op10_dispatch_table
from pdptraps import PDPTraps
def d3dispatcher(d3table, cpu, inst):
opf = d3table[(inst & 0o7000) >> 9]
if opf is None:
raise PDPTraps.ReservedInstruction
opf(cpu, inst)
# This is ALWAYS a 16-bit MOV
def op01_mov(cpu, inst):
"""MOV src,dst -- always 16 bits"""
# avoid call to the more-general operandx for mode 0, direct register.
# This optimization is a substantial speed up for register MOVs.
if (inst & 0o7000) == 0:
val = cpu.r[(inst & 0o700) >> 6]
else:
val = cpu.operandx((inst & 0o7700) >> 6)
cpu.psw_v = 0 # per manual; V is cleared
cpu.psw_z = (val == 0)
cpu.psw_n = (val > 32767)
# same optimization on the write side.
if (inst & 0o70) == 0:
cpu.r[(inst & 0o07)] = val
else:
cpu.operandx(inst & 0o0077, val)
# This is ALWAYS an 8-bit MOVB
def op11_movb(cpu, inst):
"""MOVB src,dst -- always 8 bits"""
# avoid call to the more-general operandx for mode 0, direct register.
# This optimization is a substantial speed up for register MOVs.
if (inst & 0o7000) == 0:
val = cpu.r[(inst & 0o700) >> 6] & 0o377
else:
val = cpu.operandx((inst & 0o7700) >> 6, opsize=1)
cpu.psw_v = 0
cpu.psw_z = (val == 0)
cpu.psw_n = (val & 0o200)
# No optimization on the write side, because doing so would require
# duplicating the sign-extend logic here. Yuck.
cpu.operandx(inst & 0o0077, val, opsize=1)
def op02_cmp(cpu, inst, opsize=2):
"""CMP(B) src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6, opsize=opsize)
dst = cpu.operandx(inst & 0o0077, opsize=opsize)
# note: this is other order than SUB
t = (src - dst) & cpu.MASK816[opsize]
cpu.psw_c = (src < dst)
signbit = cpu.SIGN816[opsize]
cpu.psw_n = (t & signbit)
cpu.psw_z = (t == 0)
# definition of V is: operands were of opposite signs and the sign
# of the destination was the same as the sign of the result
src_sign = src & signbit
dst_sign = dst & signbit
t_sign = t & signbit
cpu.psw_v = (dst_sign == t_sign) and (src_sign != dst_sign)
def op03_bit(cpu, inst, opsize=2):
"""BIT(B) src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6, opsize=opsize)
dst = cpu.operandx(inst & 0o0077, opsize=opsize)
t = dst & src
cpu.psw_n = t & cpu.SIGN816[opsize]
cpu.psw_z = (t == 0)
cpu.psw_v = 0
# cpu.logger.debug(f"BIT: {src=}, {dst=}, PSW={oct(cpu.psw)}")
def op04_bic(cpu, inst, opsize=2):
"""BIC(B) src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6, opsize=opsize)
dst, xb6 = cpu.operandx(inst & 0o0077, opsize=opsize, rmw=True)
dst &= ~src
cpu.psw_n = dst & cpu.SIGN816[opsize]
cpu.psw_z = (dst == 0)
cpu.operandx(xb6, dst, opsize=opsize)
def op05_bis(cpu, inst, opsize=2):
"""BIS(B) src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6, opsize=opsize)
dst, xb6 = cpu.operandx(inst & 0o0077, opsize=opsize, rmw=True)
dst |= src
cpu.psw_n = dst & cpu.SIGN816[opsize]
cpu.psw_z = (dst == 0)
cpu.psw_v = 0
cpu.operandx(xb6, dst, opsize=opsize)
def op06_add(cpu, inst):
"""ADD src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6)
dst, xb6 = cpu.operandx(inst & 0o0077, rmw=True)
t = src + dst
cpu.psw_c = (t > cpu.MASK16)
if cpu.psw_c:
t &= cpu.MASK16
cpu.psw_n = (t & cpu.SIGN16)
cpu.psw_z = (t == 0)
# definition of V is: operands were of the same signs and the
# sign of the result is different.
src_sign = src & cpu.SIGN16
dst_sign = dst & cpu.SIGN16
t_sign = t & cpu.SIGN16
cpu.psw_v = (dst_sign != t_sign) and (src_sign == dst_sign)
cpu.operandx(xb6, t)
def op16_sub(cpu, inst):
"""SUB src,dst"""
src = cpu.operandx((inst & 0o7700) >> 6)
dst, xb6 = cpu.operandx(inst & 0o0077, rmw=True)
t = (dst - src) & cpu.MASK16 # note: this is opposite of CMP
cpu.psw_n = (t & cpu.SIGN16)
cpu.psw_z = (t == 0)
# definition of V is: operands were of opposite signs and the sign
# of the source was the same as the sign of the result
src_sign = src & cpu.SIGN16
dst_sign = dst & cpu.SIGN16
t_sign = t & cpu.SIGN16
cpu.psw_v = (src_sign == t_sign) and (src_sign != dst_sign)
cpu.psw_c = (src > dst)
cpu.operandx(xb6, t)
def op17_reserved(cpu, inst):
raise PDPTraps.ReservedInstruction
op4_dispatch_table = (
lambda c, i: d3dispatcher(op00_dispatch_table, c, i),
op01_mov,
op02_cmp,
op03_bit,
op04_bic,
op05_bis,
op06_add,
lambda c, i: d3dispatcher(op07_dispatch_table, c, i),
lambda c, i: d3dispatcher(op10_dispatch_table, c, i),
op11_movb, # NOTE: optimized; not mov+lambda
lambda c, i: op02_cmp(c, i, opsize=1), # 12
lambda c, i: op03_bit(c, i, opsize=1), # 13
lambda c, i: op04_bic(c, i, opsize=1), # 14
lambda c, i: op05_bis(c, i, opsize=1), # 15
op16_sub,
op17_reserved) # 17 reserved

737
pdptests.py Normal file
View file

@ -0,0 +1,737 @@
# 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 itertools import chain
from types import SimpleNamespace
from machine import PDP1170
from pdptraps import PDPTraps
import unittest
import random
class TestMethods(unittest.TestCase):
PDPLOGLEVEL = 'INFO'
# DISCLAIMER ABOUT TEST CODING PHILOSOPHY:
# For the most part, actual PDP-11 machine code is created and
# used to establish the test conditions, as this provides additional
# (albeit haphazard) testing of the functionality. Occasionally it's
# just too much hassle to do that and the pdp object is manipulated
# directly via methods/attributes to establish conditions.
# There's no rhyme or reason in picking the approach for a given test.
# used to create various instances, collects all the options
# detail into this one place...
@classmethod
def make_pdp(cls):
return PDP1170(console=False, loglevel=cls.PDPLOGLEVEL)
@staticmethod
def ioaddr(p, offs):
"""Given a within-IO-page IO offset, return an IO addr."""
return (offs + p.mmu.iopage_base) & 0o177777
# convenience routine to load word values into physical memory
@staticmethod
def loadphysmem(p, words, addr):
for a, w in enumerate(words, start=(addr >> 1)):
p.physmem[a] = w
# some of these can't be computed at class definition time, so...
@classmethod
def usefulconstants(cls):
p = cls.make_pdp() # meh, need this for some constants
ns = SimpleNamespace()
# Kernel instruction space PDR registers
ns.KISD0 = cls.ioaddr(p, p.mmu.APR_KERNEL_OFFS)
# Kernel data space PDR registers
ns.KDSD0 = ns.KISD0 + 0o20
# Kernel instruction space PAR registers
ns.KISA0 = ns.KDSD0 + 0o20
# Kernel data space PAR registers
ns.KDSA0 = ns.KISA0 + 0o20
# User mode similar
ns.UISD0 = cls.ioaddr(p, p.mmu.APR_USER_OFFS)
ns.UDSD0 = ns.UISD0 + 0o20
ns.UISA0 = ns.UDSD0 + 0o20
ns.UDSA0 = ns.UISA0 + 0o20
return ns
#
# Create and return a test machine with a simple memory mapping:
# Kernel Instruction space seg 0 points to physical 0
# Kernel Data space segment 0 also points to physical 0
# User instruction space seg 0 points to physical 0o20000
# User Data space seg 0 points to physical 0o40000
# and turns on the MMU
#
def simplemapped_pdp(self, p=None, addons=[]):
if p is None:
p = self.make_pdp()
cn = self.usefulconstants()
# this is a table of instructions that ...
# Puts the system stack at 0o20000 (8K)
# Puts 0o22222 into physical location 0o20000
# Puts 0o33333 into physical location 0o20002
# Puts 0o44444 into physical location 0o40000
# Sets Kernel Instruction space A0 to point to physical 0
# Sets Kernel Data space A0 to point to the first 8K physical memory
# Sets Kernel Data space A7 to point to the IO page
# Sets User Instruction space A0 to point to physical 0o20000
# sets User Data space D0 to point to physical 0o40000
# and turns on the MMU with I/D sep
#
# These instructions will be placed at 2K in memory
#
setup_instructions = (
0o012706, 0o20000, # put system stack at 8k and works down
0o012737, 0o22222, 0o20000,
0o012737, 0o33333, 0o20002,
0o012737, 0o44444, 0o40000,
# point both kernel seg 0 PARs to physical zero
0o005037, cn.KISA0, # CLR $KISA0
0o005037, cn.KDSA0, # CLR $KDSA0
# kernel seg 7 D space PAR to I/O page (at 22-bit location)
0o012737, 0o017760000 >> 6, cn.KDSA0 + (7 * 2),
# user I seg 0 to 0o20000, user D seg 0 to 0o40000
0o012737, 0o20000 >> 6, cn.UISA0,
0o012737, 0o40000 >> 6, cn.UDSA0,
# set the PDRs for segment zero
0o012703, 0o077406, # MOV #77406,R3
# 77406 = PDR<2:0> = ACF = 0o110 = read/write
# PLF<14:8> =0o0774 = full length (128*64 bytes = 8K)
0o010337, cn.KISD0, # MOV R3,KISD0 ...
0o010337, cn.KDSD0,
0o010337, cn.UISD0,
0o010337, cn.UDSD0,
# PDR for segment 7
0o010337, cn.KDSD0 + (7 * 2),
# set previous mode to USER, keeping current mode KERNEL, pri 7
0o012737, (p.KERNEL << 14) | (p.USER << 12) | (7 << 5),
self.ioaddr(p, p.PS_OFFS),
# turn on 22-bit mode, unibus mapping, and I/D sep for k & u
0o012737, 0o000065, self.ioaddr(p, p.mmu.MMR3_OFFS),
# turn on relocation mode ... yeehah! (MMR0 known zero here)
0o005237, self.ioaddr(p, p.mmu.MMR0_OFFS), # INC MMR0
)
instloc = 0o4000 # 2K
self.loadphysmem(p, chain(setup_instructions, addons, (0o0,)), instloc)
return p, instloc
# these tests end up testing a other stuff too of course, including MMU
def test_mfpi(self):
# ((r0, ..., rN) results, (instructions)), ...
tvecs = (
# r1=2, mfpi (r1) -> r0; expect r0 = 33333
((0o33333,), (0o012701, 0o02, 0o006511, 0o012600)),
# r1=0, mfpi (r1) -> r0; expect r0 = 22222
((0o22222,), (0o012701, 0o00, 0o006511, 0o012600)),
)
for rslts, insts in tvecs:
with self.subTest(rslts=rslts, insts=insts):
p, pc = self.simplemapped_pdp(addons=insts)
p.run(pc=pc)
for rN, v in enumerate(rslts):
self.assertEqual(p.r[rN], v)
def test_mtpi(self):
# need an instance just for the constants, meh
px = self.make_pdp()
tvecs = (
((0o1717,), (0o012746, 0o1717, 0o006637, 0o02,
# turn MMU back off (!)
0o005037, self.ioaddr(px, px.mmu.MMR0_OFFS),
0o013700, 0o20002)),
)
for rslts, insts in tvecs:
with self.subTest(rslts=rslts, insts=insts):
p, pc = self.simplemapped_pdp(addons=insts)
p.run(pc=pc)
for rN, v in enumerate(rslts):
self.assertEqual(p.r[rN], v)
def test_add_sub(self):
p = self.make_pdp()
testvecs = (
# (op0, op1, expected op0 + op1, nzvc, expected op0 - op1, nzvc)
# None for nzvc means dont test that (yet/for-now/need to verify)
(1, 1, 2, 0, 0, 4), # 1 + 1 = 2(_); 1 - 1 = 0(Z)
(1, 32767, 32768, 0o12, 32766, 0),
(0, 0, 0, 0o04, 0, 0o04),
(32768, 1, 32769, 0o10, 32769, 0o13),
(65535, 1, 0, 0o05, 2, 1),
)
testloc = 0o10000
add_loc = testloc
sub_loc = testloc + 4
p.physmem[add_loc >> 1] = 0o060001 # ADD R0,R1
p.physmem[(add_loc >> 1) + 1] = 0
p.physmem[sub_loc >> 1] = 0o160001 # SUB R0,R1
p.physmem[(sub_loc >> 1) + 1] = 0
for r0, r1, added, a_nzvc, subbed, s_nzvc in testvecs:
with self.subTest(r0=r0, r1=r1, op="add"):
p.r[0] = r0
p.r[1] = r1
p.run(pc=add_loc)
self.assertEqual(p.r[1], added)
if a_nzvc is not None:
self.assertEqual(p.psw & 0o17, a_nzvc)
with self.subTest(r0=r0, r1=r1, op="sub"):
p.r[0] = r0
p.r[1] = r1
p.run(pc=sub_loc)
self.assertEqual(p.r[1], subbed)
if s_nzvc is not None:
self.assertEqual(p.psw & 0o17, s_nzvc)
def test_bne(self):
p = self.make_pdp()
loopcount = 0o1000
insts = (
# Program is:
# MOV loopcount,R1
# CLR R0
# LOOP: INC R0
# DEC R1
# BNE LOOP
# HALT
0o012701, loopcount, 0o005000, 0o005200, 0o005301, 0o001375, 0)
instloc = 0o4000
self.loadphysmem(p, insts, instloc)
p.run(pc=instloc)
self.assertEqual(p.r[0], loopcount)
self.assertEqual(p.r[1], 0)
def test_cc(self):
# various condition code tests
p = self.make_pdp()
insts = (
# program is:
# CLR R0
# BEQ 1f
# HALT
# 1: CCC
# BNE 1f
# HALT
# 1: DEC R0
# MOV @#05000,R1 ; see discussion below
# MOV @#05002,R2 ; see discussion below
# CMP R1,R2
# BLE 1f
# HALT
# 1: DEC R0
# CMP R2,R1
# BGT 1f
# HALT
# 1: DEC R0
# HALT
#
# and the program will poke various test cases into locations
# 5000 and 5002, with the proviso that 5000 is always the lesser.
#
# Given that, after running the program R0 should be 65553
0o005000, 0o101401, 0o0, 0o000257, 0o001001, 0, 0o005300,
# MOV @#5000 etc
0o013701, 0o5000, 0o013702, 0o5002,
# CMP R1,R2 BLE
0o020102, 0o003401, 0, 0o005300,
# CMP R2,R1 BGT
0o020201, 0o003001, 0, 0o005300,
0)
instloc = 0o4000
self.loadphysmem(p, insts, instloc)
# just a convenience so the test data can use neg numbers
def s2c(x):
return x & 0o177777
for lower, higher in ((0, 1), (s2c(-1), 0), (s2c(-1), 1),
(s2c(-32768), 32767),
(s2c(-32768), 0), (s2c(-32768), 32767),
(17, 42), (s2c(-42), s2c(-17))):
p.physmem[0o5000 >> 1] = lower
p.physmem[0o5002 >> 1] = higher
with self.subTest(lower=lower, higher=higher):
p.run(pc=instloc)
self.assertEqual(p.r[0], 65533)
# probably never a good idea, but ... do some random values
for randoms in range(1000):
a = random.randint(-32768, 32767)
b = random.randint(-32768, 32767)
while a == b:
b = random.randint(-32768, 32767)
if a > b:
a, b = b, a
p.physmem[0o5000 >> 1] = s2c(a)
p.physmem[0o5002 >> 1] = s2c(b)
with self.subTest(lower=a, higher=b):
p.run(pc=instloc)
self.assertEqual(p.r[0], 65533)
def test_unscc(self):
# more stuff like test_cc but specifically testing unsigned Bxx codes
p = self.make_pdp()
insts = (
# program is:
# CLR R0
# MOV @#05000,R1 ; see discussion below
# MOV @#05002,R2 ; see discussion below
# CMP R1,R2
# BCS 1f ; BCS same as BLO
# HALT
# 1: DEC R0
# CMP R2,R1
# BHI 1f
# HALT
# 1: DEC R0
# HALT
#
# test values in 5000,5002 .. unsigned and 5002 always higher
#
# Given that, after running the program R0 should be 65534
0o005000,
# MOV @#5000 etc
0o013701, 0o5000, 0o013702, 0o5002,
# CMP R1,R2 BCS
0o020102, 0o103401, 0, 0o005300,
# CMP R2,R1 BHI
0o020201, 0o101001, 0, 0o005300,
0)
instloc = 0o4000
self.loadphysmem(p, insts, instloc)
for lower, higher in ((0, 1), (0, 65535), (32768, 65535),
(65534, 65535),
(32767, 32768),
(17, 42)):
p.physmem[0o5000 >> 1] = lower
p.physmem[0o5002 >> 1] = higher
with self.subTest(lower=lower, higher=higher):
p.run(pc=instloc)
self.assertEqual(p.r[0], 65534)
# probably never a good idea, but ... do some random values
for randoms in range(1000):
a = random.randint(0, 65535)
b = random.randint(0, 65535)
while a == b:
b = random.randint(0, 65535)
if a > b:
a, b = b, a
p.physmem[0o5000 >> 1] = a
p.physmem[0o5002 >> 1] = b
with self.subTest(lower=a, higher=b):
p.run(pc=instloc)
self.assertEqual(p.r[0], 65534)
def test_ash1(self):
# this code sequence taken from Unix startup, it's not really
# much of a test.
insts = (0o012702, 0o0122451, # mov #122451,R2
0o072227, 0o0177772, # ash -6,R2
0o042702, 0o0176000, # bic #0176000,R2
0) # R2 should be 1224
p = self.make_pdp()
instloc = 0o4000
self.loadphysmem(p, insts, instloc)
p.run(pc=instloc)
self.assertEqual(p.r[2], 0o1224)
def test_br(self):
# though the bug has been fixed, this is a test of whether
# all branch offset values work correctly. Barn door shut...
p = self.make_pdp()
# the idea is a block of INC R0 instructions
# followed by a halt, then a spot for a branch
# then a block of INC R1 instructions followed by a halt
#
# By tweaking the BR instruction (different forward/back offsets)
# and starting execution at the BR, the result on R0 and R1
# will show if the correct branch offset was effected.
#
# NOTE: 0o477 (branch offset -1) is a tight-loop branch to self
# and that case is tested separately.
#
insts = [0o5200] * 300 # 300 INC R0 instructions
insts += [0] # 1 HALT instruction
insts += [0o477] # BR instruction .. see below
# want to know where in memory this br will is
brspot = len(insts) - 1
insts += [0o5201] * 300 # 300 INC R1 instructions
insts += [0] # 1 HALT instruction
# put that mess into memory at an arbitrary spot
baseloc = 0o10000
for a, w in enumerate(insts, start=(baseloc >> 1)):
p.physmem[a] = w
# test the negative offsets:
# Set R0 to 65535 (-1)
# Set R1 to 17
# -1 is a special case, that's the tight loop and not tested here
# -2 reaches the HALT instruction only, R0 will remain 65535
# -3 reaches back to one INC R0, R0 will be 0
# -4 reaches back two INC R0's, R0 will be 1
# and so on
# 0o400 | offset starting at 0o376 will be the BR -2 case
expected_R0 = 65535
for offset in range(0o376, 0o200, -1):
p.physmem[(baseloc >> 1) + brspot] = (0o400 | offset)
p.r[0] = 65535
p.r[1] = 17
# note the 2* because PC is an addr vs physmem word index
p.run(pc=baseloc + (2*brspot))
with self.subTest(offset=offset):
self.assertEqual(p.r[0], expected_R0)
self.assertEqual(p.r[1], 17)
expected_R0 = (expected_R0 + 1) & 0o177777
# and the same sort of test but with forward branching
expected_R1 = 42 + 300
for offset in range(0, 0o200):
p.physmem[(baseloc >> 1) + brspot] = (0o400 | offset)
p.r[0] = 17
p.r[1] = 42
# note the 2* because PC is an addr vs physmem word index
p.run(pc=baseloc + (2*brspot))
with self.subTest(offset=offset):
self.assertEqual(p.r[0], 17)
self.assertEqual(p.r[1], expected_R1)
expected_R1 = (expected_R1 - 1) & 0o177777
def test_trap(self):
# test some traps
p = self.make_pdp()
# put a handlers for different traps into memory
# starting at location 0o10000 (4K). This just knows
# that each handler is 3 words long, the code being:
# MOV something,R4
# RTT
#
# where the "something" changes with each handler.
handlers_addr = 0o10000
handlers = (
0o012704, 0o4444, 0o000006, # for vector 0o004
0o012704, 0o1010, 0o000006, # for vector 0o010
0o012704, 0o3030, 0o000006, # for vector 0o030
0o012704, 0o3434, 0o000006 # for vector 0o034
)
self.loadphysmem(p, handlers, handlers_addr)
# and just jam the vectors in place
p.physmem[2] = handlers_addr # vector 0o004
p.physmem[3] = 0 # new PSW, stay in kernel mode
p.physmem[4] = handlers_addr + 6 # each handler above was 6 bytes
p.physmem[5] = 0
p.physmem[12] = handlers_addr + 12 # vector 0o30 (EMT)
p.physmem[13] = 0
p.physmem[14] = handlers_addr + 18 # vector 0o34 (TRAP)
p.physmem[15] = 0
# (tnum, insts)
testvectors = (
# this will reference an odd address, trap 4
(0o4444, (
# establish reasonable stack pointer (at 8K)
0o012706, 0o20000,
# CLR R3 and R4 so will know if they get set to something
0o005003, 0o005004,
# put 0o1001 into R0
0o012700, 0o1001,
# and reference it ... boom!
0o011001,
# show that the RTT got to here by putting magic into R3
0o012703, 0o123456)),
# this will execute a reserved instruction trap 10
(0o1010, (
# establish reasonable stack pointer (at 8K)
0o012706, 0o20000,
# CLR R3 and R4 so will know if they get set to something
0o005003, 0o005004,
# 0o007777 is a reserved instruction ... boom!
0o007777,
# show that the RTT got to here by putting magic into R3
0o012703, 0o123456)),
# this will execute an EMT instruction
(0o3030, (
# establish reasonable stack pointer (at 8K)
0o012706, 0o20000,
# CLR R3 and R4 so will know if they get set to something
0o005003, 0o005004,
# EMT #42
0o104042,
# show that the RTT got to here by putting magic into R3
0o012703, 0o123456)),
# this will execute an actual TRAP instruction
(0o3434, (
# establish reasonable stack pointer (at 8K)
0o012706, 0o20000,
# CLR R3 and R4 so will know if they get set to something
0o005003, 0o005004,
# TRAP #17
0o104417,
# show that the RTT got to here by putting magic into R3
0o012703, 0o123456)),
)
for R4, insts in testvectors:
self.loadphysmem(p, insts, 0o3000)
p.run(pc=0o3000)
self.assertEqual(p.r[3], 0o123456)
self.assertEqual(p.r[4], R4)
def test_trapcodes(self):
# a more ambitious testing of TRAP which verifies all
# available TRAP instruction codes work
p = self.make_pdp()
# poke the TRAP vector info directly in
p.physmem[14] = 0o10000 # vector 0o34 (TRAP) --> 0o10000
p.physmem[15] = 0
# this trap handler puts the trap # into R3
handler = (
# the saved PC is at the top of the stack ... get it
0o011600, # MOV (SP),R0
# get the low byte of the instruction which is the trap code
# note that the PC points after the TRAP instruction so
# MOVB -2(R0),R3
0o116003, 0o177776,
# RTT
6)
self.loadphysmem(p, handler, 0o10000)
# just bash a stack pointer directly in
p.r[6] = 0o20000 # 8K and working down
for i in range(256):
insts = (
0o104400 | i, # TRAP #i
0o010301, # MOV R3,R1 just to show RTT worked
0)
self.loadphysmem(p, insts, 0o30000)
p.run(pc=0o30000)
self.assertEqual(p.r[3], p.r[1])
# because the machine code did MOVB, values over 127 get
# sign extended, so take that into consideration
if i > 127:
trapexpected = 0xFF00 | i
else:
trapexpected = i
self.assertEqual(p.r[1], trapexpected)
# test_mmu_1 .. test_mmu_N .. a variety of MMU tests.
#
# Any of the other tests that use simplemapped_pdp() implicitly
# test some aspects of the MMU but these are more targeted tests.
# NOTE: it's a lot easier to test via the methods than via writing
# elaborate PDP-11 machine code so that's what these do.
def test_mmu_1(self):
# test the page length field support
p = self.make_pdp()
# using ED=0 (segments grow upwards), create a (bizarre!)
# user DSPACE mapping where the the first segment has length 0,
# the second has 16, the third has 32 ... etc and then check
# that that valid addresses map correctly and invalid ones fault
# correctly. NOTE that there are subtle semantics to the so-called
# "page length field" ... in a page that grows upwards, a plf of
# zero means that to be INVALID the block number has to be greater
# than zero (therefore "zero" length really means 64 bytes of
# validity) and there is a similar off-by-one semantic to ED=1
# downward pages. The test understands this.
cn = self.usefulconstants()
for segno in range(8):
p.mmu.wordRW(cn.UDSA0 + (segno*2), (8192 * segno) >> 6)
pln = segno * 16
p.mmu.wordRW(cn.UDSD0 + (segno*2), (pln << 8) | 0o06)
# enable user I/D separation
p.mmu.MMR3 |= 0o01
# turn on the MMU!
p.mmu.MMR0 = 1
for segno in range(8):
basea = segno * 8192
maxvalidoffset = 63 + ((segno * 64) * 16)
for o in range(8192):
if o <= maxvalidoffset:
_ = p.mmu.v2p(basea + o, p.USER, p.mmu.DSPACE,
p.mmu.CYCLE.READ)
else:
with self.assertRaises(PDPTraps.MMU):
_ = p.mmu.v2p(basea + o, p.USER, p.mmu.DSPACE,
p.mmu.CYCLE.READ)
def test_mmu_2(self):
# same test as _1 but with ED=1 so segments grow downwards
# test the page length field support
p = self.make_pdp()
cn = self.usefulconstants()
for segno in range(8):
p.mmu.wordRW(cn.UDSA0 + (segno*2), (8192 * segno) >> 6)
pln = 0o177 - (segno * 16)
p.mmu.wordRW(cn.UDSD0 + (segno*2), (pln << 8) | 0o16)
# enable user I/D separation
p.mmu.MMR3 |= 0o01
# turn on the MMU!
p.mmu.MMR0 = 1
for segno in range(8):
basea = segno * 8192
minvalidoffset = 8192 - (64 + ((segno * 64) * 16))
for o in range(8192):
if o >= minvalidoffset:
_ = p.mmu.v2p(basea + o, p.USER, p.mmu.DSPACE,
p.mmu.CYCLE.READ)
else:
with self.assertRaises(PDPTraps.MMU):
_ = p.mmu.v2p(basea + o, p.USER, p.mmu.DSPACE,
p.mmu.CYCLE.READ)
def test_ubmap(self):
p = self.make_pdp()
ubmaps = self.ioaddr(p, p.ub.UBMAP_OFFS)
# code paraphrased from UNIX startup, creates a mapping pattern
# that the rest of the code expects (and fiddles upper bits)
# So ... test that.
for i in range(0, 62, 2):
p.mmu.wordRW(ubmaps + (2 * i), i << 12 & 0o1777777)
p.mmu.wordRW(ubmaps + (2 * (i + 1)), 0)
# XXX there is no real test yet because the UBMAPs
# are all just dummied up right now
# this is not a unit test, invoke it using timeit etc
def speed_test_setup(self, *, loopcount=10000, mmu=True, inst=None):
p, pc = self.simplemapped_pdp()
# the returned pdp is loaded with instructions for setting up
# the mmu; only do them if that's what is wanted
if mmu:
p.run(pc=pc)
# by default the instruction being timed will be MOV R1,R0
# but other instructions could be used. MUST ONLY BE ONE WORD
if inst is None:
inst = 0o010100
# now load the test timing loop... 9 MOV R1,R0 instructions
# and an SOB for looping (so 10 instructions per loop)
insts = (0o012704, loopcount, # loopcount into R4
inst,
inst,
inst,
inst,
inst,
inst,
inst,
inst,
inst,
0o077412, # SOB R4 back to first inst
0) # HALT
instloc = 0o4000
for a2, w in enumerate(insts):
p.mmu.wordRW(instloc + (2 * a2), w)
return p, instloc
def speed_test_run(self, p, instloc):
p.run(pc=instloc)
if __name__ == "__main__":
unittest.main()

70
pdptraps.py Normal file
View file

@ -0,0 +1,70 @@
# 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.
# exceptions representing processor traps
from types import SimpleNamespace
class PDPTrap(Exception):
vector = -1
def __init__(self, cpuerr=0, **kwargs):
# if specified, the cpuerr bit(s) will be OR'd into
# the CPU Error Register when the trap is processed by go_trap
self.cpuerr = cpuerr
# any additional arguments that are specific per-trap info
# simply get stored as-is
self.trapinfo = kwargs
def __str__(self):
s = self.__class__.__name__ + "("
s += f"vector={oct(self.vector)}"
if self.cpuerr:
s += f", cpuerr={oct(self.cpuerr)}"
if self.trapinfo:
s += f", {self.trapinfo=}"
s += ")"
return s
# rather than copy/pasta the above class, they are made this way
# It's not clear this is much better
# XXX the for/setattr loop instead of a dict() in SimpleNamespace
# only because it seems to be more readable this way
PDPTraps = SimpleNamespace()
for __nm, __v in (
('AddressError', 0o004),
('ReservedInstruction', 0o010),
('BPT', 0o014),
('IOT', 0o20),
('PowerFail', 0o24),
('EMT', 0o30),
('TRAP', 0o34),
('Parity', 0o114),
('PIRQ', 0o240),
('FloatingPoint', 0o244),
('MMU', 0o250)):
setattr(PDPTraps, __nm, type(__nm, (PDPTrap,), dict(vector=__v)))

317
rp.py Normal file
View file

@ -0,0 +1,317 @@
# 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.
# Emulate (a bare subset of) RP04..07 RM02-80 disks
from types import SimpleNamespace
class RPRM:
RPADDR_OFFS = 0o16700
NSECT = 22 # sectors per track
NTRAC = 19 # tracks per cylinder
SECTOR_SIZE = 512
# NOTE: The key names become the attribute names. See __init__
HPREG_OFFS = {
'CS1': 0o00, # control and status register
'WC': 0o02, # word count
'UBA': 0o04, # UNIBUS address
'DA': 0o06, # desired address
'CS2': 0o10, # control/status register 2
'DS': 0o12, # drive status
'AS': 0o16, # unified attention status
'RMLA': 0o20, # lookahead (sector under head!!)
'OFR': 0o32, # heads offset -- seriously, boot program??
'DC': 0o34, # desired cylinder
'CC': 0o36, # "current cylinder" and/or holding register
'BAE': 0o50, # address extension (pdp11/70 extra phys bits)
}
HPDS_BITS = SimpleNamespace(
OFM=0o000001, # offset mode
VV=0o000100, # volume valid
DRY=0o000200, # drive ready
DPR=0o000400, # drive present
MOL=0o010000, # medium online
)
HPCS1_BITS = SimpleNamespace(
GO=0o000001, # GO bit
FN=0o000076, # 5 bit function code - this is the mask
IE=0o000100, # Interrupt enable
RDY=0o000200, # Drive ready
A16=0o000400,
A17=0o001000,
TRE=0o040000,
)
def __init__(self, ub, baseoffs=RPADDR_OFFS):
self.addr = baseoffs
self.ub = ub
self.logger = ub.cpu.logger
self.command_history = [(0, tuple())] * 100
# XXX needs to be configurable somehow
self._diskimage = open('rp.disk', 'r+b')
for attr, offs in self.HPREG_OFFS.items():
setattr(self, attr, 0)
# CS1 is a special case in several ways
if attr == 'CS1':
ub.mmio.register(self.rw_cs1, baseoffs+offs, 1,
byte_writes=True, reset=True)
else:
# the rest are simple attributes; some as properties
ub.mmio.register_simpleattr(self, attr, baseoffs+offs)
# XXX obviously this is just fake for now
self.DS = (self.HPDS_BITS.DPR | self.HPDS_BITS.MOL |
self.HPDS_BITS.VV | self.HPDS_BITS.DRY)
def __del__(self):
try:
self._diskimage.close()
except (AttributeError, TypeError):
pass
self._diskimage = None
# Pass __del__ up the inheritance tree, carefully.
# Note that __del__ is not always defined, Because Reasons.
getattr(super(), '__del__', lambda self: None)(self)
@property
def UBA(self):
return self._uba
@UBA.setter
def UBA(self, value):
self.logger.debug(f"UBA address being set to {oct(value)}")
self._uba = value
@property
def CS2(self):
return self._cs2
@CS2.setter
def CS2(self, value):
self.logger.debug(f"CS2: value={oct(value)}")
self._cs2 = value
@property
def DS(self):
return (self._ds | self.HPDS_BITS.DPR | self.HPDS_BITS.MOL |
self.HPDS_BITS.VV | self.HPDS_BITS.DRY)
@DS.setter
def DS(self, value):
self._ds = value
@property
def CS1(self):
# XXX what if CS1 is just always RDY??
self._cs1 |= self.HPCS1_BITS.RDY
# --- XXX DEBUGGING XXX ---
if (self._cs1 & 0x4000):
self.logger.debug(f"RP: XXX! CS1={oct(self._cs1)}")
self.logger.debug(f"RP: reading CS1: {oct(self._cs1)}")
return self._cs1
@CS1.setter
def CS1(self, value):
self.command_history.pop(-1)
self.command_history.insert(0, (value, self.statestring()))
self.logger.debug(f"RP: writing CS1 to {oct(value)}; "
f"state: {self.statestring()}")
self._cs1 = value
self.logger.debug(f"RP: CS1 set to {oct(self._cs1)}")
if self._cs1 & 0x4000:
self.logger.debug(f"LOOK!!!! XXX")
if self._cs1 & self.HPCS1_BITS.RDY:
self.AS = 1 # this is very bogus but maybe works for now
# TRE/ERROR always cleared on next op
if value & self.HPCS1_BITS.GO:
self._cs1 &= ~self.HPCS1_BITS.TRE
match value & self.HPCS1_BITS.FN, value & self.HPCS1_BITS.GO:
case 0, go:
self._cs1 &= ~go
case 0o06 | 0o12 | 0o16 | 0o20 | 0o22 as fcode, go:
self.logger.debug(f"RP: operation {oct(fcode)} ignored.")
self.logger.debug(self.statestring())
self._cs1 &= ~(go | fcode)
self._cs1 |= self.HPCS1_BITS.RDY
if self._cs1 & self.HPCS1_BITS.IE:
self.ub.intmgr.simple_irq(5, 0o254)
case 0o30, 1: # SEARCH
self._cs1 &= ~0o31
self._cs1 |= self.HPCS1_BITS.RDY
self.CC = self.DC
if self._cs1 & self.HPCS1_BITS.IE:
self.ub.intmgr.simple_irq(5, 0o254)
case 0o60, 1:
self._cs1 &= ~0o61
self.writecmd()
if self._cs1 & self.HPCS1_BITS.IE:
self.ub.intmgr.simple_irq(5, 0o254)
case 0o70, 1:
self._cs1 &= ~0o71
self.readcmd()
if self._cs1 & self.HPCS1_BITS.IE:
self.ub.intmgr.simple_irq(5, 0o254)
case _, 0: # anything else without the go bit is a nop
pass
case _: # but with the go bit, bail out for now
raise ValueError(value)
# special function for handling writes to the CS1 attribute
# Because byte writes to the upper byte need to be treated carefully
def rw_cs1(self, addr, value=None, /, *, opsize=2):
if opsize == 1:
# by definition byte reads are impossible; this will obviously
# bomb out if they happen somehow (it is physically impossible
# to have a byte write on the real UNIBUS)
value &= 0o377 # paranoia but making sure
self.logger.debug(f"RP: BYTE addr={oct(addr)}, "
f"{value=}, _cs1={oct(self._cs1)}")
self.logger.debug(self.statestring())
if addr & 1:
self._cs1 = (value << 8) | (self._cs1 & 0o377)
else:
self.CS1 = (self._cs1 & 0o177400) | value
elif value is None:
return self.CS1 # let property getter do its thing
else:
self.CS1 = value # let property setter do its thing
return None
def _compute_offset(self):
# cyl num, track num, sector num, which were written like this:
# HPADDR->hpdc = cn;
# HPADDR->hpda = (tn << 8) + sn;
cn = self.DC
tn = (self.DA >> 8) & 0o377
sn = self.DA & 0o377
# each cylinder is NSECT*NTRAC sectors
# each track is NSECT sectors
offs = cn * (self.NSECT * self.NTRAC)
offs += (tn * self.NSECT)
offs += sn
offs *= self.SECTOR_SIZE
return offs
def readcmd(self):
offs = self._compute_offset()
self.logger.debug(f"RP READ: offs=0x{hex(offs)}, {self.WC=}")
addr = self._getphysaddr()
self._diskimage.seek(offs)
nw = (65536 - self.WC)
sector = self._diskimage.read(nw*2)
# Note conversion: from little-endian on disk to native 0 .. 65535
self.ub.cpu.physRW_N(addr, nw, self.__b2wgen(sector))
self.WC = 0
self.CS1 |= self.HPCS1_BITS.RDY
def writecmd(self):
offs = self._compute_offset()
self.logger.debug(f"RP WRITE: offs=0x{hex(offs)}, {self.WC=}")
addr = self._getphysaddr()
self._diskimage.seek(offs)
nw = (65536 - self.WC)
# Words in physmem are just python integers; they have to be
# converted into a little-endian byte stream for disk...
sector = bytes(self.__w2bgen(self.ub.cpu.physRW_N(addr, nw)))
self._diskimage.write(sector)
self.WC = 0
self.CS1 |= self.HPCS1_BITS.RDY
def __b2wgen(self, b):
"""Generate native python ints from sequence of little endian bytes"""
g = iter(b)
for lo in g:
hi = next(g)
yield lo + (hi << 8)
def __w2bgen(self, words):
"""Generate little-endian bytes from sequence of python ints"""
for w in words:
yield w & 0o377
yield (w >> 8) & 0o377
def _getphysaddr(self):
# low 16 bits in UBA, and tack on A16/A17
A16 = bool(self.CS1 & self.HPCS1_BITS.A16)
A17 = bool(self.CS1 & self.HPCS1_BITS.A17)
# but also bits may be found in bae... the assumption here is
# if these bits are non-zero they override A16/A17 but they
# really need to be consistent...
if self.BAE == 0:
A1621 = 0
else:
A16 = 0 # subsumed in A1621
A17 = 0 # subsumed
A1621 = self.BAE & 0o77
phys = self.UBA | (A16 << 16) | (A17 << 17) | (A1621 << 16)
self.logger.debug(f"RP: physaddr={oct(phys)}")
return phys
# return self.UBA | (A16 << 16) | (A17 << 17) | (A1621 << 16)
def statestring(self):
s = "RP XXX:"
for attr in self.HPREG_OFFS:
s += f"{attr}={oct(getattr(self, attr, 0))} "
return s
# produce a pretty-print version of a single RP history
@staticmethod
def rph_pps(rph):
written = rph[0]
s = f"CS1 <-- {oct(written)} : "
cmd = written & 0o70
s += {0o70: 'READ', 0o60: 'WRITE', 0o30: 'SEARCH'}.get(cmd, oct(cmd))
if rph[0] & 1:
s += "|GO"
if written & 0o100:
s += "|IE"
if written & 0o040000:
s += "|TRE"
s += f"\n {rph[1]}"
return s

74
unibus.py Normal file
View file

@ -0,0 +1,74 @@
# 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 interrupts import InterruptManager
from mmio import MMIO
# A convenient reference for addresses:
# https://gunkies.org/wiki/UNIBUS_Device_Addresses
class UNIBUS:
def __init__(self, cpu):
self.cpu = cpu
self.mmio = MMIO(cpu)
self.intmgr = InterruptManager()
def resetbus(self):
self.mmio.resetdevices()
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.mmio.register(self.uba_mmio, self.UBMAP_OFFS, self.UBMAP_N)
def uba_mmio(self, addr, value=None, /):
ubanum, hi22 = divmod(addr - self.UBMAP_OFFS, 4)
uba22 = self.ubas[ubanum]
self.cpu.logger.debug(f"UBA addr={oct(addr)}, {value=}")
self.cpu.logger.debug(f"{ubanum=}, {hi22=}")
if value is None:
if hi22 == 0:
return (uba22 >> 16) & 0o077
else:
return uba22 & 0o177777
else:
# 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
def busRW(self, ubaddr, value=None):
pass