The NanoSoC job moves buffers the CPU also touches, and today software keeps them consistent by placement: the SBR writes its output to SRAM1, which is non-cacheable, so no stale copy can exist. The price is speed: reading back 64 words from SRAM1 takes 64 x 93.75 ns = 6,000 ns, as in the boot self-test. A cacheable buffer would read the same 256 bytes as 16 line fills of 125 ns, 2,000 ns, three times faster. The day a buffer must be cacheable for that speed, or for sharing, the placement trick stops working and the integrator chooses between teaching software to maintain the caches or teaching the fabric to snoop the SBR's accesses. NanoSoC has no data cache; the numbers below describe a hypothetical one.
Software maintenance means cleaning cached writes before the SBR reads a buffer and invalidating cached copies before the CPU reads what the SBR wrote, with barriers so the maintenance itself is ordered. It costs code, discipline and cycles, and one missed invalidate corrupts silently. An I/O-coherent port, the ACE-Lite style, lets the SBR's reads snoop the caches instead: the hardware finds the newest copy wherever it lives. Its writes matter too: an I/O-coherent write must invalidate or update the other copies of the line, or the other cache keeps serving the old value. The price is snoop traffic per burst: each 16-beat SBR burst is 64 bytes, 4 lines, 4 snoops, and a 64-word job in each direction is 16 lines, so 32 snoops per job.
# Illustrative: costing one 64-word job on a hypothetical cacheable buffer (NanoSoC has no data cache).
T_CPU = 1e9 / 192e6
LINES = 256 // 16 # 16 lines of 16 bytes
MAINT_CYCLES = 2 # illustrative clk_cpu cycles per line operation
uncached_readback = 64 * 93.75 # ns, single reads of SRAM1 as in the boot self-test
cached_readback = LINES * 125.0 # ns, 16 four-beat line fills
assert uncached_readback == 6000.0 and cached_readback == 2000.0
maint = 2 * LINES * MAINT_CYCLES * T_CPU # clean the source + invalidate the destination
assert abs(maint - 333.3) < 0.1
job = (64 + 8) * 1e9 / 96e6 # the DMA job itself, 750 ns
assert abs(job - 750.0) < 0.1
assert maint < 0.5 * job # maintenance is a modest tax on the job, if every line is covered
snoops_per_job = LINES + LINES # 16 read-side + 16 write-side invalidations
assert snoops_per_job == 32
assert 16 * 4 // 16 == 4 # a 16-beat burst of 4-byte beats is 4 lines
# why maintenance must be complete: a model of one cached line and memory
def dma_reads(cpu_value, cleaned):
memory = 0 # stale value in memory
cache = cpu_value # CPU's dirty line
if cleaned:
memory = cache # clean writes the line back
return memory
assert dma_reads(0xAB, cleaned=True) == 0xAB
assert dma_reads(0xAB, cleaned=False) != 0xAB # a missed clean makes the DMA read stale data
print("PASS: 6000 ns uncached vs 2000 ns cached read-back, maintenance 333 ns per 750 ns job, 32 snoops per job, a missed clean is caught")Every DMA buffer class states its coherence method: placement, maintenance, or coherent port.
Maintenance sequences name their barriers, and the driver review checks each path, including error exits.
A coherent port choice carries its snoop bandwidth row in the fabric budget, with write-side invalidations counted.
Test both directions of staleness: CPU writes then the SBR reads, and the SBR writes then the CPU reads, with the caches hot in each case. A maintenance bug fails only when the line happens to be cached, so randomise cache state before each job. Watch for the error-exit trap: a job that ends early must still maintain the lines it touched, or the next job inherits stale data.
Every job gains maintenance: clean before the SBR reads, invalidate before the CPU reads the output, barriers around both, including on error exits. Alternatively the SBR port goes coherent and the budget gains a snoop row, with the SBR's writes invalidating other copies.