Project

UART echo on the Basys 3

Build a 115200-baud UART receiver and transmitter in Verilog, echo typed characters back with their case toggled, simulate it, then test it from a serial terminal.

BeginnerAMDUpdated 13 Nov 2026
BoardDigilent Basys 3
Timeabout 3 hours
Languageverilog
Extra partsDigilent Basys 3, Micro-USB cable, Computer with Vivado and a serial terminal program

What you will build

This project puts a serial port inside the FPGA on the Basys 3. You will write a UART receiver and a UART transmitter in Verilog, wire them together so that every byte typed into a terminal comes straight back, and add a twist: letters return with their case toggled, so typing Fpga shows fPGA. The twist matters because a plain loopback can be faked by shorting two wires. A case toggle proves the bytes went through your logic.

Along the way you will write a self-checking testbench, a constraints file, and a Vivado build in both the GUI and Tcl. It is a good first project after the beginner lessons on modules and state machines, and it leaves you with a reusable UART pair for later projects.

What you need

  • A Digilent Basys 3 (Artix 7 XC7A35T) and its micro-USB cable. The single USB port carries both the JTAG programmer and a USB-to-UART bridge, so no second cable is needed.
  • AMD Vivado, the free Standard edition. Any release from 2022 onwards works for this design. At the time of writing 2025.1 is current.
  • A serial terminal: screen or minicom on Linux and macOS, PuTTY or the Windows Terminal serial profile on Windows.
  • Optionally, Icarus Verilog and Verilator for simulation and linting outside Vivado. The Vivado simulator works too.

Design

A UART frame at 115,200 baud is a start bit (low), eight data bits least-significant first, and a stop bit (high). Each bit lasts 1/115,200 s, which is 8.68 µs, or 868 clocks at 100 MHz. The receiver waits for the falling edge of the start bit, waits half a bit period to land in the middle of it, confirms the line is still low, then samples once per bit period eight more times. That middle-of-bit sampling is what gives the UART its tolerance to small baud rate errors on both ends.

The transmitter is the mirror image: a ten-bit shift register loaded with the stop bit, the eight data bits and the start bit, and a counter that advances one bit every 868 clocks. Because the line idles high and the start bit is low, the receiver can find the beginning of every frame without any shared clock. That is the whole trick of an asynchronous serial port.

How much baud error can the pair tolerate? The receiver samples the last data bit 8.5 bit periods after the start edge. If the two clocks differ by 5 %, that sample lands 0.43 of a bit period off centre, which is still inside the bit. At 6 % it is not. Real USB-UART bridges are accurate to well under 1 %, and the Basys 3 oscillator is better than 0.01 %, so there is a wide margin, but a design that divided by 870 instead of 868 would eat 0.2 % of it for nothing. Always compute the divider from the real clock frequency.

USB-UARTFT2232 bridgeuart_rxB18, 868 clk/bitcase togglexor 0x20 on letterspendingholding registeruart_txA18, 10-bit shiftrxdata, validstart when tx not busytx4 LEDsbeat, rx, tx, error
Echo path on the Basys 3. The USB-UART bridge's TXD pin is the FPGA's receive input.

Three details make the design behave on real hardware. The receive pin passes through two flip-flops before the state machine sees it, because the bridge’s clock has nothing to do with ours and an unsynchronised input can leave a flip-flop metastable. The received byte is parked in a holding register until the transmitter is free, so a byte arriving while the previous one is still going out is not lost. And the centre button is synchronised and used as a synchronous reset, following the house rule of keeping resets synchronous unless the lesson is about reset design.

The four LEDs show a heartbeat (LD0, about 0.75 Hz), receive activity (LD1, stretched to 40 ms so it is visible), transmitter busy (LD2) and a sticky framing-error flag (LD3) that clears with the button. If LD3 lights, the terminal is at the wrong baud rate.

Code

Receiver

// 8N1 UART receiver. Samples each bit in the middle of its period.
module uart_rx #(
    parameter integer CLK_HZ = 100_000_000,
    parameter integer BAUD   = 115_200
) (
    input  wire       clk,
    input  wire       rst,
    input  wire       rx,
    output reg  [7:0] data,
    output reg        valid,       // one-cycle pulse when data is good
    output reg        frame_err    // one-cycle pulse when the stop bit was low
);
    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;       // 868 at 100 MHz
    localparam integer HALF_BIT     = CLKS_PER_BIT / 2;

    localparam [1:0] S_IDLE  = 2'd0,
                     S_START = 2'd1,
                     S_DATA  = 2'd2,
                     S_STOP  = 2'd3;

    // Two flip-flops bring the asynchronous pin into the clock domain.
    reg rx_meta;
    reg rx_sync;
    always @(posedge clk) begin
        rx_meta <= rx;
        rx_sync <= rx_meta;
    end

    reg [1:0]  state;
    reg [15:0] tick;      // counts clocks within one bit period
    reg [2:0]  bit_idx;
    reg [7:0]  shift;

    always @(posedge clk) begin
        valid     <= 1'b0;
        frame_err <= 1'b0;
        if (rst) begin
            state   <= S_IDLE;
            tick    <= 16'd0;
            bit_idx <= 3'd0;
            shift   <= 8'd0;
            data    <= 8'd0;
        end else begin
            case (state)
                S_IDLE: begin
                    tick    <= 16'd0;
                    bit_idx <= 3'd0;
                    if (rx_sync == 1'b0) state <= S_START;   // falling edge: start bit
                end
                S_START: begin
                    // Wait until the middle of the start bit, confirm it is still low.
                    if (tick == HALF_BIT[15:0]) begin
                        tick  <= 16'd0;
                        state <= (rx_sync == 1'b0) ? S_DATA : S_IDLE;
                    end else begin
                        tick <= tick + 16'd1;
                    end
                end
                S_DATA: begin
                    if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
                        tick  <= 16'd0;
                        shift <= {rx_sync, shift[7:1]};      // LSB arrives first
                        if (bit_idx == 3'd7) state <= S_STOP;
                        else bit_idx <= bit_idx + 3'd1;
                    end else begin
                        tick <= tick + 16'd1;
                    end
                end
                S_STOP: begin
                    if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
                        tick  <= 16'd0;
                        state <= S_IDLE;
                        if (rx_sync == 1'b1) begin
                            data  <= shift;
                            valid <= 1'b1;
                        end else begin
                            frame_err <= 1'b1;
                        end
                    end else begin
                        tick <= tick + 16'd1;
                    end
                end
                default: state <= S_IDLE;
            endcase
        end
    end
endmodule

Transmitter

// 8N1 UART transmitter. Load a byte with start=1 when busy=0.
module uart_tx #(
    parameter integer CLK_HZ = 100_000_000,
    parameter integer BAUD   = 115_200
) (
    input  wire       clk,
    input  wire       rst,
    input  wire       start,
    input  wire [7:0] data,
    output reg        tx,
    output wire       busy
);
    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;

    reg [15:0] tick;
    reg [3:0]  bit_idx;   // 0 = start, 1..8 = data, 9 = stop
    reg [9:0]  frame;     // {stop, data[7:0], start}
    reg        active;

    assign busy = active;

    always @(posedge clk) begin
        if (rst) begin
            tx      <= 1'b1;
            active  <= 1'b0;
            tick    <= 16'd0;
            bit_idx <= 4'd0;
            frame   <= 10'h3FF;
        end else if (!active) begin
            tx <= 1'b1;
            if (start) begin
                frame   <= {1'b1, data, 1'b0};
                active  <= 1'b1;
                tick    <= 16'd0;
                bit_idx <= 4'd0;
            end
        end else begin
            tx <= frame[bit_idx];
            if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
                tick <= 16'd0;
                if (bit_idx == 4'd9) active <= 1'b0;
                else bit_idx <= bit_idx + 4'd1;
            end else begin
                tick <= tick + 16'd1;
            end
        end
    end
endmodule

Top level with the case toggle

Upper-case and lower-case ASCII letters differ only in bit 5, so toggling case is a single XOR once you have checked the byte is a letter.

// UART echo with a twist: letters come back with their case toggled.
module top #(
    parameter integer CLK_HZ = 100_000_000,
    parameter integer BAUD   = 115_200
) (
    input  wire       clk,       // 100 MHz on W5
    input  wire       btn_c,     // centre button, synchronous reset
    input  wire       uart_rx_i, // from the USB-UART bridge (B18)
    output wire       uart_tx_o, // to the USB-UART bridge (A18)
    output wire [3:0] led
);
    // Two flops bring the push button into the clock domain. It is used as a synchronous reset.
    reg [1:0] rst_sync;
    always @(posedge clk) rst_sync <= {rst_sync[0], btn_c};
    wire rst = rst_sync[1];

    wire [7:0] rx_data;
    wire       rx_valid;
    wire       rx_err;
    wire       tx_busy;

    uart_rx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_rx (
        .clk(clk), .rst(rst), .rx(uart_rx_i),
        .data(rx_data), .valid(rx_valid), .frame_err(rx_err)
    );

    // Case toggle: ASCII letters differ by bit 5 between upper and lower case.
    wire is_upper = (rx_data >= 8'h41) && (rx_data <= 8'h5A);
    wire is_lower = (rx_data >= 8'h61) && (rx_data <= 8'h7A);
    wire [7:0] echo_data = (is_upper || is_lower) ? (rx_data ^ 8'h20) : rx_data;

    // Hold the byte until the transmitter is free.
    reg [7:0] pending_data;
    reg       pending;
    reg       tx_start;

    always @(posedge clk) begin
        tx_start <= 1'b0;
        if (rst) begin
            pending      <= 1'b0;
            pending_data <= 8'd0;
        end else begin
            if (rx_valid) begin
                pending_data <= echo_data;
                pending      <= 1'b1;
            end
            if (pending && !tx_busy && !tx_start) begin
                tx_start <= 1'b1;
                pending  <= 1'b0;
            end
        end
    end

    uart_tx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_tx (
        .clk(clk), .rst(rst), .start(tx_start), .data(pending_data),
        .tx(uart_tx_o), .busy(tx_busy)
    );

    // LEDs: heartbeat, receive activity (stretched), transmit busy, framing error (sticky).
    reg [26:0] heartbeat;
    reg [21:0] rx_stretch;
    reg        err_sticky;
    always @(posedge clk) begin
        if (rst) begin
            heartbeat  <= 27'd0;
            rx_stretch <= 22'd0;
            err_sticky <= 1'b0;
        end else begin
            heartbeat <= heartbeat + 27'd1;
            if (rx_valid) rx_stretch <= {22{1'b1}};
            else if (rx_stretch != 22'd0) rx_stretch <= rx_stretch - 22'd1;
            if (rx_err) err_sticky <= 1'b1;
        end
    end

    assign led = {err_sticky, tx_busy, (rx_stretch != 22'd0), heartbeat[26]};
endmodule

Testbench

The testbench drives the receive line with a bit-banged frame and decodes the transmit line in parallel, using a fork-join so the send and the receive overlap exactly as they would on the board. It checks nine bytes, including a carriage return and the two extreme values.

`timescale 1ns / 1ps
// Testbench: sends bytes into the echo design and checks what comes back.
module tb_uart;
    localparam integer CLK_HZ   = 100_000_000;
    localparam integer BAUD     = 115_200;
    localparam integer BIT_NS   = 1_000_000_000 / BAUD;   // 8680 ns

    reg        clk = 1'b0;
    reg        btn_c = 1'b0;
    reg        rx_line = 1'b1;
    wire       tx_line;
    wire [3:0] led;

    always #5 clk = ~clk;   // 100 MHz

    top #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) dut (
        .clk(clk), .btn_c(btn_c), .uart_rx_i(rx_line), .uart_tx_o(tx_line), .led(led)
    );

    // Drive one 8N1 frame onto the receive line.
    task send_byte(input [7:0] b);
        integer i;
        begin
            rx_line = 1'b0;                      // start bit
            #BIT_NS;
            for (i = 0; i < 8; i = i + 1) begin
                rx_line = b[i];
                #BIT_NS;
            end
            rx_line = 1'b1;                      // stop bit
            #BIT_NS;
        end
    endtask

    // Wait for a frame on the transmit line and decode it.
    task recv_byte(output [7:0] b);
        integer i;
        begin
            @(negedge tx_line);                  // start bit edge
            #(BIT_NS / 2);
            if (tx_line !== 1'b0) $display("WARN: start bit not low");
            for (i = 0; i < 8; i = i + 1) begin
                #BIT_NS;
                b[i] = tx_line;
            end
            #BIT_NS;
            if (tx_line !== 1'b1) $display("WARN: stop bit not high");
        end
    endtask

    reg [7:0] got;
    integer   errors = 0;

    // Expected echo: letters have their case toggled, everything else is unchanged.
    function [7:0] expected(input [7:0] c);
        begin
            if ((c >= "A" && c <= "Z") || (c >= "a" && c <= "z")) expected = c ^ 8'h20;
            else expected = c;
        end
    endfunction

    task check(input [7:0] c);
        begin
            fork
                send_byte(c);
                recv_byte(got);
            join
            if (got !== expected(c)) begin
                errors = errors + 1;
                $display("FAIL: sent 0x%02h, expected 0x%02h, got 0x%02h", c, expected(c), got);
            end else begin
                $display("ok:   sent 0x%02h -> got 0x%02h", c, got);
            end
        end
    endtask

    initial begin
        $dumpfile("tb_uart.vcd");
        $dumpvars(0, tb_uart);
        btn_c = 1'b1;
        repeat (10) @(posedge clk);
        btn_c = 1'b0;
        repeat (10) @(posedge clk);

        check("F");
        check("p");
        check("g");
        check("A");
        check("z");
        check("7");
        check(8'h0D);   // carriage return passes through untouched
        check(8'hFF);
        check(8'h00);

        if (errors == 0) $display("PASS: all bytes echoed correctly");
        else $display("FAIL: %0d errors", errors);
        $finish;
    end
endmodule

Constraints

## Basys 3 constraints for the UART echo project (all pins LVCMOS33)

## 100 MHz oscillator
set_property -dict { PACKAGE_PIN W5 IOSTANDARD LVCMOS33 } [get_ports clk]
create_clock -add -name sys_clk -period 10.000 -waveform {0 5} [get_ports clk]

## USB-UART bridge. The bridge's TXD drives B18, which is our receive input.
set_property -dict { PACKAGE_PIN B18 IOSTANDARD LVCMOS33 } [get_ports uart_rx_i]
set_property -dict { PACKAGE_PIN A18 IOSTANDARD LVCMOS33 } [get_ports uart_tx_o]

## Centre push button, used as a synchronous reset
set_property -dict { PACKAGE_PIN U18 IOSTANDARD LVCMOS33 } [get_ports btn_c]

## LEDs LD0..LD3
set_property -dict { PACKAGE_PIN U16 IOSTANDARD LVCMOS33 } [get_ports {led[0]}]
set_property -dict { PACKAGE_PIN E19 IOSTANDARD LVCMOS33 } [get_ports {led[1]}]
set_property -dict { PACKAGE_PIN U19 IOSTANDARD LVCMOS33 } [get_ports {led[2]}]
set_property -dict { PACKAGE_PIN V19 IOSTANDARD LVCMOS33 } [get_ports {led[3]}]

## The UART pins and button are asynchronous to the clock, so no timing paths through them.
set_false_path -from [get_ports {uart_rx_i btn_c}]
set_false_path -to [get_ports {uart_tx_o led[*]}]

set_property CFGBVS VCCO [current_design]
set_property CONFIG_VOLTAGE 3.3 [current_design]

Build and program

Simulate first

With Icarus Verilog installed, from the directory holding the four .v files:

iverilog -g2012 -o uart.vvp uart_rx.v uart_tx.v top.v tb_uart.v
vvp uart.vvp

You should see nine ok: lines and PASS: all bytes echoed correctly. The run takes about 1.5 ms of simulated time. Open tb_uart.vcd in GTKWave if you want to see the frames.

Vivado in Tcl

Save this as build.tcl next to the sources and run vivado -mode batch -source build.tcl with the board plugged in. It builds the bitstream and programs the device in one go.

# Vivado batch build: vivado -mode batch -source build.tcl
set part xc7a35tcpg236-1
create_project -in_memory -part $part
read_verilog {uart_rx.v uart_tx.v top.v}
read_xdc basys3.xdc
synth_design -top top -part $part
opt_design
place_design
route_design
report_timing_summary -file timing.rpt
write_bitstream -force top.bit
# Program the board (needs the Basys 3 connected over USB)
open_hw_manager
connect_hw_server
open_hw_target
set_property PROGRAM.FILE top.bit [current_hw_device]
program_hw_devices [current_hw_device]
close_hw_manager

Vivado in the GUI

  1. Create Project, RTL project, part xc7a35tcpg236-1 (or pick Basys 3 under Boards if you have the Digilent board files installed).
  2. Add Sources, add the three design files, and tick Copy sources into project. Do not add the testbench here.
  3. Add Sources again, Simulation Sources, and add tb_uart.v.
  4. Add Constraints and add basys3.xdc.
  5. Run Simulation if you want to see the waveform in Vivado. Set the run time to 2 ms in the simulation settings or the default 1 µs will stop before the first byte. The Tcl console prints the same ok: and PASS lines as Icarus Verilog does.
  6. Generate Bitstream. Expect a minute or two. Confirm in the timing summary that worst negative slack is positive. At 100 MHz this design has plenty of margin.
  7. Open Hardware Manager, Open Target, Auto Connect, then Program Device with top.bit.

The timing report deserves one look even on a design this small. The only real path is the 16-bit tick counter and its comparison, and Vivado should report a few nanoseconds of slack at a 10 ns period. If you see a warning that the input ports have no input delay constraints, the two false-path lines in the XDC are there to say that those paths are not meant to be timed.

The configuration is lost at power-off. To make it permanent, generate a .bin file and write it to the Basys 3’s QSPI flash from Hardware Manager, which is covered in From RTL to board.

Test

Find the serial port. On Linux it is usually /dev/ttyUSB1 (the bridge has two channels and the UART is the second). On macOS look for /dev/tty.usbserial-XXXXXXXX1. On Windows check Device Manager for a USB Serial Port.

screen /dev/ttyUSB1 115200

Settings are 115,200 baud, 8 data bits, no parity, 1 stop bit, no flow control. Most terminals do not echo locally, so what you see is what the FPGA sent back: type Hello FPGA and the screen shows hELLO fpga. Digits and punctuation come back unchanged. LD1 flashes with each keystroke and LD2 flickers while the reply goes out. Press the centre button to reset.

If nothing comes back, check that LD0 is blinking (the bitstream is loaded) and that LD3 is off. If LD3 is on, the baud rate is wrong on one side. If characters come back garbled, the same applies. Pasting a long block of text should also work byte for byte, because at 115,200 baud the transmitter finishes each byte before the next arrives.

Things to try

  • Add a 16-byte FIFO between receiver and transmitter and remove the holding register. The block RAM and FIFO lesson shows the structure.
  • Change the baud rate to 921,600. The receiver’s half-bit sampling still works at 108 clocks per bit. Work out how much baud error each side can tolerate before sampling slips off the bit.
  • Make the toggle smarter: a small command parser that treats a line starting with # as a command, such as #led 5 to set the LEDs.
  • Add parity. Extend both modules to send and check an even parity bit and report a parity error on an LED.
  • Replace the bit-banged testbench stimulus with a second instance of uart_tx, so the design tests itself.

Summary

  • A UART is two small state machines and a counter that divides the clock by the baud rate.
  • Sampling in the middle of each bit gives tolerance to clock differences between the two ends.
  • Every asynchronous input gets two flip-flops before any logic looks at it.
  • A self-checking testbench that decodes the output beats staring at waveforms.
  • The Basys 3’s USB-UART bridge means a serial console costs no extra hardware.