RS-485 与 Modbus RTU 详解
笔记 / 嵌入式
引言
车间里,PLC 到变频器隔着三百米电缆,旁边接触器和变频器持续制造干扰。UART 的”TX 对地”单端信号在自家板子上没有问题,拉出机箱就难以可靠工作。工业现场的事实标准是一对搭档:RS-485 负责电气层,规定比特如何在长线上传输;Modbus 负责协议层,规定谁说话、说什么、如何确认接收。
本页自底向上展开:差分抗噪的原理、端接与偏置、Modbus 的主从问答、帧格式与 CRC、3.5 字符静默分帧,以及驱动代码里容易踩的坑。UART 基础在 嵌入式通信协议UART、I2C、SPI对比详解,这里不重复。
一、RS-485 电气层:差分抗噪
单端信号用对地电压表示 0 和 1,地被干扰抬高 2 V,信号就跟着失真。RS-485 不看绝对电压,只看 A、B 两根线的电压差:V(A)−V(B) > +200 mV 判 1,< −200 mV 判 0。干扰电压耦合到双绞线时,两根线几乎同样多(共模),相减后抵消,这是它能跑长线的原因。

一根总线挂所有设备,同一时刻只允许一个驱动器发送,半双工分时复用。关键数字(出自 TI SLLA070D):
| 参数 | 数值 | 说明 |
|---|---|---|
| 接收判决阈 | ±200 mV | 差分死区 ±200 mV 内输出不定 |
| 共模范围 | −7 ~ +12 V | 两端地电位差再大也能正确判决 |
| 节点数 | 32 单元负载 | 1 UL ≈ 12 kΩ 输入;1/8 UL 芯片可挂 256 个 |
| 距离 | 最远约 1200 m | 速率距离互换:10 Mbps 只能十几米,1200 m 配 100 kbps 档 |
| 拓扑 | 半双工多点 | 手拉手总线,不接星型 |
二、端接与偏置
信号边沿走到电缆尽头,遇到阻抗突变会把一部分能量反射回来,叠在后续位上,眼图变差。端接的做法是在电缆两端挂一个等于特征阻抗的电阻(双绞线约 120 Ω),相当于给末端接上它期望的负载,能量到头被吸收,不再反弹。

三条工程规则比记接法更重要:
- 端接只加两端。中间节点不端接,每多一个 120 Ω 就是把总线负载再并一份,驱动器推不动。
- 短线低速可以不端接。传播延时远小于位时间时,反射在采样窗之外已经稳定,加了反而加重负载;长线高速必须端接。
- AC 端接省电。电阻串一个小电容隔断直流,牺牲边沿响应换功耗,电池供电的现场设备用。
还有一个隐蔽的坑:所有驱动器都释放(空闲)时,A、B 间差分是 0 V,正好落在 ±200 mV 死区里,接收器输出随机跳变,UART 一路收乱字符。解法是失效保护偏置:A 上拉、B 下拉各一个 680 Ω,无驱动时制造出固定极性。计算一下:两端 120 Ω 端接并联成 60 Ω,5 V 经 680 Ω—60 Ω—680 Ω 分压,V(A)−V(B) = 5×60/1420 ≈ 0.21 V,刚好越过死区。较新的收发器多数内置失效保护(0 V 差分时输出确定态),新设计先查数据手册再决定是否外挂。
三、Modbus:一主多从的问答
电气层只保证比特到位,Modbus 在上面定义了组织方式:单主多从,主站发起每一次报文,从站只应答被点名的单播(地址 1–247),广播(地址 0)不应答。从站不能主动发送,原因是半双工总线上没有仲裁机制:CAN 有显性位仲裁,输了自动退让(CAN总线详解),而 485 上两个驱动器同时发送会造成总线冲突,所以协议定为纯问答。
| 通信方式 | 从机地址 | 从机行为 |
|---|---|---|
| 单播 | 1–247 | 收到 → 处理 → 必须应答 |
| 广播 | 0 | 收到 → 处理 → 不应答(所有从机同时执行,比如全员同步启停) |
RTU 模式的一帧长这样(MODBUS over Serial Line V1.02):

| 字段 | 长度 | 作用 |
|---|---|---|
| 从机地址 | 1 B | 0 广播、1–247 单播、248–255 保留 |
| 功能码 | 1 B | 要干什么;应答同码,异常时应答最高位置 1(0x83 = 03 出错) |
| 数据 | 0–252 B | 寄存器地址、数量、写入值……随功能码变 |
| CRC | 2 B | 整帧校验,低字节先发 |
常用功能码就八个,覆盖九成场景:
| 功能码 | 含义 | 功能码 | 含义 |
|---|---|---|---|
| 01 | 读线圈(开关量输出) | 05 | 写单个线圈 |
| 02 | 读离散输入(开关量输入) | 06 | 写单个寄存器 |
| 03 | 读保持寄存器 | 0F | 写多个线圈 |
| 04 | 读输入寄存器 | 10 | 写多个寄存器 |
拿一个真实报文走一遍。主站问 1 号从机”读 0 号起 2 个保持寄存器”:
请求: 01 03 00 00 00 02 C4 0B
│ │ └────┬────┘ └─CRC─┘
│ │ 起始0x0000 数量2 │
│ 功能码03=读保持寄存器 低字节C4在前
从机地址1
应答: 01 03 04 00 0A 01 02 5A 60
│ │ │ └────┬────┘ └─CRC─┘
│ │ 字节数4 两寄存器值 寄存器值也是高字节在前
│ 同码03=正常 0x000A=10、0x0102=258
从机地址1
CRC 那两个字节是算出来的(下一节),顺序反一个字节,从机就丢弃整帧。
四、字符格式与 CRC:每个字节 11 位
RTU 没有字节转义、没有起始符,字符本身就是标准异步串口格式(低位在前,与 嵌入式通信协议UART、I2C、SPI对比详解 里的 UART 帧一致),位数固定为 11 位:

| 校验配置 | UART 参数 | 说明 |
|---|---|---|
| 偶校验(规范默认) | 8E1 | 必须支持 |
| 无校验 | 8N2 | 停止位补成 2 位凑满 11 位 |
| 奇校验 | 8O1 | 可选 |
数据位低位在前,与 UART 硬件的天然顺序一致,不需要软件倒腾。全帧校验用 CRC-16(多项式 0x8005、初值 0xFFFF、无最终异或),逐位版十几行:
uint16_t modbus_crc16(const uint8_t *buf, uint16_t len)
{
uint16_t crc = 0xFFFF;
while (len--) {
crc ^= *buf++;
for (int i = 0; i < 8; i++) // 每字节 8 轮移位
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
} // 0xA001 是 0x8005 的反转多项式
return crc; // 低字节先发!
}
// 发送侧拼帧:
// buf[n++] = crc & 0xFF; ← CRC Low 先进缓冲区
// buf[n++] = crc >> 8; ← CRC Hi 压轴
实现中最常见的 bug 是把高字节先发。帧图里”CRC Low、CRC Hi”的顺序就是线上顺序,查表法算出的 16 位值直接按大端塞,从机必报 CRC 错。查表法快八倍,逐位法省 512 字节 ROM,从机侧报文本来就慢,逐位足够。
五、帧定界:3.5 字符的静默
没有起始符,接收方靠静默判断一帧结束。UART 靠每个字符的起始位重新对齐,RTU 则用一段静默作为帧的边界:总线上保持至少 3.5 个字符时间的安静,就算上一帧结束、下一帧可以开始。

反过来,帧内不允许有超过 1.5 字符的空隙:中途断流超过 1.5 字符,接收方宣布该帧作废丢弃(规范原文 NOK),等下一次静默后重新找帧头。这两条规矩把定界从协议字节挪到了时间轴上,收发双方都得有时间观念:
- 波特率越低,3.5 字符越长:9600 bps 下一个字符 11/9600 ≈ 1.15 ms,3.5 字符 ≈ 4.0 ms,软件轮询都来得及。
- 波特率高于 19200 后字符时间太短,MCU 难以精确计时,规范直接给固定值:t1.5 = 750 µs、t3.5 = 1.75 ms,不再按波特率折算。
- 主机连续发两帧,中间必须自己留满 3.5 字符的静默;从机应答前也要等总线静下来。
实现侧的正解是 UART 的空闲中断(IDLE):总线上一个字符时间没有新字节就触发。它比 3.5 字符宽松(1 字符就报),但主机发完一帧必然留足静默,所以”IDLE 触发时刻 = 一帧收完”在实践中成立。配上 DMA 把字节自动搬进缓冲,CPU 全程不参与(DMA直接存储器访问详解 的串口空闲 + DMA 组合正是为此)。
六、驱动代码:方向控制
485 收发器有一根 DE/RE 方向脚(常绑在一起):高电平是驱动器占线,低电平是释放总线听别人。发送函数的关键是何时释放:
void rs485_send(uint8_t *buf, uint16_t len)
{
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET); // 拉高 DE:占用总线
HAL_UART_Transmit(&huart2, buf, len, 100); // 阻塞发完全部字节
while (!__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC)) {} // 等 TC:移位寄存器也空了
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_RESET); // 释放总线,切回听
}
坑在最后两行的顺序:TXE 只表示数据寄存器空了,最后 1~2 字节还在移位寄存器里往外发送,此刻拉低 DE 会把它们截断,表现是”应答最后一个字节随机丢”,而且只在某些从机上复现,很难查。必须等 TC(Transmission Complete),一个不多一个不少。
接收侧骨架,IDLE + DMA + 三道检查(搬运细节见 DMA直接存储器访问详解):
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t size)
{
if (size >= 4) { // 地址+功能+CRC 至少 4 字节
uint16_t crc = modbus_crc16(rxbuf, size - 2); // 校验前 n-2 字节
if (rxbuf[size-2] == (crc & 0xFF) && // 低字节先发,对照别反
rxbuf[size-1] == (crc >> 8) &&
(rxbuf[0] == MY_ADDR || rxbuf[0] == 0)) { // 地址匹配,或广播
handle_request(rxbuf, size); // 组织应答,rs485_send 出去
}
} // CRC 错或地址不匹配:不响应
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rxbuf, sizeof rxbuf); // 重新武装
}
两处不响应都是刻意的:CRC 错了不应答,让主站超时重试,而不是把错误数据用出去;地址不匹配不应答,那是别的从机的对话,会造成总线冲突。主站侧每次请求都挂数百毫秒超时,超时重试两三次再报故障。问答协议的可靠性,一半在协议里,一半在超时策略里。
七、常见问题与注意事项
| 症状 | 根因 | 解法 |
|---|---|---|
| 总线空闲时收到乱码 | 无失效保护,0 V 差分落在死区 | 680 Ω 偏置,或选内置失效保护的收发器 |
| 应答最后一字节随机丢 | DE 提前释放,末字节被截断 | 等 TC 标志再拉低,别用 TXE |
| 长线偶发 CRC 错 | 未端接,反射叠加 | 两端各 120 Ω,线用双绞 |
| 距离上不去 | 忽略速率距离互换 | 降波特率,换低电容电缆 |
| 两设备同时发送,数据损坏 | 半双工无仲裁,多主冲突(多主仲裁见 CAN总线详解) | 保持单主轮询,从站不主动发送 |
| 帧偶发被丢弃 | 帧内空隙超 1.5 字符 | 接收路径用 DMA,中断里不做重活 |
| 主站频繁超时 | 连发没留响应窗 | 请求间留 ≥3.5 字符静默 + 从机处理时间 |
总结
一条完整的链:差分 + 双绞把噪声挡在共模里,端接吸收反射、偏置锁住空闲电平,单主多从的纯问答避开无仲裁的问题,11 位字符 + CRC-16 低字节在前,3.5 字符静默当帧边界,DE/RE 在 TC 时刻切换方向。