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.
| Board | Digilent Basys 3 |
|---|---|
| Time | about 3 hours |
| Language | verilog |
| Extra parts | Digilent 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:
screenorminicomon 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.
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
- Create Project, RTL project, part
xc7a35tcpg236-1(or pick Basys 3 under Boards if you have the Digilent board files installed). - Add Sources, add the three design files, and tick Copy sources into project. Do not add the testbench here.
- Add Sources again, Simulation Sources, and add
tb_uart.v. - Add Constraints and add
basys3.xdc. - 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:andPASSlines as Icarus Verilog does. - 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.
- 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 5to 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.