做工业通讯调试这些年,被问得最多的问题就是:Modbus功能码到底怎么选?01、02、03、04、05、06、15、16……这串数字背下来不难,难的是每次对着设备手册,还是一头雾水,不知道手里这台仪表到底该用哪个码去读。这篇文章我就把Modbus功能码拆到报文层面讲清楚:功能码在帧里站在什么位置、8个最常用的码分别在什么场景用、收到异常码该怎么判断问题、以及项目里最容易踩的地址偏移和数量限制类坑。适合刚接触PLC通讯、组态软件和仪表接入的工程师,也适合写采集程序时想确认自己没选错功能码的人。
1. 从一帧报文看功能码的位置与职责
1.1 先拆开RTU帧:功能码就是第二个字节
一串标准的Modbus RTU请求帧是这个样子的:
| 字段 | 地址码 | 功能码 | 数据区 | CRC校验 |
|---|---|---|---|---|
| 长度 | 1字节 | 1字节 | N字节 | 2字节 |
| 示例 | 01 | 03 | 00 00 00 02 | C4 0B |
以01 03 00 00 00 02 C4 0B这帧为例:01是从站地址,03就是功能码,后面跟着的是数据区,最后两个字节是CRC校验。功能码是整个帧里语义最明确的一个字节,它直接告诉从站:"我这次请求是要读保持寄存器"还是"我要写单个线圈"。
如果从站能理解这个功能码并且参数合法,它就会按照功能码的约定往回吐数据;如果它不理解,就会把功能码的最高位置1,也就是在原始功能码基础上加上0x80,然后返回一个异常码。这也是为什么调试工具里常看到0x83、0x84这类看起来很奇怪的功能码,本质上就是原功能码01、02的"报错版本"。
1.2 请求-响应模型里,功能码决定"能不能答"与"怎么答"
Modbus是典型的一主多从请求响应模型。主站下发请求帧,从站执行完动作后回一个响应帧,广播地址0x00除外——广播时从站执行但不会回复。功能码在中间扮演的角色,就是那个约定"你这次要干什么"的指令编号。
打个比方:主站和从站之间就像两个人在柜台办业务,功能码相当于你递过去的业务单据。你递一张"查询余额"的单据,柜员就知道该去账务系统里查数字;你递一张"转账"的单据,柜员就知道要修改账目。如果单据本身写错了,柜员不会帮你猜你要干嘛,只会回一句"这业务办不了"——对应到Modbus里,就是异常响应码01(非法功能)。
理解这个模型之后,很多现场问题就有了清晰的归因方向:报文通了、CRC没错、从站也回了,但回的是异常帧,那大概率是功能码和数据区不匹配。比如你要读的是输入寄存器,却发了03(读保持寄存器)的报文,有的从站会直接返回非法功能或非法数据地址,而不是好心帮你把数据找出来。
1.3 为什么功能码比看似相似的寄存器地址更值得先研究
调试Modbus设备时,大家第一反应往往是查地址表:手册上写着"电压地址40001、电流地址40002",于是赶紧在软件里填地址。地址固然重要,但功能码才是决定通讯语义闭环的第一道门槛。
地址表通常按数据区划分,四个数据区各有一套地址编号。你选了03,地址40001才指向保持寄存器;你选了04,地址30001才指向输入寄存器。功能码选错,地址跟着全错。更麻烦的是,有些设备手册并不会把所有功能码都列全,只说"支持标准Modbus协议",这时候你必须先确认设备支持哪些功能码,再去谈地址。
我自己的习惯是:拿到新设备第一件事,翻手册里"通讯协议"章节,找两个东西——Supported Function Codes(支持的功能码列表)和Register Map(寄存器映射表)。前者决定我用哪些功能码,后者决定我填什么地址。两者对不上,后边的调试就是浪费时间。
2. 八个常用功能码:从数据模型到使用场景
2.1 四类数据区:线圈、离散输入、输入寄存器、保持寄存器
Modbus协议把从站内部的数据抽象成四块,每块对应不同的读写属性,功能码也是围绕这四块数据设计的:
| 数据区 | 位/字 | 读写属性 | 典型数据 |
|---|---|---|---|
| 线圈 Coil | 位 | 可读可写 | 继电器输出、DO点、启停命令 |
| 离散输入 Discrete Input | 位 | 只读 | 按钮、接近开关、DI点 |
| 输入寄存器 Input Register | 16位字 | 只读 | 电压、电流、采集到的测量值 |
| 保持寄存器 Holding Register | 16位字 | 可读可写 | 设定参数、运行变量、累计值 |
初看会混淆的是输入寄存器和保持寄存器,听起来差不多,实际定位完全不同。输入寄存器只读,一般放外部设备实时采集到的数据,比如温控表读到的温度、电流表读到的电流;保持寄存器可读可写,一般放允许主站修改的设定值和运行过程中的上下限、目标值之类的参数。少数设备也把测量值放在保持寄存器里,这种属于实现习惯问题,以手册为准。
给PLC做点位表时,我会先把IO类型和这四个数据区做映射:开关量输入就是离散输入,开关量输出就是线圈,模拟量采集就是输入寄存器,要下发的设定值就是保持寄存器。映射清楚了,功能码自然就出来了。
2.2 读侧功能码01-04,到底想读什么
读操作是日常调试里频率最高的。四个读功能码对应四个数据区:
01读线圈:读取DO状态、继电器吸合状态,返回的是每一位的布尔量。02读离散输入:读取DI点状态,比如门禁开关、限位开关、急停按钮。03读保持寄存器:最常用的一个码,读参数、读设定值、读运行变量,一帧最多读125个寄存器。04读输入寄存器:读只读的测量值,比如温湿度传感器读到的温湿度、电力仪表读到的电压电流。
实际选型时,核心问题就一句话:我要读的东西是只读的还是可读可写的。可读可写用03,只读用04。知道用03还是04之后,再去地址表里找对应的寄存器编号。这里有个容易踩的弯:有的设备手册把测量值放在03区,也有的放在04区,两个都能试出来,但不要凭感觉,先看手册。
2.3 写侧功能码05、06、0F、10,动作的组合拳
写操作分成单点和批量两类,四个功能码对应四种动作:
05写单个线圈:输出一个DO点的状态,比如启动一台电机、打开一路阀门。06写单个寄存器:修改一个保持寄存器的值,比如把PID目标温度改成80。0F(十进制15) 写多个线圈:一次批量输出多个DO点,按位打包,适合同步下发一组开关状态。10(十进制16) 写多个寄存器:一次连续写多个保持寄存器,适合批量下发参数表。
单点和批量怎么选?我的建议是:如果只是偶尔控制一个点,用05或06,报文简单、排查省心;如果每个扫描周期要同步刷新一批点,用0F或10,一次交互搞定,不容易出现中间态。
一个小知识点:06和10其实是能互相替代的,10也能只写一个寄存器,但主流习惯还是单个用06、批量用10。有的从站对这两个功能码的响应细节有差异,保守做法是跟随手册推荐,手册没写就首选06。
2.4 一张速查表收拢8个功能码
| 功能码 | 名称 | 操作对象 | 典型场景 | 一次最大操作数 |
|---|---|---|---|---|
| 01 | 读线圈 | 线圈 | 读DO状态 | 2000位 |
| 02 | 读离散输入 | 离散输入 | 读DI状态 | 2000位 |
| 03 | 读保持寄存器 | 保持寄存器 | 读参数/运行值 | 125个寄存器 |
| 04 | 读输入寄存器 | 输入寄存器 | 读测量值 | 125个寄存器 |
| 05 | 写单个线圈 | 线圈 | 单点启停 | 1位 |
| 06 | 写单个寄存器 | 保持寄存器 | 修改单个参数 | 1个寄存器 |
| 0F | 写多个线圈 | 线圈 | 批量DO输出 | 1968位 |
| 10 | 写多个寄存器 | 保持寄存器 | 批量参数下发 | 123个寄存器 |
这张表我建议直接打印出来贴在工位上。大多数设备调试场景,90%的功能码选型都落在上边这8个里。剩下10%可能会碰到0x16掩码写寄存器、0x17读写多寄存器之类的进阶功能码,后面章节单独说。
3. 报文拆解:让功能码从纸上落到线缆里
3.1 读保持寄存器03的完整交互
假设我要从站地址01的设备上读取保持寄存器地址0、1两个寄存器,请求帧是这样拼的:
01 03 00 00 00 02 C4 0B逐字节拆解:01从站地址,03功能码,00 00寄存器起始地址(高位在前),00 02读取数量,C4 0B是CRC16校验值。
从站正常响应长这样:
01 03 04 00 01 00 02 3B B701地址,03功能码,04数据字节数(两个寄存器乘以2字节),00 01是地址0的值,00 02是地址1的值,3B B7是CRC。从这里能看出一个规律:响应帧里寄存器值全部是大端序,高字节在前,低字节在后。
我在写采集程序时,最常用这样的伪逻辑:拼请求帧,发出去,收响应,校验地址码和功能码,然后按字节数把数据切出来,每两个字节转换成一个无符号整数。这套逻辑无论换成哪个功能码,骨架都不变。
3.2 05写单线圈:FF00这个特殊值别记岔
写单个线圈的功能码是05,请求帧格式为:
01 05 00 00 FF 00 8C 3A00 00是要写的线圈地址,FF 00表示置ON,00 00表示置OFF。注意,这里布尔量的定义不是常见的01和00,协议规定ON必须用FF00,OFF才用0000。有的从站校验很严格,你发01 00它可能当作非法数据值(异常码03)处理。
当初我在这上面栽过一次:早期用一个通用工具测试,界面上勾选"置位"后工具自动发了FF00,一切正常;后来自己写代码时图省事,直接填了0x01,结果从站那边纹丝不动,排查了半天才反应过来。所以关于05功能码,就一句话:ON就是FF00,OFF就是0000,别自创。
3.3 批量写0F/10的数据区怎么数
批量写就比单个写稍微复杂一点,因为数据区里要先声明"我有几个数据要发"。
写多个线圈0F,请求帧格式:
01 0F 00 00 00 10 02 CD 01 ...CRC00 00起始线圈地址,00 10数量(16个线圈),02字节数,后面的数据按每8个线圈打包成一个字节。字节内每个bit对应一个线圈,bit位顺序是先低字节的低bit位。这个打包规则第一次数容易乱,我的办法是先在纸上把线圈编号和bit位对应表画出来,再动手拼字节。
写多个寄存器10,格式则直白得多:
01 10 00 00 00 02 04 00 01 00 02 ...CRC起始地址后跟数量,再跟一个字节的字节数(数量乘以2),然后就是每个寄存器的两字节大端数据。响应帧只回地址码、功能码、起始地址和数量,不回数据内容。
3.4 手动算一次CRC16,以后看报文心里就有底
CRC校验是RTU帧的收尾,也是不少新人觉得玄乎的地方。其实算法很固定:多项式0xA001,初值0xFFFF,对地址码、功能码和数据区逐字节进行异或移位运算。用一句话概括就是"每个字节先和CRC低字节异或,再右移8次,碰到最低位是1就额外异或0xA001"。
给你一段可以直接抄的验证代码:
def modbus_crc(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc # 验证:请求帧数据部分 = 01 03 00 00 00 02 frame = bytes.fromhex("01 03 00 00 00 02") crc = modbus_crc(frame) print(f"CRC: {crc:04X}") # 输出 C40B记得RTU发送时CRC是低字节在前,所以计算得到0xC40B,发送顺序是C4 0B。我见过不止一个同事把顺序发反,然后对着不回复的设备发呆。如果你不想写代码,用任意一款Modbus调试工具发个读请求,工具会直接帮你把CRC算好、把整个报文显示出来,拿那个报文跟自己程序里抓的原始字节对比,就能快速定位是不是CRC问题。
4. 异常响应码:功能码加上0x80之后的事故现场
4.1 识别异常帧的三个步骤
从站收到一个无法执行或参数有误的请求时,回的不是正常数据,而是一个异常帧。判断是不是异常帧,三步就够:
第一步,看功能码是不是大于等于0x80。正常功能码范围在0x01到0x10附近,加了0x80就说明这帧是异常响应。
比如我发的是01 03 00 00 00 02,如果从站回01 83 02 C0 F1,那么这个83就是03 + 0x80,明显是异常帧。第二步,看异常帧的第三个字节,也就是异常码,它告诉我们具体错误原因。第三步,对照异常码表去定位是功能码问题、地址问题还是数据值问题。
4.2 异常码表:从非法功能到从站忙
| 异常码 | 含义 | 常见触发原因 |
|---|---|---|
| 01 | 非法功能 | 从站不支持该功能码,或功能码与数据区不匹配 |
| 02 | 非法数据地址 | 起始地址加数量超出从站有效范围 |
| 03 | 非法数据值 | 数据区内容非法,比如写入值超限、数量为0 |
| 04 | 从站设备故障 | 从站内部执行时出错,常见于写EEPROM失败 |
| 05 | 已接受但需延时 | 从站正在处理一个长任务 |
| 06 | 从站忙 | 从站正忙,无法再接收新请求 |
| 0A | 网关路径不可用 | 网关路由无法到达目标从站 |
| 0B | 网关目标无响应 | 目标从站超时未响应,网关代为回复 |
日常调试里出现频率最高的是01、02、03这三个。01是功能码本身选错;02是地址越界,比如设备只有0到9十个寄存器,你偏偏去读地址20;03是写入的值不被接受,比如把温度设定值写成了6000,超出设备允许范围。
4.3 一次读操作报02的排查过程
举个我实际排查过的例子:一台现场仪表,手册写到"电压寄存器编号40004"。我在主站软件里填地址4,功能码选03,结果从站回的是83 02,也就是非法数据地址。
当时的排查链路是这样的:先怀疑地址偏置,Modbus协议里40004对应的实际协议地址是3,也就是0x0003,于是把地址改成3;结果还是报02。再怀疑功能码不对,这台仪表的电压值会不会其实放在输入寄存器区,于是改用04读地址3,结果正常读到数据了。
这个案例说明,报02不一定就是地址越界,也可能是功能码和数据区不匹配。因为从站内部实现的寄存器映射是按数据区分开的,你在保持寄存器区用03读地址3,对应位置根本没有分配寄存器,设备就当成非法地址处理。后来我养成了一个习惯:遇到异常码,先同时怀疑两个变量——功能码和地址偏置,而不是只调地址。
5. 选型与边界:项目里怎么定功能码
5.1 读04还是读03,写05还是写0F
功能码选型的判断顺序,我总结成"先看区、再看动作、最后看数量"。
先看区:这台设备手册里把我关心的数据放在哪个数据区。只读测量值优先用04,可读可写的参数和状态值用03。有的设备手册会把寄存器映射表做成Excel,列头写着"Address/Type/Access",Access里标了R就是读,标了R/W就是可读可写,对着这个选功能码最稳。
再看动作:单个控制点用05或06,批量同步用0F或10。这里有实操经验可以分享:控制单台电机启停用05,写单个参数用06,但如果你有一组参数要下发,比如给变频器写频率上限、加速时间、减速时间三个参数,用10一次写完比用06发三次更可靠。原因是多个06分帧写入时存在中间态,万一写到一半通讯断了,设备可能就停在"部分参数已更新"的状态,而10批量写如果失败,设备可以整体丢弃。
最后看数量:超过一节5.3列出的上限时,不管功能码是什么都要分批。分批时注意重叠和连续性,别把地址算错。
5.2 40001和0x0000:地址偏移的处理
Modbus最经典的一个坑就是地址编号偏移。协议里寄存器地址从0开始,比如保持寄存器的地址是0x0000、0x0001、0x0002……但很多设备手册采用"数据地址"写法,四万系列对应保持寄存器,三万一对应输入寄存器,编号从1开始。
| 手册写法 | 协议实际地址 | 功能码 |
|---|---|---|
| 40001 | 0x0000 | 03/06 |
| 40004 | 0x0003 | 03/06 |
| 30001 | 0x0000 | 04 |
| 10001 | 0x0000 | 02 |
换句话说,手册里写40004,你发报文时起始地址填的是0x0003,不是0x0004。这个规律对线圈和离散输入同样适用:0x0000对应10001或00001编号。
有的组态软件里你填地址时会区分"PLC地址"和"协议地址",填40004时软件自动帮你减1转成3;有的裸报文调试工具则要你自己减。我的经验是每换一个新工具,先拿一个已知地址测试一下,比如读手册地址40001,发地址码+功能码+0000+数量,能读到正确数据就说明工具已经处理好偏移了,读不到就自己手动减。
5.3 一次能读多少点:报文长度与数量上限
数量上限不是随便定的,它源于一帧报文不能超过256字节这条硬约束。拿读保持寄存器来说,响应帧结构是"地址1字节+功能码1字节+字节数1字节+数据N字节+CRC2字节",总共N+5字节。要保证整帧在256以内,N最多250字节,也就是125个寄存器。这就是为什么规范里写03/04一次最多125个寄存器。
线圈因为每个点只占一个bit,2000位折合250字节,所以01/02一次最多2000位;写多个线圈0F一次最多1968位,因为写请求帧里还要多带地址和字节数;写多个寄存器10一次最多123个寄存器,也是因为要把请求帧额外开销算进去。
联调时最常见的问题发生在批量读上:我遇到过一位同事把要读的寄存器数量填成130,从站直接回异常码02,排查了很久才发现是数量超限。后来我写采集程序时都会做一个简单断言:读保持寄存器数量超过125就自动拆帧,两条请求分开发,再把结果拼接。
5.4 自定义功能码与扩展功能码的使用边界
除了上面8个常用功能码,Modbus还定义了一些扩展功能码,比如0x16掩码写寄存器,可以只改一个寄存器里的某些bit而保留其余bit,适合处理状态字;0x17读多个寄存器加写多个寄存器的组合操作,一次交互完成读和写,常用于PLC同步一段数据区。这些功能码是标准协议的一部分,但不少从站固件根本没实现,用之前一样先确认手册。
另外还有0x41到0x5A这段用户自定义区,厂商可以拿它做一些私有扩展。这段区域的通讯语义只在同一厂商的设备之间有效,不同品牌之间完全不通。我做项目时会刻意避开私有功能码,宁可多写几条标准功能码报文,也不去依赖某个具体厂商的私有实现,否则后面换设备、换平台时全是雷。
最后再提醒一句:所有功能码的最终裁判是设备手册。手册里如果有一行"Supported Function Codes"列表,那就只在这个列表里选。有些设备虽然实现了03和04,但04只支持地址0到5,你读地址6照样报02。把手册当第一参考,把调试工具当第二参考,把经验当第三参考,这个顺序能帮你省下大量现场时间。
我自己的习惯是:每接一台新设备,先在调试工具里跑一遍"读寄存器数量=1"的最小测试,拿到正常响应后再逐步扩展到批量读,确认无误后再去写程序。这个流程看起来慢,实际上最能避免“程序写完了才发现功能码选错”的返工。