笔记 / 嵌入式RS485Modbus串行通信

引言

车间里,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 Figure 9:多点拓扑——D1 是驱动器,中间 D3/R3 是收发器(既能讲又能听),R2 是纯接收器,总线两端各挂一个端接阻抗 ZT,A/A’ 与 B/B’ 为两根差分线

一根总线挂所有设备,同一时刻只允许一个驱动器发送,半双工分时复用。关键数字(出自 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 Ω),相当于给末端接上它期望的负载,能量到头被吸收,不再反弹。

TI SLLA070D:多点端接配置——每个收发器(D 驱动、R 接收)直接挂在总线上,总线两端各以 ZT = Z0 端接

三条工程规则比记接法更重要:

  • 端接只加两端。中间节点不端接,每多一个 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):

MODBUS 规范 V1.02 Figure 12:RTU 帧——从机地址 1 字节、功能码 1 字节、数据 0~252 字节、CRC 2 字节(低字节在前发送),整帧最长 256 字节

字段长度作用
从机地址1 B0 广播、1–247 单播、248–255 保留
功能码1 B要干什么;应答同码,异常时应答最高位置 1(0x83 = 03 出错)
数据0–252 B寄存器地址、数量、写入值……随功能码变
CRC2 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 位:

MODBUS 规范 V1.02 Figure 11:RTU 字符位序——带偶校验时为 1 起始位 + 8 数据位(低位在前)+ 1 奇偶校验位 + 1 停止位,共 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 个字符时间的安静,就算上一帧结束、下一帧可以开始。

MODBUS 规范 V1.02:帧间静默时序——时间轴上三帧报文依次出现,帧与帧之间以”至少 3.5 字符”的总线静默分隔,第二帧之后还标出了 3.5 与 4.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 时刻切换方向。