简介:面向FPGA初学者、电子设计竞赛选手及有串口通信开发需求的工程师,这份基于Altera平台的UART串口通信Verilog源码,提供了完整可用的工程实现。源码包含A4_Uart_Top顶层模块,并分解出UART发送模块、接收模块、波特率发生器及蜂鸣器控制等多个子模块,详细覆盖起始位、数据位、奇偶校验、停止位的时序处理,通过状态机完整管理空闲等待、同步接收、校验判断与数据输出的收发流程。压缩包大小约7.05MB,共835个文件,既有V、TDF硬件描述源码和Quartus工程配置文件,也有大量综合仿真过程文件、波形文件和可下载的SOF配置,目录结构完整,既可直接下载运行,也便于逐个模块研读。附带的编译、综合与仿真报告能辅助理解从RTL代码到FPGA布局布线的全流程,并可根据实际需求调整波特率参数与校验方式。目前已有343人学习,适合希望深入掌握UART协议并动手实践FPGA串口设计的软硬件开发者。 做FPGA的人,几乎没人绕得过UART。哪怕你做的是DDR、PCIe、图像处理,最终调试阶段大概率还是要靠一个串口往电脑上打印点数据。这个协议简单到用几根线就能讲完,但真到写源码、上板调试的时候,各种问题就冒出来了——接收乱码、丢字节、时序不对、驱动装不上。而且网上随便一搜“FPGA uart串口源码”,能出来几百个版本,代码水平参差不齐,有的能用,有的是给仿真“特供”的,上板就翻车。
我写这篇文章,就是想把这层窗户纸捅破。我会从UART协议本身聊起,把发送、接收、波特率生成、仿真验证、上板调试这条链路完整梳理一遍,把每个模块的关键代码逻辑讲透,最后再聊一聊从“能通”到“好用”的几个改造方向。无论你是刚接触FPGA的入门者,还是正在调一个复杂项目需要UART做辅助通道的开发者,这篇文章应该都能给你一些实在的参考。
1. UART帧格式与设计边界:先把“简单”两个字拆开看
1.1 协议层:一帧数据是怎么走的
UART的全称是Universal Asynchronous Receiver/Transmitter,异步收发器。“异步”是它的核心特征——收发双方不需要共享时钟,只要约定好波特率,按相同的时间节拍解析电平变化。
一帧标准UART数据包含以下几个部分:
- 空闲态:总线保持高电平。
- 起始位:发送端拉低1个位周期,告诉接收端“数据要开始了”。
- 数据位:通常5~8位,多数场景用8位,低位先发。
- 校验位(可选):奇校验、偶校验或无校验,用于简单检错。
- 停止位:拉高1位、1.5位或2位,标志一帧结束。
比如发送0x55(二进制0101_0101),无校验、1位停止位,波形就是:一个低电平起始位,然后是8个数据位(先是LSB,再是MSB),最后拉高一个停止位。
这里面有个关键点:UART收发双方只靠“约定波特率”来同步。接收端不知道数据什么时候来,只能通过检测起始位的下降沿来对齐采样时刻。这跟SPI、I2C这类带时钟线的同步协议有本质区别。很多初学者搞混uart、i2c、spi三者的适用场景,其实一句话就能分清楚:UART适合两个设备之间点对点低速通信,不需要额外时钟线;I2C用两根线挂多个设备,速度慢但省引脚;SPI速度快、全双工,但需要额外的CS线来选设备。串口调试、GPS模块、蓝牙模块这些场景,UART基本是标配。
1.2 实现层:源码该分成几块
拿到一份UART源码,先别急着复制粘贴,先把模块边界理清楚。一个工程上可用的UART源码,至少应该包含以下三块:
uart_tx发送模块:负责把并行数据变成串行位流,按波特率逐位发送。uart_rx接收模块:负责检测起始位、按位采样、把串行数据拼成并行字节。- 顶层封装(如
uart_top或uart_loopback):例化收发模块,处理握手信号。
如果源码里没有清晰的模块划分,所有逻辑堆在一个文件里,那只能用来交作业,不建议直接用在项目里。因为一旦出问题,你根本没法定位是发送的问题还是接收的问题。
另外需要注意:很多网上的源码把接口设计得特别“教学化”——只有时钟、复位、数据线、发送使能,没有busy信号、没有握手。这种代码仿真没问题,但接到真实系统里,你根本不知道什么时候能发下一个字节,很容易丢数据。后面我会详细讲工程化改造。
2. 波特率生成器:分频参数背后的误差账
2.1 从50MHz得到115200波特率
波特率生成是所有UART逻辑的地基。一个位周期多长,取决于系统时钟和分频参数。
假设系统时钟是50MHz,目标波特率是115200。那么一个位周期需要的时钟个数是:
$$50_000_000 / 115_200 = 434.0278$$
这个结果不是整数,所以分频后必然存在误差。取接近的整数值434,实际波特率为:
$$50_000_000 / 434 = 115_207.37$$
误差只有0.006%,完全在UART的容错范围之内。
Verilog里常见的写法是:
localparam BAUD_CNT = 50_000_000 / 115_200 - 1; // 433这里减1是因为计数器从0开始计数,数到BAUD_CNT时正好经历了BAUD_CNT+1个时钟周期,也就是434个周期。
注意:分频参数务必用
localparam在模块内部定义,不要直接写死数字。这样换时钟频率或换波特率时,只需要改顶层参数,不用到处改代码。
2.2 误差容限与参数化设计
UART接收端能容忍多大的波特率误差?理论上,只要采样点不落入相邻位区间就不会错。一位周期误差若是超过一定范围,累积误差会导致采样点偏移,尤其在连续接收多字节时更容易出错。工程经验值是:双方波特率误差控制在2%~3%以内基本没问题,5%是极限工况,不建议挑战。
所以做波特率生成器时,标准的做法是:
- 发送端:每个位周期,计数器从0数到
BAUD_CNT后翻转生成bit_tick脉冲。 - 接收端:检测到起始位后,先等半个位周期(
BAUD_CNT >> 1),让采样点落在数据位中央,然后每隔一个BAUD_CNT采样一次。
接收端为什么要等半拍?因为起始位下降沿的检测和实际位边沿之间有不确定性,直接在每个位周期起点采样,万一刚好赶在电平变化瞬间,就可能采到毛刺或不确定值。等半拍后再采样,能最大程度避开边沿抖动。
工程上我见过不少人直接用一个计数器从0数到BAUD_CNT/2再加到BAUD_CNT,逻辑也能跑,但代码可读性很差。更清晰的做法是维护一个baud_cnt计数器,到了BAUD_CNT清零,然后在baud_cnt == (BAUD_CNT >> 1)时采样,逻辑直观,也方便调参。
3. 接收端采样:为什么“读电平”这件事没有想象的简单
3.1 起始位检测与亚稳态处理
接收端的核心任务是准确判断每一位的电平。但RXD引脚输入的是外部异步信号,和FPGA内部时钟没有任何相位关系,直接拿这个信号来做边沿检测,存在亚稳态风险。
亚稳态的通俗理解是:触发器的建立/保持时间得不到满足,输出处于一种“既不是0也不是1”的中间状态,需要一定时间才能稳定下来。虽然这种状态最终会落回0或1,但落回哪个值不确定,还可能把不确定值传播给后面的逻辑。
标准做法是两级同步器:
reg rxd_ff1, rxd_ff2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_ff1 <= 1'b1; rxd_ff2 <= 1'b1; end else begin rxd_ff1 <= rxd; rxd_ff2 <= rxd_ff1; end end经过两级寄存器同步后,rxd_ff2可以作为后续逻辑的输入。然后再检测下降沿:
assign start_flag = rxd_ff2_latched & ~rxd_ff2;实际工程中,我习惯再打一拍生成rxd_ff2_d1,用rxd_ff2_d1 & ~rxd_ff2来产生下降沿脉冲。这一步看似多余,其实能避免在检测到下降沿的同一个时钟周期里,采样逻辑和边沿逻辑打架。
3.2 中点采样策略与毛刺抑制
检测到起始位后,接收逻辑进入采样状态。核心思路是:从起始位下降沿开始,计数到半个位周期时确认起始位仍为低,避免毛刺误触发;然后每隔一个完整位周期采样一次数据位。
这里的“采样点落在位中央”并不只是理论上的好看,它直接决定抗干扰能力。真实电平信号在边沿附近会有振铃、过冲,如果采样点靠边沿太近,采到的值可能是乱的。采位中央是最稳妥的。
有的源码为了增强抗干扰,会在采样点连续采样多拍,比如在baud_cnt == MID时连续采3个时钟,取多数表决。对于115200这种速度、FPGA时钟50MHz来说,3个时钟周期约60ns,相对于8.68us的位周期来说窗口很小,属于一种轻量滤波。实际项目中我用得不多,因为多数场景下中点单次采样已经够用;但如果总线较长、环境噪声大,可以考虑加一个。
毛刺抑制还有一个土办法:检测到起始位下降沿后,先等BAUD_CNT/2再检查RXD是不是真的低。如果是低,说明确实来了起始位;如果又变回高,说明是毛刺,直接放弃等待。这个逻辑简单有效,强烈建议在源码里保留。
4. 发送端状态机:从“把字节发出去”到“不丢字节”
4.1 三段式状态机与发送时序
发送端比接收端简单,它只需要在tx_start有效时把并行数据按位发出即可。但简单不等于可以随便写。我见过太多人用一堆计数器硬凑发送时序,代码又长又难维护。
推荐用三段式状态机:
- 空闲态:
txd保持高电平,tx_busy拉低,等待tx_start。 - 起始态:
txd拉低1个位周期,tx_busy拉高。 - 数据态:按位发送8个数据位,每个位周期发送1位。
- 停止态:
txd拉高1个位周期,然后回到空闲态,释放tx_busy。
核心代码如下:
localparam IDLE = 3'd0; localparam START = 3'd1; localparam DATA = 3'd2; localparam STOP = 3'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; txd <= 1'b1; tx_busy <= 1'b0; end else begin case (state) IDLE: begin tx_busy <= 1'b0; if (tx_start) begin state <= START; tx_busy <= 1'b1; end end START: begin txd <= 1'b0; if (bit_done) state <= DATA; end DATA: begin txd <= tx_data[bit_index]; if (bit_done) begin if (bit_index == 7) state <= STOP; else bit_index <= bit_index + 1; end end STOP: begin txd <= 1'b1; if (bit_done) begin state <= IDLE; tx_busy <= 1'b0; end end default: state <= IDLE; endcase end end这里面的bit_done由波特率计数器产生,每个位周期拉高一个时钟。
4.2 顶层封装与回环测试设计
发送和接收模块都完成后,把它们封装到顶层。工程上最常用的第一个测试是回环测试:FPGA把收到的每一个字节原封不动发回去。
module uart_loopback #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 )( input wire clk, input wire rst_n, input wire uart_rxd, output wire uart_txd ); wire [7:0] rx_data; wire rx_done; uart_rx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_rx ( .clk(clk), .rst_n(rst_n), .rxd(uart_rxd), .rx_data(rx_data), .rx_done(rx_done) ); uart_tx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_tx ( .clk(clk), .rst_n(rst_n), .tx_data(rx_data), .tx_start(rx_done), .txd(uart_txd), .tx_busy() ); endmodule回环测试的价值在于:它能把发送和接收串起来,任何一端有问题,都会在PC端的串口助手里暴露出来。这一关过了,后面接入你真正的业务逻辑(比如把ADC数据、图像数据通过UART传回上位机),才有个可靠的基础。
5. 仿真通过不等于上板OK:实测环节的坑
5.1 testbench怎么搭才有参考价值
说句实话,很多网上的testbench写得很敷衍。有的直接把发送模块的输出接到接收模块的输入,然后仿真出一段波形就算完事。这在功能验证层面也许够用,但真到了上板阶段,你会发现仿真的“理想世界”和现实差了十万八千里。
一个有参考价值的testbench,至少应该做到这几件事:
- 模拟真实PC端发送:写一个发数据的task,按波特率逐位生成串行波形,包括起始位、数据位、停止位,送到接收模块的rxd。这样能顺便验证你的接收逻辑是否能在正确的时刻采样。
- 验证回环:发送端发一个字节,检查接收端是否收回来相同的数据,打印对比结果。
- 测试连续发送:一次发多个字节,中间不留大间隔,验证模块在连续数据流下不丢字节。
- 检查时序边缘:把testbench里生成数据的波特率稍微调偏一点(比如调1%~2%),看接收端还能不能正确接收,这能模拟真实世界双方时钟不同源的场景。
task uart_send_byte(input [7:0] data); integer i; begin txd = 1'b1; #(BIT_TIME); txd = 1'b0; // start bit #(BIT_TIME); for (i = 0; i < 8; i = i + 1) begin txd = data[i]; #(BIT_TIME); end txd = 1'b1; // stop bit #(BIT_TIME); end endtask这个task直接把串行波形“喂”给接收模块,比单纯把tx模块接到rx模块更接近真实场景。
5.2 USB转串口驱动与连接器接线
仿真通过后上板,遇到的第一个坑往往不在FPGA侧,而在PC侧——USB转串口芯片的驱动。
现在很多Windows 10/11系统能自动识别FT232R、CP2102这类常见芯片,但也有系统精简、驱动冲突、或者芯片型号太老的情况,需要手动安装驱动。FTDI的FT232R/FT231X用FTDI的VCP驱动,Silicon Labs的CP2102N/CP2102用官方CP210x驱动,这两个是最常见的,建议提前下载好备用。驱动装好了,设备管理器里才能看到COM口号,串口助手才能打开。
接线是第二个坑。最经典也最常见的错误是TXD接TXD、RXD接RXD,结果收发两头都堵死。串口连接必须交叉:USB转串口芯片的TXD接FPGA的RXD,USB转串口芯片的RXD接FPGA的TXD,地线GND接GND。
很多开发板板载了USB转串口芯片,比如黑金、正点原子、Altera原厂开发板,PCB上已经把交叉连线做完了,你只需要确保板子上的跳线或开关拨到正确位置即可。如果你是自己画板子,或者用独立USB转串口模块连接,一定先查一遍原理图,确认收发交叉。
电平匹配也值得单独提出来。3.3V供电的FPGA和3.3V的USB转串口芯片直接相连没问题。但如果你的USB转串口模块是5V电平的,FPGA引脚未必支持5V容忍,接进去有烧毁风险。处理方案是加电平转换芯片,或者在串口线上分压/加限流电阻。调试前用万用表量一下模块供电和数据线电平,省得莫名其妙炸引脚。
5.3 上板调试三板斧
串口通信上板失败,大多数跑不出这三类原因。我把排查顺序整理成一个表格,遇到问题按这个顺序查,效率最高:
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 完全没反应,串口助手收不到任何数据 | 引脚约束错误、时钟没起来、复位没释放、驱动没装好 | 查引脚约束文件,用ILA抓clk和rst_n信号 |
| 收到数据但是乱码 | 波特率不匹配、收发波特率误差过大、电平不匹配 | 核对串口助手波特率设置,用逻辑分析仪看实际波特率 |
| 能收到一部分,但丢字节 | 没有握手信号、上位机连续发送过快、FIFO深度不够 | 查tx_busy信号,发一个字节等一个字节,再加FIFO |
| 回显多了一个字节或错位 | 发送启动时序与接收rx_done配合不当 | 检查rx_done信号是否持续多拍,需打一拍或做脉冲化处理 |
其中“乱码”这个问题,特别容易在FT232R/CP2102这类USB转串口上出现。这是因为USB转串口芯片内部的波特率发生器同样存在误差,两个误差叠加后可能超出容限,尤其是非整数分频的波特率(比如9600在12MHz时钟下分频就不整)。遇到这种情况,优先把目标波特率改成能整除的整数,或者调高系统时钟频率。
6. 源码进阶:从“能通”到“好用”
6.1 参数化与通用化改造
真正的工程源码,不应该是一份“写死了”的代码。参数化设计是第一步:把CLK_FREQ、BAUD_RATE、DATA_WIDTH、PARITY、STOP_BIT全部提升为模块参数。这样同一份源码,既能在50MHz的板子上跑115200,也能在100MHz的板子上跑9600,改一个顶层参数就行。
在此基础上,发送端一定要补上tx_busy信号。tx_busy的作用是告诉上游“我现在正忙着,你不要再发数据”。如果上游不管这个信号,连续给多个tx_start脉冲,后面的数据就会被吞掉。加一个简单的req/ack握手也是同样的道理。
接收端还建议增加rx_error信号,用于检测帧错误。当接收端在停止位采到低电平时,说明这帧数据有问题,把rx_error拉高一个周期,方便上层协议层做校验或重传。这一步看似简单,但在实际项目中排查噪声干扰和数据错位时非常有用。
6.2 与FIFO、图像采集、SoC调试结合
UART本身的速率瓶颈很明显。115200波特率意味着理论最大吞吐量约11.5KB/s,实际扣除起始位、停止位和协议开销,有效数据率还要再打个折。所以一旦数据量上来,不可能让业务逻辑直接往UART发送模块里塞数据,中间必须加FIFO缓存。
典型架构是:业务模块把数据写入FIFO,UART发送模块空闲时从FIFO读出并发送。这样业务模块不用等UART的慢速时序,数据到达率高于发送速率时也不会丢数据(只要FIFO深度够)。
我在做FPGA图像采集项目的时候,就用过类似的架构:摄像头数据流(比如mipi csi-2接口采集的数据)经过简单打包,写入一个FIFO,再通过UART逐包回传到上位机。调试模式下,UART只用来回传关键帧或状态信息,不要求高速率,这个小通道非常好用。这也是UART在大型FPGA项目中最常见的定位——不是主力数据传输通道,而是调试、监控、指令下发的“生命线”。
另外在SoC或嵌入式软核项目中,UART源码还经常被封装成系统外设,作为printf的底层输出通道。集成时需要注意中断或轮询方式的选择:轮询方式简单但占CPU,中断方式复杂一点但更高效。尤其在调试嵌入式内核或驱动源码时,串口打印基本是唯一能实时观察系统状态的窗口,可靠性比性能更重要。
关于“Corundum项目硬件移植”这类场景,我多说一句。像Corundum这种大型开源项目,UART通常是整个移植流程里的“第一站”——先把UART跑通,验证PCIe枚举、DMA、中断链路是否正常,再一步步往上层功能走。所以在大型项目中,UART源码往往不只是几个模块,它和时钟复位管理单元、AXI总线、寄存器组都是绑定的。拿到这类源码时,别只盯着收发逻辑,还要看它挂在哪个总线上、怎么被CPU访问,才能真正改得动。
最后再分享一个我自己反复踩过坑之后的习惯:调试UART链路时,永远先做回环测试,再接业务数据。回环通过,说明物理链路和收发逻辑都没问题;回环不通过,问题大概率在底层,先别去查业务逻辑。把“链路问题”和“逻辑问题”分开排查,能省下一大半调试时间。这个习惯,也适用于你调任何一根看似简单的串口线。
本文还有配套的精品资源,点击获取