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).
- SCK (Serial Clock): El reloj que marca el ritmo. Lo genera el Maestro.
- MOSI (Master Out Slave In): Por aquí el Maestro habla y el Esclavo escucha.
- MISO (Master In Slave Out): Por aquí el Esclavo responde (mientras el maestro habla).
- 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:
- El bit más alto del Maestro sale por MOSI y entra en el bit más bajo del Esclavo.
- 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:
- CPOL (Clock Polarity):
- 0: El reloj descansa en Bajo (0).
- 1: El reloj descansa en Alto (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
- Generamos un
sckderivado del reloj principal. - En el flanco de bajada del SCK, actualizamos el pin MOSI con el siguiente bit.
- 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
endmoduleHabrá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