简介:这是一份基于 Xilinx Spartan-6 系列 XC6SLX16 FPGA 的 RS485 通信完整工程,使用 Verilog HDL 编写,面向数字电路设计、嵌入式系统以及工业串行通信方向的开发者。工程覆盖数据缓冲、发送/接收使能切换、波特率发生器与错误检测等 RS485 控制器核心环节,并清晰划分 uart_send、uart_recv、led_ctrl、key_debounce 等模块,适合希望在实际 FPGA 平台上快速验证半双工串行通信逻辑的读者。
压缩包共 67 个文件,大小约 341KB,以 Verilog 源码(.v)、引脚约束(.ucf)、比特流配置(.bit)和 Xilinx 工程文件(.xise)为主,同时附带综合、实现与仿真过程产生的报告和日志,便于复查整个编译流程。包内目录结构将源码与工程配置分开,关键文件可直接打开使用。
目前已有 197 人浏览学习。通过该工程,读者可以掌握 RS485 与 UART 协议的 FPGA 实现思路,理解收发方向控制和波特率生成等设计要点,也可基于现成模块修改参数,适配不同场景下的通信需求。
1. 现场为什么要在一块XC6SLX16上写RS485:不只是UART串口
用 FPGA XC6SLX16 实现 RS485 通信,在很多人眼里就是“写个 UART 串口”的简单事。但我实际做过一轮之后想劝你:RS485 的难点不在 Verilog HDL 怎么收发字节,而在差分管脚的方向切换、停止位余量、总线浮空时序这些边缘条件。我在风电变流器机房里维护过一台用 XC6SLX16 做协议转换的板卡,六路传感器轮询压在一个串口服务器后面,甲方临时要求加一路 RS485 门禁状态回传。当时手头这块 Spartan-6 LX16 逻辑单元一万五、BRAM 五百多 Kb,多塞一路 UART 和一个轮询状态机绰绰有余。
这篇文章写给两类人:一是刚入坑 FPGA、想把工业总线通信跑通的开发者,二是已经在用 MCU 做 RS485、想换成 FPGA 做协议转换或数据采集的工程师。你会看到我从物理层和资源评估讲起,接着给出可以直接抄的 Verilog 代码,最后把现场调试时踩过的坑按现象、原因、解决三步列清楚。
2. RS485物理层与XC6SLX16资源评估:先算账再动手
2.1 RS485和UART的关系:你真正要写的是哪层逻辑
RS485 标准本身只定义物理层:A/B 两根差分线,逻辑“1”是 A 大于 B,逻辑“0”是 A 小于 B,支持多点拓扑,标准驱动下最多 32 个单元负载,传输距离按波特率不同可以到 1200 米。它完全不关心帧格式、校验位、波特率这些事,那是 UART 和协议栈的职责。所以 FPGA 里做 RS485 通信,拆开看无非三件事:用 Verilog 写一个 UART 核负责串行数据收发;用一根 GPIO 控制 RS485 收发器的发送/接收方向;再把 UART 的并行数据接进协议状态机,比如 Modbus-RTU 或自定义轮询协议。很多 fpga 入门教程把 UART 单独跑通就算完事,但 RS485 的工程难点恰恰在第三件事上:方向脚何时拉高、何时释放、释放太快会不会吞掉停止位。
我在项目里经常看到有人把 RS485 和 RS232 混为一谈,直接拿一个 UART IP 就外接 MAX3485,结果发送正常、接收全乱。原因就是没意识到 RS485 是半双工总线,必须有一个方向仲裁。RS232 是全双工,TX 和 RX 互不干扰;RS485 的收发器虽然引脚上也有 RO 和 DI,但总线是半双工的,DE 和 RE 共同决定当前是谁在驱动总线。这个区别决定了一件事:你的 Verilog 代码里必须有一个状态机来处理“发送完成后什么时候切回接收”。
2.2 XC6SLX16的资源够不够:逻辑单元、BRAM、DSP与引脚
XC6SLX16 是 Xilinx Spartan-6 家族里定位中低端的一块 FPGA,我常用的是 XC6SLX16-2CSG324C,也见过用 FTG256 封装的。官方参数大致是:逻辑单元 14579 个,Block RAM 总共 576Kb,DSP48A1 有 32 个。对于 RS485/UART 这个任务,资源非常宽裕——我估算过一个带 16 字节 FIFO 的 UART 核大约占 300 到 500 个 LUT,三路 UART 加一个简化 Modbus 状态机,LUT 用量不会超过 5000,BRAM 如果用 FIFO 可能占到 2 到 4 个 18Kb 块。
| 资源项 | XC6SLX16可用量 | 三路RS485+Modbus估算 |
|---|---|---|
| 逻辑单元(CLB) | 14579 | 约5000 LUT(含FIFO和状态机) |
| Block RAM | 576Kb (32个18Kb) | 2~4个18Kb块 |
| DSP48A1 | 32 | 0(如果不做CRC加速) |
| 用户I/O | 200+(依封装) | 每路约4个引脚(TX/RX/DE/隔离) |
真正需要提前算账的不是逻辑资源,而是引脚和时钟。CSG324 封装用户 I/O 虽然多,但多功能引脚和 GTP 不用时往往会吃掉一部分,最好在 UCF/XDC 里提前锁定;RS485 收发器是用 LVCMOS 电平驱动的,FPGA IO 口电压如果是 3.3V,收发器也选 3.3V 供电的 MAX3485 或 ISL3170。这里还有一个 fpga io口支持 hysteresis input mode 的参数点:如果收发器的 RO 输出噪声偏大,可以在 IO 约束里打开施密特输入模式,改善长线弱信号下的噪声容限,代价是输入切换阈值变宽,信号上升沿会稍滞后,RS485 这种低速场景完全可接受。
2.3 为什么Verilog HDL在这个任务里比MCU顺手
用 C 写 RS485 时,波特率定时器是外设,收发中断和主循环之间总有调度延迟;而在 FPGA 里,每个 bit 周期、每个采样点都是可确定的时钟周期数。我可以在 Verilog 里控制到“下降沿出现后第 8 个过采样点再判起始位”,也可以在协议状态机里精确计算“收到帧尾后 3.5 个字符时间内不响应”,这是 MCU 做起来很难受的地方。另外,如果你要跑 2Mbps 的高速 RS485,MCU 每 8 个 bit 就要进一次中断,CPU 占用率直线上升;FPGA 这边只要 FIFO 够深,收发完全并行,“CPU 负载”这个概念不存在。
选择 XC6SLX16 还因为它启动快、没有操作系统、上电即运行,非常适合工业设备里的“上电就要能响应总线命令”场景。XC6SLX16 不支持 DDR3 硬核,但 RS485 应用根本用不上 DDR;不支持 GTP 高速串行,也不是这个场景需要的。说白了,这块芯片就是为“多路低速接口+协议转换+信号采集”而生的,价格和功耗都压得住。你如果拿它去做图像处理或高速 ADC 采样,会明显感觉到资源捉襟见肘;但做 RS485 通信,它是舒舒服服的。
3. 用Verilog HDL从零写UART核:波特率、发送与接收状态机
3.1 波特率发生器:用计数器分频而不是用MMCM
RS485 在绝大多数工业场景下波特率不超过 115200,常见是 9600 和 19200。我一般不用 MMCM/PLL 去产生精确波特率时钟,因为 RS485 链路本来就不是同源时钟,对端 PLC 的晶振也可能有误差,用计数器分频足够且不占用全局时钟资源。以 50MHz 系统时钟、9600 波特率为例,每个 bit 周期是 50_000_000 / 9600 = 5208 个时钟周期。接收端为了在 bit 中心采样,常规做法是 16 倍过采样,也就是每个 bit 采样 16 次,所以接收分频计数是 5208 / 16 = 325。
这里有一个参数细节:50MHz 没有被 5208.33 整除,实际波特率是 50000000/5208=9600.6bps,误差只有 0.006%,可忽略。但如果你用 27MHz 跑 115200,27000000/115200=234.375,取 234,实际波特率 115384,误差 0.16%,仍在 UART 的 ±2% 容差内。真正要小心的是过采样倍数和接收采样点,后面 3.3 节细说。
module baud_gen #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 9600, parameter OVERSAMPLE = 16 )( input wire clk, input wire rst_n, output reg baud_tick, // 发送节拍:每bit一个脉冲 output reg sample_tick // 接收采样节拍:16倍过采样脉冲 ); localparam integer DIV_SEND = CLK_FREQ / BAUD_RATE; // 5208 localparam integer DIV_SAMPLE = DIV_SEND / OVERSAMPLE; // 325 reg [12:0] send_cnt; reg [8:0] samp_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin send_cnt <= 0; samp_cnt <= 0; baud_tick <= 1'b0; sample_tick <= 1'b0; end else begin // 发送分频:每个bit周期产生一个节拍 if (send_cnt >= DIV_SEND - 1) begin send_cnt <= 0; baud_tick <= 1'b1; end else begin send_cnt <= send_cnt + 1; baud_tick <= 1'b0; end // 采样分频:每个bit周期产生16个节拍 if (samp_cnt >= DIV_SAMPLE - 1) begin samp_cnt <= 0; sample_tick <= 1'b1; end else begin samp_cnt <= samp_cnt + 1; sample_tick <= 1'b0; end end end endmodule这个模块同时产生两路节拍信号:baud_tick 是发送节拍,每个 bit 周期来一个脉冲;sample_tick 是接收采样节拍,每个 bit 周期来 16 个脉冲。发送和接收分开用两个计数器,比共用一个计数器再分频更清晰,后续调试时互不干扰。参数 CLK_FREQ 和 BAUD_RATE 在例化时调整。如果系统时钟和波特率不是整除关系,取整误差只要在 2% 以内都能收,但发送方向波特率误差会直接影响对端接收,最好先用计算器算一遍。另外,baud_tick 的宽度只有一个时钟周期,所以 UART 状态机必须用边沿触发,不能用电平等待。
3.2 UART发送模块:状态机别写在时钟里
这个模块的状态机是 IDLE → START → DATA → STOP。空闲时 TXD 为高,这是 UART 的 mark 状态,也对应 RS485 总线空闲态。收到 tx_start 后,拉低 TXD 一个 bit 周期作为起始位,然后从 LSB 开始逐位发送 tx_data,最后拉高 TXD 一个 bit 周期作为停止位。代码用 binary 编码就够了,没必要上独热码——三四个状态用独热码反而浪费 FF、增加维护成本,这正是很多讨论里“case 用独热码和不用独热码区别”的结论:小状态机用独热码没有收益,几十个状态的大状态机才值得。
module uart_tx #( parameter DATA_BITS = 8, parameter STOP_BITS = 1 )( input wire clk, input wire rst_n, input wire baud_tick, input wire [DATA_BITS-1:0] tx_data, input wire tx_start, output reg txd, output reg tx_busy ); localparam IDLE = 2'd0, START = 2'd1, DATA = 2'd2, STOP = 2'd3; reg [1:0] state; reg [DATA_BITS-1:0] shift_reg; reg [3:0] bit_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; txd <= 1'b1; tx_busy <= 1'b0; shift_reg <= 0; bit_cnt <= 0; end else if (baud_tick) begin case (state) IDLE: begin txd <= 1'b1; tx_busy <= 1'b0; if (tx_start) begin tx_busy <= 1'b1; shift_reg <= tx_data; bit_cnt <= 0; state <= START; end end START: begin txd <= 1'b0; // 起始位:拉低一个bit state <= DATA; end DATA: begin txd <= shift_reg[0]; // LSB先发 shift_reg <= {1'b0, shift_reg[DATA_BITS-1:1]}; if (bit_cnt == DATA_BITS - 1) begin bit_cnt <= 0; state <= STOP; end else begin bit_cnt <= bit_cnt + 1; end end STOP: begin txd <= 1'b1; if (STOP_BITS == 1) begin state <= IDLE; end end endcase end end endmodule这里有几个关键写法:移位用的是“先送 shift_reg[0],再右移”,这样 LSB 在前的帧格式自然满足;bit_cnt 在第 8 个数据位时跳到 STOP,不要等到第 9 拍,否则多发一个无效位;tx_busy 从 START 一直拉高到 STOP 结束,方向控制逻辑依赖这个信号。STOP_BITS 参数默认为 1,如果用 2 个停止位,要在 STOP 状态里再计数一拍。图中没有写死波特率,而是完全由 baud_tick 驱动,这也是把“时序生成”和“数据通路”分开的好处。
如果你只是做“fpga 实现串口发送 ascii 字符串”那种演示,用这个发送模块就没问题;但 RS485 场景下 tx_busy 不是给外部轮询玩的,而是要交给方向控制状态机去生成 DE 信号。所以这个模块的输出里我特意留了 tx_busy,后续所有方向逻辑都以它为基准。
3.3 UART接收模块:中间采样与起始位确认
接收模块是整个 UART 核里最容易翻车的地方。标准做法是 16 倍过采样:在 IDLE 状态检测到 RXD 下降沿时,不立即认定是起始位,而是等 8 个采样周期(也就是半个 bit 周期)再采一次,如果仍然为低,才认为是有效起始位。这样能过滤掉总线上的毛刺和收发器切换时产生的短脉冲。确认起始位后,每 16 个采样周期采一次 RXD,采样点正好落在每个 bit 的中心,抗边沿抖动能力最强。
module uart_rx #( parameter DATA_BITS = 8 )( input wire clk, input wire rst_n, input wire sample_tick, // 16倍过采样脉冲 input wire rxd, output reg [DATA_BITS-1:0] rx_data, output reg rx_ready, output reg rx_busy ); localparam IDLE = 2'd0, START = 2'd1, DATA = 2'd2, STOP = 2'd3; reg [1:0] state; reg [DATA_BITS-1:0] shift_reg; reg [3:0] samp_cnt; reg [3:0] bit_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; rx_data <= 0; rx_ready <= 1'b0; rx_busy <= 1'b0; shift_reg <= 0; samp_cnt <= 0; bit_cnt <= 0; end else if (sample_tick) begin case (state) IDLE: begin rx_ready <= 1'b0; rx_busy <= 1'b0; samp_cnt <= 0; if (rxd == 1'b0) begin samp_cnt <= 1; // 先等8拍再确认 state <= START; rx_busy <= 1'b1; end end START: begin if (samp_cnt == 8) begin if (rxd == 1'b0) begin samp_cnt <= 0; bit_cnt <= 0; state <= DATA; end else begin state <= IDLE; // 毛刺,回IDLE rx_busy <= 1'b0; end end else begin samp_cnt <= samp_cnt + 1; end end DATA: begin if (samp_cnt == 15) begin // 采样点落在bit中心 shift_reg[bit_cnt] <= rxd; samp_cnt <= 0; if (bit_cnt == DATA_BITS - 1) begin bit_cnt <= 0; state <= STOP; end else begin bit_cnt <= bit_cnt + 1; end end else begin samp_cnt <= samp_cnt + 1; end end STOP: begin if (samp_cnt == 15) begin rx_data <= shift_reg; rx_ready <= 1'b1; state <= IDLE; end else begin samp_cnt <= samp_cnt + 1; end end endcase end end endmodule接收时序的关键是:IDLE 里检测到下降沿后,samp_cnt 从 1 开始而不是从 0,这样在第 8 拍时正好到达 bit 中心附近。确认起始位后,DATA 状态每 16 拍采一次数据,第一个数据位的采样点距离下降沿是 8+16=24 拍,也就是 1.5 个 bit 周期,正好是数据位中心。移位这里用了 shift_reg[bit_cnt] <= rxd 的按位写入方式,bit_cnt 从 0 递增,所以 LSB 先到、LSB 在下标 0,最终 rx_data 的位序天然正确,不需要额外反转。有些参考代码用“反向移位再翻转”,那种写法容易绕晕新手。
还要注意 rx_ready 只拉高一个 sample_tick,外部抓取时机会比较苛刻。如果你的下游逻辑是慢速轮询,建议在例化时把 rx_ready 接成 FIFO 写使能,或者像我后面第 5 章说的那样改成“数据有效”电平信号。接收端另一个常见坑是起始位误判:如果总线空闲时 A/B 没有偏置,收发器 RO 会随机翻转,IDLE 状态会频繁进入 START 又退出,虽然不会产生数据,但 rx_busy 会抖动,方向控制逻辑如果依赖 rx_busy 就容易被绕进去。
4. RS485半双工集成:DE方向控制、顶层例化与转身延迟
4.1 收发器方向控制:DE/RE信号的产生和时序
MAX3485、SP3485、ISL3170 这类收发器的 DE 是发送使能,高电平有效;RE 是接收使能,低电平有效。很多板子把 RE 和 DE 短接成一个方向脚,高电平驱动总线,低电平释放总线。FPGA 产生这个方向脚的经验是:开始发送前就拉高,发送结束后保持几个 bit 周期再拉低,不能刚拉高就发数据,也不能刚发完就拉低。
收发器从接收切到发送有 tD 时间,典型几十纳秒到几百纳秒。9600 波特率下一个 bit 约 104μs,tD 可以忽略;但如果上到 115200 以上,一个 bit 约 8.7μs,tD 就不能忽略了,方向脚必须先于起始位生效。我习惯在方向状态机里做“发送前排头”:tx_start 有效时先把 DE 拉高,同时延迟几个 baud_tick 再给 UART 发送模块发 tx_start。这样即便收发器切换慢,起始位到达 DI 时总线驱动早已建立。
// 方向控制:tx_start到来时拉高DE,发送完成后保持TURN_AROUND_BITS拍再拉低 reg [3:0] turn_cnt; reg de_ctrl_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin de_ctrl_reg <= 1'b0; turn_cnt <= 4'd0; end else if (tx_start_in) begin de_ctrl_reg <= 1'b1; turn_cnt <= 4'd0; end else if (tx_busy == 1'b0 && de_ctrl_reg) begin if (baud_tick) begin if (turn_cnt >= TURN_AROUND_BITS) begin de_ctrl_reg <= 1'b0; turn_cnt <= 4'd0; end else begin turn_cnt <= turn_cnt + 1'b1; end end end end这段方向控制里的 turn_cnt 是以 baud_tick 为单位计数的,不是时钟周期。TURN_AROUND_BITS 参数的单位就是 bit 周期,默认设为 2 或 3,表示停止位结束之后还要再保持 DE 高电平 2 到 3 个 bit 周期再切回接收。如果你用的是分离的 RE 和 DE,把 RE 接地、仅用 DE 控制即可,方向脚只接 DE 一根。如果 DE 和 RE 是合并引脚,直接接 de_ctrl_reg。
4.2 顶层模块例化:把所有信号串起来
把波特率发生器、uart_tx、uart_rx、方向控制例化成一个 rs485_top 顶层。这个顶层对上层协议只暴露发送接口和数据接口,对物理层只暴露三个引脚:txd_phy、rxd_phy、de_ctrl。
module rs485_top #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 9600, parameter TURN_AROUND_BITS = 2 )( input wire clk, input wire rst_n, // 用户发送接口 input wire [7:0] tx_data_in, input wire tx_start_in, output wire tx_busy_out, // 用户接收接口 output wire [7:0] rx_data_out, output wire rx_ready_out, // RS485收发器物理引脚 output wire txd_phy, input wire rxd_phy, output wire de_ctrl ); wire baud_tick, sample_tick; wire tx_busy; wire txd_buf; wire [7:0] rx_data_w; wire rx_ready_w; baud_gen #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_baud ( .clk (clk), .rst_n (rst_n), .baud_tick (baud_tick), .sample_tick(sample_tick) ); uart_tx u_tx ( .clk (clk), .rst_n (rst_n), .baud_tick(baud_tick), .tx_data (tx_data_in), .tx_start (tx_start_in), .txd (txd_buf), .tx_busy (tx_busy) ); uart_rx u_rx ( .clk (clk), .rst_n (rst_n), .sample_tick(sample_tick), .rxd (rxd_phy), .rx_data (rx_data_w), .rx_ready (rx_ready_w), .rx_busy () ); // 方向控制逻辑(见4.1,此处简写) reg [3:0] turn_cnt; reg de_ctrl_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin de_ctrl_reg <= 1'b0; turn_cnt <= 4'd0; end else if (tx_start_in) begin de_ctrl_reg <= 1'b1; turn_cnt <= 4'd0; end else if (tx_busy == 1'b0 && de_ctrl_reg) begin if (baud_tick) begin if (turn_cnt >= TURN_AROUND_BITS) begin de_ctrl_reg <= 1'b0; turn_cnt <= 4'd0; end else begin turn_cnt <= turn_cnt + 1'b1; end end end end assign txd_phy = txd_buf; assign rx_data_out = rx_data_w; assign rx_ready_out = rx_ready_w; assign tx_busy_out = tx_busy; assign de_ctrl = de_ctrl_reg; endmodule顶层例化的顺序就是数据流方向:baud_gen 产生节拍,uart_tx 发数据,uart_rx 收数据,方向控制模块看着 tx_start_in 和 tx_busy 决定 DE 的拉高与释放。物理引脚上,txd_phy 连收发器 DI,rxd_phy 连收发器 RO,de_ctrl 连收发器 DE/RE。如果你的 PCB 上收发器的 RO 和 DI 是分离的,就按这个来;如果是单线半双工收发器,需要在 FPGA 侧做三态缓冲,这里不展开。后续要加 FIFO、CRC、Modbus 状态机,都往这个顶层里叠,每层职责单一,排查问题不至于翻整个工程。
4.3 半双工时序:发送完成到切换接收的转身延迟
RS485 是半双工,总线上同一时间只能有一个节点驱动。FPGA 发送完一帧后,如果立刻把 DE 拉低,总线上可能还残留最后一位的电平,对端会把这个残留边沿误判成下一帧起始位。更实际的问题是我在某次用 Modbus-RTU 轮询时,主站的 DE 刚从发送切到接收还没稳定,从站回帧的第一个字节被主站自己丢掉。解决办法就是“转身延迟”:发送结束后至少保持 DE 高电平 2 个 bit 周期再切接收。Modbus-RTU 标准里本来就有 3.5 个字符时间的帧间隔,9600 波特率下 3.5 字符约 3.5ms,转身延迟 260μs 根本不影响协议效率。
提示:TURN_AROUND_BITS 的单位是 bit 周期,不是时钟周期。现场如果对端收发器切换慢,把这个参数从 2 调到 4 通常就能解决,代价只是每帧多等几十微秒。
方向切换时另一个细节是接收模块的状态。DE 拉低瞬间,总线电平会从驱动状态变为浮空或接收状态,如果 A/B 没有偏置电阻,RO 上会产生一个负跳变,接收模块可能误判为起始位。我处理的办法是:方向脚拉低的同时,把 uart_rx 的 rx_busy 信号通知到上层,让它强制忽略接下来几个 bit 的接收数据。更省事的做法是给 rx_ready 加上“DE 释放后的屏蔽窗口”,这个窗口长度也设为 TURN_AROUND_BITS,和方向释放同步,简单有效。这些都是在项目实战里被我验证过的处理方式,直接抄的话能省掉不少现场排查时间。
5. RS485调试避坑:方向时序、总线浮空与采样点偏移
5.1 方向脚与半双工时序:首发丢字节、总线冲突
现象:两块板子用 RS485 直连,发送方发 0x55,接收方收到的第一个字节总是缺失或变成 0x00;或者总线偶尔挂死,收发器发烫。
原因:最常见是把 DE 和 tx_start 同时拉高,收发器内部发送使能还没完全建立,起始位就到了 DI 引脚,被当成无效电平丢掉。另一种是发送方 DE 释放太早,最后一个停止位没完整送上总线,对端在停止位中间采样失败,帧错误标志置位后丢弃整个字节。
解决:从时序上保证 DE 先于数据一个 bit 周期以上。我在方向状态机里做了一个“发送前排头”:tx_start 到来时,DE 拉高,同时一个延迟计数器开始跑,等 4 个 baud_tick 之后再把有效发送信号转给 uart_tx。如果收发器切换速度慢,这个预拉时间还要加长。测量方法很简单:用示波器同时看 DE 和 A/B 差分波形,确认 DE 高电平到起始位下降沿之间有至少一个 bit 宽度的空档。
现象:两个节点同时往总线上发数据,A/B 线短路或收发器发烫。
原因:协议层问题,两个节点的发送权限没有仲裁。但 FPGA 里有个隐蔽坑:某个节点死锁后方向脚一直保持高,这个节点永远占着总线,其他节点全部失联。
解决:给方向控制加“总线释放超时”。如果 tx_busy 持续高超过一帧最长发送时间(8N1 格式下每字节 11 个 bit,一帧 16 字节最多 176 个 bit),就强制把 DE 拉低并复位 uart_tx。这个超时计数器在 FPGA 里实现很容易,相当于一个硬件看门狗。我在现场靠它把总线从死锁状态捞回来过不止一次。
5.2 数据通路与复位:rx_ready漏采、首字节假数据、波特率误差
现象:接收模块偶发多收一个字节,尤其总线上空闲时会有杂乱的随机数据。
原因:RS485 总线空闲时,如果 A/B 没有接偏置电阻,收发器输入会浮在门限电压附近,输出电压随机翻转。这是物理层的坑,不是 FPGA 逻辑的坑。另一个可能是上电瞬间收发器 DE/RE 引脚状态不确定,RO 输出一串杂波,被 UART 接收当成起始位。
解决:在 PCB 上给 A/B 线加偏置电阻,一般 A 上拉、B 下拉,阻值根据终端电阻和节点数算,常见 560 欧到 1K。让总线空闲时差分电压稳定在高电平。FPGA 内则用“连续两个采样周期都为低才确认起始位”的消抖方式,我上一章给出的 uart_rx 代码里已经实现了这个确认逻辑。如果总线确实没有任何设备连接,RO 会随机翻转,用示波器能看到,这不是代码能完全解决的,必须靠偏置电阻。
现象:下游逻辑总是抓不到 rx_ready,数据时有时无。
原因:rx_ready 在接收模块里只持续一个 sample_tick,大约 6.5μs(9600 波特率下)。如果你拿一个 1MHz 的轮询逻辑去查它,有一段时间窗口可以抓到,但如果有其他信号抢占读取时机,就可能漏掉。我在一次 fpga 项目实战里把 rx_ready 接到外部中断,结果那边错过了脉冲,查了半天发现是 FPGA 这边脉冲太窄。
解决:把 rx_ready 改成“数据帧有效”电平,保持到下一帧数据到来前的一拍。更好的方案是给接收通路加一个 16 字节 FIFO,rx_ready 作为 FIFO 写使能,下游用 fifo_empty 和 fifo_rd_en 做跨时钟域握手。XC6SLX16 的 Block RAM 正好干这个用,一个 18Kb 的 FIFO 可以放约 2000 字节(8bit 宽度),省事且不浪费。
现象:接收到的数据中某一位偶发翻转,10 个字节里错一两个 bit。
原因:波特率误差累计超出容差,或者线缆过长边沿变缓。如果收发双方都用同一个程序但不同晶振,两边波特率标称都是 9600,实际一个 9600.6 一个 9600.3,理论上没问题;但如果有一端用了非标波特率,实际是 9590,另一端是 9600,累计误差到帧尾可能超过半个 bit。长线场景下,边沿变缓会让过采样点偏离 bit 中心。
解决:把所有节点的波特率计算成实际值,不要只看标称;用逻辑分析仪抓真实波形,看每个 bit 的中心位置是不是在标称中心附近。如果发现偏差大,可以把接收采样点从“第 16 拍”改成“第 14 拍”,也就是稍微靠左采,给停止位留出更多余量。这个参数调整在 uart_rx 里就是改一个数字的事,但需要根据实测波形来定,不要凭感觉改。还有一个我在调试中常用的手段:把波特率临时降到 4800,如果错帧消失,那一定是波特率误差或边沿问题,不是协议逻辑问题。
6. 验证技巧:回环自测与ILA让链路问题无处藏身
6.1 板内回环:不接对端设备也能验证逻辑
调试 RS485 最烦的是总线上只有一块板子,没有对端设备配合。我的做法是把 DE 强制拉高,或者直接用跳线把 txd_phy 和 rxd_phy 短接,让 FPGA 的发送数据直接回到接收端。然后编写一个简单的测试逻辑:上电后每 100ms 发送一帧递增字节,同时把接收到的数据与发送计数比对。
// 回环自测逻辑:发递增数,收回来比对 reg [7:0] send_cnt, recv_cnt; reg [31:0] timer; always @(posedge clk or negedge rst_n) begin if (!rst_n) timer <= 0; else timer <= timer + 1; end assign tx_start_in = (timer == 32'd5_000_000); // 100ms@50MHz assign tx_data_in = send_cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) send_cnt <= 0; else if (tx_busy_out) send_cnt <= send_cnt + 1; end always @(posedge clk or negedge rst_n) begin if (!rst_n) recv_cnt <= 0; else if (rx_ready_out) begin if (rx_data_out == send_cnt) recv_cnt <= recv_cnt + 1; else error_flag <= 1'b1; end end如果 error_flag 拉高,说明 UART 链路本身有问题;如果一直不发错,再接上 RS485 收发器做板间通信。注意回环自测时 DE 方向要固定发送,否则半双工逻辑会介入,干扰验证。这个自测逻辑我通常固化成一个“生产自检模式”,板子出厂时不需要接总线,上电跑一遍,GPIO 点个灯表示链路 OK,现场交付时能省掉一大半无谓排查。
6.2 用ILA/ChipScope看采样点
如果判断是边沿或采样问题,把 ILA(Spartan-6 上叫 ChipScope)插到 uart_rx 内部,触发条件设为 rx_ready 上升沿,采样深度 1024。然后看 rxd 波形和 sample_tick 的采样点:如果每个 bit 的采样位置越来越靠近边沿,说明波特率误差在累积;如果采样点稳定在 bit 中心,说明 UART 接收没问题,问题在协议层或物理层。ILA 还可以顺带观察 de_ctrl 和 tx_busy,确认方向脚切换的时序。
这个方法看起来简单,但非常有效。我第一版方向控制就是靠 ILA 抓到 DE 提前拉低、吞掉停止位的问题。那是比较典型的“两层耦合”错误:单看 UART 发送波形完全正常,单看 DE 波形也正常,只有把 DE 和 txd 放在同一个时间轴上看,才发现 DE 下降沿落在停止位中间。后来我把这套回环自测逻辑固化成了一个“生产自检模式”:板子出厂时不需要接总线,上电跑一遍回环测试,GPIO 点个灯表示链路 OK,现场交付时省掉一大半无谓排查。这也是我用 XC6SLX16 做 RS485 项目时学到的经验:先把数字逻辑跑稳,再把模拟总线接进来,最后再谈协议和上位机。希望这次聊的这些,能帮你在自己的 RS485 通信项目上少走两趟弯路。
本文还有配套的精品资源,点击获取