- Start Date: 2024-07-01
- RFC PR: amaranth-lang/rfcs#69
- Amaranth Issue: amaranth-lang/amaranth#1446
Add a lib.io.PortLike object usable in simulation
Summary
A new library I/O port object lib.io.SimulationPort is introduced that can be used to simulate lib.io.Buffer and lib.io.FFBuffer components.
Motivation
End-to-end verification of I/O components requires simulating lib.io.Buffer, lib.io.FFBuffer, or lib.io.DDRBuffer objects, which is currently not possible because all defined library I/O ports (lib.io.SingleEndedPort, lib.io.DifferentialPort) require the use of unsimulatable core I/O values. Simulating these buffers can be done by adding a new library I/O port object with separate i, o, oe parts represented by normal Signals, and modifying the lowering of lib.io buffer components to drive the individual parts. Simulation models could then be written using code similar to (for SPI):
await ctx.set(cipo_port.o, cipo_value)
_, copi_value = await ctx.posedge(sck_port.i).sample(copi_port.i)
This functionality is one of the last pieces required to build a library of reusable I/O interfaces, and the need for it became apparent while writing reusable I/O blocks for the Glasgow project.
Guide-level explanation
Let's consider an I/O component, such as below, which takes several ports as its constructor argument:
class SPIController(wiring.Component):
o_data: stream.Signature(8)
i_data: stream.Signature(8)
def __init__(self, sck_port, copi_port, cipo_port):
self._sck_port = sck_port
self._copi_port = copi_port
self._cipo_port = cipo_port
super().__init__()
def elaborate(self, platform):
m = Module()
m.submodules.sck = sck_buffer = io.FFBuffer("o", self._sck_port)
m.submodules.copi = copi_buffer = io.FFBuffer("o", self._copi_port)
m.submodules.cipo = cipo_buffer = io.FFBuffer("i", self._cipo_port)
# ... wire it up to self.[io]_data...
return m
To simulate such a component, instantiate lib.io.SimulationPort objects for each of the ports, and use their .o, .oe, and .i signals to verify the functionality:
sck_port = io.SimulationPort("o", 1)
copi_port = io.SimulationPort("o", 1)
cipo_port = io.SimulationPort("i", 1)
dut = SPIController(sck_port, copi_port, cipo_port)
async def testbench_peripheral(ctx):
"""Simulates behavior of an SPI peripheral. Simplified for clarity: ignores chip select."""
for copi_fixture, cipo_fixture in zip([0,1,0,1,0,1,0,1], [1,0,1,0,1,0,1,0]):
await ctx.negedge(sck_port.o)
ctx.set(cipo_port.i, cipo_fixture)
_, copi_driven, copi_value = await ctx.posedge(sck_port.o).sample(copi_port.oe, copi_port.o)
assert copi_driven == 0b1 and copi_value == copi_fixture
async def testbench_controller(ctx):
"""Issues commands to the controller."""
await stream_put(ctx, dut.o_data, 0x55)
assert (await stream_get(ctx, dut.i_data)) == 0xAA
sim = Simulator(dut)
sim.add_clock(1e-6)
sim.add_testbench(testbench_peripheral)
sim.add_testbench(testbench_controller)
sim.run()
Note that the peripheral testbench not only checks that the right values are being output by the I/O component, but that the port is driven (not tristated) at the time when values are sampled.
Note also that e.g. copi_port.oe is a multi-bit signal, with the same width as the port itself. This is unlike the lib.io buffers, whose oe port member is always 1-bit wide. This means that for multi-wire ports that are always fully driven or fully undriven, it is necessary to compare them with 0b00..0 or 0b11..1, according to the width of the port.
Reference-level explanation
The amaranth.lib.io module is expanded with a new subclass of PortLike, SimulationPort(direction, width, *, invert=False):
i,o,oeare read-only properties each containing a newSignal(width);invertis a property equal to theinvertparameter normalized to a tuple ofwidthelements;directionis a property equal to thedirectionparameter normalized toDirectionenum;len(port)returnswidth;port[...]returns a newSimulationPortwhosei,o,oeproperties are slices ofport.i,port.o,port.oerespectively;~portreturns a newSimulationPortwhosei,o,oeproperties are the same as forportand theinvertproperty has each value inverted;port + other, whereotheris aSimulationPort, returns a newSimulationPortwhosei,o,oeproperties are concatenations of the respective properties ofportandother.
Since this is a third PortLike with a compatible __add__ implementation, and it is weird to have slicing but not concatenation, the __add__ method is added to the PortLike signature, with a deprecation warning in 0.5.1 and a hard requirement in 0.6.0.
The amaranth.lib.io.Buffer component is changed to accept SimulationPorts and performs the following connections (with the direction checks omitted for clarity):
m.d.comb += [
buffer.i.eq(Cat(Mux(oe, o, i) for oe, o, i in zip(port.oe, port.o, port.i))),
port.o.eq(buffer.o),
port.oe.eq(buffer.oe.replicate(len(port))),
]
The amaranth.lib.io.FFBuffer component is changed to accept SimulationPorts and performs the following connections (with the direction checks omitted for clarity):
m.d[buffer.i_domain] += [
buffer.i.eq(Cat(Mux(oe, o, i) for oe, o, i in zip(port.oe, port.o, port.i))),
]
m.d[buffer.o_domain] += [
port.o.eq(buffer.o),
port.oe.eq(buffer.oe.replicate(len(port))),
]
The amaranth.lib.io.DDRBuffer component is not changed.
None of the get_io_buffer functions in the vendor platforms are changed. Thus, SimulationPort is not usable with any vendor platform that implements get_io_buffer.
To improve the health of the Amaranth standard I/O library (which will make heavy use of simulation port objects), the changes are back-ported to the 0.5.x release branch.
Drawbacks
- Bigger API surface.
- Another concept to keep track of.
Rationale and alternatives
Alternatives:
- The signature of the
SimulationPort()constructor is designed to closely resemble that ofSingleEndedPort()andDifferentialPort(). However,SimulationPort()is intended to be only constructed by the designer, and the latter two almost never are. Is it a good idea to make direction optional, in this case? Specifying the direction provides additional checks that are valuable for verification.- Alternative #0:
SimulationPort(width, *, invert=False, direction=Direction.Bidir). This alternative is a near-exact match to the other port-like objects, with the exception ofioreplaced bywidth. - Alternative #1:
SimulationPort(width, *, invert=False, direction). This alternative minimally changes the signature, but requires lengthydirection=at every call site. Currently in the RFC text. - Alternative #2:
SimulationPort(width, direction, *, invert=False). This alternative is a slightly less minimal change, but does not match the ordering of arguments of signature ofBuffer(direction, port). - Alternative #3:
SimulationPort(direction, width, *, invert=False). This alternative differs significantly fromSingleEndedPort()etc, but matchesBuffer()most closely.
- Alternative #0:
- It would be possible to make
SimulationPortan interface object. Although the opportunities for connecting it are limited (since the signals are only synchronized to each other and not to any clock domains in the design), one can foresee the addition of a component that implements end-to-end or multi-drop buses using simulation-compatible logic, for example, to simulate multiple Amaranth components accessing a common SPI or I2C bus. In this contextconnectwould be valuable.- This functionality could be prototyped out-of-tree (by wrapping
SimulationPortor simply setting itssignatureproperty) and added upstream later if it proves to be useful.
- This functionality could be prototyped out-of-tree (by wrapping
- It would be possible to add
o_init,i_init,oe_initto the signature for a minor usability improvement in cases where the port is never driven in simulation, but the benefit seems low and the signature becomes much more cluttered.- It is no longer possible since Amaranth 0.5 to directly set
.initattribute of signals, so without these arguments the initial value will always be 0.
- It is no longer possible since Amaranth 0.5 to directly set
Rejected alternatives:
Multi-bitoesignal is a radical departure from the rest of Amaranth, whereoeis always single-bit. However, note the proposed RFC #68, which suggests makingoeoptionally multi-bit forio.Bufferetc.It would be possible to makeoea single-bit signal and to forbid slicing ofSimulationPortobjects. However, thePortLikeinterface does not currently allow this, and it would have to be changed, with generic code having to be aware of this new error.It would also be possible to makeoea choice of multi-bit or single-bit signal during construction, similar to RFC #68.- Without multibit
oeit is not possible to concatenate ports.
Prior art
We have previously had, and deprecated, the amaranth.lib.io.Pin object, which is essentially the same triple of i, o, oe. However, in terms of its intended use (notwithstanding how it was actually used), the Pin object was closest to the interface created by io.Buffer.Signature.create(). The newly introduced SimulationPort is intended to represent data on the other side of the buffer than Pin does.
Resolved questions
What should the signature ofSimulationPort()be?- Alternative #3 (matching
io.Buffer, etc) was decided.
- Alternative #3 (matching
Future possibilities
- Add
DDRBuffersupport by usingport.o.eq(Mux(ClockSignal(), self.o[1], self.o[0])).- It is not yet clear if this can be safely used, or should be used, in simulations.
- Make
SimulationPortan interface object. - Add a
EndToEndBus(naming TBD) class connecting pairs of ports to each other and ensuring that no wire is driven twice. - Add a
OpenDrainBus(naming TBD) class connecting a set of ports to each other and providing a "pull-up resistor" equivalent functionality.