An I2C controller has to generate START and STOP conditions, clock out bytes, read the acknowledge, receive bytes and send its own acknowledge, all with open-drain outputs and while watching the bus for clock stretching and lost arbitration. The cleanest structure is a byte engine that executes one command at a time: START, STOP, WRITE or READ. Transactions are built from those commands by software or a small sequencer. This article builds that engine.

9 min read | Intermediate

What You'll Learn

  • Drive open-drain lines from RTL
  • Generate SCL from quarter periods and place SDA changes correctly
  • Build START, STOP, byte write and byte read from those quarters
  • Support clock stretching and detect lost arbitration

Open Drain in RTL

Inside the design, each line is two signals: an output enable that pulls the line low, and an input that reads the actual level. At the chip pins they become one bidirectional pad:


assign SCL = scl_oe ? 1'b0 : 1'bz;       // pull low or release
assign SDA = sda_oe ? 1'b0 : 1'bz;
assign scl_i = SCL;                      // read back the real level
assign sda_i = SDA;

Reading back the real level is essential: it is how the controller sees acknowledges, clock stretching and other controllers.

Quarter Periods

Each SCL period is split into four quarters of Q system clocks. For a data bit: quarter 0 changes SDA while SCL is low, quarter 1 holds it, at the end of quarter 1 SCL is released, quarters 2 and 3 keep SCL high, and at the end of quarter 3 SDA is sampled and SCL pulled low again. START and STOP use the same quarters, but change SDA while SCL is high.

The RTL


// I2C controller (master) byte engine. It executes one command at a time:
//   START  (also used as a repeated START), STOP,
//   WRITE  sends wdata and returns the target's acknowledge in ack_in (0 = ACK),
//   READ   receives rdata and sends ack_out (0 = ACK, 1 = NACK for the last byte).
// SCL and SDA are open drain: scl_oe / sda_oe = 1 pulls the line low, 0 releases it.
// Each SCL period has four quarters of Q system clocks. While SCL is released the controller
// waits until it really is high, so a target can stretch the clock. While sending a 1 it checks
// SDA: if SDA is low, another controller has won the arbitration and arb_lost is set.
module i2c_master #(
  parameter Q = 5
) (
  input            clk,
  input            rst,
  input      [1:0] cmd,
  input            cmd_valid,
  output           cmd_ready,
  input      [7:0] wdata,
  input            ack_out,
  output reg [7:0] rdata,
  output reg       ack_in,
  output reg       done,               // one-cycle pulse when a command completes
  output reg       arb_lost,
  output reg       scl_oe,
  output reg       sda_oe,
  input            scl_i,
  input            sda_i
);
  localparam START = 2'd0, STOP = 2'd1, WRITE = 2'd2, READ = 2'd3;

  reg        busy;
  reg [1:0]  op;
  reg [3:0]  bitn;                     // bit within a byte: 0-7 data, 8 acknowledge
  reg [1:0]  qtr;                      // quarter of the current SCL period
  reg [15:0] cnt;
  reg [7:0]  sh;

  assign cmd_ready = !busy;

  // The value this controller puts on SDA for the current bit (1 = release)
  wire out_bit = (op == WRITE) ? ((bitn < 8) ? sh[7] : 1'b1) :
                 (op == READ)  ? ((bitn < 8) ? 1'b1 : ack_out) : 1'b1;

  always @(posedge clk) begin
    if (rst) begin
      busy     <= 1'b0;
      done     <= 1'b0;
      arb_lost <= 1'b0;
      scl_oe   <= 1'b0;
      sda_oe   <= 1'b0;
    end
    else begin
      done <= 1'b0;
      if (!busy) begin
        if (cmd_valid) begin
          busy     <= 1'b1;
          op       <= cmd;
          sh       <= wdata;
          bitn     <= 4'd0;
          qtr      <= 2'd0;
          cnt      <= 16'd0;
          arb_lost <= 1'b0;
        end
      end
      else if (qtr == 2'd2 && !scl_i && !scl_oe) begin
        // SCL released but still low: a target is stretching the clock, wait
        cnt <= 16'd0;
      end
      else if (cnt != Q - 1) begin
        cnt <= cnt + 16'd1;
      end
      else begin
        cnt <= 16'd0;
        qtr <= qtr + 2'd1;
        case (op)
          START: begin                 // SDA falls while SCL is high
            case (qtr)
              2'd0: sda_oe <= 1'b0;    // release SDA (needed for a repeated START)
              2'd1: scl_oe <= 1'b0;    // release SCL
              2'd2: sda_oe <= 1'b1;    // SDA low while SCL high: START
              2'd3: begin
                scl_oe <= 1'b1;
                busy   <= 1'b0;
                done   <= 1'b1;
              end
            endcase
          end
          STOP: begin                  // SDA rises while SCL is high
            case (qtr)
              2'd0: sda_oe <= 1'b1;    // SDA low while SCL is low
              2'd1: scl_oe <= 1'b0;    // release SCL
              2'd2: sda_oe <= 1'b0;    // SDA rises while SCL high: STOP
              2'd3: begin
                busy <= 1'b0;
                done <= 1'b1;
              end
            endcase
          end
          default: begin               // WRITE and READ: nine bits, SCL low - low - high - high
            case (qtr)
              2'd0: sda_oe <= !out_bit;          // change SDA while SCL is low
              2'd1: scl_oe <= 1'b0;              // release SCL
              2'd2: ;                            // SCL high: the bit is valid
              2'd3: begin                        // sample at the end of the high time
                scl_oe <= 1'b1;
                if (op == WRITE && bitn < 8 && out_bit && !sda_i) begin
                  arb_lost <= 1'b1;              // we sent 1 but the bus is 0: lost
                  sda_oe   <= 1'b0;
                  scl_oe   <= 1'b0;
                  busy     <= 1'b0;
                  done     <= 1'b1;
                end
                else if (bitn == 8) begin
                  if (op == WRITE)
                    ack_in <= sda_i;             // 0 = the target acknowledged
                  else
                    rdata <= sh;
                  sda_oe <= 1'b0;
                  busy   <= 1'b0;
                  done   <= 1'b1;
                end
                else begin
                  sh   <= (op == WRITE) ? {sh[6:0], 1'b0} : {sh[6:0], sda_i};
                  bitn <= bitn + 4'd1;
                end
              end
            endcase
          end
        endcase
      end
    end
  end
endmodule

Three details make it a real I2C controller rather than a shift register:

  • Clock stretching: in quarter 2, if SCL has been released but is still low, the counter is held at zero. The high time only starts once the line really is high.
  • Arbitration: when the controller sends a 1 (releases SDA) but samples a 0, another controller is driving. It releases both lines and reports arb_lost.
  • READ and ACK: during a read, SDA is released for the eight data bits and the target drives them; in the ninth bit the controller drives its own ACK or NACK from ack_out.

Quick Check

Q: With a 100 MHz system clock, what value of Q gives a 400 kHz Fast-mode SCL?

Show Answer

An SCL period of 2.5 µs is 250 system clocks, so each quarter is 62.5 clocks; Q = 63 gives about 397 kHz. Fast-mode also has minimum low and high times, so real controllers often make the low part longer than the high part.

Building Transactions

The testbench builds register accesses from commands, the way a driver would program a controller:


task write_reg(input [6:0] dev, input [1:0] r, input [7:0] v);
  reg ok;
  begin
    op(START, 0, 0);
    op(WRITE, {dev, 1'b0}, 0);
    ok = !ack_in;
    if (ok) begin
      op(WRITE, {6'b0, r}, 0);
      op(WRITE, v, 0);
    end
    op(STOP, 0, 0);
    // ... report
  end
endtask
Output
6015  write 0x50 reg1 = 0xa7
11965  write 0x51 reg1 = 0x3c
19975  read  0x50 reg1 = 0xa7
27985  read  0x51 reg1 = 0x3c
30275  write 0x52: no ACK (NACK)

After a NACK on the address, the task skips the remaining bytes and sends STOP straight away, which is what a careful driver does.

Quick Check

Q: Why does a START command first release SDA and SCL before pulling SDA low?

Show Answer

So that the same command works as a repeated START. After a byte, SCL is low and SDA may be low; the controller must bring both high first, and only then pull SDA low while SCL is high.

Running It in ChipVerify Lab

The example contains this controller, two targets and the register tasks. Follow scl_oe and sda_oe through each command in the waveform.

Common Mistakes

Mistake #1: Not reading back SCL

A controller that counts its own SCL timing without looking at the line ignores clock stretching and corrupts transfers with slow targets.

Mistake #2: Changing SDA while SCL is high

Any SDA change during SCL high is a START or STOP. Change SDA only in the low phase of the clock.

Mistake #3: Ignoring arb_lost

In a multi-controller system, a controller that keeps going after losing arbitration corrupts the winner's transfer. Stop driving at once and retry later.

Practice Exercise

Challenge: Two Bytes in One Read

Write 0xA7 to register 1 and 0xB8 to register 2 of target 0x50, then read both in one transaction: the first byte with ACK, the second with NACK.

Hint

Use two READ commands with different ack_out values.

Solution

op(START, 0, 0);
op(WRITE, {7'h50, 1'b0}, 0);
op(WRITE, 8'h01, 0);
op(START, 0, 0);                     // repeated START
op(WRITE, {7'h50, 1'b1}, 0);
op(READ, 0, 0);                      // first byte: ACK, we want more
op(READ, 0, 1);                      // second byte: NACK, the last one
op(STOP, 0, 0);
Output
6015  write 0x50 reg1 = 0xa7
11965  write 0x50 reg2 = 0xb8
19745  read byte 0xa7, ACK
21575  read byte 0xb8, NACK

The ACK after the first byte makes the target send the next register; the NACK after the second ends the read.

Open the ready-made example in the lab and run the simulation.

Summary

  • Model each open-drain line as an output enable plus a read-back input.
  • Build every bit from four quarters: change SDA in the low half, sample at the end of the high half.
  • START and STOP are SDA edges while SCL is high; START also serves as a repeated START.
  • Wait for SCL to be really high to support clock stretching.
  • Sending a 1 but reading a 0 means lost arbitration: release the bus.
Did you find what you were looking for?

Quiz

No quiz questions available for this article.