本文是《FPGA入门到实战》专栏第14篇。第13篇完成了 SPI Master 设计,本篇转向最常用的异步串行通信接口------UART。我们将从 8N1 帧格式、波特率误差与中点采样讲起,完成发送模块、16 倍过采样接收模块和字符回显顶层,并在 Vivado 中用 Testbench 验证完整收发链路。
1. UART 为什么适合 FPGA 入门
UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)只需要两根数据线:
TX:发送端输出RX:接收端输入
它不传输独立时钟,通信双方提前约定波特率和帧格式。开发板通过 USB 转串口芯片连接电脑后,就可以使用串口助手观察 FPGA 的输出,或向 FPGA 发送命令。
UART 特别适合作为 FPGA 的第一个通信接口,原因有三点:
- 信号少,硬件连接简单;
- 时序直观,逻辑分析仪和示波器都容易观察;
- 上位机工具成熟,调试成本低。
不过,"没有时钟线"也意味着接收端必须自己恢复采样节奏。波特率误差、起始位检测和亚稳态处理,正是 UART 设计的核心。
2. UART 帧格式
2.1 8N1 的含义
工程中最常见的配置是 8N1:
8:8 个数据位;N:无奇偶校验;1:1 个停止位。
线路空闲时保持高电平,一帧数据从低电平起始位开始:
text
空闲 起始位 8 个数据位(低位先发) 停止位 空闲
1 | 0 | D0 D1 D2 D3 D4 D5 D6 D7 | 1 | 1
例如发送 8'h55,二进制为 0101_0101。UART 低位先发,因此线上顺序为:
text
起始位 D0 D1 D2 D3 D4 D5 D6 D7 停止位
0 1 0 1 0 1 0 1 0 1
2.2 波特率与位时间
波特率表示每秒传输的符号数。在普通 UART 中,一个符号就是一个数据位,因此 115200 baud 的位时间为:
text
Tbit = 1 / 115200 ≈ 8.6806 us
一帧 8N1 数据共 10 位,所以理论吞吐量为:
text
115200 / 10 = 11520 Byte/s
注意:115200 baud 不等于每秒传输 115200 字节,起始位和停止位同样占用时间。
3. 波特率计数器与误差
FPGA 使用系统时钟计数产生 UART 位周期。假设系统时钟为 50 MHz,目标波特率为 115200:
text
计数周期 = 50_000_000 / 115200 ≈ 434.03
取整数 434 后,实际波特率为:
text
50_000_000 / 434 ≈ 115207.37
误差约为 0.0064%,完全可以接受。通用计算式如下:
text
误差率 = |实际波特率 - 目标波特率| / 目标波特率 × 100%
UART 收发两端的总误差过大时,采样点会逐位偏移。工程中通常希望双方累计误差控制在 2% 以内;帧越长,对误差越敏感。
计算分频值时必须先检查系统时钟和波特率是否匹配。整数除法会截断小数,不要默认任意参数组合都能得到低误差。
4. UART 发送模块设计
4.1 发送流程
发送模块接收到单周期 tx_start 后,依次输出:
- 1 个起始位;
- 8 个数据位,低位先发;
- 1 个停止位;
- 拉低
tx_busy,输出一个周期的tx_done。
把完整帧暂存为 {停止位, 数据, 起始位},发送时按位索引输出,代码会非常清晰。
4.2 完整代码
verilog
module uart_tx #(
parameter integer CLK_FREQ = 50_000_000,
parameter integer BAUD_RATE = 115_200
) (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire tx_start, // 单周期发送请求
input wire [7:0] tx_data , // 待发送数据
output reg tx , // UART 发送信号
output reg tx_busy , // 发送忙标志
output reg tx_done // 单周期完成脉冲
);
localparam integer BAUD_CNT_MAX = CLK_FREQ / BAUD_RATE - 1;
reg [31:0] baud_cnt;
reg [3:0] bit_cnt;
reg [9:0] tx_frame;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
baud_cnt <= 32'd0;
bit_cnt <= 4'd0;
tx_frame <= 10'h3ff;
tx <= 1'b1;
tx_busy <= 1'b0;
tx_done <= 1'b0;
end
else
begin
tx_done <= 1'b0;
if ((tx_start == 1'b1) && (tx_busy == 1'b0))
begin
tx_frame <= {1'b1, tx_data, 1'b0};
baud_cnt <= 32'd0;
bit_cnt <= 4'd0;
tx <= 1'b0;
tx_busy <= 1'b1;
end
else if (tx_busy == 1'b1)
begin
if (baud_cnt == BAUD_CNT_MAX)
begin
baud_cnt <= 32'd0;
if (bit_cnt == 4'd9)
begin
bit_cnt <= 4'd0;
tx <= 1'b1;
tx_busy <= 1'b0;
tx_done <= 1'b1;
end
else
begin
bit_cnt <= bit_cnt + 1'b1;
tx <= tx_frame[bit_cnt + 1'b1];
end
end
else
begin
baud_cnt <= baud_cnt + 1'b1;
end
end
else
begin
baud_cnt <= 32'd0;
bit_cnt <= 4'd0;
tx <= 1'b1;
end
end
end
endmodule
4.3 为什么在启动时锁存数据
tx_data 可能在发送过程中变化。如果直接从输入端口逐位读取,帧内容就可能前后不一致。因此发送开始时必须把数据锁存到 tx_frame,后续只操作内部寄存器。
tx_start 应当是单周期脉冲。模块忙碌时的新请求会被忽略,上层必须先检查 tx_busy,或者在前面增加 FIFO。
5. UART 接收为什么要在中间采样
接收端不知道起始位何时到来,只能先检测 RX 从高变低,再按照约定的波特率采样。
如果在数据跳变边缘附近采样,轻微抖动就可能读错。最稳妥的位置是每一位的中心:
text
RX ________----------------________
↑ ↑
跳变边缘 中心采样点
常用方法是生成波特率 16 倍的采样节拍:
- 检测到低电平后等待 8 个采样节拍;
- 在起始位中心再次确认电平为低;
- 此后每隔 16 个节拍采样一个数据位;
- 最后检查停止位是否为高。
16 倍过采样并不代表必须保存 16 个样本。入门设计只在中点取样即可;抗干扰要求更高时,可以在中心附近连续取 3 次并做多数表决。
6. 16 倍过采样接收模块
6.1 输入同步
RX 来自 FPGA 外部,与系统时钟异步,直接送入状态机可能产生亚稳态。首先用两级寄存器同步:
verilog
reg rx_r1;
reg rx_r2;
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
rx_r1 <= 1'b1;
rx_r2 <= 1'b1;
end
else
begin
rx_r1 <= rx;
rx_r2 <= rx_r1;
end
end
两级同步会带来两个系统时钟周期的延迟,但相对于一个 UART 位周期非常小,可以忽略。
6.2 完整代码
verilog
module uart_rx #(
parameter integer CLK_FREQ = 50_000_000,
parameter integer BAUD_RATE = 115_200
) (
input wire clk , // 系统时钟
input wire rstn , // 低有效复位
input wire rx , // UART 接收信号
output reg [7:0] rx_data , // 接收数据
output reg rx_valid , // 单周期数据有效脉冲
output reg frame_error // 停止位错误脉冲
);
localparam integer SAMPLE_CNT_MAX = CLK_FREQ / (BAUD_RATE * 16) - 1;
localparam [1:0] STATE_IDLE = 2'd0;
localparam [1:0] STATE_START = 2'd1;
localparam [1:0] STATE_DATA = 2'd2;
localparam [1:0] STATE_STOP = 2'd3;
reg rx_r1;
reg rx_r2;
reg [31:0] sample_div_cnt;
reg [3:0] sample_cnt;
reg [2:0] bit_cnt;
reg [7:0] rx_shift;
reg [1:0] state;
wire sample_tick;
assign sample_tick = (sample_div_cnt == SAMPLE_CNT_MAX);
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
rx_r1 <= 1'b1;
rx_r2 <= 1'b1;
end
else
begin
rx_r1 <= rx;
rx_r2 <= rx_r1;
end
end
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
sample_div_cnt <= 32'd0;
end
else if (sample_tick == 1'b1)
begin
sample_div_cnt <= 32'd0;
end
else
begin
sample_div_cnt <= sample_div_cnt + 1'b1;
end
end
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
sample_cnt <= 4'd0;
bit_cnt <= 3'd0;
rx_shift <= 8'd0;
rx_data <= 8'd0;
rx_valid <= 1'b0;
frame_error <= 1'b0;
state <= STATE_IDLE;
end
else
begin
rx_valid <= 1'b0;
frame_error <= 1'b0;
case (state)
STATE_IDLE:
begin
sample_cnt <= 4'd0;
bit_cnt <= 3'd0;
if (rx_r2 == 1'b0)
begin
state <= STATE_START;
end
end
STATE_START:
begin
if (sample_tick == 1'b1)
begin
if (sample_cnt == 4'd7)
begin
sample_cnt <= 4'd0;
if (rx_r2 == 1'b0)
begin
state <= STATE_DATA;
end
else
begin
state <= STATE_IDLE;
end
end
else
begin
sample_cnt <= sample_cnt + 1'b1;
end
end
end
STATE_DATA:
begin
if (sample_tick == 1'b1)
begin
if (sample_cnt == 4'd15)
begin
sample_cnt <= 4'd0;
rx_shift[bit_cnt] <= rx_r2;
if (bit_cnt == 3'd7)
begin
bit_cnt <= 3'd0;
state <= STATE_STOP;
end
else
begin
bit_cnt <= bit_cnt + 1'b1;
end
end
else
begin
sample_cnt <= sample_cnt + 1'b1;
end
end
end
STATE_STOP:
begin
if (sample_tick == 1'b1)
begin
if (sample_cnt == 4'd15)
begin
sample_cnt <= 4'd0;
state <= STATE_IDLE;
if (rx_r2 == 1'b1)
begin
rx_data <= rx_shift;
rx_valid <= 1'b1;
end
else
begin
frame_error <= 1'b1;
end
end
else
begin
sample_cnt <= sample_cnt + 1'b1;
end
end
end
default:
begin
sample_cnt <= 4'd0;
bit_cnt <= 3'd0;
state <= STATE_IDLE;
end
endcase
end
end
endmodule
6.3 停止位错误有什么用
如果停止位中心仍为低,说明当前帧不符合 8N1 格式。常见原因包括:
- 双方波特率不一致;
- 数据位、校验位或停止位配置不一致;
- 接线错误或电平标准不匹配;
- 线路干扰导致停止位被拉低。
因此不要只输出 rx_valid,同时保留 frame_error 能显著提高板级排错效率。
7. 字符回显顶层
回显实验的数据链路如下:
text
电脑串口助手 -> USB 转串口 TX -> FPGA RX
FPGA uart_rx -> 缓冲寄存器 -> uart_tx
FPGA TX -> USB 转串口 RX -> 电脑串口助手
接收到一个字节后,顶层把它暂存起来;发送模块空闲时发回电脑。单字节缓冲可以处理普通连续输入,如果上位机突发发送大量数据,应替换为 FIFO。
verilog
module uart_echo_top #(
parameter integer CLK_FREQ = 50_000_000,
parameter integer BAUD_RATE = 115_200
) (
input wire clk , // 系统时钟
input wire rstn, // 低有效复位
input wire rx , // UART 接收信号
output wire tx // UART 发送信号
);
wire [7:0] rx_data;
wire rx_valid;
wire frame_error;
wire tx_busy;
wire tx_done;
reg [7:0] tx_data;
reg tx_start;
reg [7:0] pending_data;
reg pending_valid;
uart_rx #(
.CLK_FREQ (CLK_FREQ ),
.BAUD_RATE(BAUD_RATE)
) u_uart_rx (
.clk (clk ),
.rstn (rstn ),
.rx (rx ),
.rx_data (rx_data ),
.rx_valid (rx_valid ),
.frame_error(frame_error)
);
uart_tx #(
.CLK_FREQ (CLK_FREQ ),
.BAUD_RATE(BAUD_RATE)
) u_uart_tx (
.clk (clk ),
.rstn (rstn ),
.tx_start(tx_start),
.tx_data (tx_data ),
.tx (tx ),
.tx_busy (tx_busy ),
.tx_done (tx_done )
);
always @(posedge clk or negedge rstn)
begin
if (rstn == 1'b0)
begin
tx_data <= 8'd0;
tx_start <= 1'b0;
pending_data <= 8'd0;
pending_valid <= 1'b0;
end
else
begin
tx_start <= 1'b0;
if ((pending_valid == 1'b1) && (tx_busy == 1'b0))
begin
tx_data <= pending_data;
tx_start <= 1'b1;
pending_valid <= 1'b0;
end
if (rx_valid == 1'b1)
begin
pending_data <= rx_data;
pending_valid <= 1'b1;
end
end
end
endmodule
frame_error 和 tx_done 在最小回显工程中没有连接到其他逻辑,调试时可以用 ILA 观察。正式项目可增加错误计数器,并把发送完成信号用于数据队列管理。
8. Testbench 仿真完整收发链路
仿真不必真的等待 50 MHz 与 115200 baud 的长时间比例。把系统时钟设为 16 MHz、波特率设为 1 MHz,可以显著缩短仿真时间,同时保持 16 倍采样关系和协议行为不变。
verilog
`timescale 1ns/1ps
module tb_uart_echo_top;
localparam integer CLK_FREQ = 16_000_000;
localparam integer BAUD_RATE = 1_000_000;
localparam integer BIT_TIME = 1_000_000_000 / BAUD_RATE;
reg clk;
reg rstn;
reg rx;
wire tx;
integer bit_index;
reg [7:0] received_data;
uart_echo_top #(
.CLK_FREQ (CLK_FREQ ),
.BAUD_RATE(BAUD_RATE)
) u_dut (
.clk (clk ),
.rstn(rstn),
.rx (rx ),
.tx (tx )
);
initial
begin
clk = 1'b0;
forever #31.25 clk = ~clk;
end
task send_uart_byte;
input [7:0] data;
integer index;
begin
rx = 1'b0;
#(BIT_TIME);
for (index = 0; index < 8; index = index + 1)
begin
rx = data[index];
#(BIT_TIME);
end
rx = 1'b1;
#(BIT_TIME);
end
endtask
task receive_uart_byte;
output [7:0] data;
integer index;
begin
@(negedge tx);
#(BIT_TIME + BIT_TIME / 2);
for (index = 0; index < 8; index = index + 1)
begin
data[index] = tx;
#(BIT_TIME);
end
if (tx != 1'b1)
begin
$display("ERROR: stop bit is low");
end
end
endtask
initial
begin
rstn = 1'b0;
rx = 1'b1;
received_data = 8'd0;
repeat (10) @(posedge clk);
rstn = 1'b1;
repeat (10) @(posedge clk);
fork
send_uart_byte(8'h55);
receive_uart_byte(received_data);
join
if (received_data == 8'h55)
begin
$display("PASS: echo data = 0x%02h", received_data);
end
else
begin
$display("FAIL: echo data = 0x%02h", received_data);
end
#(BIT_TIME * 2);
$finish;
end
endmodule
8.1 需要观察的波形
在 Vivado 仿真窗口中至少添加以下信号:
rx:Testbench 发送给 FPGA 的串行帧;u_uart_rx.state:接收状态机;u_uart_rx.sample_cnt:16 倍采样计数;u_uart_rx.rx_data与rx_valid:接收结果;tx_start与tx_busy:发送握手;tx:FPGA 回显的数据帧。
正确结果应满足:
rx先出现8'h55的 8N1 帧;- 停止位中心产生一个周期的
rx_valid; - 顶层在发送模块空闲时拉高
tx_start; tx输出内容相同的 8N1 帧;- Testbench 打印
PASS: echo data = 0x55。
如果波形中所有数据位整体错位,优先检查位时间;如果只有位顺序相反,检查是否误写成高位先发。
9. Vivado 工程与 XDC 约束
9.1 添加源码
在 Vivado 中依次添加:
uart_tx.v;uart_rx.v;uart_echo_top.v;- 仿真文件
tb_uart_echo_top.v。
将 uart_echo_top 设置为综合顶层,将 tb_uart_echo_top 设置为仿真顶层。
9.2 约束示例
下面仅展示约束格式,管脚编号和电平标准必须替换为开发板原理图中的实际值:
tcl
set_property PACKAGE_PIN <CLK_PIN> [get_ports clk]
set_property IOSTANDARD LVCMOS33 [get_ports clk]
create_clock -period 20.000 -name sys_clk [get_ports clk]
set_property PACKAGE_PIN <RST_PIN> [get_ports rstn]
set_property IOSTANDARD LVCMOS33 [get_ports rstn]
set_property PACKAGE_PIN <UART_RX_PIN> [get_ports rx]
set_property IOSTANDARD LVCMOS33 [get_ports rx]
set_property PACKAGE_PIN <UART_TX_PIN> [get_ports tx]
set_property IOSTANDARD LVCMOS33 [get_ports tx]
不要直接复制其他开发板的管脚号。约束错误可能导致串口无数据,严重时还可能造成 IO 电气冲突。
10. 下板回显步骤
- 综合、实现并生成比特流;
- 通过 JTAG 下载到 FPGA;
- 确认电脑识别到开发板的 USB 转串口;
- 串口助手选择正确 COM 口;
- 设置为 115200、8 数据位、无校验、1 停止位;
- 关闭硬件流控;
- 发送字符串
Hello FPGA; - 接收窗口应显示相同内容。
如果开发板没有板载 USB 转串口,可以使用外置 USB-TTL 模块。连接关系为交叉连接:
text
USB-TTL TX -> FPGA RX
USB-TTL RX -> FPGA TX
USB-TTL GND -> FPGA GND
必须共地,并确认 USB-TTL 模块输出电平与 FPGA IO 电压兼容。不要把 RS-232 的正负电压信号直接接到 FPGA 引脚。
11. 常见问题排查
11.1 串口助手完全收不到数据
按以下顺序检查:
- COM 口是否选择正确;
- FPGA 是否已经下载最新比特流;
- TX/RX 是否交叉连接;
- 双方是否共地;
- XDC 管脚是否与原理图一致;
CLK_FREQ是否等于真实系统时钟;tx空闲时是否保持高电平。
可以先让发送模块周期性发送固定字节 8'h55。0x55 在线上形成交替电平,非常适合用示波器测量位宽。
11.2 收到乱码
乱码通常不是"字符编码问题",而是帧采样错误:
- 波特率设置不一致;
- 系统时钟参数填写错误;
- 发送端和接收端的数据位/校验位/停止位不同;
- 时钟分频误差过大;
- 输入信号存在严重毛刺。
先测量一个数据位的持续时间,再对照理论值 8.6806 us,比盲目改代码有效得多。
11.3 第一个字节正确,连续数据丢失
单字节缓冲只能吸收有限的时序差异。上位机连续发送而下游来不及处理时,新数据会覆盖旧数据。解决方法是:
- 在
uart_rx后加入同步 FIFO; - 使用
empty控制发送启动; - 只有在 FIFO 非空且
tx_busy为低时读取一个字节; - 根据最大突发长度选择 FIFO 深度。
这也是后续 FIFO 章节与 UART 工程结合的典型场景。
11.4 偶尔出现错误字节
如果大部分数据正确、偶尔错误,应重点检查:
RX是否经过两级同步;- 电源和地连接是否可靠;
- 线缆是否过长;
- IO 电平是否匹配;
- 是否需要中心 3 点多数表决;
- 是否存在跨时钟域数据直接使用的问题。
可以把 rx_r2、state、sample_cnt、rx_shift、frame_error 加入 ILA,在错误发生时触发抓取。
12. UART 与 SPI 对比
| 对比项 | UART | SPI |
|---|---|---|
| 时钟 | 无独立时钟,双方约定波特率 | Master 输出 SCLK |
| 信号线 | TX、RX,通常再加 GND | SCLK、MOSI、MISO、CS |
| 双工方式 | 可全双工 | 全双工 |
| 从设备选择 | 通常点对点 | 使用独立 CS 选择多个从设备 |
| 速率 | 常见 9600~数 Mbps | 常见数 MHz~数十 MHz |
| 设计重点 | 波特率误差、中点采样 | CPOL/CPHA、片选与边沿关系 |
| 典型用途 | 调试日志、命令交互、低速模块 | ADC、DAC、Flash、传感器 |
UART 适合低成本点对点通信,SPI 适合板内高速外设访问。两者没有绝对优劣,应根据设备接口、距离、速率和引脚数量选择。
13. 工程扩展方向
完成基础回显后,可以继续扩展:
- 增加奇偶校验 :发送端生成校验位,接收端输出
parity_error; - 增加 FIFO:支持连续数据和模块间速率解耦;
- 增加命令解析器:识别帧头、长度、命令字、数据和校验和;
- 加入超时判断:半帧停顿过久时自动丢弃异常数据;
- 实现可配置波特率:通过寄存器动态修改分频参数;
- 连接 AXI-Stream:让 UART 与 DMA、FIFO 或处理器系统对接。
真正的工程协议通常不是"收到一个字节就处理一个字节",而是在 UART 之上再定义数据帧。建议先把底层收发模块验证稳定,再实现上层协议,避免把链路问题和解析问题混在一起排查。
14. 本篇总结
本篇完成了 UART 从原理到下板的完整闭环:
- 理解 8N1 帧格式和低位先发规则;
- 根据系统时钟计算波特率分频并评估误差;
- 实现带
busy/done握手的 UART 发送模块; - 使用两级同步和 16 倍过采样实现接收模块;
- 检查停止位并输出
frame_error; - 搭建字符回显顶层和自检 Testbench;
- 掌握串口乱码、丢字节和无输出的排查顺序;
- 明确 FIFO 在连续通信中的作用。
下一篇将进入 I2C 通信,完成起始、停止、应答、地址帧和 EEPROM 读写时序设计。与 UART 相比,I2C 的双向开漏信号和 ACK 处理更复杂,也更能体现状态机分层设计的重要性。