FPGA UART串口通信设计详解:从Verilog源码到板级调试
简介一套Verilog FPGA串口源码工程面向FPGA初学者与嵌入式开发者适用于在Altera平台学习UART异步收发协议、完成串口收发链路从RTL设计到下载验证的完整实践。压缩包共835个文件、约7.05MB以cdb、hdb等Quartus工程数据文件为主体两类合计693个同时包含tdf、v等硬件描述源文件、sof配置文件、qsf/qpf工程设置、vwf仿真波形、do仿真脚本与txt说明覆盖从编码、仿真、综合到下载调试的完整流程整体目录层次清晰便于按功能定位所需文件。目前已有343人学习下载。代码中提供发送模块、接收模块、波特率发生器、蜂鸣器模块和顶层A4_Uart_Top顶层模块封装了发送与接收通路可清晰拆解起始位、数据位、奇偶校验、停止位处理以及状态机跳转与分频计数逻辑随包的readme、仿真波形和备份文件还能辅助对照时序波形排查问题。适合希望系统学习FPGA串口通信、或直接复用UART控制器进行二次开发的读者。 搞FPGA的人十有八九都在某个深夜对着串口调试助手发过呆。屏幕上要么是乱码要么收不到数据要么发了一串字节过去对面只回了第一个。我在Xilinx 7系列板子上调第一版UART收发器的时候就为这问题熬了整整两晚上。事后复盘发现大多数坑都和协议细节、源码设计有关反而硬件本身出问题的概率很小。UART串口通信是FPGA开发里最基础也最实用的一类外设接口。往小了说它是FPGA和上位机聊天的最简单通道往中了说它能帮你把FPGA内部的关键信号实时打出来省去一堆调试逻辑往大了说像Corundum这种大型开源FPGA网络项目控制平面也是靠UART做管理通道的。这篇文章我就以一份可直接落地的Verilog源码为例把FPGA侧UART串口的设计思路、模块拆分、分频计算、板级调试和踩坑经验完整过一遍。适合刚入门FPGA但想把串口彻底搞明白的同学也适合那些手里有源码但一直被乱码和丢数据折磨的人。1. UART协议拆解一个字节在总线上怎么走UART全称Universal Asynchronous Receiver/Transmitter通用异步收发传输器。它最大的特点是异步——收发双方不共享时钟只要约定好波特率就能按固定节奏把数据一位一位传过去。这个特性让UART在长距离、低成本场景下活得非常好也是它和I2C、SPI最本质的区别。I2C和SPI都是同步通信主设备必须主动产生时钟UART完全靠双方各自的本地位定时器卡节奏协议本身只定义空闲电平、起始位、数据位和停止位。1.1 帧结构起始位、数据位、停止位怎么配合一个标准UART帧长这样空闲时总线保持高电平要发数据时先拉低一个bit时间这叫起始位然后从最低位开始依次送8个数据位协议允许5到8位实际工程里几乎都用8位如果有校验位会在数据位后面跟一位奇偶校验最后拉高至少一个bit时间这就是停止位。接收端就是从起始位下降沿获得开始信号之后按波特率周期一位一位采样。这里有个容易被忽略的点UART协议本身不要求收发双方时钟完全一致只要求波特率误差在可容忍范围内。一般RS-232标准允许3%左右的误差工程上我习惯把误差控制在1%以内最好0.1%。为什么这么严格因为误差会累积一帧有10到11个bit每个bit的采样点都在偏移累积到最后几个bit就很容易采错。这就是为什么很多板子115200通信一切正常一旦换到921600甚至更高就开始乱码——波特率越高每个bit时间越短同样的时钟漂移占比就越大。1.2 异步通信为什么能行误差容忍度分析打个比方UART就像两个人约好每秒走一步谁都不用看对方的表只要双方步频差不多短时间内不会脱节。如果一个人走快了头几步看不出来但几十步之后位置就完全错开了。落到数字上还是用50MHz时钟跑115200波特率算。分频系数DIV_CNT 50_000_000 / 115200 434.027...取整数434实际波特率变成50_000_000 / 434 115207.37误差约0.006%。这个误差对10个bit的帧来说累计偏移只有0.06%远在安全线以内。但你换个组合试试8MHz时钟跑921600波特率DIV_CNT 8_000_000 / 921600 8.68取整8之后实际波特率变成1_000_000误差直接飙到8.5%远超3%的容忍上限通信必然不稳定。所以选波特率之前先算一笔误差账比事后在代码里加各种滤波补偿管用得多。如果误差超标优先换更高的系统时钟或者选一个更“整”的波特率组合。2. 源码架构波特率发生器、发送模块、接收模块怎么拆我见过很多人写UART源码把收发逻辑全塞进一个always块状态机里一堆标志位互相纠缠看得人头大。FPGA里这样写不是不能跑但一旦要扩展——加FIFO、加换行处理、加校验位——代码会迅速变成一团乱麻。我的习惯是把UART拆成波特率发生器、发送模块、接收模块三个独立小模块再用顶层文件例化到一起每个模块只干一件事。2.1 分频系数计算与误差验证波特率发生器的职责只有一个根据系统时钟和目标波特率产生周期性的采样使能脉冲。核心参数就两个时钟频率和波特率DIV_CNT CLK_FREQ / BAUD_RATE以50MHz、115200为例DIV_CNT等于434。注意这里一定用整除后取整的值但取整会引入误差所以选参数时要按上一节的方法验证误差是否在容忍范围。计数器位宽不用手动算用Verilog内置的$clog2函数它会自动返回一个数最少需要多少bit来编码localparam CNT_WIDTH $clog2(DIV_CNT);这个细节在参数修改时特别省事把CLK_FREQ从50MHz改成100MHz位宽自动对齐不会出现计数器溢出或者位宽浪费的问题。2.2 为什么用时钟使能脉冲而不是分频时钟这里有个初学者很容易走弯路的设计决策到底该不该用分频器产生一个独立的波特率时钟再拿这个时钟去驱动发送和接收逻辑我的答案是不要。原因在于FPGA内部对时钟信号有严格约束。如果你用计数器产生一个占空比非50%的信号再接到寄存器的时钟端这个信号是没有经过全局时钟网络的时序收敛很难保证跨时钟域问题也会接踵而至。正确做法是保持所有逻辑在同一个系统时钟域里波特率发生器只输出一个“时钟周期宽”的脉冲使能信号状态机在使能信号有效时才跳转。这就像每个动作的节拍由同一个鼓手控制波特率发生器只负责在每拍告诉状态机“该动一步了”。实际工程里这种单时钟域设计在布局布线和时序分析上都省心得多。3. 核心源码实现可直接复用的Verilog代码3.1 发送模块状态机实现发送端核心状态机就是IDLE、START、DATA、STOP四个状态。IDLE保持TX引脚高电平检测到tx_start信号后锁存要发送的数据并拉低一个bit时间作为起始位DATA状态按bit_cnt从0到7逐位移出数据最后STOP状态拉高一个bit时间回到IDLE。两个细节最容易踩坑。第一个是tx_busy信号发送模块必须对外提供“忙”标志顶层只有在tx_busy拉低后才能发起新请求否则会打断当前帧。第二个是数据锁存时机必须在发起发送的同一个时钟周期把tx_data锁存到内部寄存器而不是在DATA状态才去读外部输入因为外部信号随时可能变化一旦变化发送内容就错了。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg tx, output reg tx_busy ); localparam DIV_CNT CLK_FREQ / BAUD_RATE; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] tx_data_reg; reg [1:0] state; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; baud_cnt 16d0; bit_cnt 3d0; tx_data_reg 8d0; state IDLE; end else begin case (state) IDLE: begin tx 1b1; if (tx_start) begin tx_data_reg tx_data; tx_busy 1b1; baud_cnt 16d0; state START; end else begin tx_busy 1b0; end end START: begin tx 1b0; if (baud_cnt DIV_CNT - 1) begin baud_cnt 16d0; bit_cnt 3d0; state DATA; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin tx tx_data_reg[bit_cnt]; if (baud_cnt DIV_CNT - 1) begin baud_cnt 16d0; if (bit_cnt 3d7) begin state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin tx 1b1; if (baud_cnt DIV_CNT - 1) begin baud_cnt 16d0; state IDLE; tx_busy 1b0; end else begin baud_cnt baud_cnt 1b1; end end endcase end end endmodule简单测试场景连续发送0x55和0xAA用仿真观察TX引脚波形就是01010101和10101010规律的方波一眼就能确认协议是否正确。3.2 接收模块中间采样与毛刺过滤接收端比发送端讲究。发送端自己控制节奏接收端完全不知道对方什么时候发数据只能靠RX引脚的电平变化来找“起点”。所以接收端的IDLE状态会一直监测RX线一旦捕获下降沿就认为起始位来了。但注意捕获下降沿之后不能立刻开始采数据。起始位本身可能有毛刺或者上个停止位刚结束、线上噪声导致电平跳变。稳妥做法是在起始位中点再确认一次电平确实是低确认后才进入DATA状态如果发现电平与预期不符立刻回IDLE等下一个下降沿。这就是起始位确认相当于先验一次货再收。数据位采样也遵循相同逻辑不在bit起点采在bit中点采。为什么从起始位下降沿开始每个bit的边界都有可能跳变而中点离跳变沿最远受信号上升沿、下降沿和噪声影响最小。MCU的硬件UART普遍采用16倍波特率过采样然后在中间位置采样和这个思路是同一个道理。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_done ); localparam DIV_CNT CLK_FREQ / BAUD_RATE; localparam MID_CNT CLK_FREQ / BAUD_RATE / 2; localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] rx_data_reg; reg [1:0] state; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data 8d0; rx_done 1b0; baud_cnt 16d0; bit_cnt 3d0; rx_data_reg 8d0; state IDLE; end else begin case (state) IDLE: begin rx_done 1b0; if (rx 1b0) begin state START; baud_cnt 16d0; end end START: begin if (baud_cnt MID_CNT) begin if (rx 1b0) begin state DATA; baud_cnt 16d0; bit_cnt 3d0; end else begin state IDLE; end end else begin baud_cnt baud_cnt 1b1; end end DATA: begin if (baud_cnt DIV_CNT - 1) begin baud_cnt 16d0; rx_data_reg[bit_cnt] rx; if (bit_cnt 3d7) begin state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin if (baud_cnt DIV_CNT - 1) begin baud_cnt 16d0; state IDLE; rx_data rx_data_reg; rx_done 1b1; end else begin baud_cnt baud_cnt 1b1; end end endcase end end endmodule注意采样的是rx引脚寄存后的值不是组合逻辑直接读引脚这是避免亚稳态的基础操作。rx输入在进入状态机前最好也打一拍让处理逻辑拿到的永远是稳定的同步值。3.3 顶层回环自测板级调试前的必要一步顶层模块就是简单例化。这里我强烈建议在接外部设备前先做一次回环自测把顶层模块的TX引脚直接连到RX引脚在FPGA内部把发送的数据原样接收回来。如果回环自测通过说明发送和接收链路本身没问题如果连回环都不通那先别碰外部设备回头查逻辑和仿真。module uart_loopback #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output wire tx, output wire rx, output wire [7:0] rx_data, output wire rx_done ); wire tx_busy; uart_tx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_tx ( .clk (clk), .rst_n (rst_n), .tx_data (tx_data), .tx_start (tx_start), .tx (tx), .tx_busy (tx_busy) ); uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx), .rx_data (rx_data), .rx_done (rx_done) ); endmodule在顶层里把tx和rx在外部连起来或者直接在仿真testbench里把两个端口短接然后用串口调试助手发一个字节看能不能原样收回来。这步做通了后面接USB转串口模块才分得清是逻辑问题还是接线问题。4. 板级调试与串口工具实战4.1 USB转串口芯片CH340、CP2102、FT232驱动选择现在主流FPGA开发板基本都集成了USB转串口电路常见芯片三种CH340、CP2102、FT232R/FT231X。CH340是国产芯片成本低、驱动好装多数开发板默认方案CP2102来自Silicon Labs驱动同样非常成熟Windows和Linux下通常都有系统级驱动FTDI的FT232R/FT231X在工业环境更常见稳定性好但价格略高。对调试UART来说三者功能上没有本质区别只要驱动能正常识别出虚拟COM口就行。一个实用建议第一次插上USB线后别急着打开串口助手。先去设备管理器看虚拟COM口编号是否正常识别。如果显示带黄色感叹号的未知设备多半是驱动没装对。CH340在Win10以后基本能自动联网装驱动CP2102和FT232大多免驱但如果插上没反应去对应官网下驱动手动安装装完重启一下设备管理器再确认。4.2 串口助手参数配置与测试向量串口助手的参数要和FPGA侧协议完全对齐波特率、数据位、停止位、校验位四样一个都不能差。这里容易被忽略的是“换行”设置。很多串口助手默认发送时会在数据后面追加\r\n如果你的FPGA接收端不过滤收到的十六进制数据里就会夹杂0x0D和0x0A看着像是莫名其妙多了两个字节。调试阶段我建议一律用十六进制模式发送固定值比如0xAA、0x55。这两个值的二进制分别是10101010和01010101在示波器上看就是规整方波非常适合肉眼检查时序。如果手里有逻辑分析仪对着波特率周期数格子能直接看出起始位、数据位是否按预期排列。4.3 仿真testbench怎么写用现代FPGA开发环境自带的仿真器就够了Vivado xsim、VCS、ModelSim都行不需要额外折腾工具。调试UART的testbench不难给tx_start发一个脉冲等几十微秒观察TX引脚波形是否按“低起始位8位数据高停止位”输出接收侧手动模拟一个下降沿然后按波特率周期拉RX引脚看rx_done是否正常拉高、rx_data是否正确。模拟上位机发送一个字节的核心task大概是这样的localparam BIT_TIME 8_680; // 115200波特率下一个bit约8.68us单位ns task uart_send_byte(input [7:0] byte_data); integer i; begin rx 1b1; #(BIT_TIME); rx 1b0; // start bit #(BIT_TIME); for (i 0; i 8; i i 1) begin rx byte_data[i]; #(BIT_TIME); end rx 1b1; // stop bit #(BIT_TIME); end endtaskBIT_TIME一般是用localparam根据CLK_FREQ和BAUD_RATE推导出来的不要写死。这样改参数后testbench不用跟着改防止测试环境与设计参数失配。5. 多字节传输与常见问题排查5.1 乱码问题排查清单乱码是UART调试里最常见的问题现象就是串口助手能收到数据但显示的内容完全对不上。大部分情况是波特率不匹配。我踩过的一个真实案例板子用50MHz晶振源码参数写9600串口助手却设成115200结果就是隔一段时间才蹦出一个正确字节其余全是乱码。原因很简单9600的bit时间是115200的12倍接收端采样点全部错位。排查顺序建议这样先确认串口助手波特率和FPGA源码参数完全一致然后有示波器就量TX引脚数格子算实际波特率对比目标值最后检查系统时钟是否真的等于源码里的CLK_FREQ参数。很多板载晶振标称50MHz实际可能经过PLL分频导致源码参数和实际时钟不一致乱码就是必然结果。5.2 收不到数据接线方向和共地问题TX接RX、RX接TX这句话每个调过串口的人都背过但接线时还是容易接反。FPGA的TX要接USB转串口芯片的RXFPGA的RX要接芯片的TX。用开发板集成串口电路一般不用操心接线但自己飞线连接模块时一定要用万用表确认两边信号定义。另一个高频原因是共地。UART是单端信号收发双方必须共地才能保证电平判断一致。两个设备各自供电、中间没接GNDRX引脚上的电平就可能悬浮收到的数据全是错的。解决办法就一句话GND接GND别偷懒。5.3 多字节发送丢数据的处理方案单字节通信没问题一扩展到多字节就出乱子这通常是发送请求时序问题。最典型的原因顶层在tx_busy还没拉低时就再次拉高tx_start而发送模块IDLE状态才响应新请求导致前一个请求被覆盖或丢掉。我在实际项目里踩过这个坑。当时FPGA要向上位机上报一组传感器数据连续发50个字节我用for循环在一个时钟周期里连续拉tx_start结果数据乱得没法看。后来改成在顶层加一个FIFO缓存发送模块每发完一字节自动从FIFO取下一字节问题立刻解决。所以如果你想做连续收发FIFO几乎是绕不过去的组件。没有FIFO也可以退一步代码里强制等待tx_busy拉低后再发下一字节只是效率会低一些。5.4 常见问题速查表现象可能原因排查方法乱码波特率不匹配核对源码参数和串口助手设置乱码分频误差过大计算DIV_CNT取整后实际误差收不到数据TX/RX接反检查连线方向收不到数据没有共地连接GND收到多余字节换行符干扰切换十六进制模式发送首字节丢失发送请求时序问题等待tx_busy拉低再发偶发错位未做起始位确认检查START状态是否判电平最后再分享一个实用小技巧调试UART接收时可以把接收到的字节低4位连到开发板上的4个LED发什么数能直接从灯上看到比盯着波形图和串口助手判断快得多。或者串口助手发一个特定字节FPGA收到后点亮某个LED这样整个接收链路通不通一眼就能确认。这种“用最简单的现象验证最复杂的逻辑”的做法在FPGA调试里永远不过时。本文还有配套的精品资源点击获取