ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Modbus协议实战入门:RTU/TCP报文解析与RS485数据采集

Modbus协议实战入门:RTU/TCP报文解析与RS485数据采集 1. Modbus到底是个什么“规矩”干工业现场的人大概都躲不开Modbus这个词。哪怕你主攻的是OPC UA、Profinet这类更“现代”的协议跑到车间里一摸底底层那些PLC、温控表、传感器、智能电表一个个还在用Modbus报数据。我最早接触Modbus是被叫去数一套老产线的设备点数那会儿连寄存器是什么都没搞明白拿着个串口调试助手对着说明书硬读折腾了一下午才把一台仪表的温度读出来。后来踩的坑多了才慢慢把这套协议摸透。说句实在话Modbus能活四十多年靠的不是技术多新而是它实在够简单、够开放。Modbus是施耐德电气在1979年发明的施耐德把协议规范公开免费给大家用不设专利门槛。这就导致了它在工控领域遍地开花几乎所有PLC、变频器、仪表、传感器厂商都愿意支持它。从技术角度理解Modbus本质上是一套“主从问答式”通信规则一台主机通常是PLC、触摸屏或工控机发出请求从机仪表、传感器、变频器应答数据就这么一问一答没有花哨的握手流程也没有加密、校验以外的多余开销。那为什么要专门搞一篇文章讲它因为Modbus在数据采集项目里实在太常用了。你接到一个活儿要采集数控机床的运行状态、读取传感器的温湿度、把PLC里的产量数据上报给MES系统第一反应大概率就是Modbus。尤其是“3-1 Modbus协议”这种形式在很多入门课程的章节里都会出现——前面讲完基础概念后面就该上手抓包、连设备、写采集程序了。这篇文章就是按照这个思路来的先讲清楚协议的基本规矩再带你走一遍从物理接线到写代码读数据的完整流程最后说说我在现场踩过的那些坑。适合刚入门工业通信的工程师、做设备数据采集的程序员以及需要在项目里快速对接Modbus设备的同学参考。1.1 三种形态RTU、ASCII、TCPModbus协议有三种最常见的形态Modbus RTU、Modbus ASCII和Modbus TCP。很多新手第一眼看到这三个词就晕了其实它们的核心报文结构是一样的只是“包装方式”不同。先说RTU它是在串行链路上RS232/RS485最常用的二进制传输模式。RTU模式下一帧报文里的每个字节都是直接以二进制形式发送传输效率高是现场默认选择。比如你用串口调试助手发01 03 00 00 00 01 84 0A这就是一帧标准的RTU读保持寄存器请求。通信参数通常是9600bps、8数据位、1停止位、无校验这套参数在90%的设备里都通用但不绝对具体要以设备手册为准。ASCII模式说实话现在用得越来越少它的特点是把每个字节再拆成两个ASCII字符发送传输效率比RTU低一倍好处是肉眼直接能看懂报文。比如同一帧请求ASCII模式下会变成“:010300000001F202”这种带冒号和校验字符的文本帧。因为效率低ASCII模式多见于早期远程终端设备或某些兼容性要求特殊的项目普通数据采集场景基本可以先忽略。Modbus TCP则是把报文搬到了以太网上走TCP/IP协议栈。由于网络层已经保证了数据完整性TCP报文把RTU里的CRC校验去掉了换成了一个2字节的MBAP报文头事务标识符、协议标识符、长度、单元标识符。通信模式也从主从问答变成了客户机/服务器模型一台服务器从站设备可以被多个客户端同时连接读取比串行总线自由得多。1.2 一帧报文逐字节拆解无论RTU还是TCPModbus报文的核心结构是一致的都遵循 地址/单元标识符 功能码 数据 校验 这个框架。以最经典的“读保持寄存器”为例RTU请求报文长这样01 03 00 00 00 01 84 0A逐个字节拆开看01从站地址。取值范围1~2470是广播地址248~255保留。这条命令是发给地址为1的从站的。03功能码表示读保持寄存器。Modbus最常用的功能码就那几个03读保持寄存器、04读输入寄存器、01读线圈、02读离散输入、06写单个保持寄存器、160x10写多个保持寄存器。00 00起始寄存器地址。注意这里用的是16位无符号整数高字节在前大端序。寄存器地址从0开始编号对应设备手册里的“地址40001”之类就涉及到后面的地址映射换算问题。00 01要读取的寄存器数量。同样是大端序范围1~125因为协议规定一次读寄存器数量不能超过125个这是RTU模式下报文长度限制决定的。84 0ACRC16校验码。它是用前面所有字节计算出来的低字节在前、高字节在后这条帧里0x84是低8位0x0A是高8位。从站收到这条请求后如果一切正常会回复一帧应答01 03 02 12 34 78 B0拆解如下01是自己的地址03是原功能码回显02是后续数据字节数2个字节即1个寄存器12 34就是寄存器的值最后两个字节78 B0是CRC校验。如果请求的地址不存在或者功能不支持从站不会回复这种正常帧而是回一个异常帧功能码最高位置1。比如读失败时回复01 83 02 C0 F1其中02是异常码代表非法数据地址。异常码常见的有01非法功能、02非法数据地址、03非法数据值、04从站设备故障排查问题时第一个要看的就是这个异常码。2. 数据世界线圈、寄存器与功能码刚开始学Modbus最容易绕晕的就是它的数据模型。Modbus把设备数据划分成了四个区域线圈Coil、离散输入Discrete Input、输入寄存器Input Register、保持寄存器Holding Register。这四个区的数据访问方式和读写权限不一样搞不清楚的话照着说明书找功能码也能试出来但总会心里没底。2.1 四个数据区到底是干嘛的用一句话概括线圈和离散输入是位bit数据一个bit就是一个开关量输入寄存器和保持寄存器是字word数据一个寄存器16位存数值。线圈Coil可读可写按“位”寻址。典型应用是控制输出比如PLC的DO点一个线圈代表一个继电器或阀门的开关状态。功能码01读线圈05写单个线圈15写多个线圈。离散输入Discrete Input只读按“位”寻址。典型应用是读取现场开关状态比如限位开关、按钮、断路器辅助触点。功能码02读离散输入。输入寄存器Input Register只读按“寄存器”寻址一个寄存器16位。典型应用是读取模拟量输入通道比如4~20mA电流信号转换成的16位数值。功能码04读输入寄存器。保持寄存器Holding Register可读可写按“寄存器”寻址。这是最灵活的一个区读取PLC里的温度设定值、产量计数器、设备模式参数通常都落在这个区。功能码03读保持寄存器06写单个保持寄存器16写多个保持寄存器。我们用表格对比一下就一目了然数据区名称位/字读写权限典型用途读功能码写功能码线圈Coil位可读可写开关量输出控制0105 / 15离散输入Discrete Input位只读开关量输入采集02无输入寄存器Input Register字只读模拟量输入采集04无保持寄存器Holding Register字可读可写参数设定、计数器、状态字0306 / 16记住一句话现场90%的采集需求就是读保持寄存器03功能码和读输入寄存器04功能码。手里拿着设备说明书先判断要读的数据是数值还是开关量再对照这个表格选功能码基本不会错。2.2 地址映射的经典陷阱40001还是4x000Modbus协议本身定义的寄存器地址是从0开始的16位整数从0x0000到0xFFFF。但是设备厂商在写文档时通常不写这个原始地址而是写“寄存器编号”比如“起始地址40001”“保持寄存器40010”这种。这就引出了著名的3万偏移量坑。Modbus早期规范里用数据区的起始编号来区分不同区——线圈编号从00001开始离散输入从10001开始输入寄存器从30001开始保持寄存器从40001开始。这个编号体系和实际协议地址之间隔了一个偏移量。比如手册上写“保持寄存器地址40001”对应协议里的实际地址是0x000040001 - 40001 0写“保持寄存器地址40010”对应协议地址就是0x000940010 - 40001 9。还有一些设备手册更狠写“Modbus地址4x0001”或者“寄存器地址1”这需要你判断它采用的是哪种编号方式。如果是按照PLC厂商的映射表来的比如西门子40001对应Modbus数据地址0001那你在组报文时可能还得再减一次偏移。遇到这种情况我的经验是不管手册怎么写先往协议地址里填0x0000发一帧03功能码读一个寄存器看返回值对不对再逐步试探。现场设备不敢直接信手册实测为准。2.3 数据格式与大小端校表之前先对字节序寄存器里的16位数据怎么解释同样能坑死人。最常见的两种数据类型是16位无符号整数和32位浮点数IEEE 754单精度但32位浮点数在Modbus里存的是“两个字”于是就有了字节序和字序的排列问题。举个例子一个浮点数1.5在IEEE 754下的十六进制是0x3FC00000。把这个值放进两个连续的Modbus寄存器里可能出现的排列方式包括大端字序大端字节序寄存器A存0x3FC0寄存器B存0x0000大端字序小端字节序寄存器A存0xC03F寄存器B存0x0000小端字序大端字节序寄存器A存0x0000寄存器B存0x3FC0小端字序小端字节序寄存器A存0x0000寄存器B存0xC03F不同厂商的PLC和仪表这两种顺序的组合都不一样。比如西门子系列PLC常用大端字序部分国产仪表用小端字序ABB的变频器有些型号又是小端字序。解决方案只有一个先写个测试代码把读出来的两个寄存器的值按几种组合都解析一遍看哪个结果和面板上显示的值对得上然后把这个组合固定下来。还有更复杂的字符串数据比如设备序列号、固件版本这些可能占连续多个寄存器每个寄存器存2个ASCII字符。解析时还要分清哪个字节是字符的高位哪个是低位和浮点数一样需要实测确认。3. 实战用Python写一个Modbus RTU采集程序理论铺垫得差不多了现在上手干活。我用一个实际场景来演示采集一台温控器的当前温度这台设备通过RS485接到工控机上协议是Modbus RTU温控器的Modbus地址是1温度值存在保持寄存器地址0x0000对应手册的40001。整个过程从硬件接线开始到最终代码跑起来尽量完整走一遍。3.1 硬件接线与通信参数现场第一课RS485是半双工差分总线线缆一般是两根A标识为D或正和B标识为D-或负加上一根参考地线更可靠。接线一个最简单的原则A对A、B对BA接正B接负。但说实话我在现场碰到过不少奇怪的标记有的设备标“485”实际上是B有的标反了。接完之后如果通信超时第一件事就是把A、B对调再试。设备侧通常有拨码开关或菜单来设置Modbus地址和通信参数。地址设置好以后要把波特率和校验方式也记下来主机端的串口参数必须和设备保持一致。我的建议是现场统一用9600bps、8数据位、无校验、1停止位这套最保守的参数能通以后再考虑提高波特率提速。800米以内的短距离9600和115200的稳定性差别不大但长距离、电磁干扰强的车间现场低波特率抗干扰能力明显更强。硬件就绪后可以用串口调试助手或Modbus Poll这类工具先测试通信。我这里假设工控机的COM3口已经通过USB转485模块连上了温控器接下来直接用Python写采集程序。装好pyserial库就行pip install pyserial3.2 CRC16-Modbus到底怎么算手算和程序实现CRC是Modbus RTU最核心的校验很多人第一次自己写协议栈时卡在这一步。其实CRC16-Modbus的算法并不复杂它和普通CRC16的区别在于多项式是0x8005初值是0xFFFF输出结果还要异或0x0000就是说没有最后异或输出低字节在前。我贴一段Python实现这是最直接的查表法实际项目中我一般也是这么写的def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc 1 crc ^ 0xA001 else: crc 1 # crc低字节在前高字节在后 return crc # 示例计算 01 03 00 00 00 01 的CRC frame bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]) crc crc16_modbus(frame) print(hex(crc)) # 0x0A84低字节0x84高字节0x0A这里0xA001是什么呢它是多项式0x8005的反向多项式因为16位CRC逐位右移时用反向多项式做异或等价于标准CRC的高位优先计算。查表法的速度优势在单片机或者数据量大的时候更明显Python做上位机采集时按位计算已经完全够用不需要装表。3.3 从组帧、下发到解析完整代码走一遍有了CRC函数我们就可以手写一个完整的RTU读取函数不依赖任何Modbus专用库。这样做的最大好处是你能看清楚每一字节在干什么而不只是调一个现成函数。代码如下import serial import time def crc16_modbus(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc 1 crc ^ 0xA001 else: crc 1 return crc # 注意返回值低字节在前高字节在后 def build_read_holding_request(slave_id, start_addr, quantity): # 起始地址和寄存器数量都是2字节大端序 frame bytearray() frame.append(slave_id) frame.append(0x03) # 功能码读保持寄存器 frame start_addr.to_bytes(2, byteorderbig) frame quantity.to_bytes(2, byteorderbig) crc crc16_modbus(bytes(frame)) # CRC低字节在前 frame bytes([crc 0xFF, crc 8]) return bytes(frame) def read_holding_registers(ser, slave_id, start_addr, quantity, timeout1): req build_read_holding_request(slave_id, start_addr, quantity) ser.reset_input_buffer() ser.write(req) # 等待应答读足够字节 # 应答最小长度地址1 功能码1 字节数1 数据quantity*2 CRC2 expected_len 5 quantity * 2 resp b deadline time.time() timeout while len(resp) expected_len and time.time() deadline: chunk ser.read(expected_len - len(resp)) if chunk: resp chunk if len(resp) 5: raise TimeoutError(从站无响应或响应超时) # 校验从站地址和功能码 if resp[0] ! slave_id: raise ValueError(f从站地址不匹配{resp[0]} ! {slave_id}) if resp[1] 0x03: data_len resp[2] if data_len ! quantity * 2: raise ValueError(f数据长度异常{data_len} ! {quantity * 2}) values_raw resp[3:3 data_len] crc_recv int.from_bytes(resp[-2:], byteorderlittle) if crc16_modbus(resp[:-2]) ! crc_recv: raise ValueError(CRC校验失败) # 按大端序解析寄存器值 values [] for i in range(0, len(values_raw), 2): values.append(int.from_bytes(values_raw[i:i2], byteorderbig)) return values elif (resp[1] 0x80) 0x80: exc_code resp[2] raise ValueError(fModbus异常响应异常码: {exc_code}) else: raise ValueError(f未知功能码响应: {resp[1]}) ser serial.Serial(portCOM3, baudrate9600, bytesize8, parityserial.PARITY_NONE, stopbits1, timeout1) try: while True: try: temps read_holding_registers(ser, slave_id1, start_addr0, quantity1) # 温度值可能为16位有符号整数比如25.5度存的是255放大10倍 raw temps[0] if raw 32767: raw - 65536 temp_value raw / 10.0 print(f温度{temp_value:.1f} °C) except Exception as e: print(f采集出错{e}) time.sleep(1) finally: ser.close()这段代码把整个收发流程分成了三步组帧、发送、读响应解析。其中有几个细节值得展开说。第一是ser.reset_input_buffer()这个操作在每次发送请求前清空接收缓冲避免上一次残留的应答数据或半截数据干扰本次解析。很多初学者没做这一步会偶发性地读到错位数据排查半天还以为是CRC问题。第二是超时计算。串行波特率9600时一个字节传输时间大约是1毫秒出头一帧应答最长也就256字节理论上几百毫秒肯定能收完。但实际现场有设备处理慢、链路质量差所以超时设成1秒是合理的。如果改成115200波特率同样一帧数据毫秒级就能收完超时设短一点更合适。第三是异常功能码判断。从站返回异常时功能码的最高位是1所以resp[1] 0x80能判断出是否异常。这里我给出的异常码提取方式能帮你在调试时第一时间定位问题。3.4 用现成库还是自己解析我的建议手写完这段解析代码之后肯定有人会问现在有现成的库比如pymodbus、modbus-tk为什么还要自己写我的回答是项目交付阶段用成熟库确实效率高、代码健壮但我强烈建议你至少手写一次完整的组帧和解析过程。原因有三个第一Modbus设备厂商不总是严格按照规范实现有些设备的数据填充方式、空闲间隔处理很个人化库处理不了的时候你只能靠自己写的底层去排查第二很多设备是“类Modbus”协议封包结构相近但功能码或地址映射有改动这时候改自己的代码比改库的源码快得多第三也是最重要的你在面试或者技术交流中被人问起Modbus时能准确说出CRT帧结构、大小端、异常码这些东西心里会有底得多。如果项目工期紧直接用pymodbus也没问题。但至少要能在出问题时看懂它发出的报文知道去哪里打印原始字节。我见过不少同事一报错就跑来问“这个DNS错误是怎么回事”实际上打开debug日志看原始帧十秒钟就定位了。4. TCP方式采集以及多台设备怎么轮询RTU讲完有条不紊地推进到TCP。现在很多PLC和网关设备都标配以太网口自带Modbus TCP服务不需要转串口模块采集程序也少了一层物理口占用。虽然报文结构变了但核心的寄存器模型和功能码完全一样。4.1 Modbus TCP报文和RTU差在哪Modbus TCP的请求帧是下面这个结构事务标识符(2字节) 协议标识符(2字节) 长度(2字节) 单元标识符(1字节) 功能码(1字节) 数据(N字节)其中事务标识符客户端自己维护每次请求加1让客户端能把应答和请求对应起来。多请求并发时尤其有用。协议标识符固定为0x0000表示Modbus协议。长度后面所有字节的总数即单元标识符 功能码 数据的字节数。单元标识符相当于RTU的从站地址。在一个Modbus TCP服务器上单元标识符用来区分后端不同的从站设备。用一个例子对比读从站地址为1的保持寄存器0x0000数量1Modbus TCP请求为00 01 00 00 00 06 01 03 00 00 00 01其中00 01是事务标识符00 00是协议标识符00 06是长度6单元标识符、功能码、起始地址2字节、数量2字节加起来正好6字节01是单元标识符后面就是熟悉的RTU数据区。和RTU相比TCP模式少了CRC校验多了MBAP头。由于走TCP协议数据完整性和重传机制由网络协议栈保证不需要再在应用层做CRC。会话管理和并发访问也都比串行总线好得多多个客户端可以同时访问同一台服务器而不会冲突。4.2 轮询机制从站数量、超时、重试的设计RS485总线上挂多台设备时主站必须一个一个地轮询同一时刻只能有一个从站应答。轮询机制的设计直接决定采集系统的稳定性和实时性。核心参数有三个单站超时、重试次数、轮询周期。举个例子总线上挂了8台设备每台设备的平均响应时间约50毫秒单站超时设为200毫秒重试2次。那么正常不带故障的轮询一圈时间是8×50400毫秒也就是每秒能采2.5轮。如果其中一台设备故障不响应对这台设备的采集时间会变成200毫秒超时第二次重试再等200毫秒整体周期瞬间翻倍。所以实际项目中建议每台设备连续读取4~8个寄存器打包成一条请求而不是一个寄存器发一条命令这样能大幅减少总线上的帧数和等待时间。代码层面的轮询调度我用过一个非常简单的循环方式devices [ {slave_id: 1, baud: 9600}, {slave_id: 2, baud: 9600}, {slave_id: 3, baud: 9600}, ] for device in devices: try: data read_holding_registers( ser, device[slave_id], start_addr0, quantity8, timeout0.2 ) process_and_store(device[slave_id], data) except Exception as e: log_error(device[slave_id], e) continue # 失败跳过不影响下一台设备这里有一个容易被忽略的关键点设备故障时绝不能阻塞整个轮询循环。必须逐台设置超时单台失败了就记日志继续下一台否则一台设备坏了后面所有设备都跟着停摆。4.3 网关和非标设备遇到“不太守规矩”的从站怎么办现场设备并非都直接出RS485或以太网接口。我碰到过不少情况是传感器走RS232、数控机床走专用协议、老PLC走DP总线这时候需要协议网关来做转换。市面上常见的工业网关基本都有Modbus RTU/TCP主站功能可以把不同物理接口、不同协议的数据统一汇总成Modbus TCP或者OPC UA抛给上位机。提到OPC UA很多人的第一反应是“这玩意和Modbus不是竞争关系吗”其实两者定位完全不同。OPC UA强在语义建模和信息安全适合厂级系统之间互联Modbus强在轻量和普遍适合末端设备采集。实际项目中常见组合是“Modbus RTU采集现场设备 → 边缘网关做协议转换 → OPC UA上传MES”各干各的活。遇到非标设备我的排查优先级是先确认物理层是否正常串口工具发0x01 0x03 0x00 0x00 0x00 0x01看有没有回应哪怕是异常帧再确认设备手册里的寄存器表看有没有地址映射陷阱最后用抓包工具对比正常设备的数据逐字节比对找区别。三类问题里物理层故障占30%寄存器表理解错误占50%剩下的是厂家固件Bug基本只能反馈厂商了。5. 现场问题排查与心得写到这里尽量把抽象的问题具体化。第九成以上的Modbus采集问题最后都能归到几个固定的原因上。我把常见故障整理成了速查表方便你现场照着查。5.1 常见故障速查表现象可能原因排查方向完全无响应RS485 A/B接反调换A/B线再试完全无响应地址/波特率/校验不匹配用串口调试助手手动发帧确认完全无响应从站设备未上电或总线冲突用万用表测设备端电压和总线电平偶发性超时线缆过长或干扰强降低波特率、检查线缆屏蔽层接线偶发性超时链路中终端电阻缺失总线两端各加120Ω终端电阻收到异常帧寄存器地址超范围核对设备手册寄存器地址映射CRC校验失败线缆连接接触不良重新压接端子检查485转发器CRC校验失败串口参数不一致核对数据位/校验位/停止位配置读到全0或全F从站返回空数据或异常检查设备是否处于运行故障状态数据值跳变异常数据类型/大小端配置错误切换字序和字节序组合测试5.2 几个我亲身踩过的坑第一个坑是“想当然的9600”。有一回新装了一批电表手册上说默认波特率9600结果全部超时。后来拿万用表量总线发现仪表实际是19200在跑原因是某位老哥批量设置参数的时候设错了。现场我养成了一个习惯新设备接入前先用自动波特率扫描有的串口工具支持或者发几组不同波特率的请求试一遍别太相信设备铭牌。第二个坑是“浮点数解析对不上”。这恐怕是Modbus开发中最烦人的一个问题。最早读一台仪表的累积流量上位机能读到寄存器值但拼出来完全是天文数字我和同事折腾了两个小时最后用手册里的样例数据挨个试组合发现是“小端字序大端字节序”的奇葩排列。后来我写了一个小工具输入寄存器原始值一次性展示四种排列组合的浮点数结果哪个看着合理就用哪个五分钟就定位完事。第三个坑是“超时参数设置太保守”。PLC响应本来很快但某次项目里设备处理一个读请求要200ms我超时设了300ms结果网络稍微抖动就超时。后来把所有设备的最大响应时间测了一遍统一把重试和超时调整到合理水平整体采集周期反而更稳了。第四个坑是关于“并发写和读的互斥”。Modbus串行总线同一时刻只能有一个请求在总线上所以如果你既要读数据又要写参数必须串行排队。有些新手拿多线程去同时读写同一个串口结果就是总线数据帧互相干扰所有请求全军覆没。我的建议是单串口一律用单线程轮询或者用一个请求队列把所有读写操作串行化绝不并发。6. 从读懂协议到真正会调现场写这篇文章的初衷其实是记录我从“只知道Modbus是个协议名”到“能在现场独立搞定采集”这个过程里的核心知识点。Modbus不难但它的知识点比较零散串口参数、报文格式、地址映射、CRC校验、大小端规则单独拎出来都好懂组合到一起再加上一台设备手册初学者就容易懵。我个人在实际操作中的体会是动手比看资料重要一百倍。你花二十分钟看我上面这段CRC计算和组帧代码不如花二十分钟连一台真实的RS485设备用串口调试助手手动发一帧01 03 00 00 00 02亲眼看到应答帧里的数据变化然后再换成自己的采集程序跑一遍。这一步跨过去Modbus就算是真正入门了。最后再分享一个多写采集代码时的小技巧把读回来的原始寄存器值都打日志而不是只打解析后的结果。现场设备数据异常时原始值能帮你在解析层和通信层之间快速划清界限——如果原始值一直在平稳变化那是解析或配置问题如果原始值直接卡住不刷新那是从站设备或总线本身有问题。这个习惯救过我很多次几乎成了我写所有采集程序的标准配置。
返回列表