做FPGA的人,几乎绕不开串口。哪怕你最后去做PCIe、做DDR、做高速Serdes,入门阶段也大概率是从点亮LED、把数据通过UART发给电脑开始的。原因很简单:UART是数字世界里最朴素的“说话”方式,协议简单、时序直观、调试方便,一个USB转串口模块加一根杜邦线,就能让FPGA和PC建立起最原始的通信链路。但也正因为简单,很多人反而没把UART当回事,结果一到板级调试就翻车——要么发出来的数据是乱码,要么接收逻辑在仿真里好好的、上板之后偶尔丢字节。这篇文章就把我实际调通UART串口通信FPGA实现的整个过程拆开讲,包含协议细节、Verilog实现思路、仿真验证、上板调试中真正值得注意的地方,以及那些文档里很少写、但几乎人人都会踩一遍的坑。
1. 从协议本质到硬件连接:为什么串口看似简单却容易栽跟头
UART的全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。叫“异步”,是因为收发双方不需要共享时钟信号,而是在各自独立的工作时钟下,依靠约定的波特率(Baud Rate)来对齐每一位数据的时长。这一点和SPI、I2C这类同步通信协议有本质区别,也是很多初学者第一次接触时最容易困惑的地方。
1.1 数据帧结构:一个起始位、8个数据位、一个停止位是怎么来的
常见的UART帧格式是8N1:一个起始位(Start Bit,低电平)、8个数据位(Data Bits,常按LSB First发送)、一个可选的校验位(Parity Bit,N表示无校验)、一个停止位(Stop Bit,高电平)。整帧数据在空闲状态下始终维持高电平,当发送端拉低电平并持续一个位时间(即波特率的倒数),接收端就知道“有数据来了”,从这一刻开始计时,逐位采样。
这个过程用生活化的类比来说,就像两个人约定好“每秒钟说一个字”,A在第0秒开始说话前先做一个“咳嗽”动作(起始位),B听到“咳嗽”后开始按秒计数,每个整秒读取一次A的声音状态,这样即使两个人没有共用一只钟表,也能靠事先约定好的节奏完成交流。FPGA的实现本质就是把这个“按秒计数”的过程精确量化到系统时钟的周期数上。
1.2 电平标准:TTL、RS232、USB转串口的三角关系
FPGA的IO口通常是3.3V或2.5V的LVCMOS电平标准,而电脑的串口(如果有的话)通常是RS232电平,正负电压表示逻辑1和0,范围在±3V到±15V之间。要让两者直接相连,轻则通信异常,重则可能损坏FPGA引脚。这也是为什么现在大家几乎都用USB转串口模块,比如CP2102、FT232R、CH340这些,它们内部完成了USB协议到UART协议的转换,输出端直接是TTL电平,可以和FPGA直接对接。
有一个细节值得注意:TTL电平的UART本身是一种“反向”逻辑,空闲是高电平,起始位是低电平,所以USB转串口模块默认就是“空闲高、低有效起始”的形态,不需要额外电平反相。很多人在仿真里定义了idle = 1'b1,结果上板发现发不出数据,第一反应是引脚连错了,其实多半是把电平极性搞反了。
1.3 波特率为什么能容忍误差:从一位的采样窗口说起
既然收发双方没有共享时钟,那么必然存在时钟频率偏差。比如收发两端标称都是115200bps,但实际晶振各有ppm级误差,或者FPGA端用100MHz系统时钟分频出的波特率时钟本身有取整误差。关键问题是:误差容忍上限是多少?
这就要回到采样原理。接收端通常会在每个数据位的中间时刻采样,以最大限度避开数据跳变沿附近的不稳定区域。如果位时间误差在一定范围内,中间采样点仍然落在稳定的数据区间内。工程上,累计误差不超过半个位时间(约50%)通常还能维持通信,但实际设计中建议把误差控制在2%以内,越界之后在长帧、连续大数据流场景下就非常容易偶发错位。
这也是为什么我在很多项目里宁可把UART接收模块做成16倍波特率采样,也不做1倍采样。16倍过采样可以更准确地定位起始位的下降沿和每一位的中心点,对时钟误差的容忍度明显更高。
2. UART发送模块设计:状态机怎么写才不会出现毛刺和错位
发送端负责把并行数据转成符合帧格式的串行比特流。逻辑上并不复杂,但状态机设计的好坏直接决定输出波形的干净程度。我见过不少同事发的代码,明明功能仿真全对,上板用逻辑分析仪抓出来的波形却有多余的毛刺,原因就在状态转移和输出赋值的方式上。
2.1 发送状态机与位计数器配合的经典结构
发送模块通常包含这样几个状态:IDLE、START、DATA、STOP。IDLE状态下TX线保持高电平;检测到发送使能信号tx_start后进入START状态,TX线拉低一个位时间;随后进入DATA状态,按位输出数据,每输出一位bit_cnt加1,直到8位发完;最后进入STOP状态,TX拉高一个位时间,然后回到IDLE。
位时间的计时有两种常见做法:一种是用一个计数器对系统时钟计数,记到BAUD_DIV - 1产生一个baud_clk_en脉冲;另一种是直接生成一个波特率时钟作为状态机时钟。我更推荐前者——用时钟使能脉冲(Clock Enable)而不是分频时钟。原因有两个:一是在FPGA中全局时钟网络资源是宝贵的,自己分频出的时钟走布线资源,时序约束不好做;二是用时钟使能可以保证状态机和顶层逻辑始终处于同一个时钟域,避免跨时钟域处理带来的麻烦。
下面是一个典型的发送模块核心代码结构:
module uart_tx ( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg txd ); localparam IDLE = 2'd0; localparam START = 2'd1; localparam DATA = 2'd2; localparam STOP = 2'd3; reg [1:0] state; reg [15:0] clk_cnt; reg [2:0] bit_cnt; reg [7:0] tx_data_reg; wire baud_clk_en = (clk_cnt == BAUD_DIV - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 16'd0; end else if (clk_cnt == BAUD_DIV - 1) begin clk_cnt <= 16'd0; end else begin clk_cnt <= clk_cnt + 1'b1; end end这里BAUD_DIV的计算公式是:BAUD_DIV = 系统时钟频率 / 波特率。以100MHz系统时钟和115200波特率为例,BAUD_DIV = 100_000_000 / 115200 ≈ 868。由于868并不是整数分频的精确结果,实际波特率会有约0.02%的误差,完全在容忍范围内。但如果你用的是50MHz时钟跑9600波特率,BAUD_DIV = 50_000_000 / 9600 ≈ 5208.33,取5208后误差约为0.006%,依然没问题。关键是不要取整之后直接用带小数的常量在硬件里计算,而是先在仿真里估算误差。
2.2 发送数据寄存与使能沿检测的配合细节
实现发送时有一个容易遗漏的点:tx_data外部信号可能不会在整个发送期间保持稳定,比如上游模块只把一个字节放到数据总线上保持一个周期,之后就去处理别的事了。所以状态机进入START之前,必须先把tx_data锁存到内部寄存器tx_data_reg里。
锁存时机也需要注意。如果tx_start和tx_data同时有效,那么触发状态下锁存是可以的;但更稳妥的做法是在tx_start有效的那一拍直接把tx_data拷入寄存器,而不是等进入DATA状态后再读取外部数据总线。我在实际调试中遇到过因为外部数据变化时序和发送状态机不匹配,导致发出的第一个字节整体错位的情况,最后就是靠提前一拍锁存解决的。
还有一个和“发送完成标志”有关的小细节。很多人习惯在状态机回到IDLE时拉高tx_done,但这时候如果外部逻辑立刻拉高tx_start开始发下一个字节,可能造成上一帧的STOP位还没有保持够一个完整的位时间就切换到下一帧的START位。严格来说,UART帧与帧之间应当至少保留一个停止位的时间,虽然绝大多数接收端不会因为少一个位时间就出错,但严格实现应该在STOP状态结束时才产生tx_done,避免背靠背发送时连帧异常。
3. UART接收模块设计:过采样、起始位确认和逐位中心采样
相比发送,接收端要复杂一个档次。发送是对自己写的数据负责,只要时序对齐就基本没问题;接收面对的是外部不确定的信号,什么时候来、有没有毛刺、电平稳不稳定,都需要接收机制自身去对抗。这也是我建议不要直接用波特率时钟做接收时钟的根本原因。
3.1 为什么接收端推荐16倍过采样
16倍过采样的含义是:在每个数据位的时间内,用系统时钟采样16次。以115200波特率为例,每个数据位持续约8.68微秒,在100MHz系统时钟下对应868个时钟周期;如果做16倍过采样,那么每一个“采样步”大约对应54.25个时钟周期,代码中通常用一个计数器控制每采样16次就推进一个位。
过采样的最大好处是能精确定位起始位的下降沿。空闲时RX线是高电平,当检测到下降沿时,并不能立刻确定这就是真正的起始位——可能只是外部噪声毛刺。标准做法是:检测到下降沿后,等待半个位时间(即8个采样周期),再次采样RX线,如果仍然是低电平,才确认这是一个有效的起始位;如果已经回到高电平,则判定为毛刺,丢弃并回到IDLE。这种“先怀疑,再确认”的方式能显著降低误触发概率。
3.2 接收状态机实现:从下降沿检测到8位数据收齐
接收状态机的核心流程可以分为这几个阶段:
- IDLE状态持续监测RX线电平,等待下降沿;
- 检测到下降沿后启动起始位确认计时,经过半位时间再次采样确认;
- 确认起始位有效后,进入数据采样循环,每个位时间的中点点采样一次,共采8位;
- 采样停止位,若停止位为高则接收成功,若为低则说明帧错误(比如波特率不匹配或线路干扰),丢弃该帧;
- 输出并行数据和接收完成标志。
十六位过采样在代码实现上通常用一个计数器sample_cnt控制,从0到15循环,其中第7或第8次采样通常被视为位中心附近。实际项目中,我在位中心会连续采样3次,然后取多数表决结果(三取二),这对抗毛刺和信号抖动很有效,代价也很小。
接收端另一个重要细节是:8个数据位是按LSB First顺序到达的。收到的第一个数据位是最低有效位,所以移位寄存器的赋值方向是rx_data_reg <= {rx_data_reg[6:0], sampled_bit},而不是相反。这个细节在调试波形时很容易看出来——如果方向错了,收到的字节会呈现位序反转,比如发送0x01却收到0x80。
3.3 接收FIFO与跨时钟域处理何时需要引入
接收模块的输出是8位并行数据加一个rx_done脉冲。如果下游只是一个简单的LED显示或寄存器组,那么直接连接即可;但如果FPGA内部有CPU软核、FIFO或DMA等模块,而且它们工作在另一个时钟域,那么rx_done脉冲和数据总线就不能直接跨时钟域传递。
常规做法是在接收模块后面加一个异步FIFO,把UART接收域的数据安全地搬到系统处理域。Async FIFO的设计本身又是一门大学问,但好在Xilinx和Intel(Altera)都有现成的IP核可以用。如果不想引入IP核,也可以用“两级同步器 + 握手信号”的方式实现单字节跨时钟域传输,效率低一点,但逻辑透明、易调试。
4. 仿真验证的关键环节:怎么构造测试用例才能真正暴露时序问题
仿真对UART模块来说并不仅仅是“跑一下波形看看对不对”,如果测试用例设计得不够刁钻,很多上板才会出现的问题根本暴露不出来。我自己的习惯是写一个独立的testbench,用行为级模型模拟对端UART设备,做“FPGA发送模块 + 模拟接收端”和“模拟发送端 + FPGA接收模块”两个方向的闭环验证。
4.1 用行为级模型模拟对端,验证发送方向
验证发送模块时,testbench里需要写一个“虚拟接收器”。它的工作方式与真实的接收端一致:等待下降沿、确认起始位、按波特率逐位采样、拼装数据、最后和预期值比较。这样能一帧一帧地确认FPGA发送的波形、位宽、时序是否符合协议要求。
需要注意一个判断点:仿真时间分辨率。如果testbench的时间精度设置不当,很容易在边沿采样时出现亚稳态或漏采。例如timescale 1ns/1ps和timescale 1ns/100ps的差别在高速波形仿真中就会出现细微差异。针对UART这种低速协议,我通常用timescale 1ns/1ps,并适当在采样时刻上加一点小的偏移,模拟真实世界中的采样不确定性。
4.2 构造异常输入用例:毛刺、短帧、波特率偏移
接收模块验证不能只发标准帧。实际环境中的串口信号并不总是干净的,USB转串口模块的质量、线材长度、外部电磁干扰都会影响信号质量。所以我建议在仿真阶段就主动加入这些非理想因素:
- 在起始位下降沿之前插入一个短毛刺脉冲(比如半个位时间的低电平),确认接收端不会误触发;
- 在数据位中注入一个比正常位宽窄的干扰,确认三取二采样逻辑能正确恢复数据;
- 让模拟发送端的波特率比FPGA端高1%或低1%,观察接收是否仍然正确,验证误差容忍能力。
这些用例看起来简单,但能提前发现很多“理论上没问题、实际上会翻车”的隐患。我遇到过最典型的案例是:接收模块在无噪声环境下100%正确,但在起始位下降沿后立即出现一个宽约几十纳秒的毛刺,导致采样逻辑误把毛刺当成第0位数据,整个字节直接错位。加了起始位半位确认之后,这个问题就彻底消失了。
4.3 回环测试:把发送和接收放在同一个testbench里打通
更高一层的验证是自回环(Loopback):把UART发送模块的TX输出直接接到同一模块或另一模块的RX输入,在测试中写入一组数据,再读取接收结果进行比对。这种方法能同时验证收发两条链路,而且搭建起来比独立对端模拟还要简单。上板调试时也经常用——把FPGA的TX引脚用杜邦线短接到RX引脚,配合串口助手自发自收,可以快速判断FPGA内部逻辑是否工作正常。
这里有一个容易误解的地方:如果TX和RX在FPGA内部直接相接,它们属于同一时钟域,不会暴露跨时钟域采样问题;而真实环境中TX连接外部设备后再回到RX,信号是异步的。所以回环测试通过只是第一道关卡,真正的异步验证还需要用模拟对端来驱动接收模块,或者把接收输入单独从外部引脚引入。
5. 板级调试中真正需要留意的硬件细节:电平、线序和USB转串口模块的坑
仿真做到天衣无缝,到了板级调试依然可能一脸懵。串口通信的问题大多数时候不是逻辑错了,而是物理层和连接层面的细节出了问题。
5.1 共地问题:最容易忽略的隐性坑
USB转串口模块和FPGA开发板之间除了TX、RX两条信号线,还必须把GND连在一起。如果两个设备不共地,信号电平的参考点不一致,轻则通信不稳定,重则完全无法通信。这个坑新人几乎都会踩一次,因为串口助手界面看起来一切正常,但数据就是收不到或全是乱码。
如果板子上有多个地引脚,尽量选择靠近UART引脚的地线,避免地环路引入额外噪声。同时在信号线上串一个1kΩ左右的小电阻,可以稍微抑制过冲和振铃,成本极低,实测对通信稳定性有明显帮助。
5.2 TX和RX交叉连接:一个字母带来的半小时困惑
FPGA的TX必须连接外部设备的RX,FPGA的RX必须连接外部设备的TX。这是物理连接中最基本、也最容易犯的错。如果把TX接TX、RX接RX,数据两端都在发没人收,自然什么都收不到。很多USB转串口模块上,丝印标注的是模块自身的视角,需要稍微想一下再接线。
调试时我用过一个很笨但有效的方法:先用模块的TX给FPGA发一个固定字节,比如0x55,然后在FPGA里写一个最简单的逻辑——把RX收到的数据原封不动从TX发出去(回环)。如果串口助手能收到同样的0x55,说明物理链路OK,问题只在逻辑;如果收不到,优先查接线和电平。
5.3 供电不足与晶振精度引发的偶发乱码
开发板上同时挂了太多外设、USB供电电流吃紧时,USB转串口模块的供电可能不稳定,导致信号电平漂移。这种问题表现为:单独调试时一切正常,一接上其他外设就开始偶发乱码。排查方法也简单:用独立的5V电源给USB转串口模块供电,或者换个供电能力更强的USB口试试。
晶振精度问题则是另一种情况。很多开发板上的无源晶振精度在几十到几百ppm之间,如果系统时钟误差较大,分频后的波特率也误差更大。比如标称115200bps,实际可能跑到115300,这种情况下大量连续数据传输时错误率会升高,但短报文又看不出问题。用示波器测量TX引脚的位宽,和理论值对比是最直接的判断手段。
5.4 眼见为实的逻辑分析仪检查法
如果软件层面和硬件连接都检查过仍查不出原因,我的建议是找个逻辑分析仪,哪怕是几十块钱的简易型号,挂在TX或RX引脚上抓一段波形。对照协议帧格式逐位检查:起始位是否低电平、数据位顺序是否正确、停止位是否正常。很多时候问题一眼就能看出来——比如发现数据位中间有毛刺,或者电平根本没有拉到位。
我遇到过一次非常刁钻的问题:FPGA和USB转串口模块之间线太长(约30cm),又没有正确端接,导致信号反射严重,RX端采到的高电平被反射波拉低,偶尔出现帧错误。把线缩短到10cm以内并加了一个下拉电阻后,问题迎刃而解。这种问题靠仿真永远发现不了,只能靠实际抓波形才能定位。
6. 进阶扩展:从单字节收发到应用层的实用思路
基础UART收发跑通之后,下一个问题就是:怎么把它真正用到项目里。这不只是“能发能收”就完了,而是要考虑帧协议设计、多字节组包、错误处理和应用层接口。
6.1 一个最简单的帧协议设计:帧头、长度、数据、校验
如果只是在调试阶段收发单个字节,完全不需要帧协议,但一旦涉及上位机下发配置参数、FPGA回传采集数据,就必须考虑组帧问题。最简单的做法是定义这样一个数据帧格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 1字节 | 固定值0xAA,用于同步 |
| 长度 | 1字节 | 数据区长度 |
| 数据 | N字节 | 有效载荷 |
| 校验 | 1字节 | 数据区累加和或CRC |
帧头的作用是让接收端能在连续字节流中找到新帧的起点。即使之前丢了数据,只要看到0xAA并且后续字节解析通过,就能重新进入同步状态。长度字段让接收端知道要收多少字节。校验字段用于检查整帧数据是否有错,能发现偶发误码并选择丢弃或重传。
6.2 接收状态机的“二级状态”设计:帧解析状态机嵌套在字节接收之上
有了帧协议,接收端逻辑就不能只是“收到一个字节就输出”,而要在之上再套一层帧解析状态机。每个字节到来时,根据当前帧解析状态决定它属于哪个字段:是帧头、长度、数据还是校验。这种设计把“字节层”和“帧层”分离,逻辑清晰,也便于后期扩展协议字段。
localparam FRAME_IDLE = 3'd0; localparam FRAME_HEADER = 3'd1; localparam FRAME_LENGTH = 3'd2; localparam FRAME_DATA = 3'd3; localparam FRAME_CHECK = 3'd4;帧解析状态机在收到rx_done脉冲时前进一步,如果帧头不匹配,则回到FRAME_IDLE重新等待。数据区字节数由长度字段决定,可以用一个计数器控制。校验错误时,可以选择丢弃当前帧并回到FRAME_HEADER状态,继续等待下一个帧头。
6.3 串口助手的选择和调试效率技巧
Windows下调试UART,我常用的串口助手有友善串口助手、SSCOM、XCOM等,各有优缺点。如果只是收发显示,几乎都够用;但如果要发送文件、定时发送、查看十六进制,某些轻量工具就力不从心了。我现在习惯用支持脚本的串口调试工具,可以自动完成“发送查询命令-等待响应-校验数据-决定下一步”的流程,批量回归测试时效率高很多。
还有一个提高效率的小技巧:在FPGA端增加一个回环测试模式。通过某个寄存器控制工作模式,默认正常通信,设置特定值时把RX收到的数据原样从TX发回。这样上位机可以一次性发送几千字节,验证有无丢数和错数,硬件链路是否可靠一目了然。
6.4 从UART走出来:下一步可以尝试的通信接口
UART跑通之后,很多人的下一步是SPI或I2C,这两个是板内通信的常用主角,用来和ADC、传感器、EEPROM等芯片打交道。再往后可能是PCIe或以太网等高速接口。从UART过渡到这些接口,最大的认知跨越是学会“时序图思维”——每个协议本质上都是一张时序图,你把时序图翻译成状态机,把时钟周期数算明白,剩下的就是体力活了。
以SPI为例,它比UART多了时钟信号,但少了异步握手的过程,逻辑上反而更直接。I2C则因为要处理应答位、总线仲裁、多设备寻址,状态机会复杂一些。但如果你能把UART的发送状态机和接收状态机吃透,这些接口的学习曲线会平缓很多。
7. 实测心得:从零到稳定通信的完整经验总结
最后聊聊我在实际调串口过程中踩过的一些坑和总结的经验,也算给准备动手的读者一份“少走弯路清单”。
第一点,工程上大胆使用回环测试。不管是仿真阶段还是上板阶段,用回环法验证是最快定位问题边界的手段。先在FPGA内部把TX和RX短接,确认逻辑链路OK后,再用外部线材连接USB转串口模块验证物理链路。这种“由内向外”的排查顺序,可以避免把逻辑问题和硬件问题混在一起无从下手。
第二点,调试乱码时要优先怀疑波特率误差而不是逻辑错误。乱码的原因排行榜里,波特率配置错误排第一,接线交叉排第二,电平不匹配排第三,最后才轮到代码逻辑。遇到乱码先检查串口助手的波特率设置是否和FPGA分频参数一致,再检查接线是否交叉,然后用示波器或逻辑分析仪看波形。千万不要一上来就翻代码,效率太低。
第三点,模块化设计比“一坨代码”重要得多。发送模块、接收模块、帧解析模块、FIFO模块分开写,每个模块有清晰的输入输出接口和独立的仿真测试。后期调试时,任何一个环节出问题,都能快速锁定模块范围。我见过太多人把收发逻辑写在一个always块里,结果一旦出错,整个模块都要重写。UART只是一个开始,好的模块化习惯会在后续更复杂的工程里成倍回报你。
第四点,关于文档和引脚规划。即使是简单的串口调试,也建议在工程里写一个README,记录波特率、系统时钟、引脚绑定、模块结构这些关键信息。多个项目并行开发的时候,这种记录能省下大量“回忆时间”。
第五点,时钟使能脉冲的写法要养成习惯。无论是UART还是后续的SPI、I2C,凡是用系统时钟分频产生低速定时的地方,尽量用时钟使能信号而不是生成一个新的时钟。这能让整个工程保持单时钟域设计,综合后的时序约束处理简单很多,上板稳定性也有保障。
我最初做UART串口通信FPGA实现的时候,也曾为“仿真对得上、上板发不出”折腾了好几个小时,最后发现只是USB转串口模块的TX/RX和FPGA接反了。这种经历几乎是每个FPGA学习者的必经之路。但反过来想,正因为UART足够简单,调试链路足够清晰,它才是练习“仿真验证-硬件排查-波形分析”这套方法论最好的项目。把UART彻底玩明白了,后面再接触其他通信协议和复杂数字系统,你会有一种“底层方法论已经打通”的笃定感。