FPGA实战:用Verilog实现高可靠UART串口通信的完整指南

发布时间:2026/9/5 4:03:38
FPGA实战:用Verilog实现高可靠UART串口通信的完整指南 最近在做FPGA相关项目的时候又把手头的UART通信捋了一遍。这玩意儿看起来简单不就是起始位、数据位、停止位嘛但真要在FPGA里做稳、做可靠细节其实不少。今天就把我这次用Verilog在FPGA上实现UART串口通信的完整过程写出来从协议分析到代码实现再到板上调试踩过的那些坑一次说清楚。1. 内容整体设计与思路拆解先说点实在的UARTUniversal Asynchronous Receiver/Transmitter通用异步收发器算是数字系统和外界打交道最朴素的方式了。没有时钟线就靠一根TX一根RX两边约定好速率就能把数据传出去。这在FPGA项目里太常用了板卡调试、和单片机通信、接各种带串口的传感器模块、甚至升级固件都离不开它。我这次的需求很简单FPGA作为下位机要把采集到的一组16位传感器数据通过串口发给上位机PC同时能接收上位机下发的配置参数。接口电平走板载的USB转串口芯片CP2102虚拟成一个COM口所以FPGA侧直接处理TTL电平的UART协议就行。1.1 核心需求解析用FPGA做UART本质上是在做三件事把并行数据变成串行比特流发出去、把串行比特流恢复成并行数据收进来、保证这两件事的时序是可靠的。听起来简单但实际里面有讲究。举个例子发送模块的核心就是那个分频计数器。系统时钟50MHz要产生9600bps的波特率分频系数怎么算很简单50_000_000 / 9600 5208。这表示每计满5208个时钟周期就输出一个比特。但波特率不是只能选9600115200也是常用档位所以代码里最好把这个做成参数用的时候改参数就行别写死在always块里。接收模块比发送麻烦一点因为异步通信没有时钟参考接收端得自己从数据线上“嗅”出起始位来。这里有一个经典做法在空闲状态下检测到RX线从高拉低就认为是起始位前沿然后等半个比特周期再采一次确认确实是低电平防止毛刺误触发。确认之后从起始位中心点往后每隔一个比特周期采样一次数据位。这样做的好处是每次采样都落在比特的中心附近对时钟偏差和信号畸变的容忍度最高。1.2 为什么用FPGA而不是直接用单片机这个问题经常有人问既然单片机都有现成UART外设干嘛还要在FPGA里折腾。我觉得看场景如果是做产品原型验证主控是FPGA为了一个串口再加一颗单片机纯粹多此一举如果要做多路串口比如8路、16路单片机的外设数量就是瓶颈而FPGA里例化8个UART模块只是“复制粘贴”的事资源占用几乎可以忽略再一个FPGA上实现的UART完全可控时序上想怎么调就怎么调不像单片机的外设寄存器那样受限于芯片设计。另外还有一个很实际的需求很多FPGA项目里串口不只是调试用还要承担协议解析的功能。比如我这次要用串口接收上位机的配置帧帧格式是自己定义的所以在FPGA里直接写状态机解析就行比单片机里中断来中断去要直观得多也不容易丢数据。1.3 项目整体架构规划我这次实现的UART模块是完整可复用的整体架构分四层顶层模块uart_top负责例化发送、接收和FIFO缓冲对外暴露简单的并行接口波特率发生器baud_gen独立分频产生发送和接收用的节拍信号发送模块uart_tx并行转串行输出带发送完成标志接收模块uart_rx检测起始位采样恢复数据输出并行数据和接收完成标志为什么要把波特率发生器独立出来因为发送和接收虽然速率一致但节奏不同发送是“给数据就发”接收是“线上一有动静就得处理”。分开模块化之后我可以单独对接收做16倍过采样而不影响发送逻辑这在波特率较高时尤其重要。后面我会详细说这个16倍过采样的设计。2. 核心细节解析与实操要点2.1 UART协议帧格式拆解先把这个最基础的东西彻底说透。UART的帧结构老生常谈但还是有很多新手在起始位和停止位上栽跟头。一个标准的数据帧长这样空闲时TX/RX线保持高电平发送方要发数据时先拉低一个比特周期这是起始位然后从低位到高位依次发送5到8个数据位接着是一个可选的校验位奇校验或偶校验最后拉高至少一个比特周期这是停止位。这里有几个关键点空闲电平是高不是低。很多第一次写发送模块的人容易搞反把空闲拉低结果对端收到的全是垃圾数据数据位是低位先发。比如要发送0x53二进制0101_0011线上顺序是1-1-0-0-1-0-1-0LSB first停止位至少1位常见的有1位、1.5位、2位通信双方要约定一致我用一个状态机来描述发送过程状态切换非常清晰localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; // 发送状态机核心逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx_state IDLE; tx_data_r 8d0; tx_bit_cnt 3d0; tx_line 1b1; end else begin case (tx_state) IDLE: begin if (tx_start) begin tx_data_r tx_data_in; // 锁存待发送数据 tx_bit_cnt 3d0; tx_state START; end end START: begin tx_line 1b0; // 拉低一个比特周期 if (baud_pulse) tx_state DATA; end DATA: begin tx_line tx_data_r[tx_bit_cnt]; // 从bit0开始发送 if (baud_pulse) begin if (tx_bit_cnt 3d7) tx_state STOP; else tx_bit_cnt tx_bit_cnt 1b1; end end STOP: begin tx_line 1b1; // 拉高停止位 if (baud_pulse) begin tx_state IDLE; tx_done 1b1; // 发送完成标志 end end endcase end end2.2 波特率发生器的设计思路波特率这东西是UART通信的命门。两边速率对不上数据全是乱的。FPGA里产生波特率有两种常用方法一种是直接分频产生一个与比特率相同的使能脉冲另一种是生成一个高速时钟然后再分频。我推荐第一种简单可靠。关键代码逻辑是这样的parameter CLK_FREQ 50_000_000; // 系统时钟50MHz parameter BAUD_RATE 115_200; // 目标波特率 parameter BAUD_DIV CLK_FREQ / BAUD_RATE; // 分频系数 reg [15:0] baud_cnt; reg baud_pulse; always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 16d0; baud_pulse 1b0; end else begin if (baud_cnt BAUD_DIV - 1) begin baud_cnt 16d0; baud_pulse 1b1; end else begin baud_cnt baud_cnt 1b1; baud_pulse 1b0; end end end注意这里baud_pulse只拉高一个时钟周期相当于一个“节拍使能”。发送模块检测到这个脉冲才知道“该往线上送下一个bit了”。对于接收端如果只做同步采样要求两边时钟偏差在2%以内才算稳这在大多数场景下是够的。但如果波特率比较高比如921600或者对误码率有严格要求就必须做倍频采样了。常用的是16倍过采样每个bit周期采16个点取中间3个点做多数表决能有效滤除毛刺和边沿抖动。这样做的好处是即使起始位检测出现半个bit的偏差后面每个bit的采样点也仍然落在比特中心附近容错能力强很多。代价是内部需要产生一个BAUD_RATE * 16的采样时钟对FPGA来说资源开销不大很划算。2.3 接收模块的时序设计接收模块是UART实现里最有技术含量的部分。核心链路就是空闲检测 - 起始位确认 - 数据位采样 - 停止位校验。先看起始位确认这一步决定后面所有采样点的参考起点不能马虎。代码如下// 边沿检测检测RX线从高到低 reg rx_d0, rx_d1; wire rx_negedge; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d0 1b1; rx_d1 1b1; end else begin rx_d0 rx_in; rx_d1 rx_d0; end end assign rx_negedge rx_d1 ~rx_d0; // 下降沿检测检测到下降沿后不能急着认定是起始位。万一是个毛刺呢所以标准做法是延迟半个bit周期再采一次。如果此时RX线确实是低电平才确认起始位有效如果不是说明刚才只是干扰丢弃。这个“半bit延迟”是实现里的一个细节但直接影响误码率。我用一个半bit计数器来实现在检测到下降沿时启动计数到BAUD_DIV/2时采样RX线// 半bit确认逻辑示意 if (rx_negedge rx_state RX_IDLE) begin half_cnt_en 1b1; end if (half_cnt BAUD_DIV/2 - 1) begin half_cnt_en 1b0; if (rx_in 1b0) rx_state RX_START_CFM; // 确认是起始位 else rx_state RX_IDLE; // 假起始位回空闲 end确认起始位之后后面的数据位采样就按部就班了。每间隔一个bit周期采一次连续采8次就是8个数据位。注意采样点不要选在bit的边界上要选在中间。因为信号跳变都发生在边界附近中间位置是最稳定的区域。2.4 收发FIFO缓冲的设计意义在真实项目里上位机下发的可能是一整包数据里面包含帧头、命令、数据、校验。如果FPGA主逻辑正在忙别的事比如在做图像处理来不及立刻处理收到的字节数据就会丢。所以我在uart_top里加了一个异步FIFO做缓冲接收模块把每字节数据丢进FIFO主逻辑有空了再读出来解析。这样收发双方速率不匹配的问题就解决了。FIFO的实现可以直接用FPGA厂商的IP核也可以自己用RAM搭。我这次用的是Xilinx的FIFO Generator IP配成标准模式深度设成1024。注意异步FIFO的读写时钟可以不同对于串口来说通常读写时钟相同但用异步FIFO的好处是将来如果系统里有多时钟域模块不用改。3. 实操过程与核心环节实现3.1 硬件平台与开发环境我手头的板子是Xilinx Artix-7系列的FPGA开发板板载CP2102 USB转串口芯片通过USB连到PC。系统时钟用的板载100MHz晶振经过MMCM/PLL分频到50MHz供逻辑使用。开发环境是Vivado 2022.2仿真用的自带XSim也可以用ModelSim看个人习惯。接线方面很简单FPGA的UART_TX接CP2102的RXDFPGA的UART_RX接CP2102的TXD交叉连接共地。如果是RS232电平的设备中间还需要MAX3232之类的电平转换芯片不过现在大部分情况都是TTL直接怼USB转串口省事很多。3.2 顶层模块的完整实现我把一个完整的、可用的uart_top写在这里各位可以直接拿去做参考根据自己的需求改端口即可module uart_top #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115_200, parameter PAYLOAD_W 8 // 数据位宽支持5~8 )( input wire clk, input wire rst_n, // UART接口 input wire uart_rx, // 从上位机接收 output wire uart_tx, // 向上位机发送 // 用户接口 input wire [PAYLOAD_W-1:0] tx_data, input wire tx_valid, output wire tx_ready, output wire [PAYLOAD_W-1:0] rx_data, output wire rx_valid, // 状态输出 output wire tx_busy, output wire rx_busy ); wire baud_pulse; wire rx_pulse; // 接收采样时钟16倍过采样时用 // 波特率生成 baud_gen #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE), .OVERSAMPLE(OVERSAMPLE) // 1为同步模式16为过采样模式 ) u_baud_gen ( .clk (clk), .rst_n (rst_n), .baud_pulse (baud_pulse), .sample_pulse(rx_pulse) ); // 发送模块 uart_tx #( .PAYLOAD_W(PAYLOAD_W) ) u_tx ( .clk (clk), .rst_n (rst_n), .baud_pulse(baud_pulse), .tx_data (tx_data), .tx_valid (tx_valid), .tx_ready (tx_ready), .tx_busy (tx_busy), .tx_line (uart_tx) ); // 接收模块 uart_rx #( .PAYLOAD_W(PAYLOAD_W) ) u_rx ( .clk (clk), .rst_n (rst_n), .baud_pulse(baud_pulse), .sample_pulse(rx_pulse), .rx_line (uart_rx), .rx_data (rx_data), .rx_valid (rx_valid), .rx_busy (rx_busy) ); endmodule3.3 发送模块的细节完善上面的发送状态机是核心框架但实际项目里还要处理几个边界情况一是tx_valid和tx_ready的握手机制。主逻辑拉高tx_valid表示有数据要发发送模块拉高tx_ready表示自己空闲可以接收。两者同时为高数据才算成功写入发送模块。这个机制防止了主逻辑在模块正忙着的时候就丢数据。握手的实现很简单assign tx_ready (tx_state IDLE); always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx_start 1b0; end else begin if (tx_valid tx_ready) begin tx_start 1b1; // 握手成功启动发送 end else begin tx_start 1b0; end end end二是发送完成标志的时序。tx_done这个标志将停止位的最后一个bit周期拉高主逻辑可以用它来判断一帧数据发完了可以发下一帧了。这里有个细节停止位发出后tx_line拉高但线缆上有电容电平爬升需要时间所以tx_done最好在停止位中间位置才拉高给线路留出足够的建立时间。我以前就是因为tx_done拉太早导致对端单片机偶尔收错数据后来把这个标志推迟了半个bit才解决。3.4 接收模块的16倍过采样实现详解前面提到过我对接收模块采用了16倍过采样。具体做法是用sample_pulse频率 波特率 x 16作为采样时钟对RX线进行采样。在每个bit内可以采16个点取中间的3个点第7、8、9个采样点做多数表决决定这个bit是0还是1。这样做的好处有两个一是能滤除毛刺单点毛刺不会影响判断二是对起始位的检测更精确因为下降沿发生在哪个采样点之间是可以确定的误差小于1/16 bit。多数的表决逻辑非常简洁wire [2:0] samples; assign samples {rx_sample[2], rx_sample[1], rx_sample[0]}; // 三取二多数表决 assign bit_value (samples[0] samples[1]) | (samples[0] samples[2]) | (samples[1] samples[2]);在实际代码里我会用一个移位寄存器把RX线连续打三拍然后做上面的组合逻辑。只要不是三个采样点全部同时出错bit值就是对的。这在工业现场噪声比较大的环境中特别有用。3.5 收发回环测试的代码实现一个最快的验证方式就是回环测试把FPGA的TX和RX直接短接或者通过USB转串口工具把TX和RX短接然后发送模块发什么接收模块就应该收到什么。这是验证UART模块最基本的方法。我在板级测试时写过一段简单的测试逻辑核心思路是收到一个字节就把这个字节原封不动发回去。相当于FPGA变成了一个“串口回显器”。用PC上的串口助手发送Hello FPGAFPGA返回一模一样的Hello FPGA那就说明收发链路是通的。回环逻辑只需要一个状态机// 收到什么发什么 always (posedge clk or negedge rst_n) begin if (!rst_n) begin echo_state IDLE; echo_data 8d0; end else begin case (echo_state) IDLE: begin if (rx_valid) begin echo_data rx_data; echo_state SEND; end end SEND: begin if (tx_ready) begin tx_valid_reg 1b1; tx_data_reg echo_data; echo_state DONE; end end DONE: begin tx_valid_reg 1b0; if (tx_ready) echo_state IDLE; end endcase end end不过要注意有些USB转串口芯片比如CH340、CP2102不支持硬件流控所以回环测试时CTS/RTS引脚必须处理好否则会在流控上卡住。我一开始没注意这个问题总是发两个字节就卡死排查了半天才发现是开发板上的流控引脚悬空了把CTS拉高后一切正常。4. 常见问题与排查技巧实录这部分是我最想分享的因为都是在项目里真实踩过、花了不少时间才趟平的坑。4.1 接收数据全是0xFF或者0x00这个问题出现得最频繁。如果接收端收到的全是0xFF全1说明线路上一直处于空闲态可能是发送方没有启动工作或者TX/RX接反了如果收到的是0x00全0说明线一直被拉低最常见的原因是收发模块只发了起始位和停止位但数据位没正确输出或者波特率严重不匹配。排查方法很简单用示波器或者逻辑分析仪直接看RX引脚上的波形一帧一帧地对照协议分析。没有示波器的话也可以用串口助手的hex显示模式先发一个已知字节比如0xA5二进制1010_0101交替高低的波形最容易看问题然后看收回来的是什么然后对照波形排查。4.2 波特率误差导致的乱码这个坑最隐蔽。FPGA的晶振一般是精确的但有些开发板用的晶振是12MHz、25MHz、甚至24.576MHz这类非整数倍频的如果在生成波特率时直接把分频系数四舍五入误差就出来了。举个例子系统时钟25MHz目标波特率115200分频系数 25_000_000 / 115200 217.0138。取整数217后实际波特率 25_000_000 / 217 115207.37偏差约0.006%完全没问题。但如果系统时钟是33.333MHz这种带小数的误差就可能到0.5%以上连续传8个数据位累积位偏移就会超过半个bit周期导致采到错误数据。应对方法有两个一是尽量用系统时钟晶振的整数倍频结果在PLL里先倍频再分频把时钟调到能整除的频率二是**用小数分频器NCO**代替整数计数器用累加器的方式产生平均意义上的精确波特率。后者的实现也不复杂就是每周期加一个步进值溢出时输出一个脉冲这个步进值 (目标波特率 x 2^N) / 系统时钟。4.3 起始位误触发导致的多收数据接收模块在空闲时检测到下降沿就会认为起始位来了但如果线上有电源噪声、电磁干扰或者相邻信号线的串扰也可能产生下降沿毛刺导致接收模块以为收到了一帧数据实际都是噪声。这个问题在工业现场特别突出。我的解决方法是在空闲状态下如果检测到下降沿启动一个定时窗口比如1个bit周期在这个窗口内RX线必须持续为低否则判断为毛刺并回到空闲状态。这比单纯的半bit确认又要稳健一些因为它要求整个起始位期间都是稳定的低电平而不是只在某一个点采样。4.4 上位机收不到数据但回环测试正常如果你的回环测试通过但PC就是收不到FPGA发的数据问题大概率出在PC侧的串口配置或者USB转串口驱动上。先检查设备管理器里COM口号是否存在CP210x驱动的安装是否正常Windows下经常要手动装驱动系统自带的驱动有时候版本不对然后用串口调试助手设置好波特率、数据位8、停止位1、无校验、无流控。这里特别强调一下很多调试软件默认是带流控的RTS/CTS如果你只接了三根线TX、RX、GND遇到带流控的默认配置就会卡死。把流控选项改成无大概率就好了。另外USB转串口芯片比如FT232R、CP2102有些型号需要安装官方驱动才能在Win10/Win11下正常工作不要只要插上就能识别。用Zadig之类的工具装一下WinUSB驱动或者去芯片官网下对应驱动都是常规操作。4.5 长时间通信后偶尔出现一个错误字节这个是偶发问题排查起来最费劲。现象是通信大半天一切正常然后突然冒出一个错字节之后再也没事。这种偶发的瞬时错误多半是异步信号没有同步导致亚稳态。如果你的接收模块直接拿外部进来的RX线打了一拍就进状态机那么在时钟边沿附近RX线恰好变化时触发器的建立/保持时间可能不满足输出进入亚稳态后面跟着的组合逻辑就会判断出错误的值。解决办法就是对所有异步输入先做两级同步reg rx_sync1, rx_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync1 1b1; rx_sync2 1b1; end else begin rx_sync1 uart_rx; // 第一级同步 rx_sync2 rx_sync1; // 第二级同步 end end然后用rx_sync2去做后续的逻辑判断。这一招几乎是所有跨时钟域信号处理的标配FPGA里处理按键消抖、外部中断、串口信号都建议先同步再处理。4.6 常见问题速查表问题现象可能原因排查步骤收不到任何数据接收全0xFFTX/RX接反1. 检查交叉连接 2. 用回环短接验证模块本身收到但全乱码波特率不一致或误差超限1. 对比两端波特率配置 2. 示波器量波形算实际波特率前几个字节对后面全错起始位检测异常或FIFO溢出1. 检查FIFO深度 2. 加半bit确认偶发错字节亚稳态RX信号加两级同步能发不能收接收状态机异常1. 检查接收模块的valid信号 2. 检查FIFO读使能时序板子一上电就发数据复位释放时TX线未拉高TX状态机上电初始化必须置14.7 调试技巧善用逻辑分析仪和ILA在Vivado里调试UART模块最方便的就是集成逻辑分析仪ILA。把RX线、TX线、状态机状态、接收到的数据都加进去触发条件设为rx下降沿然后点开串口助手发送一个字节就能在波形窗口里看到接收模块的完整工作过程。状态机是怎么跳转的、每个bit是在哪个时刻采样的、数据的LSB first顺序是不是对的全都能一目了然。如果没有Vivado的license或者用的是其他家的FPGA也可以用廉价的USB逻辑分析仪几十块钱那种直接量引脚波形虽然采样率可能不够看高速信号但看115200的UART完全足够。我自己反而更常用逻辑分析仪因为不用改动RTL代码、不用重新综合布线插上引脚就能看波形调试节奏快很多。5. 进阶实践与上位机联调与协议设计模块调通以后实际项目里还要做一层协议封装。裸UART只能传字节但业务数据是结构化的。如果上位机想给FPGA下发一组配置比如目标速度、工作模式、PID参数没有协议就是一团乱麻。我一般的做法是定义一个简易帧格式帧头 命令字 数据长度 数据体 校验和。上位机按这个格式组包发送FPGA在接收模块里解析具体流程是识别帧头0xAA 0x55进入接收状态收命令字1字节决定这次操作是读还是写收数据长度1字节决定后面要收几字节数据逐字节收数据体并存入寄存器数组收校验和和前面所有字节累加比对一致才执行命令否则丢弃整帧并返回错误标志这个解析逻辑在FPGA里用状态机写非常顺手。每次接收模块rx_valid拉高时状态机按顺序把字节分配到对应的处理步骤里。唯一的注意点是状态机必须要有超时保护比如约定好两个字节之间的间隔不能超过100ms超过就重置状态机回等待帧头的状态。否则上位机发到一半程序崩溃FPGA的状态就一直卡在中途了。关于校验我建议不要只用简单的累加和至少用CRC8代价不大但可靠性提升一个档次。CRC8的LFSR实现也很简单16行代码搞定网上模板一大把。别嫌麻烦在工业环境里因为一两个bit的翻转导致整包数据执行错误代价可比写代码大得多。6. 项目复盘与个人心得最后聊点我做这个UART项目整体下来的一些体会。UART虽然看起来是个“小玩意儿”但它教会我的核心思想是时序设计要围绕“建立时间”和“保持时间”这两个概念来思考。什么时候采样最安全——在bit中间。怎么防止亚稳态——多打几拍。怎么设计状态机才能健壮——每个状态都要考虑异常退出的条件。这些思想不止适用于UARTSPI、I2C、甚至高速serdes本质上都是一回事。还有一个心得是能用现成IP就直接用IP别什么都自己手写。Xilinx的UART Lite、Altera的UART IP核都是经过千锤百炼的功能完善还支持中断和FIFO。自己手写UART最适合的场景是学习、少量定制化场合或者像我这样需要完全控制发送时序的底层驱动场景。做项目时间是最大的成本能用现成工具解决的问题别花一周时间去重造轮子。再分享一个实用小技巧波特率参数一定要做成parameter并且在顶层传参。很多人在写底板代码的时候把波特率数值直接写死在计数器里最后要改速率时满代码找。参数化设计是最基本的可维护性要求做任何模块都养成这个习惯能省很多事。这次UART模块实现下来从协议分析、Verilog编码、仿真验证到板级调试整个过程走了一遍收获还是很大的。希望这篇文章能给正在做或者准备做UART串口通信FPGA实现的朋友一些参考少走一些我走过的弯路。有问题欢迎在评论区交流我尽量都回复。