fpga-protocolo-i2c-maestro-esclavo

I2C Protocol on FPGA: Master in Verilog

  • 8 min

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 0 to 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;
Copied!

The Protocol: A Strict Choreography

The bus has two lines:

  1. SCL (Clock): The master sets the pace, although a device can hold SCL low to apply clock stretching.
  2. 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;
Copied!

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

endmodule
Copied!

This 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.