41ba716759
418 -> 439 lines, 31% -> 14% comment-only. The reasoning that matters here is
kept in full, because the edge conventions and the TX launch phase are the two
things in this design that cost silicon bugs; what went was the history around
them and the console measurement logs, which CLAUDE.md already holds.
Three comments described mechanisms that no longer exist:
* `miso_next` was documented as feeding "an SB_IO output register clocked on
the FALLING edge". It is clocked on the RISING edge (NEG_TRIGGER=0) --
that is the whole point of the full-cycle-early launch. The file
contradicted itself, since the comment on the assignment 100 lines later
said "after the next RISING edge" correctly.
* `id_arm` was documented as "the register file's id_phase, synchronised
into this domain by the parent". The one-shot was removed; id_arm is tied
high and the register file has no id_phase. It is now driven low only by
the PHASE_METER build.
* T8's rationale said the gate exists so that "once the GC's first NCRA
reset clears id_phase, address 0 reads back NCRA". That justification was
not merely stale, it was WRONG -- NCRA is register space and the fast path
requires command space, so they were never ambiguous. T8's real job is to
prove a disarmed build passes the FIFO byte through, which is what
PHASE_METER depends on.
Structure: elaborate() now delegates to _rx_bytes, _device_id and _tx_bits,
each carrying the reasoning for its own block -- why rx_byte/rx_valid must be
combinational (a registered pulse loses the last byte of every transaction),
why the device ID is answered here rather than through the FIFOs, and the
output-path budget that forces the early launch.
VERIFIED EQUIVALENT: generated RTLIL is identical for BOTH build variants
(phase_meter False and True), 395 lines each, after stripping \src attributes.
Executable testbench code is unchanged apart from one trailing comment. The
suite still has teeth: mutating the TX load back to rx_bit == 0 (a cycle late)
fails 8 of the 12 checks. Full module suite passes and bba_top T9 is 6/6 at
EXI_HALF=1, 2 and 8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
440 lines
19 KiB
Python
440 lines
19 KiB
Python
"""SPI Mode 3 byte-oriented slave for the EXI bus, clocked by the EXI clock.
|
|
|
|
Mode 3 is CPOL=1, CPHA=1: CLK idles HIGH, the master changes MOSI on the
|
|
FALLING edge, and both ends sample on the RISING edge.
|
|
|
|
The engine is clocked by the EXI clock pin itself rather than oversampled from
|
|
a faster domain, so there is exactly one clock edge and one phase reference in
|
|
the whole module:
|
|
|
|
exiclk posedge of EXI CLK. Samples MOSI, assembles RX bytes, and
|
|
launches MISO -- see `_tx_bits` for why MISO goes out on this
|
|
edge rather than the falling one.
|
|
spi_cs_n asynchronous reset of that domain.
|
|
|
|
Oversampling cannot work here, which is why it is not an option to fall back
|
|
to: it puts BIT-level timing across a clock-domain boundary, and the engine's
|
|
view of the clock then lags the pin by two `FFSynchronizer` stages plus a
|
|
register -- about 55 ns against a 37 ns bit time at 27 MHz. Clocking from the
|
|
pin makes MISO clock-to-Q and leaves the byte-level `AsyncFIFO`s as the only
|
|
crossing, where a whole byte time (~296 ns) of slack is available instead of a
|
|
fraction of a bit.
|
|
|
|
EXI CLK is on pin 44, which sits at die (6,0) on the 5k-sg48 = global buffer
|
|
input net 6, so it drives a real clock network with no board change.
|
|
|
|
The clock domain is created by the PARENT (`ExiCapture`) so the byte FIFOs can
|
|
name it; this module only uses `m.d.exiclk`.
|
|
"""
|
|
|
|
from amaranth import *
|
|
|
|
__all__ = ["SPIMode3Slave"]
|
|
|
|
|
|
class SPIMode3Slave(Elaboratable):
|
|
"""Byte-oriented SPI Mode 3 slave, clocked by the EXI clock.
|
|
|
|
Ports
|
|
-----
|
|
spi_clk, spi_mosi, spi_cs_n
|
|
Raw inputs from the GC. `spi_clk` drives the `exiclk` domain and
|
|
`spi_cs_n` is its async reset, both wired by the parent; they are
|
|
listed here so the module is self-describing.
|
|
spi_miso
|
|
Serial output to the GC. Idles HIGH while CS is deasserted.
|
|
miso_next
|
|
The same bit, but combinational from the pre-edge state, so it can
|
|
feed an SB_IO output register clocked on the RISING edge. The
|
|
synthesised build drives the pad from that register; see `synth.py`.
|
|
id_arm
|
|
Enables the device-ID fast path. Normally tied high -- real hardware
|
|
answers EXI_GetID whenever it is asked -- and driven low only by the
|
|
PHASE_METER diagnostic build, so the constant test pattern is not
|
|
replaced by ID bytes.
|
|
rx_byte
|
|
Last complete received byte, valid while `rx_valid` is high.
|
|
rx_valid
|
|
One-cycle pulse in `exiclk` when `rx_byte` holds a new byte.
|
|
tx_byte
|
|
Byte to transmit, sampled at each byte boundary.
|
|
tx_load
|
|
One-cycle pulse per byte boundary asking upstream for the next byte.
|
|
Unconditional: there is no header-length or slot-threshold logic, and
|
|
`_tx_bits` explains why that is both sufficient and necessary.
|
|
"""
|
|
|
|
def __init__(self, phase_meter=False):
|
|
# DIAGNOSTIC build: every transmitted byte becomes the constant 0x80
|
|
# instead of the FIFO head, leaving the load/shift/output path
|
|
# untouched. The register file and both byte FIFOs therefore drop out
|
|
# of the MISO path, and whatever the host reads back is purely the
|
|
# engine's byte PHASE -- 0x80 aligned, 0x40 one bit late, 0x20 two
|
|
# bits late. Not a functional bitstream.
|
|
self._phase_meter = phase_meter
|
|
|
|
self.spi_clk = Signal(init=1) # idles HIGH
|
|
self.spi_mosi = Signal()
|
|
self.spi_cs_n = Signal(init=1) # active LOW
|
|
|
|
self.spi_miso = Signal(init=1)
|
|
self.miso_next = Signal(init=1)
|
|
|
|
self.id_arm = Signal()
|
|
|
|
self.rx_byte = Signal(8)
|
|
self.rx_valid = Signal()
|
|
self.tx_byte = Signal(8)
|
|
self.tx_load = Signal()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
rx_bit, nxt = self._rx_bytes(m)
|
|
id_sel, id_byte = self._device_id(m, rx_bit, nxt)
|
|
self._tx_bits(m, rx_bit, id_sel, id_byte)
|
|
return m
|
|
|
|
def _rx_bytes(self, m):
|
|
"""Sample MOSI on the rising edge and assemble bytes, MSB first.
|
|
|
|
Returns `(rx_bit, nxt)`, where `nxt` is the shift register's next
|
|
value -- i.e. the complete byte during the final bit.
|
|
|
|
`rx_byte`/`rx_valid` are COMBINATIONAL rather than registered. The
|
|
clock stops and CS rises immediately after a transaction's final bit,
|
|
so a registered pulse would need one more clock edge that never
|
|
arrives, and the LAST byte of every transaction would be silently
|
|
lost: a 4-byte write landed as DE AD BE 00, and a 34-byte TX stream as
|
|
15 bytes. Driving them combinationally makes the parent's FIFO
|
|
capture the byte on the very edge that completes it.
|
|
"""
|
|
rx_shift = Signal(8)
|
|
rx_bit = Signal(3)
|
|
|
|
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # shift left, new bit at LSB
|
|
m.d.exiclk += [rx_shift.eq(nxt),
|
|
rx_bit.eq(rx_bit + 1)]
|
|
|
|
m.d.comb += [self.rx_byte .eq(nxt),
|
|
self.rx_valid.eq(rx_bit == 7)]
|
|
return rx_bit, nxt
|
|
|
|
def _device_id(self, m, rx_bit, nxt):
|
|
"""Answer the device-ID query from this domain, not the register file.
|
|
|
|
Returns `(id_sel, id_byte)` for the TX mux.
|
|
|
|
A command-space header is only TWO bytes, so a reply that travels
|
|
rx `AsyncFIFO` → `sync` FSM → tx `AsyncFIFO` has one byte time to make
|
|
it back -- 296 ns at 27 MHz -- against a round trip costing roughly
|
|
280-320 ns. That is marginal by construction, and on a console it
|
|
lost: the ID came back one byte late.
|
|
|
|
The inter-transfer gap cannot rescue it, which is the non-obvious
|
|
part. The reply must be in hand by the LAST rising edge of the header
|
|
transfer, and the clock is paused after that edge, so no edge exists
|
|
during the gap for anything to update.
|
|
|
|
The device ID is a constant and belongs to the physical interface
|
|
rather than to the register file, so answering it here skips both
|
|
crossings. `byte 0 == 0x00` identifies the query exactly: bit 7 clear
|
|
selects command space, bit 6 clear selects read, and the register
|
|
number is 0.
|
|
"""
|
|
byte_idx = Signal(3) # bytes completed this transaction, saturating
|
|
id_mode = Signal()
|
|
with m.If(rx_bit == 7):
|
|
with m.If(byte_idx != 7):
|
|
m.d.exiclk += byte_idx.eq(byte_idx + 1)
|
|
with m.If((byte_idx == 0) & (nxt == 0x00) & self.id_arm):
|
|
m.d.exiclk += id_mode.eq(1)
|
|
|
|
# Byte N of the reply is loaded at the end of header byte N, so the
|
|
# four ID bytes are selected by byte_idx 1..4.
|
|
id_byte = Signal(8)
|
|
with m.Switch(byte_idx):
|
|
with m.Case(1): m.d.comb += id_byte.eq(0x04)
|
|
with m.Case(2): m.d.comb += id_byte.eq(0x02)
|
|
with m.Case(3): m.d.comb += id_byte.eq(0x02)
|
|
with m.Default(): m.d.comb += id_byte.eq(0x00)
|
|
|
|
id_sel = Signal()
|
|
m.d.comb += id_sel.eq(id_mode & (byte_idx >= 1) & (byte_idx <= 4))
|
|
return id_sel, id_byte
|
|
|
|
def _tx_bits(self, m, rx_bit, id_sel, id_byte):
|
|
"""Shift MISO out, launching each bit a FULL CYCLE EARLY.
|
|
|
|
The host samples MISO on the rising edge. The obvious thing is to
|
|
drive it on the preceding FALLING edge, which gives the output path a
|
|
half period -- 18.5 ns at 27 MHz. That is not enough on this part,
|
|
and the shortfall cannot be removed by any RTL change, because of what
|
|
the path is made of:
|
|
|
|
MISO changes at (falling edge AT THE CONNECTOR)
|
|
+ input pad delay
|
|
+ GLOBAL CLOCK NETWORK INSERTION <- the big one
|
|
+ IOB clock-to-Q
|
|
+ output pad delay
|
|
... and the host still needs its own setup time on top.
|
|
|
|
About 9 ns was removed from the fabric output path in three steps and
|
|
the console result stayed BYTE IDENTICAL each time. Nine nanoseconds
|
|
out of 18.5 cannot change nothing if propagation were the whole story,
|
|
so the rest is the irreducible part: getting the clock into the chip
|
|
and the data back out.
|
|
|
|
So the fix doubles the budget instead of shrinking the delay: present
|
|
the bit the host samples at rising edge N on rising edge N-1, giving
|
|
the whole path a FULL period (37 ns at 27 MHz). The output then
|
|
changes shortly AFTER the edge on which the host sampled the previous
|
|
bit, which is safe at both ends -- the insertion delay that caused the
|
|
problem now covers the host's hold time, and a full period covers its
|
|
setup.
|
|
|
|
If a LOW speed index ever regresses while 27 MHz still works, that is
|
|
the HOLD edge of this window (the launch being too early), not a setup
|
|
problem.
|
|
|
|
`tx_out` is therefore clocked on the rising edge and loads at
|
|
`rx_bit == 7`, one bit time earlier than a falling-edge shifter
|
|
loading at `rx_bit == 0`. `init=0xFF` parks MISO high under CS reset.
|
|
|
|
`tx_load` pops once per byte boundary unconditionally. That is
|
|
sufficient because the register file pushes its response during the
|
|
header's trailing pad byte, and necessary because the pops are also
|
|
what drains whatever a previous transaction left in the FIFO.
|
|
"""
|
|
tx_out = Signal(8, init=0xFF)
|
|
tx_src = Signal(8) # the byte loaded at the next byte boundary
|
|
m.d.comb += tx_src.eq(
|
|
Mux(id_sel, id_byte,
|
|
0x80 if self._phase_meter else self.tx_byte))
|
|
m.d.comb += self.tx_load.eq(rx_bit == 7)
|
|
with m.If(rx_bit == 7):
|
|
m.d.exiclk += tx_out.eq(tx_src)
|
|
with m.Else():
|
|
m.d.exiclk += tx_out.eq(Cat(0, tx_out[:-1]))
|
|
|
|
m.d.comb += self.spi_miso.eq(tx_out[7])
|
|
# What tx_out[7] WILL be after the next rising edge, which is what the
|
|
# SB_IO output register captures. Mirrors the load/shift choice above
|
|
# exactly; T6 proves the two agree bit for bit.
|
|
m.d.comb += self.miso_next.eq(Mux(rx_bit == 7, tx_src[7], tx_out[6]))
|
|
|
|
|
|
# ── Testbench ─────────────────────────────────────────────────────────────
|
|
|
|
class _Harness(Elaboratable):
|
|
"""Wraps the slave with the pin-driven domain its parent normally
|
|
creates, so the engine can be exercised standalone."""
|
|
|
|
def __init__(self):
|
|
self.dut = SPIMode3Slave()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
|
|
d = self.dut
|
|
m.d.comb += [
|
|
cd_p.clk.eq(d.spi_clk), cd_p.rst.eq(d.spi_cs_n),
|
|
]
|
|
m.submodules.dut = d
|
|
return m
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
from amaranth.sim import Simulator, Period
|
|
|
|
HP = Period(MHz=27) / 2 # one EXI half-period at the hardware rate
|
|
top = _Harness()
|
|
dut = top.dut
|
|
|
|
failures = []
|
|
|
|
def check(name, got, want):
|
|
ok = got == want
|
|
print(f" {'ok ' if ok else 'FAIL'} {name}: got {got}, want {want}")
|
|
if not ok:
|
|
failures.append(name)
|
|
|
|
# A STRICT Mode 3 master. Unlike a convenience model that parks MOSI long
|
|
# before the edge, this one changes MOSI exactly ON the falling edge and
|
|
# samples MISO in the CLK-LOW phase. Both details are what make the edge
|
|
# conventions observable at all:
|
|
#
|
|
# * A slave that sampled MOSI on the falling edge would capture the
|
|
# PREVIOUS bit. A master that parks MOSI early cannot see that, and
|
|
# a Mode 2/Mode 3 mix-up reached silicon against one that did.
|
|
# * MISO must be read in the clock-low phase, because the engine drives
|
|
# it ON the rising edge. Sampling just after raising the clock sees
|
|
# the NEXT bit in a zero-delay simulation, which would report
|
|
# everything one bit early while the hardware was correct.
|
|
async def spi_byte(ctx, mosi_val, probe_rx=False):
|
|
miso = 0
|
|
rx = None
|
|
for bit in range(7, -1, -1):
|
|
ctx.set(dut.spi_clk, 0) # falling edge
|
|
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
|
|
await ctx.delay(HP)
|
|
miso = (miso << 1) | ctx.get(dut.spi_miso) # sample in CLK-low
|
|
if probe_rx and bit == 0:
|
|
rx = (ctx.get(dut.rx_valid), ctx.get(dut.rx_byte))
|
|
ctx.set(dut.spi_clk, 1) # rising edge: slave samples
|
|
await ctx.delay(HP)
|
|
return (miso, rx) if probe_rx else miso
|
|
|
|
async def cs_low(ctx):
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.spi_cs_n, 0)
|
|
await ctx.delay(HP)
|
|
|
|
async def cs_high(ctx):
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.spi_cs_n, 1)
|
|
await ctx.delay(HP * 4)
|
|
|
|
async def tb(ctx):
|
|
ctx.set(dut.spi_cs_n, 1)
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.tx_byte, 0)
|
|
await ctx.delay(HP * 4)
|
|
|
|
# T1 — RX samples MOSI on the RISING edge, MSB first. rx_byte and
|
|
# rx_valid are combinational and peak during the final bit, which is
|
|
# the instant the parent's FIFO captures them.
|
|
await cs_low(ctx)
|
|
seen = []
|
|
for b in (0x80, 0x00, 0x31, 0x00):
|
|
_, (valid, byte) = await spi_byte(ctx, b, probe_rx=True)
|
|
seen.append((valid, byte))
|
|
await cs_high(ctx)
|
|
check("T1 rx bytes", [b for _, b in seen], [0x80, 0x00, 0x31, 0x00])
|
|
check("T1 rx_valid", [v for v, _ in seen], [1, 1, 1, 1])
|
|
|
|
# T2 — TX: the byte latched at the end of slot N appears in slot N+1,
|
|
# MSB first. Slot 0 is the header byte: the host ignores MISO there
|
|
# and nothing has been loaded yet, so it reads the CS-reset parking
|
|
# value (tx_out init=0xFF, which is what holds MISO high while
|
|
# deselected). The bits the host actually uses start at slot 1.
|
|
await cs_low(ctx)
|
|
ctx.set(dut.tx_byte, 0xA5)
|
|
s0 = await spi_byte(ctx, 0x00)
|
|
ctx.set(dut.tx_byte, 0x3C)
|
|
s1 = await spi_byte(ctx, 0x00)
|
|
ctx.set(dut.tx_byte, 0x00)
|
|
s2 = await spi_byte(ctx, 0x00)
|
|
await cs_high(ctx)
|
|
check("T2 miso slot0 (pre-load)", hex(s0), hex(0xFF))
|
|
check("T2 miso slot1", hex(s1), hex(0xA5))
|
|
check("T2 miso slot2", hex(s2), hex(0x3C))
|
|
|
|
# T3 — tx_load pulses exactly once per byte, during the final bit.
|
|
await cs_low(ctx)
|
|
loads = 0
|
|
for _ in range(3):
|
|
for bit in range(8):
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP)
|
|
loads += ctx.get(dut.tx_load)
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
await cs_high(ctx)
|
|
check("T3 tx_load pulses", loads, 3)
|
|
|
|
# T4 — MISO releases HIGH when CS is deasserted, so it never fights
|
|
# another EXI device sharing the bus.
|
|
check("T4 miso idles high", ctx.get(dut.spi_miso), 1)
|
|
|
|
# T5 — a transaction that BEGINS ON A RISING EDGE. The host asserts
|
|
# CS while CLK is already low, so the first edge the device sees is a
|
|
# rise and the transaction carries one fewer falling edge than rising.
|
|
# A TX phase counted on falling edges slips one bit here and never
|
|
# recovers; a phase taken from rx_bit cannot.
|
|
#
|
|
# THIS IS A HARDWARE BUG THIS TEST EXISTS TO CATCH. With a
|
|
# free-running falling-edge counter the console's phase-meter build
|
|
# read 0x20 (= 0x80 >> 2) in every slot, identically at 1 MHz and
|
|
# 27 MHz -- speed independence being the tell that it was not a
|
|
# propagation problem. Every earlier test asserted CS with CLK high,
|
|
# so falls and rises always matched and the slip was structurally
|
|
# invisible. When adding SPI tests, vary the edge the transaction
|
|
# starts on.
|
|
ctx.set(dut.spi_clk, 0) # CLK already LOW ...
|
|
ctx.set(dut.spi_cs_n, 0) # ... when CS asserts
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.tx_byte, 0xA5)
|
|
await spi_byte(ctx, 0x00) # slot 0: header, MISO don't-care
|
|
ctx.set(dut.tx_byte, 0x3C)
|
|
s1 = await spi_byte(ctx, 0x00)
|
|
s2 = await spi_byte(ctx, 0x00)
|
|
await cs_high(ctx)
|
|
check("T5 rising-edge start, slot1", hex(s1), hex(0xA5))
|
|
check("T5 rising-edge start, slot2", hex(s2), hex(0x3C))
|
|
|
|
# T6 — miso_next must equal what spi_miso BECOMES on the next rising
|
|
# edge. The synthesised build drives the pad from an SB_IO register
|
|
# clocked on that edge (synth.py), so if these ever diverge then the
|
|
# hardware and the simulation stop agreeing about MISO -- precisely
|
|
# the class of bug that has cost this design the most time.
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.spi_cs_n, 0)
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.tx_byte, 0x6B)
|
|
mismatch = 0
|
|
for _ in range(3):
|
|
for _bit in range(8):
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP)
|
|
predicted = ctx.get(dut.miso_next) # before the rising edge
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
if ctx.get(dut.spi_miso) != predicted:
|
|
mismatch += 1
|
|
await cs_high(ctx)
|
|
check("T6 miso_next predicts spi_miso", mismatch, 0)
|
|
|
|
# T7 — the device-ID fast path. A command-space query (byte 0 = 0x00,
|
|
# byte 1 = 0x00) must be answered 04 02 02 00 straight from this
|
|
# domain, with the tx FIFO deliberately feeding 0x00 so that only the
|
|
# fast path can be the source. The register file cannot make the
|
|
# round trip inside a 2-byte header's one byte time; on a console it
|
|
# returned the ID one byte late.
|
|
ctx.set(dut.id_arm, 1)
|
|
ctx.set(dut.tx_byte, 0x00) # FIFO deliberately contributes nothing
|
|
await cs_low(ctx)
|
|
await spi_byte(ctx, 0x00) # header byte 0 identifies the query
|
|
await spi_byte(ctx, 0x00) # header byte 1
|
|
got = [await spi_byte(ctx, 0x00) for _ in range(4)]
|
|
await cs_high(ctx)
|
|
check("T7 device ID", [hex(b) for b in got],
|
|
[hex(x) for x in (0x04, 0x02, 0x02, 0x00)])
|
|
|
|
# T8 — and the fast path must be GATED by id_arm, so that a build
|
|
# which disarms it gets the FIFO byte through untouched. That is what
|
|
# the PHASE_METER bitstream relies on: its constant 0x80 pattern would
|
|
# otherwise be replaced by ID bytes in the slots it measures.
|
|
ctx.set(dut.id_arm, 0)
|
|
ctx.set(dut.tx_byte, 0x5A)
|
|
await cs_low(ctx)
|
|
await spi_byte(ctx, 0x00)
|
|
await spi_byte(ctx, 0x00)
|
|
got2 = [await spi_byte(ctx, 0x00) for _ in range(2)]
|
|
await cs_high(ctx)
|
|
check("T8 fast path gated by id_arm", [hex(b) for b in got2],
|
|
[hex(0x5A), hex(0x5A)])
|
|
|
|
sim = Simulator(top)
|
|
sim.add_testbench(tb)
|
|
with sim.write_vcd("SPIMode3Slave.vcd"):
|
|
sim.run()
|
|
|
|
if failures:
|
|
print("\nFAILURES:")
|
|
for f in failures:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("\nAll tests passed.")
|