I²C · Module 19
FPGA as Master and as Target — Integration Patterns
Assembles everything: Module 18's verified target behind Module 19's pad, synchronizer and filter, proven end to end on a wired-AND bus in three languages. Shows the controller-side shape on Module 17's real interface, where a soft CPU attaches, and closes with two mutations that cannot be killed in simulation — the module's thesis stated as evidence.
Eight chapters have built pieces. An output stage, a wrapper, a resistor argument, a synchronizer, a filter, a constraints file and a diagnostic — each verified on its own, none of them wired to the cores they exist to serve.
This chapter assembles them, in both directions, and the assembly found two things the pieces could not.
1. One Block Per Line, Instantiated Twice
Everything Module 19 built is per-line and identical for SDA and SCL. Packaging it once and instantiating it twice is not tidiness — it is the only way to guarantee the two lines get the same treatment.
// -----------------------------------------------------------------------------
// i2c_fpga_front_end.sv
// The whole of Module 19's front end for ONE bus line, as one instantiable block.
//
// WHY ONE BLOCK PER LINE. Everything Module 19 built is per-line and identical for
// SDA and SCL: an output stage (19.1), an I/O wrapper (19.2), a synchroniser (19.4)
// and a filter (19.5). Packaging them once and instantiating twice guarantees the two
// lines get the SAME treatment -- which Chapter 19.5 §5 shows is not optional, because
// the framing rules of 18.3 are defined on the RELATIONSHIP between an SDA edge and
// the SCL level, and unequal filter thresholds would skew it.
//
// A design that wired the four blocks up twice by hand would be one edit away from
// having different depths on the two lines, and no single-line test would notice.
//
// THE ORDER IS NOT NEGOTIABLE, and each step is a chapter:
//
// pin ─▶ wrapper ─▶ synchroniser ─▶ filter ─▶ edge detect ─▶ protocol core
// 19.2 19.4 19.5 (here) 17 / 18
//
// - the wrapper first, because everything above it must be two-valued (19.2)
// - synchronise BEFORE filtering: filtering an asynchronous signal samples an
// unsettled flop N times instead of once (19.5 §2)
// - edges from the FILTERED level, never the raw one, or a rejected spike still
// produces an edge event
//
// WHAT THIS BLOCK DOES NOT CONTAIN: any protocol. No framing, no address, no
// acknowledge. It converts a pin into a settled level and two one-cycle events, and
// that is the entire contract Modules 17 and 18 need from it.
// -----------------------------------------------------------------------------
module i2c_fpga_front_end #(
parameter int SYNC_DEPTH = 2, // 19.4 -- observation latency, in clocks
parameter int N_SAMP = 3 // 19.5 -- filter threshold, in clocks
) (
input logic clk,
input logic rst_n,
// ---- the pin, as the two-signal model of Chapter 19.2 --------------------
// What this device contributes to the shared net.
output logic pin_pulls_low,
// The resolved level of the shared net.
input logic pin_resolved,
// ---- the protocol core's side --------------------------------------------
// Drive intent, exactly as Modules 17 and 18 emit it: 1 = pull LOW.
input logic drive_low,
// The settled, filtered level -- what the line IS, as far as this device can know.
output logic level,
// One-cycle events on the filtered level.
output logic rise,
output logic fall,
// ---- observability (Chapter 19.8) ----------------------------------------
// The SYNCHRONISED but UNFILTERED level, brought out so an ILA can see the stage
// the filter is deciding about. Without it, a bus being eaten by an over-aggressive
// filter and a bus with no traffic look identical from inside.
output logic level_unfiltered,
// MUST STAY 0 for a conforming driver -- Chapter 19.2's legality monitor.
output logic drives_high,
// Disturbances rejected, and levels accepted. 19.5 argues for exporting both.
output logic [15:0] n_rejected,
output logic [15:0] n_accepted
);
// ---- 19.1: intent becomes a pad request ---------------------------------
logic pad_o, pad_oe;
i2c_od_out u_od (
.drive_low(drive_low), .pad_o(pad_o), .pad_oe(pad_oe),
.pulls_low(/* the wrapper applies the pad law; see below */));
// ---- 19.2: the boundary -------------------------------------------------
// NOTE the contribution comes from the WRAPPER, not from the output stage. Both
// compute the same pad law, and taking it from the wrapper means the value that
// reaches the bus is the one produced by the module that also owns the input path
// and the legality monitor -- one place, one law.
logic pin_level_raw;
i2c_io_wrapper u_io (
.pad_o(pad_o), .pad_oe(pad_oe),
.pin_pulls_low(pin_pulls_low), .pin_resolved(pin_resolved),
.pin_level(pin_level_raw), .drives_high(drives_high));
// ---- 19.4: synchronise, before anything looks at it ---------------------
// Both chains are driven from the same pin here; only one is used. The two-line
// module is instantiated per line so that its reset-to-idle behaviour and its
// latency are identical on SDA and SCL, which is the property 19.4's T2 protects.
logic sync_level, sync_unused;
logic [3:0] sync_latency;
i2c_line_sync #(.SYNC_DEPTH(SYNC_DEPTH)) u_sync (
.clk(clk), .rst_n(rst_n),
.scl_pin(pin_level_raw), .sda_pin(pin_level_raw),
.scl_q(sync_level), .sda_q(sync_unused),
.latency_clocks(sync_latency));
assign level_unfiltered = sync_level;
// ---- 19.5: filter, and detect edges on the FILTERED level ---------------
i2c_glitch_filter #(.N_SAMP(N_SAMP)) u_filt (
.clk(clk), .rst_n(rst_n), .line_sync(sync_level),
.line_filt(level), .line_rise(rise), .line_fall(fall),
.n_rejected(n_rejected), .n_accepted(n_accepted));
endmoduleThe order is not negotiable and each step is a chapter:
pin ─▶ wrapper ─▶ synchroniser ─▶ filter ─▶ edge detect ─▶ protocol core
19.2 19.4 19.5 (in the filter) 17 / 18The wrapper first, because everything above it must be two-valued. Synchronize before filtering, because filtering an asynchronous signal samples an unsettled flop N times instead of once (19.5 §2). Edges from the filtered level, or a rejected spike still produces an edge event.
One wiring detail is deliberate: the contribution to the bus comes from the wrapper, not from the output stage. Both compute the same pad law, and taking it from the wrapper means the value that reaches the bus comes from the module that also owns the input path and the legality monitor. One place, one law — and mutation H09, which takes it from the output stage instead, fails 35 checks.
2. The Target Side
// -----------------------------------------------------------------------------
// i2c_fpga_target.sv
// Module 18's verified target, on FPGA pins. The target-side integration shape.
//
// THE ASSEMBLY, and what each layer contributes:
//
// pins ─▶ i2c_fpga_front_end ×2 ─▶ i2c_slave ─▶ register file / application
// 19.1 19.2 19.4 19.5 Module 18
// │
// └─▶ i2c_bus_health (19.8) -- observation only
//
// ONE DECISION IS WORTH READING TWICE, AND IT IS NOT THE ONE I EXPECTED TO WRITE.
//
// `i2c_slave` contains its OWN synchroniser -- Chapter 18.2's `i2c_slave_sync`, with
// SYNC_DEPTH exposed as a parameter precisely so an integrator can adjust it. Feeding
// an already-synchronised, already-filtered level into a second two-stage
// synchroniser doubles the observation latency for no benefit, so the obvious
// composition is SYNC_DEPTH = 1: one register, which is what a signal that is already
// synchronous needs, while still getting 18.2's edge detection.
//
// THAT VALUE DOES NOT ELABORATE. `i2c_slave_sync` shifts its chain with
// `{pin, chain[SYNC_DEPTH-1:1]}`, which at SYNC_DEPTH = 1 is the part select
// `chain[0:1]` -- out of order, and rejected. Verified directly: the module
// elaborates at depths 2 and 3 and fails at 1.
//
// It is the same defect Chapter 19.4 found in its own first draft, and 19.4 found it
// only because its bench instantiates depths 1, 2 and 3 while Module 18's instantiates
// the default. Module 18 is locked, so it is NOT modified here; the finding is
// reported and designed around.
//
// SO THE SLAVE RUNS AT SYNC_DEPTH = 2, and the second synchroniser is redundant but
// harmless: the signal reaching it is already synchronous, so the two extra registers
// add latency and nothing else. The cost is stated rather than hidden --
//
// total observation latency = SYNC_DEPTH (front end)
// + N_SAMP (filter)
// + 2 (the slave's own synchroniser)
// = 7 clocks at the defaults
//
// which at 50 MHz is 140 ns against a Fast-mode bit period of 2500 ns: 5.6%, and
// comfortably inside the budget. If that mattered, the fix would be a one-character
// change in Module 18 rather than an architectural change here.
// -----------------------------------------------------------------------------
module i2c_fpga_target #(
parameter [6:0] MY_ADDR = 7'h50,
parameter int N_REG = 8,
parameter int RO_MASK = 8'h04,
parameter int IDLE_CYCLES = 512,
parameter int SYNC_DEPTH = 2, // 19.4, in the front end
parameter int N_SAMP = 3, // 19.5, in the front end
parameter int CNT_W = 16
) (
input logic clk,
input logic rst_n,
// ---- the pins, as Chapter 19.2's two-signal model ------------------------
// One `inout` per line appears only at the true top level, where a vendor
// primitive replaces this pair. Chapter 19.2 §6.
output logic scl_pulls_low,
input logic scl_resolved,
output logic sda_pulls_low,
input logic sda_resolved,
// ---- the application ------------------------------------------------------
input logic stall_req,
output logic [8*N_REG-1:0] reg_flat,
output logic [7:0] pointer,
// ---- diagnostics: Module 18's, plus Chapter 19's -------------------------
output logic selected,
output logic stretching,
output logic [CNT_W-1:0] n_writes,
output logic [CNT_W-1:0] n_reads,
output logic [CNT_W-1:0] n_sda_conflict,
// 19.2: MUST STAY 0 on both lines.
output logic drives_high,
// 19.5: margin, not correctness.
output logic [15:0] n_rejected_sda,
output logic [15:0] n_rejected_scl,
// 19.8: the four bring-up bits.
output logic bus_idle,
output logic bus_activity,
output logic scl_stuck_low,
output logic sda_stuck_low,
// Edges seen on either line. BROUGHT OUT, not left unconnected: Chapter 19.8's
// DebugLab is about a diagnostic output wired to nothing, which synthesis removes
// silently. An earlier version of this file left it dangling -- and mutation H06
// (which points the health block at the filtered level instead of the synchronised
// one) survived, because with no edge count exported the two views were
// indistinguishable from outside.
output logic [15:0] n_bus_edges
);
// ---- the target's drive intent, from Module 18 ---------------------------
logic scl_drive_low, sda_drive_low;
// ---- the filtered, settled view of each line ----------------------------
logic scl_level, sda_level;
logic scl_unfilt, sda_unfilt;
logic scl_rise, scl_fall, sda_rise, sda_fall; // 19.5's edges
logic scl_dh, sda_dh;
logic [15:0] scl_acc, sda_acc;
// ---- 19.1/19.2/19.4/19.5, once per line ---------------------------------
i2c_fpga_front_end #(.SYNC_DEPTH(SYNC_DEPTH), .N_SAMP(N_SAMP)) u_scl (
.clk(clk), .rst_n(rst_n),
.pin_pulls_low(scl_pulls_low), .pin_resolved(scl_resolved),
.drive_low(scl_drive_low),
.level(scl_level), .rise(scl_rise), .fall(scl_fall),
.level_unfiltered(scl_unfilt), .drives_high(scl_dh),
.n_rejected(n_rejected_scl), .n_accepted(scl_acc));
i2c_fpga_front_end #(.SYNC_DEPTH(SYNC_DEPTH), .N_SAMP(N_SAMP)) u_sda (
.clk(clk), .rst_n(rst_n),
.pin_pulls_low(sda_pulls_low), .pin_resolved(sda_resolved),
.drive_low(sda_drive_low),
.level(sda_level), .rise(sda_rise), .fall(sda_fall),
.level_unfiltered(sda_unfilt), .drives_high(sda_dh),
.n_rejected(n_rejected_sda), .n_accepted(sda_acc));
// Either line asking the pad for a one is illegal. ORed so a single bit can be
// taken to a pin or an assertion; each line's own monitor is inside its front end.
assign drives_high = scl_dh | sda_dh;
// ---- Module 18's verified target ----------------------------------------
// SYNC_DEPTH(2), not 1 -- see the header. 1 is the value this composition wants
// and `i2c_slave_sync` does not elaborate at it. What is wanted from 18.2 here is
// its edge detection; the synchronising is redundant and costs two clocks.
i2c_slave #(
.MY_ADDR(MY_ADDR), .N_REG(N_REG), .RO_MASK(RO_MASK),
.IDLE_CYCLES(IDLE_CYCLES), .SYNC_DEPTH(2), .CNT_W(CNT_W)
) u_slave (
.clk(clk), .rst_n(rst_n),
.scl_pin(scl_level), .sda_pin(sda_level),
.scl_drive_low(scl_drive_low), .sda_drive_low(sda_drive_low),
.stall_req(stall_req),
.reg_flat(reg_flat), .pointer(pointer),
.selected(selected), .stretching(stretching),
.n_phases(), .n_restarts(),
.n_writes(n_writes), .n_refused(), .n_reads(n_reads),
.n_aborts(), .n_sda_conflict(n_sda_conflict));
// ---- 19.8: the bring-up aid, watching the SYNCHRONISED levels -----------
// Deliberately the unfiltered-but-synchronised view: a stuck line is a stuck line
// whether or not a filter would have accepted the level, and reading the filtered
// level would make the diagnostic depend on the filter it may need to diagnose.
i2c_bus_health #(.IDLE_CLKS(256), .STUCK_CLKS(4096), .EDGE_W(16)) u_health (
.clk(clk), .rst_n(rst_n),
.scl_q(scl_unfilt), .sda_q(sda_unfilt),
.idle(bus_idle), .scl_stuck_low(scl_stuck_low),
.sda_stuck_low(sda_stuck_low), .activity(bus_activity),
.n_edges(n_bus_edges));
endmodule3. Proving the Composition
// -----------------------------------------------------------------------------
// i2c_fpga_target_tb.sv
// End-to-end oracle for the assembled FPGA target.
//
// WHAT IS BEING PROVEN, and it is a composition claim rather than a protocol one:
// Module 18's target was verified against an IDEAL bus with its own synchroniser and
// nothing else. This bench drives REAL PINS, through Chapter 19's pad, synchroniser
// and filter, on Module 16's wired-AND bus model -- and requires a complete write and
// a complete read to still work.
//
// THE BENCH IS A CONTROLLER driving the pins by hand, in the style Chapter 18.1
// argues for: it changes SDA only while SCL is LOW for data and only while SCL is
// HIGH for framing. The half-period is long enough to absorb the front end's
// latency, which is itself the point -- Section 5 of the chapter works out what
// "long enough" means and this bench is where the number came from.
//
// Every wait is a fixed number of clocks; nothing waits on the DUT.
// -----------------------------------------------------------------------------
`timescale 1ns/1ps
module i2c_fpga_target_tb;
localparam int HALF = 24; // clocks per SCL half-phase -- see §5
localparam [6:0] ADDR = 7'h50;
localparam [6:0] NEAR = ADDR ^ 7'h40; // 0x10: one bit away (18.4 / 18.11)
localparam int N_REG = 8;
localparam int ROM = 8'h04; // register 2 read-only
localparam int SYNCD = 2;
localparam int NSAMP = 3;
logic clk = 1'b0, rst_n = 1'b0;
// device 0 = the bench's controller, device 1 = the DUT
logic m_scl_low = 1'b0, m_sda_low = 1'b0;
logic d_scl_low, d_sda_low;
wire scl, sda;
wire [1:0] scl_in, sda_in, scl_rbl, sda_rbl;
wire [7:0] scl_holders, sda_holders;
logic stall_req = 1'b0;
wire [8*N_REG-1:0] reg_flat;
wire [7:0] pointer;
wire selected, stretching, drives_high;
wire [15:0] n_writes, n_reads, n_sda_conflict;
wire [15:0] n_rej_sda, n_rej_scl;
wire bus_idle, bus_activity, scl_stuck, sda_stuck;
wire [15:0] n_bus_edges;
integer errors = 0;
integer n, k, i;
logic [7:0] rdbyte;
integer edges_before_spike, edges_after_spike;
logic ackbit;
i2c_line_model #(.N_DEV(2)) bus (
.scl_drive_low({d_scl_low, m_scl_low}),
.sda_drive_low({d_sda_low, m_sda_low}),
.scl(scl), .sda(sda), .scl_in(scl_in), .sda_in(sda_in),
.scl_released_but_low(scl_rbl), .sda_released_but_low(sda_rbl),
.scl_holders(scl_holders), .sda_holders(sda_holders));
i2c_fpga_target #(
.MY_ADDR(ADDR), .N_REG(N_REG), .RO_MASK(ROM),
.IDLE_CYCLES(100000), .SYNC_DEPTH(SYNCD), .N_SAMP(NSAMP), .CNT_W(16)
) dut (
.clk(clk), .rst_n(rst_n),
.scl_pulls_low(d_scl_low), .scl_resolved(scl),
.sda_pulls_low(d_sda_low), .sda_resolved(sda),
.stall_req(stall_req),
.reg_flat(reg_flat), .pointer(pointer),
.selected(selected), .stretching(stretching),
.n_writes(n_writes), .n_reads(n_reads), .n_sda_conflict(n_sda_conflict),
.drives_high(drives_high),
.n_rejected_sda(n_rej_sda), .n_rejected_scl(n_rej_scl),
.bus_idle(bus_idle), .bus_activity(bus_activity),
.scl_stuck_low(scl_stuck), .sda_stuck_low(sda_stuck),
.n_bus_edges(n_bus_edges));
// The illegal combination must never occur, in any cycle of any test. Checked
// continuously rather than at test boundaries, because a one-cycle violation
// between two checks would otherwise be invisible -- Chapter 18.11's argument for
// n_sda_conflict, applied to Chapter 19.2's monitor.
integer dh_seen = 0;
always @(posedge clk) if (rst_n && drives_high) dh_seen <= dh_seen + 1;
always #5 clk = ~clk;
task step; begin @(posedge clk); @(negedge clk); end endtask
task phase; begin for (n = 0; n < HALF; n = n + 1) step; end endtask
task do_reset;
begin
@(negedge clk); rst_n = 1'b0; m_scl_low = 1'b0; m_sda_low = 1'b0;
step; step; step;
@(negedge clk); rst_n = 1'b1;
// Let the front end settle and the health block see an idle bus.
for (n = 0; n < 300; n = n + 1) step;
dh_seen = 0;
end
endtask
// ---- the controller, driving pins ---------------------------------------
task m_start;
begin
@(negedge clk); m_sda_low = 1'b0; m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b1; phase; // SDA falls, SCL high
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_restart;
begin
@(negedge clk); m_scl_low = 1'b1; m_sda_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b1; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_stop;
begin
@(negedge clk); m_scl_low = 1'b1; m_sda_low = 1'b1; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b0; phase; // SDA rises, SCL high
end
endtask
// One bit out. SDA changes only while SCL is LOW.
task m_bit (input b);
begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = ~b; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
// The ninth slot, with the controller released so the target can answer.
// `a` returns the bit that was on the wire: 0 = ACK.
task m_ack_slot (output a);
begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
// Sample late in the high phase, after the target's own latency.
for (n = 0; n < HALF - 2; n = n + 1) step;
a = sda;
step; step;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_put (input [7:0] d, output a);
begin
for (k = 7; k >= 0; k = k - 1) m_bit(d[k]);
m_ack_slot(a);
end
endtask
// One byte in, with the controller answering `ack` in the ninth slot.
task m_get (input ack, output [7:0] d);
begin
d = 8'h00;
for (k = 7; k >= 0; k = k - 1) begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase; // released: target drives
@(negedge clk); m_scl_low = 1'b0; phase;
for (n = 0; n < HALF - 2; n = n + 1) step;
d[k] = sda;
step; step;
@(negedge clk); m_scl_low = 1'b1; phase;
end
// the controller's own acknowledge
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = ack; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase;
end
endtask
task ck (input [200*8:1] what, input integer g, input integer e);
begin
if (g !== e) begin
$display(" FAIL %0s: got %0d expected %0d", what, g, e);
errors = errors + 1;
end
end
endtask
initial begin
$display("=== i2c_fpga_target: the verified target, on pins, through the front end ===");
// ----------------------------------------------------------------
// T1. RESET RELEASES BOTH LINES, AND THE HEALTH BLOCK AGREES. A target holding
// either line at power-on is the one failure it can inflict on every other
// device on the bus.
// ----------------------------------------------------------------
do_reset;
$display("T1 a reset target releases both lines, and the bus reads idle");
ck("T1 not pulling SCL", d_scl_low, 0);
ck("T1 not pulling SDA", d_sda_low, 0);
ck("T1 SCL idles high", scl, 1);
ck("T1 SDA idles high", sda, 1);
ck("T1 the health block reports idle", bus_idle, 1);
ck("T1 and no activity yet", bus_activity, 0);
ck("T1 neither line stuck", scl_stuck | sda_stuck, 0);
ck("T1 nothing asked the pad for a one", dh_seen, 0);
// ----------------------------------------------------------------
// T2. A COMPLETE WRITE, THROUGH THE WHOLE FRONT END. Address, three data bytes,
// STOP. Register 2 is read-only, so the third byte must be NACKed -- which
// means the acknowledge decision travelled from the register file (18.9)
// through the acknowledge block (18.5), the pad (19.2) and onto the wire,
// and back to the controller. That is the longest causal chain in the module.
// ----------------------------------------------------------------
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T2 the address was acknowledged", ackbit, 0);
ck("T2 and the target says it is selected", selected, 1);
m_put(8'h00, ackbit); // pointer byte
ck("T2 the pointer byte was acknowledged", ackbit, 0);
m_put(8'h11, ackbit); // -> reg 0
ck("T2 reg 0 write acknowledged", ackbit, 0);
m_put(8'h12, ackbit); // -> reg 1
ck("T2 reg 1 write acknowledged", ackbit, 0);
m_put(8'h13, ackbit); // -> reg 2, READ-ONLY
$display("T2 a full write arrives, and the read-only refusal reaches the wire");
ck("T2 the read-only register NACKed on the wire", ackbit, 1);
m_stop;
ck("T2 reg 0 landed", reg_flat[0*8 +: 8], 8'h11);
ck("T2 reg 1 landed", reg_flat[1*8 +: 8], 8'h12);
ck("T2 reg 2 untouched", reg_flat[2*8 +: 8], 8'h00);
ck("T2 two writes counted", n_writes, 2);
ck("T2 no internal driver conflict", n_sda_conflict, 0);
ck("T2 and the pad was never asked for a one", dh_seen, 0);
// ----------------------------------------------------------------
// T3. AND A COMPLETE READ BACK. Write the pointer, repeated START, read two
// bytes. The data now travels the other way: register file -> transmit
// datapath (18.7) -> pad (19.2) -> wire -> the controller's sampler. Nothing
// in Module 18 was ever verified with a filter in this path.
// ----------------------------------------------------------------
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T3 addressed for write", ackbit, 0);
m_put(8'h00, ackbit); // point at register 0
ck("T3 pointer set", ackbit, 0);
m_restart;
m_put({ADDR, 1'b1}, ackbit); // now read
ck("T3 addressed for read", ackbit, 0);
m_get(1'b1, rdbyte); // ACK -> send another
ck("T3 the first byte came back off the wire", rdbyte, 8'h11);
m_get(1'b0, rdbyte); // NACK -> stop
$display("T3 a combined transfer reads back exactly what was written");
ck("T3 the second byte too", rdbyte, 8'h12);
m_stop;
ck("T3 two reads counted", n_reads, 2);
ck("T3 still no driver conflict", n_sda_conflict, 0);
ck("T3 and still no drive-high", dh_seen, 0);
// ----------------------------------------------------------------
// T4. A ONE-BIT NEAR-MISS ADDRESS IS STILL REFUSED. Chapter 18.4's seven-bit
// discrimination and 18.11's integrated near-miss, now with a filter and a
// synchroniser in the path. A front end that corrupted one address bit would
// pass T2 and T3 and fail here.
// ----------------------------------------------------------------
m_start;
m_put({NEAR, 1'b0}, ackbit);
$display("T4 an address one bit away is refused, through the whole front end");
ck("T4 the near miss was NOT acknowledged", ackbit, 1);
ck("T4 and we were never selected", selected, 0);
m_put(8'h77, ackbit);
ck("T4 its data byte is ignored too", ackbit, 1);
ck("T4 and nothing was written", reg_flat[0*8 +: 8], 8'h11);
m_stop;
// ----------------------------------------------------------------
// T5. THE HEALTH BLOCK SAW ALL OF THAT. Its whole purpose is to distinguish a
// working bus from a dead one, so after four transactions it must report
// activity -- and after the STOP, idle again.
// ----------------------------------------------------------------
ck("T5 activity was seen", bus_activity, 1);
ck("T5 neither line reported stuck", scl_stuck | sda_stuck, 0);
for (n = 0; n < 300; n = n + 1) step;
$display("T5 the bring-up block reports a healthy, resting bus afterwards");
ck("T5 and the bus is idle again", bus_idle, 1);
// ----------------------------------------------------------------
// T6. THE FILTER REJECTED NOTHING, BECAUSE NOTHING WAS WRONG. On a clean bus
// `n_rejected` must be zero on BOTH lines -- which is the check that turns
// the counter from a number into a margin indicator. A non-zero value here
// would mean the bench's own stimulus was producing disturbances.
// ----------------------------------------------------------------
$display("T6 a clean bus rejected nothing: the filter cost no real edges");
ck("T6 no SDA disturbances rejected", n_rej_sda, 0);
ck("T6 no SCL disturbances rejected", n_rej_scl, 0);
// ----------------------------------------------------------------
// T7. CLOCK STRETCHING, THROUGH THE FRONT END. The application asks for time,
// and the target must pull SCL -- which means its SCL drive intent has to
// travel through Chapter 19.1's output stage and 19.2's wrapper to the pin,
// exactly as SDA's does. Nothing before this test exercises the SCL output
// path at all: a target that could not stretch would pass T1 to T6, and
// mutations H02 and H11 (which sever SCL drive intent and `stall_req`) both
// survived until this test existed.
// ----------------------------------------------------------------
do_reset;
// The request must be up BEFORE the acknowledge slot closes: Chapter 18.10
// engages the stretch on the falling edge that terminates the ninth pulse, so a
// request asserted after `m_put` returns has already missed its window. An
// earlier draft asserted it afterwards and saw no stretch at all -- the target
// was correct and the stimulus was late.
@(negedge clk); stall_req = 1'b1;
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T7 addressed for write", ackbit, 0);
$display("T7 the target stretches SCL, and the pull reaches the pin");
ck("T7 the target reports stretching", stretching, 1);
ck("T7 and is actually pulling SCL at the pin", d_scl_low, 1);
ck("T7 so the resolved SCL line is LOW", scl, 0);
// Release the request; the target must let go.
@(negedge clk); stall_req = 1'b0;
for (n = 0; n < 40; n = n + 1) step;
ck("T7 it stops stretching when the application is ready", stretching, 0);
ck("T7 and releases SCL", d_scl_low, 0);
ck("T7 it never asked the pad for a one", dh_seen, 0);
m_stop;
// ----------------------------------------------------------------
// T8. A DISTURBANCE ON THE BUS IS REJECTED, AND THE TRANSFER SURVIVES IT.
//
// This is the test that makes the filter load-bearing rather than merely
// present. The bench pulls SDA low for two clocks -- below the N_SAMP = 3
// threshold -- in the middle of an SCL LOW phase, where a real spike would
// land. Two things must be true afterwards: the filter must have COUNTED a
// rejection, and the transfer must complete correctly anyway.
//
// Mutation H04, which feeds the slave the UNFILTERED level, survived every
// other test in this bench: on a clean bus the filtered and unfiltered
// views differ only by N_SAMP clocks of latency, which a 24-clock half
// period absorbs completely. It takes an actual disturbance to tell them
// apart, which is the whole reason the filter exists.
// ----------------------------------------------------------------
do_reset;
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T8 addressed for write", ackbit, 0);
m_put(8'h05, ackbit); // point at register 5
ck("T8 pointer accepted", ackbit, 0);
// ---- the disturbance, and WHERE it lands is the whole point ------------
//
// A blip on SDA while SCL is LOW is a legal data change and corrupts nothing
// even unfiltered -- the target samples SDA only at a rising SCL edge. An
// earlier draft injected there and mutation H04 (which bypasses the filter)
// survived, correctly: there was nothing for the filter to save.
//
// The disturbance that matters is SDA falling while SCL is HIGH, because
// Chapter 18.3 defines exactly that as a START. Unfiltered, a two-clock blip
// there aborts the transfer and reframes the target mid-byte. Filtered, at
// N_SAMP = 3, it never reaches the framing detector at all.
//
// So this is driven by hand rather than through `m_bit`: a data bit whose SDA
// is released, with a two-clock pull inside its SCL-HIGH phase.
@(negedge clk); m_scl_low = 1'b1; phase; // SCL low
@(negedge clk); m_sda_low = 1'b0; phase; // SDA released -> a one
@(negedge clk); m_scl_low = 1'b0; phase; // SCL HIGH, mid-bit
edges_before_spike = n_bus_edges;
// ... and now the spike, two clocks, exactly. `step` ends on a negedge, so
// releasing after another @(negedge clk) would apply the pull across a THIRD
// rising edge -- and three clocks is exactly N_SAMP, which is ACCEPTED.
@(negedge clk); m_sda_low = 1'b1;
step; step;
m_sda_low = 1'b0;
// Let the front end's latency pass, with SCL still HIGH so that NOTHING BUT
// SDA can have contributed an edge in this window. Measuring across a window
// that also contained an SCL transition was how mutation H06 first survived:
// SCL's own edge swamped the two the spike contributed.
for (n = 0; n < SYNCD + NSAMP + 4; n = n + 1) step;
edges_after_spike = n_bus_edges;
for (n = 0; n < HALF; n = n + 1) step;
@(negedge clk); m_scl_low = 1'b1; phase; // finish the bit
$display("T8 a sub-threshold spike while SCL is HIGH is rejected, not read as a START");
ck("T8 the filter rejected it on SDA", (n_rej_sda > 0) ? 1 : 0, 1);
// The consequence that matters: the target was NOT reframed. Unfiltered, the
// framing detector would have seen a START here and dropped selection.
ck("T8 and the target is still selected", selected, 1);
// Finish the byte -- seven more bits -- and confirm the data survived.
for (k = 6; k >= 0; k = k - 1) m_bit(1'b1);
m_ack_slot(ackbit);
ck("T8 the data byte was still acknowledged", ackbit, 0);
m_stop;
ck("T8 and it landed in register 5 uncorrupted", reg_flat[5*8 +: 8], 8'hFF);
ck("T8 no internal driver conflict throughout", n_sda_conflict, 0);
// The health block watches the SYNCHRONISED-BUT-UNFILTERED view on purpose, so
// it DID see the spike's two edges even though the protocol engine did not. That
// is the intended split: a stuck line is a stuck line whether or not a filter
// would have accepted it, and a diagnostic that read the filtered level would
// depend on the filter it may need to diagnose. Mutation H06 points it at the
// filtered level, and this is the check that notices.
ck("T8 the health block counted the spike the protocol engine ignored",
edges_after_spike - edges_before_spike, 2);
ck("T8 and the pad was never asked for a one", dh_seen, 0);
if (errors == 0) $display("=== i2c_fpga_target: ALL CHECKS PASSED ===");
else $display("=== i2c_fpga_target: %0d CHECK(S) FAILED ===", errors);
$finish;
end
endmoduleThis bench proves a composition claim, not a protocol one. Module 18's target was verified against an ideal bus with its own synchronizer and nothing else; this drives real pins, through the pad, the synchronizer and the filter, on Module 16's wired-AND bus.
Three tests carry the weight, and two of them exist because mutations demanded them.
T2 and T3 are the round trip. A three-byte write where the third byte targets the read-only register, so the NACK must travel from the register file (18.9) through the acknowledge block (18.5), the pad (19.2) and onto the wire — the longest causal chain in the module. Then a combined transfer reads both bytes back off the wire.
T7 is the only test that exercises the SCL output path. A target that could not stretch would pass T1 to T6 completely, because nothing else makes it drive SCL. Mutations H02 and H11 — severing SCL drive intent and stall_req — both survived until this test existed.
T8 is where the disturbance lands, and that is the entire test.
// -----------------------------------------------------------------------------
// i2c_fpga_front_end.v
// The whole of Module 19's front end for ONE bus line, as one instantiable block.
//
// WHY ONE BLOCK PER LINE. Everything Module 19 built is per-line and identical for
// SDA and SCL: an output stage (19.1), an I/O wrapper (19.2), a synchroniser (19.4)
// and a filter (19.5). Packaging them once and instantiating twice guarantees the two
// lines get the SAME treatment -- which Chapter 19.5 §5 shows is not optional, because
// the framing rules of 18.3 are defined on the RELATIONSHIP between an SDA edge and
// the SCL level, and unequal filter thresholds would skew it.
//
// A design that wired the four blocks up twice by hand would be one edit away from
// having different depths on the two lines, and no single-line test would notice.
//
// THE ORDER IS NOT NEGOTIABLE, and each step is a chapter:
//
// pin ─▶ wrapper ─▶ synchroniser ─▶ filter ─▶ edge detect ─▶ protocol core
// 19.2 19.4 19.5 (here) 17 / 18
//
// - the wrapper first, because everything above it must be two-valued (19.2)
// - synchronise BEFORE filtering: filtering an asynchronous signal samples an
// unsettled flop N times instead of once (19.5 §2)
// - edges from the FILTERED level, never the raw one, or a rejected spike still
// produces an edge event
//
// WHAT THIS BLOCK DOES NOT CONTAIN: any protocol. No framing, no address, no
// acknowledge. It converts a pin into a settled level and two one-cycle events, and
// that is the entire contract Modules 17 and 18 need from it.
// -----------------------------------------------------------------------------
module i2c_fpga_front_end #(
parameter SYNC_DEPTH = 2, // 19.4 -- observation latency, in clocks
parameter N_SAMP = 3 // 19.5 -- filter threshold, in clocks
) (
input wire clk,
input wire rst_n,
// ---- the pin, as the two-signal model of Chapter 19.2 --------------------
// What this device contributes to the shared net.
output wire pin_pulls_low,
// The resolved level of the shared net.
input wire pin_resolved,
// ---- the protocol core's side --------------------------------------------
// Drive intent, exactly as Modules 17 and 18 emit it: 1 = pull LOW.
input wire drive_low,
// The settled, filtered level -- what the line IS, as far as this device can know.
output wire level,
// One-cycle events on the filtered level.
output wire rise,
output wire fall,
// ---- observability (Chapter 19.8) ----------------------------------------
// The SYNCHRONISED but UNFILTERED level, brought out so an ILA can see the stage
// the filter is deciding about. Without it, a bus being eaten by an over-aggressive
// filter and a bus with no traffic look identical from inside.
output wire level_unfiltered,
// MUST STAY 0 for a conforming driver -- Chapter 19.2's legality monitor.
output wire drives_high,
// Disturbances rejected, and levels accepted. 19.5 argues for exporting both.
output wire [15:0] n_rejected,
output wire [15:0] n_accepted
);
// ---- 19.1: intent becomes a pad request ---------------------------------
wire pad_o, pad_oe;
i2c_od_out u_od (
.drive_low(drive_low), .pad_o(pad_o), .pad_oe(pad_oe),
.pulls_low(/* the wrapper applies the pad law; see below */));
// ---- 19.2: the boundary -------------------------------------------------
// NOTE the contribution comes from the WRAPPER, not from the output stage. Both
// compute the same pad law, and taking it from the wrapper means the value that
// reaches the bus is the one produced by the module that also owns the input path
// and the legality monitor -- one place, one law.
wire pin_level_raw;
i2c_io_wrapper u_io (
.pad_o(pad_o), .pad_oe(pad_oe),
.pin_pulls_low(pin_pulls_low), .pin_resolved(pin_resolved),
.pin_level(pin_level_raw), .drives_high(drives_high));
// ---- 19.4: synchronise, before anything looks at it ---------------------
// Both chains are driven from the same pin here; only one is used. The two-line
// module is instantiated per line so that its reset-to-idle behaviour and its
// latency are identical on SDA and SCL, which is the property 19.4's T2 protects.
wire sync_level, sync_unused;
wire [3:0] sync_latency;
i2c_line_sync #(.SYNC_DEPTH(SYNC_DEPTH)) u_sync (
.clk(clk), .rst_n(rst_n),
.scl_pin(pin_level_raw), .sda_pin(pin_level_raw),
.scl_q(sync_level), .sda_q(sync_unused),
.latency_clocks(sync_latency));
assign level_unfiltered = sync_level;
// ---- 19.5: filter, and detect edges on the FILTERED level ---------------
i2c_glitch_filter #(.N_SAMP(N_SAMP)) u_filt (
.clk(clk), .rst_n(rst_n), .line_sync(sync_level),
.line_filt(level), .line_rise(rise), .line_fall(fall),
.n_rejected(n_rejected), .n_accepted(n_accepted));
endmodule // -----------------------------------------------------------------------------
// i2c_fpga_target.v
// Module 18's verified target, on FPGA pins. The target-side integration shape.
//
// THE ASSEMBLY, and what each layer contributes:
//
// pins ─▶ i2c_fpga_front_end ×2 ─▶ i2c_slave ─▶ register file / application
// 19.1 19.2 19.4 19.5 Module 18
// │
// └─▶ i2c_bus_health (19.8) -- observation only
//
// ONE DECISION IS WORTH READING TWICE, AND IT IS NOT THE ONE I EXPECTED TO WRITE.
//
// `i2c_slave` contains its OWN synchroniser -- Chapter 18.2's `i2c_slave_sync`, with
// SYNC_DEPTH exposed as a parameter precisely so an integrator can adjust it. Feeding
// an already-synchronised, already-filtered level into a second two-stage
// synchroniser doubles the observation latency for no benefit, so the obvious
// composition is SYNC_DEPTH = 1: one register, which is what a signal that is already
// synchronous needs, while still getting 18.2's edge detection.
//
// THAT VALUE DOES NOT ELABORATE. `i2c_slave_sync` shifts its chain with
// `{pin, chain[SYNC_DEPTH-1:1]}`, which at SYNC_DEPTH = 1 is the part select
// `chain[0:1]` -- out of order, and rejected. Verified directly: the module
// elaborates at depths 2 and 3 and fails at 1.
//
// It is the same defect Chapter 19.4 found in its own first draft, and 19.4 found it
// only because its bench instantiates depths 1, 2 and 3 while Module 18's instantiates
// the default. Module 18 is locked, so it is NOT modified here; the finding is
// reported and designed around.
//
// SO THE SLAVE RUNS AT SYNC_DEPTH = 2, and the second synchroniser is redundant but
// harmless: the signal reaching it is already synchronous, so the two extra registers
// add latency and nothing else. The cost is stated rather than hidden --
//
// total observation latency = SYNC_DEPTH (front end)
// + N_SAMP (filter)
// + 2 (the slave's own synchroniser)
// = 7 clocks at the defaults
//
// which at 50 MHz is 140 ns against a Fast-mode bit period of 2500 ns: 5.6%, and
// comfortably inside the budget. If that mattered, the fix would be a one-character
// change in Module 18 rather than an architectural change here.
// -----------------------------------------------------------------------------
module i2c_fpga_target #(
parameter [6:0] MY_ADDR = 7'h50,
parameter N_REG = 8,
parameter RO_MASK = 8'h04,
parameter IDLE_CYCLES = 512,
parameter SYNC_DEPTH = 2, // 19.4, in the front end
parameter N_SAMP = 3, // 19.5, in the front end
parameter CNT_W = 16
) (
input wire clk,
input wire rst_n,
// ---- the pins, as Chapter 19.2's two-signal model ------------------------
// One `inout` per line appears only at the true top level, where a vendor
// primitive replaces this pair. Chapter 19.2 §6.
output wire scl_pulls_low,
input wire scl_resolved,
output wire sda_pulls_low,
input wire sda_resolved,
// ---- the application ------------------------------------------------------
input wire stall_req,
output wire [8*N_REG-1:0] reg_flat,
output wire [7:0] pointer,
// ---- diagnostics: Module 18's, plus Chapter 19's -------------------------
output wire selected,
output wire stretching,
output wire [CNT_W-1:0] n_writes,
output wire [CNT_W-1:0] n_reads,
output wire [CNT_W-1:0] n_sda_conflict,
// 19.2: MUST STAY 0 on both lines.
output wire drives_high,
// 19.5: margin, not correctness.
output wire [15:0] n_rejected_sda,
output wire [15:0] n_rejected_scl,
// 19.8: the four bring-up bits.
output wire bus_idle,
output wire bus_activity,
output wire scl_stuck_low,
output wire sda_stuck_low,
// Edges seen on either line. BROUGHT OUT, not left unconnected: Chapter 19.8's
// DebugLab is about a diagnostic output wired to nothing, which synthesis removes
// silently. An earlier version of this file left it dangling -- and mutation H06
// (which points the health block at the filtered level instead of the synchronised
// one) survived, because with no edge count exported the two views were
// indistinguishable from outside.
output wire [15:0] n_bus_edges
);
// ---- the target's drive intent, from Module 18 ---------------------------
wire scl_drive_low, sda_drive_low;
// ---- the filtered, settled view of each line ----------------------------
wire scl_level, sda_level;
wire scl_unfilt, sda_unfilt;
wire scl_rise, scl_fall, sda_rise, sda_fall; // 19.5's edges
wire scl_dh, sda_dh;
wire [15:0] scl_acc, sda_acc;
// ---- 19.1/19.2/19.4/19.5, once per line ---------------------------------
i2c_fpga_front_end #(.SYNC_DEPTH(SYNC_DEPTH), .N_SAMP(N_SAMP)) u_scl (
.clk(clk), .rst_n(rst_n),
.pin_pulls_low(scl_pulls_low), .pin_resolved(scl_resolved),
.drive_low(scl_drive_low),
.level(scl_level), .rise(scl_rise), .fall(scl_fall),
.level_unfiltered(scl_unfilt), .drives_high(scl_dh),
.n_rejected(n_rejected_scl), .n_accepted(scl_acc));
i2c_fpga_front_end #(.SYNC_DEPTH(SYNC_DEPTH), .N_SAMP(N_SAMP)) u_sda (
.clk(clk), .rst_n(rst_n),
.pin_pulls_low(sda_pulls_low), .pin_resolved(sda_resolved),
.drive_low(sda_drive_low),
.level(sda_level), .rise(sda_rise), .fall(sda_fall),
.level_unfiltered(sda_unfilt), .drives_high(sda_dh),
.n_rejected(n_rejected_sda), .n_accepted(sda_acc));
// Either line asking the pad for a one is illegal. ORed so a single bit can be
// taken to a pin or an assertion; each line's own monitor is inside its front end.
assign drives_high = scl_dh | sda_dh;
// ---- Module 18's verified target ----------------------------------------
// SYNC_DEPTH(2), not 1 -- see the header. 1 is the value this composition wants
// and `i2c_slave_sync` does not elaborate at it. What is wanted from 18.2 here is
// its edge detection; the synchronising is redundant and costs two clocks.
i2c_slave #(
.MY_ADDR(MY_ADDR), .N_REG(N_REG), .RO_MASK(RO_MASK),
.IDLE_CYCLES(IDLE_CYCLES), .SYNC_DEPTH(2), .CNT_W(CNT_W)
) u_slave (
.clk(clk), .rst_n(rst_n),
.scl_pin(scl_level), .sda_pin(sda_level),
.scl_drive_low(scl_drive_low), .sda_drive_low(sda_drive_low),
.stall_req(stall_req),
.reg_flat(reg_flat), .pointer(pointer),
.selected(selected), .stretching(stretching),
.n_phases(), .n_restarts(),
.n_writes(n_writes), .n_refused(), .n_reads(n_reads),
.n_aborts(), .n_sda_conflict(n_sda_conflict));
// ---- 19.8: the bring-up aid, watching the SYNCHRONISED levels -----------
// Deliberately the unfiltered-but-synchronised view: a stuck line is a stuck line
// whether or not a filter would have accepted the level, and reading the filtered
// level would make the diagnostic depend on the filter it may need to diagnose.
i2c_bus_health #(.IDLE_CLKS(256), .STUCK_CLKS(4096), .EDGE_W(16)) u_health (
.clk(clk), .rst_n(rst_n),
.scl_q(scl_unfilt), .sda_q(sda_unfilt),
.idle(bus_idle), .scl_stuck_low(scl_stuck_low),
.sda_stuck_low(sda_stuck_low), .activity(bus_activity),
.n_edges(n_bus_edges));
endmodule // -----------------------------------------------------------------------------
// i2c_fpga_target_tb.v
// End-to-end oracle for the assembled FPGA target.
//
// WHAT IS BEING PROVEN, and it is a composition claim rather than a protocol one:
// Module 18's target was verified against an IDEAL bus with its own synchroniser and
// nothing else. This bench drives REAL PINS, through Chapter 19's pad, synchroniser
// and filter, on Module 16's wired-AND bus model -- and requires a complete write and
// a complete read to still work.
//
// THE BENCH IS A CONTROLLER driving the pins by hand, in the style Chapter 18.1
// argues for: it changes SDA only while SCL is LOW for data and only while SCL is
// HIGH for framing. The half-period is long enough to absorb the front end's
// latency, which is itself the point -- Section 5 of the chapter works out what
// "long enough" means and this bench is where the number came from.
//
// Every wait is a fixed number of clocks; nothing waits on the DUT.
//
// (Verilog-2001 -- the same tests as the SystemVerilog bench.)
// -----------------------------------------------------------------------------
`timescale 1ns/1ps
module i2c_fpga_target_tb;
localparam HALF = 24; // clocks per SCL half-phase -- see §5
localparam [6:0] ADDR = 7'h50;
localparam [6:0] NEAR = ADDR ^ 7'h40; // 0x10: one bit away (18.4 / 18.11)
localparam N_REG = 8;
localparam ROM = 8'h04; // register 2 read-only
localparam SYNCD = 2;
localparam NSAMP = 3;
reg clk = 1'b0, rst_n = 1'b0;
// device 0 = the bench's controller, device 1 = the DUT
reg m_scl_low = 1'b0, m_sda_low = 1'b0;
wire d_scl_low, d_sda_low;
wire scl, sda;
wire [1:0] scl_in, sda_in, scl_rbl, sda_rbl;
wire [7:0] scl_holders, sda_holders;
reg stall_req = 1'b0;
wire [8*N_REG-1:0] reg_flat;
wire [7:0] pointer;
wire selected, stretching, drives_high;
wire [15:0] n_writes, n_reads, n_sda_conflict;
wire [15:0] n_rej_sda, n_rej_scl;
wire bus_idle, bus_activity, scl_stuck, sda_stuck;
wire [15:0] n_bus_edges;
integer errors = 0;
integer n, k, i;
reg [7:0] rdbyte;
integer edges_before_spike, edges_after_spike;
reg ackbit;
i2c_line_model #(.N_DEV(2)) bus (
.scl_drive_low({d_scl_low, m_scl_low}),
.sda_drive_low({d_sda_low, m_sda_low}),
.scl(scl), .sda(sda), .scl_in(scl_in), .sda_in(sda_in),
.scl_released_but_low(scl_rbl), .sda_released_but_low(sda_rbl),
.scl_holders(scl_holders), .sda_holders(sda_holders));
i2c_fpga_target #(
.MY_ADDR(ADDR), .N_REG(N_REG), .RO_MASK(ROM),
.IDLE_CYCLES(100000), .SYNC_DEPTH(SYNCD), .N_SAMP(NSAMP), .CNT_W(16)
) dut (
.clk(clk), .rst_n(rst_n),
.scl_pulls_low(d_scl_low), .scl_resolved(scl),
.sda_pulls_low(d_sda_low), .sda_resolved(sda),
.stall_req(stall_req),
.reg_flat(reg_flat), .pointer(pointer),
.selected(selected), .stretching(stretching),
.n_writes(n_writes), .n_reads(n_reads), .n_sda_conflict(n_sda_conflict),
.drives_high(drives_high),
.n_rejected_sda(n_rej_sda), .n_rejected_scl(n_rej_scl),
.bus_idle(bus_idle), .bus_activity(bus_activity),
.scl_stuck_low(scl_stuck), .sda_stuck_low(sda_stuck),
.n_bus_edges(n_bus_edges));
// The illegal combination must never occur, in any cycle of any test. Checked
// continuously rather than at test boundaries, because a one-cycle violation
// between two checks would otherwise be invisible -- Chapter 18.11's argument for
// n_sda_conflict, applied to Chapter 19.2's monitor.
integer dh_seen = 0;
always @(posedge clk) if (rst_n && drives_high) dh_seen <= dh_seen + 1;
always #5 clk = ~clk;
task step; begin @(posedge clk); @(negedge clk); end endtask
task phase; begin for (n = 0; n < HALF; n = n + 1) step; end endtask
task do_reset;
begin
@(negedge clk); rst_n = 1'b0; m_scl_low = 1'b0; m_sda_low = 1'b0;
step; step; step;
@(negedge clk); rst_n = 1'b1;
// Let the front end settle and the health block see an idle bus.
for (n = 0; n < 300; n = n + 1) step;
dh_seen = 0;
end
endtask
// ---- the controller, driving pins ---------------------------------------
task m_start;
begin
@(negedge clk); m_sda_low = 1'b0; m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b1; phase; // SDA falls, SCL high
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_restart;
begin
@(negedge clk); m_scl_low = 1'b1; m_sda_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b1; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_stop;
begin
@(negedge clk); m_scl_low = 1'b1; m_sda_low = 1'b1; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_sda_low = 1'b0; phase; // SDA rises, SCL high
end
endtask
// One bit out. SDA changes only while SCL is LOW.
task m_bit (input b);
begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = ~b; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
// The ninth slot, with the controller released so the target can answer.
// `a` returns the bit that was on the wire: 0 = ACK.
task m_ack_slot (output a);
begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
// Sample late in the high phase, after the target's own latency.
for (n = 0; n < HALF - 2; n = n + 1) step;
a = sda;
step; step;
@(negedge clk); m_scl_low = 1'b1; phase;
end
endtask
task m_put (input [7:0] d, output a);
begin
for (k = 7; k >= 0; k = k - 1) m_bit(d[k]);
m_ack_slot(a);
end
endtask
// One byte in, with the controller answering `ack` in the ninth slot.
task m_get (input ack, output [7:0] d);
begin
d = 8'h00;
for (k = 7; k >= 0; k = k - 1) begin
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase; // released: target drives
@(negedge clk); m_scl_low = 1'b0; phase;
for (n = 0; n < HALF - 2; n = n + 1) step;
d[k] = sda;
step; step;
@(negedge clk); m_scl_low = 1'b1; phase;
end
// the controller's own acknowledge
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = ack; phase;
@(negedge clk); m_scl_low = 1'b0; phase;
@(negedge clk); m_scl_low = 1'b1; phase;
@(negedge clk); m_sda_low = 1'b0; phase;
end
endtask
task ck (input [200*8:1] what, input integer g, input integer e);
begin
if (g !== e) begin
$display(" FAIL %0s: got %0d expected %0d", what, g, e);
errors = errors + 1;
end
end
endtask
initial begin
$display("=== i2c_fpga_target: the verified target, on pins, through the front end ===");
// ----------------------------------------------------------------
// T1. RESET RELEASES BOTH LINES, AND THE HEALTH BLOCK AGREES. A target holding
// either line at power-on is the one failure it can inflict on every other
// device on the bus.
// ----------------------------------------------------------------
do_reset;
$display("T1 a reset target releases both lines, and the bus reads idle");
ck("T1 not pulling SCL", d_scl_low, 0);
ck("T1 not pulling SDA", d_sda_low, 0);
ck("T1 SCL idles high", scl, 1);
ck("T1 SDA idles high", sda, 1);
ck("T1 the health block reports idle", bus_idle, 1);
ck("T1 and no activity yet", bus_activity, 0);
ck("T1 neither line stuck", scl_stuck | sda_stuck, 0);
ck("T1 nothing asked the pad for a one", dh_seen, 0);
// ----------------------------------------------------------------
// T2. A COMPLETE WRITE, THROUGH THE WHOLE FRONT END. Address, three data bytes,
// STOP. Register 2 is read-only, so the third byte must be NACKed -- which
// means the acknowledge decision travelled from the register file (18.9)
// through the acknowledge block (18.5), the pad (19.2) and onto the wire,
// and back to the controller. That is the longest causal chain in the module.
// ----------------------------------------------------------------
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T2 the address was acknowledged", ackbit, 0);
ck("T2 and the target says it is selected", selected, 1);
m_put(8'h00, ackbit); // pointer byte
ck("T2 the pointer byte was acknowledged", ackbit, 0);
m_put(8'h11, ackbit); // -> reg 0
ck("T2 reg 0 write acknowledged", ackbit, 0);
m_put(8'h12, ackbit); // -> reg 1
ck("T2 reg 1 write acknowledged", ackbit, 0);
m_put(8'h13, ackbit); // -> reg 2, READ-ONLY
$display("T2 a full write arrives, and the read-only refusal reaches the wire");
ck("T2 the read-only register NACKed on the wire", ackbit, 1);
m_stop;
ck("T2 reg 0 landed", reg_flat[0*8 +: 8], 8'h11);
ck("T2 reg 1 landed", reg_flat[1*8 +: 8], 8'h12);
ck("T2 reg 2 untouched", reg_flat[2*8 +: 8], 8'h00);
ck("T2 two writes counted", n_writes, 2);
ck("T2 no internal driver conflict", n_sda_conflict, 0);
ck("T2 and the pad was never asked for a one", dh_seen, 0);
// ----------------------------------------------------------------
// T3. AND A COMPLETE READ BACK. Write the pointer, repeated START, read two
// bytes. The data now travels the other way: register file -> transmit
// datapath (18.7) -> pad (19.2) -> wire -> the controller's sampler. Nothing
// in Module 18 was ever verified with a filter in this path.
// ----------------------------------------------------------------
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T3 addressed for write", ackbit, 0);
m_put(8'h00, ackbit); // point at register 0
ck("T3 pointer set", ackbit, 0);
m_restart;
m_put({ADDR, 1'b1}, ackbit); // now read
ck("T3 addressed for read", ackbit, 0);
m_get(1'b1, rdbyte); // ACK -> send another
ck("T3 the first byte came back off the wire", rdbyte, 8'h11);
m_get(1'b0, rdbyte); // NACK -> stop
$display("T3 a combined transfer reads back exactly what was written");
ck("T3 the second byte too", rdbyte, 8'h12);
m_stop;
ck("T3 two reads counted", n_reads, 2);
ck("T3 still no driver conflict", n_sda_conflict, 0);
ck("T3 and still no drive-high", dh_seen, 0);
// ----------------------------------------------------------------
// T4. A ONE-BIT NEAR-MISS ADDRESS IS STILL REFUSED. Chapter 18.4's seven-bit
// discrimination and 18.11's integrated near-miss, now with a filter and a
// synchroniser in the path. A front end that corrupted one address bit would
// pass T2 and T3 and fail here.
// ----------------------------------------------------------------
m_start;
m_put({NEAR, 1'b0}, ackbit);
$display("T4 an address one bit away is refused, through the whole front end");
ck("T4 the near miss was NOT acknowledged", ackbit, 1);
ck("T4 and we were never selected", selected, 0);
m_put(8'h77, ackbit);
ck("T4 its data byte is ignored too", ackbit, 1);
ck("T4 and nothing was written", reg_flat[0*8 +: 8], 8'h11);
m_stop;
// ----------------------------------------------------------------
// T5. THE HEALTH BLOCK SAW ALL OF THAT. Its whole purpose is to distinguish a
// working bus from a dead one, so after four transactions it must report
// activity -- and after the STOP, idle again.
// ----------------------------------------------------------------
ck("T5 activity was seen", bus_activity, 1);
ck("T5 neither line reported stuck", scl_stuck | sda_stuck, 0);
for (n = 0; n < 300; n = n + 1) step;
$display("T5 the bring-up block reports a healthy, resting bus afterwards");
ck("T5 and the bus is idle again", bus_idle, 1);
// ----------------------------------------------------------------
// T6. THE FILTER REJECTED NOTHING, BECAUSE NOTHING WAS WRONG. On a clean bus
// `n_rejected` must be zero on BOTH lines -- which is the check that turns
// the counter from a number into a margin indicator. A non-zero value here
// would mean the bench's own stimulus was producing disturbances.
// ----------------------------------------------------------------
$display("T6 a clean bus rejected nothing: the filter cost no real edges");
ck("T6 no SDA disturbances rejected", n_rej_sda, 0);
ck("T6 no SCL disturbances rejected", n_rej_scl, 0);
// ----------------------------------------------------------------
// T7. CLOCK STRETCHING, THROUGH THE FRONT END. The application asks for time,
// and the target must pull SCL -- which means its SCL drive intent has to
// travel through Chapter 19.1's output stage and 19.2's wrapper to the pin,
// exactly as SDA's does. Nothing before this test exercises the SCL output
// path at all: a target that could not stretch would pass T1 to T6, and
// mutations H02 and H11 (which sever SCL drive intent and `stall_req`) both
// survived until this test existed.
// ----------------------------------------------------------------
do_reset;
// The request must be up BEFORE the acknowledge slot closes: Chapter 18.10
// engages the stretch on the falling edge that terminates the ninth pulse, so a
// request asserted after `m_put` returns has already missed its window. An
// earlier draft asserted it afterwards and saw no stretch at all -- the target
// was correct and the stimulus was late.
@(negedge clk); stall_req = 1'b1;
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T7 addressed for write", ackbit, 0);
$display("T7 the target stretches SCL, and the pull reaches the pin");
ck("T7 the target reports stretching", stretching, 1);
ck("T7 and is actually pulling SCL at the pin", d_scl_low, 1);
ck("T7 so the resolved SCL line is LOW", scl, 0);
// Release the request; the target must let go.
@(negedge clk); stall_req = 1'b0;
for (n = 0; n < 40; n = n + 1) step;
ck("T7 it stops stretching when the application is ready", stretching, 0);
ck("T7 and releases SCL", d_scl_low, 0);
ck("T7 it never asked the pad for a one", dh_seen, 0);
m_stop;
// ----------------------------------------------------------------
// T8. A DISTURBANCE ON THE BUS IS REJECTED, AND THE TRANSFER SURVIVES IT.
//
// This is the test that makes the filter load-bearing rather than merely
// present. The bench pulls SDA low for two clocks -- below the N_SAMP = 3
// threshold -- in the middle of an SCL LOW phase, where a real spike would
// land. Two things must be true afterwards: the filter must have COUNTED a
// rejection, and the transfer must complete correctly anyway.
//
// Mutation H04, which feeds the slave the UNFILTERED level, survived every
// other test in this bench: on a clean bus the filtered and unfiltered
// views differ only by N_SAMP clocks of latency, which a 24-clock half
// period absorbs completely. It takes an actual disturbance to tell them
// apart, which is the whole reason the filter exists.
// ----------------------------------------------------------------
do_reset;
m_start;
m_put({ADDR, 1'b0}, ackbit);
ck("T8 addressed for write", ackbit, 0);
m_put(8'h05, ackbit); // point at register 5
ck("T8 pointer accepted", ackbit, 0);
// ---- the disturbance, and WHERE it lands is the whole point ------------
//
// A blip on SDA while SCL is LOW is a legal data change and corrupts nothing
// even unfiltered -- the target samples SDA only at a rising SCL edge. An
// earlier draft injected there and mutation H04 (which bypasses the filter)
// survived, correctly: there was nothing for the filter to save.
//
// The disturbance that matters is SDA falling while SCL is HIGH, because
// Chapter 18.3 defines exactly that as a START. Unfiltered, a two-clock blip
// there aborts the transfer and reframes the target mid-byte. Filtered, at
// N_SAMP = 3, it never reaches the framing detector at all.
//
// So this is driven by hand rather than through `m_bit`: a data bit whose SDA
// is released, with a two-clock pull inside its SCL-HIGH phase.
@(negedge clk); m_scl_low = 1'b1; phase; // SCL low
@(negedge clk); m_sda_low = 1'b0; phase; // SDA released -> a one
@(negedge clk); m_scl_low = 1'b0; phase; // SCL HIGH, mid-bit
edges_before_spike = n_bus_edges;
// ... and now the spike, two clocks, exactly. `step` ends on a negedge, so
// releasing after another @(negedge clk) would apply the pull across a THIRD
// rising edge -- and three clocks is exactly N_SAMP, which is ACCEPTED.
@(negedge clk); m_sda_low = 1'b1;
step; step;
m_sda_low = 1'b0;
// Let the front end's latency pass, with SCL still HIGH so that NOTHING BUT
// SDA can have contributed an edge in this window. Measuring across a window
// that also contained an SCL transition was how mutation H06 first survived:
// SCL's own edge swamped the two the spike contributed.
for (n = 0; n < SYNCD + NSAMP + 4; n = n + 1) step;
edges_after_spike = n_bus_edges;
for (n = 0; n < HALF; n = n + 1) step;
@(negedge clk); m_scl_low = 1'b1; phase; // finish the bit
$display("T8 a sub-threshold spike while SCL is HIGH is rejected, not read as a START");
ck("T8 the filter rejected it on SDA", (n_rej_sda > 0) ? 1 : 0, 1);
// The consequence that matters: the target was NOT reframed. Unfiltered, the
// framing detector would have seen a START here and dropped selection.
ck("T8 and the target is still selected", selected, 1);
// Finish the byte -- seven more bits -- and confirm the data survived.
for (k = 6; k >= 0; k = k - 1) m_bit(1'b1);
m_ack_slot(ackbit);
ck("T8 the data byte was still acknowledged", ackbit, 0);
m_stop;
ck("T8 and it landed in register 5 uncorrupted", reg_flat[5*8 +: 8], 8'hFF);
ck("T8 no internal driver conflict throughout", n_sda_conflict, 0);
// The health block watches the SYNCHRONISED-BUT-UNFILTERED view on purpose, so
// it DID see the spike's two edges even though the protocol engine did not. That
// is the intended split: a stuck line is a stuck line whether or not a filter
// would have accepted it, and a diagnostic that read the filtered level would
// depend on the filter it may need to diagnose. Mutation H06 points it at the
// filtered level, and this is the check that notices.
ck("T8 the health block counted the spike the protocol engine ignored",
edges_after_spike - edges_before_spike, 2);
ck("T8 and the pad was never asked for a one", dh_seen, 0);
if (errors == 0) $display("=== i2c_fpga_target: ALL CHECKS PASSED ===");
else $display("=== i2c_fpga_target: %0d CHECK(S) FAILED ===", errors);
$finish;
end
endmodule -- -----------------------------------------------------------------------------
-- i2c_fpga_front_end.vhd
-- The whole of Module 19's front end for ONE bus line, as one instantiable block.
-- Behavioural twin of the SystemVerilog and Verilog versions.
--
-- WHY ONE BLOCK PER LINE. Everything Module 19 built is per-line and identical for
-- SDA and SCL. Packaging it once and instantiating twice guarantees the two lines get
-- the SAME treatment -- which Chapter 19.5 shows is not optional, because the framing
-- rules of 18.3 are defined on the RELATIONSHIP between an SDA edge and the SCL level.
--
-- THE ORDER IS NOT NEGOTIABLE, and each step is a chapter:
-- pin -> wrapper (19.2) -> synchroniser (19.4) -> filter (19.5) -> protocol core
-- Synchronise BEFORE filtering: filtering an asynchronous signal samples an unsettled
-- flop N times instead of once.
-- -----------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_fpga_front_end is
generic (
SYNC_DEPTH : positive := 2; -- 19.4 -- observation latency, in clocks
N_SAMP : positive := 3 -- 19.5 -- filter threshold, in clocks
);
port (
clk : in std_logic;
rst_n : in std_logic;
-- the pin, as Chapter 19.2's two-signal model
pin_pulls_low : out std_logic;
pin_resolved : in std_logic;
-- the protocol core's side
drive_low : in std_logic;
level : out std_logic;
rise : out std_logic;
fall : out std_logic;
-- observability (Chapter 19.8)
level_unfiltered : out std_logic;
drives_high : out std_logic;
n_rejected : out unsigned(15 downto 0);
n_accepted : out unsigned(15 downto 0)
);
end entity i2c_fpga_front_end;
architecture rtl of i2c_fpga_front_end is
signal pad_o, pad_oe : std_logic;
signal od_pulls_unused : std_logic;
signal pin_level_raw : std_logic;
signal sync_level : std_logic;
signal sync_unused : std_logic;
signal sync_latency : unsigned(3 downto 0);
begin
-- 19.1: intent becomes a pad request
u_od : entity work.i2c_od_out
port map (drive_low => drive_low, pad_o => pad_o, pad_oe => pad_oe,
pulls_low => od_pulls_unused);
-- 19.2: the boundary. NOTE the contribution comes from the WRAPPER, not from the
-- output stage: both compute the same pad law, and taking it from the wrapper means
-- the value reaching the bus comes from the module that also owns the input path
-- and the legality monitor. One place, one law.
u_io : entity work.i2c_io_wrapper
port map (pad_o => pad_o, pad_oe => pad_oe,
pin_pulls_low => pin_pulls_low, pin_resolved => pin_resolved,
pin_level => pin_level_raw, drives_high => drives_high);
-- 19.4: synchronise, before anything looks at it
u_sync : entity work.i2c_line_sync
generic map (SYNC_DEPTH => SYNC_DEPTH)
port map (clk => clk, rst_n => rst_n,
scl_pin => pin_level_raw, sda_pin => pin_level_raw,
scl_q => sync_level, sda_q => sync_unused,
latency_clocks => sync_latency);
level_unfiltered <= sync_level;
-- 19.5: filter, and detect edges on the FILTERED level
u_filt : entity work.i2c_glitch_filter
generic map (N_SAMP => N_SAMP)
port map (clk => clk, rst_n => rst_n, line_sync => sync_level,
line_filt => level, line_rise => rise, line_fall => fall,
n_rejected => n_rejected, n_accepted => n_accepted);
end architecture rtl; -- -----------------------------------------------------------------------------
-- i2c_fpga_target.vhd
-- Module 18's verified target, on FPGA pins. The target-side integration shape.
-- Behavioural twin of the SystemVerilog and Verilog versions.
--
-- pins -> i2c_fpga_front_end x2 -> i2c_slave -> register file / application
-- 19.1 19.2 19.4 19.5 Module 18
-- |
-- +-> i2c_bus_health (19.8) -- observation only
--
-- ONE DECISION IS WORTH READING TWICE, AND IT IS NOT THE ONE I EXPECTED TO WRITE.
--
-- `i2c_slave` contains its own synchroniser (18.2) with SYNC_DEPTH exposed precisely
-- so an integrator can adjust it. Feeding an already-synchronised, already-filtered
-- level into a second two-stage synchroniser doubles the latency for no benefit, so
-- the obvious composition is SYNC_DEPTH = 1.
--
-- THAT VALUE DOES NOT ELABORATE. `i2c_slave_sync` shifts its chain with a part select
-- that becomes out-of-order at depth 1. Verified directly: it elaborates at 2 and 3
-- and fails at 1. It is the same defect Chapter 19.4 found in its own first draft, and
-- 19.4 found it only because its bench instantiates depths 1, 2 and 3 while Module
-- 18's instantiates the default. Module 18 is locked, so it is not modified here.
--
-- SO THE SLAVE RUNS AT SYNC_DEPTH = 2, redundantly but harmlessly, and the cost is
-- stated: SYNC_DEPTH + N_SAMP + 2 = 7 clocks of observation latency at the defaults,
-- which at 50 MHz is 140 ns against a Fast-mode bit period of 2500 ns.
-- -----------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_fpga_target is
generic (
MY_ADDR : std_logic_vector(6 downto 0) := "1010000";
N_REG : positive := 8;
RO_MASK : natural := 4;
IDLE_CYCLES : positive := 512;
SYNC_DEPTH : positive := 2;
N_SAMP : positive := 3;
CNT_W : positive := 16
);
port (
clk : in std_logic;
rst_n : in std_logic;
-- the pins, as Chapter 19.2's two-signal model
scl_pulls_low : out std_logic;
scl_resolved : in std_logic;
sda_pulls_low : out std_logic;
sda_resolved : in std_logic;
-- the application
stall_req : in std_logic;
reg_flat : out std_logic_vector(8*N_REG-1 downto 0);
pointer : out std_logic_vector(7 downto 0);
-- diagnostics: Module 18's, plus Chapter 19's
selected : out std_logic;
stretching : out std_logic;
n_writes : out unsigned(CNT_W-1 downto 0);
n_reads : out unsigned(CNT_W-1 downto 0);
n_sda_conflict : out unsigned(CNT_W-1 downto 0);
drives_high : out std_logic;
n_rejected_sda : out unsigned(15 downto 0);
n_rejected_scl : out unsigned(15 downto 0);
bus_idle : out std_logic;
bus_activity : out std_logic;
scl_stuck_low : out std_logic;
sda_stuck_low : out std_logic;
-- Brought out, not left unconnected: Chapter 19.8's DebugLab is about a
-- diagnostic wired to nothing, which synthesis removes silently.
n_bus_edges : out unsigned(15 downto 0)
);
end entity i2c_fpga_target;
architecture rtl of i2c_fpga_target is
signal scl_drive_low, sda_drive_low : std_logic;
signal scl_level, sda_level : std_logic;
signal scl_unfilt, sda_unfilt : std_logic;
signal scl_rise, scl_fall : std_logic;
signal sda_rise, sda_fall : std_logic;
signal scl_dh, sda_dh : std_logic;
signal scl_acc, sda_acc : unsigned(15 downto 0);
signal n_phases_u, n_restarts_u, n_refused_u, n_aborts_u : unsigned(CNT_W-1 downto 0);
begin
-- 19.1/19.2/19.4/19.5, once per line
u_scl : entity work.i2c_fpga_front_end
generic map (SYNC_DEPTH => SYNC_DEPTH, N_SAMP => N_SAMP)
port map (clk => clk, rst_n => rst_n,
pin_pulls_low => scl_pulls_low, pin_resolved => scl_resolved,
drive_low => scl_drive_low,
level => scl_level, rise => scl_rise, fall => scl_fall,
level_unfiltered => scl_unfilt, drives_high => scl_dh,
n_rejected => n_rejected_scl, n_accepted => scl_acc);
u_sda : entity work.i2c_fpga_front_end
generic map (SYNC_DEPTH => SYNC_DEPTH, N_SAMP => N_SAMP)
port map (clk => clk, rst_n => rst_n,
pin_pulls_low => sda_pulls_low, pin_resolved => sda_resolved,
drive_low => sda_drive_low,
level => sda_level, rise => sda_rise, fall => sda_fall,
level_unfiltered => sda_unfilt, drives_high => sda_dh,
n_rejected => n_rejected_sda, n_accepted => sda_acc);
-- Either line asking the pad for a one is illegal.
drives_high <= scl_dh or sda_dh;
-- Module 18's verified target. SYNC_DEPTH => 2, not 1 -- see the header.
u_slave : entity work.i2c_slave
generic map (MY_ADDR => MY_ADDR, N_REG => N_REG, RO_MASK => RO_MASK,
IDLE_CYCLES => IDLE_CYCLES, SYNC_DEPTH => 2, CNT_W => CNT_W)
port map (clk => clk, rst_n => rst_n,
scl_pin => scl_level, sda_pin => sda_level,
scl_drive_low => scl_drive_low, sda_drive_low => sda_drive_low,
stall_req => stall_req,
reg_flat => reg_flat, pointer => pointer,
selected => selected, stretching => stretching,
n_phases => n_phases_u, n_restarts => n_restarts_u,
n_writes => n_writes, n_refused => n_refused_u, n_reads => n_reads,
n_aborts => n_aborts_u, n_sda_conflict => n_sda_conflict);
-- 19.8: the bring-up aid, on the SYNCHRONISED-BUT-UNFILTERED view. A stuck line is
-- a stuck line whether or not a filter would have accepted the level, and reading
-- the filtered level would make the diagnostic depend on the filter it may need to
-- diagnose. Mutation H06 points it at the filtered level and is killed.
u_health : entity work.i2c_bus_health
generic map (IDLE_CLKS => 256, STUCK_CLKS => 4096, EDGE_W => 16)
port map (clk => clk, rst_n => rst_n,
scl_q => scl_unfilt, sda_q => sda_unfilt,
idle => bus_idle, scl_stuck_low => scl_stuck_low,
sda_stuck_low => sda_stuck_low, activity => bus_activity,
n_edges => n_bus_edges);
end architecture rtl; -- -----------------------------------------------------------------------------
-- i2c_fpga_target_tb.vhd
-- End-to-end oracle for the assembled FPGA target.
-- Behavioural twin of the SystemVerilog and Verilog benches.
--
-- WHAT IS PROVEN is a COMPOSITION claim, not a protocol one: Module 18's target was
-- verified against an ideal bus with its own synchroniser and nothing else. This bench
-- drives real pins, through Chapter 19's pad, synchroniser and filter, on Module 16's
-- wired-AND bus -- and requires a complete write and a complete read to still work.
--
-- THE BENCH IS A CONTROLLER driving the pins by hand, changing SDA only while SCL is
-- LOW for data and only while SCL is HIGH for framing (Chapter 18.1's argument).
-- -----------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_fpga_target_tb is
end entity i2c_fpga_target_tb;
architecture sim of i2c_fpga_target_tb is
constant HALF : integer := 24; -- clocks per SCL half-phase
constant ADDRV : std_logic_vector(6 downto 0) := "1010000"; -- 0x50
constant NEARV : std_logic_vector(6 downto 0) := "0010000"; -- 0x10, one bit away
constant NREG : integer := 8;
constant SYNCD : integer := 2;
constant NSAMP : integer := 3;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal m_scl_low, m_sda_low : std_logic := '0';
signal d_scl_low, d_sda_low : std_logic;
signal scl, sda : std_logic;
signal scl_in, sda_in, scl_rbl, sda_rbl : std_logic_vector(1 downto 0);
signal scl_holders, sda_holders : unsigned(7 downto 0);
signal sda_drv, scl_drv : std_logic_vector(1 downto 0);
signal stall_req : std_logic := '0';
signal reg_flat : std_logic_vector(8*NREG-1 downto 0);
signal pointer : std_logic_vector(7 downto 0);
signal selected, stretching, drives_high : std_logic;
signal n_writes, n_reads, n_sda_conflict : unsigned(15 downto 0);
signal n_rej_sda, n_rej_scl : unsigned(15 downto 0);
signal bus_idle, bus_activity, scl_stuck, sda_stuck : std_logic;
signal n_bus_edges : unsigned(15 downto 0);
signal dh_seen : integer := 0;
signal halt : boolean := false;
begin
clkgen : process
begin
while not halt loop
clk <= '0'; wait for 5 ns;
clk <= '1'; wait for 5 ns;
end loop;
wait;
end process;
scl_drv <= d_scl_low & m_scl_low;
sda_drv <= d_sda_low & m_sda_low;
bus_model : entity work.i2c_line_model
generic map (N_DEV => 2)
port map (scl_drive_low => scl_drv, sda_drive_low => sda_drv,
scl => scl, sda => sda, scl_in => scl_in, sda_in => sda_in,
scl_released_but_low => scl_rbl, sda_released_but_low => sda_rbl,
scl_holders => scl_holders, sda_holders => sda_holders);
dut : entity work.i2c_fpga_target
generic map (MY_ADDR => ADDRV, N_REG => NREG, RO_MASK => 4,
IDLE_CYCLES => 100000, SYNC_DEPTH => SYNCD, N_SAMP => NSAMP,
CNT_W => 16)
port map (clk => clk, rst_n => rst_n,
scl_pulls_low => d_scl_low, scl_resolved => scl,
sda_pulls_low => d_sda_low, sda_resolved => sda,
stall_req => stall_req,
reg_flat => reg_flat, pointer => pointer,
selected => selected, stretching => stretching,
n_writes => n_writes, n_reads => n_reads,
n_sda_conflict => n_sda_conflict, drives_high => drives_high,
n_rejected_sda => n_rej_sda, n_rejected_scl => n_rej_scl,
bus_idle => bus_idle, bus_activity => bus_activity,
scl_stuck_low => scl_stuck, sda_stuck_low => sda_stuck,
n_bus_edges => n_bus_edges);
-- The illegal combination must never occur, in any cycle of any test. Checked
-- continuously rather than at test boundaries, because a one-cycle violation between
-- two checks would otherwise be invisible.
dh_mon : process (clk)
begin
if rising_edge(clk) then
if rst_n = '1' and drives_high = '1' then dh_seen <= dh_seen + 1; end if;
end if;
end process;
stim : process
variable err : integer := 0;
variable ackbit : std_logic;
variable rdbyte : std_logic_vector(7 downto 0);
variable e_before, e_after : integer;
function b2i (b : std_logic) return integer is
begin
if b = '1' then return 1; else return 0; end if;
end function;
procedure step is
begin
wait until rising_edge(clk);
wait until falling_edge(clk);
end procedure;
procedure phase is
begin
for i in 1 to HALF loop step; end loop;
end procedure;
procedure do_reset is
begin
wait until falling_edge(clk);
rst_n <= '0'; m_scl_low <= '0'; m_sda_low <= '0';
step; step; step;
wait until falling_edge(clk);
rst_n <= '1';
for i in 1 to 300 loop step; end loop;
end procedure;
procedure m_start is
begin
wait until falling_edge(clk); m_sda_low <= '0'; m_scl_low <= '0'; phase;
wait until falling_edge(clk); m_sda_low <= '1'; phase;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
end procedure;
procedure m_restart is
begin
wait until falling_edge(clk); m_scl_low <= '1'; m_sda_low <= '0'; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
wait until falling_edge(clk); m_sda_low <= '1'; phase;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
end procedure;
procedure m_stop is
begin
wait until falling_edge(clk); m_scl_low <= '1'; m_sda_low <= '1'; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
wait until falling_edge(clk); m_sda_low <= '0'; phase;
end procedure;
procedure m_bit (b : std_logic) is
begin
wait until falling_edge(clk); m_scl_low <= '1'; phase;
wait until falling_edge(clk); m_sda_low <= not b; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
end procedure;
procedure m_ack_slot (a : out std_logic) is
begin
wait until falling_edge(clk); m_scl_low <= '1'; phase;
wait until falling_edge(clk); m_sda_low <= '0'; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
for i in 1 to HALF-2 loop step; end loop;
a := sda;
step; step;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
end procedure;
procedure m_put (d : std_logic_vector(7 downto 0); a : out std_logic) is
begin
for k in 7 downto 0 loop m_bit(d(k)); end loop;
m_ack_slot(a);
end procedure;
procedure m_get (ack : std_logic; d : out std_logic_vector(7 downto 0)) is
variable v : std_logic_vector(7 downto 0) := (others => '0');
begin
for k in 7 downto 0 loop
wait until falling_edge(clk); m_scl_low <= '1'; phase;
wait until falling_edge(clk); m_sda_low <= '0'; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
for i in 1 to HALF-2 loop step; end loop;
v(k) := sda;
step; step;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
end loop;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
wait until falling_edge(clk); m_sda_low <= ack; phase;
wait until falling_edge(clk); m_scl_low <= '0'; phase;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
wait until falling_edge(clk); m_sda_low <= '0'; phase;
d := v;
end procedure;
procedure ck (what : string; g : integer; e : integer) is
begin
if g /= e then
report " FAIL " & what & ": got " & integer'image(g)
& " expected " & integer'image(e) severity note;
err := err + 1;
end if;
end procedure;
function slice8 (f : std_logic_vector; idx : integer) return integer is
begin
return to_integer(unsigned(f(8*idx+7 downto 8*idx)));
end function;
begin
report "=== i2c_fpga_target: the verified target, on pins, through the front end ==="
severity note;
-- T1. Reset releases both lines, and the health block agrees. A target holding
-- either line at power-on is the one failure it can inflict on every device.
do_reset;
report "T1 a reset target releases both lines, and the bus reads idle" severity note;
ck("T1 not pulling SCL", b2i(d_scl_low), 0);
ck("T1 not pulling SDA", b2i(d_sda_low), 0);
ck("T1 SCL idles high", b2i(scl), 1);
ck("T1 SDA idles high", b2i(sda), 1);
ck("T1 the health block reports idle", b2i(bus_idle), 1);
ck("T1 and no activity yet", b2i(bus_activity), 0);
ck("T1 neither line stuck", b2i(scl_stuck) + b2i(sda_stuck), 0);
ck("T1 nothing asked the pad for a one", dh_seen, 0);
-- T2. A complete write, through the whole front end. Register 2 is read-only, so
-- the third byte must be NACKed -- which means the acknowledge decision
-- travelled from the register file (18.9) through the acknowledge block
-- (18.5), the pad (19.2) and onto the wire. The longest causal chain here.
m_start;
m_put(ADDRV & '0', ackbit);
ck("T2 the address was acknowledged", b2i(ackbit), 0);
ck("T2 and the target says it is selected", b2i(selected), 1);
m_put(x"00", ackbit);
ck("T2 the pointer byte was acknowledged", b2i(ackbit), 0);
m_put(x"11", ackbit);
ck("T2 reg 0 write acknowledged", b2i(ackbit), 0);
m_put(x"12", ackbit);
ck("T2 reg 1 write acknowledged", b2i(ackbit), 0);
m_put(x"13", ackbit);
report "T2 a full write arrives, and the read-only refusal reaches the wire"
severity note;
ck("T2 the read-only register NACKed on the wire", b2i(ackbit), 1);
m_stop;
ck("T2 reg 0 landed", slice8(reg_flat, 0), 16#11#);
ck("T2 reg 1 landed", slice8(reg_flat, 1), 16#12#);
ck("T2 reg 2 untouched", slice8(reg_flat, 2), 0);
ck("T2 two writes counted", to_integer(n_writes), 2);
ck("T2 no internal driver conflict", to_integer(n_sda_conflict), 0);
ck("T2 and the pad was never asked for a one", dh_seen, 0);
-- T3. And a complete read back. The data now travels the other way: register
-- file -> transmit datapath (18.7) -> pad (19.2) -> wire. Nothing in Module
-- 18 was ever verified with a filter in this path.
m_start;
m_put(ADDRV & '0', ackbit);
ck("T3 addressed for write", b2i(ackbit), 0);
m_put(x"00", ackbit);
ck("T3 pointer set", b2i(ackbit), 0);
m_restart;
m_put(ADDRV & '1', ackbit);
ck("T3 addressed for read", b2i(ackbit), 0);
m_get('1', rdbyte);
ck("T3 the first byte came back off the wire", to_integer(unsigned(rdbyte)), 16#11#);
m_get('0', rdbyte);
report "T3 a combined transfer reads back exactly what was written" severity note;
ck("T3 the second byte too", to_integer(unsigned(rdbyte)), 16#12#);
m_stop;
ck("T3 two reads counted", to_integer(n_reads), 2);
ck("T3 still no driver conflict", to_integer(n_sda_conflict), 0);
ck("T3 and still no drive-high", dh_seen, 0);
-- T4. A one-bit near-miss address is still refused, now with a filter and a
-- synchroniser in the path. A front end corrupting one address bit would
-- pass T2 and T3 and fail here.
m_start;
m_put(NEARV & '0', ackbit);
report "T4 an address one bit away is refused, through the whole front end"
severity note;
ck("T4 the near miss was NOT acknowledged", b2i(ackbit), 1);
ck("T4 and we were never selected", b2i(selected), 0);
m_put(x"77", ackbit);
ck("T4 its data byte is ignored too", b2i(ackbit), 1);
ck("T4 and nothing was written", slice8(reg_flat, 0), 16#11#);
m_stop;
-- T5. The health block saw all of that, and reports a resting bus afterwards.
ck("T5 activity was seen", b2i(bus_activity), 1);
ck("T5 neither line reported stuck", b2i(scl_stuck) + b2i(sda_stuck), 0);
for i in 1 to 300 loop step; end loop;
report "T5 the bring-up block reports a healthy, resting bus afterwards"
severity note;
ck("T5 and the bus is idle again", b2i(bus_idle), 1);
-- T6. The filter rejected nothing, because nothing was wrong. On a clean bus
-- n_rejected must be zero on BOTH lines -- the check that turns the counter
-- from a number into a margin indicator.
report "T6 a clean bus rejected nothing: the filter cost no real edges"
severity note;
ck("T6 no SDA disturbances rejected", to_integer(n_rej_sda), 0);
ck("T6 no SCL disturbances rejected", to_integer(n_rej_scl), 0);
-- T7. Clock stretching, through the front end. The request must be up BEFORE the
-- acknowledge slot closes: 18.10 engages the stretch on the falling edge that
-- terminates the ninth pulse, so a request asserted afterwards has missed its
-- window. Nothing before this test exercises the SCL OUTPUT path at all.
do_reset;
wait until falling_edge(clk); stall_req <= '1';
m_start;
m_put(ADDRV & '0', ackbit);
ck("T7 addressed for write", b2i(ackbit), 0);
report "T7 the target stretches SCL, and the pull reaches the pin" severity note;
ck("T7 the target reports stretching", b2i(stretching), 1);
ck("T7 and is actually pulling SCL at the pin", b2i(d_scl_low), 1);
ck("T7 so the resolved SCL line is LOW", b2i(scl), 0);
wait until falling_edge(clk); stall_req <= '0';
for i in 1 to 40 loop step; end loop;
ck("T7 it stops stretching when the application is ready", b2i(stretching), 0);
ck("T7 and releases SCL", b2i(d_scl_low), 0);
ck("T7 it never asked the pad for a one", dh_seen, 0);
m_stop;
-- T8. A disturbance is rejected, and the transfer survives it. WHERE it lands is
-- the whole point: a blip on SDA while SCL is LOW is a legal data change and
-- corrupts nothing even unfiltered. SDA falling while SCL is HIGH is a START
-- (18.3), so unfiltered it aborts the transfer and reframes the target.
do_reset;
m_start;
m_put(ADDRV & '0', ackbit);
ck("T8 addressed for write", b2i(ackbit), 0);
m_put(x"05", ackbit);
ck("T8 pointer accepted", b2i(ackbit), 0);
wait until falling_edge(clk); m_scl_low <= '1'; phase; -- SCL low
wait until falling_edge(clk); m_sda_low <= '0'; phase; -- SDA released
wait until falling_edge(clk); m_scl_low <= '0'; phase; -- SCL HIGH
e_before := to_integer(n_bus_edges);
-- a TWO-clock pull, and the width has to be exact: three clocks is exactly
-- N_SAMP and would be ACCEPTED.
wait until falling_edge(clk); m_sda_low <= '1';
step; step;
m_sda_low <= '0';
-- let the latency pass with SCL still HIGH, so nothing but SDA can contribute
-- an edge in this window.
for i in 1 to SYNCD + NSAMP + 4 loop step; end loop;
e_after := to_integer(n_bus_edges);
for i in 1 to HALF loop step; end loop;
wait until falling_edge(clk); m_scl_low <= '1'; phase;
report "T8 a sub-threshold spike while SCL is HIGH is rejected, not read as a START"
severity note;
if to_integer(n_rej_sda) > 0 then
ck("T8 the filter rejected it on SDA", 1, 1);
else
ck("T8 the filter rejected it on SDA", 0, 1);
end if;
ck("T8 and the target is still selected", b2i(selected), 1);
ck("T8 the health block counted the spike the protocol engine ignored",
e_after - e_before, 2);
for k in 6 downto 0 loop m_bit('1'); end loop;
m_ack_slot(ackbit);
ck("T8 the data byte was still acknowledged", b2i(ackbit), 0);
m_stop;
ck("T8 and it landed in register 5 uncorrupted", slice8(reg_flat, 5), 16#FF#);
ck("T8 no internal driver conflict throughout", to_integer(n_sda_conflict), 0);
ck("T8 and the pad was never asked for a one", dh_seen, 0);
if err = 0 then
report "=== i2c_fpga_target: ALL CHECKS PASSED ===" severity note;
else
report "=== i2c_fpga_target: " & integer'image(err) & " CHECK(S) FAILED ==="
severity note;
end if;
halt <= true;
wait;
end process;
end architecture sim;4. The Controller Side
The controller-side shape is the same front end with a different core behind it. Module 17's i2c_master presents the same seam, under slightly different names:
target (i2c_slave) | controller (i2c_master) | |
|---|---|---|
| drive intent out | scl_drive_low, sda_drive_low | scl_drive_low, sda_drive_low |
| resolved level in | scl_pin, sda_pin | scl_in, sda_in |
| application interface | reg_flat, pointer, stall_req | reg_addr, reg_wdata, reg_we, reg_re, reg_rdata |
| what it reports | selected, stretching | txn_busy, txn_done, txn_ok, err, arb_lost, stretch_seen |
The naming difference is worth stating plainly rather than smoothing over: *_pin and *_in mean the same thing — the resolved bus level read back — and the two modules were written in different chapters. A wrapper that assumed one name would not compile against the other.
application / soft CPU / FSM
│ reg_addr, reg_wdata, reg_we, reg_re ─▶ reg_rdata
▼
i2c_master (Module 17)
│ scl_drive_low, sda_drive_low scl_in, sda_in
▼ ▲
i2c_fpga_front_end ×2 (19.1, 19.2, 19.4, 19.5) │
│ pin_pulls_low pin_resolved │
▼ │
package pins ─────────────── the bus ──────────────────┘Two things return through the front end on the controller side, and both are why the input path must never be gated (19.2 §2):
Arbitration loss. The controller releases SDA, reads the line, and finds it LOW — so another controller is holding it. arb_lost exists because that comparison is possible, and it is impossible if the input buffer is gated by the output enable.
Clock stretching seen. The controller releases SCL and finds it still LOW, so a target is stretching. stretch_seen is the same mechanism on the other line.
5. Where a Soft CPU Attaches
i2c_master already presents a register interface — reg_addr, reg_wdata, reg_we, reg_re, reg_rdata — which is deliberate and is where a processor attaches.
Three design points survive any choice of bus:
The status register must distinguish "not finished" from "finished badly". A single done bit forces the driver to poll and then guess. txn_done with txn_ok and a separate err lets it distinguish a NACKed address from arbitration loss from a timeout — which are three different driver actions.
Arbitration loss is not an error the CPU caused. It means another controller won, and the correct response is usually to retry rather than to report a fault. Collapsing it into a general error bit makes a normal multi-controller event look like a failure.
The diagnostic counters this module accumulated belong in readable registers. n_rejected, n_sda_conflict, n_aborts, n_bus_edges — Chapter 19.8's DebugLab is about exactly what happens when they are not: synthesis removes them silently and the one measurement that would have localised a field failure is gone. An earlier version of i2c_fpga_target left n_edges unconnected, and mutation H06 survived because of it.
6. What the Mutations Found
Eighteen mutations across three languages: fifteen killed, three equivalent.
| # | connection severed | verdict |
|---|---|---|
| H01 | SDA drive intent → pad | KILLED (13) |
| H02 | SCL drive intent → pad | KILLED (1) — needed T7 |
| H03 | the two lines swapped into the slave | KILLED (23) |
| H04 | slave reads the unfiltered level | KILLED (3) — needed T8 moved |
| H05 | legality monitor tied off | EQUIVALENT |
| H06 | health block reads the filtered level | KILLED (1) — needed n_edges connected |
| H07 | SDA pin contribution never driven | KILLED (31) |
| H08 | filter fed the pin, not the synchronizer | EQUIVALENT IN SIMULATION |
| H09 | contribution from the output stage, not the wrapper | KILLED (35) |
| H10 | level_unfiltered from the pin, not the synchronizer | EQUIVALENT IN SIMULATION |
| H11 | stall_req → slave | KILLED (2) — needed T7 |
H05 is equivalent for the reason 19.1 established. pad_o arrives from i2c_od_out, which ties it to 0, so pad_oe & pad_o is provably constant here and tying the monitor off is undetectable. The property is owned by 19.2, where pad_o is an input — and 19.2's T6 kills exactly this mutation in three checks. The kill exists; it lives in the chapter where the property can be violated.
And two that cannot be killed, which is the module's thesis
7. Focused Verification Insight
Module 20 owns the verification architecture. What this chapter establishes for it:
The monitor attaches at pin_resolved, and the scoreboard may also want pin_pulls_low. The first is what the bus did; the second is what this device asked for. A scoreboard that must attribute a LOW to a device needs both, and 19.2 is what makes them separately available.
The environment should be parameterised in the front end's configuration, because SYNC_DEPTH and N_SAMP change observation latency, and a monitor that timestamps protocol events has to account for it. A monitor written against SYNC_DEPTH = 2 and run at 3 will report every event one clock late.
Coverage at this level is a cross, not a list: each protocol scenario against each front-end configuration, with the interesting cells being where a verdict changes. 19.7 §3's table is that cross with RISE_CLKS as one axis.
8. Misconceptions
9. Debugging
The target works at 50 MHz and fails on the 200 MHz board
Pitfall — one front-end parameter is in clocks and its obligation is in nanoseconds
// The assembled target from Section 2, working, on a 50 MHz board:
//
// i2c_fpga_target #(.SYNC_DEPTH(2), .N_SAMP(3)) u_target ( ... );
//
// N_SAMP = 3 at 50 MHz is 60 ns of filtering, which rejects disturbances up to about
// 40 ns -- short of the 50 ns the specification allows, but close, and deliberate.
// SYNC_DEPTH = 2 gives the sampling flop a 20 ns settling window.
//
// The design is moved to a 200 MHz board. Neither parameter is changed, because
// neither looks like it depends on the clock: one is "two flops" and the other is
// "three samples".
//
// Both depend on the clock, and they move in OPPOSITE directions.
//
// N_SAMP = 3 at 200 MHz = 15 ns of filtering. A 50 ns disturbance now spans 10
// samples and is ACCEPTED. The filter has effectively been switched off.
//
// SYNC_DEPTH = 2 at 200 MHz gives the first flop a 5 ns settling window instead of
// 20 ns. t_settle is in the EXPONENT of the MTBF expression (19.4 S5), so
// this is not a 4x reduction in reliability -- it is exponential in the
// 15 ns that were lost.Two failures with completely different signatures, from one unchanged instantiation.
The first is loud. In an electrically noisy installation, corrupted bytes at roughly one transfer in a few thousand -- and n_rejected reads ZERO throughout, which is the diagnostic tell: the filter is not rejecting anything because nothing is reaching its threshold any more. A filter that has been switched off and a bus with no disturbances look identical on that counter, and only the environment distinguishes them.
The second is quiet and much worse. Rare wrong bytes on ALL boards including quiet ones, at a rate too low to reproduce on the bench, internally consistent in every ILA capture, uncorrelated with traffic or temperature in any way anyone can pin down. That is the metastability signature from Chapter 19.7's Class 4.
The two failures have the same symptom -- occasional wrong data, no error flag -- and completely different causes, and the thing that separates them is whether the rate depends on the electrical environment. The noisy-installation one does; the metastability one does not.
Both parameters are counts of clocks, and both implement obligations expressed in time. The clock changed by 4x and neither count did.
N_SAMP: obligation is "reject up to 50 ns" -> needs 11 at 200 MHz (19.5 S5) SYNC_DEPTH: obligation is "allow enough settling" -> the first flop gets ONE clock period whatever the depth, so the only fixes are a slower clock in that domain or a device with a better tau. ADDING STAGES DOES NOT HELP, because the extra stages do not change what the first flop is given (19.4's Reason It Through works this exact case).
The first is a parameter bug with a parameter fix: N_SAMP = 11, checked against BOTH bounds -- above 11 to reject 50 ns, below 61 so a legal Fast-mode HIGH phase is not rejected (19.5 S5). The window is wide; the value was simply never recomputed.
The second is NOT fixable by changing SYNC_DEPTH, which is the counter-intuitive part and the reason the two must be separated. At 200 MHz with f_data a few hundred kilohertz the margin is probably still enormous -- but "probably" is not an engineering answer, and the answer comes from a CDC report and the device's tau, not from a simulation. Every mutation that deletes the synchroniser in this chapter's bench SURVIVES, so no amount of simulating will tell you.
The process fix is the transferable one: DERIVE BOTH PARAMETERS FROM THE CLOCK FREQUENCY AT ELABORATION rather than carrying them as literals, and fail the build when the result violates either bound --
parameter integer CLK_HZ = 200_000_000; localparam integer N_SAMP = <from CLK_HZ and the 50 ns obligation> ; // plus an elaboration-time check against the upper bound from 19.5 S5
A count that encodes a duration must name the duration somewhere a tool can check, or the next board silently invalidates it. And note which instrument caught the first failure: n_rejected reading zero in an environment known to be noisy. A counter nobody exported would have left both failures looking identical.
10. Reason It Through
11. Questions
12. What Module 19 Settled
The chapter assembled it: one front end per line, instantiated twice so the two lines cannot diverge; Module 18's verified target behind it; Module 19's diagnostic beside it; and a complete write and read proven on a wired-AND bus in three languages, with the read-only NACK travelling the longest causal chain in the module and a one-bit near-miss address still refused through the whole front end.
The assembly found two things the pieces could not. Module 18's SYNC_DEPTH has a documented value that does not elaborate — the same defect 19.4 found in itself, reported rather than fixed because Module 18 is locked. And n_edges was left unconnected in my own integration, which is precisely what 19.8's DebugLab warns about, and a mutation survived until it was wired up.
And it ends on two mutations that will not die. Deleting the synchronizer changes nothing a simulator can see, because a simulator has no aperture — so the module closes by demonstrating its own central limit rather than claiming past it.
That is the whole arc. What RTL wants is not what the pin does, is not what the bus becomes, is not what the logic eventually observes — four layers, each verified on its own terms, with the evidence for each labelled by what actually produced it: simulation where it simulated, arithmetic where it calculated, a static check where a tool was absent, and a procedure where hardware was absent.
What comes next is not more implementation. Module 20 — I²C Verification Architecture builds the verification architecture these nine chapters have been quietly making the case for — the agents, monitors, responders and scoreboards that turn a bench into an environment. Every observation this module argued for exporting is a probe that environment will want.
Continue learning
Related tutorials
- Related topic
Deriving the Master FSM — The State Machine Designed Last
There is no state machine in the finished master's top level. Every state already exists inside a block that needed it, and integration is wiring plus three decisions. Shows what integration verification catches that no block bench can, and proves one mutant equivalent by demonstrating the contention it changes never occurs.
- Related topic
Deriving the Slave FSM, Reset Behavior and Error Recovery
The whole target as one module. Owns the acknowledge policy, the SDA arbitration, what reset means for a device on a bus it does not own, and the one failure a target can inflict on every other device — plus the timeout that ends it.
- Related topic
Hardware Bring-Up and On-Chip Debug
What to do on the morning the board arrives, in what order, with what instrument. Builds a synthesizable bus-health block that answers 'is the bus even there' at a glance, works through a probe set that spans layers rather than concentrating on the FSM, and shows why an analyzer and an ILA disagreeing is the most localising evidence available.
- Related topic
Simulation vs Synthesized Hardware — Where Behavior Diverges
Eight classes of mismatch that pass in RTL simulation and fail on a board, each with why simulation passes, what hardware does instead, and what evidence exposes it. Includes a bus model with a rise time, and an experiment running Module 18's verified target against it whose result is not the expected one.
