fpga-protocolo-i2c-maestro-esclavo

Protocolo I2C en FPGA: maestro en Verilog

  • 8 min

Un bus I2C es un protocolo serie de dos hilos para comunicar un maestro con varios dispositivos esclavos.

En el artículo anterior logramos comunicarnos con el PC mediante UART. Fue un gran paso, pero la UART tiene un problema: es un protocolo “punto a punto”. Un cable para transmitir, un cable para recibir, y solo dos dispositivos hablando.

¿Qué pasa si queremos conectar 10 sensores, una pantalla OLED y una memoria EEPROM, todo a la vez? No podemos llenar la FPGA de puertos UART.

Para esto viene muy bien el bus I2C (Inter-Integrated Circuit). Con solo dos cables podemos conectar muchos dispositivos mediante direcciones; en el espacio convencional de 7 bits hay direcciones reservadas, así que no todas las 128 combinaciones están disponibles.

Hoy vamos a aprender a implementar un Maestro I2C en Verilog y, lo más difícil, a manejar correctamente un cable que sirve para enviar y recibir a la vez.

La física de I2C: open-drain y pull-ups

Antes del código, necesitamos entender la electricidad. En UART o SPI, un pin siempre “empuja” voltaje (3.3V o 0V). En I2C, la línea es Open Drain (Drenador Abierto).

Esto significa que nadie empuja 3.3V.

  • Hay una resistencia externa (Pull-up) que mantiene el cable en 3.3V por defecto.
  • Los chips solo pueden conectar el cable a Tierra (GND) para crear un 0.
  • Para crear un 1, simplemente “sueltan” el cable y la resistencia lo sube.

¿Por qué se hace así?

Es una medida de seguridad. Si dos dispositivos intentan hablar a la vez y uno pone 3.3V y el otro 0V, habría un cortocircuito. Con Open Drain, si uno suelta (1) y el otro tira (0), gana el 0 y no se rompe nada. A esto se le llama “AND cableada”.

Manejo de inout en Verilog

En Verilog, para manejar este tipo de pines bidireccionales, usamos el estado de Alta Impedancia (Z).

  • Si queremos enviar un 0: Asignamos 0 al pin.
  • Si queremos enviar un 1 (o leer): Asignamos Z (desconectado).
// SDA es un puerto 'inout'
inout wire sda;

// Control tri-estado
// Si quiero escribir 0 -> Salida 0
// Si quiero escribir 1 -> Salida Z (Alta impedancia)
assign sda = (salida_interna == 0) ? 1'b0 : 1'bz;
Copied!

El protocolo: una coreografía estricta

El bus tiene dos líneas:

  1. SCL (Clock): El maestro marca el ritmo, aunque un dispositivo puede mantener SCL a nivel bajo para aplicar clock stretching.
  2. SDA (Data): Los datos van y vienen.

La secuencia de una transmisión básica (Escribir un byte) es:

START: SDA baja mientras SCL está alto. (Es la única vez que los datos cambian con el reloj arriba).

Dirección (7 bits): Enviamos la ID del esclavo (ej: 0x3C para una OLED).

Bit R/W: 0 para Escribir, 1 para Leer.

ACK (Acknowledge): El esclavo tira de la línea a 0 para decir “¡Te he oído!”. El maestro debe soltar la línea para leer esto.

Dato (8 bits): Enviamos el byte de información.

ACK: El esclavo confirma de nuevo.

STOP: SDA sube mientras SCL está alto.

Implementación en Verilog mediante una FSM

I2C es lento (100 kHz o 400 kHz) comparado con nuestra FPGA (12 MHz). Así que usaremos una estrategia de Máquina de Estados sincronizada con un divisor de reloj.

Para simplificar, diseñaremos un módulo que envía un solo byte a una dirección.

Definición de estados

localparam IDLE      = 0;
localparam START     = 1;
localparam ADDR      = 2; // Enviar 7 bits dirección + 1 bit R/W
localparam READ_ACK1 = 3; // Leer confirmación del esclavo
localparam DATA      = 4; // Enviar 8 bits dato
localparam READ_ACK2 = 5; // Leer confirmación del dato
localparam STOP      = 6;
Copied!

Módulo maestro I2C

module i2c_master_simple #(
    parameter CLK_HZ = 12000000,
    parameter I2C_HZ = 100000    // 100 kHz estándar
)(
    input wire clk,
    input wire rst,
    input wire start,            // Pulso para iniciar
    input wire [6:0] address,    // Dirección del esclavo
    input wire [7:0] data_in,    // Dato a enviar
    
    output reg ready,            // Indica si hemos terminado
    output reg error_ack,        // 1 si el esclavo no responde
    
    // Pines físicos I2C
    inout wire scl,
    inout wire sda
);

    // --- 1. Generador de Reloj I2C ---
    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; // Pulso que se activa 4 veces por ciclo I2C para controlar las fases

    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. Lógica open-drain para SCL y SDA ---
    reg scl_low;       // 1 = conducir SCL a 0; 0 = soltar SCL
    reg sda_out;       // Lo que queremos enviar (0 o 1)
    reg sda_en;        // 1 = Nosotros conducimos, 0 = Escuchamos (Z)
    
    // Si sda_en es 1, miramos sda_out. Si es 0, soltamos (Z).
    // Recuerda: Para enviar un '1', también usamos Z (pull-up externo).
    assign scl = scl_low ? 1'b0 : 1'bz;
    assign sda = (sda_en && sda_out == 0) ? 1'b0 : 1'bz;

    // Lectura del pin real
    wire sda_in = sda; 

    // --- 3. Máquina de Estados ---
    reg [3:0] state;
    reg [2:0] bit_cnt; // Contador de bits (0-7)
    reg [7:0] shift_reg; // Registro de desplazamiento
    reg [1:0] sub_state; // Para gestionar la secuencia dentro de un bit (Low, High, etc)

    always @(posedge clk) begin
        if (rst) begin
            state <= IDLE;
            scl_low <= 0;
            sda_out <= 1;
            sda_en <= 1; // En reposo forzamos 1 (Z en realidad)
            ready <= 1;
        end else if (i2c_tick) begin
            // Esta lógica corre 4 veces más rápido que SCL
            // sub_state 0: SCL Bajando
            // sub_state 1: SCL Bajo (Cambiar datos)
            // sub_state 2: SCL Subiendo
            // sub_state 3: SCL Alto (Leer datos / Validar)
            
            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}; // Dir + Write(0)
                        sub_state <= 0;
                    end
                end

                START: begin
                    // Generar condición de Start: SDA baja mientras SCL alto
                    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 // ¡Bajada SDA!
                        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]; // Poner bit
                        end
                        2: begin scl_low <= 0; end // Soltar SCL: nivel alto por 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 // Soltar 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; // ¡Nadie respondió!
                            
                            sda_en <= 1; // Retomar control
                            if (state == READ_ACK1) begin
                                state <= DATA;
                                shift_reg <= data_in; // Cargar dato a enviar
                                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
                    // Condición Stop: SDA sube mientras SCL alto
                    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 // ¡Subida SDA!
                        3: begin state <= IDLE; end
                    endcase
                    if (sub_state < 3) sub_state <= sub_state + 1;
                end

            endcase
        end
    end

endmodule
Copied!

Este es un ejemplo simplificado para escribir (write). Libera SCL como una señal open-drain, pero no espera si otro dispositivo la mantiene baja, por lo que no implementa clock stretching ni arbitraje multi-maestro.

Para leer (Read), la lógica es similar, pero en el estado DATA tendríamos que soltar la línea (sda_en <= 0), leer los bits entrantes en shift_reg y generar nosotros el ACK.