写这个串口模块,最初是因为调试一块需要和 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 = 3'd0; localparam START = 3'd1; localparam DATA = 3'd2; localparam STOP = 3'd3; 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 <= 8'd0; end else begin case (state) IDLE: begin // 检测下降沿后进入 START 状态,但需要连续采样确认 if (rx_reg_deglitch_off == 1'b0 && rx_deglitch_on == 1'b0) begin state <= START; sample_cnt <= 4'd0; end end START: begin // 在起始位中点附近再次确认电平为低 if (sample_cnt == 4'd8) begin if (rx_synced == 1'b0) state <= DATA; else state <= IDLE; sample_cnt <= 4'd0; end else begin sample_cnt <= sample_cnt + 1'b1; end end DATA: begin // 采样点定在每个 bit 的中间附近 if (sample_cnt == 4'd8) begin rx_data <= {rx_synced, rx_data[7:1]}; sample_cnt <= 4'd0; if (bit_cnt == 3'd7) begin bit_cnt <= 3'd0; state <= STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end else begin sample_cnt <= sample_cnt + 1'b1; end end STOP: begin // 停止位采样后回归 IDLE if (sample_cnt == 4'd8) begin state <= IDLE; rx_data_valid <= 1'b1; end else begin sample_cnt <= sample_cnt + 1'b1; 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 <= 1'b0; tx_shift <= 8'd0; txd_out <= 1'b1; // 空闲时 TX 为高 end else begin if (tx_en && !tx_busy) begin tx_busy <= 1'b1; tx_shift <= tx_data; tx_bit_cnt <= 4'd0; txd_out <= 1'b0; // 起始位 end else if (tx_busy) begin // 按波特率节拍移位 if (baud_pulse) begin if (tx_bit_cnt <= 4'd7) begin txd_out <= tx_shift[0]; tx_shift <= {1'b0, tx_shift[7:1]}; tx_bit_cnt <= tx_bit_cnt + 1'b1; end else if (tx_bit_cnt == 4'd8) begin txd_out <= 1'b1; // 停止位 tx_bit_cnt <= tx_bit_cnt + 1'b1; end else begin tx_busy <= 1'b0; tx_bit_cnt <= 4'd0; 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{1'b0}}; dout <= {WIDTH{1'b0}}; 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(8'h55); // 等待接收完成 #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 校验,这套骨架还能让你少走很多弯路。