Cut the MISO output path: drop EXI_J4 and the CS mux; rank seeds by it
Remaining after the bit-phase fix was a SPEED-DEPENDENT failure -- the GC read
correctly at 1/2/4/8/16 MHz and failed only at index 5 (27 MHz), returning
0B 80 = 0x17 late by exactly ONE bit. Opposite signature to the phase slip it
was hiding under, and this one really is propagation delay.
MISO is source-synchronous: driven on the falling edge, sampled by the GC on
the next rising edge, so the whole path must fit a HALF period -- 18.5 ns at
27 MHz -- minus global-buffer insertion, pad, connector and the GC's setup.
The failing build measured ~10.5 ns of that internally. Critical path:
clk-to-q 1.39 tx_shift (3,12)
routing 3.60 tx_shift[7] (3,12) -> (5,5) to the CS mux
logic 1.21 Mux(cs_n, 1, ...) LUT
routing 4.79 miso (5,5) -> (17,0) to the J4 MISO pad
10.99 ns (2.60 logic, 8.39 routing)
Two removals, both of things that were pure cost here:
1. EXI_J4 is GONE entirely (exi_j4.py, the resource, the AND merge, the docs
section, scripts/pi-exi-wirecheck.py) -- recoverable from git history. The
AND put a LUT in the CLOCK path, denying the EXI clock pad its dedicated
global-buffer route, and MISO fanned out to the J4 pad at die (17,0) which
was the far end of the critical output path: 4.79 ns of routing to a header
with nothing plugged into it. 10.2-11.5 ns -> 9.3-9.6 ns.
2. The Mux(cs_n, 1, ...) LUT is gone from the MISO data path. It only parked
MISO high when deselected, which the flop's own reset value does for free:
tx_shift now has init=0xFF and CS is that domain's async reset, so MISO is
tx_shift[7] with nothing in between. Removes 1.21 ns of logic plus the
3.60 ns route feeding it and lets the placer sit the flop by the pad.
synth.py now PARSES AND RANKS BY the MISO clock-to-pad delay. Ranking by fmax
was how a board that failed at 27 MHz passed every seed at >=1.29x margin --
fmax was never the binding constraint for this design.
--seeds 8: clk 28.7-32.5 MHz, EXI 49.6-62.1 MHz, MISO 8.38-10.22 ns, 8/8 PASS.
Best seed 8 (clk 30.35, EXI 49.56, MISO 8.38 ns), flashed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,16 +82,29 @@ The `exi_bba/` module tree is **fully implemented** with simulation testbenches.
|
|||||||
All modules elaborate without errors and pass their unit tests, including
|
All modules elaborate without errors and pass their unit tests, including
|
||||||
`bba_top` at the real 27 MHz EXI rate (`EXI_HALF=1`).
|
`bba_top` at the real 27 MHz EXI rate (`EXI_HALF=1`).
|
||||||
|
|
||||||
**Timing closes on EVERY seed (2026-10-03).** Measured with `EXI_J4=1` +
|
**Timing closes on EVERY seed (2026-10-03).** `python -m exi_bba.synth
|
||||||
socket-3 UDP, `python -m exi_bba.synth --seeds 8`, 68% LC:
|
--seeds 8`, 68% LC:
|
||||||
|
|
||||||
| domain | target | result |
|
| domain / path | target | result |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `clk` (sync, HFOSC) | 24 MHz | 26.4–32.2 MHz — **PASS on all 8 seeds** |
|
| `clk` (sync, HFOSC) | 24 MHz | 28.7–32.5 MHz — **PASS on all 8 seeds** |
|
||||||
| EXI pin domain | 27 MHz | 45.6–64.4 MHz — **PASS on all 8 seeds** |
|
| EXI pin domain | 27 MHz | 49.6–62.1 MHz — **PASS on all 8 seeds** |
|
||||||
|
| **MISO clock→pad** | see below | **8.38–10.22 ns** |
|
||||||
|
|
||||||
Best seed **3**: `clk` 32.18 MHz, EXI 55.86 MHz (worst relative margin 1.34×).
|
Best seed **8**: `clk` 30.35 MHz, EXI 49.56 MHz, MISO 8.38 ns.
|
||||||
Flash `build/seed<N>/top.bin`.
|
|
||||||
|
⚠️ **fmax is NOT the binding constraint — MISO clock-to-pad is.** MISO is a
|
||||||
|
source-synchronous output: it leaves on the falling edge and the GameCube
|
||||||
|
samples it on the next RISING edge, so the whole path must fit a **half
|
||||||
|
period — 18.5 ns at 27 MHz** — minus global-buffer insertion, the pad, the
|
||||||
|
connector and the GC's own setup time. A build measuring ~10.5 ns failed on
|
||||||
|
console at 27 MHz while passing at 16 MHz, with every seed reporting ≥1.29×
|
||||||
|
fmax margin. `synth.py` therefore reports this delay and **ranks seeds by it**,
|
||||||
|
not by fmax. Two changes bought the headroom: removing the EXI_J4 pad merge
|
||||||
|
(MISO no longer fans out to the far J4 pad, and the clock pad regains its
|
||||||
|
dedicated global-buffer route) and removing the `Mux(cs_n, 1, …)` LUT from the
|
||||||
|
MISO data path (`tx_shift` has `init=0xFF`, so CS — the domain's async reset —
|
||||||
|
parks MISO high by itself).
|
||||||
|
|
||||||
This replaces the long-standing "closes on a minority of seeds" problem, which
|
This replaces the long-standing "closes on a minority of seeds" problem, which
|
||||||
was entirely an artefact of the PLL-fed 54 MHz `capture` domain. Removing that
|
was entirely an artefact of the PLL-fed 54 MHz `capture` domain. Removing that
|
||||||
@@ -161,7 +174,7 @@ python -m exi_bba.uart_shell # 8 tests: shell cmds + reply/timeout
|
|||||||
python -m exi_bba.eeprom_model
|
python -m exi_bba.eeprom_model
|
||||||
python -m exi_bba.bba_top # end-to-end EXI integration test (W5100 RX loop)
|
python -m exi_bba.bba_top # end-to-end EXI integration test (W5100 RX loop)
|
||||||
EXI_HALF=1 python -m exi_bba.bba_top # ...at the REAL 27 MHz rate — always do this
|
EXI_HALF=1 python -m exi_bba.bba_top # ...at the REAL 27 MHz rate — always do this
|
||||||
python -m exi_bba.exi_j4 # EXI_J4 pad-merge truth table + enumeration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### UART shell + UDP bring-up test (default flash build)
|
### UART shell + UDP bring-up test (default flash build)
|
||||||
@@ -285,44 +298,24 @@ slip was *structurally invisible* — the same failure of imagination as the
|
|||||||
Mode 2/3 mix-up, which was invisible to a master that parked MOSI early. When
|
Mode 2/3 mix-up, which was invisible to a master that parked MOSI early. When
|
||||||
adding SPI tests, vary the edge the transaction starts on.
|
adding SPI tests, vary the edge the transaction starts on.
|
||||||
|
|
||||||
### EXI on the J4 debug header (`EXI_J4=1`) — bench host without SP1
|
### EXI on the J4 debug header — REMOVED 2026-10-03
|
||||||
|
|
||||||
`EXI_J4=1` lets a bench host (the Pi) drive EXI through the unpopulated J4
|
`EXI_J4=1` used to merge a second set of EXI pads (the unpopulated J4 debug
|
||||||
header instead of the SP1 edge connector. It does NOT repin the EXI resource —
|
header, FPGA pins 9/10/11/12/6) onto the SP1 pads with an AND, so a bench host
|
||||||
it **merges** a second set of pads onto the existing ones, so SP1 stays live and
|
(a Raspberry Pi) could drive EXI without the edge connector. **The whole path
|
||||||
the default build is unchanged:
|
is gone** — `exi_bba/exi_j4.py`, the `exi_j4` resource, the AND merge and
|
||||||
|
`scripts/pi-exi-wirecheck.py`. Recover it from git history if a bench host is
|
||||||
|
ever needed again.
|
||||||
|
|
||||||
| J4 | DBG | FPGA pin | EXI |
|
It was removed because it was actively costing MISO timing, and the GameCube
|
||||||
|---|---|---|---|
|
itself is now the test host:
|
||||||
| 1 | — | — | GND |
|
- the AND put a **LUT in the clock path**, so the EXI clock pad could not use
|
||||||
| 2 | DBG0 | 9 | CLK (in) |
|
its dedicated global-buffer route; and
|
||||||
| 3 | DBG1 | 10 | MOSI (in) |
|
- MISO fanned out to both pads, and the J4 pad at die (17,0) was the far end
|
||||||
| 4 | DBG2 | 11 | MISO (out) |
|
of the **critical output path** — 4.79 ns of routing to a header with
|
||||||
| 5 | DBG3 | 12 | CS_N (in) |
|
nothing plugged into it.
|
||||||
| 6 | DBG4 | 6 | INT_N (out) |
|
|
||||||
|
|
||||||
The three inputs are **AND-merged** with `PULLUP=1` on both pad sets. AND is
|
Dropping it cut MISO clock-to-pad from ~10.2–11.5 ns to ~9.3–9.6 ns.
|
||||||
exact, not a hack: every EXI input is inactive-HIGH (CPOL=1 → CLK idles high,
|
|
||||||
CS_N is active-low, MOSI only matters while CS_N is low), so the unplugged side
|
|
||||||
idles at 1 through its pull-up and passes the other side through — no select
|
|
||||||
signal needed. Outputs drive both pads. **Drive one side at a time**; a GC on
|
|
||||||
SP1 plus a host on J4 will fight and the AND yields the mangled product.
|
|
||||||
Build refuses to run alongside `UART_J4` (also pins 9/10) or an `ETH_D3_PIN`
|
|
||||||
aimed at a J4 pad.
|
|
||||||
|
|
||||||
**Why merge rather than repin:** all five J4 pads are on the SAME bottom die
|
|
||||||
edge as the EXI pads (x=13–18 vs x=5–9, both y=0 per icestorm's `pinloc_db`
|
|
||||||
`5k-sg48`), so the EXI clock domain keeps its bottom-edge anchor. Repinning
|
|
||||||
would have perturbed the one placement variable that the pin-co-location
|
|
||||||
experiment showed was worth ±5 MHz. (That experiment mattered when the
|
|
||||||
PLL-fed capture domain was the binding constraint; with ~2.4× margin on the
|
|
||||||
EXI domain today it is no longer a concern either way.)
|
|
||||||
|
|
||||||
⚠️ **The merge changes which net is the clock.** On a plain build the clock is
|
|
||||||
the pad itself; with `EXI_J4=1` it is the AND of the two pads, a distinct net.
|
|
||||||
`synth.py` constrains whichever one applies — constraining the pad on a J4
|
|
||||||
build misses, and nextpnr then silently falls back to a 12 MHz default and
|
|
||||||
under-optimises the whole EXI domain.
|
|
||||||
|
|
||||||
**HARDWARE BRING-UP 2026-09-30: the EXI capture path is PROVEN on silicon.**
|
**HARDWARE BRING-UP 2026-09-30: the EXI capture path is PROVEN on silicon.**
|
||||||
A Raspberry Pi 4 bit-banging EXI into J4 (EXI_J4 build, seed 8) enumerated the
|
A Raspberry Pi 4 bit-banging EXI into J4 (EXI_J4 build, seed 8) enumerated the
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
"""EXI pad merging for the J4 debug-header bring-up path (``EXI_J4=1``).
|
|
||||||
|
|
||||||
``BBATopSynth`` can mirror the EXI interface onto the unpopulated J4 debug
|
|
||||||
header so a bench host (e.g. a Raspberry Pi) can drive EXI without the SP1 edge
|
|
||||||
connector. Rather than REPINNING the EXI resource onto J4 -- which would move
|
|
||||||
where the capture domain anchors on the die -- a second set of pads is MERGED
|
|
||||||
onto the existing ones, leaving SP1 live and the default build untouched.
|
|
||||||
|
|
||||||
The merge itself lives here, in one place, so the synthesis path and the
|
|
||||||
testbench below exercise the same code.
|
|
||||||
|
|
||||||
Run the test: ``python -m exi_bba.exi_j4``
|
|
||||||
"""
|
|
||||||
|
|
||||||
__all__ = ["merge_exi_inputs"]
|
|
||||||
|
|
||||||
|
|
||||||
def merge_exi_inputs(primary, mirror):
|
|
||||||
"""AND-merge two sets of EXI input pads into one.
|
|
||||||
|
|
||||||
`primary` and `mirror` are ``(clk, mosi, cs_n)`` triples; returns the merged
|
|
||||||
triple.
|
|
||||||
|
|
||||||
AND is exact here, not an approximation, because every EXI input is
|
|
||||||
inactive-HIGH: CPOL=1 so CLK idles high, CS_N is active-low, and MOSI only
|
|
||||||
matters while CS_N is low. Whichever pad set is unplugged idles at 1
|
|
||||||
through its ``SB_IO`` pull-up (``PULLUP=1`` is set on both sets in an
|
|
||||||
``EXI_J4`` build) and therefore passes the other set through untouched -- no
|
|
||||||
select signal, no register, no build variant of the core.
|
|
||||||
|
|
||||||
DRIVE ONE SIDE AT A TIME. With a GameCube on SP1 and a host on J4
|
|
||||||
simultaneously the capture engine sees the bitwise product of the two,
|
|
||||||
which is garbage.
|
|
||||||
"""
|
|
||||||
return tuple(a & b for a, b in zip(primary, mirror))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import sys
|
|
||||||
from amaranth import Elaboratable, Module, Signal
|
|
||||||
from amaranth.sim import Simulator, Period
|
|
||||||
from exi_bba.bba_top import BBATop
|
|
||||||
|
|
||||||
HALF = 8 # capture ticks per SPI half-period (well oversampled)
|
|
||||||
|
|
||||||
|
|
||||||
# No capture domain any more: the bit engine is clocked by the EXI
|
|
||||||
|
|
||||||
|
|
||||||
# pin, so drive the SPI clock with real delays instead of ticks.
|
|
||||||
|
|
||||||
|
|
||||||
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
|
|
||||||
DEVICE_ID = [0x04, 0x02, 0x02, 0x00]
|
|
||||||
|
|
||||||
class _Harness(Elaboratable):
|
|
||||||
"""BBATop behind the real merge, with two independent EXI pad sets."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.dut = BBATop(eth="w5100", reset_cycles=20)
|
|
||||||
# Both sets idle HIGH, exactly as the SB_IO pull-ups hold them.
|
|
||||||
self.sp1 = [Signal(init=1, name=f"sp1_{n}") for n in ("clk", "mosi", "cs_n")]
|
|
||||||
self.j4 = [Signal(init=1, name=f"j4_{n}") for n in ("clk", "mosi", "cs_n")]
|
|
||||||
|
|
||||||
def elaborate(self, platform):
|
|
||||||
m = Module()
|
|
||||||
m.submodules.dut = d = self.dut
|
|
||||||
clk, mosi, cs_n = merge_exi_inputs(tuple(self.sp1), tuple(self.j4))
|
|
||||||
m.d.comb += [d.exi_clk.eq(clk), d.exi_mosi.eq(mosi), d.exi_cs_n.eq(cs_n)]
|
|
||||||
return m
|
|
||||||
|
|
||||||
top = _Harness()
|
|
||||||
dut = top.dut
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
def unplug(ctx, pads):
|
|
||||||
"""Model the other connector being unplugged: every pad pulled HIGH.
|
|
||||||
|
|
||||||
Essential, and easy to forget: `spi_byte` leaves `mosi` at the last bit
|
|
||||||
it drove, so a side left at 0 will AND the *other* side's MOSI to zero
|
|
||||||
for the rest of the run. On real hardware the SB_IO pull-up does this
|
|
||||||
for us the moment the connector is empty.
|
|
||||||
"""
|
|
||||||
for s in pads:
|
|
||||||
ctx.set(s, 1)
|
|
||||||
|
|
||||||
async def spi_byte(ctx, pads, mosi_val):
|
|
||||||
"""Drive one EXI Mode-3 byte on `pads`; return the assembled MISO byte."""
|
|
||||||
clk, mosi, cs_n = pads
|
|
||||||
miso = 0
|
|
||||||
for bit in range(7, -1, -1):
|
|
||||||
ctx.set(mosi, (mosi_val >> bit) & 1)
|
|
||||||
ctx.set(clk, 0) # falling: slave samples
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
miso = (miso << 1) | ctx.get(dut.exi_miso)
|
|
||||||
ctx.set(clk, 1) # rising: slave drives
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
return miso
|
|
||||||
|
|
||||||
async def exi_read(ctx, pads, addr, length):
|
|
||||||
"""Register-space read, REAL protocol: 4-byte header then data."""
|
|
||||||
clk, mosi, cs_n = pads
|
|
||||||
ctx.set(cs_n, 0)
|
|
||||||
ctx.set(clk, 1)
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
await spi_byte(ctx, pads, 0x80)
|
|
||||||
await spi_byte(ctx, pads, (addr >> 8) & 0xFF)
|
|
||||||
await spi_byte(ctx, pads, addr & 0xFF)
|
|
||||||
await spi_byte(ctx, pads, 0x00) # turnaround byte
|
|
||||||
result = [await spi_byte(ctx, pads, 0x00) for _ in range(length)]
|
|
||||||
ctx.set(cs_n, 1)
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def exi_devid(ctx, pads):
|
|
||||||
"""EXI_GetID: write 0x0000 (2 bytes), clock 4 read bytes."""
|
|
||||||
clk, mosi, cs_n = pads
|
|
||||||
ctx.set(cs_n, 0)
|
|
||||||
ctx.set(clk, 1)
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
await spi_byte(ctx, pads, 0x00)
|
|
||||||
await spi_byte(ctx, pads, 0x00)
|
|
||||||
out = [await spi_byte(ctx, pads, 0x00) for _ in range(4)]
|
|
||||||
ctx.set(cs_n, 1)
|
|
||||||
await ctx.delay(HP * (HALF))
|
|
||||||
return out
|
|
||||||
|
|
||||||
async def testbench(ctx):
|
|
||||||
await ctx.delay(HP * (20))
|
|
||||||
|
|
||||||
# T1: enumerate over the SP1 pads while J4 is unplugged (idle high).
|
|
||||||
unplug(ctx, top.j4)
|
|
||||||
dev = await exi_devid(ctx, top.sp1)
|
|
||||||
print(f"T1 enumerate via SP1 (J4 idle): {[f'0x{b:02X}' for b in dev]}")
|
|
||||||
if dev != DEVICE_ID:
|
|
||||||
errors.append(f"T1 SP1 device ID: got {dev}")
|
|
||||||
await ctx.delay(HP * (HALF * 4))
|
|
||||||
|
|
||||||
# T2: enumerate over the J4 pads while SP1 is unplugged (idle high).
|
|
||||||
unplug(ctx, top.sp1)
|
|
||||||
dev = await exi_devid(ctx, top.j4)
|
|
||||||
print(f"T2 enumerate via J4 (SP1 idle): {[f'0x{b:02X}' for b in dev]}")
|
|
||||||
if dev != DEVICE_ID:
|
|
||||||
errors.append(f"T2 J4 device ID: got {dev}")
|
|
||||||
await ctx.delay(HP * (HALF * 4))
|
|
||||||
|
|
||||||
# T3: back to SP1, to prove using J4 left no sticky state behind.
|
|
||||||
unplug(ctx, top.j4)
|
|
||||||
dev = await exi_devid(ctx, top.sp1)
|
|
||||||
print(f"T3 enumerate via SP1 again: {[f'0x{b:02X}' for b in dev]}")
|
|
||||||
if dev != DEVICE_ID:
|
|
||||||
errors.append(f"T3 SP1 re-enumerate: got {dev}")
|
|
||||||
await ctx.delay(HP * (HALF * 4))
|
|
||||||
|
|
||||||
# T4: a register read through the merge, not just the ID ROM path.
|
|
||||||
unplug(ctx, top.sp1)
|
|
||||||
nways = await exi_read(ctx, top.j4, 0x31, 1)
|
|
||||||
print(f"T4 NWAYS via J4: 0x{nways[0]:02X} (want 0x17)")
|
|
||||||
if nways != [0x17]:
|
|
||||||
errors.append(f"T4 NWAYS via J4: got {nways}")
|
|
||||||
|
|
||||||
# T5: prove the merge really is an AND of both pad sets (and is not
|
|
||||||
# optimised down to one side). Pass-through in EITHER direction is the
|
|
||||||
# DESIRED behaviour -- the driven side wins over the idle-high side.
|
|
||||||
# The last row is the documented hazard: two active drivers collide.
|
|
||||||
unplug(ctx, top.sp1)
|
|
||||||
unplug(ctx, top.j4)
|
|
||||||
for sp1_v, j4_v, want, note in (
|
|
||||||
(1, 1, 1, "both unplugged -> idle"),
|
|
||||||
(1, 0, 0, "J4 drives, SP1 unplugged -> J4 passes through"),
|
|
||||||
(0, 1, 0, "SP1 drives, J4 unplugged -> SP1 passes through"),
|
|
||||||
(0, 0, 0, "both drive low"),
|
|
||||||
):
|
|
||||||
ctx.set(top.sp1[2], sp1_v)
|
|
||||||
ctx.set(top.j4[2], j4_v)
|
|
||||||
await ctx.delay(HP)
|
|
||||||
got = ctx.get(dut.exi_cs_n)
|
|
||||||
ok = "ok" if got == want else "MISMATCH"
|
|
||||||
print(f"T5 cs_n sp1={sp1_v} j4={j4_v} -> core {got} (want {want}) {ok} {note}")
|
|
||||||
if got != want:
|
|
||||||
errors.append(f"T5 merge truth table sp1={sp1_v} j4={j4_v}: got {got}")
|
|
||||||
unplug(ctx, top.sp1)
|
|
||||||
unplug(ctx, top.j4)
|
|
||||||
|
|
||||||
sim = Simulator(top)
|
|
||||||
sim.add_clock(Period(MHz=24), domain="sync")
|
|
||||||
sim.add_testbench(testbench)
|
|
||||||
with sim.write_vcd("exi_j4_merge.vcd"):
|
|
||||||
sim.run()
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
print("\nFAILURES:")
|
|
||||||
for e in errors:
|
|
||||||
print(" -", e)
|
|
||||||
sys.exit(1)
|
|
||||||
print("\nAll EXI_J4 merge tests passed.")
|
|
||||||
@@ -144,13 +144,20 @@ class SPIMode3Slave(Elaboratable):
|
|||||||
#
|
#
|
||||||
# Slaving the shift register to rx_bit gives the engine ONE phase
|
# Slaving the shift register to rx_bit gives the engine ONE phase
|
||||||
# reference, the same edge the host counts, so the two cannot drift.
|
# reference, the same edge the host counts, so the two cannot drift.
|
||||||
tx_shift = Signal(8)
|
# init=0xFF so CS (the domain's async reset) parks MISO HIGH by
|
||||||
|
# itself. MISO is then tx_shift[7] with NOTHING in between -- no
|
||||||
|
# Mux(cs_n, 1, ...) LUT in the data path. That mux cost 1.21 ns of
|
||||||
|
# logic AND 3.60 ns of routing to reach it, on a source-synchronous
|
||||||
|
# output that has only a HALF period (18.5 ns at 27 MHz) to reach the
|
||||||
|
# GameCube's sampling edge. Removing it also frees the placer to put
|
||||||
|
# this flop next to the MISO pad.
|
||||||
|
tx_shift = Signal(8, init=0xFF)
|
||||||
with m.If(rx_bit == 0):
|
with m.If(rx_bit == 0):
|
||||||
m.d.exiclk_n += tx_shift.eq(tx_hold) # byte boundary: load MSB
|
m.d.exiclk_n += tx_shift.eq(tx_hold) # byte boundary: load MSB
|
||||||
with m.Else():
|
with m.Else():
|
||||||
m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
|
m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
|
||||||
|
|
||||||
m.d.comb += self.spi_miso.eq(Mux(self.spi_cs_n, 1, tx_shift[7]))
|
m.d.comb += self.spi_miso.eq(tx_shift[7])
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|||||||
+61
-86
@@ -102,42 +102,11 @@ _UART_TX, _UART_RX = ("9", "10") if os.environ.get("UART_J4") else ("19", "18")
|
|||||||
# full UART_J4 design. Default = 28 (real board). See BRINGUP.md.
|
# full UART_J4 design. Default = 28 (real board). See BRINGUP.md.
|
||||||
_ETH_D3 = os.environ.get("ETH_D3_PIN", "28")
|
_ETH_D3 = os.environ.get("ETH_D3_PIN", "28")
|
||||||
|
|
||||||
# EXI bring-up on the J4 debug header (set env EXI_J4=1). Rather than REPINNING
|
# EXI pad attributes. No PULLUP: the GameCube drives all three inputs
|
||||||
# the EXI resource onto J4 -- which moves where the capture domain anchors on the
|
# through the SP1 edge connector.
|
||||||
# die -- this MERGES a second set of EXI pads onto the existing ones, so the SP1
|
_EXI_ATTRS = Attrs(IO_STANDARD="SB_LVCMOS")
|
||||||
# edge connector stays live and the normal build is one env var away.
|
|
||||||
#
|
|
||||||
# J4.1 = GND
|
|
||||||
# J4.2 = DBG0 = FPGA pin 9 -> EXI CLK (in)
|
|
||||||
# J4.3 = DBG1 = FPGA pin 10 -> EXI MOSI (in)
|
|
||||||
# J4.4 = DBG2 = FPGA pin 11 -> EXI MISO (out)
|
|
||||||
# J4.5 = DBG3 = FPGA pin 12 -> EXI CS_N (in)
|
|
||||||
# J4.6 = DBG4 = FPGA pin 6 -> EXI INT_N (out)
|
|
||||||
#
|
|
||||||
# The three INPUTS are merged with AND. That is correct precisely because every
|
|
||||||
# EXI input is idle/inactive HIGH: CPOL=1 so CLK idles high, CS_N is active-low,
|
|
||||||
# and MOSI only matters while CS_N is low. Whichever side is unplugged sits at 1
|
|
||||||
# through its SB_IO PULLUP and passes the other side through untouched, so no
|
|
||||||
# select signal is needed. Both pad sets therefore get PULLUP=1 in this build.
|
|
||||||
# DRIVE ONE SIDE AT A TIME -- a GameCube on SP1 and a host on J4 at the same time
|
|
||||||
# will fight, and the AND will hand the capture engine the mangled product.
|
|
||||||
# The two OUTPUTS are simply driven onto both pads (fanout 2, no added logic).
|
|
||||||
#
|
|
||||||
# Timing: all five J4 pads are on the SAME (bottom) die edge as the EXI pads
|
|
||||||
# (x=13..18 vs x=5..9, both y=0), so the capture domain keeps its bottom-edge
|
|
||||||
# anchor -- this does not recreate the cross-die span described in CLAUDE.md.
|
|
||||||
# It still adds 3 LUTs ahead of the input synchronisers and a second MISO load,
|
|
||||||
# and capture has little margin, so ALWAYS sweep seeds on an EXI_J4 build.
|
|
||||||
# COLLIDES WITH: UART_J4 (pins 9/10) and ETH_D3_PIN if pointed at a J4 pad.
|
|
||||||
_EXI_J4 = bool(os.environ.get("EXI_J4"))
|
|
||||||
if _EXI_J4 and os.environ.get("UART_J4"):
|
|
||||||
raise SystemExit("EXI_J4 and UART_J4 both claim J4 pins 9/10 - pick one")
|
|
||||||
if _EXI_J4 and _ETH_D3 in ("9", "10", "11", "12", "6"):
|
|
||||||
raise SystemExit(f"EXI_J4 conflicts with ETH_D3_PIN={_ETH_D3} on the J4 header")
|
|
||||||
|
|
||||||
# With the merge active the unplugged side must idle high rather than float.
|
|
||||||
_EXI_ATTRS = (Attrs(IO_STANDARD="SB_LVCMOS", PULLUP=1) if _EXI_J4
|
|
||||||
else Attrs(IO_STANDARD="SB_LVCMOS"))
|
|
||||||
|
|
||||||
|
|
||||||
class IceBreakerPlatform(LatticeICE40Platform):
|
class IceBreakerPlatform(LatticeICE40Platform):
|
||||||
@@ -160,15 +129,6 @@ class IceBreakerPlatform(LatticeICE40Platform):
|
|||||||
Subsignal("int_n", Pins("46", dir="o")),
|
Subsignal("int_n", Pins("46", dir="o")),
|
||||||
_EXI_ATTRS),
|
_EXI_ATTRS),
|
||||||
|
|
||||||
# EXI mirror on the J4 debug header (EXI_J4=1 only) -- see note above.
|
|
||||||
*([Resource("exi_j4", 0,
|
|
||||||
Subsignal("clk", Pins("9", dir="i")),
|
|
||||||
Subsignal("mosi", Pins("10", dir="i")),
|
|
||||||
Subsignal("miso", Pins("11", dir="o")),
|
|
||||||
Subsignal("cs_n", Pins("12", dir="i")),
|
|
||||||
Subsignal("int_n", Pins("6", dir="o")),
|
|
||||||
_EXI_ATTRS)] if _EXI_J4 else []),
|
|
||||||
|
|
||||||
# W5100 indirect parallel bus
|
# W5100 indirect parallel bus
|
||||||
Resource("w5100", 0,
|
Resource("w5100", 0,
|
||||||
Subsignal("addr", Pins("42 38", dir="o")),
|
Subsignal("addr", Pins("42 38", dir="o")),
|
||||||
@@ -233,46 +193,23 @@ class BBATopSynth(BBATop):
|
|||||||
exi = platform.request("exi", 0)
|
exi = platform.request("exi", 0)
|
||||||
w5100 = platform.request("w5100", 0)
|
w5100 = platform.request("w5100", 0)
|
||||||
|
|
||||||
# EXI inputs: optionally AND-merged with the J4 header mirror so a
|
|
||||||
# bench host can drive EXI without the SP1 edge connector. AND is
|
|
||||||
# valid because all three inputs are inactive-HIGH and the unused
|
|
||||||
# side is held there by its SB_IO PULLUP -- see the _EXI_J4 note.
|
|
||||||
if _EXI_J4:
|
|
||||||
j4 = platform.request("exi_j4", 0)
|
|
||||||
exi_clk_i = exi.clk.i & j4.clk.i
|
|
||||||
exi_mosi_i = exi.mosi.i & j4.mosi.i
|
|
||||||
exi_cs_n_i = exi.cs_n.i & j4.cs_n.i
|
|
||||||
m.d.comb += [
|
|
||||||
j4.miso.o .eq(self.exi_miso),
|
|
||||||
j4.int_n.o .eq(self.int_n),
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
exi_clk_i, exi_mosi_i, exi_cs_n_i = exi.clk.i, exi.mosi.i, exi.cs_n.i
|
|
||||||
|
|
||||||
# The EXI clock pin now clocks the bit engine directly (see
|
# The EXI clock pin now clocks the bit engine directly (see
|
||||||
# BBATop's clock-domain comment), so it is a REAL clock net and
|
# BBATop's clock-domain comment), so it is a REAL clock net and
|
||||||
# must be constrained. Without this nextpnr defaults it to
|
# must be constrained. Without this nextpnr defaults it to
|
||||||
# 12 MHz and happily reports PASS on a build that would miss bits
|
# 12 MHz and happily reports PASS on a build that would miss bits
|
||||||
# on a console. The GC tops out at ~27 MHz ("EXI_SPEED32MHZ" is a
|
# on a console. The GC tops out at ~27 MHz ("EXI_SPEED32MHZ" is a
|
||||||
# nominal name, not the actual rate).
|
# nominal name, not the actual rate).
|
||||||
# Constrain the net that actually BECOMES the clock, which
|
# Constrain the pad that becomes the clock. self.exi_clk is a
|
||||||
# differs between the two build variants:
|
# pure alias of it and yosys absorbs the alias, so the surviving
|
||||||
# plain -- self.exi_clk is a pure alias of the pad and yosys
|
# net is the pad's ('exi_0__clk__i_$glb_clk'). Without this
|
||||||
# absorbs it, leaving the pad net (nextpnr calls it
|
# nextpnr defaults the clock to 12 MHz and reports PASS on a build
|
||||||
# 'exi_0__clk__i_$glb_clk').
|
# that would miss bits on a console.
|
||||||
# EXI_J4 -- the clock is the AND of the two pads, a distinct net
|
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
|
||||||
# ('exi_clk_$glb_clk'); constraining either pad then
|
|
||||||
# misses, and nextpnr silently falls back to its 12 MHz
|
|
||||||
# default and under-optimises the whole EXI domain.
|
|
||||||
if _EXI_J4:
|
|
||||||
platform.add_clock_constraint(self.exi_clk, _EXI_HZ)
|
|
||||||
else:
|
|
||||||
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
|
|
||||||
|
|
||||||
m.d.comb += [
|
m.d.comb += [
|
||||||
self.exi_clk .eq(exi_clk_i),
|
self.exi_clk .eq(exi.clk.i),
|
||||||
self.exi_mosi .eq(exi_mosi_i),
|
self.exi_mosi .eq(exi.mosi.i),
|
||||||
self.exi_cs_n .eq(exi_cs_n_i),
|
self.exi_cs_n .eq(exi.cs_n.i),
|
||||||
exi.miso.o .eq(self.exi_miso),
|
exi.miso.o .eq(self.exi_miso),
|
||||||
exi.int_n.o .eq(self.int_n),
|
exi.int_n.o .eq(self.int_n),
|
||||||
|
|
||||||
@@ -419,6 +356,7 @@ if __name__ == "__main__":
|
|||||||
best_seed = 1
|
best_seed = 1
|
||||||
best_fmax = 0.0
|
best_fmax = 0.0
|
||||||
best_detail = None
|
best_detail = None
|
||||||
|
best_miso = None
|
||||||
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
|
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
|
||||||
for seed in range(1, n_seeds + 1):
|
for seed in range(1, n_seeds + 1):
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
@@ -472,6 +410,7 @@ if __name__ == "__main__":
|
|||||||
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
|
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
|
||||||
# unparseable seed must be reported and skipped, never ranked.
|
# unparseable seed must be reported and skipped, never ranked.
|
||||||
fmax = {}
|
fmax = {}
|
||||||
|
out_ns = [None] # MISO clock-to-pad, post-route
|
||||||
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
|
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
|
||||||
try:
|
try:
|
||||||
# errors="replace": a decode hiccup must not abort the parse
|
# errors="replace": a decode hiccup must not abort the parse
|
||||||
@@ -482,6 +421,21 @@ if __name__ == "__main__":
|
|||||||
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz", line)
|
r"Max frequency for clock\s+'([^']+)':\s*([\d.]+)\s*MHz", line)
|
||||||
if m_:
|
if m_:
|
||||||
fmax[m_.group(1)] = float(m_.group(2))
|
fmax[m_.group(1)] = float(m_.group(2))
|
||||||
|
# MISO clock-to-pad. fmax is NOT the binding
|
||||||
|
# constraint for this design: every seed passed both
|
||||||
|
# fmax targets with >=1.29x margin while the BOARD
|
||||||
|
# still failed at 27 MHz, because MISO is a
|
||||||
|
# source-synchronous output -- it leaves on the
|
||||||
|
# falling edge and the GameCube samples it on the
|
||||||
|
# next rising edge, so the whole path must fit a
|
||||||
|
# HALF period (18.5 ns at 27 MHz) minus global-buffer
|
||||||
|
# insertion, pad, connector and the GC's own setup.
|
||||||
|
# Track it explicitly and rank on it.
|
||||||
|
m_ = re.search(
|
||||||
|
r"Max delay\s+negedge\s+\S+\s*->\s*<async>\s*:"
|
||||||
|
r"\s*([\d.]+)\s*ns", line)
|
||||||
|
if m_:
|
||||||
|
out_ns[0] = float(m_.group(1))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f" [seed {seed}] could not read {tf}: {exc}")
|
print(f" [seed {seed}] could not read {tf}: {exc}")
|
||||||
|
|
||||||
@@ -500,7 +454,7 @@ if __name__ == "__main__":
|
|||||||
fmax_clk, fmax_exi = fmax["clk"], fmax[exi_keys[0]]
|
fmax_clk, fmax_exi = fmax["clk"], fmax[exi_keys[0]]
|
||||||
verdict = ("PASS" if fmax_exi >= _EXI_TARGET and fmax_clk >= 24.0
|
verdict = ("PASS" if fmax_exi >= _EXI_TARGET and fmax_clk >= 24.0
|
||||||
else "FAIL")
|
else "FAIL")
|
||||||
results.append((seed, fmax_clk, fmax_exi, verdict))
|
results.append((seed, fmax_clk, fmax_exi, verdict, None))
|
||||||
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
|
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
|
||||||
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
|
f"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
|
||||||
f"{verdict}"
|
f"{verdict}"
|
||||||
@@ -510,30 +464,51 @@ if __name__ == "__main__":
|
|||||||
# with the PLL gone the EXI domain has ~2.3x margin and `clk` can
|
# with the PLL gone the EXI domain has ~2.3x margin and `clk` can
|
||||||
# easily be the tighter of the two, so ranking by EXI alone would pick
|
# easily be the tighter of the two, so ranking by EXI alone would pick
|
||||||
# a seed that is worse overall.
|
# a seed that is worse overall.
|
||||||
|
# Rank by the SMALLEST MISO clock-to-pad delay among seeds that meet
|
||||||
|
# both fmax targets -- that is the constraint that actually failed on
|
||||||
|
# hardware. Fall back to relative fmax margin only if the delay could
|
||||||
|
# not be parsed.
|
||||||
|
miso = out_ns[0]
|
||||||
|
if verdict == "PASS" and miso is not None:
|
||||||
|
if best_miso is None or miso < best_miso:
|
||||||
|
best_miso = miso
|
||||||
|
best_seed = seed
|
||||||
|
best_detail = (fmax_clk, fmax_exi, miso)
|
||||||
score = min(fmax_exi / _EXI_TARGET, fmax_clk / 24.0)
|
score = min(fmax_exi / _EXI_TARGET, fmax_clk / 24.0)
|
||||||
if score > best_fmax:
|
if score > best_fmax:
|
||||||
best_fmax = score
|
best_fmax = score
|
||||||
best_seed = seed
|
if best_miso is None:
|
||||||
best_detail = (fmax_clk, fmax_exi)
|
best_seed = seed
|
||||||
|
best_detail = (fmax_clk, fmax_exi, miso)
|
||||||
|
results[-1] = (seed, fmax_clk, fmax_exi, verdict, miso)
|
||||||
|
|
||||||
# Per-seed summary. The pass RATE matters as much as the best number — a
|
# Per-seed summary. The pass RATE matters as much as the best number — a
|
||||||
# design that only closes on a minority of seeds has no real margin.
|
# design that only closes on a minority of seeds has no real margin.
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f" Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
|
print(f" Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16} verdict")
|
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16}"
|
||||||
for seed, fc, fexi, verdict in results:
|
f" {'MISO clk→pad':>13} verdict")
|
||||||
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.2f} {verdict}")
|
for seed, fc, fexi, verdict, miso in results:
|
||||||
n_pass = sum(1 for *_, v in results if v == "PASS")
|
ms = f"{miso:.2f} ns" if miso is not None else " n/a"
|
||||||
|
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.2f} {ms:>13} {verdict}")
|
||||||
|
n_pass = sum(1 for r in results if r[3] == "PASS")
|
||||||
if results:
|
if results:
|
||||||
print(f"\n passing seeds: {n_pass}/{len(results)} "
|
print(f"\n passing seeds: {n_pass}/{len(results)} "
|
||||||
f"({[s for s, *_, v in results if v == 'PASS']})")
|
f"({[r[0] for r in results if r[3] == 'PASS']})")
|
||||||
|
|
||||||
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
|
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
|
||||||
if best_detail:
|
if best_detail:
|
||||||
|
ms = (f", MISO clk→pad {best_detail[2]:.2f} ns"
|
||||||
|
if best_detail[2] is not None else "")
|
||||||
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
|
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
|
||||||
f"exi {best_detail[1]:.2f} MHz "
|
f"exi {best_detail[1]:.2f} MHz{ms} — {overall}")
|
||||||
f"(worst margin {best_fmax:.2f}x) — {overall}")
|
if best_detail[2] is not None and best_detail[2] > 12.0:
|
||||||
|
print(" ⚠️ MISO clk→pad > 12 ns. At 27 MHz the GameCube samples "
|
||||||
|
"MISO a HALF period (18.5 ns) after the falling edge it is "
|
||||||
|
"driven on, and global-buffer insertion, the pad, the "
|
||||||
|
"connector and the GC's setup all come out of what is left. "
|
||||||
|
"Reads failed at index 5 and passed at 16 MHz with 10.99 ns.")
|
||||||
else:
|
else:
|
||||||
print(f"\nBest seed: {best_seed} — {overall}")
|
print(f"\nBest seed: {best_seed} — {overall}")
|
||||||
if overall == "PASS" and n_pass * 2 < len(results):
|
if overall == "PASS" and n_pass * 2 < len(results):
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify the Pi <-> re-bba-rb J4 wiring BEFORE attempting a real EXI transfer.
|
|
||||||
|
|
||||||
Requires the FPGA to be running an EXI_J4=1 bitstream, which mirrors EXI onto
|
|
||||||
the J4 header and enables SB_IO pull-ups on the three EXI *inputs*.
|
|
||||||
|
|
||||||
J4.1 GND <-> Pi pin 25 GND
|
|
||||||
J4.2 DBG0 <-- Pi pin 23 BCM11 EXI CLK (FPGA input)
|
|
||||||
J4.3 DBG1 <-- Pi pin 19 BCM10 EXI MOSI (FPGA input)
|
|
||||||
J4.4 DBG2 --> Pi pin 21 BCM9 EXI MISO (FPGA OUTPUT - never drive)
|
|
||||||
J4.5 DBG3 <-- Pi pin 24 BCM8 EXI CS_N (FPGA input)
|
|
||||||
J4.6 DBG4 --> Pi pin 22 BCM25 EXI INT_N (FPGA OUTPUT - never drive)
|
|
||||||
|
|
||||||
Run: sudo python3 exi_wirecheck.py
|
|
||||||
"""
|
|
||||||
import sys, time
|
|
||||||
import RPi.GPIO as GPIO
|
|
||||||
|
|
||||||
CLK, MOSI, MISO, CS_N, INT_N = 11, 10, 9, 8, 25
|
|
||||||
HDR = {11: "hdr23", 10: "hdr19", 9: "hdr21", 8: "hdr24", 25: "hdr22"}
|
|
||||||
DRIVEN_BY_PI = [(CLK, "CLK", "J4.2"), (MOSI, "MOSI", "J4.3"), (CS_N, "CS_N", "J4.5")]
|
|
||||||
DRIVEN_BY_FPGA= [(MISO, "MISO", "J4.4"), (INT_N, "INT_N", "J4.6")]
|
|
||||||
|
|
||||||
fails, warns = [], []
|
|
||||||
|
|
||||||
def rd(pin, pull):
|
|
||||||
GPIO.setup(pin, GPIO.IN, pull_up_down=pull)
|
|
||||||
time.sleep(0.002)
|
|
||||||
return GPIO.input(pin)
|
|
||||||
|
|
||||||
GPIO.setmode(GPIO.BCM)
|
|
||||||
GPIO.setwarnings(False)
|
|
||||||
for p, _, _ in DRIVEN_BY_PI + DRIVEN_BY_FPGA:
|
|
||||||
GPIO.setup(p, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
|
|
||||||
print("=" * 66)
|
|
||||||
print("T0 CONTROL: pins known to be unwired MUST follow the internal pull.")
|
|
||||||
print(" Without this, a broken pull mechanism would silently turn every")
|
|
||||||
print(" T1 verdict into a false \'CONNECTED\'.")
|
|
||||||
print("=" * 66)
|
|
||||||
for pin, hdr in ((26, "hdr37"), (20, "hdr38"), (16, "hdr36"), (21, "hdr40")):
|
|
||||||
u, d = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN)
|
|
||||||
ok = (u, d) == (1, 0)
|
|
||||||
print(f" BCM{pin:<2} {hdr}: pull-up={u} pull-down={d} "
|
|
||||||
f"{'pull works' if ok else '** PULL NOT WORKING - T1/T2 INVALID **'}")
|
|
||||||
if not ok:
|
|
||||||
fails.append(f"control pin BCM{pin} did not follow the pull; results below are meaningless")
|
|
||||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 66)
|
|
||||||
print("T1 FPGA-driven pins: value must NOT follow the Pi's internal pull.")
|
|
||||||
print(" An active driver (~ohms) beats the Pi's ~50k pull; a dead wire")
|
|
||||||
print(" follows it. This test is decisive.")
|
|
||||||
print("=" * 66)
|
|
||||||
for pin, name, j4 in DRIVEN_BY_FPGA:
|
|
||||||
up, dn = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN)
|
|
||||||
if up == dn:
|
|
||||||
print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: pull-up={up} pull-down={dn}"
|
|
||||||
f" -> CONNECTED, FPGA driving {up}")
|
|
||||||
else:
|
|
||||||
print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: pull-up={up} pull-down={dn}"
|
|
||||||
f" -> ** NOT CONNECTED ** (follows the Pi's pull)")
|
|
||||||
fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is open")
|
|
||||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 66)
|
|
||||||
print("T2 FPGA-input pins: these carry SB_IO PULLUP=1 in an EXI_J4 build, so")
|
|
||||||
print(" a correct connection reads HIGH regardless of the Pi's pull.")
|
|
||||||
print(" Follows-the-pull = open. Hard low = shorted. Decisive either way.")
|
|
||||||
print("=" * 66)
|
|
||||||
for pin, name, j4 in DRIVEN_BY_PI:
|
|
||||||
u, d = rd(pin, GPIO.PUD_UP), rd(pin, GPIO.PUD_DOWN)
|
|
||||||
if (u, d) == (1, 1):
|
|
||||||
print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up=1 down=1 -> held HIGH: FPGA pull-up seen, CONNECTED")
|
|
||||||
elif (u, d) == (1, 0):
|
|
||||||
print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up=1 down=0 -> follows the Pi pull: ** OPEN **")
|
|
||||||
fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is open - no connection")
|
|
||||||
else:
|
|
||||||
print(f" {name:<5} {j4} BCM{pin:<2} {HDR[pin]}: up={u} down={d} -> hard LOW: ** SHORTED TO GND **")
|
|
||||||
fails.append(f"{name} ({j4} -> BCM{pin}/{HDR[pin]}) is shorted low - an FPGA input with "
|
|
||||||
f"PULLUP=1 cannot read a hard 0")
|
|
||||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 66)
|
|
||||||
print("T3 Bridge scan on the Pi-driven lines (catches solder bridges).")
|
|
||||||
print(" The aggressor drives LOW and victims are pulled UP: a strong")
|
|
||||||
print(" driver beats any pull, so a victim dragged low IS bridged.")
|
|
||||||
print(" (Driving HIGH against pull-downs does NOT work here -- the FPGA's")
|
|
||||||
print(" SB_IO pull-up out-pulls the Pi's internal pull-down, which would")
|
|
||||||
print(" report every pin as bridged. T2 shows this directly: down=1.)")
|
|
||||||
print("=" * 66)
|
|
||||||
for pin, name, j4 in DRIVEN_BY_PI:
|
|
||||||
victims = [(p2, n) for p2, n, _ in DRIVEN_BY_PI if p2 != pin]
|
|
||||||
for vp, _ in victims:
|
|
||||||
GPIO.setup(vp, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
|
||||||
GPIO.setup(pin, GPIO.OUT)
|
|
||||||
GPIO.output(pin, 0) # aggressor drives LOW
|
|
||||||
time.sleep(0.003)
|
|
||||||
bad = [n for vp, n in victims if GPIO.input(vp) == 0]
|
|
||||||
GPIO.output(pin, 1)
|
|
||||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
if bad:
|
|
||||||
print(f" drive {name:<5} LOW -> ** BRIDGED to {', '.join(bad)} **")
|
|
||||||
fails.append(f"{name} is bridged to {', '.join(bad)}")
|
|
||||||
else:
|
|
||||||
print(f" drive {name:<5} LOW -> no bridge to {', '.join(n for _, n in victims)}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 66)
|
|
||||||
print("T4 Liveness: assert CS_N, clock slowly, see if MISO ever moves.")
|
|
||||||
print(" Proves CLK + CS_N reach the FPGA and MISO comes back, without")
|
|
||||||
print(" depending on a correct transaction.")
|
|
||||||
print("=" * 66)
|
|
||||||
for p in (CLK, CS_N, MOSI):
|
|
||||||
GPIO.setup(p, GPIO.OUT)
|
|
||||||
GPIO.output(CLK, 1) # Mode 3: CLK idles HIGH
|
|
||||||
GPIO.output(MOSI, 0)
|
|
||||||
GPIO.output(CS_N, 1)
|
|
||||||
GPIO.setup(MISO, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
|
||||||
time.sleep(0.01)
|
|
||||||
idle = GPIO.input(MISO)
|
|
||||||
GPIO.output(CS_N, 0) # assert
|
|
||||||
time.sleep(0.005)
|
|
||||||
seen = {GPIO.input(MISO)}
|
|
||||||
for _ in range(32):
|
|
||||||
GPIO.output(CLK, 0); time.sleep(0.001)
|
|
||||||
seen.add(GPIO.input(MISO))
|
|
||||||
GPIO.output(CLK, 1); time.sleep(0.001)
|
|
||||||
seen.add(GPIO.input(MISO))
|
|
||||||
GPIO.output(CS_N, 1)
|
|
||||||
GPIO.output(CLK, 1)
|
|
||||||
print(f" MISO idle (CS_N high) = {idle}; levels seen while clocking = {sorted(seen)}")
|
|
||||||
if len(seen) > 1:
|
|
||||||
print(" -> MISO CHANGES while clocking: the FPGA is responding. Chain alive.")
|
|
||||||
else:
|
|
||||||
print(" -> MISO never changed. Either the FPGA isn't seeing CLK/CS_N, or")
|
|
||||||
print(" it has nothing to send yet. Not conclusive on its own; the")
|
|
||||||
print(" device-ID read is the real test.")
|
|
||||||
warns.append("MISO static while clocking")
|
|
||||||
|
|
||||||
GPIO.cleanup()
|
|
||||||
print()
|
|
||||||
print("=" * 66)
|
|
||||||
if fails:
|
|
||||||
print("WIRING FAULTS:")
|
|
||||||
for f in fails:
|
|
||||||
print(" -", f)
|
|
||||||
if warns:
|
|
||||||
print("ADVISORY:")
|
|
||||||
for w in warns:
|
|
||||||
print(" -", w)
|
|
||||||
if not fails:
|
|
||||||
print("No hard wiring faults found." if not warns else
|
|
||||||
"No hard wiring faults; see advisories above.")
|
|
||||||
print("Next: sudo python3 ~/exi_devid_rpi.py (expect 04 02 02 00)")
|
|
||||||
sys.exit(1 if fails else 0)
|
|
||||||
Reference in New Issue
Block a user