Custom RTL Design: TX & RX

This is the first hands-on RTL episode. We translate the UART protocol specification directly into synthesizable SystemVerilog — starting from the mathematical relationship between clock frequency and baud rate, then building the TX FSM and RX center-sampling logic. Each design decision is derived from first principles so you can reproduce and modify it for any FPGA or baud rate.

Design Requirements

Target: 50 MHz system clock, 8E1 format

RequirementSpecification
System Clock50 MHz
Baud Rate9,600 / 115,200 / 460,800 bps
Data Width8 bits
ParityEven parity
Stop Bits1
Format8E1 (11 bits total per frame)

Baud counter maximum:

$$\mathrm{BAUD}_{\mathrm{CNT,MAX}} = \frac{50{,}000{,}000}{115{,}200} \approx 434$$

The counter counts system clock cycles. It counts from 0 through 433 (434 clocks), then resets. The resulting one-cycle pulse (baud_tick) illustrates a bit-period enable; the complete TX below uses a timer restarted when a request is accepted.

Integer division accuracy: 50,000,000 / 115,200 = 434.027…; using 434 gives about 0.0064% baud-rate error. After 11 bits, the accumulated offset is about 0.0007 bit periods. The total receive margin also depends on both oscillators and the sampling implementation.

Baud Rate Generator

Counts system clock cycles and produces a one-cycle baud_tick pulse.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module baud_gen #(
    parameter CLK_FREQ  = 50_000_000,
    parameter BAUD_RATE = 115_200
)(
    input  logic clk, rst_n,
    output logic baud_tick
);
    localparam BAUD_CNT_MAX = CLK_FREQ / BAUD_RATE;  // 434

    localparam CNT_WIDTH = (BAUD_CNT_MAX > 1) ? $clog2(BAUD_CNT_MAX) : 1;
    logic [CNT_WIDTH-1:0] cnt;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) cnt <= '0;
        else if (cnt == BAUD_CNT_MAX - 1) cnt <= '0;
        else cnt <= cnt + 1;
    end

    assign baud_tick = (cnt == BAUD_CNT_MAX - 1);
endmodule

$clog2(BAUD_CNT_MAX) automatically sizes the counter register — for 434 counts, 9 bits are required. Using $clog2 keeps the code portable: changing CLK_FREQ or BAUD_RATE automatically adjusts the counter width without any manual recalculation.

TX implementation — captured frame

IDLE → START → DATA (8 bits) → PARITY → STOP → IDLE

This complete TX example accepts a one-clock tx_start on any rising clock edge while tx_ready is high. It captures the entire 8E1 frame, including parity, then holds each bit for CLK_FREQ / BAUD_RATE clocks. Input data may change after acceptance. Requests while busy are not accepted; wait for tx_ready. The stop bit completes before readiness returns. Use parameters with at least two clocks per bit. This TX has its own timer; the free-running baud generator above is a separate teaching example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
`timescale 1ns/1ps
// 8E1 teaching example. CLK_FREQ / BAUD_RATE must be at least 2.
// tx_start is a one-clock request, accepted only while tx_ready is high.
module uart_tx_example #(
    parameter integer CLK_FREQ = 50_000_000,
    parameter integer BAUD_RATE = 115_200
) (
    input logic clk, rst_n,
    input logic tx_start,
    input logic [7:0] tx_data,
    output logic tx_ready,
    output logic tx_out
);
    localparam integer BIT_CYCLES = CLK_FREQ / BAUD_RATE;
    localparam integer CW = (BIT_CYCLES > 1) ? $clog2(BIT_CYCLES) : 1;
    logic [CW-1:0] count;
    logic [10:0] frame;
    logic [3:0] bit_index;
    logic busy;
    assign tx_ready = !busy;
    assign tx_out = busy ? frame[0] : 1'b1;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            count <= '0;
            frame <= '1;
            bit_index <= '0;
            busy <= 1'b0;
        end else if (!busy) begin
            if (tx_start) begin
                // Capture data and parity together, on ANY system clock.
                frame <= {1'b1, ^tx_data, tx_data, 1'b0};
                count <= '0;
                bit_index <= '0;
                busy <= 1'b1;
            end
        end else if (count == BIT_CYCLES - 1) begin
            count <= '0;
            if (bit_index == 10) busy <= 1'b0;
            else begin
                frame <= {1'b1, frame[10:1]};
                bit_index <= bit_index + 1'b1;
            end
        end else count <= count + 1'b1;
    end
endmodule

Download uart_tx_example.sv

RX FSM — Center Sampling

Sample each bit at its center for maximum noise margin.

$$\text{Center offset} = \frac{\mathrm{BAUD}_{\mathrm{CNT,MAX}}}{2} \approx 217,\text{CLK cycles}$$

The following is pseudocode, not compilable RTL. RX needs its own timer aligned to the synchronized start bit; do not reuse the free-running TX tick. Restart the timer after the half-bit start check, then sample D0 one full bit later. Synchronize the asynchronous input and define error/valid behavior before integration.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
On every system clock:
    synchronize uart_rx through two flip-flops
    clear rx_data_valid (default: a one-clock pulse)
    IDLE: when synchronized RX is low, clear timer and enter START
    START: after half a bit, recheck RX
           if high: return to IDLE (false start)
           if low: clear timer, clear errors, enter DATA
    DATA: every full bit period, sample RX into {RX, rx_shift[7:1]}
          after 8 samples, enter PARITY
    PARITY: after a full bit, store parity_err = (^rx_shift != RX)
            enter STOP
    STOP: after a full bit, store frame_err = !RX
          copy rx_shift to rx_data
          pulse rx_data_valid only if neither error is present
          return to IDLE

False start detection: After the falling edge triggers the START state, the FSM waits half a bit period and re-checks uart_rx. If the line is already High again, the transition was noise — the FSM returns to IDLE without consuming a byte. This glitch rejection is critical on noisy lines and is the reason the half-period wait exists.

Center Sampling — Animated

The receiver must sample at the center of each bit period — not at the edge — to avoid noise contamination near signal transitions.

Testbench Strategy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Integration sketch: requires a complete RX module and clock/reset setup.
// Drive inputs on falling edges to avoid races with sequential RTL.
initial begin
    tx_start = 0;
    tx_data = 0;
    wait (rst_n);
    @(negedge clk);
    while (!tx_ready) @(negedge clk);
    tx_data = 8'hA5;
    tx_start = 1;
    @(negedge clk);
    tx_start = 0;
    tx_data = 8'h01; // Deliberately change data AND parity after acceptance.
    // A separate RX monitor must check A5, parity, and stop, with a timeout.
end

The snippet above shows request timing only. A complete loopback test also needs RX instantiation, reset, a receive monitor, and a timeout. Verify the wire independently so matching TX/RX bugs cannot hide each other.

Download the standalone TX testbench: checks one-clock requests, input changes after acceptance, busy request rejection, bit/Stop duration, and reset during transmission. This tests the new TX example above; the existing demonstration video below is separate.

Additional verification steps:

  • Confirm TX waveform: Start(0) → D0..D7 LSB-first → Parity → Stop(1)
  • Verify RX samples Start at half a bit, then D0 through Stop at full-bit intervals.
  • Parity error injection: force a wrong bit on the wire, confirm parity_err asserts
  • Frame error injection: hold RX low during Stop bit, confirm frame_err asserts

UART Frame Simulation Demo

Vivado simulation waveform demo (xsim)

Episode 5 Summary

  • Baud Generator: cnt == BAUD_CNT_MAX-1 → one-cycle baud_tick
  • TX: capture Start, data, parity, and Stop together; serialize one bit per bit period
  • RX FSM: half-period delay → center sampling; validate Start and Stop bits
  • Even Parity: capture ^tx_data when accepting the request
  • Testbench: standalone TX checks provided; add loopback and error injection after integrating a complete RX

Next Episode

Episode 6: Top Module — Integrating TX and RX Clock wizard, reset logic, parameterization, reusable interface design. Continue in the Inflearn UART course (Korean).

Key Takeaways

  • The baud generator uses integer division — CLK_FREQ / BAUD_RATE rounded down; the resulting error (~0.006% for 115200 bps at 50 MHz) is negligible within one UART frame
  • The TX captures requests on the system clock and times every bit, including Stop, with its own counter.
  • RX center sampling delays BAUD_CNT_MAX/2 after the Start Bit edge before the first sample; this provides maximum margin against clock drift and edge noise
  • False start detection (re-check Start Bit at the half-period point) rejects glitches that would otherwise corrupt a received byte

Code and References