第28章:RS-485串口通信实验
本章对应原书第 660~685 页,属于「第五篇 实战篇」。
本章在讲什么?(先看这个)
在"UART 串口通信实验"里我们学会了用串口传数据,但普通串口(RS-232 电平)有个致命弱点:抗干扰差、传不远。这一章要把串口"升级"成工业现场常用的 RS-485 接口——它靠两根线的电压"差值"来表示数据,抗干扰能力极强,一根总线还能挂 128 个设备。
本章的实验任务是:用两块开发板通过 RS-485 互联,各自板上的两个按键控制对方开发板上两个 LED 的亮灭。你会学到差分传输原理、SP3485 电平转换芯片的用法,以及如何把程序固化到 Flash 里做双板联调。
核心概念拆解
28.1 RS-485简介:先搞懂"差分传输"
RS-485 是一种基于 UART 串口的接口标准,主要规定发送器和接收器的电气特性。注意一个容易混淆的点:RS-232、RS-422、RS-485 都是"接口标准"(管电平和接线),而 UART 是"串口协议"(管数据格式),两者是不同层面的概念,实际使用中常常组合在一起。
先看两种传输方式的差别:
- 单端传输:用一根信号线对地线的电压表示逻辑 0 和 1(RS-232 就是这种)。
- 差分传输:用两根信号线传一路信号,两根线上幅值相等、极性相反,用它们的差值表示逻辑。

差分传输为什么抗干扰?打个比方:你和朋友在嘈杂的会场里,一人贴着另一人耳朵小声说话(单端),很容易被环境噪声污染;但如果你们俩用"你抬高音量、我压低音量"的相对变化来传递信息(差分),环境噪声同时叠加到两人身上,相减之后就抵消了。

当信号线上叠加了频率、幅值、相位都相同的干扰(共模干扰)时,单端传输的信号会被污染;而差分传输两根线上的干扰在求差时互相抵消,大大提高了抗干扰能力——代价是多用一根信号线。
三种电气协议对比(记结论就行):
| 对比项 | RS-232 | RS-422 | RS-485 |
|---|---|---|---|
| 工作方式 | 单端,3线全双工 | 差分,4线全双工 | 差分,2线半双工 |
| 节点数 | 1发1收 | 1发10收 | 多发多收,最大128个收发器 |
| 最大传输距离 | 约15米 | 约1219米 | 约1219米 |
| 最大传输速率 | 20Kbps | 10Mbps | 10Mbps |
| 逻辑1 | -3V~-15V | 差值电压+(2~6)V | 差值电压+(2~6)V |
| 逻辑0 | +3V~+15V | 差值电压-(2~6)V | 差值电压-(2~6)V |
要点:RS-485 只有 2 根信号线(A 和 B),发送和接收共用,所以只能半双工通信(不能同时收发),需要靠使能信号切换收发方向。
28.2 硬件设计:SP3485 电平转换

开发板板载了 RS485 接口,但它的通信引脚并没有直连 ZYNQ,而是需要跳帽选择。看下面的选择接口原理图:

P1 口的 UART2_TX 和 UART2_RX 直连 ZYNQ 引脚。本次实验要把 3和5、4和6 两组引脚用跳帽连接,选择到 RS485:

ZYNQ PL 侧串口引脚输出的是 TTL 电平(3.3V 表示 1,0V 表示 0),而 RS-485 用差分电压表示逻辑,所以中间必须有一个电平转换芯片——SP3485:

SP3485 关键引脚:
| 引脚 | 说明 |
|---|---|
| RO | 接收数据输出(到 FPGA 的 RX) |
| RE | 数据接收使能,低电平有效 |
| DE | 数据发送使能,高电平有效 |
| DI | 发送数据输入(来自 FPGA 的 TX) |
| A | 差分传输线+(正向) |
| B | 差分传输线-(反向) |
芯片判断逻辑:A-B > +0.2V 表示逻辑 1,A-B < -0.2V 表示逻辑 0。A、B 引脚上挂的三个电阻(R1、R3、R4,阻值比 3:1:3)形成偏置,保证空闲时 A、B 之间有约 0.47V 的压差(A 线约 1.88V,B 线约 1.41V)。

由于 RS-485 是半双工,收发方向怎么控制?RE 低有效、DE 高有效,两根使能线有效值相反,所以硬件上直接把它们连在一起,用一个信号 RS485_DE 控制:高电平发送,低电平接收。

更妙的是这块板用三极管做了方向自动控制:RS485_DE 由 ZYNQ 输出的 UART2_TX 经三极管驱动。UART2_TX 空闲时为高,三极管导通,RS485_DE 被拉低,芯片稳定处于接收状态;发送数据 0 时三极管截止,RS485_DE 拉高进入发送状态,把 0 发出去;发送数据 1 时三极管导通、芯片处于接收态,但接收态下数据线默认为高电平,所以 1 也能"发"出去。于是在 FPGA 代码里,你完全不用管方向切换,把 RS485 当普通串口用即可。
28.3 实验任务
用两块开发板通过 RS485 端口互联,各自的两个按键控制对方开发板上两个 LED 的亮灭:按键按下时对方对应 LED 点亮,释放时熄灭。
28.4 程序设计
总体思路:RS-485 本质上只是接口标准,经电平转换芯片后,RS485 接口可以完全当串口用。所以本章工程就是在"按键 + 串口收发 + LED"的组合上搭起来的:

两块开发板功能完全一样:本地按键经消抖后,通过串口发送模块发给对方;同时接收对方发来的按键值,取反后驱动本地 LED。

顶层系统框图包含五个模块:

- 顶层模块(rs485_key_led):例化和连接各子模块;
- 按键触发模块(key_trig):消抖并产生发送使能;
- 串口发送模块(uart_tx)、串口接收模块(uart_rx):直接复用"UART 串口通信实验"中的模块,一行不改;
- LED 控制模块(led_ctrl):解析接收数据控制 LED。
按键触发模块
难点在于:什么时候启动一次发送?答案是按键状态发生改变时。判断方法是给消抖后的按键值打两拍(key_data_d0、key_data_d1),两拍值不相等说明数据变了,此时输出一个只持续一个时钟周期的高电平信号 key_data_valid 作为串口发送使能。

两个按键各需一个消抖模块,所以例化两次"按键消抖模块"(来自按键控制蜂鸣器实验)。模块框图与时序如下:

核心代码:
module key_trig(
input clk , //外部50M 时钟
input rst_n , //外部复位信号,低有效
input [1:0] key , //外部按键输入
output reg key_data_valid, //按键数据有效标志
output [1:0] key_data //按键数据
);
//reg define
reg [1:0] key_data_d0;
reg [1:0] key_data_d1;
//对key_data 信号打两拍用于判断按键是否发生改变
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
key_data_d0 <= 2'b11;
key_data_d1 <= 2'b11;
end
else begin
key_data_d0 <= key_data;
key_data_d1 <= key_data_d0;
end
end
//当检测到按键数据发生改变时,key_data_valid 拉高
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
key_data_valid <= 1'b0;
else if (key_data_d1 != key_data_d0)
key_data_valid <= 1'b1;
else
key_data_valid <= 1'b0;
end
//按键0 消抖模块
key_debounce u_key_debounce_0(
.sys_clk (clk ),
.sys_rst_n (rst_n ),
.key (key[0] ),
.key_filter (key_data[0])
);
//按键1 消抖模块
key_debounce u_key_debounce_1(
.sys_clk (clk ),
.sys_rst_n (rst_n ),
.key (key[1] ),
.key_filter (key_data[1])
);
endmodule
代码分三部分:打两拍检测变化、产生单周期有效脉冲、例化两个消抖模块。复位时把寄存器初始化为 2'b11(按键空闲态,低电平按下)。

仿真中模拟 key0 按下:按键数据变化时 key_data_valid 拉高且只保持一个时钟周期,与设计的时序图一致。
LED 控制模块
输入是串口接收模块给出的数据(取低 2 位有效数据 led_data)和接收完成信号 uart_done(作为使能 led_en),输出 2 位 LED 控制信号。

这里有个通用经验值得记住:找模块间的"握手"信号时,不一定都能找到高有效、单周期的完美同步信号。如果找到的是低有效信号就取反;持续多周期的信号就做取沿操作;异步信号就打拍同步到本模块时钟域。

注意一个细节:按键按下是低电平,而 LED 是高电平点亮,所以接收数据要取反后再赋给 LED——这就是时序图中 led_data 为 2'b00 时 led 却输出 2'b11 的原因。
module led_ctrl(
input clk , //外部50M 时钟
input rst_n , //外部复位信号,低有效
input led_en , //LED 控制使能
input [1:0] led_data, //LED 控制数据
output reg [1:0] led //LED 灯
);
//控制LED 灯的变化
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
led <= 2'b00;
else if(led_en) //在led_en 使能时,改变LED 灯的状态
led <= ~led_data; //按键按下时为低电平,而LED 高电平时点亮
else
led <= led;
end
endmodule

顶层模块
顶层做各模块例化与信号连接。有一个"编码"细节:按键数据只有 2 位,而串口一帧数据是 8 位,所以发送前把 2 位按键数据放在低 2 位、高 6 位补 0(编码);接收时只取低 2 位有效数据(解码)。
module rs485_key_led(
input sys_clk , //外部50M 时钟
input sys_rst_n , //外部复位信号,低有效
input [1:0] key , //按键
output [1:0] led , //LED 灯
//uart 接口
input rs485_uart_rxd, //rs485 串口接收端口
output rs485_uart_txd //rs485 串口发送端口
);
//parameter define
parameter CLK_FREQ = 50000000; //定义系统时钟频率
parameter UART_BPS = 115200 ; //定义串口波特率
//wire define
wire uart_tx_en ; //UART 发送使能
wire uart_rx_done ; //UART 接收完毕信号
wire [7:0] uart_tx_data ; //UART 发送数据
wire [7:0] uart_rx_data ; //UART 接收数据
wire [1:0] key_data ; //按键数据
//将2 位按键数据编码为8 位的串口数据
assign uart_tx_data = {6'b0 , key_data};
//串口接收模块
uart_rx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS))
u_uart_rx(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.uart_rxd (rs485_uart_rxd),
.uart_rx_done (uart_rx_done ),
.uart_rx_data (uart_rx_data )
);
//LED 控制模块
led_ctrl u_led_ctrl(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.led_en (uart_rx_done ), //LED 控制使能
.led_data (uart_rx_data[1:0] ), //LED 控制数据
.led (led )
);
//按键触发模块
key_trig u_key_trig(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.key (key ),
.key_data_valid (uart_tx_en ), //按键有效通知信号
.key_data (key_data ) //按键数据
);
//串口发送模块
uart_tx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS))
u_uart_tx(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.uart_tx_en (uart_tx_en ),
.uart_tx_data (uart_tx_data ),
.uart_txd (rs485_uart_txd)
);
endmodule
仿真验证
TestBench 需要激励四个输入:系统时钟、复位、串口接收线、按键。串口和按键的激励写法分别来自之前的串口实验和按键实验,这里只强调一个新语法:
defparam u_rs485_key_led.u_key_trig.u_key_debounce_0.CNT_MAX=10;
defparam u_rs485_key_led.u_key_trig.u_key_debounce_1.CNT_MAX=10;
defparam(重定义参数)可以在编译时通过层次路径修改底层模块的参数值,格式为 defparam 例化模块名.参数名=参数值;。这里把消抖计数上限改成 10,大幅缩短仿真时间。注意:defparam 不可综合,只能用在仿真代码里,工程代码中千万别用。
`timescale 1ns/1ns
module tb_rs485_key_led;
//parameter define
parameter T = 20; // 时钟周期为20ns
defparam u_rs485_key_led.u_key_trig.u_key_debounce_0.CNT_MAX=10;
defparam u_rs485_key_led.u_key_trig.u_key_debounce_1.CNT_MAX=10;
//reg define
reg sys_clk ;
reg sys_rst_n;
reg [1:0] key ;
reg uart_rxd ;
//给输入信号初始值
initial begin
sys_clk = 1'b0 ;
sys_rst_n = 1'b0 ;
key = 2'b11;
uart_rxd = 1'b1 ; // 空闲为高
#(T+10)
sys_rst_n = 1'b1;
#(1000)
uart_rxd = 1'b0 ; //起始位
#(8680) uart_rxd = 1'b0; //bit0 位
#(8680) uart_rxd = 1'b0; //bit1 位
#(8680) uart_rxd = 1'b0; //bit2 位
#(8680) uart_rxd = 1'b0; //bit3 位
#(8680) uart_rxd = 1'b0; //bit4 位
#(8680) uart_rxd = 1'b0; //bit5 位
#(8680) uart_rxd = 1'b0; //bit6 位
#(8680) uart_rxd = 1'b0; //bit7 位
#(8680) uart_rxd = 1'b1; //停止位
#(1000)
key = 2'b10; // 模拟按键抖动
#(20)
key = 2'b11;
#(60)
key = 2'b10;
end
//50Mhz 时钟,每10ns 电平取反一次
always #(T/2) sys_clk = ~sys_clk;
//例化顶层模块
rs485_key_led u_rs485_key_led(
.sys_clk (sys_clk ),
.sys_rst_n (sys_rst_n),
.key (key ),
.led (),
.rs485_uart_rxd (uart_rxd ),
.rs485_uart_txd ()
);
endmodule

波形1验证了发送方向:按键数据变化 → key_data_valid 拉高 → 串口发送模块被使能发出数据。

波形2验证了接收方向:模拟收到 8'h00(对方两个按键都按下),uart_done 拉高后 LED 控制模块解析数据并驱动 LED。
28.5 生成比特流
引脚约束如下:
| 信号名 | 方向 | 管脚 | 说明 | 电平标准 |
|---|---|---|---|---|
| sys_clk | input | U18 | 系统时钟 50MHz | LVCMOS33 |
| sys_rst_n | input | N16 | 复位,低有效 | LVCMOS33 |
| key[0] | input | L14 | PL端按键0 | LVCMOS33 |
| key[1] | input | K16 | PL端按键1 | LVCMOS33 |
| led[0] | output | H15 | PL端LED0 | LVCMOS33 |
| led[1] | output | L15 | PL端LED1 | LVCMOS33 |
| rs485_uart_rxd | input | K14 | RS-485串口接收线 | LVCMOS33 |
| rs485_uart_txd | output | M15 | RS-485串口发送线 | LVCMOS33 |
set_property -dict {PACKAGE_PIN U18 IOSTANDARD LVCMOS33} [get_ports sys_clk]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports sys_rst_n]
set_property -dict {PACKAGE_PIN L14 IOSTANDARD LVCMOS33} [get_ports {key[0]}]
set_property -dict {PACKAGE_PIN K16 IOSTANDARD LVCMOS33} [get_ports {key[1]}]
set_property -dict {PACKAGE_PIN H15 IOSTANDARD LVCMOS33} [get_ports {led[0]}]
set_property -dict {PACKAGE_PIN L15 IOSTANDARD LVCMOS33} [get_ports {led[1]}]
set_property -dict {PACKAGE_PIN K14 IOSTANDARD LVCMOS33} [get_ports rs485_uart_rxd]
set_property -dict {PACKAGE_PIN M15 IOSTANDARD LVCMOS33} [get_ports rs485_uart_txd]
引脚分配完成后编译工程、生成比特流文件。
28.6 程序固化与下载验证
本次实验需要两块开发板通信,同一台电脑接两个下载器容易冲突,只接一个又免不了对 JTAG 热插拔(有损坏器件的风险),所以这次必须先把程序固化到 Flash,再断电脱离下载器运行。
固化脚本的使用
工程里的"zynq 纯PL 端固化脚本"文件夹包含三个文件:
- bootbin.bif:引导镜像文件,指定用哪些文件生成 BOOT.bin;
- create_bootbin.bat:批处理文件,调用 Vitis 的 bootgen(生成镜像)和 program_flash(烧写)功能,生成 BOOT.bin 并烧写到开发板 Flash;
- zynq_fsbl.elf:固化的初始化文件(7010 和 7020 核心板 DDR 型号不同,要用对应版本的 elf)。
使用步骤:① 用文本编辑器打开 create_bootbin.bat,把其中路径替换为你电脑上 Vitis 安装路径下的 bin 文件夹路径;② 把工程生成的比特流文件复制到该文件夹;③ 打开 bootbin.bif,确认其中比特流文件名与实际一致(同文件夹下直接写文件名即可)。
然后:连接电源和下载器,把启动模式开关 BOOT_CFG 拨到 JTAG 模式(1、2 都拨到 ON),双击运行 create_bootbin.bat。看到"Bootimage generated successfully"说明 BOOT.bin 生成成功;不要关窗口,继续等待,出现"Flash Operation Successful"说明固化完成。之后断电、断开下载器,用同样方法固化第二块板。
注意:固化完成后要先断电,再把 BOOT_CFG 拨到 QSPI 启动模式(1 拨到 ON、2 拨到 OFF),下次上电才会运行 Flash 里的程序。
上板验证
- 两块板的 RS485 接口用两根公对公杜邦线连接,A 接 A、B 接 B,不能接反;注意 CAN 接口与 RS485 接口外观很像,别插错。
- 两块板的 P1 口都要用跳帽(K14 即 RX 跳帽连接 485_TX,M15 即 TX 跳帽连接 485_RX)选择到 RS485。
- BOOT_CFG 拨到 QSPI 启动模式,上电。按下任一板上的按键,另一块板对应的 LED 点亮;松开熄灭,即验证成功。
另一种固化方式:把 BOOT.bin 拷贝到格式化为 FAT32 的 SD 卡,插卡后把 BOOT_CFG 拨到 SD CARD 启动模式(1、2 都 OFF),即可从 SD 卡启动。
初学者容易踩的坑
- 接口标准 ≠ 串口协议:RS-232/422/485 是电气接口标准,UART 是数据协议,RS-485 实验里 FPGA 内部跑的仍然是 UART 协议,只是电平被 SP3485 转换了。
- 杜邦线不能接反:两块板的 RS485 必须 A 接 A、B 接 B;而且板上 CAN 接口和 RS485 接口外观相似,插错口通信必失败。
- 跳帽别忘接:RS485 引脚不直连 ZYNQ,P1 口必须用跳帽选择到 RS485(K14→485_TX、M15→485_RX),否则怎么烧程序都没反应。
- 半双工别同时收发:RS-485 只有两根差分线,收发共用,同一时刻只能一个方向传输;不过本实验硬件已自动切换方向,代码里不用管。
- defparam 只能用于仿真:它不可综合,只是缩短仿真时间(如把消抖参数改小)的工具,写进工程代码会导致综合失败。
- 固化后要断电再换启动模式:Flash 烧写完成后必须先断电,再拨到 QSPI 模式,否则不会运行固化程序;且烧写窗口显示成功前不要关闭。
小结 & 下一步
本章核心是理解差分传输的抗干扰原理和 RS-485 的半双工、多点特性,并借助 SP3485 把"RS485 当串口用"——FPGA 内部逻辑与之前的 UART 实验完全一致,只是做了一次 2 位按键数据的编码/解码。同时你掌握了把纯 PL 程序固化到 QSPI Flash / SD 卡的完整流程。
动手建议:完成拓展训练——当解析到 11 时两灯熄灭、按下 key0(10)两灯点亮、按下 key1(01)两灯同闪、同时按下(00)两灯交替闪烁;再用固化脚本对旧例程做一次 QSPI 和 SD 卡启动实验,巩固固化流程。