An I2C bus is a two-wire serial protocol for connecting a master to multiple slave devices.
In the previous article, we managed to communicate with the PC via UART. It was a big step, but UART has a problem: it’s a “point-to-point” protocol. One wire to transmit, one wire to receive, and only two devices talking.
What if we want to connect 10 sensors, an OLED display, and an EEPROM memory, all at once? We can’t fill the FPGA with UART ports.
This is where the I2C (Inter-Integrated Circuit) bus comes in handy. With just two wires, we can connect many devices using addresses; in the conventional 7-bit space, there are reserved addresses, so not all 128 combinations are available.
Today, we’ll learn to implement an I2C Master in Verilog and, the hardest part, how to correctly handle a wire used for both sending and receiving.
The Physics of I2C: Open-Drain and Pull-ups
Before the code, we need to understand the electricity. In UART or SPI, a pin always “pushes” voltage (3.3V or 0V). In I2C, the line is Open Drain.
This means no one pushes 3.3V.
- There is an external resistor (Pull-up) that holds the wire at 3.3V by default.
- The chips can only connect the wire to Ground (GND) to create a 0.
- To create a 1, they simply “release” the wire, and the resistor pulls it up.
Why is it done this way?
It’s a safety measure. If two devices try to talk at the same time and one puts 3.3V and the other 0V, there would be a short circuit. With Open Drain, if one releases (1) and the other pulls (0), the 0 wins and nothing breaks. This is called “wired-AND”.
Handling inout in Verilog
In Verilog, to handle this type of bidirectional pin, we use the High Impedance (Z) state.
- If we want to send a 0: We assign
0to the pin. - If we want to send a 1 (or read): We assign
Z(disconnected).
// SDA is an 'inout' port
inout wire sda;
// Tri-state control
// If I want to write 0 -> Output 0
// If I want to write 1 -> Output Z (High impedance)
assign sda = (internal_output == 0) ? 1'b0 : 1'bz;The Protocol: A Strict Choreography
The bus has two lines:
- SCL (Clock): The master sets the pace, although a device can hold SCL low to apply clock stretching.
- SDA (Data): The data goes back and forth.
The sequence of a basic transmission (Write a byte) is:
START: SDA goes low while SCL is high. (This is the only time data changes with the clock high).
Address (7 bits): We send the slave ID (e.g., 0x3C for an OLED).
R/W Bit: 0 for Write, 1 for Read.
ACK (Acknowledge): The slave pulls the line low to say “I hear you!”. The master must release the line to read this.
Data (8 bits): We send the information byte.
ACK: The slave confirms again.
STOP: SDA goes high while SCL is high.
Implementation in Verilog using an FSM
I2C is slow (100 kHz or 400 kHz) compared to our FPGA (12 MHz). So we’ll use a Finite State Machine strategy synchronized with a clock divider.
To simplify, we’ll design a module that sends a single byte to an address.
State Definition
localparam IDLE = 0;
localparam START = 1;
localparam ADDR = 2; // Send 7-bit address + 1 R/W bit
localparam READ_ACK1 = 3; // Read slave acknowledgment
localparam DATA = 4; // Send 8-bit data
localparam READ_ACK2 = 5; // Read data acknowledgment
localparam STOP = 6;I2C Master Module
module i2c_master_simple #(
parameter CLK_HZ = 12000000,
parameter I2C_HZ = 100000 // 100 kHz standard
)(
input wire clk,
input wire rst,
input wire start, // Pulse to start
input wire [6:0] address, // Slave address
input wire [7:0] data_in, // Data to send
output reg ready, // Indicates if we are done
output reg error_ack, // 1 if slave does not respond
// Physical I2C pins
inout wire scl,
inout wire sda
);
// --- 1. I2C Clock Generator ---
localparam DIVIDER = CLK_HZ / I2C_HZ;
localparam TICK_DIV = DIVIDER / 4;
localparam COUNT_WIDTH = (TICK_DIV <= 1) ? 1 : $clog2(TICK_DIV);
reg [COUNT_WIDTH-1:0] count;
reg i2c_tick; // Pulse activated 4 times per I2C cycle to control phases
always @(posedge clk) begin
if (rst) begin
count <= 0;
i2c_tick <= 0;
end else if (count == TICK_DIV - 1) begin
count <= 0;
i2c_tick <= 1;
end else begin
count <= count + 1;
i2c_tick <= 0;
end
end
// --- 2. Open-drain Logic for SCL and SDA ---
reg scl_low; // 1 = drive SCL low; 0 = release SCL
reg sda_out; // What we want to send (0 or 1)
reg sda_en; // 1 = We drive, 0 = We listen (Z)
// If sda_en is 1, we look at sda_out. If it is 0, we release (Z).
// Remember: To send a '1', we also use Z (external pull-up).
assign scl = scl_low ? 1'b0 : 1'bz;
assign sda = (sda_en && sda_out == 0) ? 1'b0 : 1'bz;
// Reading the actual pin
wire sda_in = sda;
// --- 3. Finite State Machine ---
reg [3:0] state;
reg [2:0] bit_cnt; // Bit counter (0-7)
reg [7:0] shift_reg; // Shift register
reg [1:0] sub_state; // To manage the sequence within a bit (Low, High, etc)
always @(posedge clk) begin
if (rst) begin
state <= IDLE;
scl_low <= 0;
sda_out <= 1;
sda_en <= 1; // At idle, force 1 (actually Z)
ready <= 1;
end else if (i2c_tick) begin
// This logic runs 4 times faster than SCL
// sub_state 0: SCL Falling
// sub_state 1: SCL Low (Change data)
// sub_state 2: SCL Rising
// sub_state 3: SCL High (Read data / Validate)
case (state)
IDLE: begin
ready <= 1;
scl_low <= 0;
sda_out <= 1;
if (start) begin
state <= START;
ready <= 0;
error_ack <= 0;
shift_reg <= {address, 1'b0}; // Addr + Write(0)
sub_state <= 0;
end
end
START: begin
// Generate Start condition: SDA goes low while SCL high
case (sub_state)
0: begin sda_out <= 1; scl_low <= 0; end
1: begin sda_out <= 1; scl_low <= 0; end
2: begin sda_out <= 0; scl_low <= 0; end // SDA falling!
3: begin sda_out <= 0; scl_low <= 1; state <= ADDR; bit_cnt <= 7; sub_state <= 0; end
endcase
if (sub_state < 3) sub_state <= sub_state + 1;
end
ADDR, DATA: begin
case (sub_state)
0: begin scl_low <= 1; end
1: begin
scl_low <= 1;
sda_out <= shift_reg[bit_cnt]; // Set bit
end
2: begin scl_low <= 0; end // Release SCL: high level via pull-up
3: begin
scl_low <= 1;
if (bit_cnt == 0) begin
state <= (state == ADDR) ? READ_ACK1 : READ_ACK2;
sub_state <= 0;
end else begin
bit_cnt <= bit_cnt - 1;
sub_state <= 0;
end
end
endcase
if (sub_state < 3) sub_state <= sub_state + 1;
end
READ_ACK1, READ_ACK2: begin
case (sub_state)
0: begin scl_low <= 1; sda_en <= 0; end // Release SDA (Z)
1: begin scl_low <= 1; end
2: begin scl_low <= 0; end
3: begin
scl_low <= 1;
if (sda_in == 1) error_ack <= 1; // No response!
sda_en <= 1; // Regain control
if (state == READ_ACK1) begin
state <= DATA;
shift_reg <= data_in; // Load data to send
bit_cnt <= 7;
end else begin
state <= STOP;
end
sub_state <= 0;
end
endcase
if (sub_state < 3) sub_state <= sub_state + 1;
end
STOP: begin
// Stop condition: SDA rises while SCL high
case (sub_state)
0: begin sda_out <= 0; scl_low <= 1; end
1: begin sda_out <= 0; scl_low <= 0; end
2: begin sda_out <= 1; scl_low <= 0; end // SDA rising!
3: begin state <= IDLE; end
endcase
if (sub_state < 3) sub_state <= sub_state + 1;
end
endcase
end
end
endmoduleThis is a simplified example for writing. It releases SCL as an open-drain signal but does not wait if another device holds it low, so it does not implement clock stretching or multi-master arbitration.
For reading, the logic is similar, but in the DATA state we would need to release the line (sda_en <= 0), read the incoming bits into shift_reg, and generate the ACK ourselves.