文章目录
-
- 背景与问题
- 原理分析
-
- [UART 协议帧格式](#UART 协议帧格式)
- 波特率与时钟分频原理
- 收发状态机设计
- [与 SPI/I2C 协议对比](#与 SPI/I2C 协议对比)
- 环境准备
- 实战步骤
-
- 第一步:实现波特率生成器
- [第二步:实现 UART 发送模块](#第二步:实现 UART 发送模块)
- [第三步:实现 UART 接收模块](#第三步:实现 UART 接收模块)
- 第四步:顶层模块集成与引脚约束
- [第五步:Testbench 仿真验证](#第五步:Testbench 仿真验证)
- 第六步:板级回环验证
- 场景适配
-
- 不同波特率与时钟频率的适配
- 增加奇偶校验位
- [多字节收发与 FIFO 缓冲](#多字节收发与 FIFO 缓冲)
- 性能优化
- 故障排查
- 总结
背景与问题
调试口的选型困境
接手一块 Artix-7 评估板时,板卡只有 USB-UART、LED、按键和两个扩展口,没有 JTAG 调试器。当时要确认 FPGA 内部一个 32 位计数器是否在正确翻转,试了三条路:用 Vivado 的 ILA 逻辑分析仪需要 JTAG 下载器,现场没有;用板载 LED 观察只适合看单 bit 电平,32 位计数器的中间位变化根本看不出来;最后只能靠 USB-UART 把数据打出来。折腾一天后决定自己写一套 UART 收发模块,从此这块板子再没缺过调试手段。
这个场景在嵌入式开发里很常见:不是每个人都有逻辑分析仪和 JTAG,但几乎每块板子都有串口。UART 是 FPGA 入门绕不开的基础外设,也是验证 RTL 设计基本功的试金石------时序控制、状态机、跨时钟域处理全都会涉及。
本文要解决的核心问题
本文用 Vivado 从零实现一个 8 数据位、无校验、1 停止位的 UART 收发模块,包含波特率生成器、发送状态机、接收状态机、顶层集成和 Testbench 仿真,代码可直接编译综合并下载到 FPGA 运行。读完你能独立写出一套可复用的串口 IP,并掌握用 Modelsim/Vivado Simulator 验证时序逻辑的方法。
原理分析
UART 协议帧格式
UART 是异步串行协议,收发双方共地但不共享时钟,靠约定好的波特率对齐采样时刻。一帧数据由四段组成:起始位(1 bit 低电平)、数据位(5-8 bit,LSB 先发)、校验位(可选)、停止位(1-2 bit 高电平)。空闲时 TXD/RXD 线保持高电平,接收端通过检测下降沿识别帧起始。
| 字段 | 宽度 | 电平 | 说明 |
|---|---|---|---|
| 起始位 | 1 bit | 低 | 下降沿触发接收 |
| 数据位 | 8 bit | 高/低 | LSB 先发,本文用 8N1 |
| 停止位 | 1 bit | 高 | 帧结束标志 |
| 空闲 | - | 高 | 无数据时的默认状态 |
以 115200bps 发送字节 0x41(0100_0001)为例,线上电平顺序是:低(起始)→ 1 → 0 → 0 → 0 → 0 → 0 → 1 → 0 → 高(停止),注意最低位 1 最先出现在总线上。
波特率与时钟分频原理
波特率表示每秒传输的码元数。FPGA 内部时钟通常是 50MHz 或 100MHz,需要分频出与波特率对应的采样脉冲。分频系数 = 系统时钟频率 / 波特率,50MHz 时钟配 115200 波特率时系数为 434。
接收端采样策略很关键:理论上每一位应该在中点采样,避免采到数据跳变沿。本文用一个"半波特周期确认起始位 + 每波特周期中点采样"的方案:检测到下降沿后先等半波特周期,确认起始位仍为低电平(滤掉毛刺),之后每个波特周期在中点采集一次数据位。
收发状态机设计
发送和接收分别用独立状态机控制。发送状态机四态:空闲、起始、数据、停止。空闲时 TXD 保持高电平,收到发送请求后依次输出起始位、8 位数据、停止位,用波特率脉冲驱动状态迁移。接收状态机同样四态,但多一个同步环节------外部 RXD 信号要先打两拍同步进 FPGA 时钟域,消除亚稳态风险。
#mermaid-svg-JYO6Wim4zmhtsw6L{font-family:Arial;font-size:14px;fill:#ccc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JYO6Wim4zmhtsw6L .error-icon{fill:#a44141;}#mermaid-svg-JYO6Wim4zmhtsw6L .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JYO6Wim4zmhtsw6L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JYO6Wim4zmhtsw6L .marker{fill:lightgrey;stroke:lightgrey;}#mermaid-svg-JYO6Wim4zmhtsw6L .marker.cross{stroke:lightgrey;}#mermaid-svg-JYO6Wim4zmhtsw6L svg{font-family:Arial;font-size:14px;}#mermaid-svg-JYO6Wim4zmhtsw6L p{margin:0;}#mermaid-svg-JYO6Wim4zmhtsw6L .label{font-family:Arial;color:#ccc;}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster-label text{fill:#F9FFFE;}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster-label span{color:#F9FFFE;}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster-label span p{background-color:transparent;}#mermaid-svg-JYO6Wim4zmhtsw6L .label text,#mermaid-svg-JYO6Wim4zmhtsw6L span{fill:#ccc;color:#ccc;}#mermaid-svg-JYO6Wim4zmhtsw6L .node rect,#mermaid-svg-JYO6Wim4zmhtsw6L .node circle,#mermaid-svg-JYO6Wim4zmhtsw6L .node ellipse,#mermaid-svg-JYO6Wim4zmhtsw6L .node polygon,#mermaid-svg-JYO6Wim4zmhtsw6L .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#mermaid-svg-JYO6Wim4zmhtsw6L .rough-node .label text,#mermaid-svg-JYO6Wim4zmhtsw6L .node .label text,#mermaid-svg-JYO6Wim4zmhtsw6L .image-shape .label,#mermaid-svg-JYO6Wim4zmhtsw6L .icon-shape .label{text-anchor:middle;}#mermaid-svg-JYO6Wim4zmhtsw6L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JYO6Wim4zmhtsw6L .rough-node .label,#mermaid-svg-JYO6Wim4zmhtsw6L .node .label,#mermaid-svg-JYO6Wim4zmhtsw6L .image-shape .label,#mermaid-svg-JYO6Wim4zmhtsw6L .icon-shape .label{text-align:center;}#mermaid-svg-JYO6Wim4zmhtsw6L .node.clickable{cursor:pointer;}#mermaid-svg-JYO6Wim4zmhtsw6L .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#mermaid-svg-JYO6Wim4zmhtsw6L .arrowheadPath{fill:lightgrey;}#mermaid-svg-JYO6Wim4zmhtsw6L .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#mermaid-svg-JYO6Wim4zmhtsw6L .flowchart-link{stroke:lightgrey;fill:none;}#mermaid-svg-JYO6Wim4zmhtsw6L .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-JYO6Wim4zmhtsw6L .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-JYO6Wim4zmhtsw6L .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-JYO6Wim4zmhtsw6L .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster text{fill:#F9FFFE;}#mermaid-svg-JYO6Wim4zmhtsw6L .cluster span{color:#F9FFFE;}#mermaid-svg-JYO6Wim4zmhtsw6L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Arial;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JYO6Wim4zmhtsw6L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#mermaid-svg-JYO6Wim4zmhtsw6L rect.text{fill:none;stroke-width:0;}#mermaid-svg-JYO6Wim4zmhtsw6L .icon-shape,#mermaid-svg-JYO6Wim4zmhtsw6L .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#mermaid-svg-JYO6Wim4zmhtsw6L .icon-shape p,#mermaid-svg-JYO6Wim4zmhtsw6L .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#mermaid-svg-JYO6Wim4zmhtsw6L .icon-shape .label rect,#mermaid-svg-JYO6Wim4zmhtsw6L .image-shape .label rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#mermaid-svg-JYO6Wim4zmhtsw6L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JYO6Wim4zmhtsw6L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JYO6Wim4zmhtsw6L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 检测到下降沿
半波特周期后仍为低
半波特周期后为高
采满 8 位
停止位为高
停止位为低
空闲 IDLE
起始位确认 START
数据采样 DATA
停止位校验 STOP
输出字节 + rx_done
图表解读:
- 数据流向:空闲态检测到 RXD 下降沿进入起始位确认,半波特周期后电平仍为低则判定为有效帧,进入数据采样
- 核心决策点:起始位确认环节同时承担毛刺过滤,半波特周期后若电平已回高,说明是噪声脉冲而非起始位,直接丢弃
- 性能关键路径:DATA 态每个波特周期在数据位中点采样一次,采样点由系统时钟计数精确控制,波特率越高对计数精度要求越高
与 SPI/I2C 协议对比
| 协议 | 时钟来源 | 引脚数 | 方向性 | 适用场景 |
|---|---|---|---|---|
| UART | 收发各自定时 | 2(TX/RX) | 全双工 | 低速调试、设备互联 |
| SPI | 主设备提供 | 4(SCLK/MOSI/MISO/CS) | 全双工 | 片内高速外设 |
| I2C | 主设备提供 | 2(SCL/SDA) | 半双工 | 多设备低速总线 |
UART 最大的优势是只需两根线就能全双工通信,不需要额外时钟线,特别适合跨设备长距离调试。代价是双方波特率必须一致,且误差要控制在可接受范围内(一般要求收发双方误差累计不超过半个位周期)。
环境准备
硬件与软件版本
| 工具 | 版本 | 用途 |
|---|---|---|
| Vivado | 2023.2 | 综合、实现、生成比特流 |
| Vivado Simulator | 随 Vivado 安装 | RTL 仿真验证 |
| Artix-7 FPGA 评估板 | XC7A35T | 板级验证(其他型号可直接替换约束) |
| 板载 USB-UART | FT232 或 CH340 | 与 PC 通信 |
| 串口助手 | XCOM / SSCOM | 板级回环验证 |
综合和仿真工具不需要额外安装,Vivado 2023.2 自带仿真器。板卡型号不关键,代码是纯 RTL,只要引脚约束按实际板卡修改即可。
工程创建步骤
创建工程时选择 RTL Project,目标器件选 XC7A35T 或手头板卡的实际型号。源文件按以下结构组织:
bash
uart_demo/
├── rtl/
│ ├── baud_gen.v # 波特率生成器
│ ├── uart_tx.v # 发送状态机
│ ├── uart_rx.v # 接收状态机
│ └── uart_top.v # 顶层集成
├── sim/
│ └── uart_top_tb.v # Testbench
└── constraints/
└── uart_top.xdc # 引脚约束
代码解读:rtl 目录放可综合的 RTL 源文件,sim 目录放仿真专用文件,constraints 目录放 XDC 约束。Testbench 不要混进 rtl,避免综合时把不可综合的 initial 语句带进网表。
实战步骤
第一步:实现波特率生成器
波特率生成器是一个自由运行的计数器,每计数到分频系数就输出一个时钟宽度的脉冲。这个脉冲是发送和接收状态机的"节拍器",所有位操作都以它为基准。
文件名:rtl/baud_gen.v
verilog
`timescale 1ns / 1ps
// 波特率生成器:50MHz 时钟分频产生 115200bps 采样脉冲
// 分频系数 = 50000000 / 115200 = 434
module baud_gen #(
parameter CLK_FREQ = 50_000_000, // 系统时钟频率
parameter BAUD_RATE = 115200 // 目标波特率
) (
input wire clk, // 系统时钟 50MHz
input wire rst_n, // 异步复位,低有效
output reg baud_tick // 波特率脉冲,每波特周期输出一个高电平周期
);
// 分频系数减 1 作为计数目标
localparam DIV_CNT = CLK_FREQ / BAUD_RATE - 1;
reg [15:0] cnt;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt <= 16'd0;
baud_tick <= 1'b0;
end else if (cnt == DIV_CNT) begin
cnt <= 16'd0; // 计满清零,重新开始
baud_tick <= 1'b1; // 输出一个时钟周期的脉冲
end else begin
cnt <= cnt + 16'd1; // 未计满则继续累加
baud_tick <= 1'b0;
end
end
endmodule
代码解读:DIV_CNT 用参数计算而不是写死,换波特率或换时钟频率时只需改参数。cnt 是 16 位寄存器,50MHz/1200bps 时系数约 41666,16 位足够。baud_tick 每波特周期只拉高一个时钟周期,下游状态机用这个脉冲做节拍,天然不会漏采样也不会重复采样。
第二步:实现 UART 发送模块
发送状态机在空闲态检测 tx_start 脉冲,锁存待发数据后依次拉低起始位、逐位发送 8 位数据、拉高停止位,最后回到空闲态。数据右移发送,保证 LSB 最先出现在总线上。
文件名:rtl/uart_tx.v
verilog
`timescale 1ns / 1ps
// UART 发送模块:8 数据位、无校验、1 停止位,LSB 先发
module uart_tx (
input wire clk, // 系统时钟
input wire rst_n, // 复位,低有效
input wire baud_tick, // 波特率脉冲
input wire [7:0] tx_data, // 待发送字节
input wire tx_start, // 发送启动脉冲,高电平有效
output reg txd, // 串行输出
output reg tx_busy // 忙标志,1=正在发送
);
localparam IDLE = 2'd0; // 空闲态
localparam START = 2'd1; // 起始位
localparam DATA = 2'd2; // 数据位
localparam STOP = 2'd3; // 停止位
reg [1:0] state; // 当前状态
reg [2:0] bit_cnt; // 已发送数据位数
reg [7:0] shift_reg; // 发送移位寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= IDLE;
txd <= 1'b1; // 空闲时 TXD 保持高电平
tx_busy <= 1'b0;
bit_cnt <= 3'd0;
shift_reg <= 8'd0;
end else begin
case (state)
IDLE: begin
txd <= 1'b1; // 保证空闲电平为高
if (tx_start) begin
state <= START; // 收到启动请求
tx_busy <= 1'b1; // 置忙,外部轮询或等待
shift_reg <= tx_data; // 锁存待发数据
end else begin
tx_busy <= 1'b0;
end
end
START: begin
txd <= 1'b0; // 输出起始位低电平
if (baud_tick) begin
state <= DATA; // 一个波特周期后进入数据位
end
end
DATA: begin
txd <= shift_reg[0]; // 当前最低位输出
if (baud_tick) begin
shift_reg <= {1'b0, shift_reg[7:1]}; // 右移,下一位就绪
if (bit_cnt == 3'd7) begin
bit_cnt <= 3'd0;
state <= STOP; // 8 位发完
end else begin
bit_cnt <= bit_cnt + 3'd1;
end
end
end
STOP: begin
txd <= 1'b1; // 停止位高电平
if (baud_tick) begin
state <= IDLE; // 回空闲,释放总线
tx_busy <= 1'b0;
end
end
default: state <= IDLE;
endcase
end
end
endmodule
代码解读:状态迁移完全由 baud_tick 驱动,每个状态停留恰好一个波特周期。DATA 态里 txd 直接接 shift_reg0,在 baud_tick 到来前该位已经稳定,发送时刻精确对齐波特周期。tx_busy 用于外部逻辑判断是否可发起下一字节发送,避免数据覆盖。
第三步:实现 UART 接收模块
接收模块比发送复杂,要处理异步输入的同步和毛刺过滤。外部 RXD 先打两拍同步进时钟域,检测到下降沿后等待半波特周期再确认起始位,之后每个波特周期的中点采样数据位。
文件名:rtl/uart_rx.v
verilog
`timescale 1ns / 1ps
// UART 接收模块:下降沿检测起始位,中点采样恢复数据
module uart_rx #(
parameter CLK_FREQ = 50_000_000, // 系统时钟频率
parameter BAUD_RATE = 115200 // 目标波特率
) (
input wire clk, // 系统时钟
input wire rst_n, // 复位,低有效
input wire rxd, // 串行输入
output reg [7:0] rx_data, // 接收到的字节
output reg rx_done // 接收完成脉冲,高电平一个时钟周期
);
// 一个波特周期的时钟数(50MHz/115200=434)与半波特周期
localparam BAUD_DIV = CLK_FREQ / BAUD_RATE;
localparam HALF_DIV = BAUD_DIV / 2; // 217
localparam IDLE = 2'd0;
localparam START = 2'd1;
localparam DATA = 2'd2;
localparam STOP = 2'd3;
reg [1:0] state;
reg [2:0] bit_cnt; // 已采样数据位数
reg [8:0] clk_cnt; // 波特周期内时钟计数,最大 433
reg [7:0] shift_reg; // 接收移位寄存器
reg rxd_d1, rxd_d2; // 两级同步寄存器
// 两级同步:消除跨时钟域亚稳态,同步后信号在同一时钟域判断
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rxd_d1 <= 1'b1;
rxd_d2 <= 1'b1;
end else begin
rxd_d1 <= rxd;
rxd_d2 <= rxd_d1;
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= IDLE;
rx_data <= 8'd0;
rx_done <= 1'b0;
bit_cnt <= 3'd0;
clk_cnt <= 9'd0;
shift_reg <= 8'd0;
end else begin
rx_done <= 1'b0; // 完成脉冲默认拉低,避免重复触发
case (state)
IDLE: begin
if (!rxd_d2) begin // 检测到下降沿
state <= START; // 进入起始位确认
clk_cnt <= 9'd0;
end
end
START: begin
if (clk_cnt == HALF_DIV) begin // 半波特周期后采样确认
if (!rxd_d2) begin // 起始位仍为低
state <= DATA; // 确认有效帧起始
clk_cnt <= 9'd0;
bit_cnt <= 3'd0;
end else begin
state <= IDLE; // 高电平说明是毛刺
end
end else begin
clk_cnt <= clk_cnt + 9'd1;
end
end
DATA: begin
if (clk_cnt == BAUD_DIV) begin // 满一个波特周期,即数据位中点
shift_reg <= {rxd_d2, shift_reg[7:1]}; // 先到bit0最终落在最低位
clk_cnt <= 9'd0;
if (bit_cnt == 3'd7) begin
bit_cnt <= 3'd0;
state <= STOP; // 8 位采样完成
end else begin
bit_cnt <= bit_cnt + 3'd1;
end
end else begin
clk_cnt <= clk_cnt + 9'd1;
end
end
STOP: begin
if (clk_cnt == BAUD_DIV) begin
if (rxd_d2) begin // 停止位应为高
rx_data <= shift_reg; // 锁存完整字节
rx_done <= 1'b1; // 输出完成脉冲
end
state <= IDLE; // 无论对错都回空闲
end else begin
clk_cnt <= clk_cnt + 9'd1;
end
end
default: state <= IDLE;
endcase
end
end
endmodule
代码解读:接收端不依赖 baud_tick,而是用系统时钟直接计数。clk_cnt 在 START 态计到 HALF_DIV(217 个时钟,约半波特周期)确认起始位,在 DATA 态计到 BAUD_DIV(434 个时钟,一个波特周期)时采样,采样点精确落在每个数据位的中点,不受波特率脉冲对齐关系影响。注意接收移位方向:先到的 bit0 通过逐次右移最终停在 shift_reg0,与发送端的 LSB-first 顺序对应。停止位校验失败时选择不回错误标志,直接丢弃帧,简化协议层处理。
第四步:顶层模块集成与引脚约束
顶层把三个子模块串起来:波特率生成器给收发提供节拍,接收模块收到字节后输出 rx_done 脉冲,顶层把该脉冲作为发送启动信号,实现"收到什么就回发什么"的回环模式,方便板级自测。
文件名:rtl/uart_top.v
verilog
`timescale 1ns / 1ps
// 顶层模块:波特率生成 + 接收 + 回环发送
module uart_top (
input wire clk, // 50MHz 系统时钟
input wire rst_n, // 复位,低有效
input wire rxd, // UART 接收引脚
output wire txd, // UART 发送引脚
output wire tx_busy, // 发送忙标志(调试观察用)
output wire [7:0] rx_data, // 最近接收字节(调试观察用)
output wire rx_done // 接收完成脉冲(调试观察用)
);
wire baud_tick;
// 例化波特率生成器,115200bps
baud_gen #(
.CLK_FREQ (50_000_000),
.BAUD_RATE (115200)
) u_baud_gen (
.clk (clk),
.rst_n (rst_n),
.baud_tick (baud_tick)
);
// 例化接收模块,内部自行计数,不接 baud_tick
uart_rx #(
.CLK_FREQ (50_000_000),
.BAUD_RATE (115200)
) u_rx (
.clk (clk),
.rst_n (rst_n),
.rxd (rxd),
.rx_data (rx_data),
.rx_done (rx_done)
);
// 例化发送模块,收到字节后立即回发
uart_tx u_tx (
.clk (clk),
.rst_n (rst_n),
.baud_tick (baud_tick),
.tx_data (rx_data), // 回环:发送内容取自接收数据
.tx_start (rx_done), // 回环:接收完成即触发发送
.txd (txd),
.tx_busy (tx_busy)
);
endmodule
代码解读:顶层只是连线,不含任何逻辑。回环模式的巧妙之处在于不引入外部控制逻辑就能完整验证收发链路:PC 发字节,FPGA 收到后原样回发,PC 端对比收发数据是否一致。rx_data、rx_done、tx_busy 引到顶层端口,方便后续接 ILA 或 LED 观察。
文件名:constraints/uart_top.xdc
xdc
# 引脚约束:以 Artix-7 评估板为例,实际板卡需按原理图修改
set_property PACKAGE_PIN E3 [get_ports clk] # 50MHz 时钟输入
set_property IOSTANDARD LVCMOS33 [get_ports clk]
set_property PACKAGE_PIN C4 [get_ports rst_n] # 按键复位
set_property IOSTANDARD LVCMOS33 [get_ports rst_n]
set_property PACKAGE_PIN A10 [get_ports txd] # FPGA TX 接 USB-UART RXD
set_property IOSTANDARD LVCMOS33 [get_ports txd]
set_property PACKAGE_PIN B10 [get_ports rxd] # FPGA RX 接 USB-UART TXD
set_property IOSTANDARD LVCMOS33 [get_ports rxd]
代码解读:XDC 里 PACKAGE_PIN 是芯片物理引脚号,IOSTANDARD 是电气标准,必须与板卡原理图一致。三个调试输出端口(tx_busy、rx_data、rx_done)先不约束,综合器会自动分配到空闲引脚,后续需要观察时再补充约束。
第五步:Testbench 仿真验证
仿真前先用波形验证时序逻辑,比直接上板效率高得多。Testbench 里生成 50MHz 时钟,模拟串口主机按 115200 波特率逐位发送两个测试字节,观察回环输出是否正确。
文件名:sim/uart_top_tb.v
verilog
`timescale 1ns / 1ps
// 仿真测试:模拟串口主机发送 0xA5 和 0x3C,验证回环收发
module uart_top_tb;
reg clk; // 时钟激励
reg rst_n; // 复位激励
reg rxd; // 模拟串口主机发送
wire txd; // 回环输出
wire [7:0] rx_data; // 接收数据
wire rx_done; // 接收完成
localparam BAUD = 115200;
localparam BIT_T = 8680; // 位时间 8680ns(约 115200bps)
// 50MHz 时钟,周期 20ns
initial clk = 1'b0;
always #10 clk = ~clk;
// 复位并初始化总线电平
initial begin
rst_n = 1'b0;
rxd = 1'b1; // 空闲时 RXD 高电平
#100;
rst_n = 1'b1;
end
// 按波特率逐位发送一个字节,LSB 先发
task send_byte(input [7:0] data);
integer i;
begin
rxd = 1'b0; // 起始位
#(BIT_T);
for (i = 0; i < 8; i = i + 1) begin
rxd = data[i]; // 依次发送 8 个数据位
#(BIT_T);
end
rxd = 1'b1; // 停止位
#(BIT_T);
end
endtask
// 例化被测模块
uart_top u_dut (
.clk (clk),
.rst_n (rst_n),
.rxd (rxd),
.txd (txd),
.tx_busy (),
.rx_data (rx_data),
.rx_done (rx_done)
);
// 发送两个测试字节后结束仿真
initial begin
#1000;
send_byte(8'hA5); // 测试字节1:1010_0101
#(BIT_T * 5);
send_byte(8'h3C); // 测试字节2:0011_1100
#(BIT_T * 15);
$display("rx_data = 0x%02X, rx_done = %b", rx_data, rx_done);
$finish;
end
endmodule
代码解读:send_byte 任务按真实串口时序逐位翻转 rxd,位时间用 8680ns 模拟 115200bps。$display 打印最终接收结果,若 rx_data 依次等于 0xA5 和 0x3C 说明收发链路正确。波形上重点看两处:起始位下降沿后 rx_done 是否在约 10 个位时间后出现,以及 txd 输出是否与 rxd 输入差一个完整帧的延迟。
第六步:板级回环验证
仿真通过后综合实现,生成比特流下载到 FPGA。PC 串口助手打开对应 COM 口,波特率选 115200、8N1,发送任意字节,串口助手应原样收到该字节。
验证方法:发送 0xA5,接收区出现 0xA5,连续发 100 个随机字节全部一致,说明收发链路稳定。若偶发错误,优先检查波特率误差------串口助手侧和 FPGA 侧都要确认实际波特率,PC 端 USB-UART 芯片的时钟误差一般小于 0.1%,FPGA 侧 50MHz 晶振误差通常也在 0.01% 量级,两边的误差不会累计到破坏采样点。
场景适配
不同波特率与时钟频率的适配
换波特率或系统时钟时只改参数,RTL 不用动。50MHz 时钟下常见组合:9600bps 分频系数 5208、115200bps 系数 434、921600bps 系数 54。分频系数低于 16 时要注意:接收端"半波特周期确认 + 中点采样"依赖 sample_cnt 的精度,系数太小时采样点会偏离中点,需要改用更高倍过采样方案。
| 时钟频率 | 波特率 | 分频系数 | 采样精度 |
|---|---|---|---|
| 50MHz | 9600 | 5208 | 高 |
| 50MHz | 115200 | 434 | 高 |
| 50MHz | 921600 | 54 | 中 |
| 100MHz | 115200 | 868 | 高 |
增加奇偶校验位
在帧格式中加入奇校验或偶校验,需要同步改发送和接收两端。发送端在数据位后插入校验位:奇校验时校验位使整个帧(数据位 + 校验位)中 1 的个数为奇数。接收端在停止位前采样校验位并检查,不一致时置错误标志。
verilog
// 发送端:数据位发完后插入校验位
// 奇校验:数据位中 1 的个数为奇数时校验位为 0
wire parity_bit = ^shift_reg; // 数据位异或,1 的个数为奇数时为 1
// 偶校验需要取反:wire parity_bit = ~^shift_reg;
// 状态机中在校验位停留一个波特周期再进入停止位
代码解读:异或归约(^)统计数据位中 1 的奇偶性。奇校验时校验位 = ^shift_reg,偶校验时取反。帧长度从 10 位变成 11 位,状态机要增加一个 PARITY 状态,接收端同样要增加对应采样逻辑。
多字节收发与 FIFO 缓冲
单字节回环只适合验证链路,实际应用(如打印调试日志)需要连续收发多字节。方案是在接收端挂一个异步 FIFO,rx_done 作为写使能,主逻辑空闲时从 FIFO 读数据。FIFO 用 Vivado 的 FIFO Generator IP 或 Xilinx 官方原语,避免自己写跨时钟域逻辑。
| 场景 | 接收端方案 | 发送端方案 |
|---|---|---|
| 单字节回环 | 直接输出 rx_data | 收到即回发 |
| 多字节日志 | 异步 FIFO 缓存 | 轮询 tx_busy 逐字节发送 |
| DMA 批量传输 | AXI-Stream FIFO | AXI-Stream 接口对接 |
性能优化
分频器实现方式对比
本文的计数器分频在 115200bps 下每波特周期产生一个脉冲,逻辑开销只有 16 位计数器,资源占用可忽略。若需要同时输出多个波特率(调试波特率可切换),可以改为查找表:把常用分频系数存成 ROM,按配置选通。
| 方案 | LUT 占用 | 灵活性 | 适用场景 |
|---|---|---|---|
| 计数器分频 | 约 20 LUT | 参数化,固定值 | 单波特率固定应用 |
| 查找表分频 | 约 50 LUT | 运行时可切换 | 多波特率调试口 |
| 过采样 PLL | 最低 | 依赖 PLL 资源 | 高速 UART 921600+ |
接收端过采样优化
本文方案在波特率较高时分频系数变小,采样点可能偏离中点。稳健做法是过采样:用系统时钟直接对 RXD 采样,每个数据位采 16 个点,用多数判决(连续采 3 次取 2 次一致)消除毛刺。代价是状态机逻辑翻倍,但抗干扰能力显著提升,适合工业现场长线传输。
资源占用与吞吐平衡
本文设计在 XC7A35T 上综合后资源占用:LUT 约 60 个、FF 约 80 个,占芯片资源不到 1%。单字节收发流程固定,吞吐上限由波特率决定,115200bps 时约 11.5KB/s。如果需要更高吞吐,直接提高波特率并配合 FIFO 批量传输,UART 本身不是瓶颈。
故障排查
故障一:仿真波形完全无输出
现象:Testbench 跑起来后 txd 一直保持高电平,rx_done 始终为 0。
原因:最常见的是复位未释放或复位极性错误。检查 Testbench 中 rst_n 是否在 100ns 后拉高;如果 rst_n 初始化为 1'b1 而模块是低有效复位,整个设计会一直停在复位态。
定位方法:在仿真波形里加 rst_n 和 clk 信号,确认复位释放时刻正确。再用 $monitor 打印状态机状态,确认 state 是否进入 START。
解决方案:复位激励改为 rst_n = 1'b0; #100; rst_n = 1'b1;,确保复位先拉低至少一个时钟周期再释放。
故障二:接收数据错位或完全错误
现象:能收到 rx_done 脉冲,但 rx_data 和发送字节不一致,或者所有字节都差一位。
原因:起始位确认逻辑和采样时刻没对齐。如果起始位确认后立即采第一个数据位,采到的是起始位末尾的跳变沿而非数据位中点。另一个常见错误是移位方向反了:发送端 LSB-first,接收端却按 MSB-first 组装,会导致整个字节位序颠倒。
定位方法:波形上展开 rxd、baud_tick、sample_cnt,数一下起始位下降沿到第一次数据采样的时钟周期数,正确值是半波特周期加一个波特周期,即约 1.5 个波特周期。
解决方案:确认 START 态在 clk_cnt 计到 HALF_DIV(半波特周期)后才进入 DATA,DATA 态在 clk_cnt 计到 BAUD_DIV(又过了一个波特周期,正好是数据位中点)才采样。移位方向统一为 shift_reg <= {rxd_d2, shift_reg[7:1]}。
故障三:板级回环偶发丢字节
现象:PC 串口助手发送大部分字节能正确回显,但偶发丢字节或回显乱码,多发生在快速连发时。
原因:回环模式下 rx_done 直接触发 tx_start,若上一帧还没发完(tx_busy=1)时下一帧接收完成,新数据会被覆盖或发送状态机漏触发。串口助手连发时字节间隔小于发送耗时,必然撞车。
定位方法:用 ILA 抓 rx_done、tx_busy 和 tx_start,观察是否有 rx_done 在 tx_busy=1 期间出现。没有 ILA 的话,串口助手改用手动逐字节发送,间隔 1 秒以上,如果不再丢字节基本可以确认是回环时序冲突。
解决方案:加发送缓冲,接收完成先存寄存器,待 tx_busy 拉低后再发起发送:
verilog
// 主控侧发送调度:等上一帧发完再启动下一帧
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
tx_pending <= 1'b0;
tx_start <= 1'b0;
end else begin
tx_start <= 1'b0; // 启动脉冲默认拉低
if (rx_done && !tx_busy) begin
tx_data_reg <= rx_data; // 接收完成且发送空闲
tx_start <= 1'b1; // 立即启动发送
tx_pending <= 1'b0;
end else if (rx_done) begin
tx_pending <= 1'b1; // 发送忙则挂起待发
end else if (tx_pending && !tx_busy) begin
tx_start <= 1'b1; // 空闲后补发挂起的字节
tx_pending <= 1'b0;
end
end
end
代码解读:tx_pending 标志记录是否有字节在等待发送,只有发送空闲时才允许新发送启动,从根上消除回环撞车。这个调度逻辑是后续所有多字节发送方案的基础。
故障四:时序违例导致综合失败
现象:综合或实现时出现 setup timing violation,时序报告显示路径延迟超标。
原因:外部 RXD 信号直接进状态机判断,没有约束输入延迟;或 rst_n 异步复位路径过长。默认情况下 Vivado 对纯 RTL 工程的时钟约束是 10ns(100MHz),而实际时钟是 20ns(50MHz),约束比实际严格不会报错,但输入引脚时序约束缺失会导致 I/O 路径无约束。
定位方法:打开 Implemented Design 的 Timing Summary,看 WNS(最差负余量)是否为负。RTL 内部逻辑在 50MHz 下几乎不可能违例,违例通常集中在 I/O 路径。
解决方案:先添加时钟约束 create_clock -period 20 [get_ports clk],再对 rxd 输入添加 set_input_delay -clock clk 5 [get_ports rxd],让 Vivado 正确分析输入时序。RTL 内部若仍违例,检查组合逻辑链中是否有过长的级联判断。
总结
核心要点
- UART 帧 = 起始位 + 8 数据位(LSB 先发)+ 停止位,空闲时总线保持高电平,接收端靠下降沿启动
- 波特率生成器本质是分频计数器,50MHz/115200 时系数 434,参数化设计可随时切换
- 接收端必须做两级同步消除亚稳态,并用半波特周期确认 + 中点采样保证采样点落在数据位中间
- 发送接收各自独立状态机,由 baud_tick 统一节拍,状态迁移严格对齐波特周期
- 回环模式(收即发)是验证串口链路最快的手段,多字节场景必须加发送调度避免撞车
适用边界与扩展方向
本方案适用于单字节或低速率串口调试场景,波特率上限约 921600。需要高吞吐连续传输时建议改用 AXI-Stream + FIFO 架构;需要多串口时可以把收发模块参数化后例化多份。后续可扩展方向:增加奇偶校验和帧错误标志、把接收端升级为 16 倍过采样提升抗干扰能力、用 AXI-Lite 总线封装成标准外设接入 MicroBlaze 软核。