fpga-uart-tx-rx-puerto-serie-verilog

UART en FPGA: transmisor y receptor en Verilog

  • 6 min

Una UART es un periférico que transmite y recibe tramas serie sin compartir una señal de reloj con el otro extremo.

Pero nuestra FPGA no tiene una UART (algunas si) así que no podemos comunicarnos con una placa, como hacemos con un microcontrolador normal.

Para solucionar esto, vamos a construir el puente de comunicación más universal, robusto y querido por los ingenieros: La UART (Universal Asynchronous Receiver-Transmitter).

Hoy vamos a diseñar, desde cero y bit a bit, un circuito capaz de enviar y recibir texto hacia tu ordenador.

¿Cómo hablar sin un reloj compartido?

La clave de la UART es que es Asíncrona. No hay un cable de reloj que viaje del PC a la FPGA. Entonces, ¿cómo saben cuándo leer? Se ponen de acuerdo de antemano en una velocidad (Baud Rate).

Si acordamos hablar a 9600 baudios y cada símbolo representa un bit, cada bit dura 1 / 9600 = 104,17 µs.

La trama UART

La línea en reposo (Idle) está siempre a 1 (3.3V). Para enviar un byte (8 bits), seguimos esta coreografía estricta:

  1. Start Bit (0): Bajamos la línea a 0 para despertar al receptor.
  2. Datos (D0..D7): Enviamos los 8 bits, empezando por el menos significativo (LSB).
  3. Stop Bit (1): Subimos la línea a 1 para descansar y separar del siguiente byte.

El transmisor (Tx)

El módulo Tx es básicamente una máquina de estados que gestiona un registro de desplazamiento.

El generador de baudios

Primero, necesitamos saber cuántos ciclos de nuestro reloj (12 MHz) dura un bit de la UART.

CLKS_POR_BIT = 12.000.000 / 9600 = 1.250 ciclos.

Cuando la división no es exacta aparece un error de baud rate. Debes comprobar que el error combinado del transmisor, el receptor y sus relojes quede dentro de la tolerancia de ambos equipos.

Código Verilog para un Tx sencillo

module uart_tx #(
    parameter CLK_HZ = 12000000,
    parameter BAUD_RATE = 9600
)(
    input wire clk,
    input wire start,       // Pulso para iniciar envío
    input wire [7:0] data,  // Byte a enviar
    output reg tx = 1'b1,   // Pin de salida, en reposo a nivel alto
    output wire busy        // Indica si está ocupado
);

    localparam CLKS_PER_BIT = CLK_HZ / BAUD_RATE;

    // Estados de la FSM
    localparam IDLE  = 2'b00;
    localparam START = 2'b01;
    localparam DATA  = 2'b10;
    localparam STOP  = 2'b11;

    reg [1:0] state = IDLE;
    localparam COUNT_WIDTH = (CLKS_PER_BIT <= 1) ? 1 : $clog2(CLKS_PER_BIT);
    reg [COUNT_WIDTH-1:0] clk_count = 0;
    reg [2:0] bit_index = 0;  // Para saber qué bit (0-7) enviamos
    reg [7:0] data_temp = 0;  // Copia del dato para no perderlo

    assign busy = (state != IDLE);

    always @(posedge clk) begin
        case (state)
            IDLE: begin
                tx <= 1'b1; // Línea en reposo (High)
                clk_count <= 0;
                bit_index <= 0;
                
                if (start) begin
                    data_temp <= data; // Capturamos el dato
                    state <= START;
                end
            end

            START: begin
                tx <= 1'b0; // Start Bit (Low)
                
                // Esperamos 1 tiempo de bit completo
                if (clk_count < CLKS_PER_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    clk_count <= 0;
                    state <= DATA;
                end
            end

            DATA: begin
                tx <= data_temp[bit_index]; // Enviamos bit actual
                
                if (clk_count < CLKS_PER_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    clk_count <= 0;
                    // ¿Hemos enviado los 8 bits?
                    if (bit_index < 7) begin
                        bit_index <= bit_index + 1;
                    end else begin
                        bit_index <= 0;
                        state <= STOP;
                    end
                end
            end

            STOP: begin
                tx <= 1'b1; // Stop Bit (High)
                
                if (clk_count < CLKS_PER_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    state <= IDLE; // Fin de transmisión
                end
            end
            
            default: state <= IDLE;
        endcase
    end
endmodule
Copied!

El receptor (Rx)

Recibir es más difícil que transmitir. ¿Por qué? Porque no sabemos cuándo llega el mensaje. El PC puede enviarlo en cualquier momento.

  1. Tenemos que esperar a que la línea baje a 0 (Start Bit).
  2. Una vez detectado el start bit, esperamos medio tiempo de bit (CLKS_PER_BIT / 2).
  • ¿Por qué? Para colocarnos justo en el centro temporal del bit. Así, si el reloj está un poco desfasado, seguimos leyendo el dato correctamente y no el ruido de los bordes.

Código Verilog para un Rx sencillo

module uart_rx #(
    parameter CLK_HZ = 12000000,
    parameter BAUD_RATE = 9600
)(
    input wire clk,
    input wire rx,           // Pin de entrada
    output reg [7:0] data,   // Byte recibido
    output reg valid         // Pulso cuando data es válido
);

    localparam CLKS_PER_BIT = CLK_HZ / BAUD_RATE;
    localparam CLKS_HALF_BIT = CLKS_PER_BIT / 2;

    localparam IDLE  = 2'b00;
    localparam START = 2'b01;
    localparam DATA  = 2'b10;
    localparam STOP  = 2'b11;

    reg [1:0] state = IDLE;
    localparam COUNT_WIDTH = (CLKS_PER_BIT <= 1) ? 1 : $clog2(CLKS_PER_BIT);
    reg [COUNT_WIDTH-1:0] clk_count = 0;
    reg [2:0] bit_index = 0;
    reg [7:0] data_scratch = 0; // Registro temporal

    // Sincronización de entrada (Evitar metastabilidad)
    reg rx_meta, rx_sync, rx_prev;
    always @(posedge clk) begin
        rx_meta <= rx;
        rx_sync <= rx_meta;
        rx_prev <= rx_sync;
    end

    always @(posedge clk) begin
        valid <= 0; // Pulso por defecto a 0

        case (state)
            IDLE: begin
                clk_count <= 0;
                bit_index <= 0;
                
                // Detectamos flanco de bajada (Start Bit)
                if (rx_prev == 1'b1 && rx_sync == 1'b0) begin
                    state <= START;
                end
            end

            START: begin
                // Esperamos MEDIO bit para centrarnos
                if (clk_count < CLKS_HALF_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    clk_count <= 0;
                    // Comprobamos que sigue siendo 0 (evitar ruido)
                    if (rx_sync == 1'b0)
                        state <= DATA;
                    else
                        state <= IDLE; // Falsa alarma
                end
            end

            DATA: begin
                // Ahora esperamos UN tiempo de bit completo
                if (clk_count < CLKS_PER_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    clk_count <= 0;
                    data_scratch[bit_index] <= rx_sync; // Muestreamos
                    
                    if (bit_index < 7) begin
                        bit_index <= bit_index + 1;
                    end else begin
                        bit_index <= 0;
                        state <= STOP;
                    end
                end
            end

            STOP: begin
                if (clk_count < CLKS_PER_BIT - 1) begin
                    clk_count <= clk_count + 1;
                end else begin
                    // Solo aceptamos la trama si el bit de parada es alto
                    if (rx_sync == 1'b1) begin
                        valid <= 1;
                        data  <= data_scratch;
                    end
                    state <= IDLE;
                end
            end
            
            default: state <= IDLE;
        endcase
    end

endmodule
Copied!

Un echo como primera prueba

Ahora que tienes Tx y Rx, puedes hacer el proyecto “Echo”:

  1. Conectas Rx y Tx en tu top_module.
  2. Si Rx recibe un dato válido, activas la señal start del Tx con ese mismo dato.
  3. Abres un terminal serial en tu PC (Putty / Arduino Monitor).
  4. Escribes una ‘A’, y la FPGA te devuelve una ‘A’.

¡Enhorabuena! Has creado tu primer periférico de comunicación.