4. 多机通信协议设计:主从模式与多主模式

多机通信,说白了就是让多个设备在同一条总线上“说话”。但问题来了——谁先说?谁听谁的?万一两个人同时开口怎么办?

我刚开始做多机通信时,以为只要把几个单片机的 TX、RX 连在一起就能聊起来。结果一上电,数据全乱套。嗯,从那以后我才明白:没有协议的总线,就是菜市场。

4.1 主从模式:最简单的“一问一答”

主从模式是最常用的多机通信方式。总线上只有一个主机(Master),其他都是从机(Slave)。主机掌握“话语权”,从机只能被点名后才能发言。

核心规则:

  • 主机主动发送数据帧,从机被动响应
  • 从机之间不直接通信,必须通过主机中转
  • 总线冲突概率极低,实现简单

我在一个温湿度采集项目中用过这种模式。一个 STM32 做主控,挂了 8 个传感器节点。主机轮询每个节点,节点收到自己的地址就回复数据。简单、稳定、好调试。

主从模式的典型流程:

  1. 主机发送地址字节,选中某个从机
  2. 从机识别到自己的地址,准备接收数据
  3. 主机发送功能码 + 数据
  4. 从机执行操作并回复响应
  5. 主机收到响应,继续下一轮轮询

4.2 多主模式:谁都能说话,但得守规矩

多主模式就复杂多了。总线上每个节点都能主动发起通信,就像会议室里谁都能发言,但得等别人说完。

我做过一个工业控制项目,要求多个 PLC 之间实时交换状态信息。用主从模式?不行,每个 PLC 都可能需要主动上报紧急事件。最后选了多主模式,配合 CSMA/CD 机制。

注意:多主模式必须解决总线仲裁问题。否则两个节点同时发送数据,总线就乱套了。我曾经因为没做仲裁,导致整个产线停机 20 分钟——教训深刻。

4.3 地址字节与数据字节的区分

多机通信中,接收端怎么知道当前收到的是“叫谁”还是“说什么”?

常用的方法有两种:

区分方式 原理 典型应用
第9位地址位 UART 数据帧的第9位为1表示地址,为0表示数据 8051 多机通信、STM32 地址位模式
特殊字节标记 用特定字节(如 0xAA)作为地址前缀 Modbus RTU、自定义协议

我个人习惯用第9位地址位的方式。为什么?因为硬件自动识别,不占用数据字节,效率高。STM32 的 USART 支持地址位模式,配置好之后,收到地址字节会自动触发中断,数据字节则不会打扰 CPU。

经验之谈:用第9位地址位时,注意所有节点的数据格式必须一致。我遇到过有人把 8 位数据 + 1 位地址配成了 9 位数据 + 无地址——结果地址和数据全混了,查了三天才找到原因。

4.4 总线仲裁机制

多主模式下,两个节点同时发送怎么办?总线仲裁就是解决这个问题的。

常见的仲裁方式:

  • CSMA/CD(载波监听多点接入/冲突检测):先听总线是否空闲,空闲才发送。发送时继续监听,发现冲突就退避重试。
  • 位仲裁(Bit Arbitration):每个节点发送自己的地址位,谁先发送 0 谁就赢得总线。CAN 总线用的就是这种方式。
  • 令牌传递(Token Passing):总线上只有一个令牌,拿到令牌的节点才能说话。谁用完就传给下一个。

UART 多机通信中,最常用的是 CSMA/CD。为什么?因为 UART 是异步通信,没有时钟同步,位仲裁很难实现。

下面这张图展示了 CSMA/CD 的工作流程:

CSMA/CD 总线仲裁流程 节点A 准备发送 节点B 准备发送 监听总线空闲 监听总线空闲 A和B同时发送 → 冲突! 检测到冲突,停止发送 随机退避后重试 退避时间 = 随机数 × 时隙 避免再次冲突

4.5 冲突检测与重试机制

冲突检测是 CSMA/CD 的核心。怎么检测?

发送节点在发送数据的同时,持续监听总线上的电平。如果自己发送的是 1,但总线上读到的是 0,说明有别的节点在拉低总线——冲突了!

重试策略:

  • 立即重试:冲突后马上再发一次。简单但容易再次冲突。
  • 随机退避:每个节点随机等待一段时间再重试。时间 = 随机数 × 基本退避时间。
  • 指数退避:每次冲突后,退避时间范围翻倍。比如第一次 1-2 个时隙,第二次 1-4 个时隙,第三次 1-8 个时隙。

我的建议:嵌入式系统中,指数退避最实用。为什么?因为节点数量少,冲突概率低,但一旦冲突,指数退避能快速让总线恢复稳定。我在一个 12 节点的多主系统中用过,效果很好。

4.6 典型协议栈:Modbus RTU

说到多机通信协议,Modbus RTU 是绕不开的。它是工业自动化领域的事实标准,几乎所有 PLC、变频器、传感器都支持。

Modbus RTU 的特点:

特性 说明
物理层 RS-232 / RS-485,半双工
通信模式 主从模式,一主多从
地址范围 1-247(0 为广播地址)
帧格式 地址 + 功能码 + 数据 + CRC16
波特率 9600 / 19200 / 115200 等

Modbus RTU 数据帧结构:

| 地址字节 (1B) | 功能码 (1B) | 数据区 (N B) | CRC16 (2B) |
|     0x01      |    0x03     |  0x00 0x00 0x00 0x01 | 0x84 0x0A |

举个例子:主机读取 1 号从机的寄存器。地址 0x01,功能码 0x03(读保持寄存器),起始地址 0x0000,读取数量 0x0001。CRC16 校验码 0x840A。

从机收到后,如果地址匹配,就执行操作并回复:

| 地址字节 (1B) | 功能码 (1B) | 数据长度 (1B) | 数据区 (N B) | CRC16 (2B) |
|     0x01      |    0x03     |     0x02      |  0x00 0x64   | 0xB8 0x1D |

回复中数据长度 0x02,表示后面有 2 个字节的数据。0x0064 就是 100——寄存器的当前值。

踩坑提醒:Modbus RTU 的帧间隔要求很严格。两帧之间必须有至少 3.5 个字符时间的空闲。我见过有人把帧间隔设得太短,结果从机把两帧当成一帧处理,CRC 校验永远过不了。查了整整一天才发现是时序问题。

Modbus RTU 的地址字节与数据字节怎么区分?很简单——地址字节是帧的第一个字节,接收端先判断地址是否匹配,匹配才继续解析后面的数据。不匹配就直接丢弃,不浪费 CPU 资源。

我个人习惯在项目中用 Modbus RTU 做设备级通信。为什么?因为生态好,调试工具多,随便一个串口助手都能抓包分析。自己写协议?除非有特殊需求,否则别折腾。

4.7 本章小结

多机通信协议设计,核心就三件事:

  • 谁说话:主从还是多主?决定了总线控制权的分配方式。
  • 说什么:地址和数据怎么区分?决定了接收端能不能正确解析。
  • 冲突了怎么办:仲裁和重试机制决定了总线在异常情况下能不能恢复。

Modbus RTU 是一个很好的参考模板。它把主从模式、地址区分、CRC 校验都封装好了,拿来就能用。但理解它背后的设计思路,比直接调库更重要。