FPGA超稳串口收发模块设计:Verilog代码、过采样与亚稳态处理全解析
写这个串口模块最初是因为调试一块需要和 PC 通信的板子手上的「通用收发模块」总是带着点脾气——要么偶尔丢字节要么在连续传输时莫名错位排查到最后往往是采样时机差了一点点或者状态机被干扰信号带飞了。后来痛定思痛重新整理了一套 Verilog 串口收发方案经过数字仿真、板级跑机和长时间连续收发考验基本做到了「插上就能用、连续跑也不会翻车」。这篇博文就把这套方案的设计思路、关键代码和避坑经验完整拆出来给同样需要稳定串口收发的开发者一个可直接抄作业的参考。文章里有注释的 RTL 代码、Testbench 示例、参数计算方法还有我从踩坑中总结的排查清单。不管你是刚入门 FPGA、打算把串口收发当作练手项目还是已经在工程中反复被数据错乱折磨的老手这套实现和思路都应该能帮上忙。1. 为什么需要一个“超稳”的串口模块1.1 常见串口收发模块的翻车现场串口模块看起来简单无非是 UART 协议起始位、数据位、校验位、停止位再加上波特率时钟。但真正写过、调过的人都知道串口是「简单协议复杂打架」的重灾区。我见过不少模块单独仿真空载收发没问题一接到真实工程就立刻现形。最常见的翻车现场有三个。第一是起始位误判高速异步信号闯进来一个毛刺状态机直接当作起始位开始采样后面的数据当然全错。第二是采样点偏移分频计数器没有跟数据位对齐采样落在位数据的边缘地带电平刚好跳变采到 0 还是 1 就看运气了。第三是跨时钟域处理马虎外部数据直接拿进 FPGA 内部时钟域亚稳态没有消除整天产生些幽灵数据。这几个问题单看都不难解但堆叠在一起再叠加不同板卡、不同波特率、不同线缆长度就成了让人抓狂的玄学问题。所谓「超稳」就是要把这些坑提前填平。1.2 设计方案时要考虑的硬指标在设计这个串口模块前我给自己定了几项硬指标所有代码和测试都围绕它们展开波特率误差不超过 2%实际留出更低的余量。接收端采用过采样保证在采样窗口内能拿到稳定的电平。支持连续收发不丢数接收侧必须有缓冲能力。异步输入信号必须经过同步处理消除亚稳态。这几个指标排开了之后模块的骨架基本就确定了过采样 状态机 FIFO 缓冲。下面逐个拆开讲。2. 核心模块设计与实现细节2.1 波特率发生器的参数计算波特率发生器是整个串口收发的地基。它本质是一个计数器把系统时钟分频成接近目标波特率的节拍。关键是分频值怎么算以及为什么这么算。假设系统时钟clk_freq为 50 MHz目标波特率baud为 115200。过采样倍数我习惯用 16 倍也就是每个数据位采样 16 次。这样分频计数器的目标值可以用公式计算div_cnt clk_freq / (baud * oversampling) - 1代入数值50000000 / (115200 * 16) 27.126...取整为 27计数器从 0 数到 27周期就是 28。此时实际波特率是实际波特率 50000000 / (28 * 16) 111607和 115200 的误差约 3.1%这个误差对 UART 来说偏大了。关键在于整数分频舍弃了小数部分导致误差积累。要让误差降下来有两个方向一是把系统时钟选成波特率的整数倍二是用小数分频器。对于常见场景我建议直接选一个合适的系统时钟或者使用带小数分频的计数器。实际工程中更稳妥也更好用的做法是直接用 FPGA 厂商提供的时钟 IP 生成一个114.24 MHz之类的时钟它能被 115200 整除。我这里给出一个通用参数化写法供你按需调整parameter CLK_FREQ 50_000_000; parameter BAUD_RATE 115200; parameter OVERSAMPLE 16; localparam DIV_MAX (CLK_FREQ / (BAUD_RATE * OVERSAMPLE)) - 1;这样模块的适用范围就宽了换时钟频率和波特率只需要改参数不用动状态机逻辑。2.2 接收端状态机设计接收端是串口模块里最容易出问题的地方因为数据是异步进来的你不知道什么时候会有一个下降沿代表起始位。状态机的设计目标只有一个在噪声环境中准确找到每一位的中点并采样。我采用 16 倍过采样每个数据位内部会有 16 个采样节拍。起始位判断要特别谨慎当检测到 RX 引脚出现下降沿时不能立刻认定是有效起始位而是要连续采样几次确认电平确实被拉低并保持住。这相当于给起始位做了一个数字滤波能把几纳秒到几十纳秒的窄毛刺滤掉。一位一位地说接收状态机可以这样写代码做了精简但核心逻辑完整// 简化版示例16倍过采样接收状态机 localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; reg [2:0] state; reg [3:0] sample_cnt; // 0~15 采样计数器 reg [2:0] bit_cnt; // 记录已接收的数据位个数 reg [7:0] rx_data; // 接收移位寄存器 always (posedge clk) begin if (!rst_n) begin state IDLE; rx_data 8d0; end else begin case (state) IDLE: begin // 检测下降沿后进入 START 状态但需要连续采样确认 if (rx_reg_deglitch_off 1b0 rx_deglitch_on 1b0) begin state START; sample_cnt 4d0; end end START: begin // 在起始位中点附近再次确认电平为低 if (sample_cnt 4d8) begin if (rx_synced 1b0) state DATA; else state IDLE; sample_cnt 4d0; end else begin sample_cnt sample_cnt 1b1; end end DATA: begin // 采样点定在每个 bit 的中间附近 if (sample_cnt 4d8) begin rx_data {rx_synced, rx_data[7:1]}; sample_cnt 4d0; if (bit_cnt 3d7) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin sample_cnt sample_cnt 1b1; end end STOP: begin // 停止位采样后回归 IDLE if (sample_cnt 4d8) begin state IDLE; rx_data_valid 1b1; end else begin sample_cnt sample_cnt 1b1; end end endcase end end这段代码里我额外引入了一个细节IDLE 状态下不是直接看原始 RX 引脚而是看经过两级同步器和去毛刺处理后的信号rx_synced、rx_deglitch_on。这部分逻辑在后面跨时钟域处理里会专门展开。2.3 发送端设计与 FIFO 接口发送端比接收端简单因为它完全由本地时钟驱动只要按照波特率节拍把移位寄存器里的数据逐位送出去就行。但「简单」不等于「没有坑」。发送模块最容易出现的问题是上层数据还没准备好发送端就急着把移位寄存器里的旧数据发出去了造成数据错位。所以我的发送端并不是一个孤立的移位发送器而是一个带握手接口的模块。发送端空闲时会主动向外发出tx_busy信号上层逻辑看到tx_busy为低就可以把 8 位数据放到总线上再拉高tx_en一个周期。发送端收到tx_en之后锁存数据并启动发送。发送状态机核心结构reg [3:0] tx_bit_cnt; reg [7:0] tx_shift; reg tx_busy; always (posedge clk) begin if (!rst_n) begin tx_busy 1b0; tx_shift 8d0; txd_out 1b1; // 空闲时 TX 为高 end else begin if (tx_en !tx_busy) begin tx_busy 1b1; tx_shift tx_data; tx_bit_cnt 4d0; txd_out 1b0; // 起始位 end else if (tx_busy) begin // 按波特率节拍移位 if (baud_pulse) begin if (tx_bit_cnt 4d7) begin txd_out tx_shift[0]; tx_shift {1b0, tx_shift[7:1]}; tx_bit_cnt tx_bit_cnt 1b1; end else if (tx_bit_cnt 4d8) begin txd_out 1b1; // 停止位 tx_bit_cnt tx_bit_cnt 1b1; end else begin tx_busy 1b0; tx_bit_cnt 4d0; end end end end end发送端后面接 FIFO 的思路也很直接外部数据先写入 FIFO发送模块从 FIFO 读出数据并逐字节发送。这样即使发送速度较慢上层模块也不用时刻等待只要 FIFO 不满就可以连续批量写入数据。这个缓冲层在 PC 通过串口下发数据、FPGA 回传状态时尤其重要。3. 怎么保证“稳”跨时钟域、复位与仿真3.1 亚稳态处理亚稳态是整个同步系统里最隐蔽、最致命的杀手。串口接收的 RX 引脚相对 FPGA 内部时钟是异步信号电平变化完全不受本地时钟控制。如果我们直接把这个异步信号拿去做边沿检测、状态跳变一旦信号在时钟上升沿附近变化触发器输出就可能进入亚稳态——输出电平在 0 和 1 之间来回抖动甚至传导到后续逻辑里造成整片逻辑错乱。解决方案是两层保险。第一层是两级同步器。把 RX 信号用两个串联的触发器各打一拍让亚稳态在第一级输出时开始收敛第二级大概率能采到稳定电平。第二层是去毛刺也就是在起始位判断前连采几次确认信号是稳定拉低。刚才代码里的rx_reg_deglitch_off和rx_deglitch_on就是去毛刺后的信号。两级同步器的 RTL 写法非常简单但很多人都容易漏掉。我在这个模块里单独拆出一个子模块module sync_2stage #(parameter WIDTH 1) ( input wire clk, input wire rst_n, input wire [WIDTH-1:0] din, output reg [WIDTH-1:0] dout ); reg [WIDTH-1:0] mid; always (posedge clk or negedge rst_n) begin if (!rst_n) begin mid {WIDTH{1b0}}; dout {WIDTH{1b0}}; end else begin mid din; dout mid; end end endmodule接收端实例化这个同步器后所有状态机判定都用rx_synced而不是原始引脚。这一点做对了很多诡异的乱码问题会从根上消失。3.2 信号完整性与板级实践代码层面稳了还得看板上表现。串口在低速场景下信号完整性问题不重但一旦线缆过长、电平标准不匹配或者地线处理不好照样会出现间歇性误码。几个我实测有效的板级习惯串口线尽量短试过 20cm 和 2m 的线误码率完全是两个量级。如果板卡之间电平不一致务必加电平转换芯片不要盲目直连。TTL 电平和高电平推挽之间的差别在长线上很容易变成方波变形。接收端引脚附近放一个 100 nF 去耦电容可以在一定程度上缓解电源毛刺对 RX 的干扰。如果板上还有其他高速信号串口线和它们保持距离避免耦合噪声。这一部分看起来不像Verilog正题但“超稳”往往是板级习惯堆出来的。我见过代码没毛病但在某块板上疯狂误码的案例最后换了一根带屏蔽的串口线就全好了。3.3 仿真验证的 Testbench 写法与覆盖率写 RTL 不仿真就上板等于裸奔。串口收发模块尤其适合仿真因为协议是标准的激励很容易构造。我的 Testbench 思路是构造一个模拟 UART 发送源按照波特率节拍生成完整帧然后送入接收模块检查接收模块输出的数据和原始数据是否一致。同时还要验证发送方向——给发送模块送入随机数据用模拟接收端去采集 TX 引脚并解析成字节逐字节比对。一个像样的串口收发 Testbench 大概长这样timescale 1ns/1ps module uart_loopback_tb; reg clk 0; reg rst_n 0; reg uart_rx 1; wire uart_tx; reg [7:0] send_data; reg send_en; // DUT 实例化省略 always #10 clk ~clk; // 50MHz task uart_send_byte(input [7:0] data); begin // 起始位 uart_rx 0; #8680; // 115200 每一位约 8.68us // 8个数据位LSB first for (integer i 0; i 8; i i 1) begin uart_rx data[i]; #8680; end // 停止位 uart_rx 1; #8680; end endtask initial begin rst_n 0; #500 rst_n 1; // 发送一个 0x55 uart_send_byte(8h55); // 等待接收完成 #100; end endmodule我还会在仿真里故意向 RX 注入毛刺比如在 IDLE 状态塞一个#100低脉冲然后恢复高电平检验状态机会不会被误触发。这类边界测试比单纯跑正确数据更能暴露问题。仿真覆盖到的点多了上板调试的时间能省一大半。4. 实际踩坑记录与排查手册4.1 误码率高的几个隐蔽原因不要把误码全部推给信号干扰很多误码根源在逻辑设计。我整理过几个常见但是隐蔽的坑一是起始位采样点过于靠前。如果对下降沿处理得很粗糙可能在起始位刚开始时就把sample_cnt清零随后采样点相对每个数据位都前移越靠近位边界就越容易采到跳变沿。解决办法就是把起始位的中点重采样作为状态切换依据并让后面的位采样都以它为基准。二是波特率时钟脉冲宽度不对。有些分频器在每个周期只拉高一个时钟周期但拉高时序和状态机的判定没有对齐导致状态机少走一拍整个位流向后移了一个时钟。后来我统一了baud_pulse的生成逻辑让它在计数器到达DIV_MAX时产生单周期脉冲状态机只在该脉冲有效时进行位计数。三是接收端的停止位处理太随意。有人不在停止位采样直接回 IDLE如果字节间有微小抖动下一个起始位可能会提前到来导致状态机误判。稳一点的做法是在停止位中点确认高电平再返回 IDLE。4.2 数据丢失的排查思路连续收发时丢数据最常见的场景是接收模块收到一个字节后没有及时被上层取走紧接着第二个字节又进来了覆盖了上次数据。这种现象在没加 FIFO 时几乎必现。我排查时的思路是先看接收完成信号的持续时间。如果它只拉高一个周期上层逻辑错过这个脉冲数据就悄悄丢了。解决方向有两个第一接收模块内部把有效信号维持到上层明确读取再用一个ack信号清除。第二也是更工程的做法在接收模块后面接一个异步 FIFO要求上游尽快读走即使晚几个周期也不会丢数据。FIFO 本身也有坑。读写指针的跨时钟域同步如果没处理好读到的计数可能是滞后的造成空满信号判断错误。我用的是厂商提供的 FIFO IP把读写时钟设置成同一个但读写端都经过独立时钟域处理。自写 FIFO 的话一定要把读指针同步到写时钟域时用两级同步器写指针同步到读时钟域时同样处理否则空满标志会在边界上出错。4.3 几个值得保留的小技巧说几个我在这个串口模块里保留下来、后来多个项目复用的个人技巧。第一个技巧是IDLE 状态下不要对 RX 信号做边沿检测后立刻置位 START而是设计一个「潜在起始」状态。也就是说检测到下降沿后先把计数器开启如果在采样中点上 rx_synced 仍然是低才确认这是真正的起始位。这样做虽然状态机多一个状态但抗毛刺能力高了不少。第二个技巧是采样位置永远取位中心即过采样计数器的中点值而不是加速度调整后的中心位置。比如 16 倍过采样采样点在计数到 7 或 8 处。如果选择在计数到 3 处提前采样虽然给了后续逻辑更多时间余量但采样点离位边缘太近对信号抖动几乎没有容忍度。第三个技巧是发送端的tx_busy信号要在发送结束前至少提前一个周期拉低否则上位机软件连续发送时可能因为看到 busy 还没降下来而暂停一个字节周期形成间歇性慢速。我在实测中发现某些上位机串口控件就是根据 CTS/busy 来流控的提前量留足能让连续吞吐更加稳定。第四个技巧更偏向工程管理把波特率发生器和收发状态机分开成独立小模块顶层只做例化和 FIFO 连接。这样后面想换波特率、加校验、或者改成 RS485 半双工时只需要替换局部调试范围更小。一点心得体会是串口模块写一次容易写“稳”需要把细节抠到极致。这个超稳模块最大的价值不是让你直接抄代码而是让你在设计新模块时自然地把同步、去毛刺、位中心采样、FIFO 缓冲这些原则内化进去。按照这个思路整理出的串口收发在连续跑数小时、反复开关电源、甚至带电插拔调试线时都能稳定工作这才是真正值得放进自己代码库里的模块。后续如果你要扩展成多通道串口或者加硬件流控、CRC 校验这套骨架还能让你少走很多弯路。