4. Modbus ASCII 帧结构:起始符、地址、功能码、LRC校验、结束符

好,咱们接着聊。上一节我们把 RTU 模式扒了个底朝天,这一节轮到 ASCII 模式了。

说实话,我刚开始接触工业通信那会儿,总觉得 ASCII 模式有点「笨重」。同样的数据,RTU 发几个字节就搞定,ASCII 得翻一倍。但后来在项目里吃过亏,才明白它存在的意义——可读性

你想想看,调试的时候,拿着串口助手,满屏的十六进制 01 03 00 00 00 01 84 0A,你一眼能看出对不对?但如果是 ASCII 帧,直接就是字符串,人眼一扫就知道「哦,地址是 01,功能码是 03」。这就是 ASCII 模式最大的价值。

核心区别一句话:ASCII 模式把每个字节拆成两个 ASCII 字符来传输,帧结构多了起始符和结束符,校验也从 CRC 换成了 LRC。

4.1 帧结构总览

先看一张完整的帧结构图,我亲手画的,你感受一下。

Modbus ASCII 帧结构 起始符 ':' (0x3A) 地址 2 个 ASCII 字符 功能码 2 个 ASCII 字符 数据区 N 个 ASCII 字符 LRC 校验 2 个 ASCII 字符 结束符 CR + LF 每个字节(如 0x01)被拆分为两个 ASCII 字符('0' 和 '1')传输 实际报文示例 : 0 1 0 3 0 0 0 0 0 0 0 1 F A CR LF 起始符 | 地址 | 功能码 | 数据区(起始地址+数量) | LRC | 结束符

嗯,这张图基本把 ASCII 帧的骨架画清楚了。下面我们逐个拆解。

4.2 起始符:冒号 ':'

ASCII 帧的起始符是固定的,就是冒号 ':',十六进制是 0x3A

为什么用冒号?我个人猜测,早期设计者就是想找一个在 ASCII 表里不太会被误用的字符。你想想,如果起始符用 'A',那数据里出现 'A' 怎么办?不就乱套了?冒号在正常数据里几乎不会出现,所以用它做起始符很安全。

调试小技巧:用串口助手抓 ASCII 报文时,直接搜冒号就能定位到一帧的开始。我经常用这个办法快速过滤掉乱码。

4.3 地址字段

地址字段占 2 个 ASCII 字符,对应 1 个字节的十六进制值。取值范围是 00F7(0 到 247)。

举个例子:

  • 如果从站地址是 1,那么地址字段就是 "01"(字符 '0' 和 '1')
  • 如果从站地址是 16,就是 "10"(字符 '1' 和 '0')
  • 广播地址是 "00",所有从站都会响应

这里有个坑,我提醒一下:ASCII 模式下,地址 0 也是有效的广播地址,但有些国产设备对广播的处理不太规范。我曾经遇到过一个温控器,发广播地址 00 给它,它居然不响应,后来发现它只认地址 01。所以用广播功能前,最好先确认设备手册。

4.4 功能码

功能码也是 2 个 ASCII 字符,对应 1 个字节。常用的就那么几个:

功能码(十六进制) ASCII 表示 含义
0x01 "01" 读线圈
0x02 "02" 读离散输入
0x03 "03" 读保持寄存器
0x04 "04" 读输入寄存器
0x05 "05" 写单个线圈
0x06 "06" 写单个寄存器
0x0F "0F" 写多个线圈
0x10 "10" 写多个寄存器

注意,功能码在 ASCII 帧里是大写字母。比如 0x0F 要写成 "0F",不能写成 "0f"。虽然有些设备不区分大小写,但按规范来,一律大写。

4.5 数据区

数据区是变长的,长度取决于具体功能。每个字节拆成两个 ASCII 字符。

比如读保持寄存器 0x0001 开始的 2 个寄存器:

  • 起始地址:0x0000 → "0000"
  • 寄存器数量:0x0002 → "0002"
  • 数据区就是 "00000002"

整帧看起来就是::010300000002??CRLF(?? 是 LRC 校验值)

4.6 LRC 校验:核心中的核心

LRC(Longitudinal Redundancy Check,纵向冗余校验)是 ASCII 模式专用的校验方式。它比 RTU 的 CRC 简单得多,但可靠性也差一些。

LRC 的计算方法:

  1. 地址字段开始数据区结束,把所有字节累加
  2. 取累加和的补码(256 - 累加和 & 0xFF)
  3. 结果就是 LRC 值

说白了,就是:LRC = (0x100 - sum) & 0xFF

我直接给你一个 C 语言实现,这是我项目里一直在用的,经过无数次验证:

// 计算 LRC 校验值
// 输入:从地址开始的原始字节数组(不是 ASCII 字符串)
// 长度:字节数
// 返回:LRC 校验值(1 个字节)
unsigned char calcLRC(unsigned char *data, int len)
{
    unsigned char sum = 0;
    for (int i = 0; i < len; i++)
    {
        sum += data[i];
    }
    return (unsigned char)((0x100 - sum) & 0xFF);
}

举个例子,计算帧 :010300000001 的 LRC:

  • 原始字节:0x01, 0x03, 0x00, 0x00, 0x00, 0x01
  • 累加和:0x01 + 0x03 + 0x00 + 0x00 + 0x00 + 0x01 = 0x05
  • LRC = 0x100 - 0x05 = 0xFB
  • ASCII 表示:"FB"

所以完整帧就是::010300000001FB<CR><LF>

注意:LRC 计算时,不包括起始符 ':' 和结束符 CR/LF。只计算地址、功能码、数据区这三个部分。我见过有人把冒号也算进去,结果校验永远对不上。

4.7 结束符:CR + LF

ASCII 帧的结束符是两个字符:回车(CR,0x0D)和换行(LF,0x0A)。

为什么用两个?这其实是继承了早期电传打字机的传统。CR 让打印头回到行首,LF 让纸张向上走一行。在 Modbus ASCII 里,这两个字符组合起来告诉接收端:「这一帧结束了,可以开始解析了」。

我建议你在写解析程序时,一定要同时检查 CR 和 LF。有些设备只发 CR 不发 LF,或者反过来,这都会导致解析失败。我曾经调试一个进口的流量计,它发的结束符是 LF 在前 CR 在后,跟标准反的。折腾了我一上午才找到原因。

4.8 完整帧示例

最后,给你一个完整的例子,把上面所有知识点串起来。

场景:主机读取从站地址 1 的保持寄存器,从地址 0x0000 开始,读 2 个寄存器。

请求帧:

: 0 1 0 3 0 0 0 0 0 0 0 2 F B CR LF
| |   |   |           |   |   |   |
| |   |   |           |   |   |   +-- LF (0x0A)
| |   |   |           |   |   +------ CR (0x0D)
| |   |   |           |   +---------- LRC = "FB"
| |   |   |           +-------------- 数据区 = "00000002"
| |   |   +-------------------------- 功能码 = "03" (读保持寄存器)
| |   +------------------------------ 地址 = "01"
| +---------------------------------- 起始符 ':'
+------------------------------------ 帧开始

响应帧(假设寄存器值分别为 0x1234 和 0x5678):

: 0 1 0 3 0 4 1 2 3 4 5 6 7 8 7 0 CR LF
| |   |   |   |               |   |   |
| |   |   |   |               |   |   +-- LF
| |   |   |   |               |   +------ CR
| |   |   |   |               +---------- LRC = "70"
| |   |   |   +-------------------------- 数据区 = "12345678" (4 个字节)
| |   |   +------------------------------ 字节数 = "04"
| |   +---------------------------------- 功能码 = "03"
| +-------------------------------------- 地址 = "01"
+---------------------------------------- 起始符 ':'

总结一下:ASCII 帧结构虽然看起来比 RTU 啰嗦,但它最大的优势就是可读性强。调试阶段用 ASCII 模式,可以省去很多手动换算的麻烦。等调试通过,再切回 RTU 模式提高效率。这是很多老工程师的常规操作,我也是这么干的。


公众号:蓝海资料掘金营,微信deep3321