Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • RTL Foundations36
  • Combinational Design25
  • Sequential Design27
  • FSM & Control27
    • Controller and Datapath
    • Moore vs Mealy
    • State Encoding
    • Multi-Cycle Sequencers
    • UART Transmitter
      • Framing and Timing
      • The UART TX FSM
      • Cycle-by-Cycle Walkthrough
      • Verification and Integration
      • Mini Project: an SPI Master
    • Memory Controller
  • Timing & Clocking39
  • Reset & CDC34
  • Memory & Buses57
  • Advanced RTL28
  • Senior & Interview76
  • Glossary and Notation
Loading lesson

‹UART Transmitter
  1. Topics
  2. RTL Design
  3. FSM & Control
  4. UART Transmitter
  5. The UART TX FSM

Part 4 · FSM & Control · Intermediate

The UART TX FSM

Transmitter FSM with registered output, ready handshake, and a full testbench.

On this page

    Why this matters

    A UART transmitter is four states and one counter, yet a tick compare that is one off makes every bit a clock short. A producer that waits on tx_ready can also form a combinational loop.

    After this lesson you can: write the four-state transmitter with a baud counter, back-to-back frames and CTS gating, and explain the off-by-one in the tick.

    State machine

    State diagram of the UART transmitter.
    Four states. STOP can go straight to START when another byte is waiting and CTS permits acceptance, so frames can follow each other with no idle gap.

    Four states cover idle, start, data, and stop. STOP returns to START at once when another byte waits and CTS permits acceptance, so frames can run with no idle gap.

    Design decisions

    Decision

    Why

    tx is a registered output

    Glitch-free on a pin. It changes only right after a clock edge, and from state-derived values (a Moore-style, registered output).

    Byte captured into shreg on accept

    The producer may change tx_data immediately after the handshake.

    tx_ready includes STOP && tick

    Accepts the next byte in the last clock of the stop bit, so the next start bit follows with no gap (full line rate).

    One shared baud_cnt

    Each bit lasts CLKS_PER_BIT clocks. START and STOP last one bit-time each, DATA lasts eight, and IDLE is unbounded.

    bit_idx 0 to 7 in DATA only

    A 3-bit counter; the FSM does not need extra states for each data bit.

    default: state <= IDLE

    RTL fallback only. All four 2-bit codes are legal; a known bit flip can select another legal phase. Synthesis recoding changes the physical fault space; see fault models and recovery.

    CLKS_PER_BIT must be >= 1. One clock per bit is legal: BW is guarded because $clog2(1) is zero. A time-zero simulation diagnostic rejects nonpositive divisors. The sweep below uses 1, 2, 3 and 434. CTS is a synchronized, active-high permission input; tie it high when unused.

    Common mistakes

    Warning

    tx_ready depends on baud_cnt (through tick), so if the producer's tx_valid depends combinationally on tx_ready there is a combinational path through both blocks. Keep one of them registered. The testbench driver follows the usual rule: it never waits for ready before asserting valid.

    Common pitfall

    Off-by-one in the tick: with tick = (baud_cnt == CLKS_PER_BIT - 1) and the counter reset to 0 at every bit boundary, each bit lasts exactly CLKS_PER_BIT clocks. Compare against CLKS_PER_BIT - 2 and every bit is one clock short. The monitor catches this on the first frame: at CPB=4 the first directed byte is 0x00, so the short start and data bits look alike until the stop bit arrives early (sample 27), and burst spacing becomes 30 clocks instead of 40. At CPB=1 the compare never matches, so the tick never fires and the offer times out.

    Check yourself Why must the start bit be driven in the same cycle the byte is accepted, rather than the cycle after?

    Driving tx low as part of the accept edge makes the start bit begin exactly when baud_cnt is reset to 0, so every bit is exactly CLKS_PER_BIT clocks. Delaying it by a cycle would shorten the start bit or require extra state.

    Testbench

    The transmitter plus a testbench that checks every clock of every frame.

    uart_tx.sv
    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
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    module uart_tx #(parameter int CLKS_PER_BIT = 16) (
      input  logic       clk, rst_n,
      input  logic       tx_valid,
      input  logic       cts_allow,        // synchronized permission; blocks acceptance, never cancels a frame
      output logic       tx_ready,
      input  logic [7:0] tx_data,
      output logic       tx                // serial line, idles high
    );
      typedef enum logic [1:0] {IDLE, START, DATA, STOP} state_t;
      state_t state;
      initial if (CLKS_PER_BIT < 1) $fatal(1, "uart_tx: CLKS_PER_BIT must be at least 1");
    
      // CLKS_PER_BIT = 1 is legal (one clock per bit): guard the width as $clog2(1) is 0.
      localparam int BW = (CLKS_PER_BIT > 1) ? $clog2(CLKS_PER_BIT) : 1;
      logic [BW-1:0] baud_cnt;                        // counts clocks within one bit
      logic [2:0]                      bit_idx;      // which data bit is on the line
      logic [7:0]                      shreg;        // remaining data bits, LSB first
    
      logic tick, accept;
      assign tick     = (baud_cnt == CLKS_PER_BIT - 1);               // last clock of the current bit
      assign tx_ready = cts_allow && ((state == IDLE) || (state == STOP && tick));
      assign accept   = tx_valid && tx_ready;
    
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin
          state <= IDLE;  tx <= 1'b1;  baud_cnt <= '0;  bit_idx <= '0;  shreg <= '0;
        end else if (accept) begin
          shreg    <= tx_data;                                   // capture the byte
          tx       <= 1'b0;                                      // start bit begins now
          baud_cnt <= '0;
          state    <= START;
        end else begin
          baud_cnt <= tick ? '0 : baud_cnt + 1'b1;               // default: count within the bit
          unique case (state)
            IDLE:  begin tx <= 1'b1; baud_cnt <= '0; end
            START: if (tick) begin                               // start bit done: put data bit 0 on the line
                     tx <= shreg[0];  shreg <= {1'b0, shreg[7:1]};  bit_idx <= '0;  state <= DATA;
                   end
            DATA:  if (tick) begin
                     if (bit_idx == 3'd7) begin tx <= 1'b1; state <= STOP; end   // all 8 bits sent: stop bit
                     else begin tx <= shreg[0]; shreg <= {1'b0, shreg[7:1]}; bit_idx <= bit_idx + 1'b1; end
                   end
            STOP:  if (tick) state <= IDLE;                      // stop bit done (no new byte waiting)
            default: state <= IDLE;
          endcase
        end
    endmodule
    
    module uart_env #(parameter int CPB = 4) (input logic clk, output int errors, output bit finished);
      timeunit 1ns; timeprecision 1ps;
      logic rst_n = 1, tx_valid = 0, tx_ready, tx, cts_allow = 1;
      logic [7:0] tx_data = 0;
      uart_tx #(CPB) dut (.*);
    
      logic [7:0] sent [$];
      int frames_checked = 0, accepted = 0, cancelled = 0;
      bit back_to_back = 0, stop_driver = 1;
      int frames_before = 0, frames_in_burst = 0, boundary_guard;
      bit manual = 1;
    
      // acceptance is recorded AT the rising edge (it samples the values the DUT saw); the driver only moves at the falling edge
      logic accepted_q = 0;
      always @(posedge clk) accepted_q <= rst_n && tx_valid && tx_ready;
    
      // driver: offers a byte (probability depends on mode) and holds valid and data unchanged until the edge that accepts it
      always @(negedge clk) begin
        if (!rst_n) tx_valid = 0;
        else if (manual) begin end
        else if (tx_valid && !accepted_q) begin
          // offered but not accepted at the last edge: hold (also while draining after stop_driver)
        end
        else if (stop_driver || !rst_n) tx_valid = 0;
        else if (back_to_back || $urandom_range(0, 9) < 5) begin tx_valid = 1; tx_data = $urandom_range(0, 255); end
        else tx_valid = 0;
      end
    
      // monitor: once a byte is offered and not accepted, valid and data must not change until it is
      logic pending = 0;
      logic [7:0] pend_data;
      always @(posedge clk) if (!rst_n) pending <= 0;
      else begin
        if (pending && (tx_valid !== 1'b1 || tx_data !== pend_data)) begin
          errors++;
          if (errors < 5) $display("FAIL: offer changed before it was accepted (valid=%b data=%h, was %h)", tx_valid, tx_data, pend_data);
        end
        pending   <= tx_valid && !tx_ready;
        pend_data <= tx_data;
      end
    
      // Sample acceptance pre-edge, then line post-NBA: one independent frame model.
      logic [7:0] exp_byte;
      logic [9:0] frame;
      int edge_no = 0, sample_no = -1, last_accept = -1, burst_pairs = 0;
      bit last_was_burst = 0;
      always @(posedge clk) begin
        edge_no++;
        if (!rst_n) begin
          if (sample_no >= 0) cancelled++;
          sample_no = -1; sent.delete(); last_accept = -1; last_was_burst = 0;
          #1;
          if (tx !== 1'b1) errors++;
        end else begin
          if (tx_valid && tx_ready) begin
            if (sample_no >= 0) errors++; // previous stop must already have lasted CPB samples
            if (back_to_back && last_was_burst) begin
              burst_pairs++;
              if (edge_no - last_accept != 10 * CPB) begin
                errors++; $display("FAIL CPB=%0d: frame spacing %0d", CPB, edge_no - last_accept);
              end
            end
            last_accept = edge_no; last_was_burst = back_to_back;
            sent.push_back(tx_data); accepted++;
            exp_byte = sent.pop_front();
            frame = {1'b1, exp_byte, 1'b0};
            sample_no = 0;
          end
          #1;
          if (sample_no >= 0) begin
            if (tx !== frame[sample_no / CPB]) begin
              errors++; if (errors < 5) $display("FAIL CPB=%0d byte=%h sample=%0d tx=%b", CPB, exp_byte, sample_no, tx);
            end
            if (sample_no == 10 * CPB - 1) begin frames_checked++; sample_no = -1; end
            else sample_no++;
          end else if (tx !== 1'b1) errors++; // catches an unsolicited start while idle
        end
      end
    
      task automatic offer(input logic [7:0] value);
        int guard;
        @(negedge clk); #1; tx_valid = 1; tx_data = value;
        guard = 0;
        do begin @(posedge clk); guard++; end while (!tx_ready && guard < 12 * CPB);
        if (!tx_ready) $fatal(1, "uart_env: offer timed out CPB=%0d", CPB);
        @(negedge clk); #1; tx_valid = 0;
      endtask
    
      initial begin
        errors = 0; finished = 0;
        #1 rst_n = 0; @(posedge clk); #1 rst_n = 1;
        // Directed bytes: 00/FF repeat one bit; A5 reads the same MSB- or LSB-first; 96 does not, so it catches bit-order bugs.
        offer(8'h00); offer(8'hff); offer(8'ha5); offer(8'h96);
        repeat (11 * CPB) @(negedge clk);
        if (tx !== 1'b1) errors++;
    
        // Idle reset, then cancel an active frame. A receiver must discard the truncated frame.
        #1 rst_n = 0;
        @(posedge clk); #2;
        if (tx !== 1'b1) errors++;
        @(negedge clk); #1 rst_n = 1;
        offer(8'h00);
        repeat (2 * CPB) @(negedge clk);
        #1 rst_n = 0; tx_valid = 0;
        @(posedge clk); #2;
        if (tx !== 1'b1) errors++;
        @(negedge clk); #1 rst_n = 1;
        offer(8'ha5);
        repeat (11 * CPB) @(negedge clk);
    
        // Withdraw CTS with a stable next offer stalled behind an accepted frame.
        offer(8'h00);
        @(negedge clk); #1; tx_valid = 1; tx_data = 8'h96;
        @(posedge clk);
        if (tx_ready !== 1'b0 || cts_allow !== 1'b1) errors++;
        @(negedge clk); #1 cts_allow = 0; // withdraw permission from an already stalled offer
        repeat (11 * CPB) begin
          @(posedge clk);
          if (tx_ready !== 1'b0) errors++;
        end
        @(negedge clk); #1;
        if (tx_valid !== 1'b1 || tx_data !== 8'h96 || tx !== 1'b1) errors++;
        cts_allow = 1;
        @(posedge clk); // pending byte accepts now, not during the stall
        @(negedge clk); #1 tx_valid = 0;
        repeat (11 * CPB) @(negedge clk);
    
        // CTS may also fall exactly before the STOP acceptance edge.
        offer(8'hff);
        @(negedge clk); #1; tx_valid = 1; tx_data = 8'h3c;
        // Wait for the last stop-bit slot; drive CTS before its rising edge.
        boundary_guard = 0;
        while (!(dut.state == dut.STOP && dut.tick) && boundary_guard < 11 * CPB) begin
          @(negedge clk); #1; boundary_guard++;
        end
        if (boundary_guard == 11 * CPB) $fatal(1, "UART STOP boundary not reached");
        cts_allow = 0;
        @(posedge clk);
        if (tx_ready !== 1'b0) errors++;
        @(negedge clk); #1;
        if (tx !== 1'b1 || tx_valid !== 1'b1 || tx_data !== 8'h3c) errors++;
        cts_allow = 1;
        @(posedge clk);
        @(negedge clk); #1 tx_valid = 0;
        repeat (11 * CPB) @(negedge clk);
    
        manual = 0; stop_driver = 0;
        repeat (200 * 10 * CPB) @(posedge clk);                 // random gaps
        #2; back_to_back = 1;  frames_before = frames_checked;
        repeat (200 * 10 * CPB) @(posedge clk);                 // continuous stream
        #2;
        frames_in_burst = frames_checked - frames_before;
        stop_driver = 1;
        repeat (30 * CPB) begin @(posedge clk); #2; end
        if (frames_checked + cancelled != accepted || cancelled != 1) errors++;
        if (sample_no >= 0 || tx_valid || sent.size() != 0 || tx !== 1'b1) errors++;
        if (frames_in_burst < 199 || burst_pairs < 198) errors++;
        finished = 1;
      end
    endmodule
    
    module tb_uart;
      timeunit 1ns; timeprecision 1ps;
      logic clk = 0;
      always #5 clk = ~clk;
      int e1, e2, e3, e434;
      bit f1, f2, f3, f434;
      uart_env #(1) u1(clk, e1, f1);
      uart_env #(2) u2(clk, e2, f2);
      uart_env #(3) u3(clk, e3, f3);
      uart_env #(434) u434(clk, e434, f434);
      initial begin
        wait (f1 && f2 && f3 && f434);
        if (e1 + e2 + e3 + e434 != 0) $fatal(1, "UART sweep FAIL: %0d %0d %0d %0d", e1, e2, e3, e434);
        $display("PASS: CPB=1,2,3,434; full frames, exact burst spacing, CTS and reset cancellation");
        $finish;
      end
      initial begin
        repeat (5000 * 10 * 434) @(posedge clk);
        $fatal(1, "UART sweep watchdog");
      end
    endmodule

    Remember

    • tick is baud_cnt == CLKS_PER_BIT - 1 with the counter reset at every bit boundary, so each bit lasts exactly CLKS_PER_BIT clocks.

    • STOP can return to START at once when a byte waits and CTS allows it, so frames run with no idle gap.

    • tx_ready depends on the baud counter. Keep one side of the producer handshake registered.

    Rule of thumb: start the start bit on the accept edge and reset the counter on the same edge.

    Keep going

    • Code lab: Build a UART transmitter FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Cycle-by-cycle walkthrough

    Previous

    108 / 350

    Next: Cycle-by-Cycle Walkthrough

    Up next

    Up nextCycle-by-Cycle Walkthrough

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Verification and Integration
    • Mini Project: an SPI Master