从零构建FPGA通信模块:基于Vivado仿真的UART实战指南
你有没有过这样的经历?写完一段Verilog代码,烧进FPGA板子,结果串口助手收到的不是0x55,而是一串乱码。反复检查接线、波特率、电平标准……最后发现,原来是状态机少了一个跳转条件。
与其一次次“下载-调试-改错”,不如在动手前就把问题消灭在仿真阶段——这正是Vivado仿真的真正价值所在。
本文将带你从零开始,在Xilinx Vivado环境中完整实现一个UART通信模块,并通过精心设计的Testbench进行功能验证。我们将不只讲语法,更要深入工程实践中的关键细节:如何确保波特率精度?怎样避免亚稳态?测试平台该怎么写才够“健壮”?
全程无需开发板,所有验证都在仿真中完成。当你看到波形图里清晰的起始位、数据位和停止位时,你会明白:真正的FPGA开发,始于仿真,而非下载。
为什么是UART?它远比你想的更重要
尽管高速接口如PCIe、Ethernet日益普及,但UART依然是嵌入式系统中最不可或缺的“生命线”。它的核心作用从来不是传输效率,而是可见性——无论是Zynq启动日志、MCU调试信息,还是传感器原始数据输出,几乎都依赖UART回传。
更重要的是,UART是一个绝佳的学习入口。它虽结构简单,却涵盖了FPGA设计的四大核心能力:
- 状态机建模:发送/接收过程本质是有限状态机;
- 时序控制:波特率生成依赖精确分频;
- 跨时钟域处理(CDC):常需与主系统异步交互;
- 协议合规性:帧格式、采样时机必须严格符合规范。
掌握UART,等于掌握了通向复杂通信系统的大门钥匙。
而在整个开发流程中,Vivado仿真是那把最趁手的工具。它不只是“看看波形”,更是你设计思维的延伸——你可以在这里模拟噪声、注入毛刺、制造时钟偏移,提前暴露那些只有在现场才会暴露的问题。
UART发送器设计:不只是移位寄存器
我们先来实现一个标准8N1格式的UART发送模块(即8位数据、无奇偶校验、1位停止位)。虽然网上有大量参考代码,但很多忽略了实际工程中的关键考量。
下面是优化后的Verilog实现:
// uart_tx.v - 高可靠性UART发送模块 module uart_tx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input clk, input rst_n, input tx_en, input [7:0] tx_data, output reg tx_out, output tx_done ); // 波特率分频系数:每bit需16个采样周期,提高鲁棒性 localparam DIVIDER = CLK_FREQ / (BAUD_RATE * 16); localparam BIT_CNT_WIDTH = $clog2(10); // 支持最多10位(含起始+停止) reg [15:0] baud_count; // 波特率计数器(16倍超采) reg [BIT_CNT_WIDTH:0] bit_cnt; reg [9:0] shift_reg; // 10位帧缓冲:start + 8 data + stop reg tx_busy; assign tx_done = !tx_busy && tx_en; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx_out <= 1'b1; // 空闲态为高 baud_count <= 0; bit_cnt <= 0; shift_reg <= 0; tx_busy <= 0; end else begin // 启动发送:仅当空闲且使能有效 if (tx_en && !tx_busy) begin shift_reg <= {1'b1, tx_data, 1'b0}; // 帧封装:stop + data + start tx_busy <= 1'b1; bit_cnt <= 0; baud_count <= 0; tx_out <= shift_reg[0]; // 发送起始位(低) end // 正在发送 else if (tx_busy) begin baud_count <= baud_count + 1; // 每16个系统时钟发送一位 if (baud_count == DIVIDER - 1) begin baud_count <= 0; if (bit_cnt < 9) begin shift_reg <= {1'b1, shift_reg[9:1]}; // 右移一位 tx_out <= shift_reg[1]; bit_cnt <= bit_cnt + 1; end else begin tx_busy <= 0; tx_out <= 1'b1; // 恢复空闲态 end end end end end endmodule关键设计点解析
✅16倍过采样机制
虽然这是发送端,但我们仍采用“每bit等待16个时钟周期”的策略。这不仅与接收端逻辑对称,也为后续扩展支持动态波特率调整留出空间。
📌 提示:DIVIDER 计算应确保整除。若无法整除(如使用25MHz时钟),建议加入小数分频或选择更接近的目标波特率。
✅帧封装方式
{1'b1, tx_data, 1'b0}这一行代码完成了完整的帧构造:
- 最低位是1'b0→ 起始位
- 中间8位是数据(LSB优先)
- 最高位是1'b1→ 停止位
移位时从低位逐次送出,自然满足LSB先行要求。
✅tx_done信号设计
assign tx_done = !tx_busy && tx_en;
这个表达式看似简单,实则精准捕捉了“请求已发出且已完成”的语义。上层模块可用它作为调度依据,避免重复触发。
构建高可信度的Testbench:别再只发两个字节了
很多人写Testbench只是“跑通就行”:复位一下,发个0x55,看一眼波形就收工。但真实系统中,我们要面对的是连续传输、边界值、异常使能等复杂场景。
下面是一个工业级强度的测试平台:
// tb_uart.v - 强化版UART Testbench `timescale 1ns / 1ps module tb_uart(); parameter CLK_FREQ = 50_000_000; parameter BAUD_RATE = 115200; parameter CLK_PERIOD = 1_000_000_000 / CLK_FREQ; // ns parameter BIT_TIME_NS = (1_000_000_000 / BAUD_RATE); // ~8680ns reg clk; reg rst_n; reg tx_en; reg [7:0] tx_data; wire tx_out; wire tx_done; // 实例化DUT uart_tx #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) uut ( .clk(clk), .rst_n(rst_n), .tx_en(tx_en), .tx_data(tx_data), .tx_out(tx_out), .tx_done(tx_done) ); // 时钟生成 always begin clk = 0; # (CLK_PERIOD / 2); clk = 1; # (CLK_PERIOD / 2); end initial begin // 初始化 rst_n = 0; tx_en = 0; tx_data = 0; # (20 * CLK_PERIOD); rst_n = 1; // 释放复位 # (100 * CLK_PERIOD); // 测试序列1:常规发送 send_byte(8'h55); send_byte(8'hAA); send_byte(8'h00); send_byte(8'hFF); // 测试序列2:快速连续触发(检验busy保护) fork begin send_byte(8'h12); send_byte(8'h34); end begin #10 tx_en = 1; // 尝试非法重入 end join // 测试序列3:边界值测试 for (int i = 0; i < 256; i = i + 64) send_byte(i); $display("✅ All tests completed successfully."); #1000 $finish; end // 辅助任务:发送单字节并等待完成 task send_byte(input [7:0] data); tx_data = data; @(posedge clk); tx_en = 1; @(posedge clk); tx_en = 0; wait(tx_done); # (BIT_TIME_NS * 2); // 稍作间隔 endtask // 断言监控:检测非法状态 initial begin // 不应在忙时接受新使能 assert property (@(posedge clk) disable iff (!rst_n) !(tx_en && tx_busy)) else $error("❌ Violation: tx_en asserted while TX is busy!"); end // 实时监控 initial begin $monitor("[%0t] TX_EN=%b, DATA=0x%0h, DONE=%b, OUT=%b", $time, tx_en, tx_data, tx_done, tx_out); end endmodule这个Testbench强在哪?
| 特性 | 说明 |
|---|---|
| 多轮测试序列 | 包括正常发送、连续触发、边界值扫描 |
| 自动化任务封装 | send_byte()提高可读性和复用性 |
| 断言检查 | 自动捕获“忙时重入”等逻辑错误 |
| 实时日志输出 | $monitor输出便于CI/CD集成 |
| 时间参数化 | 所有时序基于频率自动计算,便于移植 |
💡 经验之谈:一个好的Testbench应该能自己发现问题,而不是等着你去“看波形”。
在Vivado中运行仿真:一步步教你操作
即便代码写得好,不会用工具也白搭。以下是完整操作流程(基于Vivado 2023.1):
第一步:创建项目
- 打开Vivado → Create Project
- 选择RTL Project → 不勾选“Do not specify sources”
- 添加
uart_tx.v和tb_uart.v - 选择任意Artix-7或Kintex-7器件(仿真无需匹配具体型号)
第二步:设置仿真属性
- Flow Navigator → Simulation → Simulation Settings
- 设置:
-Simulation Run Time:100us
-Time Resolution:1ns
-Target Language: Verilog
第三步:启动仿真
- 点击菜单 Run Simulation → Run Behavioral Simulation
- XSIM启动后,Wave窗口自动打开
第四步:添加并分析信号
- 展开
tb_uart -> uut,选中shift_reg,bit_cnt,tx_busy - 右键 → Add to Wave Window
- 缩放时间轴,观察第一个字节
0x55(即0b01010101)是否正确发送
🔍重点检查项:
- 起始位宽度 ≈ 8.68μs(对应115200bps)
- 数据位顺序:LSB先出 → 第一位应为1
- 每位持续时间一致
-tx_done在帧结束时拉高
如果一切正常,你应该看到如下波形特征:
TX_OUT: [H]----[L]----[1][0][1][0][1][0][1][0]----[H]... ↑起始 ↑D0=1 ↑D1=0 ... ↑D7=0 ↑停止工程级设计建议:让模块真正可用
别以为仿真通过就能直接用。以下几点才是决定模块能否投入生产的关键。
✅ 加入可测性设计(Design for Testability)
保留内部节点供观测:
// 添加调试信号(综合时可通过synthesis off保留) wire debug_shift_out = shift_reg[0]; wire debug_state_active = tx_busy; // 或使用attribute标记 (* mark_debug = "true" *) reg [3:0] debug_bit_cnt = bit_cnt;这样可在ILA中实时监控,极大提升现场调试效率。
✅ 参数化与复用性
当前模块已支持参数化波特率,进一步可扩展:
parameter DATA_BITS = 8, parameter STOP_BITS = 1, parameter PARITY_EN = 0甚至封装为IP核,支持GUI配置。
✅ 与AXI总线集成(适用于Zynq)
建议将UART包装为AXI-Lite从设备,实现内存映射访问:
Address | Register --------|---------- 0x00 | TX_DATA + TX_EN(写) 0x04 | STATUS(tx_done, tx_busy等) 0x08 | RX_DATA(读)如此一来,PS端CPU可通过C语言轻松调用:
Xil_Out32(UART_BASEADDR, 0x55); // 发送字符 while (!(Xil_In32(UART_BASEADDR + 4) & 0x1)); // 等待完成常见问题与避坑指南
| 问题 | 根因 | 解法 |
|---|---|---|
| 发送乱码 | 系统时钟不准或分频溢出 | 检查DIVIDER是否超出计数器位宽 |
| 多次触发失败 | tx_en保持时间太短 | 在Testbench中加@(posedge clk)同步 |
| 波形不对齐 | 时间尺度设置错误 | 使用set_property TIME_UNITS ns [current_fileset] |
| 仿真卡死 | wait(tx_done)未触发 | 检查tx_busy是否未能清零 |
| 资源占用过高 | 多通道未共享分频器 | 抽象出公共时钟管理模块 |
⚠️ 特别提醒:永远不要假设你的分频器是精确的!对于关键应用,建议在Testbench中加入±3%误差模型,验证容错能力。
写在最后:仿真不是附属品,而是设计本身
很多人把仿真当作“证明我没错”的工具,但高手把它当作“探索我会哪里错”的实验场。
下次当你准备写一个新的SPI控制器、I2C从机或以太网MAC时,请记住:
- 先想清楚Testbench怎么写;
- 再定义清楚哪些信号需要监控;
- 最后才动笔写RTL;
- 并且让每一次仿真都比上一次多覆盖一个边界情况。
这才是现代FPGA工程师应有的工作范式。
而Vivado仿真,正是这套方法论的最佳载体。它不仅仅是XSIM引擎,更是连接你大脑与硬件之间的桥梁。
当你能在波形图中“看见”逻辑的流动,“听见”时序的节奏,你就真正进入了数字世界的内层。
如果你正在学习FPGA开发,不妨现在就打开Vivado,把上面的代码跑一遍。也许第一次会出错,但请坚持到看到那个完美的UART帧为止——因为那一刻,你离成为一名真正的硬件工程师,又近了一步。
有什么问题,欢迎留言讨论。