
1. 从一根RS485线说起Modbus到底在解决什么问题如果你手上有一块STM32或者STC51的板子想跟PLC、变频器、电表、温控器、扫码枪这些东西通信大概率绕不开Modbus。很多人第一次接触它的时候脑子里全是问号为什么地址有0开头的也有1开头的功能码03和04到底差在哪CRC校验怎么算出来跟别人对不上主站发出去的报文从站为什么装死不回我当年第一次调Modbus RTU用电脑串口助手发了一帧读保持寄存器的报文从站一点反应都没有。折腾了一下午才发现我把从站地址写成了0而广播地址0在Modbus里是不允许从站回复的。这种坑文档里不会专门提醒你但实际调试中一踩一个准。Modbus本质上是一个应用层报文协议它规定了数据以什么样的格式在设备之间传递。它不关心你底下跑的是RS232、RS485还是TCP只要双方约定好帧格式就能互相理解。这也是它能活到今天、几乎成了工业设备普通话的原因——简单、开放、够用。这篇文章我会从协议结构、功能码、报文格式、CRC校验、地址映射、主从通信实战这几个角度把Modbus RTU讲透。适合刚入门的嵌入式开发者、工控调试人员以及需要自己封装Modbus通信的软件工程师。看完你至少能做到手算CRC、看懂任意一帧报文、自己写一个能跑通的主站或从站。2. Modbus RTU的报文骨架每一字节都有它的位置2.1 一帧报文长什么样Modbus RTU的一帧数据结构非常紧凑没有多余的包头包尾靠的是帧间静默时间来区分帧与帧。标准规定帧与帧之间至少要有3.5个字符时间的空闲帧内字符间隔不能超过1.5个字符时间。这个规则在波特率9600下3.5个字符大约是4ms左右。一帧完整的RTU报文由四部分组成字段长度说明从站地址1字节0x01~0xF70为广播功能码1字节决定操作类型数据域N字节地址、数量、值等CRC校验2字节低字节在前高字节在后举个实际例子主站要读从站1的保持寄存器起始地址0读2个寄存器报文是01 03 00 00 00 02 C4 0B拆开看01是从站地址03是功能码00 00是起始地址00 02是寄存器数量C4 0B是CRC。从站正常回复01 03 04 00 0A 00 14 XX XX01地址03功能码04表示后面有4个字节数据00 0A和00 14是两个寄存器的值最后两字节是CRC。注意CRC在RTU报文里是低字节在前、高字节在后跟数据域里寄存器值的高字节在前正好相反。这个细节我第一次写CRC发送函数时搞反了导致从站一直不响应。2.2 为什么RTU比ASCII更常用Modbus有两个串行传输模式RTU和ASCII。ASCII模式每个字节用两个ASCII字符表示比如0x01变成01两个字符报文长度翻倍但好处是可读性强用普通串口助手就能看懂。RTU模式直接传二进制效率高同样的数据量传输时间更短。实际项目里RTU占了绝大多数。原因很直接工业现场对实时性有要求ASCII模式在9600波特率下传一帧要几十毫秒RTU只要十几毫秒。而且RTU的CRC校验比ASCII的LRC校验更可靠。除非你的调试环境特别受限否则没有理由选ASCII。2.3 帧间隔的坑为什么你的从站偶尔不响应帧间静默这个机制在低速单片机上特别容易出问题。我见过一个案例主站用STM32从站用STC51波特率9600大部分时候通信正常但每隔几十帧就丢一帧。后来用示波器抓波形才发现主站发送完一帧后因为中断优先级问题下一个字节提前了几百微秒发出来从站还没判断出帧结束就把两帧当成一帧处理了。解决办法有两个一是主站发送时用DMA加发送完成中断确保帧间有足够间隔二是从站用定时器做超时判断收到一个字节后启动定时器超过3.5个字符时间没收到新字节就认为一帧结束。第二种方法更稳妥因为从站无法控制主站的发送节奏。3. 功能码Modbus的动词体系3.1 常用功能码速查功能码决定了这帧报文要干什么。Modbus定义的功能码范围是1~127其中1~65是标准功能码65~72是用户自定义73~119是保留128~255用于异常响应。实际项目里最常用的就那几个功能码名称操作对象数据单位01读线圈可读写位位02读离散输入只读位位03读保持寄存器可读写字16位字04读输入寄存器只读字16位字05写单个线圈可读写位位06写单个寄存器可读写字16位字15写多个线圈可读写位位16写多个寄存器可读写字16位字功能码02和04是只读的对应的是设备出厂就固定的状态量比如温度传感器的测量值、限位开关的状态。功能码01和03是可读写的对应的是可以修改的参数比如设定温度、输出频率。3.2 功能码03和04的区别不只是读写权限很多人以为03和04的区别就是可读写和只读其实在协议层面它们访问的是两个完全独立的地址空间。一个设备可以同时有保持寄存器和输入寄存器地址都是从0开始编互不冲突。我调试过一台汇川的变频器它的频率设定值放在保持寄存器地址是0x1000实际输出频率放在输入寄存器地址也是0x1000。如果你用03去读0x1000拿到的是设定值用04去读同一个地址拿到的是实际值。这个设计在协议上是合法的但第一次遇到很容易懵。实操建议拿到一个新设备先看它的Modbus地址映射表确认每个参数属于哪个寄存器区。如果文档没写清楚用03和04分别读一遍同一个地址对比结果就能判断。3.3 异常响应从站装死之外的另一种回答从站如果收到一帧报文但无法正常处理它会返回一个异常响应。格式是功能码最高位置1后面跟一个异常码。比如主站发01 03 00 00 00 02 CRC从站如果发现起始地址不合法会回复01 83 02 XX XX83就是03 | 0x80表示这是功能码03的异常响应02是异常码表示非法数据地址。常见的异常码有01非法功能码从站不支持这个功能02非法数据地址地址超出范围03非法数据值数据域里的值不合法04从站设备故障05确认从站正在处理需要主站稍后重试06从站忙看到异常响应不要慌它比从站完全不回复要好得多至少说明从站收到了你的报文只是内容有问题。根据异常码去查地址映射表或者检查数据范围通常能快速定位。4. CRC校验手算一遍就再也不会忘4.1 CRC-16/MODBUS的计算逻辑Modbus用的是CRC-16多项式是0xA001这是0x8005的位反转形式初始值0xFFFF输入输出都不反转。它的计算过程是逐字节异或加移位听起来抽象但手算一遍就清楚了。以报文01 03 00 00 00 02为例计算步骤CRC初始值设为0xFFFF第一个字节0x01与CRC低字节异或0xFFFF ^ 0x01 0xFFFE判断最低位如果是0就右移一位如果是1就右移一位后再异或0xA001重复8次处理完一个字节对下一个字节重复上述过程直到所有字节处理完最终CRC值低字节在前高字节在后这个过程用代码实现更直观uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return crc; }算出来的结果低字节先发高字节后发。上面那帧报文的CRC是0x0BC4所以发送顺序是C4 0B。4.2 查表法从站响应速度的优化点逐位计算CRC每个字节要循环8次一帧报文如果有几十个字节在低速单片机上会占用不少时间。如果从站对响应速度有要求可以用查表法预先算好256个字节对应的CRC值运行时直接查表。static const uint16_t crc_table[256] { /* 预计算值 */ }; uint16_t modbus_crc16_fast(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc (crc 8) ^ crc_table[(crc ^ *buf) 0xFF]; } return crc; }查表法比逐位计算快大约5到8倍代价是占用512字节的Flash空间。对于资源紧张的STC51如果Flash够用建议用查表法如果实在紧张逐位计算也能满足大部分场景。4.3 CRC校验失败的排查思路CRC对不上通常有三个原因字节顺序搞反CRC低字节在前但有些人习惯性地按高字节在前发送计算范围错误CRC只计算从站地址到数据域结束不包括CRC本身字节丢失或多余串口接收中断丢字节或者把帧间空闲字节也算进去了我排查CRC问题的习惯是先用在线CRC计算工具算一遍标准报文的CRC确认自己的算法没问题然后用串口助手抓实际发送的字节跟预期报文逐字节对比。十有八九是字节顺序或者计算范围的问题。5. 地址映射0和1的战争5.1 为什么会有0基和1基两种地址Modbus协议本身定义的地址是从0开始的。功能码03读保持寄存器起始地址0表示第一个寄存器。但在很多PLC和组态软件的文档里你会看到40001、40002这样的地址这是Modicon编号体系把不同寄存器区用数字前缀区分寄存器区地址范围对应功能码线圈00001~0999901, 05, 15离散输入10001~1999902输入寄存器30001~3999904保持寄存器40001~4999903, 06, 1640001对应的协议地址是040002对应1以此类推。所以当你看到文档写保持寄存器40001实际发报文时起始地址要填0。这是新手最容易踩的坑文档写40001你报文里填40001从站直接返回异常码02。记住一个换算公式协议地址 Modicon地址 - 40001保持寄存器。5.2 不同设备的地址映射表能通用吗不能。Modbus只规定了通信格式没有规定地址映射。每个设备厂商可以自由决定哪个参数放在哪个地址。同样是读电压A品牌的电表可能放在0x0000B品牌可能放在0x0010。我手上有一份汇川Easy系列PLC的Modbus从站地址表它的线圈区从0x0000开始保持寄存器区从0x1000开始输入寄存器区从0x2000开始。而另一台台达的温控器保持寄存器直接从0x0000开始没有分区。如果你拿汇川的地址去读台达肯定读不到东西。所以每次对接新设备第一件事就是找它的Modbus通信手册把地址映射表整理出来。如果手册找不到可以用Modbus Poll这类调试工具从地址0开始逐个扫描看哪些地址有响应、值是否合理。5.3 地址扫描的实用技巧用Modbus Poll扫描地址时不要一个一个读效率太低。可以一次读多个寄存器比如从0开始读10个看返回的数据里哪些是非零的。但要注意有些设备对越界地址会返回异常导致整帧读取失败。这时候可以缩小范围或者用功能码03和04分别试。我一般会先读0~9这10个地址如果返回异常说明起始地址0可能不合法换成1再试。如果返回正常但数据全是0可能是地址偏移不对试试从100、1000、10000这些常见起始地址开始扫。6. 主从通信实战从零跑通一帧报文6.1 硬件连接RS485的A和B不要接反Modbus RTU最常用的物理层是RS485两线制A和B一对差分信号。接线很简单但接反了就是收不到数据。我见过太多人因为A、B接反调了半天以为是软件问题。RS485的A通常对应差分正B对应差分负。但不同厂商的标注可能相反有的标A、B-有的标D、D-。最稳妥的办法是先按A接A、B接B接好如果通信不上把A和B对调再试。不会烧设备放心试。另外RS485总线两端要接终端电阻通常是120欧姆。短距离通信几米以内不接也能凑合但超过几十米或者波特率较高时不接终端电阻会导致信号反射通信误码率飙升。6.2 主站发送流程从组帧到等待响应以STM32为例主站发送一帧读保持寄存器的报文流程是组装报文从站地址 功能码03 起始地址高字节 起始地址低字节 数量高字节 数量低字节计算CRC追加到报文末尾拉低RS485的DE引脚切换到发送模式逐个字节发送发送完成后拉高DE引脚切换回接收模式启动超时定时器等待从站响应超时时间怎么定标准没有强制规定但一般建议根据波特率和报文长度估算。9600波特率下一个字节大约1ms从站处理一帧加上回复通常几十毫秒内完成。我一般设200ms超时超过就认为从站没响应重试或者报错。void modbus_master_send(uint8_t addr, uint8_t func, uint16_t reg, uint16_t num) { uint8_t buf[8]; buf[0] addr; buf[1] func; buf[2] reg 8; buf[3] reg 0xFF; buf[4] num 8; buf[5] num 0xFF; uint16_t crc modbus_crc16(buf, 6); buf[6] crc 0xFF; buf[7] crc 8; RS485_DE_HIGH(); // 切换发送 for (int i 0; i 8; i) { uart_send_byte(buf[i]); } while (!uart_tx_complete()); RS485_DE_LOW(); // 切换接收 }6.3 从站响应流程中断接收加超时判帧从站的核心是正确判断一帧的开始和结束。我的做法是串口接收中断里每收到一个字节就存入缓冲区同时重置一个定时器。定时器设为3.5个字符时间如果定时器超时了还没收到新字节就认为一帧结束开始处理。处理流程检查从站地址是否匹配广播地址0除外检查CRC是否正确解析功能码执行对应操作组装响应报文计算CRC切换RS485为发送模式发送响应切换回接收模式等待下一帧从站处理异常时要返回异常响应而不是沉默。比如功能码不支持返回地址 (功能码|0x80) 0x01 CRC。这样主站能明确知道问题所在。6.4 调试工具Modbus Poll和Modbus Slave的配合使用调试阶段我强烈建议用Modbus Poll模拟主站Modbus Slave模拟从站先把协议跑通再上真实设备。Modbus Poll可以设置从站地址、功能码、起始地址、寄存器数量还能实时显示收发报文。Modbus Slave可以模拟一个从站设置各个寄存器的值观察主站读到的数据是否正确。一个实用技巧在Modbus Poll里打开Display菜单勾选Communication Traffic可以看到每一帧的原始字节。对比你自己代码发出的报文逐字节核对能快速定位问题。注意Modbus Poll和Modbus Slave是付费软件有30天试用期。如果只是临时调试试用期够用。长期使用建议购买授权或者用开源的QModMaster替代。7. 那些文档不会告诉你的实战经验7.1 广播地址0的陷阱从站地址0是广播地址主站发广播帧时所有从站都会执行但不会回复。这个特性常用于同时给多个从站下发参数。但如果你不小心把从站地址设成了0主站发读请求从站执行了但不回复主站就会一直超时。我遇到过一个案例客户把从站地址拨码开关全拨到了0以为是地址1结果主站读不到数据。后来查手册才知道拨码全0对应广播地址从站不回复。把拨码改成00000001就好了。7.2 寄存器数量超限的异常功能码03一次最多读125个寄存器功能码01一次最多读2000个线圈。超过这个数量从站会返回异常码03。有些设备支持的数量更少比如只支持一次读10个寄存器。如果你一次读太多从站直接拒绝。稳妥的做法是分批读每次读不超过设备手册规定的最大数量。如果手册没写从10个开始试逐步增加找到上限。7.3 字节序和字序的混乱Modbus寄存器是16位的但很多参数是32位浮点数或者32位整数需要两个寄存器拼起来。问题来了高字在前还是低字在前高字节在前还是低字节在前Modbus标准没有规定32位数据的字节序完全由设备厂商决定。我见过四种组合高字在前高字节在前大端低字在前低字节在前小端高字在前低字节在前混合低字在前高字节在前混合对接新设备时如果读到的浮点数明显不对比如应该是220.5读出来是-0.0003大概率是字节序问题。用Modbus Poll的Float显示模式切换不同的字节序组合看哪个能读出合理值。7.4 通信距离和波特率的取舍RS485理论上能传1200米但那是理想条件。实际项目中波特率越高可靠传输距离越短。9600波特率下几百米没问题115200波特率下可能几十米就开始丢包。如果通信距离远优先降波特率而不是加中继器。降波特率对实时性的影响通常比加中继器带来的故障点更可控。我做过一个项目现场总线大概300米一开始用19200偶尔丢帧降到9600后连续跑了一周零误码。7.5 从站响应慢导致的超时有些从站设备处理一帧报文需要几十甚至上百毫秒比如某些电表要完成一次测量才回复。如果主站超时设得太短就会误判为无响应。我的经验是先用Modbus Poll手动发一帧看从站多久回复然后把这个时间乘以2作为超时值。如果从站响应时间波动大再适当放宽。宁可超时设长一点也不要频繁重试重试太多反而会加重总线负载。8. 从RTU到TCP协议变体的一点延伸Modbus RTU跑在串口上Modbus TCP跑在以太网上。TCP模式去掉了CRC校验因为TCP本身有校验增加了7字节的MBAP头包含事务标识、协议标识、长度和单元标识。如果你已经理解了RTU的报文结构TCP模式基本就是去掉CRC加个头。功能码、地址映射、异常响应这些核心概念完全一样。很多Modbus网关设备就是做RTU和TCP之间的转换把串口报文封装成TCP包发出去或者反过来。实际项目中如果设备支持以太网优先用Modbus TCP调试方便用Wireshark就能抓包分析。如果只有串口那就老老实实调RTU。两者不是替代关系而是适应不同物理层的同一套应用层协议。我在实际使用中发现Modbus的坑大多不在协议本身而在设备厂商的实现差异上。同样一个功能码03有的设备返回数据后CRC正确有的设备CRC字节序反了有的设备地址从0开始有的从1开始。所以每次对接新设备先用调试工具跑一遍把它的脾气摸清楚再写代码能省下大量返工时间。