1. 为什么嵌入式工程师绕不开MODBUS这台老式收音机
我做嵌入式调试这些年,接触过的总线协议少说也有十几二十种:I2C、SPI、CAN、EtherCAT、Profinet……但有一个协议,它论速度不是最快的,论功能不是最强的,论优雅程度甚至排不上号,却在工业现场活了几十年,至今还在PLC、传感器、电表、阀门、变频器、触摸屏里到处出现——没错,就是MODBUS。
如果你刚入行,可能觉得奇怪:都什么年代了,为什么还要学这么"古老"的东西?原因其实很现实:不是因为它技术上有多先进,而是因为它足够便宜、足够简单、足够容易实现。一个MCU只要有一路UART,外加一颗RS485收发芯片,几十行代码就能实现一个支持多设备通信的从站。工程师想调试它,一台电脑、一个USB转串口模块、一个串口调试助手就够用了——不需要示波器,不需要逻辑分析仪,不需要任何专用工具。这种"零门槛"恰恰是它在工业现场不可替代的原因。
这篇文章是我嵌入式调试笔记系列的第七篇,围绕MODBUS协议本身展开。我会把RTU模式下完整的报文结构、功能码含义、寄存器模型、CRC校验规则这些底层细节拆开讲清楚,然后给你一套可以直接抄走的从机程序框架,再演示怎么用串口调试助手手动组帧、模拟主站、抓包回放,最后复盘我实际调试中踩过的几个坑。无论是写设备端固件、做上位机联调,还是纯粹在产线上用串口助手查问题,这篇笔记都适用。
为了让你有直观概念,先给一个最简单的调度场景:现场有一台温控器(从站,站号1),PLC(主站)要读它的当前温度值(保持寄存器地址40001),MODBUS RTU的请求帧就是这样的:
01 03 00 00 00 01 84 0A其中01是从站地址,03是功能码(读保持寄存器),00 00是起始寄存器地址(协议地址从0开始),00 01是读取数量,84 0A是CRC16校验。这一帧一共8个字节,完成一次完整的读取。后面你会看到,整篇笔记其实就是围绕这几段字段的拆解和展开。
先说清楚三件事。第一,MODBUS分RTU、ASCII和TCP三种模式,嵌入式串口通信里RTU占绝对统治地位,所以本文默认说MODBUS就是指MODBUS RTU,涉及TCP的我会单独标注。第二,MODBUS是主从式协议,总线上同一时刻只能有一个主站发起请求,从站不能主动说话——理解了这句话,后面很多调试坑都能想明白。第三,MODBUS的"寄存器地址"和"数据区编号"之间存在偏1关系,这是初学者乃至部分老手都容易犯迷糊的地方,我也会专门讲。
2. MODBUS的底层骨架:报文结构、寄存器模型与功能码
2.1 RTU报文就是一问一答的"对讲机"
MODBUS RTU的报文结构可以用一种很直白的方式理解:主站和从站之间是一次对讲机式的通话。主站拿起对讲机喊一嗓子(请求帧),点名某个从站(地址字段),然后说清楚要干什么(功能码),附带干活的具体参数(数据字段),最后喊一句"完毕"(校验字段)。从站听到后,如果发现是在叫自己,就回一声(响应帧),告诉主站干得怎么样。
标准RTU请求帧的格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 地址 | 1字节 | 从站地址,有效范围1~247,0用于主站广播 |
| 功能码 | 1字节 | 1~255,具体含义见下表 |
| 数据 | N字节 | 寄存器地址、数量、数据值等,取决于功能码 |
| CRC16 | 2字节 | 低字节在前、高字节在后,对地址、功能码、数据所有字节做校验 |
这里有两个细节容易被轻视。第一,从站地址0是广播地址,主站发到地址0时所有从站都会收,但从站不回复。第二,CRC是低字节在前。很多人在PC端组帧时算出来的CRC是0x0A84,但实际发到串口上必须先发84再发0A。你看到的那些抓包工具里显示为84 0A的顺序,就是标准传输顺序。
响应帧稍微有点变化。正常响应中,数据字段包含实际数据(如寄存器值);异常响应中,功能码的最高位置1,数据字段放错误码。比如主站询问功能码03,从站如果返回83 04,就表示"你让我读保持寄存器,但我处理的时候就出错了,错误码04表示非法数据地址"。
2.2 寄存器模型:线圈、离散量、保持寄存器、输入寄存器
MODBUS把从站设备里的数据划分成四个区,这在协议里叫作"数据模型"。理解这个模型特别关键,因为你写上位机或者调设备的时候,地址对照错了,功能码就全乱了。
| 数据区 | 区域编号 | 位/字 | 读写特性 | 常见用途 |
|---|---|---|---|---|
| 线圈(Coil) | 0区 | 位 | 可读可写 | 继电器输出、启停控制 |
| 离散输入(Discrete Input) | 1区 | 位 | 只读 | 限位开关、按钮状态 |
| 输入寄存器(Input Register) | 3区 | 16位字 | 只读 | 模拟量输入、传感器采集值 |
| 保持寄存器(Holding Register) | 4区 | 16位字 | 可读可写 | 设定参数、运行累计值、可修改配置 |
你注意编号:功能区编号是0、1、3、4,没有2区。原因是2区在历史上曾经被定义过一些用途,但后来MODBUS规范里没有采用完整独立的对应关系,实际产品中2区基本不露面。所以你在设备手册里看到"3区、4区",对应就是输入寄存器和保持寄存器。
再强调一遍偏1问题。PLC的组态软件里,通常把保持寄存器的地址写成"40001",这个叫PLC地址;但实际在协议报文里,寄存器地址字段是"0000"。也就是说,协议地址 = PLC地址 - 1。你要读40001,报文的寄存器地址就得写0x0000。如果你直接拿着40001的十进制转十六进制(0x9C41)填进报文里,从站返回错误码04(非法数据地址)几乎是必然的。这个坑我见过无数人踩,包括当年的我自己。
2.3 常用功能码:不是每个码都要背,但这8个得门儿清
MODBUS官方定义的功能码有几十个,但实际嵌入式设备上常见的就下面几张"牌":
| 功能码 | 名称 | 对应数据区 | 用途 |
|---|---|---|---|
| 0x01 | 读线圈 | 0区 | 读开关输出状态 |
| 0x02 | 读离散输入 | 1区 | 读开关输入状态 |
| 0x03 | 读保持寄存器 | 4区 | 读参数或运行数据 |
| 0x04 | 读输入寄存器 | 3区 | 读传感器采集值 |
| 0x05 | 写单个线圈 | 0区 | 控制单个继电器/开关 |
| 0x06 | 写单个保持寄存器 | 4区 | 修改单个参数 |
| 0x0F | 写多个线圈 | 0区 | 批量控制多个开关 |
| 0x10 | 写多个保持寄存器 | 4区 | 批量修改参数 |
读操作基本围绕0x03和0x04展开,写操作最常用0x06和0x10。0x0F用得相对少,但批量控制场景下会用,报文格式是地址、功能码、起始地址、数量、字节数、N个字节的数据、CRC,比如向站1写2个线圈(地址0和1都为ON):
01 0F 00 00 00 02 01 03 11 0F最后一字节11 0F是CRC。你可以看出发送数据是03(二进制0000 0011,表示线圈0和线圈1都为1)。单个线圈在MODBUS里的标准值是0xFF00表示ON、0x0000表示OFF,但批量写线圈时用的是位映射,每个开关占1位,最后一字节剩余位补0。这种"单点用值、批量用位"的差异,也是新手容易晕的地方。
2.4 从站错误码的快速解读
异常响应里,从站会返回错误码,通常意义如下:
| 错误码 | 含义 | 常见触发原因 |
|---|---|---|
| 01 | 非法功能码 | 从站固件不支持该功能 |
| 02 | 非法数据地址 | 寄存器地址超出范围,或者起始地址+数量越界 |
| 03 | 非法数据值 | 写入值超出范围,比如把一个4位数写进温度设定值 |
| 04 | 从站设备故障 | 从站内部处理出错、硬件异常 |
调试时如果收到83 02,第一反应应该是"我读的寄存器地址是不是越界了"。收到90 01(功能码0x10读保持寄存器?不对,0x10的错误响应是0x90),就检查"功能码本身它支不支持"。这个映射关系记牢,排查效率能提高一半。
3. 从零手写一套MODBUS RTU从机:状态机、缓存与CRC实现
3.1 为什么从机程序适合用串口接收中断+超时定时器
设备端实现MODBUS RTU从机,核心问题只有一个:怎么判断一帧报文结束。MODBUS RTU没有帧头标志,没有帧尾定界符,协议规范里明确要求:报文内字节间隔不能大于1.5个字符时间,帧与帧之间的间隔必须大于3.5个字符时间。也就是说,接收端要么靠"超过1.5T35后认为一帧结束",要么靠"收到至少3.5T35的空闲认为前面的数据是完整一帧"。
在实际代码里,常见的做法是串口接收到首个字节时启动一个定时器,定时时间设为比如9600波特率下3.5个字符时间。每次收到新的字节都重置定时器,一旦定时器超时,就说明总线已经安静了足够长时间,当前缓冲区的数据就是一帧完整报文。这个做法实现简单,而且能天然处理主站连续发两帧的情况。
我个人的经验是:从设备端建议直接用串口接收中断 + 一个5ms或10ms的软件定时器,而不是用空闲中断或DMA半满中断。虽然STM32的串口空闲中断(IDLE)处理看起来更"高端",但不同芯片的IDLE行为有差异,调试时容易踩雷。用通用定时器,逻辑直白,移植到任何MCU平台上都能跑。9600波特率下一个字符大约1.04ms,一个字节含起始位、8个数据位、1个停止位共10位,所以一个字符时间约1.04ms,3.5字符时间约3.65ms,取整数5ms比较稳妥;如果是115200波特率,一个字符约0.087ms,3.5字符约0.3ms,软件定时器取1ms~2ms也够用。
3.2 状态机框架:接收、解析、应答三步走
下面给一个完整可用的从机逻辑框架,以STM32平台为例(伪代码风格,可平移到任何MCU)。整个从机处理流程分成三步:接收、解析、应答。接收在中断里做,解析和应答在主循环或单独任务里做。
接收部分:
// 串口接收中断 void USART_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t ch = USART_ReceiveData(USART1); modbus_rx_buf[modbus_rx_len++] = ch; if (modbus_rx_len >= MODBUS_RX_BUF_SIZE) modbus_rx_len = 0; // 防止溢出,简单粗暴地丢掉重收 // 重置超时定时器 modbus_timer_count = 0; } }定时器部分:
// 1ms或5ms的定时中断 void Tim_IRQHandler(void) { if (modbus_timer_count >= MODBUS_FRAME_GAP_MS) { modbus_frame_ready = 1; // 一帧接收完成 modbus_timer_count = 0; } else { modbus_timer_count++; } }主循环解析与应答:
void modbus_poll(void) { if (modbus_frame_ready) { uint8_t addr = modbus_rx_buf[0]; uint8_t func = modbus_rx_buf[1]; // 1. 地址匹配:只有收到自己的地址才回复 if (addr != MODBUS_SLAVE_ADDR && addr != 0x00) { modbus_frame_ready = 0; modbus_rx_len = 0; return; } // 广播帧(地址0)执行命令但不回复 // 2. CRC校验:注意先比较CRC还是先解析数据? // 建议先校验,CRC不对直接丢弃,不构造响应 uint16_t crc_calc = modbus_crc16(modbus_rx_buf, modbus_rx_len - 2); uint16_t crc_recv = (modbus_rx_buf[modbus_rx_len - 2]) | (modbus_rx_buf[modbus_rx_len - 1] << 8); if (crc_calc != crc_recv) { modbus_frame_ready = 0; modbus_rx_len = 0; return; } // 3. 按功能码分发 switch (func) { case 0x03: modbus_handle_read_holding(); break; case 0x04: modbus_handle_read_input(); break; case 0x06: modbus_handle_write_single(); break; case 0x10: modbus_handle_write_multiple(); break; default: // 功能码不支持,回异常码01 modbus_send_exception(func | 0x80, 0x01); break; } modbus_frame_ready = 0; modbus_rx_len = 0; } }这段代码里我特意把CRC校验放在解析前面,这是经验之谈。如果CRC错误你还去做地址和功能码解析,可能在日志里输出一堆"非法功能码"之类的误导信息,把问题引到错误方向。先校验CRC,确保这帧数据是完整的、可信的,再做业务处理,整个系统调试起来会清爽很多。
3.3 CRC16的实现与自测方法
MODBUS RTU的CRC本质是对全部字节做循环冗余校验,生成多项式是x^16 + x^15 + x^2 + 1,即多项式值0xA001(反位序后)。可以用查表法加速,也可逐位计算。我贴一个逐位实现的版本,方便新手看懂原理:
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 bit = 0; bit < 8; bit++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }这个函数返回的CRC,就是标准的MODBUS CRC16值。发送时先发低字节(crc & 0xFF),再发高字节(crc >> 8)。自测方法很关键:拿着前面那帧报文01 03 00 00 00 01手动算一遍CRC,你应该得到0x0A84,发送顺序是84 0A。用你自己的串口助手发这个帧给从站,从站如果正常响应,说明收发链路通了。
3.4 寄存器映射表设计
从机固件里,寄存器地址到实际数据的映射一般用一张表完成。建议用一张静态结构体数组来维护:
typedef struct { uint16_t addr; // 协议寄存器地址 uint8_t access; // 读写权限:REG_READ_ONLY / REG_READ_WRITE / REG_WRITE_ONLY uint16_t min_value; // 写入值下限 uint16_t max_value; // 写入值上限 uint16_t *data_ptr; // 指向实际存储变量 } reg_entry_t; static uint16_t g_temperature_value = 0; static uint16_t g_setpoint_value = 250; static const reg_entry_t g_reg_table[] = { {0x0000, REG_READ_ONLY, 0, 0xFFFF, &g_temperature_value}, {0x0001, REG_READ_WRITE, 0, 10000, &g_setpoint_value}, // ... };收到0x03读保持寄存器请求时,遍历表找地址;收到0x06写请求时,需要检查权限和写入值范围,超限就回错误码03。这样做的好处有三个:一是代码结构清晰,加一个新寄存器就加一行表;二是权限和范围校验统一,不容易漏;三是调试时打印整个表就能看到全部映射关系。
4. 用串口调试助手模拟主站:手把手组帧、抓包、回放
4.1 为什么我先不用MODBUS调试软件
网上有大量专门的MODBUS调试工具,比如ModbusPoll、Modbus Slave、Docklight这些,功能确实很强。但我还是强烈建议你先学会用串口调试助手手动组帧。原因很朴素:调试工具帮你封装好了地址转换、CRC计算、报文解析,你反而看不见底层发生了什么。等你在现场遇到一个不响应报文或者回复异常码的设备,手边又没有专用工具时,唯一能救你的就是裸的串口助手加你的脑子和笔。
我用得最多的串口调试助手是SSCOM,界面简洁、支持HEX发送和显示,免费且不需要安装。其他像AccessPort、格西烽火、SerialPortPlot也行,只要具备三个能力就行:能按HEX字节发送、能按HEX字节显示接收、能手动输入任意字节序列。反正核心逻辑都一样。
4.2 第一个压测案例:读取温控器的当前温度
假设现场有一台温控器,站号01,当前温度寄存器是40001(协议地址0x0000),支持功能码03。我们要做的请求就是文章开头那一帧:
01 03 00 00 00 01 84 0A在SSCOM里的操作步骤如下:
- 串口参数选择:波特率9600、数据位8、停止位1、无校验、无流控。注意绝大多数默认MODBUS RTU配置是8N1,少数设备出厂是8E1或8O1,具体看设备手册。
- 勾选"HEX显示"和"HEX发送",不然你输入的
01会被当成ASCII字符'0'和'1'发送,报文直接烂掉。 - 发送区输入
01 03 00 00 00 01 84 0A(空格不影响HEX输入的解析)。 - 点击发送,观察接收区。
正常响应应该是:
01 03 02 0A 7B 8C 3F拆解一下:01是站号,03是功能码,02是后面数据字节数,0A 7B是温度值(十六进制0x0A7B = 2683),8C 3F是CRC。如果温度值带小数或补码,就要根据设备手册再做换算,比如乘以0.1得到268.3摄氏度。
如果接收区回的是:
01 83 02 C0 F1那说明从站明确告诉你:03功能码请求中的寄存器地址或数量越界。这时候你就该回头查自己的寄存器地址有没有算错——大概率是你把PLC地址40001直接当成协议地址用了。
如果接收区完全没反应,那就是另一个问题,接下来讲排查链路。
4.3 写单个寄存器怎么组帧
写单个保持寄存器用功能码06,帧格式是地址、功能码、寄存器地址、数据值、CRC。比如把站号01的设备地址0x0001的值写成100(0x0064):
01 06 00 01 00 64 19 CA正常响应会把请求帧原样返回,这在调试时非常有价值:如果响应帧和请求帧一模一样,说明从站已经认定数据写成功了。如果从站返回01 86 03 02 0C,则说明功能码05/06请求中数据值非法——比如你要写温度设定值,但值超过了固件里定义的上限。
4.4 连续读和批量写的报文格式
连续读多个保持寄存器用功能码03,寄存器数量字段写N。比如读地址0x0002开始的3个寄存器:
01 03 00 02 00 03 85 CA响应帧会一次性返回6个字节的数据值(3个寄存器各2字节)。批量写用功能码10,比如往0x000A和0x000B两个寄存器分别写0x1111和0x2222:
01 10 00 0A 00 02 04 11 11 22 22 B4 A6中间那个04是"数据总字节数"。注意计算CRC时要把整条数据都算进去,包括数量、字节数这些中间字段,很多人在这一步把CRC算错,函会一直报CRC错误。
4.5 用回环法验证PC端组帧工具
如果你用ModbusPoll这类工具自动组帧,怎么验证它没算错?方法很简单:拿一台你确定没问题的从站设备(或者你刚写完的自制从机),轮询读一个已知寄存器,同时在PC端用串口镜像软件(或者干脆把主站发的原始串口数据抓到示波器/逻辑分析仪上)看实际发出的报文。我一般用逻辑分析仪挂在RS485收发芯片的RO引脚上,抓PC发出的原始电平,再对照工具填写的地址和数量,几秒钟就能发现工具的地址偏移设置有没有跟你理解一致。特别是PC端工具里"从0开始"还是"从1开始"这种设置,错了就全场翻车。
5. 调试实际项目时最常见的一堆坑
5.1 从站没有响应到底先查哪个环节
排查"从站没响应"是MAX级别的高频场景。我总结了一条经验原则:按物理层→配置层→协议层→业务层的顺序逐层排查,别跳级,别凭感觉猜。
物理层:用万用表量A/B线电压。因为RS485是差分信号,静态空闲时A线比B线高200mV以上,如果两个引脚电压接近甚至反向,说明接反了。接反了报文照发,但设备收不到。如果A/B间电压为0,检查是否共地以及是否终端电阻缺失。很多短距离调试出问题的原因是总线上忘了加120Ω终端电阻,导致信号反射,数据传输时好时坏。
配置层:检查串口参数,波特率、校验位、停止位。MODBUS RTU默认8N1,但有些老设备用8E1,两边对不上报文根本解不出来。其次是站号,请求帧的地址和设备DIP拨码设置的地址不一致,一切都白搭。
协议层:抓实际发送的HEX字节,确认串口助手发出去的是不是你想发的那一帧。最常见的坑就是我前面说的,没开HEX发送,ASCII字符"01"变成字节0x30 0x31。另一个坑是CRC算错。
业务层:以上都正常,用示波器观察从站UART的RX引脚有没有收到完整的波形,并确认从站程序里中断是否被更高优先级的中断堵住了。尤其是带无线模块或者频繁Flash擦除的程序,串口中断被长时间关闭,报文来了没进中断,从站自然没反应。
5.2 CRC计算正确但CRC校验失败的诡异现象
有一个现象我遇到很多次:上位机工具抓包显示报文完整,CRC看起来也对,但设备端总是校验失败。最后定位出来的原因通常是字节间隙超时。主站在发送一帧报文时,因为操作系统调度或USB转串口的驱动缓冲问题,帧内相邻字节之间被插入了很长的延迟(超过1.5字符时间),从站按MODBUS规则认为这不是同一帧,直接拆成了两帧或丢弃。报文抓出来每个字节都对,但设备就是不处理。
解决方法有几种:一是从站接收超时时间适当放宽,比如9600波特率下从3.5字符时间放宽到10ms,能容忍USB转串口的偶发卡顿;二是排查主站程序的发送方式,避免在发送一帧中间做耗时操作;三是如果主站在Windows上用了USB转串口,可以考虑设置更小的延迟定时器参数或者使用实时性更强的操作系统。
5.3 寄存器值高低字节顺序之争
MODBUS规定寄存器是16位,且一帧内高字节在前(Big-endian,大端序)。比如0x0A7B发送时先发0x0A再发0x7B。但你写从机代码时,如果直接把一个16位变量的两个字节塞进响应缓冲区,就得注意平台的大小端。在STM32这类小端平台上:
uint16_t value = 0x0A7B; resp[0] = value >> 8; // 高字节0x0A resp[1] = value & 0xFF; // 低字节0x7B这句赋值顺序错了,上位机读到的温度就会从2683变成31810这种莫名其妙的数字。另外,PLC和触摸屏那边通常也有一个"字节序"设置(AB/CD或CD/AB),两边对不上也会出现同样的问题。遇到读数异常,先花10秒钟检查字节序,而不是怀疑打数逻辑。
5.4 多机通信时一个从站掉线拖垮整条总线
RS485是多点总线,只要有一个节点的收发芯片串到A/B线上,整个总线都瘫痪。我遇到过一种情况:新接一个从站设备后,原来的两个从站全部没响应。用示波器看A/B波形是零碎的毛刺完全不是干净方波。最后查出是新设备的地线没有和总线的地接在一起,导致共模电压超范围,收发芯片工作异常。
多从站组网现场要记住三件事:A/B线必须双绞,屏蔽层单端接地或按设备规范处理;每个从站的地线要和主站地线相连(通信距离长时尤其重要);只有末端两个节点加120Ω终端电阻。中间节点的终端电阻加不加,总线上信号完整性的表现完全不同。
5.5 写入大参数时不要用0x06而用0x10
有些参数的数值超过65535吗?不会。但一个功能命令要同时更新多个寄存器时,比如校准参数有K值、B值、量程下限、量程上限4个寄存器,如果你逐条用06写,中间状态就是"已写K未写B"的不一致状态,上位机或主站万一在写第二条前掉线,设备参数就处于半更新状态。用0x10一次写完整组参数,从站收到完整帧后一次性更新,才是可靠的做法。
5.6 从站收到广播帧(地址0)执行但不回复
广播地址0的帧会发给所有从站,且不允许从站回复。这个特性很有用——主站可以用广播发一个"所有从站同步保存参数"的命令。但如果多个从站需要反馈执行结果,广播就不适用了。调试时注意:不要用广播帧去测试单站响应,它根本不会回;也不要在广播帧后立刻发点对点请求,给从站留一点执行时间。
6. 进阶调试技巧与工具配合
6.1 用示波器和逻辑分析仪看RS485时序
当串口助手层面已经看不出问题时,就该上示波器了。很多人觉得RS485看波形很麻烦,其实没那么复杂。你只需要把示波器探头挂到A线、B线对地,或者用差分探头看A-B电压,触发条件设成下降沿或上升沿,单次触发。抓到的波形应该能看到明显的显性/隐性电平交替。
有个省事做法:把示波器探头直接挂在RS485收发芯片的RO(接收输出)脚上,这里已经是转换成TTL电平后的波形,清晰更易读。数据波特率可以从波形宽度估算出来,一个位的时间就是1/波特率。比如9600波特率的一个位大约104微秒,带起始位的第一个下降沿就能看到稳定方波。判断总线是否正常收发,这一招很直接:能收到稳定的方波说明收发链路是通的,剩下就查协议逻辑。
6.2 用脚本工具做自动化和边界测试
手动组帧适合初学和排查,但要验证从站对边界条件的处理(比如同时读全部寄存器会怎样、CRC错误会不会卡死),手动组帧效率太低。我推荐用Python的pyserial库顺手写个小脚本,自动循环发各种异常报文,看从站是否还能正常响应。脚本逻辑很简单,核心就几行:
import serial, time, struct ser = serial.Serial('COM3', 9600, timeout=0.5) def crc16_modbus(data): crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc # 读站号1保持寄存器地址0,取1个 req = bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x01]) crc = crc16_modbus(req) req += bytes([crc & 0xFF, crc >> 8]) ser.write(req) resp = ser.read(100) print(resp.hex())我当时用这个方法,一次性发现了从站程序里一个隐藏的Bug:当主站请求读取的寄存器数量超过固件定义长度时,从站异常处理里数组越界,导致设备直接死机。这个Bug在手动测试时很难触发,但边界测试几万帧下去马上暴露。跑批量异常注入时,建议头一次用500个无效请求加1个有效请求的组合,观察从站是否还能正常响应有效请求——这考察的就是"主站乱发东西时你的从站固件够不够稳"。
6.3 从站日志:给设备留一个真实串口观察窗口
很多嵌入式设备只有一个调试串口,如果这个串口被MODBUS占用了,现场调试就没法输出日志。我的做法是:设备上保留一个独立的调试日志串口,用另一个USB转TTL模块连接PC,打印完整收发报文、解析状态和错误标志。打印格式一般是这样:
[T] 10.123 RX: 01 03 00 00 00 01 84 0A [T] 10.124 FRAME_OK addr=01 func=03 reg=0000 cnt=0001 crc=OK [T] 10.130 TX: 01 03 02 0A 7B 8C 3F这样一个日志串口能看到的不只是最终结果,还能看到从站内部每一步的判断结果。尤其在做主从联调时,两边的日志一对照,(是谁没回这个语义上的端到端问题);到底卡在哪个环节,一目了然。
6.4 把MODBUS集成进自己的协议分析框架
最后说一个长远角度的事。如果你经常做嵌入式通信调试,建议把这套组帧/CRC/解析的代码整理成一个独立模块,做成一个平台无关的协议库。这样新项目要支持MODBUS时,把库拿过来改几个回调函数就能用。我在实际项目中验证过,一个struct加几个handler写得好的MODBUS协议栈,可以做到数据收发完全解耦——底层UART、SPI、TCP都能用,上层应用只需要维护寄存器表。这种抽象层次清晰的设计,后期加新设备、新寄存器都会非常省心。
再补充一句:不同MCU厂家的外设库在串口接收中断的处理上略有差异,但"接收中断+定时器超时+状态机解析"的组合,在STM32、GD32、NXP、国产M0核、甚至老式C51上都跑得通。这套心智模型可以说是一通百通。