ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modbus功能码实战指南:从存储区到报文排错一次讲透

Modbus功能码实战指南:从存储区到报文排错一次讲透

刚入行的时候,我一度以为Modbus的难点在于报文超时重试、CRC校验还有波特率配对。干了一段时间才发现,真正分水岭在功能码。设备连不上、数据读出来是乱的、写操作没反应,百分之八十都能回溯到功能码用错或者用混了。这篇文章不打算系统复述协议文档,我想站在工程实操的角度把功能码这一层彻底捋一遍——每个码干什么用、报文长什么样、什么时候用它替代另一个码、出异常了先查哪里。不管你是刚摸PLC的新手,还是被第三方设备手册搞得头疼的集成工程师,这篇应该对你有用。

1. 先把底层的“四间屋子”搞清楚:功能码在跟什么打交道

功能码不是随便编的号码,每个功能码本质上是对从站内部某一类存储区发起操作。Modbus约定俗成的存储区就四种:线圈、离散输入、输入寄存器、保持寄存器。不理解这四种区的差异,后面看任何功能码都会觉得“每个都差不多”。

1.1 四种数据区,对应四种物理身份

我习惯把这四个区理解成设备的“四间屋子”:

  • 线圈(Coil):可读可写,1 bit,对应中间继电器、输出触点、开关阀门。你改它的值,设备真的会“动”。
  • 离散输入(Discrete Input):只读,1 bit,对应外部按钮、限位开关、干接点状态。设备之外的人为动作才能改变它。
  • 输入寄存器(Input Register):只读,16 bit,对应模拟量输入通道,比如4-20mA电流环的原始采样值、热电偶的温度值。
  • 保持寄存器(Holding Register):可读可写,16 bit,这是最常用的一间,对应设备的设定值、参数、累计量、工作模式,你写入它,等于给设备下发指令。

这四间屋子为什么存在?因为PLC世界里“输出映像区”和“输入映像区”物理上就不一样。线圈是输出映像,离散输入是输入映像,输入寄存器是模拟量输入映像,保持寄存器是数据块或者说掉电保持区。协议设计者把存储区按I/O属性分开,主站才能安全地操作:不会把只读量当可写量硬写,也不会把开关量当成16位寄存器来读。

1.2 一张表看清功能码和四间屋子的对应关系

功能码名称操作对象读写属性一次最大操作量
01(0x01)读线圈线圈只读操作2000位
02(0x02)读离散输入离散输入只读操作2000位
03(0x03)读保持寄存器保持寄存器只读操作125个寄存器
04(0x04)读输入寄存器输入寄存器只读操作125个寄存器
05(0x05)写单个线圈线圈写操作1位
06(0x06)写单个寄存器保持寄存器写操作1个寄存器
15(0x0F)写多个线圈线圈写操作1968位
16(0x10)写多个寄存器保持寄存器写操作123个寄存器
22(0x16)屏蔽写寄存器保持寄存器写操作1个寄存器
23(0x17)读写多个寄存器保持寄存器读写混合读125/写120

注意,功能码01-04虽然叫“读”,但它们对应的是“读从站状态”,不是主站控制输出。很多人会把01和03混在一起,觉得反正都是读,看PLC程序里点位表用的是“保持寄存器”,结果拿02去读,自然读出来全是0或者地址直接报错。

1.3 功能码是指令,不是地址

新手最容易钻进的一个误区:以为功能码编号就是地址区间。实际上地址是地址,功能码是指令。比如保持寄存器的数据地址可能是40001,协议里的地址偏移是0x0000,功能码03只是告诉从站“我要读保持寄存器”,具体读哪几个,由报文里的“起始地址+数量”决定。同一块数据,你用03能读,用04去读大概率非法,因为从站内部只有保持寄存器没有输入寄存器。

往后排查时,我通常先问三个问题:对端是什么设备?我要的数据在哪个区?那个区是只读还是可读写?三个问题一过,功能码基本就锁定了。

2. 01/05/0F:线圈类功能码的报文帧边界与字节序坑

线圈类功能码在项目里不如寄存器类用得多,但凡是碰到阀门、继电器、电机启停,这三兄弟绕不开。我用一个实际的RTU报文来拆,方便你在串口调试器里直接对着看。

2.1 01读线圈:一次能读多少,边界在哪里

假设从站地址是0x01,我要读从0x0000开始的10个线圈。请求帧:

01 01 00 00 00 0A CRC_L CRC_H

这六类字节分别是:从站地址、功能码01、起始地址高字节、起始地址低字节、线圈数量高字节、线圈数量低字节,后面跟两个CRC字节。0x0000到0x000A表示读10个线圈,对应实际设备里的线圈地址1到10(地址偏移问题后面说)。

响应帧:

01 01 02 05 00 CRC_L CRC_H

其中0x02是“数据字节数”,因为10个线圈凑不够2个字节,按位打包后需要2个字节。0x05二进制是00000101,这里的位序是个经典坑:bit0对应起始地址那个线圈,bit1对应第二个,也就是说0x05代表第1个和第3个线圈为ON,而不是第1个和第2个。

协议规定01功能码一次最多读2000个线圈,不对,再确认一下:Modbus官方限制是2000位(0x07D0)。超过这个数量从站会回异常码03“非法数据值”。

2.2 05写单个线圈:为什么ON要发FF00

写单个线圈的报文里,线圈值只有两种合法写法:0xFF00表示ON,0x0000表示OFF。别的任何值都会触发异常码03。为什么不用0x01表示ON?我问过不少老工程师,最靠谱的解释是早期电报传输怕单bit翻转,一位错从1变0或者从0变1不好区分;用FF00这种双字节里全0全1的极值编码,单bit翻转大概率还能纠出来。实际写参数时就不要自作聪明发0x0100之类的值,从站不认。

举例,把地址0x0005的线圈置位:

01 05 00 05 FF 00 CRC_L CRC_H

正常响应就是原样回显这帧,主站看到回显一致就知道写成功了。

2.3 0F写多个线圈:字节数和位序的双重考验

0F比05麻烦在数量多了以后,如何把布尔量塞进字节里。把10个线圈写ON,请求帧:

01 0F 00 00 00 0A 02 03 FF CRC_L CRC_H

0x02是字节数,0x03的二进制是00000011,表示第1、2个线圈ON;0xFF表示第3到第10个线圈全ON。如果我只想写8个线圈,一个字节就够了,数据填0xFF,9到16个线圈就需要两个字节,即使高字节只用了部分位,也要占一个字节。

这里我就不止一次看到有人把“字节数”写成“线圈数/8+1”,导致从站认为帧长度不对,直接静默不回复。正确做法是:字节数 = ceil(线圈数 / 8),也就是线圈数除以8向上取整,9个线圈就是2字节,16个也是2字节,17个才是3字节,别多加。

3. 03/06/10:保持寄存器,工控项目中最常打交道的三个码

如果你做的设备是电机、变频器、温控表、智能电表,那么数据基本都在保持寄存器里。读用03,写单个用06,写多个用10。这三个码的报文结构最有规律。

3.1 03读保持寄存器:为什么一次只能读125个

保持寄存器一个寄存器16位,读多个采用的是连续块读取。例如读从站1从地址0x0000开始的4个寄存器:

01 03 00 00 00 04 CRC_L CRC_H

响应:

01 03 08 12 34 56 78 9A BC DE F0 CRC_L CRC_H

0x08是字节数,等于4×2。后面每两个字节就是一个寄存器,顺序是高位在前(大端)。0x1234是第一个寄存器的值,0x5678是第二个,以此类推。很多初学单片机的朋友默认小端,读出来高低字节一颠倒,数值直接翻车。Modbus明确规定寄存器内部是MSB在前,TCP和RTU都遵守这个规则。

协议建议03一次最多读125个寄存器。为什么不一次读满256个?因为响应帧里“字节数”是一个字节,最大255,而125个寄存器占250字节,加上地址、功能码、字节数字段还能控制在255以内。253个寄存器就一定放不下。所以很多主站库里的上限就是125,读更多就得分批。

3.2 06写单个寄存器:适合下发的“单点设定”

写单个寄存器常用于设置一个参数,比如给定转速、给定温度。报文最清晰:

01 06 00 01 00 0F CRC_L CRC_H

意思是在地址0x0001写入0x000F。响应同样是回显。这里提醒一点:有些设备处理写操作比读取慢,主站如果没有合理的写后延时,紧接着发下一条写请求可能触发从站异常06“从站忙”。我一般写操作之间至少留20ms,特殊设备甚至要50ms以上,具体看手册说明。

3.3 10写多个寄存器:批量下发参数的首选

批量写比分次写单个高效得多。把地址0x0000开始的3个寄存器分别写成0x000A、0x000B、0x000C:

01 10 00 00 00 03 06 00 0A 00 0B 00 0C CRC_L CRC_H

0x06是后续数据字节数(3×2)。响应帧固定为:

01 10 00 00 00 03 CRC_L CRC_H

不回显数据,只回显起始地址和数量。这一点要记住,别拿06的回显逻辑套在10上。

实际项目里,10常用于参数组下载,比如把一组PID参数或者一组报警阈值一次性下发。但注意从站不是所有地址都支持批量写,有些设备只支持单寄存器写,批量的地址里只要有一个非法,整个请求都会被拒绝,而且不告诉你具体哪个地址非法。这时候就只能退回去一个一个写,对每个地址的返回值分别判断。

4. 02/04和“管理型”功能码:容易被忽略但关键时刻救命

读线圈和读保持寄存器大家背得很熟,但离散输入和输入寄存器经常被无视。到了做数据采集、设备自动识别、链路诊断的时候,才发现这几个码其实很有用。

4.1 02和04:只读量的宿命

离散输入对应的是没有输出能力的开关信号,比如急停按钮、门磁开关、流量开关。输入寄存器对应的是模拟量输入通道的原始值,比如压力变送器的4-20mA采样。这两类数据的共同点是:从站应用程序不会通过Modbus写它们,只能外部信号触发变化。

工程上有个经典翻车案例:某设备手册里写着“AI1当前值地址40001”,有人拿着03去读,读回来一个大数,自己算了半天发现不对,最后才看到手册上方写着“AI通道寄存器属于输入寄存器区,请用04功能码”。所以在拿到设备手册时,先看它标注的寄存器区是“Holding Register”还是“Input Register”,再决定用03还是04。不要凭地址编号瞎猜,地址区间看起来连续也不能跨功能码。

02和04的报文格式跟01、03完全一样,只是功能码和长度限制不同。02一次最多读2000位,04一次最多读125个寄存器。

4.2 23读写多个寄存器:减少轮询往返的取巧方案

23功能码(0x17)是“先写后读”的组合操作,一条报文完成读写两件事。适合那种“写一个命令然后立即读状态”的场景。比如控制设备的运行模式后马上回读实际运行状态,用一条23替代了原先的“先06写,再03读”两条报文。它的PDU格式比较特殊:

请求:0x17 读起始地址 读数量 写起始地址 写数量 写字节数 写数据

响应只有读回来的数据,没有回显写数据。我实测下来,部分国产设备的从站实现并不完整,对23支持得不好。能用但别依赖,做产品选型时优先确认从站固件功能码列表里有没有它。

同理,22屏蔽写寄存器(0x16)用得更少,它做的是按位掩码更新,适合修改寄存器里某一个位而不影响其他位。但绝大多数项目里,先读后写比它更直观,我个人的做法是:除非设备手册明确支持22,否则不优先用它,免得给自己找麻烦。

4.3 08诊断和43设备识别:少有人用,但做运维调试是神器

08功能码的子功能0x0000是“返回查询数据”,主站发一串数据,从站原样返回。这相当于工控界的ping,调试链路质量非常直接。串口线接好以后,我第一件事不是读数据,而是先发一条08 00 00来确认物理链路和从站地址没搞错——如果这条能回,后面读数据失败就是地址或功能码的问题;如果这条都不回,先查接线、波特率、从站地址。

43功能码(0x2B)用于读设备标识,可以拿到制造商名称、产品代码、固件版本。设备有多个版本时,这个功能的价值特别大。我遇过一个项目,现场一批设备固件版本不同,寄存器表有差异,程序里先发43读版本,再按版本号选择解析哪一版寄存器表。一个自动化流程就避开了“某些设备数据不对”的玄学问题。注意43的实现有些设备只做到子功能0x01(基本设备标识),复杂子功能可能不支持。

5. 异常码与“0x83”的排查链路:别急着改代码,先读从站的“拒绝理由”

功能码发出去,从站不配合,会回一个异常响应。如果你只盯着界面上的“通讯超时”,会错过最重要的排错信息。异常响应帧是“请求功能码+0x80”,比如你发了03读寄存器,从站拒绝就回0x83,后面跟一个异常码字节。

异常码含义常见触发原因
01非法功能从站不支持这个功能码,或者当前模式禁止
02非法数据地址起始地址或地址范围超出从站有效区间
03非法数据值数量超过上限,或者写入的数据值不合理
04从站设备故障从站内部错误,比如写入非易失区失败
05确认从站已受理但处理时间长,需要等待
06从站设备忙从站正在处理其他任务,重发请稍候
08存储奇偶校验错数据区校验失败,可能是掉电丢数据

5.1 一个真实的0x83 0x02排查案例

某项目里,传感器从站地址为2,手册写明“温度值寄存器地址41002”。我按习惯用03读,报文发的是:

02 03 03 E9 00 01 CRC_L CRC_H

结果返回:

02 83 02 CRC_L CRC_H

0x83说明03被拒,0x02说明“非法数据地址”。我不急着改代码,按下面的链路排查:

  1. 先怀疑地址算错:41002在Modbus协议里的“协议地址”是寄存器编号减1,也就是0x03E9(1001)。看起来没错。
  2. 再怀疑地址区间:这个设备总共寄存器可能没有覆盖到1001这个点。我用03从0x0000开始逐个读,发现返回正常,但读到具体的1000时开始报0x02。
  3. 接着差手册:原来手册里41002是“数据块地址”,对应协议地址是0x03E8(1000),而不是0x03E9。就是手册的“4xxxx”编号比协议地址多1这个坑,很多国产设备手册懒得跟你解释这个偏移,直接写40001对应地址0。
  4. 修正后报文:
02 03 03 E8 00 01 CRC_L CRC_H

返回正常。

很多人遇到通讯异常第一个动作就是调超时、加重试,其实异常码已经把答案写在手里了。看到0x83 0x02就找地址区间,看到0x83 0x03就检查数量和数值,看到0x83 0x06就先等一等再重发,比瞎试快得多。

5.2 关于超时和重试次数的一些个人习惯

Modbus作为一主多从的半双工协议,不存在从站主动上报的机制,只能主站轮询。轮询时超时时间怎么设?串口9600波特率下,一个标准请求几十字节,加上从站处理时间,从站通常在10ms内应答。我一般把超时设到200ms-500ms,太短会误判慢速设备,太长会让整个轮询周期拖死。轮询故障从站时,建议连续重试2次,如果还是超时就标记离线,别用无限重试,否则一个掉线从站会把整条总线的扫描周期拖垮。

6. 选型与轮询设计:功能码用得对,系统才谈得上“好用”

协议通了不代表项目成了。功能码选得合适,代码写起来干净,现场跑起来也少出幺蛾子。我把自己做项目时的选型思路整理一下。

6.1 拿到一台新设备,先看什么

第一步绝不是翻功能码表,而是看手册里的“Modbus寄存器定义”。重点确认四件事:寄存器区类型(保持还是输入)、数据地址范围、寄存器数量限制、各地址的数据格式(int/float/BCD)。手里有一张清晰的寄存器地图,功能码自然就浮出来了。

举几个常见例子:

  • 变频器:频率、电流、状态字都在保持寄存器,用03读,用06或10写频率给定。
  • 智能电表:电压、电流、电能,按DL/T 645转Modbus的实现,常用03和04,具体看厂家映射表。
  • 环境传感器:二氧化碳、温湿度如果放在输入寄存器,就必须用04;如果放在保持寄存器就是03。
  • 阀门执行器:阀位反馈可能是离散输入或保持寄存器,开关命令多半是线圈,用01和05最直接。

6.2 轮询策略三原则

我见过不少项目把采集周期做得很烂,一个周期内发了上百条报文,全是读单个寄存器。这里有几个原则,照着做基本不会太差:

  • 能批量就别单点:同区块地址连续的寄存器,用03一次多读,比如20个连续温度点,一条报文就能读完,别发20条读单点。批量读带来的问题是如果中间某个地址无效,整个请求失败,所以需要先把区块规划好,避开空洞。
  • 写操作降到最低频次:设定值没变化就别反复写。主站可以记录上一次发送的写值,只有值变化时才真正下发,否则只是给总线添乱。
  • 从站故障后按“慢速重试”处理:离线从站先跳过,每10个周期再探一次,避免拖垮在线从站。

6.3 RTU的CRC16:把好最后一关

最后补一个串口RTU的细节。RTU的每帧后面两个字节是CRC16-Modbus,不是标准CRC16,多项式是0x8005,初始值是0xFFFF。写上位机的时候很多人直接拿网上库用,但偶尔会遇到高低字节顺序和从站不匹配的问题。我提供一个最笨但最可靠的校验方法:先用Modbus Poll之类的调试工具发一帧,看它生成的CRC是什么,然后拿自己的函数对同一帧算一次,两个一致说明字节序和多项式都对。

TCP模式就没有CRC了,取而代之的是报文里多了6个字节的MBAP头,长度字段也要自己算。如果代码跨串口和TCP两种模式通用,记得把CRC和MBAP头这些差异封装在传输层下面,应用层只解析PDU。这也是我后期重构代码最大的收获之一。

最后再分享一个经验:现场调Modbus,永远不要把软件界面上的解析结果当成唯一依据。把原始字节复制出来,自己拿计算器对一遍CRC、把每个字节的十六进制过一遍脑子,十次有九次问题在报文本身,而不是“设备坏了”。功能码说到底就是一张表,但真正理解这张表背后“四种区、两类操作、一份异常码”的逻辑,你调起设备来就不会再靠猜了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进