如果你在调试FPGA的UART串口接收,发现实验室里跑得稳稳当当,一到现场就乱码、丢字节、甚至一错错一串,那这篇就是为你准备的。围绕FPGA、UART、串口接收这三个关键词,我把自己在强干扰环境下打磨接收逻辑的完整过程捋了一遍,重点说说高干扰的情况到底会踩哪些坑,以及怎么从架构上把问题解决掉。全文没有花架子,都是可以直接抄到工程里的思路和代码片段,适合正在搞FPGA开发、被串口通信折磨过的朋友。
先说结论:UART本身是个很老的异步串行协议,教科书上那套“检测下降沿、延时半位、中心采样”的接收方式,在干净环境里确实够用,但放到继电器乱跳、变频器呼啸、电机启停频繁的工业现场,基本就是裸奔。真正的抗干扰接收,必须把输入滤波、起始位确认、多次采样、错误恢复这几件事全部做进状态机里,而不是指望外部电路帮你擦屁股。
1. 高干扰场景下的UART接收,到底难在哪
1.1 教科书代码在高干扰环境里为什么扛不住
很多人学FPGA入门时写UART接收,都是这么干的:空闲时rxd为高,检测到下降沿就认为起始位来了,然后数半个位时间,在数据位中心连续采样8个bit,最后等停止位。这套逻辑在信号干净的时候没有任何问题,因为时序是确定的,电平是稳定的,采样点永远踩在bit正中间。
但高干扰环境下,问题立刻就暴露出来了。干扰信号会在rxd线上叠加出大量窄毛刺,这些毛刺宽度可能只有几十纳秒,但幅度足够越过TTL或CMOS电平阈值。一旦毛刺出现在空闲态,状态机就会误判成起始位,紧接着开始接收一帧根本不存在的数据。更麻烦的是,毛刺不光是增加假起始位,它还会叠加在真正的数据信号上,让沿的位置左右抖动。你原本算好的“延时半位再采样”就会踩偏,轻则这一位采错,重则后面所有位全部错位,整帧报废。
我遇到过最头疼的情况是现场一开变频器,接收端就开始连续帧错误。用示波器一看,rxd线上的毛刺已经严重到看起来像随机噪声了。这时你才会意识到,教科书代码本质上是“信任信号”的代码,它假设电平只有0和1,没有第三种状态。但干扰环境下,信号在0和1之间来回弹跳,随便信一个边沿,后面必然崩。
1.2 干扰从哪里来:常见工业场景的噪声源
先别急着改代码,搞清楚干扰从哪来更重要。在我接触过的项目里,UART高干扰主要有几个来源:
- 感性负载通断:继电器、接触器、电磁阀线圈断开瞬间会产生很高的反向电动势,通过空间辐射或地线耦合进串口线。
- 变频器与电机:变频器输出的是高dv/dt的PWM波,对附近的弱信号线来说是极强的干扰源,尤其是长距离走线时,串口线就像一根天线。
- 长线缆与地电位差:两个设备距离几十米,地电位不完全相等,共模电压会把接收端的电平参考打得乱七八糟。
- 电源纹波:如果串口芯片或FPGA的供电被电机驱动电路污染,接收引脚的电平阈值就会抖动,本来1.5V以下算低电平,结果噪声一来,判定边界全乱了。
这些噪声有个共同特点:它们不以“完整的一帧数据”形式出现,而是以极窄的毛刺、边沿抖动、电平漂移的形式叠加在信号上。所以接收逻辑的核心任务不是“更快地采样”,而是在噪声中把真实的数据沿和数据位挑出来。想通这一点,方案就清晰了。
2. 接收方案设计:先把“抗干扰”刻进架构里
2.1 分层设计思路:输入滤波、起始位确认、动态采样、错误处理
既然干扰主要在信号边缘和数据电平上做手脚,那接收端就得分层设防。我在实际工程里把接收路径拆成了四道工序:
- 输入滤波:原始rxd先过一级滑窗滤波器,连续多个采样点都是同一个电平,才认为电平真正翻转了。这一层干掉大部分窄毛刺。
- 起始位确认:检测到滤波后的下降沿后,不急着开始收数,而是等半个bit时间再采一次,确认rxd仍然是低电平,才认定这是真起始位。这一层干掉“毛刺伪装起始位”的情况。
- 数据位多次采样:每个数据位不止采一个点,而是采三个点做多数表决,避免单个采样点落在抖动上导致误判。
- 错误检测与恢复:停止位检查、超时退出、错误标志输出,保证一帧被干扰弄坏之后,状态机还能回到空闲态,而不是卡死在某个状态。
这四个工序缺一不可。我见过很多工程师只加了滤波,觉得毛刺滤掉就万事大吉,结果起始位确认没做,照样会被宽一点的干扰脉冲骗到;也有人只做多次采样,却不做输入滤波,结果毛刺直接让采样结果乱跳。我的经验是:滤毛刺、确认起始位、多次采样这三件事必须同时做到,缺一个高干扰下都会露出破绽。
2.2 为什么宁愿牺牲一点实时性也要做滤波和多次采样
有人会问,滤波和多次采样会让接收逻辑变重,会不会影响吞吐率?答案是:影响微乎其微,但可靠性提升是数量级的。UART本身是异步低速协议,常用波特率从9600到921600,哪怕921600,一个bit也只有1.085微秒左右。FPGA里的系统时钟通常是50MHz或更高,一个bit对应几十个甚至几百个时钟周期,拿几个周期出来做滤波和多次采样,完全不心疼。
真正要权衡的是滤波窗口大小。窗口太短,毛刺滤不干净;窗口太长,会把真实的边沿变化也抹掉,导致采样点偏移。我的经验法则是:滤波窗口至少能覆盖你现场实测到的最宽毛刺,但不能超过半个bit时间。比如采样时钟是5MHz,一个bit时间是200个时钟周期,那滤波窗口取4到8个时钟周期比较合适,既滤掉了大多数窄毛刺,又不影响正常跳变。
2.3 采样点的选择:中心采样 vs 多次表决
教科书喜欢“中心采样”,因为理论上bit中心离两边沿最远,最安全。但高干扰环境下,信号沿会抖动,中心点本身也可能被噪声污染。我后来改成了每个bit内做三次采样,位置分别放在bit周期的40%、50%、60%附近(比如一个bit共16个采样钟,就取第6、7、8个点),然后三选二投票。这样做的好处是:哪怕其中一次采样正好踩在毛刺上,另外两次仍然能正确反映该bit的电平,整体判断不会被带偏。
关于为什么选40%、50%、60%而不是20%、50%、80%,原因很简单:UART在工业现场最容易在bit边沿处出问题,起始沿和结束沿附近的信号质量最差,所以三个采样点都应该尽量靠近bit中心区域,但又要有一定间隔,才能起到“多数表决”的效果。实践下来,第6、7、8这三个点,既不会太靠近边沿,又有足够的分散度,表现最稳。
3. 核心代码实现:一个带抗干扰处理的UART接收模块
3.1 模块接口与参数定义
下面这个模块是我在实际项目中经过多轮现场验证的版本,做了一些精简便于说明,但核心逻辑都保留着。参数化设计,直接修改CLK_FREQ和BAUD_RATE就能适配不同平台。
module uart_rx_ahb #( parameter CLK_FREQ = 50_000_000, // 系统时钟 parameter BAUD_RATE = 115200, // 波特率 parameter SAMPLE_MULT = 16, // 每个bit采样倍数 parameter FILTER_CNT = 4, // 滤波窗口,连续N拍同电平 parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire rxd, output reg [DATA_WIDTH-1:0] rx_data, output reg rx_valid, output reg frame_err, output reg parity_err );这里有几个参数要重点说明。SAMPLE_MULT通常取16,也就是一个bit时间被划分成16个采样时钟周期,这样起始位半位确认就是8个采样时钟。FILTER_CNT是高干扰工程里调得最频繁的参数,建议做成可配置,方便现场调试时用寄存器现场改,而不是每次改代码重新综合。
3.2 输入滤波与沿检测:把毛刺先干趴下
原始rxd进来之后,先打一到多拍移位寄存器。当移位寄存器里所有位都是1时,认为滤波后输出高电平;所有位都是0时,认为输出低电平;中间状态保持上一次输出不变。这种“迟滞”效果能有效过滤掉窄毛刺。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_shift <= {FILTER_CNT{1'b1}}; rxd_filtered <= 1'b1; rxd_filtered_prev <= 1'b1; end else begin rxd_shift <= {rxd_shift[FILTER_CNT-2:0], rxd}; if (&rxd_shift) rxd_filtered <= 1'b1; else if (~|rxd_shift) rxd_filtered <= 1'b0; rxd_filtered_prev <= rxd_filtered; end end assign rxd_negedge = rxd_filtered_prev & ~rxd_filtered; assign rxd_posedge = ~rxd_filtered_prev & rxd_filtered;要点是:沿检测必须基于滤波后的信号,而不是原始rxd。我见过有人直接对原始rxd做边沿检测,然后滤波只用于数据采样,结果假起始位照样触发。滤波和沿检测必须是一体的,滤波之后的边沿才可信。
3.3 起始位确认:半位后再验证
检测到滤波后下降沿后,状态机从IDLE跳到START,但这时还不能确认起始位是真的。我的做法是在START状态等8个采样时钟(半位时间),然后再采一次rxd_filtered。如果这时候已经是高电平,说明刚才那个下降沿只是干扰脉冲,状态机立刻回IDLE;如果仍然是低电平,说明rxd确实被拉低了至少半个bit,这才认定是有效起始位。
这一步非常关键。很多干扰脉冲虽然窄,但滤波窗口滤不掉所有情况,比如现场有很宽的共模干扰时,毛刺宽度可能达到几个微秒。如果没有半位确认,这种宽毛刺就会被当成起始位。加了半位确认之后,只有持续低电平超过半个bit的下降沿才能触发接收,假起始位的概率会下降好几个数量级。
START: begin if (baud_x16_tick) begin if (half_cnt < SAMPLE_MULT / 2 - 1) half_cnt <= half_cnt + 1; else begin if (rxd_filtered == 1'b1) begin state <= IDLE; // 假起始位,丢弃 end else begin state <= DATA; bit_cnt <= 0; end half_cnt <= 0; end end end3.4 数据位采样:三选二多数表决怎么落地
进入DATA状态后,每个bit持续SAMPLE_MULT个采样时钟。我在一个bit的第6、7、8个采样时钟分别采样一次,得到三个电平,然后多数表决。表决逻辑很简单:三个里至少两个为1,就判该位为1,否则为0。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sample_cnt <= 0; bit_cnt <= 0; shift_reg <= 0; end else if (state == DATA) begin if (baud_x16_tick) begin if (sample_cnt == 6 || sample_cnt == 7 || sample_cnt == 8) begin sample_1bit[sample_cnt - 6] <= rxd_filtered; end if (sample_cnt == SAMPLE_MULT - 1) begin shift_reg <= {vote3(sample_1bit), shift_reg[DATA_WIDTH-1:1]}; if (bit_cnt == DATA_WIDTH - 1) state <= STOP; else begin bit_cnt <= bit_cnt + 1; sample_cnt <= 0; end end else begin sample_cnt <= sample_cnt + 1; end end end end function automatic vote3; input [2:0] samples; begin vote3 = (samples[0] & samples[1]) | (samples[0] & samples[2]) | (samples[1] & samples[2]); end endfunction这里有一个很容易犯的错:sample_cnt必须和波特率时钟严格对齐,不能出现某些位采样点偏移的情况。我在调试时就遇到过,前面几个bit正常,到后面某个bit开始错位,最后发现是sample_cnt在状态切换时没有清零,导致采样点越走越偏。所以状态跳转时,所有计数器都要有明确的复位或清零动作。
3.5 停止位校验与错误恢复:别让状态机卡死
数据位收完之后进入STOP状态,正常停止位应该是高电平。我在停止位的中间位置采样,如果采到低电平,说明这一帧有问题,置位frame_err。但不管停止位对不对,状态机都要回IDLE,同时把rx_valid拉起来(或者根据需求选择错误时不拉valid)。这时候上层协议可以根据frame_err判断这一帧要不要丢弃。
还有一种极端情况必须处理:对端设备故障把txd拉死为低,或者强干扰让rxd长时间保持低电平。如果状态机没有超时机制,它会一直停在DATA状态里,后面的数据全都不收。我的做法是在DATA状态加一个超时计数器,如果超过正常一帧所需时间的1.5倍还没收满8个bit,强制回IDLE并置frame_err。这个机制在真实现场帮了我大忙,否则每次对端设备抽风,板子就得重新上电才能恢复。
4. 关键参数计算:波特率、采样时钟与滤波窗口怎么配
4.1 采样时钟频率与分频计算
假定系统时钟50MHz,目标波特率115200,SAMPLE_MULT取16。那么采样时钟频率应该是115200 × 16 = 1.8432MHz。分频系数为:
50MHz / 1.8432MHz ≈ 27.126
取整为27,实际采样时钟频率是50MHz / 27 ≈ 1.85185MHz,换算成实际波特率约为1.85185MHz / 16 = 115740bps,与目标115200的误差约0.47%。这个误差在UART容差范围内,可以正常工作。
但如果偷懒把分频系数取成28,实际波特率约111607bps,误差约3.12%,高于普通UART的±2%容差,高波特率下就会随机出错。这里要特别提醒:分频系数一定要按整数参数算好,不能随便四舍五入。
常用配置我整理了一张表,直接照着填就行:
| 系统时钟 | 目标波特率 | 采样倍数 | 分频系数 | 实际波特率 | 误差 |
|---|---|---|---|---|---|
| 50MHz | 9600 | 16 | 325 | 9615 | 0.16% |
| 50MHz | 115200 | 16 | 27 | 115740 | 0.47% |
| 50MHz | 921600 | 16 | 3 | 1041667 | 13% |
| 100MHz | 115200 | 16 | 54 | 115740 | 0.47% |
| 100MHz | 921600 | 16 | 6 | 1041667 | 13% |
921600那一行误差很大,原因是分频系数太小,取整损失比例太高。如果目标波特率很高,要么提高系统时钟,要么降低采样倍数到8,否则误差会大到无法通信。我一般建议:串口波特率在工业现场不超过460800,采样倍数不低于16,留足抗干扰余量。
4.2 滤波窗口宽度怎么定
滤波窗口FILTER_CNT是高干扰调试里最值得花时间的参数。它表示“连续多少个采样点同电平才认为信号真正变化”。窗口太短没效果,窗口太长会影响正常信号。
举个实际计算的例子。系统时钟25MHz,波特率115200,SAMPLE_MULT=16,那么采样时钟是1.8432MHz,一个采样周期约542纳秒,一个bit时间约8.68微秒,对应16个采样周期。如果现场毛刺宽度在几十到几百纳秒,滤波窗口取2到4个采样周期(约1到2微秒)足够滤掉大部分毛刺。如果线特别长、干扰特别严重,可以把窗口加到6到8个采样周期,但千万别超过8,否则起始位的低电平持续时间会被滤波吃掉太多,半位确认那一步就可能判断失败。
我通常的做法是:先把FILTER_CNT设成4,上板实测。如果还有误触发,就往大调;如果发现正常帧开始丢失,就往小调。反复试几次,总能找到一个稳定区间。另外,滤波参数最好做成寄存器可配,这样现场调试时不用反复重新综合烧写,效率高很多。
4.3 采样点偏移与波特率容差
UART是异步协议,收发两端各自用自己的时钟,并没有同步机制。所以波特率误差、时钟抖动、干扰引起的边沿偏移,都会叠加起来。接收端判断一个bit是否正确的依据是采样点是否落在该bit有效区间内。
以115200为例,一个bit时间约8.68微秒。假设用中心采样,理论上采样点在4.34微秒处。如果发送端和接收端的时钟误差总计1%,边沿抖动消耗掉1微秒,那实际采样点会偏移到距离真实bit中心几百纳秒的位置,仍在有效区间内,问题不大。但如果误差到3%,再加上干扰造成的沿抖动,采样点就可能落到bit边沿附近甚至越过边界,这一位就采错了。
所以我的经验是:接收端自身分频导致的波特率误差最好控制在±1%以内。加上对端误差,总误差控制在±3%以内。如果算出来误差接近或超过2%,优先提高系统时钟频率或降低目标波特率。也别迷信“误差在容差范围内就行”,高干扰场景下,余量越大越稳。
5. 仿真与板级调试:把干扰“造”出来再验证
5.1 仿真激励怎么写:人为注入毛刺
很多人写仿真激励,就规规矩矩地按UART时序发送正常数据,跑通了就以为完事。这种仿真在高干扰工程里几乎没有价值,因为干扰恰恰不在正常时序里。我的做法是专门写一个带干扰注入的testbench,在正常数据帧里随机插入毛刺和沿抖动。
思路大概是:正常发送起始位、数据位、停止位;在发送过程中,用随机数控制在某个时刻往rxd上叠加一个短脉冲(把rxd强制拉低或拉高几个时钟周期);再模拟一下边沿抖动,把起始沿和停止沿的位置随机偏移几十个纳秒。跑一百帧,统计接收正确的帧数。如果误帧率还在可接受范围,再拿板上实测。
用ModelSim或者Vivado自带的仿真器都能做,关键是组合出各种极端情况,而不是只跑一遍通过就行。高干扰环境下的验证,本质上是在赌“哪种干扰组合会让逻辑崩掉”,仿真的价值就是提前把这些组合都试一遍。
5.2 实测波形怎么看:抓关键信号
上板调试时,不要只看最终收到的数据对不对,要抓FPGA内部的中间信号。我用SignalTap或ILA通常会同时抓以下几组信号:
- rxd(原始输入)和rxd_filtered(滤波后输入):对比看滤波是否把毛刺滤干净,以及滤波造成的延迟有多大。
- rxd_negedge:看假起始位触发的频率,如果这个信号频繁拉高但状态机并没有正确接收,说明干扰在起始位确认阶段还有漏网。
- state:监控状态机在哪里停留最久。如果经常卡在DATA,很可能是对端信号异常或超时机制没生效。
- frame_err:这个信号一旦频繁拉高,说明停止位经常被破坏,优先去查外部电路和地线。
我调试时还有个习惯:把rx_valid配合误帧统计一起看。比如上位机每发100帧,FPGA统计收到多少有效帧、多少帧错误,再把统计数据通过另一路串口或LED灯状态发出来,这样不用反复插拔调试线也能远程判断现场情况。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 整帧乱码、数据完全不对 | 波特率分频参数错误、采样点偏移过大 | 核对分频系数,减小波特率误差 |
| 只有第一个字节错,后面正常 | 假起始位把数据帧起点搞偏 | 加滤波窗口、半位确认 |
| 偶尔丢一个字节 | 数据位采样点踩到边沿抖动 | 多次采样+多数表决 |
| 一直报frame_err | 停止位被拉低、对端异常 | 检查停止位采样点,加外部隔离 |
| 状态机卡死、收到几帧后不再响应 | 对端拉低或长时间低电平 | 增加超时强制恢复 |
| 开变频器/继电器时错码增多 | 共模干扰通过地线耦合 | 外部光耦隔离、屏蔽线处理 |
这张表基本覆盖了我这几年遇到的大部分串口高干扰问题,建议收藏。实际调试时先对照表定位方向,再针对性处理,比盲目改代码高效得多。
5.4 我踩过的几个坑
第一个坑是分频参数用非整数。早期我图省事,直接在代码里写50_000_000 / (115200 * 16),综合工具会给个浮点结果,我顺手取整成28,结果高波特率下错码率惨不忍睹。后来改成手动算好整数分频值,误差立刻降到0.5%以内,问题消失。
第二个坑是忘记处理空闲时rxd被异常拉低的情况。有次现场设备通信一段时间后整条链路静默,排查半天发现是对方单片机上电瞬间txd会拉低几个毫秒,正好被FPGA当成起始位,然后状态机一路收了个乱七八糟的数据后卡死。后来加上超时机制,这个问题再也没出现过。
第三个坑是滤波窗口调太大。有次为了滤掉一个特别顽固的毛刺,我把FILTER_CNT一路加到10,结果发现正常帧也开始丢。原因前面说过,滤波窗口超过半位时间,正常起始位的下降沿被滤波逻辑“磨平”了,沿检测根本触发不了。这个教训让我记住了滤波窗口必须小于半位时间这条铁律。
6. 扩展与延伸:这套思路还能用在哪些地方
6.1 从UART扩展到其他异步协议
滤波、起始位确认、多次采样、错误恢复这套组合拳,其实不止能用在UART上。只要是从一根线上异步接收数据的协议,比如LIN总线、DMX512灯光控制、红外遥控接收、单线CAN,都会面临类似的干扰问题。我之前做红外遥控接收时,直接把UART的滤波逻辑搬过去,稍微改一下载波周期参数,效果立竿见影。所以建议把这套代码保留成通用模块,别只当UART专用。
6.2 配合FPGA其他资源协同工作
接收端做好之后,别忘了和FPGA内部的其他资源搭配起来。最常用的组合是接收模块后面接一个FIFO,把rx_valid写入FIFO,再由上层逻辑按自己的节奏读取,这样即使瞬时数据量很大,也不会因为后端处理不过来而丢字节。另外,如果上位机发送频率不稳定,可以在FPGA里做一个超时判断,超过一定时间没收到新数据就认为通信结束,方便上层协议处理。
6.3 后续还可以做的改进
这套接收逻辑还有几个可以继续优化的方向。比如自动波特率检测,上电后先让对方发送一个特定字符(比如0x55),FPGA测量起始位到第一个上升沿的时间,反推出波特率并自动配置分频系数,省去手动设置参数的麻烦。再比如增加CRC校验,在数据帧尾部加校验字节,进一步提高噪声环境下的可靠性。不过这些都属于锦上添花,先把滤波、采样、错误恢复这套基本功做扎实,才是高干扰串口接收的正确打开方式。
最后再分享一个我自己的经验:高干扰串口接收,关键不是代码写得有多聪明,而是能不能在噪声面前保持“冷静”。滤波、半位确认、多次采样、超时恢复,每一条都是在和干扰博弈之后总结出来的。调试时一定要带着示波器去现场看波形,别只在仿真里自嗨。拿示波器探头夹在rxd线上,亲眼看看毛刺长什么样、宽度多少、频率多高,再回来调FILTER_CNT和采样点位置,比盲调代码高效十倍。工程里“能通”和“稳”是两回事,实验室里调通只是起点,能扛住现场电磁环境才算真正做完。