Skip to content
VLSI Mentor

I²C · Module 20

What Should an I²C Transaction Object Represent?

Why an intent and an observation must be different types, with the comparison that holds when the DUT is removed. Builds both types and a transaction assembler against Module 18's real target, and reaches a transfer the target refused completely on which intent and observation agree perfectly — the gap a type system cannot close.

Ask what abstraction level an I²C transaction object should sit at and the answer sounds like a matter of taste. Bit, byte, phase, or a whole START-to-STOP transfer — pick one, write the class, move on.

It is not a matter of taste, and the level is not even the important part of the question. The important part is that most environments have one transaction type where they need two, and the single type is what makes circular verification easy to write and hard to see.

1. The Shape of the Mistake

Here is the code. It appears, in some form, in a very large fraction of working verification environments.

Azvya Education Pvt. Ltd.VLSI Mentor
The comparison that holds with the DUT removed
   task run_one();
      txn = sequence.next();        // we decide: write 0xA5 to register 3
      driver.send(txn);             // the driver puts that on the bus
      scoreboard.expect(txn);       // ... and we expect to see it
      observed = monitor.get();     // the monitor reads the bus back
      scoreboard.compare(observed); // 0xA5 == 0xA5.  PASS.
   endtask

Nothing in that chain involves the design. The comparison closes a loop from the sequence, through the driver, onto the wire, through the monitor and back to the sequence's own object. Comment out the DUT instantiation and every test still passes.

That is not a subtle bug. It is a closed loop, and it is written constantly because it is the shortest thing that compiles — and because there is exactly one type in scope, so scoreboard.expect(txn) reads perfectly naturally.

2. Two Types, Because There Are Two Kinds of Object

The fix is not discipline. Discipline fails, because the bad line is the natural one to write. The fix is to make the two things different types, so that the bad line does not compile.

An intent exists before the bus. It is a request. Every field in it is something the environment chose. It contains no facts about anything.

An observation exists only after the bus. It is a measurement. Every field was read off the resolved wire. It contains nothing the environment chose.

They overlap, and they are not the same shape — and the fields that exist in only one of them are the interesting ones.

Azvya Education Pvt. Ltd.VLSI Mentor
i2c_txn_pkg.sv — two transaction types, deliberately not interchangeable
   // -----------------------------------------------------------------------------
   // i2c_txn_pkg.sv
   // Two transaction types, deliberately not interchangeable.
   //
   // The single most expensive mistake in verification-environment design is having ONE
   // transaction type. One type invites this code:
   //
   //     txn = new_random_write();     // what we decided to send
   //     driver.send(txn);
   //     scoreboard.expect(txn);       // ... and what we expect to see
   //
   // which passes whenever the driver and the scoreboard agree, whether or not the DUT
   // did anything at all. The type system is where that circularity can be stopped,
   // because the two things genuinely are different kinds of object:
   //
   //   AN INTENT exists BEFORE the bus. It is a request. Every field is something the
   //   environment chose. It contains no facts.
   //
   //   AN OBSERVATION exists only AFTER the bus. It is a measurement. Every field was
   //   read off the resolved wire. It contains nothing the environment chose.
   //
   // They overlap, but they are not the same shape, and the fields that exist in only one
   // of them are the interesting ones. An observation knows whether the address was
   // acknowledged; no intent can, because acknowledgement is the target's decision. An
   // intent knows how many bytes were WANTED; an observation only knows how many arrived.
   //
   // Keeping them distinct makes the circular comparison above a type error rather than a
   // plausible line of code.
   // -----------------------------------------------------------------------------
   package i2c_txn_pkg;

      // ---- WHAT WAS ASKED FOR --------------------------------------------------
      // Constructed by the sequence, consumed by the driver. Contains no measurement.
      typedef struct packed {
         logic [6:0] addr;           // the address we chose to send
         logic       read;           // the direction we chose
         logic [2:0] n_bytes;        // how many bytes we intend to transfer
         logic [7:0] d0, d1, d2;     // write payload -- MEANINGLESS for a read
         logic       restart_first;  // we intend to open with a repeated START
      } i2c_intent_t;

      // ---- WHAT THE WIRE CARRIED ----------------------------------------------
      // Assembled by the monitor from resolved SCL/SDA. Contains no intent.
      typedef struct packed {
         logic [6:0] addr;             // the address that was actually framed
         logic       read;             // the direction bit that was actually framed
         logic       addr_acked;       // <-- NO INTENT COUNTERPART: the target decides
         logic [3:0] n_data;           // how many bytes actually completed
         logic [7:0] b0, b1, b2;       // the bytes that were actually on the wire
         logic [2:0] acks;             // <-- NO INTENT COUNTERPART, one per data byte
         logic       began_with_restart; // the framing that actually opened it
         logic       ended_by_restart;  // <-- NO INTENT COUNTERPART: how it actually ended,
         logic       ended_by_stop;     //     which the NEXT transfer's opening decides
      } i2c_observed_t;

      // ---- THE DIFFERENCE, FIELD BY FIELD -------------------------------------
      // Not a pass/fail bit. Chapter 20.3 argues that collapsing a comparison to one bit
      // throws away the only information that makes a failure diagnosable, so the result
      // names which fields differ.
      localparam int D_ADDR    = 0,
                     D_DIR     = 1,
                     D_NBYTES  = 2,
                     D_DATA    = 3,
                     D_FRAMING = 4;
      localparam int N_DIFF    = 5;

      // Bit N_DIFF of the result is DEFINED. It is low when the comparison is not
      // meaningful, and the only honest thing a caller can do with an undefined result is
      // refuse to draw a conclusion from it.
      //
      // The comparison is undefined for a READ, and the reason is worth being exact about.
      // For a read, the payload on the wire came from the TARGET. Nothing in the intent
      // predicts it -- the intent's d0..d2 were never sent anywhere. Comparing them to the
      // observed bytes would not be a weak check; it would be a check of two unrelated
      // quantities that happens to be expressible. What a read's payload must be compared
      // against is the device contract, which is Chapter 20.8's predictor, not this
      // function.
      function automatic logic [N_DIFF:0] i2c_diff(input i2c_intent_t  w,
                                                   input i2c_observed_t o);
         logic [N_DIFF-1:0] m;
         begin
            m = {N_DIFF{1'b0}};
            if (w.read) begin
               i2c_diff = {1'b0, {N_DIFF{1'b0}}};   // UNDEFINED -- see above
            end else begin
               m[D_ADDR]    = (w.addr !== o.addr);
               m[D_DIR]     = (w.read !== o.read);
               m[D_NBYTES]  = ({1'b0, w.n_bytes} !== o.n_data);
               m[D_DATA]    = (w.n_bytes > 3'd0 && w.d0 !== o.b0)
                           || (w.n_bytes > 3'd1 && w.d1 !== o.b1)
                           || (w.n_bytes > 3'd2 && w.d2 !== o.b2);
               // Note WHICH framing field this compares. A transfer's OPENING is
               // something the controller chose, so an intent can carry it. Its ENDING
               // is decided by whatever framing event arrives next -- possibly the next
               // transfer's repeated START -- so no intent for THIS transfer can predict
               // it. The observation records both; only one has an intent to compare to.
               m[D_FRAMING] = (w.restart_first !== o.began_with_restart);
               i2c_diff = {1'b1, m};
            end
         end
      endfunction

      // The two fields that correspond in EVERY direction. Useful on its own: a read whose
      // payload cannot be compared to an intent still has a comparable address and
      // direction, and losing that check because the payload check is impossible would be
      // giving up more than the situation requires.
      function automatic logic [1:0] i2c_diff_framing_only(input i2c_intent_t  w,
                                                           input i2c_observed_t o);
         i2c_diff_framing_only = {(w.read !== o.read), (w.addr !== o.addr)};
      endfunction

   endpackage

Three asymmetries in that file are worth naming explicitly, because each one is a check a single-type environment silently cannot make.

addr_acked has no intent counterpart. Whether the address was acknowledged is the target's decision, taken from a contract the intent knows nothing about. No amount of care with the stimulus produces an expected value for it.

began_with_restart has one and ended_by_restart does not. A transfer's opening framing is something the controller chose, so an intent can state it. Its ending is decided by whatever framing event arrives next — possibly the next transfer's repeated START — so no intent for this transfer can predict it. The observation records both; only one has an intent to be compared against.

The read case is refused rather than weakened. For a read, the payload on the wire came from the target. The intent's d0 to d2 were never sent anywhere, so comparing them to the observed bytes is not a weak check — it is a comparison of two unrelated quantities that happens to be expressible. i2c_diff returns a defined bit that is low for a read, and the only honest thing a caller can do with an undefined result is decline to conclude anything from it.

3. What the Level Decision Actually Costs

With two types established, the level question becomes answerable, because it is really a question about what each level discards.

levelwhat one object iswhat it discardswhat you lose by checking only here
bitone SCL periodnothingno notion of a byte, so no acknowledge and no address
byteeight bits and an acknowledgeedge timingcannot express "which transfer" or "which register"
phaseaddress, or a run of dataframing relationshipsrepeated START versus STOP becomes invisible
transactionSTART to the next framing eventindividual bit timingsignal-level faults: two devices pulling low at once

There is no level at which everything is visible, which is the real content of Chapter 20.1's four-level argument. The transaction level is the right level for the scoreboard because the device contract is written in transaction terms — "a write lands in the register the pointer selects" is a statement about transfers, not bits. It is the wrong level for a protocol checker, and putting protocol rules there is how "SDA must not change while SCL is high" gets checked nowhere.

So: assemble transactions for the scoreboard, and keep the byte and signal levels reporting independently. Do not replace them.

4. The Assembler

The step from bytes to transactions has to happen somewhere. Doing it inside a checker means re-deriving, at every comparison, which transfer a byte belonged to and where in that transfer it sat — and that derivation, scattered across a checker, is where environments accumulate their subtlest bugs.

Azvya Education Pvt. Ltd.VLSI Mentor
i2c_txn_asm.sv — signal level to transaction level, once
   // -----------------------------------------------------------------------------
   // i2c_txn_asm.sv
   // Signal level -> transaction level. The step that is usually skipped.
   //
   // Chapter 20.7's monitor reports BYTES: one pulse per byte, with its acknowledge bit and
   // whether it was the address. That is the protocol level. A scoreboard that works at the
   // byte level has to re-derive, at every comparison, which transfer a byte belonged to and
   // where in that transfer it sat -- and that derivation, scattered across a checker, is
   // where environments accumulate their subtlest bugs.
   //
   // This module does the derivation ONCE, and it does it in the only place that has the
   // information: between the framing events. It accumulates bytes between a START and the
   // framing event that ends the transfer, and emits a single i2c_observed_t.
   //
   // Two design decisions worth stating, both learned the hard way in this module:
   //
   //   THE OUTPUT IS A COUNT, NOT A PULSE. `obs_count` increments when a transaction is
   //   assembled and never goes down. A one-cycle `obs_valid` pulse is invisible to any
   //   consumer that polls, and a polling consumer that misses it sees a deadlock rather
   //   than a missed event -- an hour of debugging pointed at the wrong component. The
   //   pulse is still provided for cycle-accurate consumers; the count is what a procedural
   //   bench should wait on.
   //
   //   IT ASSEMBLES, IT DOES NOT JUDGE. There is no expected value anywhere in this file
   //   and no notion of correctness. It raises the abstraction level of a measurement and
   //   nothing else. What the measurement should have been is Chapter 20.8's predictor;
   //   whether it matches is Chapter 20.8's scoreboard. Three components, three jobs, and
   //   each one is wrong in a recognisably different way when it breaks.
   // -----------------------------------------------------------------------------
   `timescale 1ns/1ps

   module i2c_txn_asm
      import i2c_txn_pkg::*;
   (
      input  logic clk,
      input  logic rst_n,

      // ---- from the MONITOR, which reads the resolved bus ----------------------
      input  logic       saw_start,
      input  logic       saw_restart,
      input  logic       byte_valid,
      input  logic [7:0] byte_data,
      input  logic       byte_acked,
      input  logic       byte_is_addr,
      input  logic       txn_done,
      input  logic       txn_ended_by_restart,

      // ---- the assembled observation ------------------------------------------
      output i2c_observed_t obs,
      output logic          obs_valid,   // one cycle, for a cycle-accurate consumer
      output logic [15:0]   obs_count    // monotonic, for a procedural one
   );

      i2c_observed_t acc;

      always @(posedge clk or negedge rst_n) begin
         if (!rst_n) begin
            acc       <= {$bits(i2c_observed_t){1'b0}};
            obs       <= {$bits(i2c_observed_t){1'b0}};
            obs_valid <= 1'b0;
            obs_count <= 16'd0;
         end else begin
            obs_valid <= 1'b0;

            // A START or a repeated START begins a new observation. Note that a repeated
            // START both ENDS one transfer and BEGINS another, and the order of these two
            // branches is what makes that work: the done branch below publishes the old
            // accumulator in the same cycle this branch clears it for the new transfer.
            if (saw_start || saw_restart) begin
               acc                    <= {$bits(i2c_observed_t){1'b0}};
               acc.began_with_restart <= saw_restart;
            end

            if (byte_valid) begin
               if (byte_is_addr) begin
                  acc.addr       <= byte_data[7:1];
                  acc.read       <= byte_data[0];
                  acc.addr_acked <= byte_acked;
               end else begin
                  case (acc.n_data)
                     4'd0: begin acc.b0 <= byte_data; acc.acks[0] <= byte_acked; end
                     4'd1: begin acc.b1 <= byte_data; acc.acks[1] <= byte_acked; end
                     4'd2: begin acc.b2 <= byte_data; acc.acks[2] <= byte_acked; end
                     // Beyond three, the count still rises. Dropping the payload is a
                     // capacity limit; losing the COUNT would be a measurement error, and
                     // a checker comparing byte counts would then silently agree with a
                     // transfer that carried more bytes than it was asked to.
                     default: ;
                  endcase
                  acc.n_data <= acc.n_data + 4'd1;
               end
            end

            if (txn_done) begin
               obs                  <= acc;
               obs.n_data           <= acc.n_data;
               obs.ended_by_restart <= txn_ended_by_restart;
               obs.ended_by_stop    <= ~txn_ended_by_restart;
               obs_valid            <= 1'b1;
               obs_count            <= obs_count + 16'd1;
            end
         end
      end

   endmodule

Two decisions in that file were both learned by getting them wrong.

The output is a count, not a pulse. obs_count increments and never goes down. A one-cycle obs_valid is invisible to a consumer that polls, and a polling consumer that misses it sees a deadlock rather than a missed event — which points debugging at the wrong component entirely. The pulse is still there for cycle-accurate consumers; the count is what a procedural bench should wait on.

It assembles, and it does not judge. There is no expected value anywhere in the file and no notion of correctness. It raises the abstraction level of a measurement and nothing else.

5. The Bench, and the Result That Matters

The comparison is a pure function, and that is worth exploiting. One simulated transfer produces one observation; every field of the difference mask can then be tested by mutating the intent alone — no re-simulation, and no chance that two runs differed for an unrelated reason. A checker tested this way is tested as a function, which is what it is.

Azvya Education Pvt. Ltd.VLSI Mentor
i2c_txn_tb.sv — the two types against the real Module 18 target
   // -----------------------------------------------------------------------------
   // i2c_txn_tb.sv
   // The two transaction types against the real Module 18 target.
   //
   // The comparison function is PURE: given an intent and an observation it returns a
   // difference mask and nothing else. That is worth exploiting. One simulated transfer
   // produces one observation, and every field of the mask can then be tested by mutating
   // the INTENT alone -- no re-simulation, no chance that two runs differed for an
   // unrelated reason. A checker tested this way is tested as a function, which is what it
   // is.
   //
   // The tests fall into three groups:
   //   T1-T6  the mask is exact: every bit fires for its own field and for nothing else.
   //   T7     a read's payload comparison is UNDEFINED, and the code says so.
   //   T8-T9  a difference between intent and observation is a FACT, not a verdict.
   // -----------------------------------------------------------------------------
   `timescale 1ns/1ps

   module i2c_txn_tb;
      import i2c_txn_pkg::*;

      localparam int   HALF = 16;
      localparam [6:0] ADDR = 7'h50;

      logic clk = 1'b0, rst_n = 1'b0;

      logic c_scl_low, c_sda_low, d_scl_low, d_sda_low;
      wire  scl, sda;
      wire [1:0] scl_in2, sda_in2, scl_rbl2, sda_rbl2;
      wire [7:0] scl_holders, sda_holders;

      i2c_line_model #(.N_DEV(2)) bus (
         .scl_drive_low({d_scl_low, c_scl_low}),
         .sda_drive_low({d_sda_low, c_sda_low}),
         .scl(scl), .sda(sda), .scl_in(scl_in2), .sda_in(sda_in2),
         .scl_released_but_low(scl_rbl2), .sda_released_but_low(sda_rbl2),
         .scl_holders(scl_holders), .sda_holders(sda_holders));

      logic       req = 1'b0, req_read = 1'b0, req_restart = 1'b0, req_hold = 1'b0;
      logic [6:0] req_addr = ADDR;
      logic [2:0] req_len = 3'd1;
      logic [7:0] req_d0 = 8'h00, req_d1 = 8'h00, req_d2 = 8'h00;
      wire [7:0]  c_donecnt;
      wire        c_aacked, c_stretched, c_timeout, c_busy;
      wire [2:0]  c_nacked;
      wire [7:0]  c_r0, c_r1, c_r2;

      i2c_ctrl_bfm #(.HALF(HALF), .STRETCH_TIMEOUT(4000)) ctrl (
         .clk(clk), .rst_n(rst_n),
         .scl_drive_low(c_scl_low), .sda_drive_low(c_sda_low),
         .scl_in(scl), .sda_in(sda),
         .req(req), .req_addr(req_addr), .req_read(req_read), .req_len(req_len),
         .req_d0(req_d0), .req_d1(req_d1), .req_d2(req_d2), .req_restart(req_restart), .req_hold(req_hold),
         .done_count(c_donecnt), .obs_addr_acked(c_aacked), .obs_n_acked(c_nacked),
         .obs_r0(c_r0), .obs_r1(c_r1), .obs_r2(c_r2),
         .obs_stretched(c_stretched), .obs_timeout(c_timeout), .busy(c_busy));

      wire [8*8-1:0] dut_regs;
      wire [7:0] dut_pointer;

      i2c_slave #(.MY_ADDR(ADDR), .N_REG(8), .RO_MASK(8'h04),
                  .IDLE_CYCLES(900), .SYNC_DEPTH(2), .CNT_W(16)) dut (
         .clk(clk), .rst_n(rst_n),
         .scl_pin(scl), .sda_pin(sda),
         .scl_drive_low(d_scl_low), .sda_drive_low(d_sda_low),
         .stall_req(1'b0),
         .reg_flat(dut_regs), .pointer(dut_pointer),
         .selected(), .stretching(),
         .n_phases(), .n_restarts(), .n_writes(), .n_refused(),
         .n_reads(), .n_aborts(), .n_sda_conflict());

      wire m_start, m_restart, m_stop, m_bvalid, m_backed, m_bisaddr;
      wire [7:0] m_bdata;
      wire m_active, m_read, m_aacked, m_done, m_rs;
      wire [6:0] m_addr;
      wire [3:0] m_ndata;
      wire [15:0] m_ntxn, m_nbytes, m_nnacks;

      i2c_mon #(.MAX_BYTES(8)) mon (
         .clk(clk), .rst_n(rst_n), .scl(scl), .sda(sda),
         .saw_start(m_start), .saw_restart(m_restart), .saw_stop(m_stop),
         .byte_valid(m_bvalid), .byte_data(m_bdata), .byte_acked(m_backed),
         .byte_is_addr(m_bisaddr),
         .txn_active(m_active), .txn_addr(m_addr), .txn_read(m_read),
         .txn_addr_acked(m_aacked), .txn_n_data(m_ndata),
         .txn_done(m_done), .txn_ended_by_restart(m_rs),
         .n_txns(m_ntxn), .n_bytes(m_nbytes), .n_nacks(m_nnacks));

      i2c_observed_t obs;
      wire           obs_valid;
      wire [15:0]    obs_count;

      i2c_txn_asm asm (
         .clk(clk), .rst_n(rst_n),
         .saw_start(m_start), .saw_restart(m_restart),
         .byte_valid(m_bvalid), .byte_data(m_bdata), .byte_acked(m_backed),
         .byte_is_addr(m_bisaddr),
         .txn_done(m_done), .txn_ended_by_restart(m_rs),
         .obs(obs), .obs_valid(obs_valid), .obs_count(obs_count));

      // A transfer's ENDING is decided by the framing event that arrives next, so the
      // observation of transfer N is only complete once transfer N+1 has begun. Keeping the
      // previous observation is not bookkeeping: it is the only way a framing relationship
      // between two transfers can be checked at all.
      //
      // The extra stage is not decoration. `obs` and `obs_valid` are registered by the
      // assembler on the SAME edge, so by the time a consumer sees the pulse, `obs` already
      // holds the NEW transaction -- sampling it there captures the transaction that was
      // just published, not the one before it. The shadow register lags by one cycle and
      // therefore still holds the previous publication when the pulse arrives. This is the
      // same one-cycle phase error that made the Chapter 20.8 scoreboard compare an
      // acknowledge against the previous byte's expectation, and it presents the same way:
      // plausible values, consistently off by one transaction.
      i2c_observed_t obs_prev, obs_d1;
      always @(posedge clk) if (rst_n) begin
         if (obs_valid) obs_prev <= obs_d1;
         obs_d1 <= obs;
      end

      // ---- the INTENT lives here, in the stimulus, and nowhere near the monitor ----
      i2c_intent_t intent, bad;
      logic [N_DIFF:0] d;

      integer errors = 0, n, dc_before, oc_before;

      always #5 clk = ~clk;
      task step; begin @(posedge clk); @(negedge clk); end endtask

      task do_reset;
         begin
            @(negedge clk); rst_n = 1'b0; req = 1'b0;
            step; step; step;
            @(negedge clk); rst_n = 1'b1;
            for (n = 0; n < 40; n = n + 1) step;
         end
      endtask

      // Drive the bus from an INTENT. This is the only place the intent touches anything,
      // and it touches the driver -- never the monitor, never the assembler, never a check.
      // `hold` keeps the bus after the payload, and `wait_obs` must then be 0: a held
      // transfer has not ended yet, so no observation can have been assembled for it, and
      // waiting for one would hang on our own stimulus.
      task drive (input i2c_intent_t w, input logic hold, input logic wait_obs);
         begin
            dc_before = c_donecnt; oc_before = obs_count;
            @(negedge clk);
            req_hold    = hold;
            req_addr    = w.addr;
            req_read    = w.read;
            req_len     = w.n_bytes;
            req_d0      = w.d0;
            req_d1      = w.d1;
            req_d2      = w.d2;
            req_restart = w.restart_first;
            req         = 1'b1;
            @(negedge clk); req = 1'b0;
            n = 0;
            while (c_donecnt == dc_before && n < 400000) begin @(posedge clk); n = n + 1; end
            if (c_donecnt == dc_before) begin
               $display("  FAIL the driver never completed the transfer");
               errors = errors + 1;
            end
            // Wait for the ASSEMBLER, not for the driver. The driver finishing means the
            // stimulus is done; the observation is complete only when the framing event
            // that ends the transfer has been seen on the wire.
            if (wait_obs) begin
               n = 0;
               while (obs_count == oc_before && n < 200000) begin @(posedge clk); n = n+1; end
               if (obs_count == oc_before) begin
                  $display("  FAIL no transaction was ever assembled from the bus");
                  errors = errors + 1;
               end
            end
            for (n = 0; n < 8; n = n + 1) step;
         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

      // Exactly one mask bit, and only that bit. The "and only that bit" half is what
      // catches a comparison that flags everything whenever anything differs -- a checker
      // that is never silent is as useless as one that is never loud.
      task ck_only (input [200*8:1] what, input [N_DIFF:0] got, input integer bit_idx);
         begin
            if (!got[N_DIFF]) begin
               $display("  FAIL %0s: the comparison came back UNDEFINED", what);
               errors = errors + 1;
            end else if (got[N_DIFF-1:0] !== (1 << bit_idx)) begin
               $display("  FAIL %0s: mask %b expected only bit %0d",
                        what, got[N_DIFF-1:0], bit_idx);
               errors = errors + 1;
            end
         end
      endtask

      initial begin
         #4000000;
         $display("  FAIL watchdog: the transaction environment did not finish");
         $display("=== i2c_txn: 1 CHECK(S) FAILED ===");
         $finish;
      end

      initial begin
         $display("=== i2c_txn: an intent and an observation are not the same object ===");
         do_reset;

         // ----------------------------------------------------------------
         // T1. A WRITE THAT WENT THROUGH. Every field that corresponds, corresponds.
         // ----------------------------------------------------------------
         intent.addr = ADDR; intent.read = 1'b0; intent.n_bytes = 3'd2;
         intent.d0 = 8'h01; intent.d1 = 8'h11; intent.d2 = 8'h00;
         intent.restart_first = 1'b0;
         drive(intent, 1'b0, 1'b1);
         d = i2c_diff(intent, obs);
         $display("T1  a completed write: the mask is empty and the comparison is defined");
         ck("T1 the comparison is defined for a write", d[N_DIFF], 1);
         ck("T1 the mask is empty",                     d[N_DIFF-1:0], 0);
         ck("T1 the observed address is the one framed", obs.addr, ADDR);
         ck("T1 the observation records the acknowledge", obs.addr_acked, 1);
         ck("T1 two bytes were observed",               obs.n_data, 2);
         ck("T1 the transfer ended with a STOP",         obs.ended_by_stop, 1);
         ck("T1 every byte was acknowledged",            obs.acks[1:0], 2'b11);
         // and the target really did what the wire said
         ck("T1 register 1 holds the written byte", dut_regs[1*8 +: 8], 8'h11);

         // ----------------------------------------------------------------
         // T1b. THE COMPARISON IS BLIND TO REFUSAL, BY CONSTRUCTION.
         //
         //      The same write aimed at register 2, which this target declares READ-ONLY
         //      (RO_MASK = 0x04). Decision D4 says it must refuse the byte with a NACK, and
         //      it does. Every byte the intent asked for was nevertheless framed and
         //      carried, so THE MASK IS STILL EMPTY -- an intent-to-observation comparison
         //      reports perfect agreement on a transfer that changed nothing.
         //
         //      This is not a defect in the comparison. Acknowledgement has no intent
         //      counterpart at all: whether a byte is accepted is the target's decision,
         //      taken from a contract the intent knows nothing about. The comparison is
         //      answering the question it can answer -- "did the bus carry what I asked
         //      for" -- and that question is simply not "did the target accept it".
         //
         //      Which is exactly why Chapter 20.8 exists. An oracle for acceptance has to
         //      come from the device contract, and no amount of care with the stimulus will
         //      substitute for one.
         // ----------------------------------------------------------------
         intent.addr = ADDR; intent.read = 1'b0; intent.n_bytes = 3'd2;
         intent.d0 = 8'h02; intent.d1 = 8'h99; intent.d2 = 8'h00;
         intent.restart_first = 1'b0;
         drive(intent, 1'b0, 1'b1);
         d = i2c_diff(intent, obs);
         $display("T1b a REFUSED write: the mask is empty and nothing was written");
         ck("T1b the mask is empty -- the bus carried what was asked",
            d[N_DIFF-1:0], 0);
         ck("T1b both bytes were observed on the wire", obs.n_data, 2);
         ck("T1b the pointer byte was accepted",        obs.acks[0], 1);
         ck("T1b but the DATA byte was refused",        obs.acks[1], 0);
         ck("T1b and the read-only register is untouched",
            dut_regs[2*8 +: 8], 8'h00);

         // ----------------------------------------------------------------
         // T2-T6. THE MASK IS EXACT. One observation, five mutated intents. Because the
         //        comparison is a pure function, nothing is re-simulated: any difference in
         //        the result is caused by the field that was changed and by nothing else.
         // ----------------------------------------------------------------
         bad = intent; bad.addr = 7'h51;
         ck_only("T2 a wrong address sets only D_ADDR", i2c_diff(bad, obs), D_ADDR);

         bad = intent; bad.n_bytes = 3'd3;
         ck_only("T3 a wrong byte count sets only D_NBYTES", i2c_diff(bad, obs), D_NBYTES);

         bad = intent; bad.d1 = 8'h12;
         ck_only("T4 a wrong payload sets only D_DATA", i2c_diff(bad, obs), D_DATA);

         bad = intent; bad.restart_first = 1'b1;
         ck_only("T5 a wrong framing expectation sets only D_FRAMING",
                 i2c_diff(bad, obs), D_FRAMING);

         // A one-bit near miss in the payload, because a comparison that only notices large
         // differences is the usual way a byte-level check turns out to be a length check.
         bad = intent; bad.d1 = 8'h10;
         ck_only("T6 a one-bit payload difference still sets D_DATA",
                 i2c_diff(bad, obs), D_DATA);

         // The FIRST byte gets its own case. A payload comparison that starts at the second
         // byte is a real and easy mistake -- the pointer byte of an I2C write is the one
         // most likely to be treated as framing rather than as payload.
         bad = intent; bad.d0 = 8'h09;
         ck_only("T6b a wrong FIRST byte also sets D_DATA", i2c_diff(bad, obs), D_DATA);
         $display("T2-T6 each mask bit fires for its own field and for no other");

         // ----------------------------------------------------------------
         // T7. A READ. The payload comparison is refused, and refusing is the correct
         //     answer -- the bytes on the wire came from the TARGET, so no field of the
         //     intent predicts them. The address and direction still compare, and the
         //     payload's oracle is the device contract in Chapter 20.8.
         // ----------------------------------------------------------------
         intent.addr = ADDR; intent.read = 1'b1; intent.n_bytes = 3'd1;
         intent.d0 = 8'hAA;            // deliberately nonsense: nothing will send this
         intent.d1 = 8'h00; intent.d2 = 8'h00; intent.restart_first = 1'b0;
         drive(intent, 1'b0, 1'b1);
         d = i2c_diff(intent, obs);
         $display("T7  a read: the intent cannot predict the payload, and says so");
         ck("T7 the intent-to-observation comparison is UNDEFINED", d[N_DIFF], 0);
         ck("T7 but the address still compares",
            i2c_diff_framing_only(intent, obs), 0);
         ck("T7 the observation captured a real byte from the target",
            obs.n_data, 1);
         // The honest oracle for that byte: the register the device contract says is
         // selected. Module 18's D5 leaves the pointer one past the last byte read, so the
         // byte just read came from the register before it.
         ck("T7 and the byte equals the register the CONTRACT selects",
            obs.b0, dut_regs[((dut_pointer-1) % 8)*8 +: 8]);
         ck("T7 which is not the nonsense the intent carried",
            (obs.b0 !== 8'hAA), 1);

         // The direction-and-address check that SURVIVES a read must still be a check. A
         // comparison that is only ever asked to return zero has never been shown to be
         // capable of returning anything else.
         bad = intent; bad.read = 1'b0;
         ck("T7b a direction mismatch is reported", i2c_diff_framing_only(bad, obs), 2'b10);
         bad = intent; bad.addr = 7'h51;
         ck("T7c an address mismatch is reported",  i2c_diff_framing_only(bad, obs), 2'b01);

         // ----------------------------------------------------------------
         // T8. AGREEMENT IS NOT SUCCESS. The most important test in this file.
         //
         //     A two-byte write to an address no device owns. The target NACKs it, which is
         //     exactly correct, and the driver -- like a real controller that does not abort
         //     on a NACK -- sends the payload anyway.
         //
         //     So every byte the intent asked for WAS framed and WAS on the wire. The mask
         //     is EMPTY. An intent-to-observation comparison reports full agreement on a
         //     transfer in which nothing was accepted by anyone and no state changed
         //     anywhere.
         //
         //     Read that result carefully, because it is the shape almost every circular
         //     environment fails in. Nothing here is broken: the driver sent what it was
         //     told, the monitor saw what was sent, the comparison found them equal. Each
         //     component is individually correct, and the environment as a whole still
         //     reports agreement for a transfer that did nothing at all. Verification that
         //     compares a stimulus against a recording of itself will always agree with
         //     itself.
         //
         //     The facts that distinguish this from a successful write are all in fields
         //     with NO INTENT COUNTERPART -- addr_acked and acks. Those are the target's
         //     decisions, and judging them needs the contract-driven predictor of 20.8.
         // ----------------------------------------------------------------
         intent.addr = 7'h2A; intent.read = 1'b0; intent.n_bytes = 3'd2;
         intent.d0 = 8'h03; intent.d1 = 8'h77; intent.d2 = 8'h00;
         intent.restart_first = 1'b0;
         drive(intent, 1'b0, 1'b1);
         d = i2c_diff(intent, obs);
         $display("T8  a wholly refused transfer -- and intent vs observation AGREE");
         ck("T8 the comparison is defined",               d[N_DIFF], 1);
         ck("T8 and the mask is EMPTY",                   d[N_DIFF-1:0], 0);
         ck("T8 the address was framed as asked",         obs.addr, 7'h2A);
         ck("T8 both bytes really were carried",          obs.n_data, 2);
         // the facts that make this a failure live only in the observation
         ck("T8 the address was NOT acknowledged",        obs.addr_acked, 0);
         ck("T8 nor was any data byte",                   obs.acks[1:0], 2'b00);
         ck("T8 and nothing was written anywhere",        dut_regs[3*8 +: 8], 8'h00);
         // the intent is entirely unmoved by any of it, which is the point
         ck("T8 the intent still says two bytes to 0x2A", intent.n_bytes, 2);

         // ----------------------------------------------------------------
         // T9. FRAMING IS MEASURED, AND IT SPANS TRANSFERS.
         //
         //     Transfer A writes and then KEEPS the bus -- no STOP. Transfer B opens with a
         //     repeated START, which is what finally ends A. So A's ending is caused by B's
         //     beginning, and A's observation cannot be complete until B has started.
         //
         //     That is why `began_with_restart` is the field the intent compares against and
         //     `ended_by_restart` is not. The opening framing is a decision the controller
         //     made, and an intent can state it. The closing framing is a consequence of
         //     what the NEXT transfer does, which this transfer's intent cannot know.
         // ----------------------------------------------------------------
         do_reset;
         intent.addr = ADDR; intent.read = 1'b0; intent.n_bytes = 3'd2;
         intent.d0 = 8'h05; intent.d1 = 8'h44; intent.d2 = 8'h00;
         intent.restart_first = 1'b0;
         bad = intent;                       // keep a copy: `intent` is reused for B
         drive(intent, 1'b1, 1'b0);          // HOLD the bus; no observation yet
         ck("T9 nothing has been assembled yet -- A has not ended", obs_count, 0);

         intent.addr = ADDR; intent.read = 1'b1; intent.n_bytes = 3'd1;
         intent.d0 = 8'h00; intent.d1 = 8'h00; intent.d2 = 8'h00;
         intent.restart_first = 1'b1;        // a GENUINE repeated START: no STOP preceded it
         drive(intent, 1'b0, 1'b1);
         $display("T9  a held bus: A is ended by B's repeated START");
         ck("T9 two transfers were assembled",             obs_count, 2);
         ck("T9 transfer A was ended by a repeated START",  obs_prev.ended_by_restart, 1);
         ck("T9 and not by a STOP",                         obs_prev.ended_by_stop, 0);
         ck("T9 transfer A opened with a plain START",      obs_prev.began_with_restart, 0);
         ck("T9 transfer B opened with a repeated START",   obs.began_with_restart, 1);
         ck("T9 B was ended by a STOP",                     obs.ended_by_stop, 1);
         ck("T9 A's payload landed",                        dut_regs[5*8 +: 8], 8'h44);
         ck("T9 A's comparison is defined and empty",
            i2c_diff(bad, obs_prev), {1'b1, {N_DIFF{1'b0}}});
         bad.restart_first = 1'b1;
         ck_only("T9 expecting a repeated START opening sets only D_FRAMING",
                 i2c_diff(bad, obs_prev), D_FRAMING);
         ck("T9 B's intent comparison is UNDEFINED, being a read",
            i2c_diff(intent, obs) >> N_DIFF, 0);
         ck("T9 but B's address and direction still compare",
            i2c_diff_framing_only(intent, obs), 0);

         if (errors == 0) $display("=== i2c_txn: ALL CHECKS PASSED ===");
         else             $display("=== i2c_txn: %0d CHECK(S) FAILED ===", errors);
         $finish;
      end

   endmodule

Three results in that file are worth reading carefully, and the first two are the reason the chapter exists.

T1b — the comparison is blind to refusal, by construction

The same two-byte write, aimed at register 2, which this target declares read-only. Decision D4 says it must refuse the data byte with a NACK, and it does. Every byte the intent asked for was nevertheless framed and carried, so the mask is still empty: an intent-to-observation comparison reports perfect agreement on a transfer that changed nothing.

This is not a defect in the comparison. Acknowledgement has no intent counterpart at all. The comparison is answering the question it can answer — "did the bus carry what I asked for" — and that question is simply not "did the target accept it".

T8 — agreement is not success

A two-byte write to an address no device owns. The target NACKs it, correctly. The driver, like a real controller that does not abort on a NACK, sends the payload anyway. So every byte was framed, every byte was on the wire, and the mask is empty.

Read that carefully, because it is the shape almost every circular environment fails in. Nothing is broken. The driver sent what it was told. The monitor saw what was sent. The comparison found them equal. Each component is individually correct, and the environment as a whole reports agreement for a transfer in which nothing was accepted by anyone and no state changed anywhere.

The facts that distinguish this from a successful write are all in fields with no intent counterpart — addr_acked, and acks. Those are the target's decisions, and judging them requires the contract.

T9 — framing is measured, and it spans transfers

Transfer A writes and then keeps the bus: no STOP. Transfer B opens with a repeated START, which is what finally ends A. A's ending is caused by B's beginning, so A's observation cannot be complete until B has started — which is why the bench waits on the assembler rather than on the driver, and why the previous observation has to be kept at all.

A block diagram in two rows. The upper row shows an intent object flowing into a driver, which drives the resolved bus, which reaches the DUT. The lower row shows the monitor reading the resolved bus, feeding the transaction assembler, which produces an observation object. Both the observation and a separate reference model feed a comparison block. A dashed crossed-out arrow runs from the intent directly to the comparison, labelled as the circular path.Intentchosen, notmeasuredDriver20.5Resolved buswired-ANDTargetModule 18Monitor20.7Assemblerthis chapterObservationmeasured, notchosenComparison20.8pull lowresolvedbytes12
Figure 1 — the two types and the one legal path between them. An intent flows only into the driver; an observation flows only out of the monitor. There is no arrow from the intent to the comparison, and that absence is the whole design: the missing arrow is the circular comparison of Section 1, and making it a type error is what stops it being drawn by accident.

Two transfers that differ in exactly one bit, and it is not a bit anyone sent

10 cycles
A ten-cycle waveform. SCL is a clock. SDA carries a data byte over eight cycles, identical in both traces. The ninth cycle is the acknowledge slot: in the accepted trace SDA is low, in the refused trace SDA is high. Markers highlight the ninth cycle as the only difference and label it as belonging to the receiver.eight bits, byte-identical in botheight bits, byte-identical in boththe receiver answers, or does notthereceive…the acknowledge slotthe acknowledge slotSCLSDA acceptedSDA refusedt0t1t2t3t4t5t6t7t8t9
Cycles 0 to 7 are what the controller transmitted and are the same in both traces. Cycle 8 is the only difference, and no intent field predicts it: the receiver owns that slot. This is why the observed transaction type carries acks and the intent type cannot.
Figure 2 — the same write, twice, at two levels. Above: the byte level, where both transfers are identical eight-bit sequences. Below: the acknowledge bit, the one difference — and it is the target's decision, not the controller's. A transaction object that records only what was sent cannot tell these two transfers apart, which is exactly the situation in T1b and T8.

The scoreboard that had never failed, and the transaction object that made it inevitable

Pitfall — one transaction type, eleven months of green
Buggy Code
// An I2C environment with a single transaction class. The scoreboard has never
// reported a mismatch, which was taken as good news.
//
//    class i2c_txn;
//      bit [6:0] addr;  bit read;  bit [7:0] data[];
//      bit       ack;                    // <-- the field that gives it away
//    endclass
//
//    txn = seq.next();          // addr=0x50, read=0, data={3, 0xA5}
//    driver.send(txn);
//    sb.expect(txn);            // the SAME handle
//    sb.compare(mon.get());
//
// Note 'ack'. In an intent it is meaningless -- the sequence cannot know whether
// the target will acknowledge. So either it is left at its default and compared
// against reality (and every NACK is reported as a mismatch), or it is excluded
// from the comparison (and every NACK is invisible). The team chose the second.
//
// Which means: a transfer the target refuses COMPLETELY -- wrong address, every
// byte NACKed, nothing written anywhere -- compares EQUAL. The driver sent what
// it was told, the monitor saw it, the fields match.
//
// Delete the DUT. Every test still passes.
Pitfall — every field plausible, every transaction one too late
Buggy Code
// A bench keeping the previous observation, to check a framing relationship
// that spans two transfers.
//
//    always @(posedge clk) if (obs_valid) obs_prev <= obs;
//
// Symptom: transfer A, which was ended by a repeated START, reports
// ended_by_restart = 0 and ended_by_stop = 1 -- and reports B's opening framing
// as its own. Every field holds a legal value. Nothing crashes. The values are
// simply those of the WRONG TRANSACTION, consistently.
//
// Hours go into the assembler, which is correct.

6. What 20.3 Settled

An intent and an observation are different types. One is chosen and contains no facts; the other is measured and contains nothing chosen. The fields that appear in only one of them — acknowledgement, and how the transfer ended — are the ones a single-type environment cannot check.

The type discipline stops a circular comparison being written; it does not supply an oracle. T1b and T8 are refused transfers on which intent and observation agree perfectly, because the bus really did carry what was asked. Refusal is not a fact about the bus.

The signal-to-transaction step belongs in one component, not scattered across a checker. The assembler does it once, exports a monotonic count rather than a pulse, and judges nothing.

Framing relationships span transfers. A transfer ended by a repeated START cannot be fully observed until the next transfer has begun — which is why the bench waits on the assembler rather than the driver, and why building this chapter found a req_restart in 20.5's driver that had been unreachable since it was written.

Next comes the distinction that decides how much of this environment can ever be reused: which components are allowed to touch the bus at all. Chapter 20.4 — Active and Passive Components.

Continue learning