I²C · Module 8
Multi-Byte I²C Writes — Timing, Throughput and Waveforms
A three-byte write spends a third of its bus time on overhead. This chapter derives the closed forms for what a burst costs, computes the real byte rate at both standard speeds, reads an annotated burst capture, and builds a passive instrument that measures all of it in hardware.
Chapter 8.1 counted twenty-seven SCL pulses to move two bytes of payload. That number was presented as an observation. This chapter takes it seriously, because it is the number that decides whether a design meets its throughput budget — and because the arithmetic behind it is short enough to derive exactly and important enough to get right.
The conclusion, stated up front: I²C's efficiency ceiling is 8/9, or 88.9%, and a short write is nowhere near it. A one-byte write runs at 44%. Everything in this chapter is about where the missing bits go and what bursting recovers.
1. Nine Pulses, Eight Bits
The unit of cost on this bus is not the bit. It is the byte slot, and it is nine clock pulses long.
Chapter 7.1 established the byte format and §3.1.5 of the specification is explicit about both halves of it.
Those three sentences are the whole cost model. Eight bits of payload per byte, one mandatory pulse that carries none, and no limit on how many bytes you may chain behind a single addressing. The first two set the per-byte overhead at exactly one pulse in nine; the third is what makes bursting legal, and therefore what makes the overhead amortisable.
The 8/9 ceiling is structural and cannot be improved. There is no mode, no speed grade and no configuration in which a byte costs fewer than nine pulses. Fast-mode and Fast-mode Plus raise the clock, not the efficiency — a 400 kHz bus wastes exactly the same one pulse in nine as a 100 kHz bus, four times faster.
2. The Closed Forms
Let n be the number of payload bytes in a write. Then:
| quantity | closed form | at n = 3 |
|---|---|---|
| bytes on the wire | n + 1 | 4 |
| SCL pulses | 9(n + 1) | 36 |
| payload bits delivered | 8n | 24 |
| overhead pulses | 9(n+1) − 8n = n + 9 | 12 |
| payload efficiency | 8n / 9(n+1) | 66.7% |
The +1 in the byte count is the address byte, and it is the term that makes short writes expensive. It costs a full nine pulses and delivers zero payload bits, because its eight bits are addressing rather than data.
The overhead expression is worth reading as a sum of two distinct things, because they behave completely differently:
| term | what it is | behaviour |
|---|---|---|
| 9 | the address byte's nine pulses | fixed — paid once per transfer |
| + n | one acknowledge pulse per payload byte | proportional — paid per byte, forever |
The 9 is fixed — one address byte per transfer, no matter how long the transfer is. This is the term bursting amortises.
The n is proportional — one acknowledge pulse per payload byte, forever. This is the term that cannot be amortised, and it is what sets the 8/9 ceiling. No burst length makes the acknowledges go away; a long burst merely stops the address byte from dominating.
3. The Efficiency Curve, and the Real Byte Rate
Here is the whole curve, with the wall-clock figures at both common speeds. The times are nominal — pulse count multiplied by the nominal clock period — and §4 covers what they leave out.
| n | pulses | efficiency | time @ 100 kHz | rate @ 100 kHz | time @ 400 kHz | rate @ 400 kHz |
|---|---|---|---|---|---|---|
| 1 | 18 | 44.4% | 180 µs | 5.6 kB/s | 45.0 µs | 22.2 kB/s |
| 2 | 27 | 59.3% | 270 µs | 7.4 kB/s | 67.5 µs | 29.6 kB/s |
| 4 | 45 | 71.1% | 450 µs | 8.9 kB/s | 112.5 µs | 35.6 kB/s |
| 8 | 81 | 79.0% | 810 µs | 9.9 kB/s | 202.5 µs | 39.5 kB/s |
| 16 | 153 | 83.7% | 1530 µs | 10.5 kB/s | 382.5 µs | 41.8 kB/s |
| 32 | 297 | 86.2% | 2970 µs | 10.8 kB/s | 742.5 µs | 43.1 kB/s |
| 64 | 585 | 87.5% | 5850 µs | 10.9 kB/s | 1462.5 µs | 43.8 kB/s |
| → ∞ | — | 88.9% | — | 11.1 kB/s | — | 44.4 kB/s |
Three readings of that table are worth more than the table itself.
A single-byte write is less than half payload. At 44.4%, more bus time goes to addressing and acknowledging than to data. A driver that issues one-byte writes in a loop is running the bus at half its capability, and the fix is usually free — most devices auto-increment, so the loop can become one burst.
The curve is steep early and flat late. Going from 1 byte to 4 recovers 27 percentage points. Going from 16 to 64 recovers 3.8. So the useful engineering advice is stop issuing single-byte writes, not maximise burst length — and past roughly 8 to 16 bytes the remaining gain is not worth restructuring a driver for. That point is where the fixed 9 stops dominating the proportional n.
The ceiling is a hard number, and it is low. 11.1 kB/s on a standard-mode bus. If a design needs more than that, no amount of software cleverness will produce it; the answer is Fast-mode, Fast-mode Plus, or a different bus. Knowing the ceiling before committing to I²C is the single most valuable thing this table is for.
4. What These Numbers Do Not Include
The closed forms are exact for what they count. They deliberately count only SCL pulses, and three real costs sit outside them.
Framing and bus-free time. The START and STOP consume no clock pulses (Chapter 8.1 §3), but they consume time — and so does the mandatory bus-free interval before the next transfer, which Chapter 5.5 built a timer for. On a bus doing many short transfers, tBUF between them is a real fraction of the total, and it is a cost the pulse count is blind to. This is a second, independent argument for bursting: one long transfer pays the bus-free interval once.
Clock stretching. Module 12 derives clock stretching: a slave may hold SCL low for as long as it needs. A stretched byte still takes nine pulses, so the pulse count is unchanged and the time is not. For devices that stretch — EEPROMs during a page write, microcontroller slaves servicing an interrupt — the pulse count can understate the real duration by an order of magnitude, and no formula can predict it. It has to be measured, which is what §7's instrument is for.
Rise time on a loaded bus. Chapter 2.4 covered why a heavily loaded bus cannot reach its nominal clock rate at all. A 400 kHz design on 400 pF runs slower than 400 kHz, and the pulse arithmetic is correct while the wall-clock column is optimistic.
5. Reading an Annotated Burst
Here is an eight-byte burst drawn at byte resolution — one interval per byte slot rather than one per bit. At bit resolution this frame would be 81 intervals wide and unreadable.
Burst write: S, address, then eight payload bytes, then P
10 cyclesThe figure has no clock row, and that is deliberate rather than an omission. At byte resolution one interval is nine SCL pulses, so a clock row would draw one period where nine belong — a figure that was precise about the pulse count and wrong about the clock. The pulses row carries the information instead, honestly and in the units the section is about.
Four things to read off it, in the order a capture should be read:
Count the intervals between the framing events, not the pulses. Ten intervals, two of which are framing, so eight bytes on the wire — one address plus seven payload. The pulse total is 8 × 9 = 72. This is the fast way to size a capture.
Check the pulse total is a multiple of nine. 72 is. If it is not, the capture contains a truncated byte or a framing event you have not spotted, and that check costs one division.
Read the ninth-bit row as a pattern, not as a pass/fail. All A's here, which for a write means complete (Chapter 7.4). A single N anywhere in that row is the whole diagnosis: at position 1 it means nobody is at the address; at position k it means the device accepted k−1 payload bytes and refused the kth.
Locate the payload band. Seven intervals of the ten carry payload, which is where the 71% figure in §3 comes from — and seeing it as a band rather than a number is what makes the argument for bursting obvious at a glance.
6. Bursting Depends on the Slave, Not on the Protocol
There is a gap between "the protocol permits unlimited bytes per transfer" and "you may send this device eight bytes in one go", and it is a gap that costs real debugging time.
The protocol says the byte count is unrestricted. It says nothing whatsoever about what the bytes mean. So a burst only works if the slave has somewhere to put byte two — which means the slave needs an auto-incrementing pointer, and that is exactly the mechanism Chapter 8.2 built.
| slave behaviour | what a burst does | how it appears |
|---|---|---|
| auto-increment | writes consecutive registers — the intended case | all ACKed, registers p through p+n−1 updated |
| no pointer advance | every byte lands in the same register | all ACKed, only the last byte survives |
| bounds-checked end | accepts up to the end, then refuses | ACKs until the end, then a NACK: condition 4 |
| wrapping end | accepts everything, corrupting register 0 upward | all ACKed, and unrelated registers changed |
Row two is the one that costs time, because the bus reports complete success. Every byte is acknowledged, the transaction returns without error, and seven of the eight bytes have silently vanished. There is nothing in the capture to find — the capture is perfect. The only evidence is that the registers do not contain what was written, and the only place the answer exists is the datasheet.
Row four is the one that causes damage, and Chapter 8.2 §3 ranked it as the worst of the possible overrun behaviours for exactly this reason.
7. The Instrument in Three Languages
Every number in §2 and §3 is arithmetic, and arithmetic in a tutorial is exactly the kind of claim that should be checked rather than asserted. So the design for this chapter is not another protocol block — it is a passive instrument that watches a transfer and measures it.
Its whole value is that it never participates. It has no output that reaches the bus, drives nothing, and cannot perturb what it measures. That makes it the right shape for three real uses: a bus monitor in a verification environment, a debug counter block in silicon, and — here — a way to make a tutorial's arithmetic falsifiable.
| output | closed form it measures |
|---|---|
bytes_seen | n + 1 |
scl_pulses | 9(n + 1) |
payload_bits | 8n |
overhead_pulses | n + 9 |
// A passive instrument that measures what a write burst actually costs. It exists so
// the arithmetic in this chapter is CHECKED rather than asserted: for a write of n
// payload bytes the closed forms are
//
// bytes on the wire = n + 1 (the address byte plus the payload)
// SCL pulses = 9(n + 1) (nine per byte, from Chapter 7.1)
// payload bits = 8n (the address byte carries none)
// overhead pulses = 9(n+1) - 8n = n + 9
//
// and a testbench can compare every one of them against a hand calculation.
module i2c_write_burst_metrics #(
parameter int CNT_W = 16
)(
input logic clk,
input logic rst_n,
input logic scl_in, // observed bus level
input logic frame_start, // pulse: S or Sr
input logic frame_stop, // pulse: P
input logic byte_done, // pulse: a byte AND its ninth slot completed
output logic [CNT_W-1:0] scl_pulses, // SCL rising edges in this transfer
output logic [CNT_W-1:0] bytes_seen, // bytes INCLUDING the address byte
output logic [CNT_W-1:0] payload_bits, // 8 x (bytes_seen - 1), floored at zero
output logic [CNT_W-1:0] overhead_pulses, // pulses that carried no payload
output logic transfer_active,
output logic metrics_valid // pulse: a STOP closed the transfer
);
logic scl_q;
logic scl_rise;
assign scl_rise = !scl_q && scl_in;
// The address byte carries no payload, so it is excluded -- and the subtraction is
// guarded, because bytes_seen is zero before the first byte completes and an
// unguarded 8 x (0 - 1) would wrap to a very large number rather than to zero.
assign payload_bits = (bytes_seen == '0) ? '0
: ((bytes_seen - 1'b1) << 3);
assign overhead_pulses = (scl_pulses >= payload_bits) ? (scl_pulses - payload_bits)
: '0;
always_ff @(posedge clk) begin
if (!rst_n) begin
scl_q <= 1'b1; // idle bus: SCL released, therefore high
scl_pulses <= '0;
bytes_seen <= '0;
transfer_active <= 1'b0;
metrics_valid <= 1'b0;
end else begin
metrics_valid <= 1'b0;
scl_q <= scl_in;
if (frame_stop) begin
// The counters are deliberately NOT cleared here: a consumer reads them
// AFTER the transfer, so clearing on the STOP would empty them before
// anything could look. The next frame_start clears them instead.
if (transfer_active) metrics_valid <= 1'b1;
transfer_active <= 1'b0;
end else if (frame_start) begin
// A repeated START begins a new measurement: the bytes after it belong
// to a new addressing, so counting them with the previous phase's
// totals would misreport both.
scl_pulses <= '0;
bytes_seen <= '0;
transfer_active <= 1'b1;
end else if (transfer_active) begin
if (scl_rise) scl_pulses <= scl_pulses + 1'b1;
if (byte_done) bytes_seen <= bytes_seen + 1'b1;
end
end
end
endmodule module i2c_write_burst_metrics_tb;
localparam int CNT_W = 16;
logic clk = 1'b0, rst_n, scl_in, frame_start, frame_stop, byte_done;
logic [CNT_W-1:0] scl_pulses, bytes_seen, payload_bits, overhead_pulses;
logic transfer_active, metrics_valid;
int errors = 0;
i2c_write_burst_metrics #(.CNT_W(CNT_W)) dut (.*);
always #5 clk = ~clk;
initial begin #200000; $display("FAIL: watchdog expired"); $finish; end
// One SCL pulse. The metrics block counts rising edges, so this is the unit.
task automatic scl_pulse();
scl_in = 1'b0; repeat (1) @(negedge clk);
scl_in = 1'b1; repeat (1) @(negedge clk);
scl_in = 1'b0; repeat (1) @(negedge clk);
endtask
// One byte: nine SCL pulses, then the byte_done the engine of Chapter 7.1 emits.
task automatic wire_byte();
for (int i = 0; i < 9; i++) scl_pulse();
byte_done = 1'b1; @(negedge clk); byte_done = 1'b0; @(negedge clk);
endtask
task automatic pulse_start(); frame_start = 1'b1; @(negedge clk); frame_start = 1'b0; @(negedge clk); endtask
task automatic pulse_stop(); frame_stop = 1'b1; @(negedge clk); frame_stop = 1'b0; @(negedge clk); endtask
// Drive a complete write of n payload bytes: address byte plus n data bytes.
task automatic write_burst(input int n);
pulse_start();
wire_byte(); // the address byte
for (int b = 0; b < n; b++) wire_byte(); // the payload
pulse_stop();
endtask
// The hand calculation, written as arithmetic on n and compared against the
// hardware. Two independent routes to the same four numbers.
task automatic check_burst(input int n);
automatic int exp_bytes = n + 1;
automatic int exp_pulses = 9 * (n + 1);
automatic int exp_payload = 8 * n;
automatic int exp_overhead = n + 9;
if (bytes_seen !== exp_bytes[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- bytes_seen %0d, expected %0d", n, bytes_seen, exp_bytes);
errors++; end
if (scl_pulses !== exp_pulses[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- scl_pulses %0d, expected 9(n+1) = %0d", n, scl_pulses, exp_pulses);
errors++; end
if (payload_bits !== exp_payload[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- payload_bits %0d, expected 8n = %0d", n, payload_bits, exp_payload);
errors++; end
if (overhead_pulses !== exp_overhead[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- overhead %0d, expected n+9 = %0d", n, overhead_pulses, exp_overhead);
errors++; end
endtask
int snap_pulses, snap_bytes, snap_valid;
int n_valid = 0;
always @(posedge clk) if (rst_n && metrics_valid) n_valid++;
initial begin
rst_n = 1'b0; scl_in = 1'b1; frame_start = 1'b0; frame_stop = 1'b0; byte_done = 1'b0;
repeat (3) @(negedge clk);
if (transfer_active !== 1'b0) begin $display("FAIL: active out of reset"); errors++; end
if (payload_bits !== '0) begin
$display("FAIL: payload_bits = %0d before any byte -- the guarded subtraction wrapped",
payload_bits); errors++; end
rst_n = 1'b1; @(negedge clk);
// ---- the closed forms, across a range of burst lengths ----
write_burst(1); check_burst(1);
write_burst(2); check_burst(2);
write_burst(4); check_burst(4);
write_burst(8); check_burst(8);
// ---- an ADDRESS-ONLY transfer: one byte, nine pulses, and ZERO payload.
// The guarded subtraction is what stops this reporting a huge number.
write_burst(0);
if (bytes_seen !== 16'd1) begin
$display("FAIL: an address-only transfer saw %0d bytes", bytes_seen); errors++; end
if (scl_pulses !== 16'd9) begin
$display("FAIL: an address-only transfer took %0d pulses, expected 9", scl_pulses); errors++; end
if (payload_bits !== '0) begin
$display("FAIL: an address-only transfer reported %0d payload bits", payload_bits); errors++; end
if (overhead_pulses !== 16'd9) begin
$display("FAIL: overhead was %0d, expected all 9 pulses", overhead_pulses); errors++; end
// ---- the metrics must SURVIVE the STOP, because that is when anything reads them.
if (transfer_active !== 1'b0) begin $display("FAIL: still active after the STOP"); errors++; end
if (bytes_seen === '0) begin
$display("FAIL: the counters were cleared by the STOP"); errors++; end
// ---- a repeated START starts a NEW measurement rather than accumulating.
pulse_start();
wire_byte(); wire_byte(); // address + one payload byte
if (bytes_seen !== 16'd2) begin
$display("FAIL: mid-phase bytes_seen = %0d, expected 2", bytes_seen); errors++; end
pulse_start(); // Sr
if (bytes_seen !== '0) begin
$display("FAIL: a repeated START did not restart the measurement (%0d)", bytes_seen);
errors++; end
wire_byte(); wire_byte(); wire_byte();
pulse_stop();
check_burst(2); // the post-Sr phase: address + 2 payload
// ---- an IDLE BUS must not be measured. Between transfers SCL keeps moving --
// another master's traffic, a monitor probing, a stuck clock -- and
// counting any of it would corrupt the very totals a consumer reads
// after the STOP. The snapshot is taken rather than assuming a value,
// because the point is that NOTHING changes.
snap_pulses = scl_pulses; snap_bytes = bytes_seen;
repeat (6) scl_pulse();
wire_byte(); // and a byte_done with no transfer open
if (scl_pulses !== snap_pulses || bytes_seen !== snap_bytes) begin
$display("FAIL: idle-bus clocking was counted -- %0d/%0d, expected %0d/%0d",
scl_pulses, bytes_seen, snap_pulses, snap_bytes); errors++; end
if (transfer_active !== 1'b0) begin
$display("FAIL: clocking an idle bus opened a transfer"); errors++; end
// ---- a STOP that closed NOTHING must not announce a measurement. A bus sees
// plenty of P's that end somebody else's transfer; a consumer woken by
// one of those would read a stale total as if it were fresh.
snap_valid = n_valid;
pulse_stop();
if (n_valid !== snap_valid) begin
$display("FAIL: a STOP that closed nothing produced metrics_valid"); errors++; end
// ---- one metrics_valid per completed transfer: 5 bursts + 1 Sr transfer = 6.
if (n_valid != 6) begin
$display("FAIL: %0d metrics_valid pulses, expected 6", n_valid); errors++; end
if (errors == 0)
$display("PASS: 9(n+1) pulses, 8n payload bits and n+9 overhead confirmed for n = 0,1,2,4,8");
else $display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule // A passive instrument that measures what a write burst actually costs. It exists so
// the arithmetic in this chapter is CHECKED rather than asserted: for a write of n
// payload bytes the closed forms are
//
// bytes on the wire = n + 1 (the address byte plus the payload)
// SCL pulses = 9(n + 1) (nine per byte, from Chapter 7.1)
// payload bits = 8n (the address byte carries none)
// overhead pulses = 9(n+1) - 8n = n + 9
//
// and a testbench can compare every one of them against a hand calculation.
module i2c_write_burst_metrics #(
parameter integer CNT_W = 16
)(
input wire clk,
input wire rst_n,
input wire scl_in, // observed bus level
input wire frame_start, // pulse: S or Sr
input wire frame_stop, // pulse: P
input wire byte_done, // pulse: a byte AND its ninth slot completed
output reg [CNT_W-1:0] scl_pulses, // SCL rising edges in this transfer
output reg [CNT_W-1:0] bytes_seen, // bytes INCLUDING the address byte
output wire [CNT_W-1:0] payload_bits, // 8 x (bytes_seen - 1), floored at zero
output wire [CNT_W-1:0] overhead_pulses, // pulses that carried no payload
output reg transfer_active,
output reg metrics_valid // pulse: a STOP closed the transfer
);
reg scl_q;
wire scl_rise = ~scl_q & scl_in;
// The address byte carries no payload, so it is excluded -- and the subtraction is
// guarded, because bytes_seen is zero before the first byte completes and an
// unguarded 8 x (0 - 1) would wrap to a very large number rather than to zero.
assign payload_bits = (bytes_seen == {CNT_W{1'b0}}) ? {CNT_W{1'b0}}
: ((bytes_seen - 1'b1) << 3);
assign overhead_pulses = (scl_pulses >= payload_bits) ? (scl_pulses - payload_bits)
: {CNT_W{1'b0}};
always @(posedge clk) begin
if (!rst_n) begin
scl_q <= 1'b1; // idle bus: SCL released, therefore high
scl_pulses <= {CNT_W{1'b0}};
bytes_seen <= {CNT_W{1'b0}};
transfer_active <= 1'b0;
metrics_valid <= 1'b0;
end else begin
metrics_valid <= 1'b0;
scl_q <= scl_in;
if (frame_stop) begin
// The counters are deliberately NOT cleared here: a consumer reads them
// AFTER the transfer, so clearing on the STOP would empty them before
// anything could look. The next frame_start clears them instead.
if (transfer_active) metrics_valid <= 1'b1;
transfer_active <= 1'b0;
end else if (frame_start) begin
// A repeated START begins a new measurement: the bytes after it belong
// to a new addressing, so counting them with the previous phase's
// totals would misreport both.
scl_pulses <= {CNT_W{1'b0}};
bytes_seen <= {CNT_W{1'b0}};
transfer_active <= 1'b1;
end else if (transfer_active) begin
if (scl_rise) scl_pulses <= scl_pulses + 1'b1;
if (byte_done) bytes_seen <= bytes_seen + 1'b1;
end
end
end
endmodule module i2c_write_burst_metrics_tb;
localparam integer CNT_W = 16;
reg clk, rst_n, scl_in, frame_start, frame_stop, byte_done;
wire [CNT_W-1:0] scl_pulses, bytes_seen, payload_bits, overhead_pulses;
wire transfer_active, metrics_valid;
integer errors, n_valid, i, b;
integer snap_pulses, snap_bytes, snap_valid;
integer exp_bytes, exp_pulses, exp_payload, exp_overhead;
i2c_write_burst_metrics #(.CNT_W(CNT_W)) dut (.clk(clk), .rst_n(rst_n), .scl_in(scl_in),
.frame_start(frame_start), .frame_stop(frame_stop), .byte_done(byte_done),
.scl_pulses(scl_pulses), .bytes_seen(bytes_seen), .payload_bits(payload_bits),
.overhead_pulses(overhead_pulses), .transfer_active(transfer_active),
.metrics_valid(metrics_valid));
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin #200000; $display("FAIL: watchdog expired"); $finish; end
// One SCL pulse. The metrics block counts rising edges, so this is the unit.
task scl_pulse; begin
scl_in = 1'b0; repeat (1) @(negedge clk);
scl_in = 1'b1; repeat (1) @(negedge clk);
scl_in = 1'b0; repeat (1) @(negedge clk);
end endtask
// One byte: nine SCL pulses, then the byte_done the engine of Chapter 7.1 emits.
task wire_byte; begin
for (i = 0; i < 9; i = i + 1) scl_pulse();
byte_done = 1'b1; @(negedge clk); byte_done = 1'b0; @(negedge clk);
end endtask
task pulse_start; begin frame_start = 1'b1; @(negedge clk); frame_start = 1'b0; @(negedge clk); end endtask
task pulse_stop; begin frame_stop = 1'b1; @(negedge clk); frame_stop = 1'b0; @(negedge clk); end endtask
// Drive a complete write of n payload bytes: address byte plus n data bytes.
task write_burst; input integer n; begin
pulse_start();
wire_byte(); // the address byte
for (b = 0; b < n; b = b + 1) wire_byte(); // the payload
pulse_stop();
end endtask
// The hand calculation, written as arithmetic on n and compared against the
// hardware. Two independent routes to the same four numbers.
task check_burst; input integer n; begin
exp_bytes = n + 1;
exp_pulses = 9 * (n + 1);
exp_payload = 8 * n;
exp_overhead = n + 9;
if (bytes_seen !== exp_bytes[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- bytes_seen %0d, expected %0d", n, bytes_seen, exp_bytes);
errors = errors + 1; end
if (scl_pulses !== exp_pulses[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- scl_pulses %0d, expected 9(n+1) = %0d", n, scl_pulses, exp_pulses);
errors = errors + 1; end
if (payload_bits !== exp_payload[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- payload_bits %0d, expected 8n = %0d", n, payload_bits, exp_payload);
errors = errors + 1; end
if (overhead_pulses !== exp_overhead[CNT_W-1:0]) begin
$display("FAIL: n=%0d -- overhead %0d, expected n+9 = %0d", n, overhead_pulses, exp_overhead);
errors = errors + 1; end
end endtask
always @(posedge clk) if (rst_n && metrics_valid) n_valid = n_valid + 1;
initial begin
errors = 0; n_valid = 0;
rst_n = 1'b0; scl_in = 1'b1; frame_start = 1'b0; frame_stop = 1'b0; byte_done = 1'b0;
repeat (3) @(negedge clk);
if (transfer_active !== 1'b0) begin $display("FAIL: active out of reset"); errors = errors + 1; end
if (payload_bits !== {CNT_W{1'b0}}) begin
$display("FAIL: payload_bits = %0d before any byte -- the guarded subtraction wrapped",
payload_bits); errors = errors + 1; end
rst_n = 1'b1; @(negedge clk);
// ---- the closed forms, across a range of burst lengths ----
write_burst(1); check_burst(1);
write_burst(2); check_burst(2);
write_burst(4); check_burst(4);
write_burst(8); check_burst(8);
// ---- an ADDRESS-ONLY transfer: one byte, nine pulses, and ZERO payload.
// The guarded subtraction is what stops this reporting a huge number.
write_burst(0);
if (bytes_seen !== 16'd1) begin
$display("FAIL: an address-only transfer saw %0d bytes", bytes_seen); errors = errors + 1; end
if (scl_pulses !== 16'd9) begin
$display("FAIL: an address-only transfer took %0d pulses, expected 9", scl_pulses); errors = errors + 1; end
if (payload_bits !== {CNT_W{1'b0}}) begin
$display("FAIL: an address-only transfer reported %0d payload bits", payload_bits); errors = errors + 1; end
if (overhead_pulses !== 16'd9) begin
$display("FAIL: overhead was %0d, expected all 9 pulses", overhead_pulses); errors = errors + 1; end
// ---- the metrics must SURVIVE the STOP, because that is when anything reads them.
if (transfer_active !== 1'b0) begin $display("FAIL: still active after the STOP"); errors = errors + 1; end
if (bytes_seen === {CNT_W{1'b0}}) begin
$display("FAIL: the counters were cleared by the STOP"); errors = errors + 1; end
// ---- a repeated START starts a NEW measurement rather than accumulating.
pulse_start();
wire_byte(); wire_byte(); // address + one payload byte
if (bytes_seen !== 16'd2) begin
$display("FAIL: mid-phase bytes_seen = %0d, expected 2", bytes_seen); errors = errors + 1; end
pulse_start(); // Sr
if (bytes_seen !== {CNT_W{1'b0}}) begin
$display("FAIL: a repeated START did not restart the measurement (%0d)", bytes_seen);
errors = errors + 1; end
wire_byte(); wire_byte(); wire_byte();
pulse_stop();
check_burst(2); // the post-Sr phase: address + 2 payload
// ---- one metrics_valid per completed transfer: 5 bursts + 1 Sr transfer = 6.
// ---- an IDLE BUS must not be measured. Between transfers SCL keeps moving --
// another master's traffic, a monitor probing, a stuck clock -- and
// counting any of it would corrupt the very totals a consumer reads
// after the STOP. The snapshot is taken rather than assuming a value,
// because the point is that NOTHING changes.
snap_pulses = scl_pulses; snap_bytes = bytes_seen;
for (i = 0; i < 6; i = i + 1) scl_pulse;
wire_byte; // and a byte_done with no transfer open
if (scl_pulses !== snap_pulses[CNT_W-1:0] || bytes_seen !== snap_bytes[CNT_W-1:0]) begin
$display("FAIL: idle-bus clocking was counted -- %0d/%0d, expected %0d/%0d",
scl_pulses, bytes_seen, snap_pulses, snap_bytes); errors = errors + 1; end
if (transfer_active !== 1'b0) begin
$display("FAIL: clocking an idle bus opened a transfer"); errors = errors + 1; end
// ---- a STOP that closed NOTHING must not announce a measurement. A bus sees
// plenty of P's that end somebody else's transfer; a consumer woken by
// one of those would read a stale total as if it were fresh.
snap_valid = n_valid;
pulse_stop;
if (n_valid !== snap_valid) begin
$display("FAIL: a STOP that closed nothing produced metrics_valid"); errors = errors + 1; end
if (n_valid != 6) begin
$display("FAIL: %0d metrics_valid pulses, expected 6", n_valid); errors = errors + 1; end
if (errors == 0)
$display("PASS: 9(n+1) pulses, 8n payload bits and n+9 overhead confirmed for n = 0,1,2,4,8");
else $display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
-- A passive instrument that measures what a write burst actually costs. It exists so
-- the arithmetic in this chapter is CHECKED rather than asserted: for a write of n
-- payload bytes the closed forms are
--
-- bytes on the wire = n + 1 (the address byte plus the payload)
-- SCL pulses = 9(n + 1) (nine per byte, from Chapter 7.1)
-- payload bits = 8n (the address byte carries none)
-- overhead pulses = 9(n+1) - 8n = n + 9
entity i2c_write_burst_metrics is
generic (
CNT_W : positive := 16
);
port (
clk : in std_logic;
rst_n : in std_logic;
scl_in : in std_logic; -- observed bus level
frame_start : in std_logic; -- pulse: S or Sr
frame_stop : in std_logic; -- pulse: P
byte_done : in std_logic; -- pulse: a byte and its slot ended
scl_pulses : out unsigned(CNT_W - 1 downto 0);
bytes_seen : out unsigned(CNT_W - 1 downto 0);
payload_bits : out unsigned(CNT_W - 1 downto 0);
overhead_pulses : out unsigned(CNT_W - 1 downto 0);
transfer_active : out std_logic;
metrics_valid : out std_logic -- pulse: a STOP closed the transfer
);
end entity;
architecture rtl of i2c_write_burst_metrics is
constant ZERO : unsigned(CNT_W - 1 downto 0) := (others => '0');
signal scl_q : std_logic := '1'; -- idle bus: SCL released, therefore high
signal pulses : unsigned(CNT_W - 1 downto 0) := (others => '0');
signal nbytes : unsigned(CNT_W - 1 downto 0) := (others => '0');
signal active : std_logic := '0';
signal scl_rise : std_logic;
signal pay : unsigned(CNT_W - 1 downto 0);
begin
scl_rise <= (not scl_q) and scl_in;
scl_pulses <= pulses;
bytes_seen <= nbytes;
transfer_active <= active;
-- The address byte carries no payload, so it is excluded -- and the subtraction is
-- guarded, because nbytes is zero before the first byte completes and an unguarded
-- 8 x (0 - 1) would wrap to a very large number rather than to zero.
pay <= ZERO when nbytes = ZERO else shift_left(nbytes - 1, 3);
payload_bits <= pay;
overhead_pulses <= (pulses - pay) when pulses >= pay else ZERO;
process (clk)
begin
if rising_edge(clk) then
if rst_n = '0' then
scl_q <= '1';
pulses <= (others => '0');
nbytes <= (others => '0');
active <= '0';
metrics_valid <= '0';
else
metrics_valid <= '0';
scl_q <= scl_in;
if frame_stop = '1' then
-- The counters are deliberately NOT cleared here: a consumer reads
-- them AFTER the transfer. The next frame_start clears them.
if active = '1' then metrics_valid <= '1'; end if;
active <= '0';
elsif frame_start = '1' then
-- A repeated START begins a new measurement.
pulses <= (others => '0');
nbytes <= (others => '0');
active <= '1';
elsif active = '1' then
if scl_rise = '1' then pulses <= pulses + 1; end if;
if byte_done = '1' then nbytes <= nbytes + 1; end if;
end if;
end if;
end if;
end process;
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_write_burst_metrics_tb is
end entity;
architecture sim of i2c_write_burst_metrics_tb is
constant CNT_W : positive := 16;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal scl_in : std_logic := '1';
signal frame_start : std_logic := '0';
signal frame_stop : std_logic := '0';
signal byte_done : std_logic := '0';
signal scl_pulses, bytes_seen, payload_bits, overhead_pulses : unsigned(CNT_W - 1 downto 0);
signal transfer_active, metrics_valid : std_logic;
-- Owned solely by the counting process; the stimulus snapshots it instead of
-- clearing it, because VHDL permits one driver per signal.
signal n_valid : natural := 0;
signal test_done : std_logic := '0';
begin
dut : entity work.i2c_write_burst_metrics
generic map (CNT_W => CNT_W)
port map (clk => clk, rst_n => rst_n, scl_in => scl_in, frame_start => frame_start,
frame_stop => frame_stop, byte_done => byte_done,
scl_pulses => scl_pulses, bytes_seen => bytes_seen,
payload_bits => payload_bits, overhead_pulses => overhead_pulses,
transfer_active => transfer_active, metrics_valid => metrics_valid);
clk <= not clk after 5 ns;
watchdog : process
begin
wait for 200 us;
if test_done = '0' then
report "watchdog expired -- the design never reached the expected state"
severity failure;
end if;
wait;
end process;
counter : process (clk)
begin
if rising_edge(clk) then
if rst_n = '1' and metrics_valid = '1' then n_valid <= n_valid + 1; end if;
end if;
end process;
stim : process
variable errs : natural := 0;
variable snap_pulses, snap_bytes : unsigned(CNT_W - 1 downto 0);
variable snap_valid : natural;
procedure waitn (n : in positive) is
begin
for i in 1 to n loop wait until falling_edge(clk); end loop;
end procedure;
procedure scl_pulse is
begin
scl_in <= '0'; waitn(1);
scl_in <= '1'; waitn(1);
scl_in <= '0'; waitn(1);
end procedure;
-- One byte: nine SCL pulses, then the byte_done the engine of Chapter 7.1 emits.
procedure wire_byte is
begin
for i in 1 to 9 loop scl_pulse; end loop;
byte_done <= '1'; waitn(1); byte_done <= '0'; waitn(1);
end procedure;
procedure pulse_start is
begin
frame_start <= '1'; waitn(1); frame_start <= '0'; waitn(1);
end procedure;
procedure pulse_stop is
begin
frame_stop <= '1'; waitn(1); frame_stop <= '0'; waitn(1);
end procedure;
procedure write_burst (n : in natural) is
begin
pulse_start;
wire_byte; -- the address byte
for b in 1 to n loop wire_byte; end loop; -- the payload
pulse_stop;
end procedure;
-- The hand calculation, as arithmetic on n, compared against the hardware.
procedure check_burst (n : in natural) is
variable exp_bytes, exp_pulses, exp_payload, exp_overhead : natural;
begin
exp_bytes := n + 1;
exp_pulses := 9 * (n + 1);
exp_payload := 8 * n;
exp_overhead := n + 9;
if bytes_seen /= to_unsigned(exp_bytes, CNT_W) then
report "n=" & integer'image(n) & ": bytes_seen wrong" severity error;
errs := errs + 1; end if;
if scl_pulses /= to_unsigned(exp_pulses, CNT_W) then
report "n=" & integer'image(n) & ": scl_pulses is not 9(n+1)" severity error;
errs := errs + 1; end if;
if payload_bits /= to_unsigned(exp_payload, CNT_W) then
report "n=" & integer'image(n) & ": payload_bits is not 8n" severity error;
errs := errs + 1; end if;
if overhead_pulses /= to_unsigned(exp_overhead, CNT_W) then
report "n=" & integer'image(n) & ": overhead is not n+9" severity error;
errs := errs + 1; end if;
end procedure;
begin
waitn(3);
if transfer_active /= '0' then
report "active out of reset" severity error; errs := errs + 1; end if;
if payload_bits /= to_unsigned(0, CNT_W) then
report "payload_bits nonzero before any byte -- the guarded subtraction wrapped"
severity error; errs := errs + 1; end if;
rst_n <= '1'; waitn(1);
-- the closed forms, across a range of burst lengths
write_burst(1); check_burst(1);
write_burst(2); check_burst(2);
write_burst(4); check_burst(4);
write_burst(8); check_burst(8);
-- an ADDRESS-ONLY transfer: one byte, nine pulses, ZERO payload.
write_burst(0);
if bytes_seen /= to_unsigned(1, CNT_W) then
report "an address-only transfer saw the wrong byte count" severity error;
errs := errs + 1; end if;
if scl_pulses /= to_unsigned(9, CNT_W) then
report "an address-only transfer took the wrong number of pulses" severity error;
errs := errs + 1; end if;
if payload_bits /= to_unsigned(0, CNT_W) then
report "an address-only transfer reported payload bits" severity error;
errs := errs + 1; end if;
if overhead_pulses /= to_unsigned(9, CNT_W) then
report "overhead should be all nine pulses" severity error; errs := errs + 1; end if;
-- the metrics must SURVIVE the STOP.
if transfer_active /= '0' then
report "still active after the STOP" severity error; errs := errs + 1; end if;
if bytes_seen = to_unsigned(0, CNT_W) then
report "the counters were cleared by the STOP" severity error; errs := errs + 1; end if;
-- a repeated START starts a NEW measurement rather than accumulating.
pulse_start;
wire_byte; wire_byte;
if bytes_seen /= to_unsigned(2, CNT_W) then
report "mid-phase byte count wrong" severity error; errs := errs + 1; end if;
pulse_start; -- Sr
if bytes_seen /= to_unsigned(0, CNT_W) then
report "a repeated START did not restart the measurement" severity error;
errs := errs + 1; end if;
wire_byte; wire_byte; wire_byte;
pulse_stop;
check_burst(2);
-- an IDLE BUS must not be measured. Between transfers SCL keeps moving --
-- another master's traffic, a monitor probing, a stuck clock -- and counting
-- any of it would corrupt the very totals a consumer reads after the STOP.
-- The snapshot is taken rather than assuming a value, because the point is
-- that NOTHING changes.
snap_pulses := scl_pulses; snap_bytes := bytes_seen;
for i in 1 to 6 loop scl_pulse; end loop;
wire_byte; -- and a byte_done with no transfer open
if scl_pulses /= snap_pulses or bytes_seen /= snap_bytes then
report "idle-bus clocking was counted" severity error; errs := errs + 1; end if;
if transfer_active /= '0' then
report "clocking an idle bus opened a transfer" severity error;
errs := errs + 1; end if;
-- a STOP that closed NOTHING must not announce a measurement. A bus sees
-- plenty of P's that end somebody else's transfer; a consumer woken by one of
-- those would read a stale total as if it were fresh.
snap_valid := n_valid;
pulse_stop;
if n_valid /= snap_valid then
report "a STOP that closed nothing produced metrics_valid" severity error;
errs := errs + 1; end if;
-- one metrics_valid per completed transfer: 5 bursts + 1 Sr transfer = 6.
if n_valid /= 6 then
report "wrong number of metrics_valid pulses" severity error; errs := errs + 1; end if;
if errs = 0 then
report "i2c_write_burst_metrics self-check complete: 9(n+1) pulses, 8n payload "
& "bits and n+9 overhead confirmed for n = 0,1,2,4,8" severity note;
else
report "i2c_write_burst_metrics self-check FAILED" severity error;
end if;
test_done <= '1';
wait;
end process;
end architecture;7a. Four Decisions Worth Defending
The subtraction is guarded. payload_bits is 8 × (bytes_seen − 1), and before the first byte completes bytes_seen is zero. An unguarded subtraction there does not produce −8; it wraps to a very large unsigned number, and the instrument reports 65528 payload bits on an idle bus. The guard is one ternary, and the mutation that removes it is killed by a check that runs before any stimulus at all.
This is the generic unsigned-arithmetic hazard and it is worth naming: any subtraction on an unsigned counter needs a floor, and the case that exposes it is the reset state — which is exactly the case a testbench that starts by driving stimulus never reaches.
The counters are not cleared by the STOP. They must survive it, because the STOP is when a consumer reads them. Clearing on the STOP would make the instrument's outputs readable only during a transfer, which is the one time they are incomplete. They are cleared on the next frame_start instead — the last possible moment that cannot destroy a result somebody is entitled to read. The same reasoning appears in Chapter 8.2 §5a for that block's verdict outputs; it is a general rule for any block whose output is a result rather than a state.
A repeated START restarts the measurement rather than accumulating. An Sr begins a new addressing (Chapter 5.4), so the bytes after it belong to a different transfer and a different direction. Accumulating across the Sr would report a single meaningless total for two transfers — and would do so plausibly, which is worse than reporting nothing.
The pulse counter is gated on transfer_active. Between transfers SCL keeps moving: another master's traffic, a monitor probing, a stuck clock. An ungated counter would add all of it to the totals a consumer reads after the STOP, silently. The mutation that removes the gate survived the first version of the testbench, and §9 records what had to be added to kill it.
7b. Verified Execution
$ iverilog -g2012 -o c1 i2c_write_burst_metrics.sv i2c_write_burst_metrics_tb.sv && ./c1
PASS: 9(n+1) pulses, 8n payload bits and n+9 overhead confirmed for n = 0,1,2,4,8
i2c_write_burst_metrics_tb.sv:138: $finish called at 8040 (1s)
$ iverilog -g2005 -o c2 i2c_write_burst_metrics.v i2c_write_burst_metrics_tb.v && ./c2
PASS: 9(n+1) pulses, 8n payload bits and n+9 overhead confirmed for n = 0,1,2,4,8
i2c_write_burst_metrics_tb.v:144: $finish called at 8040 (1s)
$ nvc -a i2c_write_burst_metrics.vhd i2c_write_burst_metrics_tb.vhd
$ nvc -e i2c_write_burst_metrics_tb && nvc -r i2c_write_burst_metrics_tb --stop-time=250us
** Note: 8040ns+0: i2c_write_burst_metrics self-check complete: 9(n+1) pulses,
8n payload bits and n+9 overhead confirmed for n = 0,1,2,4,8Every number in §2's table is now a hardware measurement compared against an independently computed expectation, for n = 0, 1, 2, 4 and 8. The testbench computes the expectation from n arithmetically — exp_pulses = 9 * (n + 1) — rather than from a table of constants, which is what makes it a check on the formula rather than a check on a transcription.
8. What the Testbench Proves
| # | stimulus | what it establishes |
|---|---|---|
| 1 | n = 1, 2, 4, 8 | all four closed forms, against arithmetic computed from n |
| 2 | before any byte | payload_bits is 0, not 65528 — the guarded subtraction |
| 3 | n = 0 | an address-only probe: 1 byte, 9 pulses, 0 payload, 9 overhead |
| 4 | after the STOP | the counters survive, and transfer_active has dropped |
| 5 | an Sr mid-transfer | the measurement restarts: 2 bytes seen, then 0 after the Sr |
| 6 | SCL toggled on an idle bus | nothing is counted, and no transfer opens |
| 7 | a STOP that closed nothing | no metrics_valid — 6 completed transfers, 6 pulses |
Test 3 is the address-only case again, and it is the strongest single test of the payload_bits formula. One byte on the wire, nine pulses, and zero payload bits — so all nine pulses are overhead, which is the n = 0 endpoint of the n + 9 expression. A design that counted the address byte as payload reports 8 bits here and is caught immediately.
Tests 6 and 7 both exist because mutations survived without them, which §9 details.
9. Mutation Testing
Eight defects injected into the SystemVerilog instrument.
| # | injected defect | outcome |
|---|---|---|
| C1 | pulses counted on the falling edge | killed — 19 pulses where 18 were expected |
| C2 | the address byte counted as payload | killed — payload_bits 16, expected 8 |
| C2b | the guard dropped, so a zero byte count wraps | killed — payload_bits = 65528 before any byte |
| C3 | a repeated START accumulates instead of restarting | killed — bytes_seen 5, expected 3 |
| C4 | the STOP clears the counters | killed — bytes_seen 0 after the transfer |
| C5 | pulses counted while no transfer is active | killed — idle clocking counted, 42 pulses where 27 stood |
| C6 | metrics_valid fires on a STOP that closed nothing | killed |
| C7 | bytes counted but pulses are not | killed — 0 pulses, expected 18 |
Across all three of this module's designs the final tally is 24 injected, 24 killed, no survivors — but that was not the first result, and the honest account is more useful than the number.
C1's kill message is worth examining, because the error is small. Counting on the falling edge instead of the rising edge gives 19 pulses where 18 are expected — off by one, not off by a factor. The testbench's scl_pulse task drives SCL low, high, low, so a falling-edge counter sees one extra transition from the trailing low.
An off-by-one is exactly the size of error a loosely-written check misses. A test asserting only that the payload-to-total ratio looked about right would have passed; so would one that compared this transfer's count against the previous transfer's. What kills it is comparing an absolute count against a number computed independently from n — which is the argument for exp_pulses = 9 * (n + 1) in the testbench rather than a table of expected values.
C5 and C6 both survived the first testbench, and both for the same structural reason: the testbench only ever exercised the instrument inside a transfer. Every SCL pulse it generated came between a START and a STOP, so the transfer_active gate was never tested — nothing in the stimulus ever asked what happens on an idle bus. Two tests were added: clock the bus with no transfer open and confirm nothing moves, and issue a STOP that closes nothing and confirm no measurement is announced.
The generalisation is worth keeping, because it is one of the most common gaps in a testbench: a test suite that only exercises the active case cannot test the gate that defines "active". If a design has an enable, a valid, or a state predicate, some stimulus has to occur while that predicate is false.
C2 and C2b are a pair and neither is sufficient. C2 checks the formula is right when there is data; C2b checks it does not wrap when there is none. The second is a test of the reset state, which is a region a stimulus-driven test naturally skips over on its way to doing something interesting.
10. Verification Connection — Throughput Is a Coverage Problem
An instrument that measures a burst is only half of what a verification environment wants. The other half is knowing which burst lengths were ever tried — and that is functional coverage, not a checker.
covergroup i2c_burst_cg with function sample(int n, bit nacked, bit stretched);
// The bins are chosen from section 3's curve, not arbitrarily. The interesting
// region is small n, where efficiency changes fast; past 16 the curve is flat
// and one bin covers it. Coverage bins should follow the shape of the thing
// being covered.
burst_len: coverpoint n {
bins probe = {0}; // address-only: the scan primitive
bins single = {1}; // 44% efficient -- the case to find in a driver
bins short = {[2:4]};
bins medium = {[5:16]};
bins long = {[17:255]};
}
// A NACK partway through a burst is a distinct scenario from a clean one,
// because it is where the partial-write semantics of Chapter 8.1 apply.
nack_in_burst: coverpoint nacked;
// Clock stretching changes the TIME without changing the pulse count, so a
// throughput measurement that never saw a stretched burst has not been
// validated against the dominant real-world cost.
stretch: coverpoint stretched;
// The cross is the point. A long burst that was never NACKed and a short one
// that always was would fill both coverpoints while leaving the interesting
// combinations -- a NACK late in a long burst -- entirely untried.
len_x_nack: cross burst_len, nack_in_burst;
len_x_stretch: cross burst_len, stretch;
endgroupAnd the throughput check itself, which belongs in a scoreboard rather than an assertion because it is a statement about a completed transfer:
// The formulas of section 2, checked against what the monitor observed. Written
// as arithmetic on n rather than as a table of expected values, so this is a
// check on the FORMULA and not on a transcription of it.
function void check_burst_cost(int n, int observed_pulses, int observed_payload);
int exp_pulses = 9 * (n + 1);
int exp_payload = 8 * n;
int exp_overhead = n + 9;
if (observed_pulses !== exp_pulses)
`uvm_error("COST", $sformatf("n=%0d: %0d SCL pulses, expected 9(n+1)=%0d",
n, observed_pulses, exp_pulses))
if (observed_payload !== exp_payload)
`uvm_error("COST", $sformatf("n=%0d: %0d payload bits, expected 8n=%0d",
n, observed_payload, exp_payload))
// The identity that ties them together. Checking it as well as the two
// parts catches a monitor whose two counters are individually plausible
// and mutually inconsistent -- which a check on each part alone cannot.
if ((observed_pulses - observed_payload) !== exp_overhead)
`uvm_error("COST", $sformatf("n=%0d: overhead %0d, expected n+9=%0d",
n, observed_pulses - observed_payload, exp_overhead))
endfunction11. FPGA and ASIC Implications
The instrument's cost is two counters. At CNT_W = 16 that is 32 flops plus an edge detector, and payload_bits and overhead_pulses are combinational — a shift by three and a guarded subtraction. Nothing here needs to be wide: 16 bits counts 65535 pulses, which is a 7000-byte transfer, far beyond anything a real I²C burst reaches.
Sizing CNT_W deserves one moment of thought, and the answer is to size it from the pulse count rather than the byte count. A transfer of n bytes generates 9(n+1) pulses, so the pulse counter overflows nine times sooner than intuition based on byte counts suggests. At CNT_W = 8, that is 28 bytes — plausibly reachable, and an overflow reports a small number rather than an error.
It is genuinely passive and worth keeping that way. No output goes near SDA or SCL, so it cannot be the cause of a bus problem — which is the whole point of putting one in silicon. A debug block that can perturb the bus it is measuring is a debug block people switch off, and then it is not there on the day it is needed.
On an FPGA this is the cheapest bus-health monitor available. Two counters and a flag, readable over whatever register interface already exists, and it answers questions that are otherwise a logic-analyser session: is the bus being clocked at all, are transfers completing, is the byte count what the driver thinks it is. The metrics_valid pulse is designed to drive an interrupt or a capture trigger for exactly that use.
The comparison against a hand calculation is what makes it trustworthy, and that property does not survive into silicon automatically. In simulation the testbench computes 9(n+1) independently. In silicon nothing does — so the instrument's outputs are only as trustworthy as the verification that shipped with it. This is the argument for the check in §10 being part of the environment rather than a one-off.
12. Debugging — The Burst That Reported Perfect Success and Wrote One Byte
Pitfall — a device that acknowledges a burst it cannot store
// A driver writes eight calibration bytes to a sensor in one burst. The device's
// register map has the eight coefficients at 0x20 through 0x27, so the burst is:
//
// S 0x90 A 0x20 A d0 A d1 A d2 A d3 A d4 A d5 A d6 A d7 A P
//
// Every byte is acknowledged. The transaction returns success. The instrument of
// section 7 reports 10 bytes on the wire, 90 SCL pulses, 72 payload bits and 18
// pulses of overhead -- which is exactly 9(n+1), 8n and n+9 for n = 9.
//
// The measurement is correct. The transfer is correct. The bus is correct.
// And the device has one calibration coefficient, not eight.Reading the coefficients back gives 0x20 through 0x26 holding their old values and 0x27 -- the LAST register in the burst -- holding d7. Every other byte is gone.
Everything visible says the write worked. The capture is textbook: correct address, correct pointer, eight data bytes in the right order, an ACK on every one of them, a clean STOP. The instrument's numbers match the closed forms exactly. There is no error anywhere to attach an investigation to.
So the investigation went to the READBACK path, on the theory that the write had landed and the read was wrong. It had not, and the read was fine. Then to the driver's buffer, on the theory that it was sending the same byte repeatedly -- but the capture shows eight DIFFERENT bytes on the wire, which rules that out directly.
The clue was which register survived: the last one, holding the last byte. That is not the signature of a transfer that failed partway. It is the signature of eight successful writes to the SAME address, where each one overwrote the one before -- and the address they all went to was the pointer, unchanged.
The device does not auto-increment its pointer on a write. The protocol permits an unlimited number of bytes per transfer, and the device dutifully accepted and acknowledged all eight -- into the same register.
Auto-increment is a DEVICE convention, not an I2C feature. Nothing in the protocol, the capture, or the measured cost can reveal its absence, because from the bus's point of view nothing went wrong. The only place the answer exists is the datasheet, which in this case said the coefficients must be written with individual transactions.
13. Common Misconceptions
"Fast-mode makes I²C more efficient." It makes it faster. The efficiency is 8/9 at every speed, because the acknowledge pulse scales with the clock exactly as the data pulses do. A 400 kHz bus wastes the same one pulse in nine as a 100 kHz bus.
"A long enough burst reaches 100% efficiency." It reaches 88.9%. The address byte is amortisable; the per-byte acknowledge is not. One pulse in nine is a floor, and no burst length, mode or configuration goes below it.
"The STOP and START cost clock pulses." They cost time but not pulses — they are defined by SDA moving while SCL is high. This is why a frame's pulse count is always a multiple of nine, and why that divisibility is a useful sanity check on a capture.
"If every byte was ACKed, the burst worked." Every byte was received. Whether it was stored where you intended depends on whether the device auto-increments, which is a datasheet property the bus cannot report. §12 is that failure in full.
"The arithmetic in §3 gives the throughput I will get." It gives an upper bound on an unloaded bus with a slave that never stretches. Clock stretching and rise time both reduce it, and both are properties of the devices and the board rather than the protocol.
"A 16-bit pulse counter is overkill." Size it from the pulse count, not the byte count: 9(n+1) overflows nine times sooner than a byte counter would. At eight bits the limit is 28 bytes.
"Bursting is always better." Bursting is better when the device supports it and the registers are contiguous. §12's device supported neither, and the correct answer there was eight single-byte writes at 44% efficiency — because a fast wrong answer is not a trade-off.
14. Reason It Through
A capture shows 63 SCL pulses between one START and the next. How many payload bytes, and how do you know the count is trustworthy?
63 / 9 = 7 bytes on the wire, so six payload bytes plus the address. The count is trustworthy because 63 divides by nine — a pulse total that is not a multiple of nine means either a truncated byte or a framing event that was missed, so the divisibility is the check and not just the arithmetic.
Two designs move 64 bytes to a device that needs no register-pointer byte: one issues 64 single-byte writes, the other one 64-byte burst. Compute both costs and state the ratio.
Single-byte writes: 64 × 18 = 1152 pulses, plus 64 START/STOP pairs and 64 bus-free intervals. One burst: 9 × 65 = 585 pulses, plus one START/STOP pair. The ratio is 1.97 — just under 2× — and that is before the framing and bus-free time, which widens it further. The reason it is almost exactly 2× is that a single-byte write is 44.4% efficient and a long burst approaches 88.9%, and 88.9 / 44.4 = 2.
Why does the instrument's payload_bits need a guard, when bytes_seen can only ever be zero before the first byte?
Because that is precisely when it is read. payload_bits is combinational, so it has a value at every instant including immediately after reset — and 8 × (0 − 1) on an unsigned counter is not −8 but 65528. The guard is needed for the state the design spends its idle life in, which is the state a stimulus-driven test reaches only if somebody deliberately checks before driving anything.
An engineer moves a driver from 100 kHz to 400 kHz and measures a 2.8× speed-up rather than 4×. Give two candidate explanations consistent with this chapter.
Clock stretching, which costs the same wall-clock time at both speeds and therefore becomes a larger fraction of a shorter transfer — so the stretched portion does not speed up at all. Or rise time: Chapter 2.4 established that a loaded bus cannot reach its nominal rate, and the loading that was invisible at 100 kHz may be limiting at 400. Both are properties the pulse arithmetic is blind to, and distinguishing them takes a measurement of the actual SCL period — which is what an instrument that counts pulses over a known time window gives you.
The instrument's counters are not cleared on the STOP. Is that a bug waiting to happen?
No — it is the requirement. The STOP is when the totals are complete and therefore when a consumer reads them; clearing there would make them readable only while incomplete. They are cleared on the next frame_start, which is the latest point that cannot destroy a result somebody is entitled to read. The general rule: a block whose output is a result clears at the start of the next operation, and a block whose output is a state clears when the state ends.
15. Understanding Check
16. Summary
The unit of cost is the nine-pulse byte slot, and the specification makes all three of its terms explicit: eight bits per byte, one mandatory acknowledge bit after each, and no limit on bytes per transfer. The first two set the efficiency ceiling at 8/9; the third is what makes the address byte's cost amortisable.
The closed forms are worth memorising. A write of n payload bytes puts n+1 bytes on the wire, takes 9(n+1) SCL pulses, delivers 8n payload bits, and wastes n+9 pulses. The overhead splits into a fixed 9 for the address byte and a proportional n for the acknowledges, and only the first can be amortised.
The curve is steep early and flat late. 44% at one byte, 71% at four, 84% at sixteen, 88.9% in the limit. The engineering advice that follows is stop issuing single-byte writes, not maximise burst length — past 8 to 16 bytes the remaining gain rarely justifies restructuring anything.
The arithmetic is an upper bound. It counts pulses, so it is blind to framing time, the bus-free interval, clock stretching and rise time — and the last two are often the dominant real costs. A throughput budget from arithmetic must be validated by measurement, which is what a passive instrument is for.
An ACK means received, never stored. Bursting depends on the slave's auto-increment, which is a datasheet property no capture and no protocol checker can verify. A device that does not auto-increment acknowledges a whole burst and keeps one byte of it, and the register that survives tells you which mechanism failed.
A subtraction on an unsigned counter needs a floor, and the case that exposes it is reset. That is the smallest lesson here and the one most likely to recur in unrelated code.
17. What Comes Next
Module 8 is complete: the write transaction end to end, both of its roles, and what it costs. Every claim in it was compiled and run in three languages, and every design survived a mutation suite whose survivors were closed rather than excused.
Module 9 turns the direction bit around, and the read is genuinely harder for a reason this module can now make precise. In a write, the transmitter role is held by one device for the whole frame and the only handover is the eight-to-nine slot boundary inside a byte. In a read, the master becomes the receiver and the slave becomes the transmitter — which means SDA changes owner at a byte boundary, on a bus where both devices can pull it low and neither can drive it high. Every mechanism in Modules 5 through 8 is reused, and the one new thing is the handover.
The chapter that becomes load-bearing there is 7.4. In a write, the final NACK is a fault. In a read, it is how the master tells a transmitting slave to stop — and a master that acknowledges a read's last byte hangs the bus, for reasons that are now entirely derivable from what this module established.
Continue learning
Related tutorials
- Related topic
Multi-Byte I²C Reads — ACK Policy, Timing and Waveforms
A well-formed read of n bytes contains exactly n−1 ACKs and one NACK, always last. That pattern is narrow enough to check in hardware — and narrow enough to predict a bus hang before the STOP that cannot form is even attempted.
- Related topic
Repeated START — Holding the Bus Between Phases
A repeated START is not a new waveform. It is the START edge again, and what makes it a different event is that the bus was already busy. That single fact is why a classifier needs state and why a monitor that joins late cannot classify what it sees.
- Related topic
10-Bit Addressing
A two-byte addressing mode built entirely out of reserved space, coexisting with seven-bit devices on the same wires. Its first byte is deliberately not unique, and a read has to re-address with only one byte — which is why a 10-bit slave needs memory a 7-bit slave does not.
- Related topic
Repeated START in Practice — Why Not STOP Then START
Two sequences that look nearly identical in a driver's source are completely different on the wire. This chapter measures the difference, builds the passive monitor that tells them apart from two wires alone, and names exactly what a STOP costs on a shared bus.
