目录
[一:RS485 理论知识](#一:RS485 理论知识)
[2. 差分信号------RS485 的核心](#2. 差分信号——RS485 的核心)
[3. 半双工 + 方向控制(昨天学的 PB8)](#3. 半双工 + 方向控制(昨天学的 PB8))
[5. RS485 网络拓扑](#5. RS485 网络拓扑)
[6. RS485 vs CAN 对比(工控两兄弟)](#6. RS485 vs CAN 对比(工控两兄弟))
[7. Modbus RTU(RS485 最常见的上层协议,后面学)](#7. Modbus RTU(RS485 最常见的上层协议,后面学))
[3、发送流程(按 KEY1 触发)](#3、发送流程(按 KEY1 触发))
一:RS485 理论知识
一句话:RS485 = 串口 + 差分电平 + 半双工方向控制
1:为什么需要RS485
普通串口(TTL 电平)的痛点:
TTL 串口:0V=0, 3.3V=1
缺点:
① 距离短——超过 1 米就丢数据(电压衰减 + 干扰)
② 只能点对点——一个 TX 只能接一个 RX
③ 抗干扰差——工厂里电机一启动,5V 的 1 可能被干扰成 0
RS485 差分:用两根线(A/B)的电压差表示数据
A-B 压差 > +0.2V = 逻辑 1
A-B 压差 < -0.2V = 逻辑 0
优点:
① 距离长——1200 米(低速时)
② 多节点——一根总线上挂 32~128 个设备
③ 抗干扰强——干扰同时叠加在 A 和 B 上,压差不变
2. 差分信号------RS485 的核心
单端信号(TTL):一根线对地传
TX ──────── 信号线
GND ────── 参考地
干扰直接叠加在信号线上,电平就变了 ❌
差分信号(485):两根线传反相的信号
A ────────┐
├─ 接收端看 A-B 的差
B ────────┘
干扰同时打在 A 和 B 上(共模干扰),A-B 的差不变 ✅
这就是"差分抗干扰"的本质------干扰是共模的,两边一起涨一起跌,差值不受影响。
3. 半双工 + 方向控制(昨天学的 PB8)
RS485 是半双工:同一时刻只能一个方向传
485 芯片有两个控制脚:
DE(Driver Enable)—— 高 = 使能发送
RE(Receiver Enable)—— 低 = 使能接收(注意是低有效)
很多板子把 DE 和 RE 接一起,用一根 GPIO 控制(你的 PB8):
PB8 = 高 → 发送模式(MCU 往总线发数据)
PB8 = 低 → 接收模式(MCU 从总线听数据)
对讲机逻辑:按着按钮说话,松开听别人说
4:为什么必须"发完立刻切回接收"
这是 485 最容易踩的坑:
BSP_485_TX_EN(); // PB8 高,开始发
HAL_UART_Transmit(...); // 发数据
BSP_485_RX_EN(); // PB8 低,切回收 ← 必须做!
如果不切回收:
你的 485 芯片一直处于发送模式
→ 别人发来的数据你听不到
→ 而且你的发送器一直占着总线,干扰别人
5. RS485 网络拓扑
主机 (你的 STM32)
│
├─ 120Ω 电阻(总线起点)
│
│ A ────────────────┐
│ B ────────────────┤
│ │
│ ┌────┼────┐ ┌────┼────┐
│ │ 从机1 │ │ 从机2 │
│ │ A B │ │ A B │
│ └────┴────┘ └────┴────┘
│ │
│ ┌────┼────┐
│ │ 从机3 │
│ │ A B │
│ └────┴────┘
│ │
└─ 120Ω 电阻(总线终点)
-
两根线:A 和 B(有时还有 GND 参考地)
-
终端电阻:总线两端各 120Ω,防止信号反射。你的底板已经焊好了
-
菊花链:设备串行接,不能星形分叉
-
最多节点:32 个标准收发器,实际看芯片
6. RS485 vs CAN 对比(工控两兄弟)
| RS485 | CAN | |
|---|---|---|
| 底层 | USART(串口) | 专用 CAN 控制器 |
| 线数 | 2 根(A/B) | 2 根(CAN_H/L) |
| 节点寻址 | 无(靠协议) | ID 广播 |
| 多主竞争 | 无(软件仲裁) | 硬件自动仲裁 |
| 方向控制 | 需要(PB8) | 不需要 |
| 错误检测 | 无 | CRC+ACK+重发 |
| 上层协议 | Modbus RTU | CANopen/J1939 |
| 成本 | 低(几块钱芯片) | 高 |
一句话总结:RS485 便宜但"笨"------没有地址、没有仲裁、没有纠错,全靠软件(Modbus)补;CAN 贵但"聪明"------硬件把这些全做了。
7. Modbus RTU(RS485 最常见的上层协议,后面学)
Modbus RTU 帧格式:
┌────────┬──────────┬──────────┬─────┬─────┬─────┐
│ 地址 │ 功能码 │ 数据 │ CRC │ 结束 │
│ 1字节 │ 1字节 │ N字节 │ 2字节│ 3.5T │
└────────┴──────────┴──────────┴─────┴─────┘
主机:发"地址1,读保持寄存器"→ 从机1 应答"地址1,数据是xxx"
从机靠地址字节区分"是不是叫我",跟 CAN 的 ID 广播是两种思路
8:和can不同在哪
CAN 也是差分,但电平定义和叫法不同:
CAN 用两根线:CAN_H 和 CAN_L(对应 485 的 A 和 B)
隐性电平(逻辑 1,总线空闲):
CAN_H ≈ 2.5V
CAN_L ≈ 2.5V
压差 ≈ 0V → 表示 1
显性电平(逻辑 0,有数据):
CAN_H ≈ 3.5V
CAN_L ≈ 1.5V
压差 ≈ 2V → 表示 0
485 和 CAN 的关键区别:
| RS485 | CAN | |
|---|---|---|
| 逻辑 1 | A-B > +0.2V(正压差) | 压差 ≈ 0(隐性) |
| 逻辑 0 | A-B < -0.2V(负压差) | 压差 ≈ 2V(显性) |
| 空闲状态 | 由软件决定 | 固定隐性(1) |
| 谁规定 0/1 | 看 A-B 正负 | 看压差大小 |
CAN 为什么这样设计------还记得之前学的仲裁吗?
显性(0)压隐性(1):
节点A发 0 → CAN_H=3.5V, CAN_L=1.5V(强驱动,压差2V)
节点B发 1 → CAN_H=2.5V, CAN_L=2.5V(弱驱动,压差0V)
两个节点同时在总线上:
一个想把压差拉到 2V(显性0)
一个想保持 0V(隐性1)
→ 显性赢!总线变成 0
→ 发 1 的节点发现自己输了,自动退避
这就是 CAN 仲裁的物理基础——显性压隐性
二:硬件连接:
1:GPIO控制读和写

PB8控制读和写
// PB8 = 低 → 接收模式(435 芯片"听"总线,你的 MCU 准备收数据)
#define _485_RX_EN() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET);
// PB8 = 高 → 发送模式(435 芯片"喊"向总线,你的 MCU 往外发数据)
#define _485_TX_EN() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET);
//跟对讲机一样——按着按钮(PB8=高)才能喊话,松开按钮(PB8=低)只能听。
UART 天生全双工(发收各一根线),而 485 是半双工(发和收共用一根差分线)
必须靠 PB8 告诉 485 芯片"现在我是要说话还是要听话"
CAN 不需要这一脚——CAN 控制器硬件自动处理收发仲裁。
2:tx和rx跳帽选择
芯片视角命名的------TXD 是被写入,RXD 是从芯片读出:
STM32 ←──────────────→ 485 芯片 ←────────→ A/B 总线
PD5(TX) → TXD(被 MCU 写,MCU 想发的数据给它)
PD6(RX) ← RXD(从 MCU 读,芯片总总线上听来的数据送出来)
PB8 → DE/RE(高=发、低=收)
实物跳帽是这么连的:


pin1和pin2短接,给RS485芯片供电,pin3和pin4是485TX和485RX,需要接到我们单片机的TX和RX,PD5为f429对应的usart_tx,PD6为f429对应的usart_rx
PD5连485TX,PD6连485RX
普通串口直连(两个 MCU 直接对插,必须交叉):
MCU_A TX ──────→ RX MCU_B
MCU_A RX ←────── TX MCU_B
因为"我的发送"要接到"你的接收",所以 TX 接 RX
MCU 接 485 芯片(直连,不交叉):
MCU PD5(TX) ──→ 485_TX(485 芯片的"数据输入脚") //485_TX 其实是"芯片接收数据的输入脚"(DI)
MCU PD6(RX) ←── 485_RX(485 芯片的"数据输出脚") //485_RX 其实是"芯片接收数据的输入脚"(RO)
芯片的命名是"从芯片视角":485_TX = 芯片要发出去的数据(所以是 MCU 给它),485_RX = 芯片收进来的数据(所以给 MCU)
因为 CAN 外设内部自带回环路径,RS485 用的 USART 没有:
CAN 回环(硬件内置):
┌──── bxCAN 外设 ────┐
│ 发送引擎 ──┐ │
│ ↓ (内部绕回) │
│ 接收引擎 ←─┘ │
└────────────────────┘
TX/RX 引脚悬空,不经过外部
USART 回环(没有内部绕回电路):
┌──── USART2 ────┐
│ TX → PD5 引脚 │ ← 信号从这里出去了
│ RX ← PD6 引脚 │ ← 必须从外面接回来
└────────────────┘
│ ↑
└───线───┘ ← 只能外部短接
CAN 外设在芯片设计时就在数字逻辑里加了一条内部绕回路径,CAN_MODE_LOOPBACK 一开就行。USART 没设计这个功能------TX 发出去必须从外部把 PD5 和 PD6 连起来才能回环。RS485 的 485 芯片回环(接 485_D─485_R)同理------芯片内部也没绕回路,只能靠外部跳帽短接。
三:RS485回环实验
实验简介:
剥开那层 485 的皮,本质就是 USART2 收发------你昨天回环实验用杜邦线短接 PD5-PD6,压根没经过 485 芯片,纯粹是 STM32 的串口自己发自己收。
RS485 完整链路:
USART2 发送 → 485芯片转差分 → 双绞线 → 对面485芯片转回来 → USART2 接收
↑ 这层"皮"才是 485 跟串口的区别
你昨天做的:
USART2 发送 ──杜邦线直连── USART2 接收
(完全没碰 485 芯片,就是纯串口回环)
所以 RS485 学习成本极低------串口你会了,485 就只多一个 PB8 方向脚 + 一颗差分芯片。真正要花心思的不是 485 本身,是跑在它上面的 Modbus RTU 协议(地址、功能码、CRC 校验)。这也是为什么路线图里把 RS485 和 Modbus 放一起。
等你 USB 转 485 模块到了,把跳帽 Pin1-Pin2 接上给芯片供电,螺丝端子 A 接 A、B 接 B,就能验证真正的差分通信了。
1:BSP_485_Init初始化函数
g.Pull = GPIO_PULLUP; ← 配置"上拉电阻",不是"输出高电平"
BSP_485_RX_EN(); ← 拉低 = 设置输出电平为低
关键:Pull 和"输出电平"是两码事。
| 配置 | 控制什么 | 对推挽输出的影响 |
|---|---|---|
g.Mode = GPIO_MODE_OUTPUT_PP |
引脚当推挽输出用 | 决定引脚能输出 0/1 |
g.Pull = GPIO_PULLUP |
内部上拉电阻 | 推挽输出模式下其实没用 |
HAL_GPIO_WritePin() |
输出电平(ODR 寄存器) | 决定引脚实际输出 0 还是 1 |
HAL_GPIO_Init 只负责"把这个引脚配成推挽输出",它不设置输出电平 。所以 HAL_GPIO_Init 之后引脚电平是不确定的(保持复位默认值),你必须显式调一次 HAL_GPIO_WritePin 来明确拉低。
至于 g.Pull = GPIO_PULLUP 这行------对推挽输出模式来说是多余的,它只在输入模式(或开漏模式)才真正起作用。野火代码习惯性写上,不影响功能。
一句话:初始化只定"模式",拉低要单独写 ODR 寄存器 。所以 BSP_485_RX_EN() 这一步不能省------它明确告诉芯片"开机默认接收模式"。
2:HAL_UART_RxCpltCallback中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
if (usart1_rx_len < sizeof(usart1_rx_buf)) /* 防止溢出 */
{
usart1_rx_buf[usart1_rx_len++] = rx_byte; /* 存字节,计数 +1 */
}
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); /* 继续收下一个 */
}
else if (huart->Instance == USART2) /* 485 接收 */
{
if (bsp_485_rx_len < sizeof(bsp_485_rx_buf))
bsp_485_rx_buf[bsp_485_rx_len++] = bsp_485_rx_byte; /* 存字节 */
if (bsp_485_rx_len == 256) bsp_485_rx_flag = 1;
HAL_UART_Receive_IT(&bsp_485_huart, (uint8_t *)&bsp_485_rx_byte, 1); /* 继续收 */
}
}
bsp_485_rx_flag = 1 在第 158 行——当 bsp_485_rx_len == 256(收满 256 字节)时置 1。
为什么它在 Usart.c 而不是 bsp_485.c?因为 HAL_UART_RxCpltCallback 是整个工程只能有一个的全局回调——USART1 和 USART2 共用它。之前两个文件各自写了一份,链接报 multiply defined。所以我把它统一放在 Usart.c,用 huart->Instance 区分是哪个串口:
HAL_UART_RxCpltCallback(huart)
├─ huart == USART1 → 存到 usart1_rx_buf(调试串口)
└─ huart == USART2 → 存到 bsp_485_rx_buf,收满256字节置 bsp_485_rx_flag=1(485)
这样设计有个小缺点:485 的接收逻辑散落在 Usart.c 里,不够内聚。如果你觉得乱,更好的做法是回调里只调一个 bsp_485_rx_byte 相关的处理函数,但功能上现在这样跑通了。
四:实验总结
1、硬件层(本质)
STM32 USART2
│
├─ PD5(TX) ──杜邦线短接──→ PD6(RX) ← 数据通路(绕过 485 芯片)
│
└─ PB8(DE/RE) → 485 芯片方向脚 ← 本实验没接芯片,空转
2、初始化流程
main()
├─ HAL_Init() HAL 库初始化
├─ SysTick_Init() 180MHz PLL + 10us 时基
├─ LED_Init() RGB LED
├─ Usart1_Init(115200) 调试串口(打印用)
├─ Key_Init() KEY1 GPIO
└─ BSP_485_Init()
├─ 配 PD5(TX)/PD6(RX) 为 AF7 推挽
├─ 配 PB8 为推挽输出,默认拉低(接收模式)
├─ 配 USART2 115200 8-N-1
└─ 启动首次 HAL_UART_Receive_IT(收 1 字节到 bsp_485_rx_byte)
3、发送流程(按 KEY1 触发)
main() while(1)
│
├─ 检测 KEY1 按下(消抖)
├─ 清空接收缓冲:rx_len=0, rx_flag=0
│
├─ 循环发 256 字节(0x00~0xFF):
│ for (i = 0; i < 256; i++)
│ BSP_485_SendByte(i)
│ ├─ PB8 拉高(发送模式) ← 本实验空转
│ ├─ HAL_UART_Transmit 发 1 字节
│ └─ PB8 拉低(接收模式) ← 本实验空转
│
├─ HAL_Delay(500) 等接收完成
│
└─ 检查 bsp_485_rx_flag:
= 1 → 打印收到的 256 字节
= 0 → 提示"检查 PD5-PD6 是否短接"
4、接收流程(中断自动,藏在后台)
发出去的字节从 PD5 → PD6 流回来
│
├─ USART2 RXNE 中断触发
│ └─ USART2_IRQHandler → HAL_UART_IRQHandler
│ └─ HAL_UART_RxCpltCallback(在 Usart.c)
│ ├─ huart == USART2 分支
│ ├─ bsp_485_rx_buf[rx_len++] = bsp_485_rx_byte ← 存字节
│ ├─ 收满 256 字节 → bsp_485_rx_flag = 1
│ └─ 重新 HAL_UART_Receive_IT 收下一字节
│
└─ main 里检测到 flag=1 → 打印缓冲区
5、一句话总结
写:HAL_UART_Transmit 把字节塞进 USART2 发送寄存器 → 从 PD5 出去
读:PD6 收到字节 → RXNE 中断 → 回调存进 bsp_485_rx_buf
回环:杜邦线让 PD5 的数据直接流回 PD6
PB8:485 芯片的方向控制,本实验芯片未参与,空转
本质就是 USART2 的收发 + 中断缓冲,套了层 485 的皮(PB8 方向控制 + 差分芯片),回环阶段只验证了 USART2 软件部分