fpga-protocolo-spi-maestro-esclavo

Protocolo SPI en FPGA: maestro full duplex en Verilog

  • 6 min

El SPI es un bus serie síncrono y full duplex en el que un controlador genera el reloj e intercambia bits con uno o varios periféricos.

Si queremos enviar píxeles a una pantalla LCD TFT, leer audio de un micrófono o escribir en una tarjeta SD, el I2C se queda corto. Necesitamos velocidad bruta.

SPI (Serial Peripheral Interface) es simple y puede alcanzar decenas de megahercios cuando el dispositivo, la placa y la integridad de señal lo permiten.

Hoy vamos a diseñar un controlador SPI en Verilog que nos permitirá intercambiar datos a toda velocidad.

La física de SPI: cuatro hilos y push-pull

A diferencia del I2C, el SPI no escatima en cables. Utiliza 4 líneas dedicadas y lógica Push-Pull (aquí sí empujamos 3.3V y 0V con fuerza, nada de resistencias pull-up débiles).

  1. SCK (Serial Clock): El reloj que marca el ritmo. Lo genera el Maestro.
  2. MOSI (Master Out Slave In): Por aquí el Maestro habla y el Esclavo escucha.
  3. MISO (Master In Slave Out): Por aquí el Esclavo responde (mientras el maestro habla).
  4. CS / SS (Chip Select): Una línea individual para cada esclavo. Normalmente es Activa a Nivel Bajo (0 para activar).

Push-Pull vs Open Drain:

Como el SPI usa salidas digitales estándar (Push-Pull), los flancos de subida son muy verticales y limpios. Esto es lo que nos permite alcanzar velocidades de decenas de MHz sin que la señal se degrade.

Intercambio mediante registros de desplazamiento

Lo más bonito del SPI es entender cómo funciona por dentro. Imagina dos registros de desplazamiento circulares conectados en anillo: uno en el Maestro y otro en el Esclavo.

Cuando el Maestro genera 8 pulsos de reloj:

  1. El bit más alto del Maestro sale por MOSI y entra en el bit más bajo del Esclavo.
  2. Simultáneamente, el bit más alto del Esclavo sale por MISO y entra en el Maestro.

Es un intercambio. Para leer un byte del esclavo, el maestro está obligado a enviarle un byte (aunque sea basura, 0xFF o 0x00). No puedes recibir sin dar.

Modos SPI: CPOL y CPHA

Aquí es donde los principiantes suelen frustrarse. No todos los chips SPI hablan el mismo “dialecto”. Existen 4 modos definidos por dos parámetros:

  1. CPOL (Clock Polarity):
  • 0: El reloj descansa en Bajo (0).
  • 1: El reloj descansa en Alto (1).
  1. CPHA (Clock Phase):
  • 0: Se leen los datos en el primer flanco (subida si CPOL=0).
  • 1: Se leen los datos en el segundo flanco (bajada si CPOL=0).

El modo más común (y el que usa Arduino por defecto) es el Modo 0 (CPOL=0, CPHA=0). Ese es el que implementaremos hoy.

  • Reposo en 0.
  • Leemos (Sample) datos en el flanco de Subida.
  • Cambiamos (Shift) datos en el flanco de Bajada.

Implementación en Verilog

Vamos a diseñar un módulo genérico. A diferencia del I2C, aquí no necesitamos estados complejos para ACK ni direcciones. Solo necesitamos generar 8 (o N) pulsos de reloj y mover los bits.

Estrategia de diseño

  1. Generamos un sck derivado del reloj principal.
  2. En el flanco de bajada del SCK, actualizamos el pin MOSI con el siguiente bit.
  3. En el flanco de subida del SCK, leemos el pin MISO y lo guardamos.
module spi_master #(
    parameter CLK_HZ = 12000000,
    parameter SPI_HZ = 1000000   // 1 MHz
)(
    input wire clk,
    input wire rst,
    input wire start,
    input wire [7:0] data_in,    // Dato a enviar
    output reg [7:0] data_out,   // Dato recibido (MISO)
    output reg busy,             // 1 mientras transmite
    output reg new_data,         // Pulso al terminar
    
    // Pines Físicos
    output reg sck,
    output reg mosi,
    input wire miso
);

    // 1. Generador de Reloj SPI (SCK)
    // El SCK debe ser 2 veces la frecuencia SPI_HZ para tener flancos de subida y bajada
    localparam DIVIDER = CLK_HZ / (2 * SPI_HZ);
    localparam COUNT_WIDTH = (DIVIDER <= 1) ? 1 : $clog2(DIVIDER);
    reg [COUNT_WIDTH-1:0] clk_count;

    // Estados
    localparam IDLE = 0;
    localparam WORK = 1;
    reg state;
    
    reg [2:0] bit_cnt;      // Cuenta bits 0-7
    reg [7:0] shift_reg_tx; // Registro desplazamiento salida
    reg [7:0] shift_reg_rx; // Registro desplazamiento entrada

    // Lógica del divisor de reloj
    wire sck_rise = (clk_count == DIVIDER-1) && (sck == 0);
    wire sck_fall = (clk_count == DIVIDER-1) && (sck == 1);

    always @(posedge clk) begin
        if (rst) begin
            state <= IDLE;
            sck <= 0; // CPOL = 0
            mosi <= 0;
            busy <= 0;
            new_data <= 0;
            clk_count <= 0;
        end else begin
            new_data <= 0; // Pulso por defecto off

            case (state)
                IDLE: begin
                    sck <= 0;
                    busy <= 0;
                    clk_count <= 0;
                    
                    if (start) begin
                        state <= WORK;
                        busy <= 1;
                        shift_reg_tx <= data_in; // Cargamos dato
                        shift_reg_rx <= 0;
                        bit_cnt <= 7;            // MSB First
                        mosi <= data_in[7];      // Ponemos ya el primer bit
                        // Preparamos para el primer flanco de subida
                    end
                end

                WORK: begin
                    // Gestión del divisor de reloj
                    if (clk_count == DIVIDER-1) begin
                        clk_count <= 0;
                        sck <= ~sck; // Toggle del reloj
                    end else begin
                        clk_count <= clk_count + 1;
                    end

                    // Eventos en los flancos del SCK
                    
                    // A. Flanco de Subida (RISING): LEEMOS MISO (Sample)
                    if (sck_rise) begin
                        shift_reg_rx <= {shift_reg_rx[6:0], miso};
                    end

                    // B. Flanco de Bajada (FALLING): CAMBIAMOS MOSI (Shift)
                    if (sck_fall) begin
                        if (bit_cnt == 0) begin
                            // ¡Hemos terminado los 8 bits!
                            state <= IDLE;
                            busy <= 0;
                            new_data <= 1;
                            data_out <= shift_reg_rx; // El último bit se capturó en el flanco anterior
                        end else begin
                            // Preparamos siguiente bit
                            bit_cnt <= bit_cnt - 1;
                            shift_reg_tx <= {shift_reg_tx[6:0], 1'b0}; // Shift left
                            mosi <= shift_reg_tx[6]; // Sacamos el siguiente (que ahora es el 6 tras el shift)
                        end
                    end
                end
            endcase
        end
    end

endmodule
Copied!

Habrás notado que el código no controla el pin CS. Es una buena práctica dejar el control del CS al módulo superior (top_level).

¿Por qué? Porque a menudo quieres bajar el CS, enviar 3 bytes seguidos y luego subirlo. Si el módulo SPI subiera el CS automáticamente después de cada byte, rompería la transacción.

Ejemplo de uso: lectura de un sensor

Imagina que quieres leer un acelerómetro SPI. El módulo superior haría esto:

// Secuencia en el Top-Level
case (paso)
    0: begin
        cs_pin <= 0; // 1. Activar esclavo
        data_to_send <= 8'h0B; // Comando "LEER"
        start_spi <= 1;
        paso <= 1;
    end
    1: begin
        start_spi <= 0;
        if (spi_done) paso <= 2; // Esperar fin
    end
    2: begin
        // Ahora enviamos 0x00 solo para empujar el reloj y leer la respuesta
        data_to_send <= 8'h00; 
        start_spi <= 1;
        paso <= 3;
    end
    3: begin
        start_spi <= 0;
        if (spi_done) begin
            dato_leido <= spi_data_out; // ¡Aquí está el valor!
            cs_pin <= 1; // 4. Desactivar esclavo
            paso <= 0;
        end
    end
endcase
Copied!