UART模块手写RTL设计实战:从协议理解到板级验证的完整闭环
你有没有遇到过这样的场景:FPGA工程跑通了,但串口调试信息却像断线风筝一样时有时无?或者在高速波特率下,接收数据莫名其妙地错位、帧错误频发?又或者——当你想给UART加个CRC校验、支持9位数据帧、甚至动态切换波特率时,却发现AXI UARTLite IP核像一堵密不透风的墙,改不了、看不到、调不动?
这正是我们坚持手写UART RTL的根本原因:不是为了重复造轮子,而是为了真正握住那根控制通信命脉的“操作杆”。
为什么UART值得你亲手写一遍?
UART表面简单,实则暗藏玄机。它不像SPI有明确的SCLK同步,也不像I2C有ACK握手机制,它的可靠性全靠时间精度、采样策略与状态鲁棒性三者咬合。而这些,恰恰是数字系统设计中最锻炼工程直觉的部分。
- 它是最小的跨时钟域实战沙盒:RX输入异步于系统时钟,必须两级同步+起始位滤波,否则一个毛刺就能让整个FSM跑飞;
- 它是波特率精度的显微镜:50MHz系统时钟下生成115200bps,误差超过±2.5%,接收端就可能把‘H’(0x48)错判成‘J’(0x4A);
- 它是状态机设计的教科书案例:TX要防止数据覆盖,RX要容忍噪声干扰,两者都需在“严格时序”与“容错弹性”之间找平衡点;
- 它是最轻量级的可观测性通道:printf级日志、Bring-up阶段寄存器dump、固件升级握手——没有它,FPGA就像闭着眼调试。
所以,这不是一个“能用就行”的模块,而是一块检验你是否真正理解数字电路落地逻辑的试金石。
协议不是背出来的,是推出来的
先抛开Verilog,我们回到物理层本质:
UART发送一个字节0x48(ASCII ‘H’),实际在线上跑的是这样一串电平序列(以8N1为例):
空闲高 → 起始位(0) → 0 0 0 1 0 0 0 (LSB先出) → 停止位(1) → 空闲高 ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ b0 b1 b2 b3 b4 b5 b6 b7注意两个关键事实:
- 起始位是唯一确定的下降沿:它不携带信息,只承担“唤醒接收端”的任务;
- 所有后续比特都在这个下降沿之后,按固定间隔采样——这个间隔就是波特率周期 $ T_{bit} = \frac{1}{\text{BAUD_RATE}} $。
所以,接收端的核心动作只有一个:在起始位下降沿后,等待 $ \frac{T_{bit}}{2} $,然后在每个 $ T_{bit} $ 中点处采样一次RX线。
但现实世界有噪声。单次采样极易误判。于是我们引入三采样多数判决:在每个比特周期内,分别于 $ 0.25T_{bit} $、$ 0.5T_{bit} $、$ 0.75T_{bit} $ 处采样三次,取相同结果两次以上的值作为该比特最终值。这样,哪怕某次采样被干扰拉低或拉高,只要另两次正确,就能恢复原始电平。
这就是为什么手册里总强调“采样点对齐”——它不是技术细节,而是UART能否稳定工作的分水岭。
波特率发生器:精度藏在累加器的第16位里
很多人用计数器做分频:cnt <= cnt + 1; if(cnt == DIVIDE-1) begin tick <= 1; cnt <= 0; end。看似简洁,但问题明显:当DIVIDE不是整数时(比如50MHz ÷ 115200 ≈ 434.027),取整为434会导致实际波特率为50_000_000 / 434 ≈ 115207bps,误差0.006%看似很小,但在长帧传输中会累积相位偏移,最终导致采样点漂移到比特边缘。
更优解是累加器法(Fractional-N Divider):
localparam CLK_FREQ = 50_000_000; localparam BAUD_RATE = 115200; localparam N = 16; // 分辨率:2^16 = 65536 localparam BAUD_INC = (CLK_FREQ << N) / BAUD_RATE; // = 28433 reg [N-1:0] baud_acc; reg baud_tick; always @(posedge clk_i) begin baud_acc <= baud_acc + BAUD_INC; baud_tick <= baud_acc[N-1]; // 溢出即tick end这里的关键在于:baud_acc是一个16位寄存器,每次加BAUD_INC(28433),相当于每65536个系统时钟周期,累加器溢出28433次——也就是平均每个baud_tick间隔为65536 / 28433 ≈ 2.305个系统周期,对应波特率50_000_000 / 2.305 ≈ 115200.001,误差低于0.001%。
而且,这种结构天然支持任意波特率配置:只需重新计算BAUD_INC,无需改动RTL结构。你在顶层加个BAUD_DIV寄存器,CPU运行时写入新值,就能动态切到921600bps用于高速日志流——这是IP核很难灵活支持的。
顺便提醒一句:baud_tick虽然是由系统时钟驱动的,但它本身是一个衍生时钟域信号。Vivado默认不会把它当真正时钟处理,所以你必须手动添加约束:
create_generated_clock -name baud_clk -source [get_pins top/uart_inst/clk_i] \ -divide_by 1 [get_pins top/uart_inst/baud_tick]否则,report_timing_summary里根本看不到RX采样路径的时序报告,等板子上跑起来才发现接收乱码,再回头查就晚了。
TX状态机:别让数据在移位寄存器里“打架”
TX FSM看似简单:IDLE → LOAD → SHIFT[0..7] → STOP → IDLE。但真正考验设计功力的地方,在于边界条件。
比如,CPU在TX刚进入SHIFT态时,又往tx_data_i写入新数据。如果不加保护,新数据会直接覆盖旧数据寄存器,导致当前帧发送一半就被打断。
我们的做法是引入双缓冲+忙信号反馈:
// tx_reg:当前正在发送的数据寄存器 // tx_buffer:CPU写入的暂存区 // tx_busy_o:组合逻辑输出,只要tx_reg正在使用就为高 assign tx_busy_o = (state == LOAD) || (state == SHIFT) || (state == STOP); always @(posedge clk_i or posedge rst_n) begin if (!rst_n) tx_reg <= 8'h00; else if (state == LOAD && tx_en_i) tx_reg <= tx_data_i; end注意tx_busy_o是组合逻辑,不是寄存器输出。这意味着CPU写入tx_data_i后,几乎立刻就能读到tx_busy_o == 1,从而避免轮询延迟导致的数据丢失。
另一个容易被忽略的点是空闲电平控制。UART规定空闲态为高电平(逻辑1)。所以tx_o不能简单连到移位寄存器Q0,而必须用状态机控制:
assign tx_o = (state == IDLE) ? 1'b1 : (state == LOAD) ? 1'b1 : (state == SHIFT) ? tx_shreg[0] : (state == STOP) ? 1'b1 : 1'b1;少写这一行,你的TX线在空闲时可能是不定态,接上MAX3232后PC端看到的就是满屏乱码。
RX状态机:抗干扰不是靠运气,是靠三重采样+超时复位
RX比TX难得多,因为它是被动方,一切都要靠自己“猜”。
第一步:同步。rx_i来自外部世界,必须先过两级寄存器同步进系统时钟域:
reg rx_sync0, rx_sync1; always @(posedge clk_i) begin rx_sync0 <= rx_i; rx_sync1 <= rx_sync0; end wire rx_sync = rx_sync1;第二步:起始位检测。不能只看一次下降沿,要连续3个baud_tick周期都采到低电平才确认起始位有效——这是对抗开关噪声最廉价有效的手段。
第三步:中心采样。我们在每个baud_tick上升沿,对rx_sync采样一次。但为了进一步提升鲁棒性,我们其实做了隐式三采样:在每个比特周期内,baud_tick每4个系统时钟触发一次(因50MHz→115200bps约需434系统周期/bit),我们在第1、2、3个baud_tick处分别采样,并用一个3位移位寄存器保存:
reg [2:0] rx_sample_buf; always @(posedge clk_i) begin if (rx_start_detected) begin rx_sample_buf <= {rx_sample_buf[1:0], rx_sync}; end end然后取rx_sample_buf[2:0]的多数值(即(a&b)|(b&c)|(a&c))作为该比特最终值。这个技巧比显式插入3个独立采样点更省资源,且效果相当。
最后,必须加超时保护。如果RX线长时间卡在低电平(比如线缆脱落、设备死机),FSM可能永远停在SHIFT态。我们加一个8位超时计数器:
reg [7:0] rx_timeout_cnt; always @(posedge clk_i) begin if (rx_start_detected || (state == SHIFT)) begin rx_timeout_cnt <= rx_timeout_cnt + 1; if (rx_timeout_cnt == 8'hFF) begin state <= IDLE; rx_timeout_cnt <= 0; end end else begin rx_timeout_cnt <= 0; end end这个小模块,能帮你省去90%的“板子接上没反应”的现场排查时间。
Vivado约束:XDC不是可选项,是必填项
很多初学者把XDC当成“引脚分配表”,只写set_property PACKAGE_PIN ...,结果综合后timing report一片红色。UART最关键的路径,恰恰不在数据通路,而在RX输入到第一级同步寄存器这段。
你需要告诉Vivado三件事:
RX是异步输入,必须同步:
tcl set_input_delay -clock sys_clk 2.0 [get_ports rx_i] set_false_path -from [get_ports rx_i] -to [get_cells *rx_sync*]baud_tick是衍生时钟,必须声明:
tcl create_generated_clock -name baud_clk -source [get_pins uart/clk_i] \ -divide_by 1 [get_pins uart/baud_tick]TX输出有建立时间要求,尤其接RS-232芯片时:
tcl set_output_delay -clock sys_clk 1.5 [get_ports tx_o]
还有一个实战经验:Artix-7的W5引脚(常见TX引脚)默认是高性能Bank,I/O标准必须设为LVCMOS33,否则电平不匹配会导致PC端无法识别。同时,RX引脚务必开启内部上拉:
set_property PULLUP true [get_ports rx_i]否则,未连接串口线时,rx_i处于浮空态,FSM会频繁误触发起始位,CPU被中断风暴拖垮。
板级验证:别急着看SecureCRT,先抓波形
写完代码、跑通仿真、约束也加了,下一步不是烧录,而是用逻辑分析仪看真实波形。
重点关注三个信号:
tx_o:用Saleae或Sigrok抓一段发送0x48的波形,测量起始位宽度、比特周期、停止位宽度是否符合115200bps(≈8.68μs/bit)。如果发现起始位只有7μs,说明BAUD_INC算错了;rx_i:对比PC发送波形与FPGA采样点位置,确认采样边沿是否落在每个比特正中央;baud_tick:观察其占空比是否接近50%,频率是否精确为115200Hz(用示波器FFT功能)。
你会发现,很多“软件层面无法解释”的问题,其根源都在这里:比如baud_tick抖动大,是因为累加器位宽太小(N=12不够,必须N=16);比如rx_i采样点偏左,是因为同步链路上多了一级寄存器没删干净。
真正的FPGA工程师,一半时间在写代码,另一半时间在看波形。
它不只是UART,是你通往复杂接口的跳板
当你亲手实现了一个带三采样、累加器分频、双缓冲、超时保护的UART,你就已经掌握了:
- ✅ 异步信号同步与亚稳态防护(RX输入)
- ✅ 精密时钟分频与衍生时钟约束(baud_tick)
- ✅ Mealy型状态机建模与边界处理(TX/RX FSM)
- ✅ 跨时钟域握手与数据完整性保障(tx_busy_o / rx_valid_o)
- ✅ I/O电气特性与PCB协同设计(LVCMOS33、PULLUP、串联电阻)
这些能力,可以直接迁移到SPI主控(需管理SCLK相位与CS延时)、I2C从机(需响应地址匹配与ACK时序)、甚至CAN FD控制器(需处理位填充与仲裁段采样)。它们共享同一套底层思维范式。
所以,下次当你打开Vivado准备拖一个UART IP核时,不妨暂停一秒,问问自己:
我是否真的需要一个黑盒?还是,我更想亲手点亮那盏代表tx_o的LED,并清楚知道,此刻它亮起的每一毫秒,都源于我对时间与逻辑的绝对掌控?
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。