做嵌入式这几年,MODBUS协议是我打交道最多的工业通信协议,没有之一。不管是做温湿度采集、变频器控制,还是给PLC写网关程序,只要现场设备需要联调,用MODBUS的概率至少占六成。这篇笔记是“嵌入式调试笔记”系列的第7篇,记录一下我在实际项目中梳理MODBUS协议细节、把设备调通的过程和踩过的坑。如果你想快速搞懂MODBUS RTU和MODBUS TCP的区别、想自己写一个从机程序、或者正在被串口收不到数据折腾——这篇应该能帮你省不少时间。前面几篇聊过串口、GDB和Linux下的调试技巧,这次专门把MODBUS这个“工业现场普通话”单独拿出来讲透,顺便把调试实战中用到的工具和排查思路也一并交代清楚。
1. MODBUS协议的本质认知:先把主从模型刻在脑子里
1.1 从“一问一答”理解MODBUS的通信模型
MODBUS本质上就是一套主从问答协议。什么叫主从问答?你可以把它想象成课堂上老师点名学生回答问题:老师(主机)提问,被点到名的学生(从机)才能站起来回答,其他学生只能听着,绝对不能抢答。主机永远是主动方,从机永远不能主动往总线上发数据。这个特性非常重要,因为很多人在调试时会遇到这种情况——从机数据变了,怎么让PC主动收到?在标准MODBUS下做不到,只能让主机周期性地去轮询读取。
在RTU模式下,从机地址范围是1到247,0是广播地址。广播地址有意思的地方在于:主机发一帧广播数据,总线上所有从机都能收到,而且从机不需要回复。这在批量设置参数、统一启停设备的时候非常有用。但需要注意,广播帧没有应答,主机无法确认从机是否真的收到,所以现场设备控制里我一般不拿广播做关键操作,顶多用来做同步或者简单的组播指令。
MODBUS同一时刻只允许一个主机存在,总线上多个从机共享一根通信线。从机之间不能直接互相通信,所有的数据交换都得经过主机中转。这种结构看起来笨,但胜在简单可靠,工业现场要的就是这个。很多PLC之间的数据交换,本质上就是各自作为MODBUS主机去读对方的数据。
1.2 RTU、ASCII、TCP三种传输模式怎么选
很多人会把“485协议”和“MODBUS协议”混为一谈,实际上这俩根本不是一个层级的东西。RS485是物理层标准,只管电平怎么编码、电压怎么差分、怎么抗干扰;MODBUS是应用层协议,管报文怎么组织、数据怎么解析。只不过在工程实践中,MODBUS RTU跑在RS485物理链路上是绝对的主流,所以大家经常把它们打包在一起叫“485 MODBUS”。
MODBUS协议本身有几种不同的“外壳”:
- MODBUS RTU:串行链路上用二进制传输,带CRC16校验,效率最高,工业现场最常用。特点是一帧报文里所有数据都是二进制字节,人眼直接看串口助手的HEX显示才能读懂。
- MODBUS ASCII:同样是串行链路,但把每个字节拆成两个ASCII字符发送,带LRC校验。效率比RTU低一半,但好处是报文可以用串口助手的文本模式直接读出来,适合老设备或者调试阶段。现在用得越来越少了。
- MODBUS TCP:走以太网,默认端口502。不需要CRC校验(TCP/IP协议栈自己保证传输正确性),取而代之的是一个叫做MBAP的报文头,用来标示事务处理标识符、协议标识符、长度和单元标识符。调试时可以用网络调试助手代替串口助手来发包。
RTU和TCP的关系可以这么理解:RTU是一条窄马路上的货车,TCP是高速公路上的货车,货物(功能码和寄存器数据)本质上是一样的,只是包装和运输规则不同。实际做嵌入式产品时,如果设备支持网口,通常会同时提供RTU和TCP两种模式,让用户根据现场网络情况切换。
2. 报文格式逐字节拆解:调试前必须吃透的底层细节
2.1 功能码:真正干活的指令只有那么几条
MODBUS协议看起来复杂,但真正在生产环境高频使用的功能码就那么几个。我整理了一个常用功能码表格,调试的时候对照这个表格拆报文,效率高很多:
| 功能码 | 名称 | 操作对象 | 典型用途 |
|---|---|---|---|
| 0x01 | 读线圈 | DO(数字输出) | 读取开关状态、继电器状态 |
| 0x02 | 读离散输入 | DI(数字输入) | 读取按钮、限位开关 |
| 0x03 | 读保持寄存器 | 可读可写的寄存器 | 读取温度、速度、电压等参数 |
| 0x04 | 读输入寄存器 | 只读寄存器 | 读取传感器采集到的原始值 |
| 0x05 | 写单个线圈 | DO(数字输出) | 控制单个开关、继电器 |
| 0x06 | 写单个寄存器 | 可读可写的寄存器 | 修改单个参数 |
| 0x0F | 写多个线圈 | DO(数字输出) | 批量设置开关状态 |
| 0x10 | 写多个寄存器 | 可读可写的寄存器 | 批量下发参数、PID参数整定 |
实际项目里,03、06、16这三个功能码占了绝大多数流量。比如做环境监控系统,主机就是不断用03功能码轮询各从机的温湿度寄存器;做伺服电机控制,主机用06功能码把目标转速写进驱动器的寄存器;做PLC批量参数下装,用的就是16功能码。所以面试题里但凡考MODBUS,让你手写报文,大概率也是考这三兄弟。
功能码属于协议栈必须“有条件支持”的部分:从机收到不支持的或者非法的功能码时,不能直接忽略,而要返回一个异常响应。异常响应的功能码是把原功能码的最高位置1(即原功能码 | 0x80),后面再跟一个异常码说明原因。这个细节我在第5章会专门展开讲。
2.2 寄存器地址映射:设备手册和代码里的“两张地图”
MODBUS把数据分成四个区:线圈、离散输入、输入寄存器、保持寄存器。每个区都有自己的编号规则:
- 线圈:编号从00001开始
- 离散输入:编号从10001开始
- 输入寄存器:编号从30001开始
- 保持寄存器:编号从40001开始
这里有个大坑,几乎是新手必踩。很多设备手册上写的地址是PLC风格的寄存器编号,比如“温度保存在40001”,这个40001是PLC里的数据区编号,不是报文里直接发送的地址。报文里的地址是“偏移量”,也就是40001减去区起始编号:40001 - 40001 = 0x0000。所以发03功能码读温度时,报文数据域里的起始地址填的是0x0000,而不是40001。如果不理解这个换算,你会看到从机一直返回异常码02(非法数据地址),然后怀疑人生。
再补充一个容易混淆的点:有的设备手册直接给出十六进制地址0x0000,有的给出十进制40001,还有的给出“MODBUS地址=寄存器编号-1”,不同厂家习惯不一样。我的做法是拿到设备手册先看它的地址定义是“PLC地址”还是“协议地址”(也就是报文真实地址),确认清楚再写代码。这个确认过程能帮你省掉后续至少一小时的排查时间。
2.3 CRC校验:最容易出错也最容易被忽略的环节
MODBUS RTU的报文帧结构是:从机地址(1字节)+ 功能码(1字节)+ 数据域(N字节)+ CRC校验(2字节)。CRC校验的范围是从从机地址开始一直到数据域结束,不包含CRC本身。算法是CRC16-MODBUS,多项式0x8005(查表法实现时反序为0xA001),初值0xFFFF,发送时低字节在前,高字节在后。
这里有两个容易错的地方我应该强调一下。第一,CRC的数据范围千万别算错,只算地址+功能码+数据域,不要把CRC自己也算进去。第二,字节顺序一定是低字节先发,比如计算出来的CRC是0x4598,发送顺序就是0x98 0x45,很多人在串口助手里手动组帧时把这个顺序搞反,导致从机一直返回CRC错误。
下面是CRC计算的标准实现,我习惯直接用查表法,运行效率高,代码也好维护:
const uint16_t crc_table[256] = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, // ... 完整256项查表数组,此处省略,多数MODBUS库源码里能直接拷 }; uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc = crc_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); } return crc; }如果你不想维护一个256项的查表数组,也可以用逐位计算的方式,代码量更少,只不过速度稍微慢一点。对从机来说,串口波特率9600时一帧报文就几十字节,逐位计算完全够用。
uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }发送时注意顺序:
uint16_t crc = crc16_modbus(frame, len); frame[len++] = crc & 0xFF; // 低字节在前 frame[len++] = (crc >> 8) & 0xFF; // 高字节在后3. 调试环境搭建与工具选型
3.1 调试硬件链路:USB转485的坑
调试MODBUS RTU,第一件事就是把物理链路打通。最常见的就是用USB转485模块把PC和设备的RS485总线连起来。这个环节有三个特别容易踩的坑。
第一个坑是AB线接反。RS485总线用A、B两根差分线传输,设备端的A要接模块的A,B要接模块的B。很多人图省事直接把两根线一拧,结果数据完全不通,或者偶尔通一下然后又断。判断AB线是否接反,最靠谱的办法是用示波器量差分波形,正常通信时A、B之间有2V以上的压差。没有示波器的话,可以用串口助手盲试——把A、B换过来再试一次,大概率就能通。
第二个坑是共地问题。USB转485模块虽然和PC通过USB连接,但它的GND和设备的GND不一定等电位。如果两者之间电位差太大,通信就会出现随机字节错误。解决办法就是在模块和设备之间拉一根共地线。很多现场调试时设备没有专门的GND端子,可以借用屏蔽层或者电源负极,一定保证参考电平一致。
第三个坑是终端电阻。RS485总线要求在物理链路的两端各接一个120Ω的终端电阻,用来消除信号反射。但做点对点调试时,如果距离很短(一两米以内),不接终端电阻一般也能通信。距离超过几十米或者现场电磁环境复杂,就需要考虑在两端接上终端电阻。需要注意的是,电阻只能接在总线两端,不能每个设备都接,否则等效阻抗太低,驱动芯片会过载。
另外USB转485模块本身质量差异很大。便宜的模块用CH340加一颗MAX485,几十块钱,短距离调试完全够用,但高速长距离就不太稳。如果通信老是有偶发错误,换一个带隔离的工业级USB转485模块,很多问题直接消失。
3.2 软件工具选型:串口助手 vs 专业MODBUS调试工具
调试MODBUS时,我一般会准备两类软件,各有各的用途。
第一类是通用串口调试助手,比如SSCOM,这类工具适合手动组帧验证。打开软件,设置好串口号、波特率、数据位、停止位、校验位,勾选“HEX显示”和“HEX发送”,就能手动把报文按字节敲进去。比如发送01 03 00 00 00 02 98 45,然后看从机返回的01 03 04 00 FF 02 58 CF 69。这种方式的好处是你能完全掌控每一个字节,适合验证从机程序对单帧报文的解析逻辑。
第二类是专业的MODBUS调试工具,比如Modbus Poll(主机模拟)和Modbus Slave(从机模拟)。这类工具不需要手动组帧,填个从机地址、功能码、起始地址、寄存器数量,就能自动生成报文并解析响应。调试时左边看寄存器地址,右边看实时数值,非常直观。我写从机程序时,会先用Modbus Poll在PC上模拟主机,连续轮询几百次,测试从机程序在压力下的稳定性。
如果手头没有现成的Modbus Poll,用Python也能快速顶上去。pymodbus库提供了一个简单的从机模拟器,几十行代码就能跑起来。我在写主机程序时经常这样干,把PC变成一个假的MODBUS从机,验证自己主机程序的组帧和解析逻辑。
3.3 用从机模拟器快速验证你的主机代码
用从机模拟器验证主机逻辑,这个思路我做项目时反复用。原理很简单:自己的主机程序还没接到真实设备前,先在PC上起一个假的从机,把所有可能的响应情况都模拟一遍。比如正常响应、异常响应、超时不响应——三种情况分别测试。
正常响应测的是主机解析报文的正确性;异常响应测的是主机对错误码的处理逻辑,比如从机返回非法数据地址,主机能否正确提示而不是直接卡死;超时不响应模拟的是现场设备掉线的情况,看主机的超时重试机制是否生效。真实设备在现场出问题的时候,主机能不能扛得住,很大程度取决于这些异常分支有没有提前测过。
4. 从零调通一个MODBUS RTU从机:完整实操记录
4.1 需求定义与寄存器规划
用我最近做的一个采集终端项目来说吧,基于STM32,通过RS485走MODBUS RTU和PLC通信。终端负责采集温度和湿度,同时接收PLC下发的设备启停命令。硬件上就是STM32 + 温湿度传感器 + SP3485收发器,串口波特率9600,8N1。
写代码前先把寄存器规划好:
| 寄存器地址 | 内容 | 读写属性 | 说明 |
|---|---|---|---|
| 0x0000 | 温度值 | 只读 | 实际温度 = 寄存器值 / 10,单位℃ |
| 0x0001 | 湿度值 | 只读 | 实际湿度 = 寄存器值 / 10,单位%RH |
| 0x0002 | 设备状态 | 只读 | bit0表示运行状态,bit1表示故障 |
| 0x0003 | 启停控制 | 读写 | 写1启动,写0停止 |
| 0x0004 | 通信重启次数 | 只读 | 记录异常重启次数,便于现场诊断 |
为什么要把PLC和设备之间的寄存器映射表提前定死?因为现场联调时,PLC工程师和嵌入式工程师经常各干各的,如果寄存器规划不一致,双方调接口时就会发现“你读的是地址2,我写的是地址3”,电话来回打半天都扯不清。项目初期花十分钟把表格发群里,后面能省一天。
4.2 串口初始化与收发帧判断
STM32串口初始化的代码非常常规,关键是RS485方向的切换。SP3485的DE/RE引脚连到STM32的一个GPIO,发送数据时拉高,进入接收模式时拉低。很多人以为发送完调用完HAL_UART_Transmit就算完了,其实数据还在移位寄存器里没发完,立刻拉低DE会把最后一两个字节截掉。正确做法是等发送完成标志位(TC)置位后再切方向。
MODBUS RTU的帧边界靠“静默时间”来判断,规定3.5个字符时间的空闲表示一帧开始或结束,帧内字符间隔不能超过1.5个字符时间。在9600波特率下,1个字符约等于1.04ms,3.5个字符就是约3.64ms。工程上最常用的做法是开启串口接收中断,配合一个定时器,收到一个字节就重置定时器,定时器超时3.5个字符时间以上就认为一帧结束,然后去解析缓冲区里的数据。我用的就是串口空闲中断(IDLE)加接收缓冲区的方式,实测很稳。
// 串口接收中断中:每收到一个字节就存入缓冲区 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { rtu_rx_buf[rtu_rx_len++] = rtu_rx_byte; if (rtu_rx_len >= RTU_BUF_MAX) { rtu_rx_len = 0; // 溢出保护 } HAL_UART_Receive_IT(&huart2, &rtu_rx_byte, 1); } }这里有个小建议:缓冲区溢出保护一定要写。现场总线上万一出现异常干扰帧,数据特别长,缓冲区不处理会直接冲掉后面正常的数据。我的做法是长度超过最大帧长就把接收状态清零,等下一帧新的3.5字符空闲重新开始。
4.3 报文解析与响应组帧:一个简洁的从机核心
收到完整一帧后,从机程序的核心处理流程可以总结成五步:地址匹配、CRC校验、功能码分发、执行操作、组帧响应。我写了一个适合嵌入式单片机的处理框架,函数内部直接操作缓冲区,无常内存分配,非常适合裸机环境。
void modbus_rtu_slave_handle(uint8_t *frame, uint16_t len) { uint8_t addr = frame[0]; uint8_t func = frame[1]; uint16_t crc = crc16_modbus(frame, len - 2); uint16_t recv_crc = frame[len - 2] | (frame[len - 1] << 8); if (addr != SLAVE_ADDR && addr != 0x00) { return; // 地址不匹配,直接丢弃 } if (crc != recv_crc) { return; // CRC错误,丢弃 } switch (func) { case 0x03: // 读保持寄存器 read_holding_registers(frame, len); break; case 0x06: // 写单个寄存器 write_single_register(frame, len); break; default: build_exception_response(frame, 0x01); // 非法功能码 break; } }组帧响应的逻辑也很有讲究。以读保持寄存器为例,响应帧是:从机地址 + 功能码 + 字节数 + 寄存器数据 + CRC。字节数等于寄存器数量乘2。如果请求读的寄存器范围超出从机实际支持的地址范围,要返回异常码02(非法数据地址),而不是静默不处理,或者发一个半截的响应帧。工业现场的PLC主机都有严格的状态机,收到格式错误的响应可能会导致它进入异常状态甚至停机。
每种功能码的逻辑我用一个独立函数实现,不写在一起。这种拆分方式在单片机内存紧张时看起来“浪费”了几个函数跳转的开销,但调试和维护时的幸福感是直线上升的。我见过把一个从机协议栈全写在中断里、几百行用一个函数硬撑的代码,改一个寄存器的地址映射要翻半天,这种代码基本就是给自己埋雷。
4.4 用串口助手手动发包验证:一步一步对报文
从机程序烧进去之后,先用USB转485接好线,打开SSCOM串口助手,波特率设9600,勾选HEX显示和HEX发送。首先测试读保持寄存器功能。
发送读前两个保持寄存器的请求帧:
发送:01 03 00 00 00 02 98 45如果从机程序正常,并且温度寄存器当前值是25.5℃(寄存器值255即0x00FF),湿度是60.0%RH(寄存器值600即0x0258),那么正确的响应帧应该是:
接收:01 03 04 00 FF 02 58 CF 69这里我拆开说明一下:01是从机地址,03是功能码,04表示后面有4个字节数据,00 FF是温度寄存器值,02 58是湿度寄存器值,CF 69是CRC校验码。如果你收到的数据里温度、湿度的数值对不上,优先确认传感器采集有没有问题,而不要怀疑协议栈——因为只要地址对、CRC对、帧格式对,从机协议栈本身是没毛病的。
再测试写单个寄存器。用06功能码往0x0003地址写1,让设备启动:
发送:01 06 00 03 00 01 38 06这个报文的CRC需要你根据实际报文计算,我这里的38 06对应的就是01 06 00 03 00 01的CRC。写成功后从机会原封不动把请求帧回给主机(这是06功能码的标准行为),此时回帧和请求帧完全一样。如果CRC算错,从机会直接丢弃不回,串口助手那边会一直“干等”,这时候第一个要怀疑的就是CRC字节顺序。
手动发包验证完两个功能码后,再测试异常响应。比如故意发送一个不支持的功能码,比如0x07:
发送:01 07 00 00 00 01 [CRC]正常从机应该返回:
接收:01 87 01 [CRC]其中87是07 | 0x80,01是异常码,表示非法功能码。这一步测的是从机的异常处理分支,千万不要跳过。很多从机程序功能码正常分发了,异常分支却漏写,结果主机一发出未知功能码,从机就死等或者乱回,现场联调直接卡住。
5. 常见问题与排查技巧实录:调试中踩过的坑
5.1 常见问题速查表
把我在多个项目里遇到过的典型问题整理成一张表,调试的时候对照排查:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 串口助手完全收不到从机响应 | USB转485接线错误、AB反接、波特率不匹配、地址错误 | 先用示波器看总线波形,确认有无信号;再逐项核对波特率、地址、接线 |
| 收到响应但CRC校验错误 | CRC计算范围不对、高低字节反了、帧被拆包 | 在代码里加CRC日志,把收到的原始字节打出来,对照算法逐步计算 |
| 乱码或者字符错位 | 波特率不一致、校验位/停止位配置不对、地电位不共 | 重点检查串口参数是否8N1,确认两端共GND |
| 偶发性通信超时 | RS485方向切换太慢、干扰、总线无终端电阻 | 检查发送后是否等待TC标志再切方向,尝试降低波特率 |
| 从机返回异常码01 | 功能码不受支持 | 确认从机实现了该功能码分支 |
| 从机返回异常码02 | 起始地址或寄存器数量超出范围 | 核对寄存器映射表,注意PLC地址和协议地址的换算 |
| 从机返回异常码03 | 写入值非法 | 检查写入值是否在允许范围内 |
| 长距离通信不稳定 | 终端电阻缺失、线缆不是双绞线、屏蔽层未接地 | 总线两端接120Ω电阻,换屏蔽双绞线,现场加磁环 |
5.2 一个真实的“偶发超时”排查过程
有次做现场项目,设备离控制柜大概80米,PLC通过RS485读仪表数据,大概每分钟会超时一次,很规律,但不频繁。因为手头没有示波器,我先用MODBUS调试工具反复轮询,统计超时概率,发现大概1%左右。看波形不方便,就先从软件上找问题。
先怀疑方向切换。检查从机代码,发送完成后确实等TC了,不是这个问题。再怀疑CRC偶发错误,但和日志显示CRC错误率极低,基本能排除。后面怀疑物理层,才发现现场施工队用的是普通的4芯护套线,不是屏蔽双绞线,而且两个终端电阻一个都没接。在PLC端和仪表端各接了一个120Ω终端电阻,同时把总线换成了屏蔽双绞线,屏蔽层在PLC端单端接地,问题彻底消失。这次排查最大的教训是:物理层的问题不应该放在最后怀疑,尤其布线和距离都不是自己控制的时候,先确认物理层再接软件调试。
5.3 帧间隔和轮询时序:主机别“说话太快”
还有一类问题不是出在单个报文的正确性上,而是出在主机发送的节奏上。MODBUS RTU要求主机在发完一帧后等从机处理完,从机回复前总线上必须保持3.5个字符时间的静默间隔。有些从机程序处理比较慢(比如写Flash操作要几十毫秒),如果主机发完请求立刻发下一帧,从机可能还在处理上一条,缓冲区就直接被新帧覆盖了。
现场联调时如果遇到数据更新慢或者偶发无响应,先看看主机的轮询周期设置是否合理。比如写EEPROM或Flash这类耗时操作,建议从机先做判断:如果当前还在忙,新的请求可以暂时不响应或者返回忙状态。而主机这边,轮询周期和命令间隔要留足余量,最稳妥的做法是在一帧请求发出后,至少等上50-100ms再发下一帧(具体看从机处理耗时)。这个问题在写PC上位机和PLC程序时都容易踩,因为PC发数据速度太快,潜意识里会认为从机“肯定能处理完”。
5.4 串口助手里看不到十六进制报文怎么办
这个问题几乎每个新手都会遇到。串口助手默认按ASCII显示,发出来的MODBUS报文看起来就是一堆乱码字符。解决办法很简单:发送和接收都勾选“HEX”或者“十六进制”显示,这样一帧报文就像01 03 00 00 00 02 98 45这样清晰可见了。如果用的调试工具没有HEX选项,建议直接换工具,SSCOM、友善串口助手这些都支持。
另外调试时最好把串口助手的“时间戳”打开,这样你能看到每一帧之间的间隔。MODBUS RTU对时间间隔有要求,相邻两帧之间必须保持至少3.5个字符时间的静默。如果你看到两帧几乎连在一起,中间没有间隔,说明发送方组帧的逻辑有问题,需要检查帧边界判定。
写在最后:一个不算技巧的技巧
最后分享一个我自己的习惯。凡是写MODBUS协议栈,我都会先在PC上把它调通,再接到真实设备上。具体做法是:从机程序先用Modbus Slave或者自己写的Python脚本模拟,主机程序用Modbus Poll模拟,两边先在PC上把协议逻辑跑通;然后再把从机程序烧到真实的STM32板子上,用USB转485接PC,跑一轮自动化测试,把读保持寄存器、写单个寄存器、写多个寄存器这些命令各发一遍,确认CRC和响应帧都对,再拉到现场跟PLC联调。
这套“先在PC上验证逻辑,再上板验证硬件”的流程,能把“我的代码问题”和“现场设备问题”分开,排查起来会轻松很多。现场联调最怕的就是两边同时猜是不是对方的锅,结果互相看半天也定位不了问题。先把协议栈本身验证到足够稳,再面对现场各种奇怪的物理层问题,你会发现自己省下来的时间不是一星半点。希望这篇笔记能帮你少踩几个坑,剩下的事情就交给你的示波器和串口助手了。