The chip freezes on the first CPU read of a DMA register after software wrote CLK_EN to zero. No error, no timeout, no interrupt; only a reset recovers it. That is the signature of a dependency cycle: each party waits for something another holds, around a ring, and nobody can move first. This lesson draws the ring so the next lesson can break it.
AXI's own channels cannot deadlock on their own: a master's valid never waits for ready, a slave's response never waits for the master's ready, and every dependency runs from request to response. Rings appear when a component adds an edge the protocol does not have: it serialises independent channels, shares one buffer between request and response, or waits on something outside the fabric. NanoSoC has exactly one such component on a path that matters, the axi2apb bridge, which handles one access at a time and waits for PREADY.
The real ring is the gated clock. The CPU reads DMA STATUS (a blocking access, one in flight). The bridge starts the APB access and waits for the DMA's PREADY. The DMA drives PREADY only with clk_dma running, and clk_dma is off because CLK_EN[0] is 0. The only thing that could restart it is a store to CLK_EN in RSTPWR, which is the next instruction the CPU would execute, if it were not blocked on the read. Ring: CPU read, bridge access, DMA PREADY, clk_dma, CLK_EN write, CPU. Even if another agent could issue the store, it would queue behind the stuck access in the same bridge. A second real source is power-down with a request in flight: a live master waits for a response from a block whose supply is cut.
# NanoSoC teaching values: the gated-clock hang as a channel-dependency graph.
# An edge a -> b means a cannot complete until b does.
GRAPH = {
"cpu_read_dma_status": ["bridge_access_in_flight"],
"bridge_access_in_flight": ["dma_pready"],
"dma_pready": ["clk_dma_running"],
"clk_dma_running": ["clk_en_write"],
"clk_en_write": ["cpu_issues_store"],
"cpu_issues_store": ["cpu_read_dma_status"], # the CPU is blocked on the read
}
def find_cycle(graph):
done = set()
def visit(node, stack):
if node in stack:
return stack[stack.index(node):] + [node]
if node in done:
return None
for nxt in graph.get(node, []):
hit = visit(nxt, stack + [node])
if hit:
return hit
done.add(node)
return None
for node in graph:
hit = visit(node, [])
if hit:
return hit
return None
cycle = find_cycle(GRAPH)
assert cycle is not None and cycle[0] == cycle[-1]
assert len(cycle) == 7 # six waiters closing the ring
# the fix: a 256-cycle watchdog completes the access, so the bridge stops waiting for PREADY
fixed = dict(GRAPH)
fixed["bridge_access_in_flight"] = ["watchdog_expiry"]
fixed["watchdog_expiry"] = []
assert find_cycle(fixed) is None
# a different fix: refuse to gate a block that can be addressed, removing the clock edge
fixed2 = dict(GRAPH)
fixed2["clk_dma_running"] = []
assert find_cycle(fixed2) is None
print("PASS: gated-clock ring found, both fixes (watchdog, no gating) break it")A hypothetical variant shows the general shape (it is not NanoSoC): if the SBR could also reach APB registers through the same bridge, an SBR write to a slow register would hold the bridge while a CPU read behind it waited for data that only the SBR's finished job could release. Draw the same way: one edge per wait, ring closed through the serialising component.
Every bridge states what it serialises and what it waits on, and every wait ends at a slave that always answers (PREADY within the documented wait states) or at a watchdog.
Shared slaves behind one bridge list their maximum concurrent users and the quiesce order for reset and power-down.
No path is described as always fast enough; every shared path has a full-buffer and a never-answers case.
Drive the ring on purpose: gate clk_dma, then read a DMA register. With the watchdog the read must return SLVERR after 256 clk_sys cycles; without it the access never ends and the dump shows one outstanding access at the bridge. When a chip does hang, dump every outstanding transaction and its dependency: the cycle reads off the dump as a ring of waiters. Confirm the ring by completing one member by hand in the testbench (for example forcing PREADY); if the rest unwind, the diagnosis is proved.
Depth only delays a cycle; a true dependency ring fills any finite buffer eventually, or never involves a buffer at all (the gated-clock ring has none). The fix is breaking an edge, for example with a watchdog, not deepening a queue.