ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Modbus协议精讲:从报文结构到嵌入式移植实践

Modbus协议精讲:从报文结构到嵌入式移植实践 简介这是一份基于国标框架的MODBUS协议详解文档面向嵌入式开发、工业通信及自动化控制相关工程师帮助读者系统掌握MODBUS应用层协议在串行链路与TCP/IP网络上的实现方法。文档共1个PDF文件压缩包大小约1.06MB内容完整便于离线查阅。目前已吸引937人学习下载是理解和落地MODBUS通信不可多得的参考资料。文档严格参照国标组织方式依次涵盖MODBUS协议规范、TCP/IP实现指南和串行链路实现指南三大部分并详细说明了ADU、PDU、功能码、事务处理机制、差错校验及异常响应等核心概念同时结合TIA/EIA-232-F、TIA/EIA-485-A、RFC793/791等底层标准给出了从客户机/服务器模型到实际组网场景的完整映射。读者既能借此吃透协议帧结构与交互流程也能获得在PLC、HMI、I/O设备、驱动器和网关之间实现互操作的工程依据。对于需要通信协议选型、驱动开发或设备调试的技术人员这份资料具有直接参考价值。现场设备品牌五花八门的时候Modbus 往往是唯一不需要翻译就能对上的“通用语”。它本质上是 OSI 第 7 层的应用层报文传输协议不绑定物理介质既可以跑在 EIA/TIA-232、485 串行链路上也可以跑在 TCP/IP 网络上端口 502。国标把 Modbus 拆成三部分协议规范、TCP/IP 实现指南、串行链路实现指南加起来一百多页但真正核心的框架和边界条件一条串口调试线和一个报文分析工具就能验证完。对做嵌入式驱动、网关和工控上位机的人来说读懂 PDU/ADU 结构、功能码语义和异常码比盲目复制库函数更重要。1. 为什么说 Modbus 是嵌入式设备互通的“最小公约数”做设备接入时最怕遇到各说各话的控制器西门子的 S7 协议、三菱的 MC 协议、施耐德的 Modbus 在现场混着没有统一网关根本转不动。而 Modbus 从 1979 年出现到成为事实标准最大优势就是“够用且简单”——它只定义应用层的报文格式和数据访问规则把物理层和传输方式完全交给下面的标准去处理。工程师只需要关注功能码和四个数据表格离散量输入、线圈、输入寄存器、保持寄存器就能覆盖大多数数字量和模拟量设备的读写需求。国标文档还给出了串行链路和 TCP/IP 两种链路上的实现指南这意味着你可以拿到一份规范同时搞定 RTU 串口和 Modbus TCP 网关而不需要为不同链路重新设计协议。这个协议特别适合资源受限的 MCU一个状态机加一个 CRC 算法就能跑通从站逻辑这也是它在嵌入式领域经久不衰的根本原因。2. 国标 Modbus 的分层结构与 PDU/ADU 帧格式解析2.1 从 OSI 模型看 Modbus 的位置Modbus 位于 OSI 模型第 7 层与物理层、数据链路层完全解耦。国标文档给出了两种典型的协议栈组合串行链路Modbus 应用层直接映射到 TIA/EIA-232-F 或 TIA/EIA-485-A 物理层中间没有网络层和传输层。TCP/IP 链路Modbus 应用层通过 TCPRFC793和 IPRFC791承载使用保留端口 502。所以 Modbus 根本不关心你是用双绞线、光纤还是无线它只负责组装和解析应用层报文。这也是为什么同一个功能码 03读保持寄存器在 RS485 上和在以太网上语义完全一致只是封装格式不同。理解这一点后移植代码时就不需要修改功能码处理逻辑只需要更换底层收发接口和帧封装函数。2.2 PDU 与 ADU地址域、功能码、数据、校验的边界国标文档定义了通用 Modbus 帧结构ADU 附加域 PDU PDU 功能码 数据具体到两种链路串行链路 ADU地址域1 字节 PDU 差错校验CRC2 字节TCP/IP 链路 ADUMBAP 报文头7 字节 PDU其中 PDU 本身结构固定第一个字节是功能码后面是数据域。这种设计保证了两种链路下功能码处理逻辑完全一致底层只需要处理各自的附加域。写代码时可以抽象出统一的 PDU 处理函数把地址域、校验或 MBAP 的组装交给驱动层去完成。2.2.1 为什么 PDU 大小受链路限制国标文档明确给出了链路约束。串行链路上第一个 Modbus 实现的长度限制决定了 ADU 最大为 256 字节因此串行链路 PDU 256 - 1地址域 - 2CRC 253 字节 TCP链路 PDU 256 - 7MBAP 249 字节注意这里的差异TCP 链路因为 MBAP 占了 7 字节所以 PDU 可用空间被压缩到 249 字节。但这只是国标给出的参考边界实际 Modbus TCP 报文长度字段允许更灵活嵌入式开发时建议按 256 字节缓冲区设计避免溢出。2.3 大端编码与四种数据模型Modbus 的地址和数据项统一使用 big-endian大端编码多字节传输时先发最高有效位。例如寄存器值 0x1234先发送 0x12再发送 0x34。这个细节在对接非大端 MCU 时最容易出错稍后第 4 章会给出具体的字节序处理代码。数据模型是四个独立表格每个表格支持 65536 个数据项协议允许跨连续数据项批量操作。下表概括了四个基本表格基本表格对象类型访问类型内容提供方离散量输入单个比特只读I/O 系统线圈单个比特读写应用程序输入寄存器16 比特字只读I/O 系统保持寄存器16 比特字读写应用程序国标文档用两个实例说明设备内部数据结构的组织方式。第一种是四个独立的块每块用对应的功能码访问第二种是仅一个数据块通过不同功能码可以访问同一数据的不同视图。设计从站时我一般建议按“数据参考地址”与“物理地址分离”的原则实现即 Modbus 逻辑地址从 0 开始索引内部映射表负责把逻辑地址翻译到实际存储位置这样改动硬件布局时不需要修改协议栈。3. 功能码实战从读线圈到写寄存器的报文逐字节拆解3.1 功能码分类与公共功能码总览功能码分三类公共功能码唯一且公开、用户定义功能码65–72 和 100–110、保留功能码。公共功能码是嵌入式开发中最常用的部分。下面表格列出位访问和字访问的核心公共功能码功能码名称操作01 (0x01)读线圈读取 1–2000 个连续线圈状态02 (0x02)读离散量输入读取 1–2000 个连续离散输入03 (0x03)读保持寄存器读取 1–125 个连续保持寄存器04 (0x04)读输入寄存器读取 1–125 个连续输入寄存器05 (0x05)写单个线圈写单个输出为 ON/OFF06 (0x06)写单个寄存器写单个保持寄存器15 (0x0F)写多个线圈写多个连续线圈16 (0x10)写多个寄存器写多个连续寄存器在嵌入式应用中01–06 这六个功能码覆盖了 90% 的调试场景。下面逐个拆解报文。3.2 01/02 位操作读线圈和读离散量的比特打包规则读线圈请求 PDU功能码 0x01起始地址 2 字节线圈数量 2 字节。例如读取从地址 0x0013十进制 19开始的 19 个线圈请求 PDU十六进制01 00 13 00 13 01 功能码 00 13 起始地址高字节 00低字节 13 00 13 线圈数量19响应 PDU 中线圈状态按比特打包第一个字节的 LSB 对应起始地址后续比特从低到高排列。响应示例01 03 CD 6B 05 01 功能码 03 字节数3 字节因为 19 个比特需要 3 字节 CD 输出状态 27–20LSB 对应地址 20 6B 输出状态 35–28 05 输出状态 38–36高 5 位补零这个例子对应国标文档里的经典场景请求读离散量输出 20–38。处理响应时逐比特解析顺序是从 LSB 到 MSB而这与人类阅读十六进制字节时从左到右的习惯相反很容易搞反。我写解析函数时通常会先用一个循环解出每个 bit 的绝对偏移再映射到实际地址。3.2.1 用 Python 拆解响应比特下面这段代码把响应字节转换为按地址排列的布尔列表def unpack_coils(data_bytes, start_addr, count): bits [] for byte in data_bytes: for i in range(8): bits.append((byte i) 0x01) # 只取 count 个有效位 bits bits[:count] return {start_addr idx: val for idx, val in enumerate(bits)}逻辑说明外层循环遍历每个数据字节内层循环从 LSB 开始逐位取出。因为 Modbus 串行传输时先发低比特位所以这里i从 0 到 7 对应从低到高。最后用字典返回“地址 - 开关状态”的映射方便上层直接按地址取值。参数data_bytes是响应中字节数字段之后的原始字节序列start_addr是请求中的起始地址count是请求的线圈数量。注意如果count不是 8 的倍数最后一个字节的高位会自动补零所以必须用count截断否则会多出无效位。3.3 03/04 字操作读保持寄存器和读输入寄存器读保持寄存器请求功能码 0x03起始地址 2 字节寄存器数量 2 字节数量范围 1–125。读取地址 0x0006 开始的 3 个寄存器示例03 00 06 00 03 03 功能码 00 06 起始地址高字节 00低字节 06 00 03 寄存器数量3正常响应03 06 02 2B 00 00 00 64 03 功能码 06 字节数3 个寄存器 × 2 字节 02 2B 寄存器 0x0006 的值 0x022B 555 00 00 寄存器 0x0007 的值 0 00 64 寄存器 0x0008 的值 100每个寄存器值都是大端序先高字节后低字节。解析时直接按(hi 8) | lo合成 16 位无符号整数。如果值是负数或浮点数则需要按设备厂商约定进行二次转换但协议层只认 16 位无符号。3.3.1 C 语言处理响应帧的函数在 MCU 上解析这种响应可以用一个简单的函数uint16_t modbus_get_u16(const uint8_t *buf, int offset) { return (uint16_t)(buf[offset] 8) | buf[offset 1]; } void parse_read_register_resp(const uint8_t *adu, uint8_t *values, int reg_count) { for (int i 0; i reg_count; i) { values[i] modbus_get_u16(adu, 3 i * 2); } }逻辑说明adu指向完整 ADU串行含地址和 CRCTCP 含 MBAP。parse_read_register_resp假定功能码之后是字节数所以第一个寄存器值从偏移3开始adu[0]是地址/MBAP 中的单元号adu[1]是功能码adu[2]是字节数。这里依赖底层已经剥离了 CRC 或 MBAP 长度字段直接把有效数据传进来。参数values是输出缓冲区reg_count是请求时指定的寄存器数量。3.4 05/06 写操作写单个线圈和寄存器写单个线圈的请求和响应完全相同功能码 0x05输出地址 2 字节输出值 2 字节。输出值只有两个合法常量FF 00表示 ON00 00表示 OFF其他值非法且不起作用。例如写线圈 173 为 ON请求/响应05 00 AC FF 00 05 功能码 00 AC 输出地址172注意示例 FF 00 输出值 ON国标文档中示例是写线圈 173地址是0x00AC172因为从零寻址线圈 173 对应地址 172。写单个寄存器功能码 0x06 类似请求和响应都是功能码 地址 寄存器值例如写寄存器 2 为 0x03 的报文06 00 02 00 03。3.5 异常响应功能码 0x80 与异常码当服务器检测到错误时会返回异常响应 PDU功能码 请求功能码 0x80后跟一个字节的异常码。例如读保持寄存器请求非法地址返回83 02 83 0x03 0x80 02 异常码非法数据地址处理异常响应的关键是先判断功能码的最高位是否置 1若是则进入异常处理分支不要继续按正常响应解析数据。异常码 01–04 覆盖了大多数现场问题第 5 章会详细展开。4. 串行链路与 TCP/IP 的映射差异RTU 与 MBAP 头部处理4.1 串行链路 RTU 模式地址域和 CRC16 校验串行链路上最常见的模式是 RTU。每个 ADU 由地址域、PDU、CRC 组成[地址域 1 字节] [功能码 1 字节] [数据 n 字节] [CRC 低字节] [CRC 高字节]RTU 模式下地址域用于选择从站范围 1–2470 作为广播地址从站不响应。CRC 校验计算从地址域开始到数据域结束的所有字节多项式为0xA001即反转的0x8005结果先发低字节再发高字节。这是串口调试时最容易出问题的地方——很多工程师把 CRC 字节序搞反导致从站直接丢弃报文。下面给出一个适合嵌入式平台的 CRC16 计算函数uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *data; for (int i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }逻辑说明初始值为0xFFFF每次取一个字节与 CRC 低字节异或然后按位右移。如果移出位为 1就与0xA001异或这个多项式是标准 Modbus 使用的反转多项式。参数data是地址域开始的字节指针len是地址域 PDU 的长度。函数返回值是 16 位 CRC发送时先放低字节crc 0xFF再放高字节crc 8。4.2 TCP/IP 上的 ModbusMBAP 头部的七个字节Modbus TCP 使用 MBAPModbus Application Protocol头替代串行链路的地址域和 CRC。MBAP 共 7 字节字段长度说明事务处理标识符2 字节用于匹配请求与响应可由客户端自增协议标识符2 字节0 表示 Modbus 协议长度2 字节后续字节数单元标识符 PDU单元标识符1 字节相当于串行链路中的从站地址注意长度字段只表示“后续还有多少字节”不包括长度字段本身。例如读取保持寄存器的请求帧00 01 00 00 00 06 0A 03 00 00 00 01 00 01 事务处理标识符 00 00 协议标识符 0 00 06 长度 60A 03 00 00 00 01 0A 单元标识符相当于地址 10 03 功能码 00 00 起始地址 00 01 寄存器数量与 RTU 相比TCP 不需要 CRC因为 TCP 自身有校验和重传机制。调试时可以用 Wireshark 抓包并设置modbus过滤条件快速查看这些字段。4.2.1 用 Python 构造 Modbus TCP 请求上位机调试时我常用 Python 快速拼帧import struct def make_mbap_request(unit_id, function_code, address, quantity): pdu struct.pack(BHH, function_code, address, quantity) mbap struct.pack(HHHB, 0x0001, 0, len(pdu) 1, unit_id) return mbap pdu逻辑说明struct.pack采用大端格式。MBAP 中事务标识符固定为 1协议标识符为 0长度是 PDU 长度加 1单元标识符字节。unit_id是网关或从站的单元标识符function_code是功能码address和quantity分别是起始地址和数量。这个函数返回的字节串可以直接通过 TCP socket 发送。参数unit_id在直连 Modbus TCP 设备时通常填 1 或 255但在通过网关转串行链路时必须与目标从站地址一致。4.3 两种模式下的 PDU 长度限制对比下表总结了国标文档给出的边界条件链路类型ADU 最大长度PDU 最大长度附加域串行链路RS232/RS485256 字节253 字节地址 1 字节 CRC 2 字节TCP/IP256 字节249 字节MBAP 7 字节这个差异在批量读写时要特别注意。功能码 03 读取最大 125 个寄存器如果以 2 字节算产生 250 字节响应在串行链路上 250 3 253 勉强能放得下但加上功能码和字节数字段后总 PDU 是 250 2 252加上地址和 CRC 正好 255刚好小于 256。而在 TCP 链路上同样的 250 字节数据会导致 PDU 252 字节加上 MBAP 7 字节是 259 字节超出国标文档给出的 256 字节参考值。所以建议 TCP 场景下单次读取不超过 121 个寄存器留出余量。4.4 超时处理与重试策略两种链路的超时策略不同。串行链路 RS485 半双工通信发送完请求后必须等从站响应超时时间至少要覆盖报文发送时间加上从站最大处理时间。我一般设置 500ms 到 1s 之间的超时如果现场有慢速设备再适当放宽。TCP 链路则可以依赖 socket 本身的超时但同样要处理网关透传串行链路时的额外延迟。一个实用的补偿方法是先测量从站对最小请求如读单个寄存器的响应时间再乘以 2 加上 50ms 作为动态超时基准。5. 嵌入式移植要点与排错状态机、异常码与调试经验5.1 服务器处理状态机先确认再执行国标文档给出了从站处理事务的状态图接收请求后依次确认功能码是否支持、数据地址是否合法、数据值是否有效最后才执行操作并产生响应。任何一个环节失败都会直接跳到异常响应分支。写嵌入式从站代码时我建议按这个顺序实现避免因为执行了部分操作后才报错导致数据不一致。下面是一个精简的状态机伪代码if (!is_supported(function_code)) { send_exception(0x01); return; } if (!is_valid_address(start_addr, quantity)) { send_exception(0x02); return; } if (!is_valid_value(data)) { send_exception(0x03); return; } if (!execute_operation(function_code, start_addr, data)) { send_exception(0x04); return; } send_normal_response(function_code, result_data);逻辑说明这段伪代码严格遵循异常码优先级——先功能码再地址再数据值最后是设备执行失败。参数start_addr和quantity需要一起验证防止起始地址合法但地址 数量溢出表格范围。execute_operation返回布尔值用于覆盖设备存储介质写入失败等内部错误。5.2 异常码表与最常见问题现场排错时异常码是最直接的线索。下表列出前四个异常码的含义和对应常见场景异常码名称原因与排查方向0x01非法功能码从站不支持该功能码或该功能码被禁用0x02非法数据地址起始地址 数量超出从站映射范围或地址未对齐0x03非法数据值数据值不在合法范围内如写线圈设置了非 0xFF00 的值0x04从站设备故障从站内部操作失败如 EEPROM 写错误如果收到异常码 0x02首先检查是否把人类习惯的“寄存器编号”直接当成了地址。Modbus 从零寻址很多设备手册说的“保持寄存器地址 40001”其实是 0 基地址 0 对应 40001如果误把 40001 放入报文当然会报地址越界。我自己的排查方法是先用功能码 03 从地址 0 读 10 个寄存器看从站是否正常返回再逐步缩小范围。5.3 抓包验证与自测的最小复现清单无论做主机还是从站把报文原始字节打出来永远比看封装好的结构体高效。推荐用串口助手直接发送十六进制帧来测试从站例如发送01 03 00 00 00 0A C5 CD其中C5 CD是 CRC16 校验结果低字节 C5高字节 CD。如果从站正常应当返回 01 03 14 后跟 10 个寄存器的 20 个字节。这个办法能快速定位 CRC 算法、字节序和地址映射是否正确。自测时我通常遵循以下清单用最小请求读 1 个线圈/寄存器确认基本链路。批量读时把数量分别设置为 1、8、9、125验证位打包和长度字段。故意请求超范围的地址确认异常码是否为 0x02。写线圈时分别发送 0xFF00、0x0000、0x1234确认第三个值被拒绝。在 TCP 网关场景下确认 MBAP 的事务标识符随请求递增并且协议标识符恒为 0。完成这五项后我对设备的 Modbus 实现就基本有底了。比如调试一个从站时发现批量读 8 个线圈返回的字节数不对最终定位到响应中字节数计算用了(count 7) / 8但实际数据长度少了一个字节问题出在底层堆栈把最后一个全零字节优化掉了前端严格按字节数字段解析后立即发现了不一致。这种小坑只有把报文打印出来逐字节对过才能看穿。本文还有配套的精品资源点击获取
返回列表