ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FPGA实战:UART串口通信从协议原理到Verilog实现与调试全解析

FPGA实战:UART串口通信从协议原理到Verilog实现与调试全解析 做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 BitN表示无校验、一个停止位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 1b1结果上板发现发不出数据第一反应是引脚连错了其实多半是把电平极性搞反了。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 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; 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 16d0; end else if (clk_cnt BAUD_DIV - 1) begin clk_cnt 16d0; end else begin clk_cnt clk_cnt 1b1; 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和IntelAltera都有现成的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必须连接外部设备的RXFPGA的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 3d0; localparam FRAME_HEADER 3d1; localparam FRAME_LENGTH 3d2; localparam FRAME_DATA 3d3; localparam FRAME_CHECK 3d4;帧解析状态机在收到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彻底玩明白了后面再接触其他通信协议和复杂数字系统你会有一种“底层方法论已经打通”的笃定感。
返回列表