简介:面向FPGA开发者,以赛灵思XC7A35T为平台,用Verilog HDL实现RS485串口通信驱动,适用于工业多点通信、嵌入式接口设计等场景,也适合想掌握UART与FPGA时序控制的初学者。压缩包共113个文件,大小约1.18MB,以Vivado工程文件为主,含Verilog源码、XDC约束、BIT比特流、DCP网表、RPT报告和脚本,覆盖从综合实现到配置烧录的完整链路;其中BIT和DCP文件表明已经过综合实现,可直接下载验证或二次开发。实现原理上包含波特率发生器、数据移位寄存器、收发方向控制、电平转换与错误检测等模块,能帮助读懂RS485物理层与FPGA硬件逻辑的对接方式。RS485采用差分信号、抗干扰强,该驱动通过DE/RE信号自动切换收发状态,并留有参数修改入口,便于移植到其他FPGA型号。目前已有205人学习下载,既可用作通信协议入门参考,也可作为实际工程模板复用。
1. 在XC7A35T上写RS485串口通信驱动,比查串口芯片手册更重要的一件事
拿到一块XC7A35T开发板,最常被要求做的事就是接一个RS485接口。很多人第一反应是直接去找UART的Verilog代码,把TX/RX连到MAX3485上,然后发现上位机发来一堆乱码,或者总线上明明有波形,FPGA就是收不到。问题通常不在Verilog语法,而在三个地方同时没盯住:波特率分频误差、RS485的半双工方向控制、以及接收端的采样时机。RS485物理层是差分,但FPGA这一侧看到的只是三根单端信号:发送、接收、方向使能。理解这一点之后,驱动就好写了。这篇文章基于Verilog HDL,从UART帧结构出发,把XC7A35T上实现RS485串口通信驱动的最小完整方案拆开讲,最后落到Vivado里的真实波形验证技巧,适合刚把FPGA开发流程跑通、准备碰实际总线协议的工程师。
2. 用Verilog HDL实现RS485之前,先把UART帧结构和XC7A35T的时钟条件定下来
RS485驱动不复杂,但它依赖UART串口通信的时序精度。如果框架没定对,后面写状态机很容易陷入“仿真好看、上板乱码”的循环。
2.1 RS485与UART的关系:FPGA侧看到的只是三根单端信号
RS485是差分总线标准,而FPGA的管脚输出的是单端LVCMOS电平。两者之间需要一个RS485收发器芯片,常见的是MAX3485类别,供电3.3V,内部把TTL UART信号转成A/B差分信号。FPGA侧需要控制的信号只有三根:
uart_tx:接收发器的DI,发送串行数据。uart_rx:接收发器的RO,接收串行数据。rs485_de:接收发器的DE和RE,控制方向。高电平时驱动器使能,低电平时接收器使能。
所以所谓“RS485驱动”,本质上就是“UART驱动”加上“方向控制”。UART是异步串行协议,帧格式常规为8N1:1位起始位(低电平)、8位数据位(LSB先发)、1位停止位(高电平)。空闲状态下总线保持高电平。RS485的A/B差分信号在FPGA内部根本不关心,收发器芯片已经帮你做了电平转换。
理解这一点后,引脚约束就清楚了。XC7A35T的IO bank如果供电3.3V,那么这三个信号全部按LVCMOS33约束。方向控制信号在很多原理图上会标成DE/RE,因为收发器常把DE和RE并联,高电平时发送、低电平时接收。有些自动收发电路会省掉这根线,但在FPGA项目里我建议保留它,因为自动收发依赖RC延时,在高速波特率下容易在停止位附近产生毛刺。
2.2 波特率分频参数的计算:以50MHz和115200为例
UART通信要求收发双方波特率误差在一定范围内。常见标准是误差不超过2%,实际上串口芯片和RS485总线上的其他设备往往更苛刻。XC7A35T开发板常用系统时钟是50MHz,也可以用MMCM倍频到更高,但第一版驱动建议直接在50MHz上做。
波特率分频的基本公式是:
DIV = CLK_FREQ / BAUD_RATEDIV是每个数据位持续的系统时钟周期数。以50MHz为例:
| 目标波特率 | 理论分频值 | 实际使用分频值 | 实际波特率 | 误差 |
|---|---|---|---|---|
| 9600 | 5208.33 | 5208 | 9600.61 | 0.006% |
| 19200 | 2604.17 | 2604 | 19201.23 | 0.006% |
| 115200 | 434.03 | 434 | 115207.37 | 0.006% |
从表里可以看出,整数分频在低速下误差极小。但如果把系统时钟改成100MHz,115200的分频值是868.06,取整后误差仍然是0.006%,问题不大。实际使用中要注意的是:如果板载晶振精度太差,或者收发芯片和FPGA共用一个不太干净的电感供电,误差会被放大。所以建议FPGA侧的UART时钟直接用系统时钟分频,不要在UART逻辑内部再插入PLL,除非需要倍频或者多路波特率动态切换。
分频值的位宽也需要提前考虑。50MHz/9600约为5208,用13位计数器就够。但如果日后要支持300波特率,分频值会到16万,必须把计数器位宽扩展到18位。这个参数最好写成Verilog的局部参数,方便顶层工程复用。
2.3 XC7A35T引脚约束:RS485的DE/RO/DI管脚定义与电平标准
在写RTL之前,先确认开发板的原理图。不同XC7A35T板卡的FPGA管脚连接差异很大,典型做法是把UART收发器芯片放在板上,FPGA管脚直连收发器的DI、RO、DE。假设原理图上的网络名是rs485_tx、rs485_rx、rs485_de,那么在XDC文件里可以这样约束:
set_property -dict {PACKAGE_PIN K17 IOSTANDARD LVCMOS33} [get_ports rs485_tx] set_property -dict {PACKAGE_PIN K18 IOSTANDARD LVCMOS33} [get_ports rs485_rx] set_property -dict {PACKAGE_PIN M17 IOSTANDARD LVCMOS33} [get_ports rs485_de] set_property -dict {PACKAGE_PIN N17 IOSTANDARD LVCMOS33} [get_ports clk] set_property -dict {PACKAGE_PIN N18 IOSTANDARD LVCMOS33} [get_ports rst_n]具体管脚名必须按手头开发板的原理图填写,不能照抄。约束时还需要注意rs485_de如果连的是收发器的RE#,那么是低有效;大多数电路板会把RE和DE连在一起,所以一次只控制一个信号即可。IO电平标准务必与Bank供电一致,XC7A35T的HR Bank在3.3V供电时用LVCMOS33,在2.5V供电时就要改成LVCMOS25。
3. Verilog HDL实现RS485串口通信驱动:分频、发送、接收与顶层组装
这一章给出可综合的Verilog HDL代码。核心模块只有三个:波特率发生器、UART发送、UART接收。方向控制放在顶层模块里,因为RS485的方向切换和发送状态机的生命周期紧密相关。
3.1 波特率发生器模块:整除分频与小数分频的取舍
最简单的波特率发生器是一个计数器,计数到DIV-1时拉高一个baud_tick脉冲,其余时间保持低电平。这个脉冲在每个数据位边界产生一次,发送和接收模块都用它做状态推进。
module baud_gen #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 )( input wire clk, input wire rst_n, output wire baud_tick ); localparam integer DIV = CLK_FREQ / BAUD_RATE; reg [$clog2(DIV)-1:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 0; else if (cnt >= DIV - 1) cnt <= 0; else cnt <= cnt + 1'b1; end assign baud_tick = (cnt == DIV - 1); endmodule这里cnt的位宽用$clog2(DIV)自动计算,避免手动算位宽出错。115200波特率下DIV=434,所以位宽是9位。baud_tick是一个时钟周期宽度的脉冲,出现在每个位周期的最后一拍。发送模块里,状态的跳变以这个脉冲为节拍;接收模块里,脉冲用于确定采样点。
某些场景下会用到“小数分频”来进一步降低波特率误差,比如用一个累加器每次累加分频余数。但在RS485串口通信中,整数分频误差已经远小于UART的允许范围,再加小数分频反而会让时序分析变得复杂,还要处理累加器的清零时序。对XC7A35T这种规模的应用,整除分频完全足够,优先保证上板可复现。
3.2 UART发送模块:移位寄存器与起始/停止位状态机
发送模块的任务是把8位数据变成一帧串行波形。状态机包含空闲、起始、数据、停止四个状态。发送期间tx_busy拉高,顶层再用这个信号来控制RS485方向。
module uart_tx #( parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire baud_tick, input wire tx_start, input wire [DATA_WIDTH-1:0] tx_data, output reg tx, output reg tx_busy ); localparam IDLE = 2'd0; localparam START = 2'd1; localparam DATA = 2'd2; localparam STOP = 2'd3; reg [1:0] state; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx <= 1'b1; tx_busy <= 1'b0; state <= IDLE; end else begin tx_busy <= (state != IDLE); case (state) IDLE: begin tx <= 1'b1; if (tx_start && !tx_busy) begin shift_reg <= tx_data; bit_cnt <= 0; state <= START; end end START: begin tx <= 1'b0; if (baud_tick) state <= DATA; end DATA: begin if (baud_tick) begin tx <= shift_reg[0]; if (bit_cnt == DATA_WIDTH - 1) begin state <= STOP; end else begin shift_reg <= {1'b0, shift_reg[DATA_WIDTH-1:1]}; bit_cnt <= bit_cnt + 1'b1; end end end STOP: begin tx <= 1'b1; if (baud_tick) state <= IDLE; end endcase end end endmodule需要注意:状态机里tx_busy是用非阻塞赋值更新的,因此在tx_start拉高的同一个时钟周期,tx_busy还是0,只能等下一拍才变为1。这样设计可以避免发送模块在开始瞬间自锁。数据位采用LSB先行,所以移位时右移,shift_reg从bit0开始输出。
在DATA状态里,当bit_cnt等于7时,停止位上会有一次多余的移位操作,但并不影响停止位电平。停止位由STOP状态强制拉高,避免数据位最后一位是0导致总线无法回高。这个状态机在115200波特率、50MHz时钟下,每帧约为87微秒,发送一帧后自动回到IDLE,可以连续接收新的tx_start请求。
3.3 UART接收模块:下降沿检测、中点采样与亚稳态过滤
接收比发送复杂,因为FPGA不知道发送端什么时候会起始位。RS485总线上没有独立时钟,只能靠检测RX引脚的下降沿。接收模块需要先对输入信号打两拍,消除跨时钟域亚稳态,然后检测下降沿,再之后按波特率分频值进行中点采样。
module uart_rx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_done ); localparam integer DIV = CLK_FREQ / BAUD_RATE; localparam integer HALF = DIV / 2; localparam IDLE = 2'd0; localparam START = 2'd1; localparam DATA = 2'd2; localparam STOP = 2'd3; reg [1:0] state; reg [$clog2(DIV)-1:0] cnt; reg [3:0] bit_cnt; reg rx_d1, rx_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d1 <= 1'b1; rx_d2 <= 1'b1; end else begin rx_d2 <= rx_d1; rx_d1 <= rx; end end wire rx_neg = ~rx_d1 & rx_d2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; rx_data <= 0; rx_done <= 0; cnt <= 0; bit_cnt <= 0; end else begin case (state) IDLE: begin rx_done <= 1'b0; if (rx_neg) begin cnt <= 0; state <= START; end end START: begin cnt <= cnt + 1'b1; if (cnt == HALF - 1) begin if (rx_d1 == 1'b1) begin state <= IDLE; end else begin cnt <= 0; bit_cnt <= 0; state <= DATA; end end end DATA: begin if (cnt == HALF - 1) begin rx_data[bit_cnt] <= rx_d1; if (bit_cnt == 7) begin state <= STOP; cnt <= 0; end else begin bit_cnt <= bit_cnt + 1'b1; cnt <= 0; end end else begin cnt <= cnt + 1'b1; end end STOP: begin if (cnt == HALF - 1) begin if (rx_d1 == 1'b1) begin rx_done <= 1'b1; end state <= IDLE; end else begin cnt <= cnt + 1'b1; end end endcase end end endmodule这段代码的关键点是:在START状态里,当计数器到达半位位置时,如果采样值仍然为低,才算有效起始位。这可以过滤掉很窄的毛刺。进入DATA状态后,每个数据位的中点采样一次,rx_data位从左到右恢复。接收完成后在STOP状态检查停止位是否为高,如果不是高就认为帧错误,但仍然给出rx_done,由上层协议判断是否丢弃。
HALF = DIV/2是整数除法,在115200波特率下是217个时钟周期。由于DIV是偶数或奇数,半位采样和实际的位中点会偏差一两个时钟周期,对UART来说是完全可以接受的。真正需要关心的是起始位下降沿后的第一个半位,如果这里计数不准,后续每个位都会被整体偏移。解决方法是每次进入START都清空计数器,让采样时机和发送端的相位重新对齐。
3.4 RS485顶层模块:发送使能DE的方向切换时序
把发送、接收、波特率发生器组合成顶层的RS485驱动。方向控制rs485_de必须在发送开始时拉高,在发送结束后延时释放。最简单的方式是记录tx_busy从拉高到变低的时刻,然后继续等待一个位时间,再把方向切回接收。
module rs485_uart #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 )( input wire clk, input wire rst_n, input wire rx, output wire tx, output reg rs485_de, input wire [7:0] tx_data, input wire tx_start, output reg tx_busy, output wire [7:0] rx_data, output wire rx_done ); wire baud_tick; wire tx_wire; wire tx_busy_wire; wire [7:0] rx_data_wire; wire rx_done_wire; baud_gen #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_baud ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick) ); uart_tx u_tx ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick), .tx_start (tx_start), .tx_data (tx_data), .tx (tx_wire), .tx_busy (tx_busy_wire) ); uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx), .rx_data(rx_data_wire), .rx_done(rx_done_wire) ); assign tx = tx_wire; assign rx_data = rx_data_wire; assign rx_done = rx_done_wire; assign tx_busy = tx_busy_wire; localparam integer DE_HOLD_CYCLES = CLK_FREQ / BAUD_RATE; reg [$clog2(DE_HOLD_CYCLES)-1:0] de_cnt; reg de_hold; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rs485_de <= 1'b0; de_cnt <= 0; de_hold <= 1'b0; end else begin de_hold <= tx_busy_wire; if (tx_start && !tx_busy_wire) begin rs485_de <= 1'b1; de_cnt <= 0; end else if (tx_busy_wire) begin rs485_de <= 1'b1; end else if (de_hold) begin rs485_de <= 1'b1; if (de_cnt == DE_HOLD_CYCLES - 1) rs485_de <= 1'b0; else de_cnt <= de_cnt + 1'b1; end else begin rs485_de <= 1'b0; end end end endmodule顶层里的延时释放逻辑用了一个额外寄存器de_hold。tx_busy_wire在最后一个停止位的发送周期内保持高,等它变低时,硬件EQ收发器的DI已经输出到总线上,但总线上最后一个停止位仍然需要一段稳定时间。DE_HOLD_CYCLES设置为一个完整位周期,意思是发送结束后再保持DE高一个位时间。这样可以让停止位在总线上完整可见,同时避免DE切换瞬间造成总线相位模糊。
这部分逻辑不能省。如果让DE在tx_busy变为0的同一拍马上拉低,某些收发器芯片的驱动器输出会进入高阻,A/B线的电平会失去驱动,由外部偏置电阻决定,而这可能发生在停止位的末尾,导致对端采样到的停止位边长或变短。RS485组网对信号完整性的容忍度比点对点低,方向切换时序是必须抠的细节。
4. RS485方向控制与组网:半双工总线上最容易翻车的三个地方
代码写完后,真正上板遇到的问题大多不是代码逻辑,而是对RS485半双工总线的理解。无论是点对点还是组网,方向切换、终端电阻、总线空闲电平这三个问题按顺序排查,能解决大部分故障。
4.1 方向切换时序:发送结束后的释放延时
RS485是半双工,同一时刻只能有一个节点驱动总线。所以从发送切换到接收,中间必须有一段缓冲时间。最稳妥的延迟值是一个数据位的时间,也就是DE_HOLD_CYCLES。如果波特率是115200,这个延时约为8.68微秒;如果波特率是9600,延时就变成104微秒。延时太长会拖慢主从轮询速度,太短则可能让停止位不完整。对于一主多从的轮询场景,建议在顶层协议里额外加一个“帧间间隔”,也就是一个字节结束后至少让总线空闲一到两个字节时间,再允许其他从站回复。
调试时建议把方向切换延时做成可配置参数,而不是直接写死在顶层里。使用Vivado时,可以在顶层模块加一组拨码开关输入,把延时值低两位引出来,上板后通过拨码改变延时,同时用逻辑分析仪观察总线波形。很多工程师习惯在仿真里把延时期望得很精确,到实际总线上却发现收发器芯片的上升沿和下降沿不对称,导致DE释放瞬间产生毛刺,配合A/B线上的偏置电阻还会演化成一位窄脉冲。遇到这种情况,优先增大DE_HOLD_CYCLES,看接收端是否恢复正常。
4.2 一主多从组网:地址字节约定与收发器方向控制
RS485最常见的应用场景是RS485组网,一个主机挂多个从机,从机之间不能同时占用总线。FPGA在组网里通常充当从机的协议处理器。主机发送一帧数据,包含地址字节和功能码;每个从机的UART接收模块都会收到数据,但只有地址匹配的从机才会在确认总线空闲后回复。
从机侧的DE控制策略更加严格。平时DE必须保持低电平,让接收器一直工作。只有当本机地址匹配,且主机发送完毕一个短帧后,从机才可以把DE拉高进入发送模式。发送完毕后再延时释放DE。不要在接收到任意数据后就立刻把DE拉高,这会覆盖掉总线上其他从机发来的数据。
组网时另一个细节是终端电阻和总线偏置。标准的RS485组网要求在总线两端各接一个120欧终端匹配电阻,防止信号反射。如果FPGA开发板已经在板上有终端电阻,就不需要额外再接。上下拉偏置电阻也很关键,它们保证总线空闲时A/B间有一个确定的电平差,否则所有收发器都会把空闲误判为起始位。很多“乱码”实际上是总线空闲电平不确定造成的,和FPGA内部逻辑一点关系都没有。在调试一主多从前,先用万用表量A/B之间的电压差,正常情况下应大于200mV。
4.3 回环测试与排错:串口助手和ILA联合定位
上板调试最直接的方式是做自发自收回环。先不要让FPGA回复主机,而是在FPGA内部把rx_data直接送回到tx_data,再接一根短网线连接USB转RS485调试器。上位机用串口助手发送一帧0x55,FPGA收到后原样发回。如果回环正确,说明驱动基本工作正常。
如果回环失败,按以下顺序排查:
- 检查RS485的A/B线有没有接反。收发器芯片的A、B端和USB转RS485调试器的A、B端必须同相,反接时表现为FPGA能收到数据但上位机收不到,或者反之。
- 检查
rs485_de是否一直为低。用示波器或者Vivado里的ILA抓取rs485_de,发送时应该出现高电平脉冲,宽度大于一个字符帧时间。 - 检查波特率误差。FPGA和USB转RS485调试器都必须配置成同一波特率,如果一方用9600、另一方用115200,回环必然失败。
- 检查停止位是否完整。用ILA抓
tx和rs485_de,观察停止位高电平是否持续完整一个位周期,如果DE提前释放,停止位会被截断。
常见故障对照表如下:
| 现象 | 常见原因 | 排查手段 |
|---|---|---|
| 完全无回复 | A/B接反或DE没有使能 | 万用表量A/B压差,ILA看DE |
| 回环乱码 | 波特率分频参数不对 | 核对DIV计算值 |
| 首字节正确后续乱码 | 方向切换释放过早 | 增大DE_HOLD_CYCLES |
| 总线上有多个设备同时发 | 从机地址过滤失效 | 确认地址比较逻辑 |
在Vivado里例化ILA时,采样时钟直接用系统的50MHz,采样深度不用太大,1024就够观察一帧数据。触发条件可以设置在tx_start或rx_done上,这样能把完整的发送过程抓下来。逻辑分析仪信号里不要只抓tx和rx,一定要把baud_tick也拉进去,因为baud_tick能直接反映出分频计数器是否稳定,很多时候乱码的根源是计数器位宽不够导致溢出回绕,但仿真里看不出来。
5. 用ILA抓取RS485驱动中的DE释放时机:Vivado里的一个实际验证技巧
驱动在仿真里跑通后,上板前最值得做的一次验证,是用Vivado的ILA观察RS485方向信号DE的释放时机。这个验证能直接发现发送模块、波特率发生器、顶层延时三者之间的时序配合问题。
我一般会在顶层RS485模块里预留一组调试信号,包括tx、rx、rs485_de、baud_tick和tx_busy。在Vivado里新建一个ILA IP核,采样时钟选择顶层时钟,采样深度设成1024,把五个信号全部连到ILA探针上。要注意tx_busy是内部信号,不能直接在约束文件里使用;需要先把它引到顶层输出端口,或者在IP集成器里用Mark Debug方式标记,之后综合后就能出现在硬件管理器里。
触发条件这样设置:选择rs485_de作为触发探针,触发位置设为下降沿。这样每次发送完成后方向切换时,ILA就会抓下完整的一帧数据。打开波形后,把光标放在rs485_de从高变低的位置,往前数一个位周期,检查tx上应该已经是高电平的停止位。如果这个时刻tx仍然是低电平,说明DE释放太早,发送状态机还没来得及输出停止位电平,总线会被强行拉低,接收端必然出错。
这个验证的好处是能看到DE和tx的绝对相位关系,而不是只看协议层有没有数据。很多时候仿真里的testbench是理想的,收发器芯片的延迟没被建模,DE释放提前一个时钟周期都能在板级暴露出来。如果发现DE和停止位之间存在固定延迟,可以调整顶层模块里的DE_HOLD_CYCLES。把它设成DIV的一半,往往能在保持总线稳定和切换速度之间达到平衡。波形稳定后,再把触发条件换成rx_done,抓一遍从站回复的方向切换序列,确认FPGA在从机模式下的DE也符合预期。经过这轮验证,RS485驱动才算真正达到可交付的状态。
本文还有配套的精品资源,点击获取