I²C · Module 3
I²C Inside an SoC — Controller, Peripherals and the Software View
Follow a transfer from software to the conductor. A driver writes registers; a controller peripheral turns that into bus activity; completion and failure come back as status and interrupts. Build the register shell in three languages and see where UVM RAL fits.
Module 2 ended at a pin. Chapter 3.3 ended at the board outside that pin. This chapter goes the other way — inward, through the package, to the thing that actually decides when SDA and SCL move.
The question it answers is one every embedded and SoC engineer eventually needs: what actually happens between a line of driver code and a level on a conductor? The answer has more layers than most people expect, and each layer exists to hide the one below it. Getting the layering right is what lets a driver author ignore rise times and an RTL designer ignore register maps.
1. The Stack
Start with the whole path, then take it apart.
Read it downward once. Software performs ordinary loads and stores to addresses that happen to belong to a peripheral. The interconnect carries those accesses. The register block is where they land — and this is the software-visible contract, the only part of the stack a driver author has to understand. The engine turns a register-level command into the actual sequence of edges on two conductors. The pad converts pull-low intent into pin behaviour, exactly as Chapter 2.3 established. The board is Chapter 3.3.
Two things are worth noticing immediately. The layers are narrow: each talks only to its neighbours. And the abstraction runs one way: software cannot see edges, and the engine cannot see the driver\u0027s intent — only the command it was given.
2. Software Does Not Toggle the Bus
The most common misconception about this stack is that a CPU drives SDA and SCL directly, one edge at a time. Worth addressing head-on, because the truth is a better systems lesson than the myth.
In the ordinary hardware-controller model the flow is:
- Software configures the controller once — enable it, set the speed.
- Software describes a transfer: which target, which direction, what payload.
- Software starts it with a single register write.
- The controller executes the entire transfer autonomously — every edge, every bit, at bus speed.
- Status changes, and software learns about it by polling or by interrupt.
- Software consumes the result.
Notice what software never does: it never waits for an individual bit, never times an edge, and never reasons about a rising edge\u0027s duration. A transfer that takes tens of microseconds on the wire costs software a few register accesses and a notification.
That division exists because the two domains run at incompatible speeds and with incompatible notions of time. A bus bit period is glacial by CPU standards and unforgiving in its timing requirements; software is fast and cannot be relied upon to be punctual. Hardware is good at precise slow things, software is good at imprecise fast things, and the register block is the seam between them.
3. The Register Interface
The register block is the software contract, so it is worth designing rather than listing. This is a deliberately generic educational map — it mimics no specific vendor peripheral, and real ones have more registers, deeper FIFOs and more configuration. The six below are the minimum that makes the architecture visible.
| Address | Register | Access | Why it exists |
|---|---|---|---|
| 0 | CTRL | read/write | enable the controller; configuration lives here |
| 1 | STATUS | read, write-one-to-clear | busy, done, error — how software learns what happened |
| 2 | TARGET | read/write | which target this transfer concerns |
| 3 | TXDATA | read/write | the payload offered to the controller |
| 4 | RXDATA | read-only | the payload the controller received |
| 5 | COMMAND | write-only | start a transfer, and say which direction |
Four design decisions in that table are worth defending, because each one is a general register-design lesson:
STATUS flags are write-one-to-clear. done and error are set by hardware and cleared by software writing a one to the bit it has observed. Software cannot simply be trusted to have seen a flag, and hardware cannot know when it has — so acknowledgement has to be explicit. This is the same pattern as the sticky fault bit in Chapter 1.3.
RXDATA is read-only. Software writing received data would mean nothing. Writes are ignored rather than treated as errors, which is the conventional and more forgiving choice.
COMMAND is write-only and self-clearing. It is an action, not a state. Reading it back would invite software to treat it as a mode.
busy and done are separate bits. busy says a transfer is in progress; done says one finished and software has not acknowledged it yet. Collapsing them loses the ability to distinguish "nothing has happened yet" from "something finished and you missed it".
4. The Command/Status Shell in RTL
Now build it. The block below is the software-facing half of an I\u00B2C controller: it accepts register accesses, validates a command, hands an abstract request to an engine, and turns the engine\u0027s completion into software-visible status.
The bit-level engine is deliberately abstract. It appears as a handshake — eng_req out, eng_done/eng_error/eng_rdata back — and nothing here implements framing, addressing, acknowledgement or timing. Its implementation is Module 17, after START/STOP, addressing, ACK/NACK, clock stretching and arbitration have been established. Treating this as a controller would produce something that compiles and cannot talk to a device.
What the shell does own is a real and genuinely tricky contract:
- A command is accepted only when enabled and not busy. A command written while busy is ignored — a defined behaviour, deliberately chosen rather than left unspecified.
eng_reqis a one-cycle pulse, not a level, so the engine sees exactly one request per accepted command.RXDATAis latched only on a read, so a failed or write transfer cannot disturb data software has not consumed.doneanderrorare sticky until acknowledged.
4a. SystemVerilog
module i2c_cmd_shell (
input logic clk,
input logic rst_n,
// --- host register interface (minimal: no full on-chip bus protocol) ----
input logic reg_wr,
input logic [2:0] reg_addr,
input logic [7:0] reg_wdata,
output logic [7:0] reg_rdata,
// --- abstract engine interface (the bit-level engine is Module 17) ------
output logic eng_req,
output logic [6:0] eng_target,
output logic eng_is_read,
output logic [7:0] eng_wdata,
input logic eng_done,
input logic eng_error,
input logic [7:0] eng_rdata
);
localparam logic [2:0] A_CTRL = 3'd0, A_STATUS = 3'd1, A_TARGET = 3'd2,
A_TXDATA = 3'd3, A_RXDATA = 3'd4, A_COMMAND = 3'd5;
logic enable;
logic busy, done_f, err_f;
logic [6:0] target_q;
logic [7:0] tx_q, rx_q;
logic is_read_q;
always_ff @(posedge clk) begin
if (!rst_n) begin
enable <= 1'b0; busy <= 1'b0; done_f <= 1'b0; err_f <= 1'b0;
target_q <= '0; tx_q <= '0; rx_q <= '0; is_read_q <= 1'b0;
eng_req <= 1'b0;
end else begin
eng_req <= 1'b0; // one-cycle request pulse
if (reg_wr) begin
case (reg_addr)
A_CTRL: enable <= reg_wdata[0];
A_TARGET: target_q <= reg_wdata[6:0];
A_TXDATA: tx_q <= reg_wdata;
A_STATUS: begin // write-one-to-clear flags
if (reg_wdata[1]) done_f <= 1'b0;
if (reg_wdata[2]) err_f <= 1'b0;
end
A_COMMAND: begin
// ACCEPTED ONLY WHEN ENABLED AND NOT BUSY. A command
// written while busy is IGNORED -- a defined behaviour,
// not an unspecified one.
if (reg_wdata[0] && enable && !busy) begin
busy <= 1'b1;
is_read_q <= reg_wdata[1];
eng_req <= 1'b1;
end
end
default: ; // RXDATA is read-only
endcase
end
if (busy && eng_done) begin
busy <= 1'b0;
done_f <= 1'b1;
if (eng_error) err_f <= 1'b1;
if (is_read_q) rx_q <= eng_rdata; // latched ONLY on a read
end
end
end
assign eng_target = target_q;
assign eng_is_read = is_read_q;
assign eng_wdata = tx_q;
always_comb begin
case (reg_addr)
A_CTRL: reg_rdata = {7'b0, enable};
A_STATUS: reg_rdata = {5'b0, err_f, done_f, busy};
A_TARGET: reg_rdata = {1'b0, target_q};
A_TXDATA: reg_rdata = tx_q;
A_RXDATA: reg_rdata = rx_q;
default: reg_rdata = 8'h00; // COMMAND is write-only
endcase
end
endmoduleThe testbench is written as a software model: it performs register writes and reads exactly as a driver would, and a separate task plays the engine. That framing is the point — it tests the contract software depends on rather than internal signals.
module i2c_cmd_shell_tb;
logic clk = 1'b0, rst_n;
logic reg_wr; logic [2:0] reg_addr; logic [7:0] reg_wdata, reg_rdata;
logic eng_req, eng_is_read, eng_done, eng_error;
logic [6:0] eng_target; logic [7:0] eng_wdata, eng_rdata;
int errors = 0;
i2c_cmd_shell dut (.*);
always #5 clk = ~clk;
// --- software model: register write / read ------------------------------
task automatic wr(input logic [2:0] a, input logic [7:0] d);
reg_addr = a; reg_wdata = d; reg_wr = 1'b1;
@(negedge clk); reg_wr = 1'b0;
endtask
task automatic rd(input logic [2:0] a, input logic [7:0] exp, input string what);
reg_addr = a; #1;
if (reg_rdata !== exp) begin
$error("%s: reg[%0d]=%h expected %h", what, a, reg_rdata, exp); errors++; end
endtask
// --- engine model: complete a transfer after a couple of cycles ---------
task automatic engine_completes(input logic err, input logic [7:0] data);
repeat (2) @(negedge clk);
eng_rdata = data; eng_error = err; eng_done = 1'b1;
@(negedge clk); eng_done = 1'b0; eng_error = 1'b0;
endtask
initial begin
rst_n=0; reg_wr=0; reg_addr='0; reg_wdata='0;
eng_done=0; eng_error=0; eng_rdata='0;
repeat (2) @(negedge clk);
rd(3'd1, 8'h00, "reset: status clear"); // 1 reset
rd(3'd4, 8'h00, "reset: rxdata clear");
rst_n=1; @(negedge clk);
// 2 -- a command while DISABLED must be ignored
wr(3'd5, 8'h01);
if (eng_req!==1'b0) begin $error("command accepted while disabled"); errors++; end
rd(3'd1, 8'h00, "disabled: still idle");
wr(3'd0, 8'h01); rd(3'd0, 8'h01, "enable set"); // 3 configure
wr(3'd2, 8'h48); rd(3'd2, 8'h48, "target id programmed");
wr(3'd3, 8'h3C); rd(3'd3, 8'h3C, "tx data loaded");
// 4 -- WRITE command: engine sees the request and the operands
wr(3'd5, 8'h01);
if (eng_req!==1'b1) begin $error("eng_req not pulsed"); errors++; end
if (eng_target!==7'h48) begin $error("wrong target to engine"); errors++; end
if (eng_is_read!==1'b0) begin $error("wrong direction to engine"); errors++; end
if (eng_wdata!==8'h3C) begin $error("wrong wdata to engine"); errors++; end
rd(3'd1, 8'h01, "busy asserted");
@(negedge clk);
if (eng_req!==1'b0) begin $error("eng_req is a level, not a pulse"); errors++; end
// 5 -- a SECOND command while busy must be ignored (defined behaviour)
wr(3'd5, 8'h01);
if (eng_req!==1'b0) begin $error("command accepted while busy"); errors++; end
rd(3'd1, 8'h01, "still busy, unchanged");
engine_completes(1'b0, 8'h00); // 6 complete
rd(3'd1, 8'h02, "done set, busy cleared");
rd(3'd4, 8'h00, "a WRITE must not change rxdata"); // 7 no latch
wr(3'd1, 8'h02); rd(3'd1, 8'h00, "done write-one-to-clear");
// 8 -- READ command: rxdata latches the engine's data
wr(3'd5, 8'h03); // go + is_read
if (eng_is_read!==1'b1) begin $error("read direction not passed"); errors++; end
engine_completes(1'b0, 8'hA5);
rd(3'd1, 8'h02, "read completed");
rd(3'd4, 8'hA5, "rxdata latched on read");
wr(3'd1, 8'h02);
// 9 -- ERROR injection: engine reports failure, status records it
wr(3'd5, 8'h01);
engine_completes(1'b1, 8'h00);
rd(3'd1, 8'h06, "done + error set");
rd(3'd4, 8'hA5, "a failed write must not disturb rxdata");
wr(3'd1, 8'h06); rd(3'd1, 8'h00, "both flags cleared");
// 10 -- RXDATA is read-only
wr(3'd4, 8'hFF); rd(3'd4, 8'hA5, "rxdata is read-only");
if (errors==0) $display("PASS: command/status shell -- accept, busy, complete, read latch, error, W1C, read-only");
else $display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule4b. Verilog
Same architecture and the same twelve checks. reg/wire typing, localparam address constants, and $display reporting.
module i2c_cmd_shell (
input wire clk,
input wire rst_n,
input wire reg_wr,
input wire [2:0] reg_addr,
input wire [7:0] reg_wdata,
output reg [7:0] reg_rdata,
output reg eng_req,
output wire [6:0] eng_target,
output wire eng_is_read,
output wire [7:0] eng_wdata,
input wire eng_done,
input wire eng_error,
input wire [7:0] eng_rdata
);
localparam A_CTRL=3'd0, A_STATUS=3'd1, A_TARGET=3'd2,
A_TXDATA=3'd3, A_RXDATA=3'd4, A_COMMAND=3'd5;
reg enable, busy, done_f, err_f, is_read_q;
reg [6:0] target_q;
reg [7:0] tx_q, rx_q;
always @(posedge clk) begin
if (!rst_n) begin
enable<=1'b0; busy<=1'b0; done_f<=1'b0; err_f<=1'b0;
target_q<=7'h00; tx_q<=8'h00; rx_q<=8'h00; is_read_q<=1'b0;
eng_req<=1'b0;
end else begin
eng_req <= 1'b0; // one-cycle request pulse
if (reg_wr) begin
case (reg_addr)
A_CTRL: enable <= reg_wdata[0];
A_TARGET: target_q <= reg_wdata[6:0];
A_TXDATA: tx_q <= reg_wdata;
A_STATUS: begin // write-one-to-clear
if (reg_wdata[1]) done_f <= 1'b0;
if (reg_wdata[2]) err_f <= 1'b0;
end
A_COMMAND: if (reg_wdata[0] && enable && !busy) begin
busy <= 1'b1; is_read_q <= reg_wdata[1]; eng_req <= 1'b1;
end
default: ; // RXDATA is read-only
endcase
end
if (busy && eng_done) begin
busy <= 1'b0; done_f <= 1'b1;
if (eng_error) err_f <= 1'b1;
if (is_read_q) rx_q <= eng_rdata; // latched ONLY on a read
end
end
end
assign eng_target = target_q;
assign eng_is_read = is_read_q;
assign eng_wdata = tx_q;
always @(*) begin
case (reg_addr)
A_CTRL: reg_rdata = {7'b0, enable};
A_STATUS: reg_rdata = {5'b0, err_f, done_f, busy};
A_TARGET: reg_rdata = {1'b0, target_q};
A_TXDATA: reg_rdata = tx_q;
A_RXDATA: reg_rdata = rx_q;
default: reg_rdata = 8'h00; // COMMAND is write-only
endcase
end
endmodule module i2c_cmd_shell_tb;
reg clk, rst_n, reg_wr, eng_done, eng_error;
reg [2:0] reg_addr;
reg [7:0] reg_wdata, eng_rdata;
wire [7:0] reg_rdata, eng_wdata;
wire eng_req, eng_is_read;
wire [6:0] eng_target;
integer errors;
i2c_cmd_shell dut (
.clk(clk), .rst_n(rst_n), .reg_wr(reg_wr), .reg_addr(reg_addr),
.reg_wdata(reg_wdata), .reg_rdata(reg_rdata), .eng_req(eng_req),
.eng_target(eng_target), .eng_is_read(eng_is_read), .eng_wdata(eng_wdata),
.eng_done(eng_done), .eng_error(eng_error), .eng_rdata(eng_rdata));
initial clk = 1'b0;
always #5 clk = ~clk;
task wr; input [2:0] a; input [7:0] d;
begin reg_addr=a; reg_wdata=d; reg_wr=1'b1; @(negedge clk); reg_wr=1'b0; end
endtask
task rd; input [2:0] a; input [7:0] exp; input [8*40:1] what;
begin
reg_addr=a; #1;
if (reg_rdata !== exp) begin
$display("FAIL: %0s: reg[%0d]=%h expected %h", what, a, reg_rdata, exp);
errors=errors+1;
end
end
endtask
task engine_completes; input err; input [7:0] data;
begin
repeat (2) @(negedge clk);
eng_rdata=data; eng_error=err; eng_done=1'b1;
@(negedge clk); eng_done=1'b0; eng_error=1'b0;
end
endtask
initial begin
errors=0; rst_n=0; reg_wr=0; reg_addr=0; reg_wdata=0;
eng_done=0; eng_error=0; eng_rdata=0;
repeat (2) @(negedge clk);
rd(3'd1, 8'h00, "reset: status clear");
rd(3'd4, 8'h00, "reset: rxdata clear");
rst_n=1; @(negedge clk);
wr(3'd5, 8'h01); // command while disabled
if (eng_req!==1'b0) begin $display("FAIL: accepted while disabled"); errors=errors+1; end
rd(3'd1, 8'h00, "disabled: still idle");
wr(3'd0, 8'h01); rd(3'd0, 8'h01, "enable set");
wr(3'd2, 8'h48); rd(3'd2, 8'h48, "target id programmed");
wr(3'd3, 8'h3C); rd(3'd3, 8'h3C, "tx data loaded");
wr(3'd5, 8'h01); // WRITE command
if (eng_req!==1'b1) begin $display("FAIL: eng_req not pulsed"); errors=errors+1; end
if (eng_target!==7'h48) begin $display("FAIL: wrong target"); errors=errors+1; end
if (eng_is_read!==1'b0) begin $display("FAIL: wrong direction"); errors=errors+1; end
if (eng_wdata!==8'h3C) begin $display("FAIL: wrong wdata"); errors=errors+1; end
rd(3'd1, 8'h01, "busy asserted");
@(negedge clk);
if (eng_req!==1'b0) begin $display("FAIL: eng_req is a level"); errors=errors+1; end
wr(3'd5, 8'h01); // second command while busy
if (eng_req!==1'b0) begin $display("FAIL: accepted while busy"); errors=errors+1; end
rd(3'd1, 8'h01, "still busy, unchanged");
engine_completes(1'b0, 8'h00);
rd(3'd1, 8'h02, "done set, busy cleared");
rd(3'd4, 8'h00, "a WRITE must not change rxdata");
wr(3'd1, 8'h02); rd(3'd1, 8'h00, "done write-one-to-clear");
wr(3'd5, 8'h03); // READ command
if (eng_is_read!==1'b1) begin $display("FAIL: read direction not passed"); errors=errors+1; end
engine_completes(1'b0, 8'hA5);
rd(3'd1, 8'h02, "read completed");
rd(3'd4, 8'hA5, "rxdata latched on read");
wr(3'd1, 8'h02);
wr(3'd5, 8'h01); // ERROR injection
engine_completes(1'b1, 8'h00);
rd(3'd1, 8'h06, "done + error set");
rd(3'd4, 8'hA5, "a failed write must not disturb rxdata");
wr(3'd1, 8'h06); rd(3'd1, 8'h00, "both flags cleared");
wr(3'd4, 8'hFF); rd(3'd4, 8'hA5, "rxdata is read-only");
if (errors==0) $display("PASS: command/status shell -- accept, busy, complete, read latch, error, W1C, read-only");
else $display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule4c. VHDL
Two VHDL choices are worth naming. The address comparisons use named constants in an if/elsif chain rather than a case, because comparing std_logic_vector against named constants reads more clearly than a case over a vector. And eng_req is driven from an internal signal req_i rather than assigned in the process directly, because the register block reads its own busy state — the same readable-output issue Chapter 2.3 met, solved the same portable way.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_cmd_shell is
port (
clk : in std_logic;
rst_n : in std_logic;
reg_wr : in std_logic;
reg_addr : in std_logic_vector(2 downto 0);
reg_wdata : in std_logic_vector(7 downto 0);
reg_rdata : out std_logic_vector(7 downto 0);
eng_req : out std_logic;
eng_target : out std_logic_vector(6 downto 0);
eng_is_read : out std_logic;
eng_wdata : out std_logic_vector(7 downto 0);
eng_done : in std_logic;
eng_error : in std_logic;
eng_rdata : in std_logic_vector(7 downto 0)
);
end entity;
architecture rtl of i2c_cmd_shell is
constant A_CTRL : std_logic_vector(2 downto 0) := "000";
constant A_STATUS : std_logic_vector(2 downto 0) := "001";
constant A_TARGET : std_logic_vector(2 downto 0) := "010";
constant A_TXDATA : std_logic_vector(2 downto 0) := "011";
constant A_RXDATA : std_logic_vector(2 downto 0) := "100";
constant A_COMMAND : std_logic_vector(2 downto 0) := "101";
signal enable, busy, done_f, err_f, is_read_q : std_logic := '0';
signal target_q : std_logic_vector(6 downto 0) := (others => '0');
signal tx_q, rx_q : std_logic_vector(7 downto 0) := (others => '0');
signal req_i : std_logic := '0';
begin
process (clk)
begin
if rising_edge(clk) then
if rst_n = '0' then
enable <= '0'; busy <= '0'; done_f <= '0'; err_f <= '0';
target_q <= (others => '0'); tx_q <= (others => '0');
rx_q <= (others => '0'); is_read_q <= '0'; req_i <= '0';
else
req_i <= '0'; -- one-cycle pulse
if reg_wr = '1' then
if reg_addr = A_CTRL then
enable <= reg_wdata(0);
elsif reg_addr = A_TARGET then
target_q <= reg_wdata(6 downto 0);
elsif reg_addr = A_TXDATA then
tx_q <= reg_wdata;
elsif reg_addr = A_STATUS then -- write-one-to-clear
if reg_wdata(1) = '1' then done_f <= '0'; end if;
if reg_wdata(2) = '1' then err_f <= '0'; end if;
elsif reg_addr = A_COMMAND then
if reg_wdata(0) = '1' and enable = '1' and busy = '0' then
busy <= '1'; is_read_q <= reg_wdata(1); req_i <= '1';
end if;
end if; -- RXDATA is read-only
end if;
if busy = '1' and eng_done = '1' then
busy <= '0'; done_f <= '1';
if eng_error = '1' then err_f <= '1'; end if;
if is_read_q = '1' then rx_q <= eng_rdata; end if;
end if;
end if;
end if;
end process;
eng_req <= req_i;
eng_target <= target_q;
eng_is_read <= is_read_q;
eng_wdata <= tx_q;
reg_rdata <= "0000000" & enable when reg_addr = A_CTRL else
"00000" & err_f & done_f & busy when reg_addr = A_STATUS else
"0" & target_q when reg_addr = A_TARGET else
tx_q when reg_addr = A_TXDATA else
rx_q when reg_addr = A_RXDATA else
(others => '0');
end architecture; library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity i2c_cmd_shell_tb is
end entity;
architecture sim of i2c_cmd_shell_tb is
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal reg_wr : std_logic := '0';
signal reg_addr : std_logic_vector(2 downto 0) := (others => '0');
signal reg_wdata : std_logic_vector(7 downto 0) := (others => '0');
signal reg_rdata : std_logic_vector(7 downto 0);
signal eng_req : std_logic;
signal eng_target : std_logic_vector(6 downto 0);
signal eng_is_read : std_logic;
signal eng_wdata : std_logic_vector(7 downto 0);
signal eng_done : std_logic := '0';
signal eng_error : std_logic := '0';
signal eng_rdata : std_logic_vector(7 downto 0) := (others => '0');
begin
dut : entity work.i2c_cmd_shell
port map (clk=>clk, rst_n=>rst_n, reg_wr=>reg_wr, reg_addr=>reg_addr,
reg_wdata=>reg_wdata, reg_rdata=>reg_rdata, eng_req=>eng_req,
eng_target=>eng_target, eng_is_read=>eng_is_read, eng_wdata=>eng_wdata,
eng_done=>eng_done, eng_error=>eng_error, eng_rdata=>eng_rdata);
clk <= not clk after 5 ns;
stim : process
procedure wr (a : in std_logic_vector(2 downto 0);
d : in std_logic_vector(7 downto 0)) is
begin
reg_addr <= a; reg_wdata <= d; reg_wr <= '1';
wait until falling_edge(clk);
reg_wr <= '0';
end procedure;
procedure rd (a : in std_logic_vector(2 downto 0);
exp : in std_logic_vector(7 downto 0);
what : in string) is
begin
reg_addr <= a;
wait for 1 ns;
assert reg_rdata = exp report "i2c_cmd_shell: " & what severity error;
end procedure;
procedure engine_completes (err : in std_logic;
data : in std_logic_vector(7 downto 0)) is
begin
wait until falling_edge(clk);
wait until falling_edge(clk);
eng_rdata <= data; eng_error <= err; eng_done <= '1';
wait until falling_edge(clk);
eng_done <= '0'; eng_error <= '0';
end procedure;
begin
wait until falling_edge(clk);
wait until falling_edge(clk);
rd("001", x"00", "reset: status must be clear");
rd("100", x"00", "reset: rxdata must be clear");
rst_n <= '1';
wait until falling_edge(clk);
wr("101", x"01"); -- command while disabled
assert eng_req = '0' report "i2c_cmd_shell: accepted while disabled" severity error;
rd("001", x"00", "disabled: must still be idle");
wr("000", x"01"); rd("000", x"01", "enable did not set");
wr("010", x"48"); rd("010", x"48", "target id did not program");
wr("011", x"3C"); rd("011", x"3C", "tx data did not load");
wr("101", x"01"); -- WRITE command
assert eng_req = '1' report "i2c_cmd_shell: eng_req not pulsed" severity error;
assert eng_target = "1001000" report "i2c_cmd_shell: wrong target" severity error;
assert eng_is_read = '0' report "i2c_cmd_shell: wrong direction" severity error;
assert eng_wdata = x"3C" report "i2c_cmd_shell: wrong wdata" severity error;
rd("001", x"01", "busy must be asserted");
wait until falling_edge(clk);
assert eng_req = '0' report "i2c_cmd_shell: eng_req is a level" severity error;
wr("101", x"01"); -- second command while busy
assert eng_req = '0' report "i2c_cmd_shell: accepted while busy" severity error;
rd("001", x"01", "must still be busy and unchanged");
engine_completes('0', x"00");
rd("001", x"02", "done must be set and busy cleared");
rd("100", x"00", "a WRITE must not change rxdata");
wr("001", x"02"); rd("001", x"00", "done write-one-to-clear failed");
wr("101", x"03"); -- READ command
assert eng_is_read = '1' report "i2c_cmd_shell: read direction not passed" severity error;
engine_completes('0', x"A5");
rd("001", x"02", "read did not complete");
rd("100", x"A5", "rxdata did not latch on read");
wr("001", x"02");
wr("101", x"01"); -- ERROR injection
engine_completes('1', x"00");
rd("001", x"06", "done and error must both be set");
rd("100", x"A5", "a failed write must not disturb rxdata");
wr("001", x"06"); rd("001", x"00", "both flags did not clear");
wr("100", x"FF"); rd("100", x"A5", "rxdata must be read-only");
report "i2c_cmd_shell self-check complete" severity note;
wait;
end process;
end architecture;4d. Cross-Language Comparison
Register addresses, bit positions, reset values, the accept condition, the one-cycle request pulse, the read-latch rule, the write-one-to-clear semantics and the twelve-step stimulus are identical across all three, and all three testbenches complete at the same simulated time.
| Concern | SystemVerilog | Verilog | VHDL |
|---|---|---|---|
| Address decode | case over reg_addr | case over reg_addr | if/elsif over named constants |
| Read mux | always_comb + case | always @(*) + case | concurrent conditional assignment |
| Readable request output | output read directly | output read directly | internal req_i, port driven from it |
| Failure reporting | $error with a count | $display with a count | assert ... report ... severity error |
i2c_cmd_shell \u2014 register write, engine request, completion, acknowledgement
10 cyclesThis is an RTL simulation figure of the register handshake, and the classification matters. Every signal on it is a port of the shell, and the cycle counts are the shell\u0027s real behaviour. It is not I\u00B2C bus timing — the engine\u0027s two-cycle completion here is a testbench convenience, where a real transfer takes as long as the bus takes. Confusing the two is exactly the layering error this chapter exists to prevent.
5. Assertions — The Shell\u0027s Contract as Properties
Four properties state the contract the shell promises software. They are small on purpose: Module 21 owns the protocol assertion library, and these are about the register interface.
module i2c_cmd_shell_props (
input logic clk, rst_n,
input logic reg_wr,
input logic [2:0] reg_addr,
input logic [7:0] reg_wdata,
input logic eng_req, eng_done,
input logic busy, done_f
);
localparam logic [2:0] A_COMMAND = 3'd5;
// Previous-cycle history, so each property can talk about "the cycle before".
logic acc_cmd_q, eng_req_q, busy_q, eng_done_q, done_f_q;
always_ff @(posedge clk) begin
acc_cmd_q <= reg_wr && reg_addr == A_COMMAND && reg_wdata[0] && !busy;
eng_req_q <= eng_req;
busy_q <= busy;
eng_done_q <= eng_done;
done_f_q <= done_f;
end
// CLOCKED IMMEDIATE assertions. Every signal here is synchronous to clk, so
// sampling at the edge is well defined -- unlike Chapter 2.5's electrical
// invariants on a resolved net, which needed deferral to avoid a transient.
always_ff @(posedge clk) begin
if (rst_n) begin
// 1 -- a request is issued ONLY for an accepted command.
assert (!eng_req || acc_cmd_q)
else $error("eng_req asserted without an accepted COMMAND write");
// 2 -- a request is always accompanied by busy.
assert (!eng_req || busy)
else $error("engine requested while the interface does not report busy");
// 3 -- SINGLE OUTSTANDING: no request in the cycle after a request.
assert (!(eng_req && eng_req_q))
else $error("a second request was issued immediately after the first");
// 4 -- done is set ONLY by a completing transfer.
assert (!(done_f && !done_f_q) || (busy_q && eng_done_q))
else $error("done_f rose without the engine completing a transfer");
end
end
endmoduleEach property states something the shell promises software, and each is small enough to read in one pass:
- A request is issued only for an accepted command. Nothing other than a legal
COMMANDwrite can poke the engine. - A request is always accompanied by
busy. Software never sees a transfer begin without the interface reporting it. - Single outstanding. This shell is single-entry, so no second request may follow immediately.
doneis set only by a completing transfer. The flag cannot appear without the engine having finished.
Two things about the form are worth more than the properties themselves.
These are clocked immediate assertions, and that is a deliberate choice. The idiomatic way to express such invariants is a concurrent assertion, which reads more directly and holds continuously:
// Property 1 written concurrently. $past replaces the explicit history
// register, and the assertion is checked on every clock without a surrounding
// always block. This is the form to reach for in a real environment.
property req_only_on_accepted_command;
@(posedge clk) disable iff (!rst_n)
eng_req |-> $past(reg_wr && reg_addr == A_COMMAND && reg_wdata[0] && !busy);
endproperty
assert property (req_only_on_accepted_command)
else $error("eng_req asserted without an accepted COMMAND write");The clocked-immediate version in the checker above exists because it runs in any simulator, including ones with no SVA support, while expressing exactly the same four invariants — the explicit history registers do by hand what $past does by construction. Knowing both forms is practical: the concurrent one is what you write when the tool allows it, and the immediate one is what you fall back to without giving up the check.
They are clocked, unlike Chapter 2.5\u0027s. Chapter 2.5 needed deferred assertions because its invariants were about a resolved net that settles within a time step. Here every signal is synchronous to clk, so there is a well-defined sampling point and no transient to avoid. The right assertion construct follows from what the signals are, not from habit — and recognising which situation you are in is the transferable skill.
6. Verification Connection — RAL and Cross-Interface Checking
This chapter is where I\u00B2C verification stops being a single-interface problem, and it is worth seeing the shape before Module 19 builds it.
The difficulty is that a single transfer now has three separate observable faces, and correctness is a statement about all three agreeing:
CPU-bus sequence "configure target 0x48, write 0x3C"
|
v
register model (RAL) mirrored register state, predicted after each access
|
v
I2C controller DUT the shell plus the engine
|
v
I2C interface the resolved SDA/SCL conductors
|
v
target responder a model of the real device
monitor(s) ------------------> scoreboard
correlates:
register command
bus transaction
status / interrupt resultWhy a register model earns its place. A register model — UVM\u0027s RAL — holds a mirror of what the hardware\u0027s registers should contain, updated by prediction as accesses go by. That lets a test say "configure the target" rather than "write 0x48 to offset 2", and it lets a scoreboard check register state without re-reading the device.
The shell in §4 exercises the parts of RAL that are easy to get wrong, and they are worth naming concretely:
STATUSis not simply readable state.busyis set and cleared by hardware,doneanderrorare set by hardware and cleared by software writing one. A model that treatsSTATUSas an ordinary read/write register will mispredict it constantly — the access policies have to be declared per field, not per register.RXDATAis read-only and hardware-updated. Prediction cannot know its value from the access stream alone; it comes from the bus. So a scoreboard has to learn it from the I\u00B2C-side monitor, not from the register side.COMMANDis write-only with a side effect. Writing it does not store a value, it starts a transfer. A naive mirror would record a value that no read will ever return.
A concise sketch of the intent, not an implementation:
// The point is the POLICIES, not the syntax. Each one encodes something the
// hardware does that a default read/write mirror would get wrong.
class i2c_status_reg extends uvm_reg;
rand uvm_reg_field busy; // "RO" -- hardware owns it entirely
rand uvm_reg_field done; // "W1C" -- hardware sets, software clears with 1
rand uvm_reg_field error; // "W1C" -- same
// A model that declared these "RW" would predict the wrong value after
// every transfer, and the scoreboard would report failures that are
// artefacts of the model rather than defects in the design.
endclassWhy the scoreboard has to correlate three things. A register write says a transfer was requested. The bus says what happened. Status and interrupt say what the hardware reported. A design can be wrong in the gap between any two: it can report success for a transfer the bus shows failing, report the wrong byte count, latch read data from the wrong transfer, or set done without the engine having completed. None of those is visible from one interface alone — which is precisely why this is the chapter where verification becomes a multi-interface problem.
A conceptual sequence, showing architecture rather than boilerplate:
// Written against the register model, so it survives a change of register map
// and reads as the operation it performs.
task body();
regmodel.CTRL.write(status, 8'h01); // enable
regmodel.TARGET.write(status, 7'h48); // which device
regmodel.TXDATA.write(status, 8'h3C); // payload
regmodel.COMMAND.write(status, 8'h01); // go -- a write transfer
wait_for_done(); // interrupt or polled status
regmodel.STATUS.read(status, sts);
// The scoreboard -- not this sequence -- is what confirms the I2C side
// actually carried 0x3C to 0x48. A sequence that checked only STATUS would
// pass against a controller that reported success and drove nothing.
endtaskThat last comment is the chapter\u0027s verification thesis: a test that only reads status verifies the status register, not the bus.
7. The Error Model — Where Bus Failures Become Software Facts
A controller has to report failure, because things on a board go wrong in ways software has to handle. What belongs in Module 3 is the categories and the boundary crossing, not the detection mechanisms.
| Category | Roughly what happened | Where it is developed |
|---|---|---|
| No response | the named target did not answer | Module 7 |
| Bus busy / not free | the bus was not available to start | Modules 5 and 13 |
| Lost to another controller | another controller was active, this one gave way | Module 13 |
| Timeout | the transfer did not progress within a bound | Modules 11 and 12 |
| Configuration / disabled | software asked for something the controller cannot do | here |
The architectural point is the one the table makes by existing: a bus-level event becomes a software-visible status bit. Software has no way to observe an unanswered address or a line held too long — those are events on a conductor. The controller observes them and translates them into a flag a driver can branch on.
That translation is a genuine design responsibility with two failure modes worth naming. A controller that reports too little leaves software unable to distinguish "the device is absent" from "the bus is broken" — different remedies entirely. A controller that reports too much makes every driver handle conditions it cannot act on. Where a real peripheral draws that line is a design decision, and error here is deliberately one bit because the shell has one abstract engine; a real one has several.
8. FPGA and ASIC Integration
The same stack, implemented two different ways. Both inherit the boundary from Module 2 and neither changes it.
On an FPGA, the controller is a soft peripheral in fabric. Its register interface attaches to whatever bus the design uses — a soft CPU\u0027s peripheral bus, or a state machine driving the registers directly. SDA and SCL are top-level inout ports constrained to package pins, and the open-drain behaviour lives at the I/O buffer exactly as Chapter 2.3 established: intent in fabric, output-enable at the boundary, pull-ups on the board. Two integration realities: the I/O bank voltage must suit the pull-up rail from Chapter 3.3, and sda_in/scl_in are asynchronous inputs that need synchronising before logic uses them — flagged here, developed in Module 19.
On an ASIC, the controller is a peripheral on the SoC interconnect. Its register block is mapped into the address space; its interrupt joins the interrupt controller; a high-throughput design may attach DMA, though for control traffic the payloads are small enough that it rarely earns its place. The pins reach the outside through pad-ring cells chosen for open-drain capability and sink strength, as Chapter 2.3 covered. Two additional realities: clock and reset domains — the controller runs from a peripheral clock unrelated to the bus, which is where the bit-timing generator gets its reference; and power domains — if the controller can be powered down while the bus stays active, the pad behaviour in that state is a system question, not an RTL one.
Neither is developed further here. Module 19 owns implementation engineering for both.
9. Debugging — The Driver That Trusted a Status Bit
Every transfer reported success and no device was ever written
Pitfall \u2014 verifying the register interface and calling it a verified controller
// A new I2C controller peripheral. The register interface is clean and the driver
// is straightforward:
//
// write CTRL = enable
// write TARGET = 0x48
// write TXDATA = 0x3C
// write COMMAND = go
// poll STATUS until done
// check STATUS.error == 0 -> report success
//
// The block-level regression is thorough about the REGISTER contract: reset
// values, read-back, write-one-to-clear, command-while-busy, read-only fields.
// All of it passes. The driver reports success on every transfer. The register
// interface is, in fact, correct.
//
// What nothing in that regression does is look at SDA or SCL.Software sees a perfectly healthy peripheral: enable works, commands are accepted, busy asserts, done arrives, error stays clear, and RXDATA returns plausible values on reads. Every layer software can observe says the transfer happened. The devices on the board disagree. The PMIC is never configured, so rails stay at their defaults -- which on this board is close enough to correct that the board boots. The EEPROM read returns the value RXDATA was left holding from reset or from an earlier transfer, which looks like a plausible board identifier. The first hard symptom appears weeks later on a board variant where the default rails are wrong, and it presents as a power problem. On a scope SDA and SCL never move at all. The bus is idle throughout.
The shell was verified; the engine was not connected. Because the shell's completion path is driven by eng_done, and the abstract engine in the block-level environment was a testbench model that simply asserted eng_done a couple of cycles after eng_req, every status transition software depends on was produced WITHOUT ANY BUS ACTIVITY. In the integrated design the engine's request input had been left unconnected during integration, so it never ran -- but nothing downstream of the shell was being checked, so nothing noticed. The failure is a VERIFICATION SCOPE error rather than a logic error. Each layer was correct in isolation and the seam between them was never tested. The status bits are not lying: they faithfully report what the shell was told, and the shell was told a transfer completed. Chapter 3.4's verification thesis is exactly this -- a test that reads only status verifies the status register.
// Close the loop across the two interfaces. The scoreboard must correlate what was
// REQUESTED with what APPEARED ON THE BUS, not merely with what was REPORTED:
//
// 1. Put a monitor on the I2C interface and reconstruct transfers from the
// resolved SDA/SCL conductors -- Chapter 2.5's rule, the resolved bus and
// never any participant's intent.
// 2. Attach a target responder model at the addressed identity so there is
// something to answer.
// 3. Have the scoreboard require, for every accepted COMMAND: a matching
// transaction on the bus, carrying the programmed target and payload, AND a
// status outcome consistent with what the responder did.
// 4. Add the test that fails immediately here: assert a transfer to a target
// identity for which NO responder exists, and require that the controller
// reports a failure. A controller wired to nothing passes an "expect success"
// test and fails an "expect no-response" test -- which is why negative cases
// catch integration faults that positive ones cannot.
//
// The lab-level equivalent, once hardware exists: put a scope or analyser on the
// bus and confirm activity. A status bit is evidence about the peripheral's
// internal state, never about the conductor.The engineering lesson: status is a report, not an observation. A controller\u0027s status register tells you what the controller believes, and a controller can believe a transfer succeeded while driving nothing — most easily when the block below it was abstract during verification and real during integration. The general rule for any layered peripheral is that each layer must be verified against the layer below it, not only against its own contract, and the cheapest test that catches a disconnected lower layer is a negative one: ask for something that should fail and require that it does.
10. Common Misconceptions
11. Reason It Through
Work these through before reading the answers.
Software writes
COMMANDwhileSTATUS.busyis set. Which block should define what happens, and what are the reasonable options?
Reasoning. The hardware must define it, because software cannot make a race disappear by being careful — the write may land in the same cycle a transfer is completing. Reasonable options are to ignore the write (what the shell in §4 does), to accept and queue it, or to accept and flag a fault. What is not acceptable is leaving it unspecified, because then the behaviour depends on internal timing and differs between implementations. Notice that "software should not do that" is not a design: a driver can be wrong, and a peripheral that behaves unpredictably when it is makes the bug unreproducible.
A driver reports every transfer succeeding and no device on the board is being configured. Where do you look first?
Answer. At the bus, with an instrument. Status is a report from the controller about its own state, and §9 shows how it can be entirely self-consistent while nothing reaches a conductor. Confirming activity on SDA and SCL takes a minute and eliminates the whole class of "the controller believes it worked" faults — whereas reading the driver again cannot, because the driver is correct.
Why does a register model need per-field access policies rather than one policy per register?
Reasoning. Because fields inside one register can have different owners. In STATUS, busy is written only by hardware, while done and error are set by hardware and cleared by software writing a one. A single register-level policy cannot express that, so prediction would be wrong for at least one field after every transfer — and the resulting failures look like design bugs while being model bugs, which is an expensive kind of noise.
A test issues a transfer to an identity for which no target model exists, and the controller reports success. Is the controller wrong?
Reasoning. Almost certainly yes, and this is the negative test from §9. An unanswered target should produce a no-response error, so reporting success means either the controller is not detecting the condition or — more commonly — it is not actually driving the bus at all. This is why negative tests find integration faults that positive tests cannot: a controller connected to nothing passes every "expect success" test.
12. Understanding Check
13. Summary
Between a driver and a conductor sit several narrow layers, each hiding the one below. Software performs loads and stores; the interconnect carries them; the register block is the software-visible contract; the engine turns a command into edges; the I/O boundary pulls conductors LOW or releases them; the board is everything outside the pin.
Software does not toggle the bus. It configures, describes a transfer, starts it with one register write, and learns the outcome from status or an interrupt. Bit-banging is a fallback with real costs, not the normal model — the split exists because hardware is good at precise slow things and software is not.
The register interface is where the design decisions live: STATUS flags are write-one-to-clear because acknowledgement must be explicit, RXDATA is read-only and hardware-updated, COMMAND is a write-only action rather than state, and busy and done are separate so a completed transfer cannot be silently missed.
The shell built in §4 owns a real contract — accept only when enabled and not busy, one-cycle request pulse, latch read data only on a read, sticky status until acknowledged — and it hands an abstract engine the work. That engine is Module 17.
For verification, this is where a single interface stops being enough. A transfer has three faces — command, bus activity, reported status — and correctness means all three agree. A register model needs per-field access policies or it mispredicts every transfer, and a test that reads only status verifies the status register. The debug case shows the consequence: a controller can report clean success while its bus never moves.
Errors cross the boundary by design: a bus-level event software cannot observe becomes a flag it can branch on, and how finely a controller reports is a genuine design decision.
14. What Comes Next
Module 3 is complete. The bus has participants with defined responsibilities, a topology that shares two conductors among many devices, a real schematic with real electrical constraints, and a path from software down to the pin.
What it still has no account of is time. Every chapter so far has said "the controller generates the clock" and "the transfer proceeds" without once saying how fast, how a bit is delimited, when a level is valid, or what a receiver is entitled to assume about when to look. Module 4 — Timing Fundamentals supplies that: the bit cell, the data-valid rule that governs every bit on the bus, and how to read a timing diagram — which is the last thing needed before the framing and addressing mechanics that Modules 5 through 8 build on top.
Browse the full path on the I\u00B2C tutorials index. For the boundary this chapter\u0027s stack terminates at, see Open-Drain Outputs; for the board outside it, Wiring a Real I\u00B2C Bus.
Continue learning
Related tutorials
- Related topic
Why Chips on a Board Need a Bus
A connection between two chips is not a wire. It is a pin on each package, a routed trace, the board area and layers that trace consumes, and an I/O cell driving it — and all of that is paid for again for every device added. This is the cost structure that makes dedicating an interface per peripheral stop scaling, and that forces a board to share one set of wires instead.
- Related topic
From Parallel Buses to Two Wires
Derive the bus rather than meet it. Trading wires for time gives serialisation; trading exclusivity for coordination gives a shared medium; losing the wire as an implicit address forces a logical one. Each step is a deliberate exchange, and what falls out is a two-wire addressed bus — which is what Philips specified as I²C.
- Related topic
Where I²C Lives — Boards, SoCs and Real Devices
Place the derived bus in a real system: the host controller inside an SoC or FPGA, the regulators, sensors, memories and clock devices attached to it, and what each one is actually doing. The traffic turns out to have a specific shape — control plane, not data plane — and that shape is why the bus remains useful.
- Related topic
The Shared Bus — Many Targets, Sometimes Many Masters
One pair of conductors serves an entire board. Physically the bus is a broadcast medium — every device sees everything — while participation is logically selective. That gap is the central abstraction of I²C, and it also opens the multi-controller case.
