Register blocks look trivial and fail in production anyway, because their bugs are semantic: the write that cleared an interrupt enable the driver still needed, the status clear that lost a race with hardware, the configuration write that vanished because the engine was busy. The NanoSoC DMA, whose behaviour comes from real RTL, exhibits all three by design, which makes it the perfect specimen: build the pattern once, correctly, and the traps become recognisable everywhere else.
The block below implements the DMA's five registers with their real register semantics, simplified in timing and in the job engine: reads are combinational with pready tied high (the real block takes three clocks per access with one wait state), and BUSY, DONE-set, ERR-set and COUNT arrive as inputs from the engine rather than being generated inside. What is modelled faithfully is the register behaviour software sees: CTRL stores IRQ_EN from bit 1 on every write and pulses GO only when bit 0 is one and the engine is idle, an accepted GO clears DONE (the real engine also raises BUSY and clears COUNT, which live on the engine side here), and ERR is never cleared by GO. STATUS holds DONE and ERR as sticky bits that hardware sets and software clears by writing one; a hardware set wins a same-cycle clear, so a completion can never be erased unseen. LEN and KEY accept writes only while the engine is idle. COUNT is read-only. Anything else completes with pslverr.
// NanoSoC teaching values: DMA-style APB register block (simplified timing:
// pready tied high; the real block inserts one wait state, three clocks/access).
module nano_dma_regs (
input logic clk,
input logic rst_n,
input logic [7:0] paddr,
input logic psel,
input logic penable,
input logic pwrite,
input logic [31:0] pwdata,
output logic [31:0] prdata,
output logic pready,
output logic pslverr,
input logic busy_in,
input logic done_set,
input logic err_set,
input logic [15:0] count_in,
output logic go_pulse,
output logic irq_en_o,
output logic [15:0] len_o,
output logic [31:0] key_o
);
logic irq_en, done_r, err_r;
logic mapped;
logic wr, is_ctrl, is_status, is_len, is_key;
assign wr = psel & penable & pwrite;
assign is_ctrl = (paddr == 8'h00);
assign is_status = (paddr == 8'h04);
assign is_len = (paddr == 8'h08);
assign is_key = (paddr == 8'h0C);
assign mapped = is_ctrl | is_status | is_len | is_key | (paddr == 8'h10);
assign pready = 1'b1;
assign pslverr = psel & penable & ~mapped;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
irq_en <= 1'b0; done_r <= 1'b0; err_r <= 1'b0;
len_o <= 16'h0; key_o <= 32'h0; go_pulse <= 1'b0;
end else begin
go_pulse <= 1'b0;
if (wr && is_ctrl) begin
irq_en <= pwdata[1]; // every CTRL write copies bit 1 (see lesson text)
if (pwdata[0] && !busy_in) begin
go_pulse <= 1'b1;
done_r <= 1'b0; // an accepted GO clears DONE (ERR is not cleared)
end
end
if (wr && is_status) begin
if (pwdata[1]) done_r <= 1'b0;
if (pwdata[2]) err_r <= 1'b0;
end
if (done_set) done_r <= 1'b1; // hardware set wins over a same-cycle clear
if (err_set) err_r <= 1'b1;
if (wr && is_len && !busy_in) len_o <= pwdata[15:0]; // ignored while BUSY
if (wr && is_key && !busy_in) key_o <= pwdata; // ignored while BUSY
end
end
assign irq_en_o = irq_en;
always_comb begin
case (paddr)
8'h00: prdata = {30'h0, irq_en, 1'b0}; // GO reads back 0
8'h04: prdata = {29'h0, err_r, done_r, busy_in};
8'h08: prdata = {16'h0, len_o};
8'h0C: prdata = key_o;
8'h10: prdata = {16'h0, count_in};
default: prdata = 32'h0;
endcase
end
endmoduleFirst, the access-type mix-up: implementing STATUS as plain read-write lets software clear DONE by writing zero to the whole register, which also overwrites bits it did not mean to touch, while implementing it as write-1-to-clear at the wrong priority loses completions to a same-cycle clear. The block above takes the safe line on both: per-field types, set wins. Second, the control-write trap, which is real DMA behaviour and not a defect: every CTRL write copies bit 1 into IRQ_EN, so a driver that writes 0x1 to start a job silently disables its own interrupt and then waits forever for a completion that already happened. The driver rule is to write IRQ_EN = 1 on every CTRL write when interrupts are used, 0x3 to start a job with the interrupt armed. Third, the silently ignored write: LEN and KEY writes during BUSY complete normally, with no error, and change nothing, so a driver that reprograms a running engine gets the old transfer with the old key and no signal anywhere. The driver rule is to poll BUSY to zero first. All three rules are hardware-software contract terms; the software topic writes them into the errata discipline.
Status clear priority is stated in the specification (set wins) and matches the RTL line by line.
Control registers with read-modify side effects, like CTRL copying IRQ_EN, are flagged in the table and in the driver guide.
Busy-ignored writes are documented as ignored, with the driver's polling obligation stated beside them.
Exercise the block the way the verification topic will: reset readback against the table, write 0x1 to CTRL and observe IRQ_EN clear (the trap made visible), write LEN during a busy job and read back the old value, and set DONE in the same cycle as a clear and confirm it survives. Each behaviour is one directed test; together they pin the semantics the driver depends on. If a test surprises you, the specification is ambiguous and the fix belongs in the table first.
The write was silently ignored, as specified: the engine kept the old key. The rule is to wait for BUSY to read zero before touching LEN or KEY, and to confirm by readback when it matters.