diff --git a/axi/README.md b/axi/README.md index d5c2c23d23..3629633638 100644 --- a/axi/README.md +++ b/axi/README.md @@ -4,7 +4,7 @@ This tree contains reusable AXI-family RTL and wrappers. Top-level `axi/ruckus.t ## Layout -- `axi-lite/`: AXI-Lite records, crossbars, endpoints, masters, slaves, monitors, and IP-integrator adapters. +- [`axi-lite/`](axi-lite/README.md): AXI-Lite records, crossbars, endpoints, masters, slaves, monitors, and IP-integrator adapters. - `axi-stream/`: AXI Stream records, FIFOs, muxes, monitors, protocol adapters, and stream wrappers. - `axi4/`: full AXI4 support blocks and adapters. - `bridge/`: bridges between AXI-family buses and SURF protocol records. diff --git a/axi/axi-lite/README.md b/axi/axi-lite/README.md new file mode 100644 index 0000000000..670a8386d0 --- /dev/null +++ b/axi/axi-lite/README.md @@ -0,0 +1,52 @@ +# AXI-Lite + +This directory contains reusable AXI-Lite records, interconnects, endpoints, +clock-domain bridges, and IP-integrator adapters. + +## Layout + +- `rtl/`: AXI-Lite packages and synthesizable cores. +- `ip_integrator/`: flattened wrappers for block-design and simulator-facing + integration. +- `tb/`: legacy VHDL testbenches. + +Executable cocotb regressions live under +[`tests/axi/axi_lite/`](../../tests/axi/axi_lite/README.md). + +## `AxiLiteAsync` Contract + +`AxiLiteAsync` directly connects the slave and master interfaces when +`COMMON_CLK_G = true`. Otherwise, five asynchronous FIFOs carry the read +request, read response, write address, write data, and write response channels. + +The asynchronous bridge permits one read and one write in flight. The write +address and data channels remain independent, so either may arrive first, but +each channel accepts only one pending beat until the write response completes. +The bridge enforces this limit with its READY outputs. External masters that +pipeline requests are therefore backpressured rather than buffered to the FIFO +depth. + +All five FIFOs share a registered reset request and are flushed when either AXI +domain resets. Reset handling follows these rules: + +- A slave/source-domain reset abandons outstanding source transactions; no + response is owed after that reset. +- A master/destination-domain reset while the slave domain remains active + completes each accepted read locally with `AXI_ERROR_RESP_G`. +- A locally completed write returns `AXI_ERROR_RESP_G` only after both its AW + and W beats have been accepted. +- Transactions discarded by reset are not replayed when the master domain + recovers, and stale responses do not survive a slave-domain reset. + +A slave/source-domain reset can flush only transactions still held by the +bridge. It cannot retract a write-address or write-data beat that the +downstream slave has already accepted. If a system must prevent a partially +accepted downstream write from surviving reset, it must quiesce AXI-Lite +traffic before resetting or coordinate reset of the destination domain. + +If a clock is unavailable, its corresponding reset must remain asserted. The +clock must be stable before reset is released, and traffic must remain inactive +until synchronized reset release completes. + +`AxiLiteAsyncIpIntegrator.vhd` exposes the same clocks, resets, and response +codes through flattened AXI-Lite ports. diff --git a/axi/axi-lite/rtl/AxiLiteAsync.vhd b/axi/axi-lite/rtl/AxiLiteAsync.vhd index 4fbf5edb8c..507ae675fa 100755 --- a/axi/axi-lite/rtl/AxiLiteAsync.vhd +++ b/axi/axi-lite/rtl/AxiLiteAsync.vhd @@ -51,7 +51,7 @@ end AxiLiteAsync; architecture STRUCTURE of AxiLiteAsync is - signal s2mRst : sl; -- Slave rst sync'd to master clk + signal sRst : sl; -- Slave rst sync'd to slave clk signal m2sRst : sl; -- Master rst sync'd to slave clk signal readSlaveToMastDin : slv(NUM_ADDR_BITS_G+2 downto 0); @@ -89,6 +89,40 @@ architecture STRUCTURE of AxiLiteAsync is signal writeMastToSlaveRead : sl; signal writeMastToSlaveWrite : sl; + -- Depth of every channel FIFO instantiated below + constant FIFO_ADDR_WIDTH_C : positive := 4; + + -- Reset terms normalized to active HIGH, independent of RST_POLARITY_G + signal m2sRstActive : sl; + signal mAxiRstActive : sl; + + -- Registered active-HIGH reset request for every FIFO in the bridge + signal fifoRstReq : sl; + signal fifoRst : sl; + + -- Slave side handshakes, kept local so the error responder can observe them + signal sArReady : sl; + signal sRValid : sl; + signal sAwReady : sl; + signal sWReady : sl; + signal sBValid : sl; + + type RegType is record + errMode : sl; -- Answering locally with AXI_ERROR_RESP_G + rPend : sl; -- Read accepted, not yet answered + awPend : sl; -- Write address accepted, not yet answered + wPend : sl; -- Write data accepted, not yet answered + end record RegType; + + constant REG_INIT_C : RegType := ( + errMode => '0', + rPend => '0', + awPend => '0', + wPend => '0'); + + signal r : RegType := REG_INIT_C; + signal rin : RegType; + begin GEN_SYNC : if (COMMON_CLK_G = true) generate @@ -102,18 +136,18 @@ begin GEN_ASYNC : if (COMMON_CLK_G = false) generate - -- Synchronize each reset across to the other clock domain - LOC_S2M_RstSync : entity surf.RstSync + -- Synchronize the local reset release before it controls fifoRst + LOC_S_RstSync : entity surf.RstSync generic map ( TPD_G => TPD_G, IN_POLARITY_G => RST_POLARITY_G, - OUT_POLARITY_G => RST_POLARITY_G, - OUT_REG_RST_G => false) + OUT_POLARITY_G => RST_POLARITY_G) port map ( - clk => mAxiClk, + clk => sAxiClk, asyncRst => sAxiClkRst, - syncRst => s2mRst); + syncRst => sRst); + -- Synchronize the remote reset into the slave/control clock domain LOC_M2S_RstSync : entity surf.RstSync generic map ( TPD_G => TPD_G, @@ -125,6 +159,112 @@ begin asyncRst => mAxiClkRst, syncRst => m2sRst); + -- Normalize reset indications to active HIGH + m2sRstActive <= '1' when (m2sRst = RST_POLARITY_G) else '0'; + mAxiRstActive <= '1' when (mAxiClkRst = RST_POLARITY_G) else '0'; + + -- Build one glitch-free FIFO reset request in the slave/control domain. + -- The local reset asserts it asynchronously, so the FIFOs are reset even + -- if sAxiClk is stopped. The remote reset is synchronized above before it + -- sets this register. Deassertion is synchronous and delayed until error + -- mode has drained every abandoned transaction. FifoAsync then + -- resynchronizes this single registered request into both FIFO domains. + fifoRstReq <= m2sRstActive or r.errMode; + + U_FifoRstReg : entity surf.RegisterVector + generic map ( + TPD_G => TPD_G, + RST_POLARITY_G => RST_POLARITY_G, + RST_ASYNC_G => true, + WIDTH_G => 1, + INIT_G => "1") + port map ( + clk => sAxiClk, -- [in] + rst => sRst, -- [in] + sig_i(0) => fifoRstReq, -- [in] + reg_o(0) => fifoRst); -- [out] + + -- Transaction tracking and local error responder. + -- + -- One transaction per channel is in flight at a time, matching + -- AxiLiteCrossbar, whose per-slot state machine does not release a slot + -- until the response completes. The ready outputs below enforce that bound + -- rather than assuming the master honours it. + -- + -- The same state decides what the bridge owes the slave side while the + -- remote domain is in reset, when each access is answered locally instead + -- of being forwarded. That keeps AXI-Lite ordering intact, because a read + -- response only follows an accepted AR and a write response only follows + -- both an accepted AW and W, and it covers the transaction discarded by + -- fifoRst above, which still owes the slave side a response. + comb : process (m2sRstActive, r, sArReady, sAwReady, sAxiClkRst, + sAxiReadMaster, sAxiWriteMaster, sBValid, sRValid, sWReady) is + variable v : RegType; + variable arTxn : sl; + variable rTxn : sl; + variable awTxn : sl; + variable wTxn : sl; + variable bTxn : sl; + begin + -- Latch the current value + v := r; + + -- Slave side handshakes + arTxn := sAxiReadMaster.arvalid and sArReady; + rTxn := sRValid and sAxiReadMaster.rready; + awTxn := sAxiWriteMaster.awvalid and sAwReady; + wTxn := sAxiWriteMaster.wvalid and sWReady; + bTxn := sBValid and sAxiWriteMaster.bready; + + -- Read accepted but not yet answered. Set and clear are mutually + -- exclusive because ARREADY is held low while the read is pending. + if (arTxn = '1') then + v.rPend := '1'; + elsif (rTxn = '1') then + v.rPend := '0'; + end if; + + -- Write address accepted but not yet answered + if (awTxn = '1') then + v.awPend := '1'; + elsif (bTxn = '1') then + v.awPend := '0'; + end if; + + -- Write data accepted but not yet answered + if (wTxn = '1') then + v.wPend := '1'; + elsif (bTxn = '1') then + v.wPend := '0'; + end if; + + -- Enter error mode when the remote domain resets and stay there until + -- the abandoned transaction has been answered + if (m2sRstActive = '1') then + v.errMode := '1'; + elsif (v.rPend = '0') and (v.awPend = '0') and (v.wPend = '0') then + v.errMode := '0'; + end if; + + -- Synchronous Reset + if (RST_ASYNC_G = false) and (sAxiClkRst = RST_POLARITY_G) then + v := REG_INIT_C; + end if; + + -- Register the variable for the next clock cycle + rin <= v; + + end process comb; + + seq : process (sAxiClk, sAxiClkRst) is + begin + if (RST_ASYNC_G) and (sAxiClkRst = RST_POLARITY_G) then + r <= REG_INIT_C after TPD_G; + elsif rising_edge(sAxiClk) then + r <= rin after TPD_G; + end if; + end process seq; + ------------------------------------ -- Read: Slave to Master ------------------------------------ @@ -133,19 +273,19 @@ begin U_ReadSlaveToMastFifo : entity surf.FifoASync generic map ( TPD_G => TPD_G, - RST_POLARITY_G => RST_POLARITY_G, + RST_POLARITY_G => '1', RST_ASYNC_G => RST_ASYNC_G, MEMORY_TYPE_G => "distributed", -- Use Dist Ram FWFT_EN_G => true, SYNC_STAGES_G => 3, PIPE_STAGES_G => PIPE_STAGES_G, DATA_WIDTH_G => NUM_ADDR_BITS_G+3, - ADDR_WIDTH_G => 4, + ADDR_WIDTH_G => FIFO_ADDR_WIDTH_C, INIT_G => "0", FULL_THRES_G => 15, EMPTY_THRES_G => 1) port map ( - rst => s2mRst, + rst => fifoRst, wr_clk => sAxiClk, wr_en => readSlaveToMastWrite, din => readSlaveToMastDin, @@ -171,9 +311,12 @@ begin readSlaveToMastDin(2 downto 0) <= sAxiReadMaster.arprot; readSlaveToMastDin(NUM_ADDR_BITS_G+2 downto 3) <= sAxiReadMaster.araddr(NUM_ADDR_BITS_G-1 downto 0); - -- Write control and ready generation - sAxiReadSlave.arready <= ite(m2sRst = '0', not readSlaveToMastFull, '1'); - readSlaveToMastWrite <= sAxiReadMaster.arvalid and (not readSlaveToMastFull); + -- Write control and ready generation. The request is never queued while the + -- bridge is answering locally, otherwise an access already reported as + -- failed would still reach the master side. + sArReady <= (not r.rPend) when (r.errMode = '1') else ((not readSlaveToMastFull) and (not r.rPend)); + sAxiReadSlave.arready <= sArReady; + readSlaveToMastWrite <= sAxiReadMaster.arvalid and sArReady and (not r.errMode); -- Data Out mAxiReadMaster.arprot <= readSlaveToMastDout(2 downto 0); @@ -196,19 +339,19 @@ begin U_ReadMastToSlaveFifo : entity surf.FifoASync generic map ( TPD_G => TPD_G, - RST_POLARITY_G => RST_POLARITY_G, + RST_POLARITY_G => '1', RST_ASYNC_G => RST_ASYNC_G, MEMORY_TYPE_G => "distributed", -- Use Dist Ram FWFT_EN_G => true, SYNC_STAGES_G => 3, PIPE_STAGES_G => PIPE_STAGES_G, DATA_WIDTH_G => 34, - ADDR_WIDTH_G => 4, + ADDR_WIDTH_G => FIFO_ADDR_WIDTH_C, INIT_G => "0", FULL_THRES_G => 15, EMPTY_THRES_G => 1) port map ( - rst => m2sRst, + rst => fifoRst, wr_clk => mAxiClk, wr_en => readMastToSlaveWrite, din => readMastToSlaveDin, @@ -235,15 +378,17 @@ begin readMastToSlaveDin(33 downto 2) <= mAxiReadSlave.rdata; -- Write control and ready generation - mAxiReadMaster.rready <= ite(mAxiClkRst = '0', not readMastToSlaveFull, '1'); + mAxiReadMaster.rready <= '1' when (mAxiRstActive = '1') else (not readMastToSlaveFull); readMastToSlaveWrite <= mAxiReadSlave.rvalid and (not readMastToSlaveFull); -- Data Out - sAxiReadSlave.rresp <= ite(m2sRst = '0', readMastToSlaveDout(1 downto 0), AXI_ERROR_RESP_G); + sAxiReadSlave.rresp <= AXI_ERROR_RESP_G when (r.errMode = '1') else readMastToSlaveDout(1 downto 0); sAxiReadSlave.rdata <= readMastToSlaveDout(33 downto 2); - -- Read control and valid - sAxiReadSlave.rvalid <= ite(m2sRst = '0', readMastToSlaveValid, '1'); + -- Read control and valid. Answering locally requires an accepted AR, so the + -- response can never arrive ahead of its request. + sRValid <= r.rPend when (r.errMode = '1') else readMastToSlaveValid; + sAxiReadSlave.rvalid <= sRValid; readMastToSlaveRead <= sAxiReadMaster.rready; ------------------------------------ @@ -254,19 +399,19 @@ begin U_WriteAddrSlaveToMastFifo : entity surf.FifoASync generic map ( TPD_G => TPD_G, - RST_POLARITY_G => RST_POLARITY_G, + RST_POLARITY_G => '1', RST_ASYNC_G => RST_ASYNC_G, MEMORY_TYPE_G => "distributed", -- Use Dist Ram FWFT_EN_G => true, SYNC_STAGES_G => 3, PIPE_STAGES_G => PIPE_STAGES_G, DATA_WIDTH_G => NUM_ADDR_BITS_G+3, - ADDR_WIDTH_G => 4, + ADDR_WIDTH_G => FIFO_ADDR_WIDTH_C, INIT_G => "0", FULL_THRES_G => 15, EMPTY_THRES_G => 1) port map ( - rst => s2mRst, + rst => fifoRst, wr_clk => sAxiClk, wr_en => writeAddrSlaveToMastWrite, din => writeAddrSlaveToMastDin, @@ -293,8 +438,9 @@ begin writeAddrSlaveToMastDin(NUM_ADDR_BITS_G+2 downto 3) <= sAxiWriteMaster.awaddr(NUM_ADDR_BITS_G-1 downto 0); -- Write control and ready generation - sAxiWriteSlave.awready <= ite(m2sRst = '0', not writeAddrSlaveToMastFull, '1'); - writeAddrSlaveToMastWrite <= sAxiWriteMaster.awvalid and (not writeAddrSlaveToMastFull); + sAwReady <= (not r.awPend) when (r.errMode = '1') else ((not writeAddrSlaveToMastFull) and (not r.awPend)); + sAxiWriteSlave.awready <= sAwReady; + writeAddrSlaveToMastWrite <= sAxiWriteMaster.awvalid and sAwReady and (not r.errMode); -- Data Out mAxiWriteMaster.awprot <= writeAddrSlaveToMastDout(2 downto 0); @@ -317,19 +463,19 @@ begin U_WriteDataSlaveToMastFifo : entity surf.FifoASync generic map ( TPD_G => TPD_G, - RST_POLARITY_G => RST_POLARITY_G, + RST_POLARITY_G => '1', RST_ASYNC_G => RST_ASYNC_G, MEMORY_TYPE_G => "distributed", -- Use Dist Ram FWFT_EN_G => true, SYNC_STAGES_G => 3, PIPE_STAGES_G => PIPE_STAGES_G, DATA_WIDTH_G => 36, - ADDR_WIDTH_G => 4, + ADDR_WIDTH_G => FIFO_ADDR_WIDTH_C, INIT_G => "0", FULL_THRES_G => 15, EMPTY_THRES_G => 1) port map ( - rst => s2mRst, + rst => fifoRst, wr_clk => sAxiClk, wr_en => writeDataSlaveToMastWrite, din => writeDataSlaveToMastDin, @@ -356,8 +502,9 @@ begin writeDataSlaveToMastDin(35 downto 4) <= sAxiWriteMaster.wdata; -- Write control and ready generation - sAxiWriteSlave.wready <= ite(m2sRst = '0', not writeDataSlaveToMastFull, '1'); - writeDataSlaveToMastWrite <= sAxiWriteMaster.wvalid and (not writeDataSlaveToMastFull); + sWReady <= (not r.wPend) when (r.errMode = '1') else ((not writeDataSlaveToMastFull) and (not r.wPend)); + sAxiWriteSlave.wready <= sWReady; + writeDataSlaveToMastWrite <= sAxiWriteMaster.wvalid and sWReady and (not r.errMode); -- Data Out mAxiWriteMaster.wstrb <= writeDataSlaveToMastDout(3 downto 0); @@ -375,19 +522,19 @@ begin U_WriteMastToSlaveFifo : entity surf.FifoASync generic map ( TPD_G => TPD_G, - RST_POLARITY_G => RST_POLARITY_G, + RST_POLARITY_G => '1', RST_ASYNC_G => RST_ASYNC_G, MEMORY_TYPE_G => "distributed", -- Use Dist Ram FWFT_EN_G => true, SYNC_STAGES_G => 3, PIPE_STAGES_G => PIPE_STAGES_G, DATA_WIDTH_G => 2, - ADDR_WIDTH_G => 4, + ADDR_WIDTH_G => FIFO_ADDR_WIDTH_C, INIT_G => "0", FULL_THRES_G => 15, EMPTY_THRES_G => 1) port map ( - rst => m2sRst, + rst => fifoRst, wr_clk => mAxiClk, wr_en => writeMastToSlaveWrite, din => writeMastToSlaveDin, @@ -417,10 +564,12 @@ begin writeMastToSlaveWrite <= mAxiWriteSlave.bvalid and (not writeMastToSlaveFull); -- Data Out - sAxiWriteSlave.bresp <= ite(m2sRst = '0', writeMastToSlaveDout, AXI_ERROR_RESP_G); + sAxiWriteSlave.bresp <= AXI_ERROR_RESP_G when (r.errMode = '1') else writeMastToSlaveDout; - -- Read control and valid - sAxiWriteSlave.bvalid <= ite(m2sRst = '0', writeMastToSlaveValid, '1'); + -- Read control and valid. Answering locally requires both an accepted AW and + -- an accepted W, so the two channels can still arrive in either order. + sBValid <= (r.awPend and r.wPend) when (r.errMode = '1') else writeMastToSlaveValid; + sAxiWriteSlave.bvalid <= sBValid; writeMastToSlaveRead <= sAxiWriteMaster.bready; end generate; diff --git a/tests/axi/axi_lite/README.md b/tests/axi/axi_lite/README.md new file mode 100644 index 0000000000..4412ce4eeb --- /dev/null +++ b/tests/axi/axi_lite/README.md @@ -0,0 +1,33 @@ +# AXI-Lite Regressions + +This directory contains cocotb regressions for the reusable AXI-Lite cores in +[`axi/axi-lite/`](../../../axi/axi-lite/README.md). Tests use thin +IP-integrator wrappers where a flattened simulator interface is needed. + +Run the directory with: + +```bash +make MODULES="$PWD" import +./.venv/bin/python -m pytest -n auto --dist=worksteal -q tests/axi/axi_lite +``` + +Use `-n 0` with a single test file when serial simulator logs are useful. + +## `AxiLiteAsync` + +`test_AxiLiteAsync.py` covers common-clock pass-through and four asynchronous +configurations: active-high reset, active-low reset, asynchronous reset, and +pipelined FIFO outputs. Its scenarios verify: + +- ordinary read/write round trips and recovery after reset; +- local error responses while the master domain is reset; +- no replay of rejected or already queued requests after recovery; +- no stale response after a slave-domain reset; +- correct AW/W ordering when a reset splits a write transaction; and +- the one-pending-beat limit on AR, AW, and W, including VALID held while READY + is low. + +The asynchronous reset tests use a gateable master clock so the slave domain +can remain live while the remote domain is unavailable. Accepted handshakes are +monitored on both sides; final memory contents alone are not used to infer that +a rejected transaction stayed out of the downstream interface. diff --git a/tests/axi/axi_lite/test_AxiLiteAsync.py b/tests/axi/axi_lite/test_AxiLiteAsync.py index 6708c05bf6..0b186b0522 100644 --- a/tests/axi/axi_lite/test_AxiLiteAsync.py +++ b/tests/axi/axi_lite/test_AxiLiteAsync.py @@ -9,27 +9,27 @@ ############################################################################## # Test methodology: -# - Sweep: Keep a narrow common-clock wrapper-focused case that proves the -# cocotb-facing bridge topology and stable pass-through behavior without -# trying to force the less simulator-stable asynchronous reset branches into -# the initial regression batch. -# - Stimulus: Drive AXI-Lite writes and reads through the slave-side port into -# a cocotb RAM attached to the master-side port, then assert only the master +# - Sweep: Cover common-clock pass-through plus asynchronous active-high, +# active-low, asynchronous-reset, and pipelined FIFO configurations. +# - Stimulus: Drive AXI-Lite writes and reads through the slave-side port into a +# cocotb RAM attached to the master-side port, then assert only the master # reset while the slave side remains live in the asynchronous case. # - Checks: Successful transactions must round-trip through the bridge into the -# backing RAM, common-clock reset must restart the path cleanly, and -# post-reset traffic must recover without stale responses. +# backing RAM, common-clock reset must restart the path cleanly, post-reset +# traffic must recover without stale responses, requests held while READY is +# low must not cross the bridge, and a transaction rejected while the master +# domain is reset must never execute downstream afterwards. # - Timing: The bench drives both bridge clocks from one lockstep coroutine so -# `COMMON_CLK_G=true` is exercised as a true shared-clock configuration. +# `COMMON_CLK_G=true` is exercised as a true shared-clock configuration. The +# asynchronous case drives mAxiClk from a gateable coroutine so the test can +# hold the master domain still while the slave domain keeps running. import os import cocotb import pytest from cocotb.clock import Clock -from cocotb.triggers import RisingEdge, Timer - -from tests.common.regression_utils import sample_after_tpd +from cocotb.triggers import RisingEdge, Timer, with_timeout from cocotbext.axi import AxiLiteBus, AxiLiteMaster, AxiResp from tests.common.regression_utils import ( @@ -38,33 +38,140 @@ env_sl, parameter_case, run_surf_vhdl_test, + sample_after_tpd, start_lockstep_clocks, ) -class TB: +# cocotb resolves `skip` when the decorator runs, so the configuration has to be +# read at import time rather than from inside a test body. +COMMON_CLK = env_flag("COMMON_CLK_G", default=False) + +# Bound every slave-side transaction so a missing fail-fast response is reported +# as a test failure instead of hanging the regression. +TXN_TIMEOUT_US = 20 + +# Distinct addresses keep the baseline, rejected, and recovery accesses from +# aliasing each other in the backing RAM. +BASELINE_ADDR = 0x040 +REJECTED_WRITE_ADDR = 0x044 +REJECTED_READ_ADDR = 0x048 +RECOVERY_ADDR = 0x04C + + +class GatedClock: + """Free-running clock that the test can stop and restart. + + `cocotb.clock.Clock` cannot be paused, and the remote-reset scenario needs + mAxiClk held low while sAxiClk keeps running. + """ + + def __init__(self, signal, period_ns): + self.signal = signal + self.half_period_ns = period_ns / 2 + self.enabled = True + signal.setimmediatevalue(0) + self._clock_task = cocotb.start_soon(self._drive()) + + async def _drive(self): + """Lifetime agent: drive the gateable clock until cocotb ends the test.""" + while True: + await Timer(self.half_period_ns, unit="ns") + if not self.enabled: + self.signal.value = 0 + continue + self.signal.value = 1 + await Timer(self.half_period_ns, unit="ns") + self.signal.value = 0 + + def stop(self): + self.enabled = False + self.signal.value = 0 + + def start(self): + self.enabled = True + + +class SourcePortMonitor: + """Counts handshakes on the slave-side AXI-Lite port. + + The bridge must never present a response that the slave side did not ask + for, so the test compares accepted requests against completed responses. + """ + def __init__(self, dut): self.dut = dut - self.common_clk = env_flag("COMMON_CLK_G", default=False) + self.counts = {"AR": 0, "R": 0, "AW": 0, "W": 0, "B": 0} + self._monitor_task = cocotb.start_soon(self._run()) + + @staticmethod + def _high(signal) -> bool: + try: + return int(signal.value) == 1 + except ValueError: + return False + + def _handshake(self, valid, ready) -> bool: + return self._high(valid) and self._high(ready) + + async def _run(self): + """Lifetime agent: monitor source handshakes until cocotb ends the test.""" + dut = self.dut + channels = ( + ("AR", dut.S_AXI_ARVALID, dut.S_AXI_ARREADY), + ("R", dut.S_AXI_RVALID, dut.S_AXI_RREADY), + ("AW", dut.S_AXI_AWVALID, dut.S_AXI_AWREADY), + ("W", dut.S_AXI_WVALID, dut.S_AXI_WREADY), + ("B", dut.S_AXI_BVALID, dut.S_AXI_BREADY), + ) + while True: + # Sample at the clock edge, before combinational logic reacts to it. + await RisingEdge(dut.sAxiClk) + for name, valid, ready in channels: + if self._handshake(valid, ready): + self.counts[name] += 1 + + +class TB: + def __init__(self, dut, drive_master=True): + self.dut = dut + self.common_clk = COMMON_CLK self.pipe_stages = int(os.environ["PIPE_STAGES_G"]) self.reset_active = env_sl("RST_POLARITY_G", default=1) + self.m_clk = None if self.common_clk: self._clock_task = start_lockstep_clocks(dut.sAxiClk, dut.mAxiClk, period_ns=6.0) else: - cocotb.start_soon(Clock(dut.sAxiClk, 8.0, unit="ns").start()) - cocotb.start_soon(Clock(dut.mAxiClk, 5.0, unit="ns").start()) + self._clock_task = cocotb.start_soon(Clock(dut.sAxiClk, 8.0, unit="ns").start()) + # Gateable so the remote-reset test can hold the master domain still. + self.m_clk = GatedClock(dut.mAxiClk, 5.0) dut.sAxiClkRst.setimmediatevalue(self.reset_active_value()) dut.mAxiClkRst.setimmediatevalue(self.reset_active_value()) - self.axil = AxiLiteMaster( - bus=AxiLiteBus.from_prefix(dut, "S_AXI"), - clock=dut.sAxiClk, - reset=dut.sAxiClkRst, - reset_active_level=bool(self.reset_active), - ) + if drive_master: + self.axil = AxiLiteMaster( + bus=AxiLiteBus.from_prefix(dut, "S_AXI"), + clock=dut.sAxiClk, + reset=dut.sAxiClkRst, + reset_active_level=bool(self.reset_active), + ) + else: + # Channel level tests drive the slave side port by hand, so the + # cocotbext master must not be driving the same signals. + self.axil = None + for signal in ( + dut.S_AXI_AWADDR, dut.S_AXI_AWPROT, dut.S_AXI_AWVALID, + dut.S_AXI_WDATA, dut.S_AXI_WSTRB, dut.S_AXI_WVALID, + dut.S_AXI_BREADY, + dut.S_AXI_ARADDR, dut.S_AXI_ARPROT, dut.S_AXI_ARVALID, + dut.S_AXI_RREADY, + ): + signal.setimmediatevalue(0) + self.slave = SimpleAxiLiteSlave(dut, self.reset_active) + self.source = SourcePortMonitor(dut) async def close(self) -> None: await self.slave.close() @@ -88,6 +195,54 @@ async def m_cycle(self, count=1): await RisingEdge(self.dut.mAxiClk) await self.settle() + async def write(self, addr, payload): + # Every slave-side access is bounded; a bridge that never answers is a + # failure, not a reason to stall the regression. + return await with_timeout( + self.axil.write(addr, payload), TXN_TIMEOUT_US, "us" + ) + + async def read(self, addr, length): + return await with_timeout( + self.axil.read(addr, length), TXN_TIMEOUT_US, "us" + ) + + async def drive_handshake(self, valid, ready, what, limit=64): + # Hold valid until the edge where ready is also high, sampling ready + # before the clock edge so the check matches the transfer itself. + valid.value = 1 + for _ in range(limit): + await RisingEdge(self.dut.sAxiClk) + accepted = int(ready.value) == 1 + await self.settle() + if accepted: + valid.value = 0 + return + valid.value = 0 + raise AssertionError(f"{what} was never accepted") + + async def await_high(self, signal, what, limit=64): + for _ in range(limit): + await self.s_cycle() + if int(signal.value) == 1: + return + raise AssertionError(f"{what} never asserted") + + async def consume(self, valid, ready, what, limit=128): + # Mirror of drive_handshake for the response direction: raise ready until + # the edge where valid is also high, then drop it again. + ready.value = 1 + for _ in range(limit): + await RisingEdge(self.dut.sAxiClk) + taken = int(valid.value) == 1 + await self.settle() + if taken: + ready.value = 0 + await self.settle() + return + ready.value = 0 + raise AssertionError(f"{what} was never returned") + async def reset(self): # Hold both domains in reset together so the bridge and RAM start from # a known empty state before each scenario. @@ -114,6 +269,9 @@ def __init__(self, dut, reset_active): self.dut = dut self.reset_active = reset_active self.mem = {} + # Ordered record of everything this slave accepted, so the test can + # prove a rejected request never reached the far side of the bridge. + self.handshakes = [] dut.M_AXI_AWREADY.setimmediatevalue(0) dut.M_AXI_WREADY.setimmediatevalue(0) @@ -139,6 +297,9 @@ def in_reset(self) -> bool: except ValueError: return True + def addresses_seen(self, channel) -> list: + return [value for kind, value in self.handshakes if kind == channel] + async def cycle(self, count=1): for _ in range(count): await sample_after_tpd(self.dut.mAxiClk) @@ -166,6 +327,7 @@ async def _run_write(self): self.dut.M_AXI_AWREADY.value = 1 await self.cycle(1) self.dut.M_AXI_AWREADY.value = 0 + self.handshakes.append(("AW", awaddr)) while not int(self.dut.M_AXI_WVALID.value): await self._wait_while_reset() @@ -185,6 +347,7 @@ async def _run_write(self): self.dut.M_AXI_WREADY.value = 1 await self.cycle(1) self.dut.M_AXI_WREADY.value = 0 + self.handshakes.append(("W", wdata)) self.dut.M_AXI_BRESP.value = int(AxiResp.OKAY) self.dut.M_AXI_BVALID.value = 1 @@ -209,6 +372,7 @@ async def _run_read(self): self.dut.M_AXI_ARREADY.value = 1 await self.cycle(1) self.dut.M_AXI_ARREADY.value = 0 + self.handshakes.append(("AR", araddr)) self.dut.M_AXI_RDATA.value = self.mem.get(araddr, 0) self.dut.M_AXI_RRESP.value = int(AxiResp.OKAY) @@ -236,11 +400,11 @@ async def bridge_round_trip_test(dut): # Sweep a few aligned accesses so the test proves the slave-side bus # can drive data through the bridge into the master-side backing RAM. for addr, payload in transactions: - wr_txn = await tb.axil.write(addr, payload) + wr_txn = await tb.write(addr, payload) assert wr_txn.resp == AxiResp.OKAY assert tb.slave.mem[addr].to_bytes(4, "little")[: len(payload)] == payload - rd_txn = await tb.axil.read(addr, len(payload)) + rd_txn = await tb.read(addr, len(payload)) assert rd_txn.resp == AxiResp.OKAY assert rd_txn.data == payload finally: @@ -254,9 +418,9 @@ async def reset_behavior_test(dut): await tb.reset() baseline = b"\x5A\xA5\xC3\x3C" - wr_txn = await tb.axil.write(0x020, baseline) + wr_txn = await tb.write(0x020, baseline) assert wr_txn.resp == AxiResp.OKAY - rd_txn = await tb.axil.read(0x020, len(baseline)) + rd_txn = await tb.read(0x020, len(baseline)) assert rd_txn.resp == AxiResp.OKAY assert rd_txn.data == baseline @@ -270,18 +434,618 @@ async def reset_behavior_test(dut): await tb.s_cycle(3) tb.dut.sAxiClkRst.value = self_release tb.dut.mAxiClkRst.value = self_release - await tb.s_cycle(3) + # Both resets are released together here, so in the asynchronous case + # each one still has to cross into the opposite domain before the bridge + # stops reporting the remote side as reset. + await tb.s_cycle(16) + await tb.m_cycle(16) recovery = b"\x89\x67\x45\x23" - wr_txn = await tb.axil.write(0x024, recovery) + wr_txn = await tb.write(0x024, recovery) assert wr_txn.resp == AxiResp.OKAY - rd_txn = await tb.axil.read(0x024, len(recovery)) + rd_txn = await tb.read(0x024, len(recovery)) assert rd_txn.resp == AxiResp.OKAY assert rd_txn.data == recovery finally: await tb.close() +@cocotb.test(skip=COMMON_CLK) +async def remote_reset_ghost_test(dut): + """A transaction rejected while the remote domain is reset must never run. + + `COMMON_CLK_G=true` reduces the bridge to direct pass-through with no request + FIFOs, so this scenario only exists in the asynchronous configuration. + """ + tb = TB(dut) + await tb.reset() + + # Prove the bridge is healthy before the fault is injected. + baseline = b"\x01\x02\x03\x04" + wr_txn = await tb.write(BASELINE_ADDR, baseline) + assert wr_txn.resp == AxiResp.OKAY + rd_txn = await tb.read(BASELINE_ADDR, len(baseline)) + assert rd_txn.resp == AxiResp.OKAY + assert rd_txn.data == baseline + + # Let the downstream slave model return to its idle state before its clock + # is taken away. + await tb.m_cycle(4) + + # Stop the master clock, then assert only the master reset. The slave domain + # keeps running, so the bridge has to fail these accesses locally. + tb.m_clk.stop() + tb.dut.mAxiClkRst.value = tb.reset_active_value() + + # Give mAxiClkRst time to synchronize into the slave domain. + await tb.s_cycle(16) + + downstream_before = list(tb.slave.handshakes) + mem_before = dict(tb.slave.mem) + + # Both accesses must fail fast, inside the bounded transaction timeout. + rejected = b"\xDE\xAD\xBE\xEF" + wr_txn = await tb.write(REJECTED_WRITE_ADDR, rejected) + assert wr_txn.resp == AxiResp.SLVERR, ( + f"write during remote reset returned {wr_txn.resp!r}, expected SLVERR" + ) + rd_txn = await tb.read(REJECTED_READ_ADDR, 4) + assert rd_txn.resp == AxiResp.SLVERR, ( + f"read during remote reset returned {rd_txn.resp!r}, expected SLVERR" + ) + + # Nothing can have reached the downstream slave yet: its clock is stopped. + assert tb.slave.handshakes == downstream_before, ( + "downstream slave saw activity while mAxiClk was stopped" + ) + assert tb.slave.mem == mem_before, ( + "downstream memory changed while mAxiClk was stopped" + ) + + # Restart the master clock while the master reset is still asserted. + tb.m_clk.start() + await tb.m_cycle(8) + + # Release the master reset and let both sides finish coming out of reset. + tb.dut.mAxiClkRst.value = tb.reset_inactive_value() + await tb.m_cycle(16) + await tb.s_cycle(16) + + # The rejected requests must not have been replayed downstream. + replayed = [ + entry + for entry in tb.slave.handshakes[len(downstream_before):] + if entry in (("AW", REJECTED_WRITE_ADDR), ("AR", REJECTED_READ_ADDR)) + ] + assert not replayed, ( + f"request rejected with SLVERR was replayed downstream after recovery: {replayed}" + ) + assert REJECTED_WRITE_ADDR not in tb.slave.mem, ( + "write rejected with SLVERR modified downstream memory after recovery" + ) + assert tb.slave.mem == mem_before, ( + "downstream memory changed after recovery without a new transaction" + ) + + # A fresh access must still work, and must not consume a stale response left + # over from the rejected pair. + recovery = b"\x0F\x1E\x2D\x3C" + wr_txn = await tb.write(RECOVERY_ADDR, recovery) + assert wr_txn.resp == AxiResp.OKAY + rd_txn = await tb.read(RECOVERY_ADDR, len(recovery)) + assert rd_txn.resp == AxiResp.OKAY + assert rd_txn.data == recovery + assert tb.slave.mem[RECOVERY_ADDR].to_bytes(4, "little") == recovery + + # Every response the bridge produced has to map to a request it accepted. + counts = tb.source.counts + assert counts["R"] == counts["AR"], ( + f"bridge returned {counts['R']} read responses for {counts['AR']} accepted " + "read requests" + ) + assert counts["B"] == counts["AW"], ( + f"bridge returned {counts['B']} write responses for {counts['AW']} accepted " + "write addresses" + ) + assert counts["W"] == counts["AW"], ( + f"bridge accepted {counts['W']} write data beats for {counts['AW']} accepted " + "write addresses" + ) + + +@cocotb.test(skip=COMMON_CLK) +async def remote_reset_write_order_test(dut): + """A local write response must wait for both AW and W, arriving separately. + + The bridge carries the write address and the write data in separate FIFOs, so + the error response has to be paired explicitly instead of being asserted as + soon as the remote domain resets. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Hold the remote domain still and in reset. + tb.m_clk.stop() + dut.mAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(16) + + # Present the write address on its own. + dut.S_AXI_AWADDR.value = REJECTED_WRITE_ADDR + await tb.drive_handshake( + dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "AW during remote reset" + ) + + # No write data has been accepted yet, so there must be no write response. + for _ in range(8): + await tb.s_cycle() + assert int(dut.S_AXI_BVALID.value) == 0, ( + "write response asserted before any write data was accepted" + ) + + # Now present the write data. + dut.S_AXI_WDATA.value = 0xA5A5A5A5 + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake( + dut.S_AXI_WVALID, dut.S_AXI_WREADY, "W during remote reset" + ) + + # The paired response must appear, and must carry the error code. + await tb.await_high(dut.S_AXI_BVALID, "write response during remote reset") + assert int(dut.S_AXI_BRESP.value) == int(AxiResp.SLVERR), ( + f"write response during remote reset carried {int(dut.S_AXI_BRESP.value)}, " + f"expected {int(AxiResp.SLVERR)}" + ) + + # Accept it, then confirm a single write produced a single response. + dut.S_AXI_BREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_BREADY.value = 0 + for _ in range(8): + await tb.s_cycle() + assert int(dut.S_AXI_BVALID.value) == 0, ( + "write response repeated for a single accepted write" + ) + + # The same pairing rule applies to reads: no response without an accepted AR. + for _ in range(8): + await tb.s_cycle() + assert int(dut.S_AXI_RVALID.value) == 0, ( + "read response asserted with no read address accepted" + ) + + dut.S_AXI_ARADDR.value = REJECTED_READ_ADDR + await tb.drive_handshake( + dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "AR during remote reset" + ) + await tb.await_high(dut.S_AXI_RVALID, "read response during remote reset") + assert int(dut.S_AXI_RRESP.value) == int(AxiResp.SLVERR) + + dut.S_AXI_RREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_RREADY.value = 0 + for _ in range(8): + await tb.s_cycle() + assert int(dut.S_AXI_RVALID.value) == 0, ( + "read response repeated for a single accepted read" + ) + + # Nothing may have reached the master side, and nothing may be replayed once + # the remote domain recovers. + assert tb.slave.handshakes == [], ( + f"master side saw activity while held in reset: {tb.slave.handshakes}" + ) + + tb.m_clk.start() + await tb.m_cycle(8) + dut.mAxiClkRst.value = tb.reset_inactive_value() + await tb.m_cycle(16) + await tb.s_cycle(16) + + assert tb.slave.handshakes == [], ( + f"rejected request replayed downstream after recovery: {tb.slave.handshakes}" + ) + assert tb.slave.mem == {}, ( + f"rejected write reached downstream memory: {tb.slave.mem}" + ) + + +@cocotb.test(skip=COMMON_CLK) +async def remote_reset_inflight_flush_test(dut): + """A request queued before the remote reset must not survive it. + + The request FIFOs are written from the slave domain but drained from the + master domain, so a transaction can already be sitting in them when + mAxiClkRst asserts. Gating new requests is not enough; the queued one has to + be discarded rather than replayed once the remote domain recovers. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Freeze the master domain while its reset is still released, so the bridge + # accepts and queues the write but cannot forward it yet. + tb.m_clk.stop() + await tb.s_cycle(4) + + dut.S_AXI_AWADDR.value = REJECTED_WRITE_ADDR + await tb.drive_handshake( + dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "AW before remote reset" + ) + dut.S_AXI_WDATA.value = 0xDEADBEEF + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake( + dut.S_AXI_WVALID, dut.S_AXI_WREADY, "W before remote reset" + ) + + # The write is queued and still unanswered, and cannot have reached the far + # side because that clock is stopped. + assert int(dut.S_AXI_BVALID.value) == 0, ( + "write answered while the master domain was still expected to handle it" + ) + assert tb.slave.handshakes == [], ( + f"master side saw activity with its clock stopped: {tb.slave.handshakes}" + ) + + # Now reset the remote domain underneath the queued write. + dut.mAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(16) + + # The bridge still owes a response for it, and it must be the error response. + await tb.await_high(dut.S_AXI_BVALID, "write response after remote reset") + assert int(dut.S_AXI_BRESP.value) == int(AxiResp.SLVERR), ( + f"queued write answered with {int(dut.S_AXI_BRESP.value)}, " + f"expected {int(AxiResp.SLVERR)}" + ) + dut.S_AXI_BREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_BREADY.value = 0 + + # Recover and confirm the queued write was discarded, not replayed. + tb.m_clk.start() + await tb.m_cycle(8) + dut.mAxiClkRst.value = tb.reset_inactive_value() + await tb.m_cycle(16) + await tb.s_cycle(16) + + assert tb.slave.handshakes == [], ( + f"write queued before the remote reset was replayed downstream: " + f"{tb.slave.handshakes}" + ) + assert tb.slave.mem == {}, ( + f"write queued before the remote reset reached memory: {tb.slave.mem}" + ) + + +@cocotb.test(skip=COMMON_CLK) +async def remote_reset_orphan_pairing_test(dut): + """An orphaned write address must not pair with a later write data beat. + + The write address and the write data cross the bridge in separate FIFOs, so a + write that straddles the remote reset can leave an address queued with no + data behind it. If that address is not discarded, the next write's data is + committed to the wrong location. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Queue the address only, with the master domain frozen. + tb.m_clk.stop() + await tb.s_cycle(4) + dut.S_AXI_AWADDR.value = REJECTED_WRITE_ADDR + await tb.drive_handshake(dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "orphan AW") + + # Reset the remote domain with that address queued and no data sent yet. + dut.mAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(16) + + # Supply the data now. The bridge answers locally and both halves are dropped. + dut.S_AXI_WDATA.value = 0xDEADBEEF + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake( + dut.S_AXI_WVALID, dut.S_AXI_WREADY, "W during remote reset" + ) + await tb.await_high(dut.S_AXI_BVALID, "write response during remote reset") + assert int(dut.S_AXI_BRESP.value) == int(AxiResp.SLVERR) + dut.S_AXI_BREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_BREADY.value = 0 + + # Recover. + tb.m_clk.start() + await tb.m_cycle(8) + dut.mAxiClkRst.value = tb.reset_inactive_value() + await tb.m_cycle(16) + await tb.s_cycle(16) + + # Issue a fresh write to a different address. + dut.S_AXI_AWADDR.value = RECOVERY_ADDR + await tb.drive_handshake(dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "recovery AW") + dut.S_AXI_WDATA.value = 0x0F1E2D3C + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake(dut.S_AXI_WVALID, dut.S_AXI_WREADY, "recovery W") + + # Let the master side act before checking, so a misdirected write is reported + # as exactly that rather than as a missing response. + await tb.m_cycle(64) + + assert ("AW", REJECTED_WRITE_ADDR) not in tb.slave.handshakes, ( + f"abandoned write address reached the master side: {tb.slave.handshakes}" + ) + assert REJECTED_WRITE_ADDR not in tb.slave.mem, ( + f"recovery write data was committed to the abandoned address: {tb.slave.mem}" + ) + assert tb.slave.mem == {RECOVERY_ADDR: 0x0F1E2D3C}, ( + f"recovery write did not land correctly: {tb.slave.mem}" + ) + + await tb.await_high(dut.S_AXI_BVALID, "recovery write response", limit=128) + assert int(dut.S_AXI_BRESP.value) == int(AxiResp.OKAY) + dut.S_AXI_BREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_BREADY.value = 0 + + +@cocotb.test(skip=COMMON_CLK) +async def source_reset_stale_response_test(dut): + """A response queued when the slave domain resets must be discarded. + + The response FIFOs are written from the master domain, so a completed + response can still be queued when sAxiClkRst asserts. If it survives the + reset, the next read after recovery consumes it and returns another + address's data. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Seed two addresses with distinct data. + seeds = ((BASELINE_ADDR, 0x11111111), (RECOVERY_ADDR, 0x22222222)) + for addr, data in seeds: + dut.S_AXI_AWADDR.value = addr + await tb.drive_handshake( + dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, f"seed AW {addr:#05x}" + ) + dut.S_AXI_WDATA.value = data + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake( + dut.S_AXI_WVALID, dut.S_AXI_WREADY, f"seed W {addr:#05x}" + ) + await tb.await_high(dut.S_AXI_BVALID, f"seed B {addr:#05x}", limit=128) + dut.S_AXI_BREADY.value = 1 + await tb.s_cycle() + dut.S_AXI_BREADY.value = 0 + + # Read one address but never take the response, so it sits in the response + # FIFO on the slave side. + dut.S_AXI_ARADDR.value = BASELINE_ADDR + await tb.drive_handshake(dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "stale AR") + await tb.await_high(dut.S_AXI_RVALID, "stale read response", limit=128) + + # Reset the slave domain with that response still queued. + dut.sAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(8) + dut.sAxiClkRst.value = tb.reset_inactive_value() + await tb.s_cycle(16) + await tb.m_cycle(16) + + # A fresh read must return its own data, not the abandoned response. + dut.S_AXI_ARADDR.value = RECOVERY_ADDR + await tb.drive_handshake(dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "recovery AR") + await tb.await_high(dut.S_AXI_RVALID, "recovery read response", limit=128) + assert int(dut.S_AXI_RDATA.value) == 0x22222222, ( + f"read after a slave-domain reset returned " + f"{int(dut.S_AXI_RDATA.value):#010x}, expected 0x22222222 for its own " + "address" + ) + + +@cocotb.test(skip=COMMON_CLK) +async def source_reset_clears_outstanding_test(dut): + """A slave-domain reset must clear the bridge's outstanding transaction state. + + The outstanding counts decide whether the bridge owes a local error response. + If a slave-domain reset leaves them stale, the next remote reset answers a + transaction the slave side already abandoned, which is a response with no + request behind it. + + This is the case that actually exercises the reset path of the registered + logic, through the sequential process when RST_ASYNC_G is true and through + the combinational next-state path when it is false. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Freeze the master domain so no real response can ever be produced, then + # leave a read accepted and unanswered. + tb.m_clk.stop() + await tb.s_cycle(4) + dut.S_AXI_ARADDR.value = REJECTED_READ_ADDR + await tb.drive_handshake( + dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "AR before slave reset" + ) + assert int(dut.S_AXI_RVALID.value) == 0, ( + "read answered while the master domain was frozen" + ) + + # Reset the slave domain. That abandons the read, so the bridge no longer + # owes anything for it. + dut.sAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(8) + dut.sAxiClkRst.value = tb.reset_inactive_value() + await tb.s_cycle(16) + + # Now reset the remote domain, which puts the bridge into local-answer mode. + dut.mAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(16) + + # With the outstanding state cleared there is nothing to answer. + for _ in range(16): + await tb.s_cycle() + assert int(dut.S_AXI_RVALID.value) == 0, ( + "bridge answered a read that was abandoned by the slave-domain reset" + ) + assert int(dut.S_AXI_BVALID.value) == 0, ( + "bridge produced a write response with no write outstanding" + ) + + # And nothing may reach the master side once it recovers. + tb.m_clk.start() + await tb.m_cycle(8) + dut.mAxiClkRst.value = tb.reset_inactive_value() + await tb.m_cycle(16) + await tb.s_cycle(16) + assert tb.slave.handshakes == [], ( + f"abandoned read reached the master side after recovery: " + f"{tb.slave.handshakes}" + ) + + +@cocotb.test(skip=COMMON_CLK) +async def single_outstanding_bound_test(dut): + """The bridge allows one transaction per channel in flight, like the crossbar. + + AxiLiteCrossbar does not release a slave slot until the response completes, + so AxiLiteAsync matches that bound. It enforces the bound with its own ready + outputs rather than trusting the master to honour it, which is what lets the + remote-reset responder be a single flag per channel and still answer exactly + once per accepted request. + """ + tb = TB(dut, drive_master=False) + await tb.reset() + + # Normal operation: a second read must not be accepted while the first is + # still unanswered. + dut.S_AXI_RREADY.value = 0 + dut.S_AXI_ARADDR.value = BASELINE_ADDR + await tb.drive_handshake(dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "first AR") + + dut.S_AXI_ARADDR.value = REJECTED_READ_ADDR + dut.S_AXI_ARVALID.value = 1 + for _ in range(32): + await tb.s_cycle() + assert int(dut.S_AXI_ARREADY.value) == 0, ( + "bridge accepted a second read while the first was still unanswered" + ) + dut.S_AXI_ARVALID.value = 0 + await tb.settle() + assert REJECTED_READ_ADDR not in tb.slave.addresses_seen("AR"), ( + "read held while ARREADY was low crossed to the master side: " + f"{tb.slave.handshakes}" + ) + + # Answer the first read, after which the next one is accepted normally. + await tb.await_high(dut.S_AXI_RVALID, "first read response", limit=128) + await tb.consume(dut.S_AXI_RVALID, dut.S_AXI_RREADY, "first read response") + + dut.S_AXI_ARADDR.value = BASELINE_ADDR + await tb.drive_handshake(dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "second AR") + await tb.await_high(dut.S_AXI_RVALID, "second read response", limit=128) + await tb.consume(dut.S_AXI_RVALID, dut.S_AXI_RREADY, "second read response") + + # The same bound applies to writes: no second address while one is pending. + dut.S_AXI_AWADDR.value = BASELINE_ADDR + await tb.drive_handshake(dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "first AW") + dut.S_AXI_AWADDR.value = RECOVERY_ADDR + dut.S_AXI_AWVALID.value = 1 + for _ in range(16): + await tb.s_cycle() + assert int(dut.S_AXI_AWREADY.value) == 0, ( + "bridge accepted a second write address while one was still pending" + ) + dut.S_AXI_AWVALID.value = 0 + await tb.settle() + dut.S_AXI_WDATA.value = 0x5A5A5A5A + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake(dut.S_AXI_WVALID, dut.S_AXI_WREADY, "first W") + await tb.await_high(dut.S_AXI_BVALID, "first write response", limit=128) + await tb.consume(dut.S_AXI_BVALID, dut.S_AXI_BREADY, "first write response") + + # Once the first response releases the downstream slave, none of the address + # beats held above while AWREADY was low may appear there. + await tb.m_cycle(16) + assert RECOVERY_ADDR not in tb.slave.addresses_seen("AW"), ( + "write address held while AWREADY was low crossed to the master side: " + f"{tb.slave.handshakes}" + ) + + # Exercise the same rule on W independently. Queue one accepted data beat + # before its address, then hold a different beat while WREADY is low. + first_wdata = 0x11223344 + blocked_wdata = 0x55667788 + dut.S_AXI_WDATA.value = first_wdata + dut.S_AXI_WSTRB.value = 0xF + await tb.drive_handshake(dut.S_AXI_WVALID, dut.S_AXI_WREADY, "W before AW") + + dut.S_AXI_WDATA.value = blocked_wdata + dut.S_AXI_WVALID.value = 1 + for _ in range(16): + await tb.s_cycle() + assert int(dut.S_AXI_WREADY.value) == 0, ( + "bridge accepted a second write data beat while one was still pending" + ) + dut.S_AXI_WVALID.value = 0 + await tb.settle() + + # Complete the first W with its address and consume its response. + dut.S_AXI_AWADDR.value = RECOVERY_ADDR + await tb.drive_handshake(dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "AW after W") + await tb.await_high(dut.S_AXI_BVALID, "W-before-AW response", limit=128) + await tb.consume(dut.S_AXI_BVALID, dut.S_AXI_BREADY, "W-before-AW response") + assert tb.slave.mem[RECOVERY_ADDR] == first_wdata + + # A fresh address must wait for a fresh W; an unaccepted data beat left in + # the FIFO would instead pair with this address and modify memory. + dut.S_AXI_AWADDR.value = REJECTED_WRITE_ADDR + await tb.drive_handshake( + dut.S_AXI_AWVALID, dut.S_AXI_AWREADY, "AW after blocked W" + ) + await tb.m_cycle(32) + assert REJECTED_WRITE_ADDR not in tb.slave.mem, ( + "write data held while WREADY was low crossed to the master side: " + f"{tb.slave.handshakes}" + ) + + # Finish the legitimate write so the following remote-reset scenario starts + # with no partial transaction in either interface. + dut.S_AXI_WDATA.value = 0x99AABBCC + await tb.drive_handshake( + dut.S_AXI_WVALID, dut.S_AXI_WREADY, "fresh W after blocked W" + ) + await tb.await_high(dut.S_AXI_BVALID, "fresh write response", limit=128) + await tb.consume(dut.S_AXI_BVALID, dut.S_AXI_BREADY, "fresh write response") + + # Error mode: the same bound holds, and the accepted read is answered once. + tb.m_clk.stop() + dut.mAxiClkRst.value = tb.reset_active_value() + await tb.s_cycle(16) + + dut.S_AXI_ARADDR.value = REJECTED_READ_ADDR + await tb.drive_handshake( + dut.S_AXI_ARVALID, dut.S_AXI_ARREADY, "AR during remote reset" + ) + + dut.S_AXI_ARVALID.value = 1 + for _ in range(16): + await tb.s_cycle() + assert int(dut.S_AXI_ARREADY.value) == 0, ( + "bridge accepted a second read during remote reset while the first " + "was still unanswered" + ) + dut.S_AXI_ARVALID.value = 0 + await tb.settle() + + await tb.await_high(dut.S_AXI_RVALID, "error response during remote reset") + assert int(dut.S_AXI_RRESP.value) == int(AxiResp.SLVERR), ( + f"remote-reset read answered with {int(dut.S_AXI_RRESP.value)}, " + f"expected {int(AxiResp.SLVERR)}" + ) + await tb.consume(dut.S_AXI_RVALID, dut.S_AXI_RREADY, "error response") + + for _ in range(16): + await tb.s_cycle() + assert int(dut.S_AXI_RVALID.value) == 0, ( + "error response repeated for a single accepted read" + ) + + PARAMETER_SWEEP = [ parameter_case( "common_clk_sync", @@ -291,6 +1055,45 @@ async def reset_behavior_test(dut): RST_ASYNC_G="false", RST_POLARITY_G="'1'", ), + parameter_case( + "async_active_high", + COMMON_CLK_G="false", + PIPE_STAGES_G="0", + NUM_ADDR_BITS_G="12", + RST_ASYNC_G="false", + RST_POLARITY_G="'1'", + ), + # Active LOW covers the reset-polarity handling in the bridge; the remote + # reset comparisons are only exercised for one sense per case. + parameter_case( + "async_active_low", + COMMON_CLK_G="false", + PIPE_STAGES_G="0", + NUM_ADDR_BITS_G="12", + RST_ASYNC_G="false", + RST_POLARITY_G="'0'", + ), + # Asynchronous reset reaches the registered logic through the sequential + # process instead of the combinational next-state path, so it needs its own + # case to be executed at all. + parameter_case( + "async_rst_async", + COMMON_CLK_G="false", + PIPE_STAGES_G="0", + NUM_ADDR_BITS_G="12", + RST_ASYNC_G="true", + RST_POLARITY_G="'1'", + ), + # A non-zero PIPE_STAGES_G adds output registers to every channel FIFO and + # widens the worst-case outstanding transaction count. + parameter_case( + "async_pipelined", + COMMON_CLK_G="false", + PIPE_STAGES_G="2", + NUM_ADDR_BITS_G="12", + RST_ASYNC_G="false", + RST_POLARITY_G="'1'", + ), ]