在嵌入式Linux设备上做Modbus RTU开发,是我这些年接触最多的一类工业物联网需求。主控是一块跑着Linux的ARM板卡,环境里排着一堆温湿度传感器、风速传感器、PM2.5传感器,通讯接口清一色RS485,协议清一色Modbus RTU。人家传感器手册写得很简单:“波特率9600,8数据位,无校验,1停止位,读保持寄存器起始地址0x0102,返回两个寄存器,高字节在前。”但等你真把串口数据读回来,发现问题全在细节里:CRC校验不过、寄存器字节顺序反了、浮点数解析出来是天文数字、RS485收发切换慢了一拍导致应答丢帧。这篇文章就把我在嵌入式Linux端做Modbus RTU读传感器数据的完整过程拆开讲,串口配置、协议细节、代码实现、联调验证和现场踩坑,一次说清楚。
1. 为什么在嵌入式Linux上,Modbus开发不能只靠“调库”
1.1 这个需求来自哪类项目
做工业数据采集和物联网网关的时候,设备选型常常是这种配置:主控用NXP i.MX6ULL、全志T113、瑞芯微RV1126这类板卡,跑完整的嵌入式Linux系统,负责协议转换、数据上传;下位是若干Modbus RTU从机设备,比如温湿度变送器、风速仪、土壤墒情传感器、智能电表。任务落到应用层工程师头上,就是写一个C程序,通过串口主动轮询这些从机,把寄存器里的数据读回来,再拼成JSON或者MQTT包往上送。
这类项目有个共同特点:从机地址、寄存器地址、寄存器数量、字节顺序这些参数,只有拿到现场设备的Modbus寄存器表才能确定。所以先搞清楚协议细节,比急着选库更重要。
1.2 第三方库能省事,但你仍然得懂串口和帧细节
网上搜“嵌入式Linux Modbus”,最常见的是libmodbus。这个库确实写得不错,跨平台,支持RTU和TCP,函数封装也清晰。我早期项目也用它,但后来遇到几个现实问题:
- 现场出现无法解释的通信故障,库内部把串口设置成什么样、超时机制怎么工作,你很难一眼看穿。
- 某些交叉编译环境工具链较老,libmodbus依赖的cmake配置会折腾一阵。
- 供应商给的传感器偶发返回异常帧,库的报错信息不够直接,调试效率低。
所以我的做法是:核心的RTU主站程序直接用标准C手写,只依赖Linux系统调用。串口用open()打开,用termios配置,自己组装Modbus请求帧,自己校验CRC16,自己解析应答。这样整个数据链路都在自己手上,每一帧发出的是什么、收到的是什么,打日志一清二楚。出问题的时候,能快速定位是协议解析问题还是硬件时序问题。
这不是说libmodbus不能用,而是建议先弄懂RTU帧和串口底层,再用库。明白原理之后,用什么东西都是工具问题。
2. 串口配置:Modbus能否稳定运行,首先看这里
2.1 接线与设备节点:RS232、RS485、TTL怎么选
很多新手栽的第一个跟头,不是代码,是物理接线。嵌入式Linux板卡上通常引出的是TTL串口(3.3V电平),而工业传感器接口常见RS485或RS232。TTL和RS485不能直接对接,板子上没有收发器的话,需要外接一个TTL转RS485模块。模块一般有四个引脚:VCC、GND、TXD、RXD,有的带DE/RE方向控制脚。
接线时要注意共地,模块的GND必须和板子的GND连在一起,否则通讯一长就随机出错。设备节点一般是/dev/ttyS0、/dev/ttymxc0这类名字,不同内核平台命名不一样。插上USB转串口之后会多出/dev/ttyUSB0,开发调试阶段经常用它临时顶上。
2.2 termios参数:从规范模式切换到raw模式
Linux串口默认不是为Modbus这种裸数据设计的。默认的规范模式(canonical mode)会对输入做行缓冲、回车换行转换、特殊字符处理,而这正是Modbus帧解析的大敌。Modbus RTU主站和从站之间交换的是二进制帧,中间任何一个字节都可能和特殊字符冲突,所以必须把串口切到原始模式。
下面这段代码是基础,建议直接抄入自己的项目:
#include <termios.h> #include <unistd.h> #include <fcntl.h> int uart_open(const char *dev, int baud) { int fd = open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open"); return -1; } struct termios opts; if (tcgetattr(fd, &opts) != 0) { perror("tcgetattr"); return -1; } cfmakeraw(&opts); // 关键:切到 raw 模式 opts.c_cflag |= CLOCAL | CREAD; opts.c_cflag &= ~CRTSCTS; // 关闭硬件流控 // 8 数据位 opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; // 无校验 opts.c_cflag &= ~PARENB; // 1 停止位 opts.c_cflag &= ~CSTOPB; // 波特率 cfsetispeed(&opts, baud); cfsetospeed(&opts, baud); // 原始读取:read 返回 0 表示没有数据,返回 >0 表示实际读取字节数 opts.c_cc[VMIN] = 0; opts.c_cc[VTIME] = 10; // 1 秒超时 tcsetattr(fd, TCSANOW, &opts); tcflush(fd, TCIOFLUSH); return fd; }这里的baud参数直接传B9600、B19200这种宏。有些传感器支持更高波特率,但工业现场9600和19200用得最多。
2.3 读取策略:非规范模式、VMIN和VTIME
串口配置里最容易忽略的是VMIN和VTIME,这两个参数决定了read()阻塞多久、读多少字节才返回。
- VMIN=0,VTIME=10:非阻塞读,内核等待最多1秒,有数据就返回实际读到的字节数,没数据也返回0。适合Modbus主站轮询。
- VMIN=1,VTIME=0:阻塞读,必须读到至少1个字节才返回。如果从站永久不回应,程序会卡死在read()上。
做Modbus主站必须用第一种。因为主站发出请求后,本来就要处理“从站无响应”的情况,read返回0正好当成超时处理。VTIME的单位是0.1秒,VTIME=10是1秒,项目里配置1秒比较合适,因为标准的Modbus RTU从机响应时间一般在几十毫秒内,超过1秒基本就可以认为通信故障。
2.4 串口开起来之后,做几个动作验证
代码写完不要急着解析传感器数据,先用最简单的收发验证串口是通的。我常用的做法是打开两个串口,一端接板卡串口,另一端接USB转串口插到电脑,用串口工具往板卡发数据,板卡程序原样打印收到的十六进制字节。板子这边程序大概这样:
while (1) { int n = read(fd, buf, sizeof(buf)); if (n > 0) { for (int i = 0; i < n; i++) printf("%02X ", buf[i]); printf("\n"); write(fd, buf, n); // 回显 } usleep(10000); }电脑端发出“01 03 00 00 00 01 84 0A”,如果板子原样打印出来再回显,说明TTL串口和电平转换链路是通的。到了这一步,才进入真正的Modbus协议层面。
3. Modbus RTU协议层:帧结构、CRC16和功能码
3.1 RTU帧格式,一个请求由什么组成
Modbus RTU没有复杂的分隔符,靠“静默间隔”来分帧:帧内字节间隔不能超过1.5个字符时间,帧与帧之间至少要间隔3.5个字符时间。实际工程里这个间隔已经很难手动维持了,9600波特率下1个字符约1ms,程序里只要不做多余延时,一般能满足。
一个完整的RTU主站请求帧是这样的:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 取值范围1~247,0是广播地址 |
| 功能码 | 1字节 | 03读保持寄存器、04读输入寄存器、06写单个寄存器、16写多个寄存器 |
| 数据段 | 可变 | 寄存器地址、数量或写入数据等 |
| CRC16 | 2字节 | 低字节在前,高字节在后 |
例如读取从站地址0x01的保持寄存器,起始地址0x0000,数量1个:01 03 00 00 00 01 84 0A。前面6个字节是地址、功能码和数据,后面84 0A是CRC16。注意CRC发送顺序是低字节84在前,高字节0A在后。
3.2 CRC16计算:按位算法和查表法
CRC16-Modbus是这个协议最容易写错的地方。多项式是0x8005,初始值是0xFFFF,结果要异或0x0000(也就是不额外异或)。
按位算法逻辑清楚,代码量也不大:
uint16_t modbus_crc16(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; }0xA001是0x8005的反向多项式,适合软件逐位计算。查表法更快,但嵌入式单次轮询也就几十个字节的吞吐量,按位算法完全够用,且代码更透明,调试方便。
一个实用的自检方法:把要发送的整帧(含从站地址、功能码、数据段)喂给这个函数,得到的CRC低位字节在前、高位字节在后,拼到帧尾。接收时,把从站地址到数据段全部字节再算一次CRC,跟收到的两个CRC字节比较,同时注意接收时CRC是低字节在前。
3.3 功能码03/04/06/16,读取传感器最常用的几个
| 功能码 | 含义 | 主要用于 |
|---|---|---|
| 0x03 | 读保持寄存器 | 读取可读写的寄存器区,很多传感器配置和校准值在这 |
| 0x04 | 读输入寄存器 | 读取只读的测量值区,比如温度、湿度、压力 |
| 0x06 | 写单个寄存器 | 修改一个配置项,比如改变从站地址 |
| 0x10 | 写多个寄存器 | 批量写参数,比如修改量程 |
很多传感器把实测数据放在输入寄存器里,用功能码04;也有部分厂商混着来,手册说用03就读03。最稳妥的做法是看它寄存器表的起始地址范围:60000以上一般是保持寄存器,30000段是输入寄存器(这个分段来自Modicon约定,现在很多新设备不完全遵守,还是要以手册为准)。
应答帧格式也好理解。以04功能码为例,正常应答:
| 字段 | 长度 |
|---|---|
| 从站地址 | 1字节 |
| 功能码 | 1字节 |
| 字节数 | 1字节,等于寄存器数×2 |
| 寄存器数据 | N×2字节,每寄存器高字节在前 |
| CRC16 | 2字节 |
异常应答则把功能码最高位置1,并带一个异常码,比如请求读一个不存在的寄存器地址,从站可能回01 84 02 CRC,其中02表示非法数据地址。程序里必须能识别这种异常帧,否则会把错误数据当成正常数据解析。
4. 完整实现:从请求帧到传感器数据解析
4.1 构造读取输入寄存器的请求
我的传感器手册是这样的:Modbus从站地址0x01,波特率9600,读取输入寄存器起始地址0x0007,数量8个寄存器。返回值前2个寄存器合成一个32位无符号整数,是温度放大10倍的值,后2个寄存器是相对湿度放大10倍的值。这类传感器很喜欢把多个测量值连续摆在一起,读一次拿8个寄存器省事。
构造请求帧:
uint8_t frame[8]; frame[0] = 0x01; // 从站地址 frame[1] = 0x04; // 功能码:读输入寄存器 frame[2] = 0x00; // 起始地址高字节 frame[3] = 0x07; // 起始地址低字节 frame[4] = 0x00; // 寄存器数量高字节 frame[5] = 0x08; // 寄存器数量低字节 uint16_t crc = modbus_crc16(frame, 6); frame[6] = crc & 0xFF; // CRC低字节 frame[7] = (crc >> 8) & 0xFF;寄存器地址高字节在前,是Modbus规定的大端格式。很多工程师在这里出过问题,直接把地址0x0007赋值成frame[2]=0x07,导致发送帧变成从地址0x0700开始读,数据自然不对。
4.2 发送、接收和整帧校验
发送很简单,write全部8字节即可。但注意write返回值必须等于8,否则说明串口出现部分写入,这种属于异常情况,需要重试。
接收比发送讲究。RTU一个帧最多256字节,传感器读32个寄存器的应答也就67字节。我通常用一个固定缓冲区分几次read累积,因为串口数据可能会分片到达,也可能一个read就收到了完整帧。处理逻辑:
uint8_t rsp[256]; int rsp_len = 0; uint8_t tmp[64]; int n; uint32_t start = get_tick_ms(); while (get_tick_ms() - start < 500) { // 整体超时500ms n = read(fd, tmp, sizeof(tmp)); if (n > 0) { memcpy(rsp + rsp_len, tmp, n); rsp_len += n; if (rsp_len >= 5) { // 至少收到地址+功能码+字节数+CRC int expect_len = 0; // 功能码低字节末位不是1,正常应答是 rsp[1] if ((rsp[1] & 0x80) == 0) { expect_len = 3 + rsp[2] + 2; // 地址+功能码+数据长度+CRC } else { expect_len = 5; // 异常应答固定5字节 } if (rsp_len >= expect_len) { // 帧接收完整 break; } } } }这里有个容易被忽略的点:read返回的字节数不一定就是完整的一帧,有可能是半帧,也可能把两帧的尾和下一帧的头拼在一起。所以不能read一次就立刻解析,要累积到expect_len再校验CRC。
4.3 把寄存器数据还原成温度/湿度浮点数
这一步是好多人的“重灾区”。假设应答数据段是:
08 00 13 88 00 00 C2 98
前两个寄存器(高字节在前)合并:
uint32_t temp_raw = (rsp[3] << 24) | (rsp[4] << 16) | (rsp[5] << 8) | rsp[6] ?等等,这里要看清楚。应答帧从索引3开始是第一个寄存器高字节,索引4是第一个寄存器低字节,索引5是第二个寄存器高字节,索引6是第二个寄存器低字节。
正确的合并方式:
uint32_t temp_raw = (rsp[3] << 16) | (rsp[4] << 8) | rsp[5]; // 错 // 正确:两个相邻寄存器合成32位值 uint32_t temp_raw = ((uint32_t)rsp[3] << 24) | ((uint32_t)rsp[4] << 16) | ((uint32_t)rsp[5] << 8) | ((uint32_t)rsp[6]); float temp = (float)temp_raw / 10.0f;我在现场项目里遇到过一种更诡异的传感器:它的32位浮点数用“寄存器低16位在前”的方式存放,也就是第一个寄存器存的是浮点数低16位,第二个寄存器存高16位。这类和标准大端不同的情况非常坑,必须在联调时先用已知值验证,不能想当然用大端解析。
真正的IEEE754浮点数解析也可以直接用memcpy:
uint32_t raw = ((uint32_t)rsp[3] << 24) | ((uint32_t)rsp[4] << 16) | ((uint32_t)rsp[5] << 8) | (uint32_t)rsp[6]; float value; memcpy(&value, &raw, sizeof(value));这样得到的float就是传感器返回的原始浮点值。用memcpy而不是强制类型转换,是因为直接做强制类型转换会改变字节序解释规则,在ARM小端处理器上很容易出问题。
4.4 一个可直接编译的读取示例
下面是一个完整可运行的示例,读取从站地址0x01的输入寄存器,解析前两个寄存器为32位浮点数,打印出来:
#include <stdio.h> #include <stdint.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <time.h> static uint32_t tick_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } static uint16_t modbus_crc16(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; } static int uart_open_all(const char *dev) { int fd = open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) return -1; struct termios opts; tcgetattr(fd, &opts); cfmakeraw(&opts); opts.c_cflag |= CLOCAL | CREAD; opts.c_cflag &= ~CRTSCTS; opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; opts.c_cflag &= ~PARENB; opts.c_cflag &= ~CSTOPB; cfsetispeed(&opts, B9600); cfsetospeed(&opts, B9600); opts.c_cc[VMIN] = 0; opts.c_cc[VTIME] = 10; tcsetattr(fd, TCSANOW, &opts); tcflush(fd, TCIOFLUSH); return fd; } int main(void) { int fd = uart_open_all("/dev/ttyS0"); if (fd < 0) { printf("open uart failed\n"); return 1; } uint8_t frame[8]; frame[0] = 0x01; frame[1] = 0x04; frame[2] = 0x00; frame[3] = 0x07; frame[4] = 0x00; frame[5] = 0x02; // 只读2个寄存器,相当于一个32位浮点数 uint16_t crc = modbus_crc16(frame, 6); frame[6] = crc & 0xFF; frame[7] = (crc >> 8) & 0xFF; printf("send:"); for (int i = 0; i < 8; i++) printf(" %02X", frame[i]); printf("\n"); write(fd, frame, 8); tcflush(fd, TCIFLUSH); // 读之前清一次输入缓冲,避免收到旧数据 uint8_t rsp[256]; int rlen = 0; uint32_t start = tick_ms(); while (tick_ms() - start < 500) { uint8_t tmp[32]; int n = read(fd, tmp, sizeof(tmp)); if (n > 0) { memcpy(rsp + rlen, tmp, n); rlen += n; if (rlen >= 9) break; // 正常应答:1+1+1+4+2=9 } } printf("recv:"); for (int i = 0; i < rlen; i++) printf(" %02X", rsp[i]); printf("\n"); if (rlen >= 9 && (rsp[1] & 0x80) == 0) { uint16_t recv_crc = rsp[rlen - 2] | (rsp[rlen - 1] << 8); uint16_t calc_crc = modbus_crc16(rsp, rlen - 2); if (recv_crc != calc_crc) { printf("crc error\n"); return 1; } uint32_t raw = ((uint32_t)rsp[3] << 24) | ((uint32_t)rsp[4] << 16) | ((uint32_t)rsp[5] << 8) | (uint32_t)rsp[6]; float val; memcpy(&val, &raw, sizeof(val)); printf("value: %.2f\n", val); } else { printf("invalid response or exception\n"); } close(fd); return 0; }这个例子里的应答接收简化了,只判断rlen >= 9,实际项目里应该像第4.2节那样计算expect_len。为什么我在示例里要简化?主要是先让链路跑通,确认能收到一个完整的正常应答,再逐步完善健壮性。工程开发讲循序渐进,一步到位反而容易排查不了问题。
5. 联调验证:仿真从站加真实抓包
5.1 本地用一个从站模拟器验证
没有真传感器的时候,也可以在PC上把程序跑起来,用一个Modbus从站模拟软件(比如Modbus Slave)模拟传感器。PC上起一个虚拟串口工具,比如用socat -d -d pty,raw,echo=0 pty,raw,echo=0创建一对虚拟串口,一个给模拟软件用,一个给我们的程序用;也可以在嵌入式板卡上直接接一个USB转RS485工具,连到一个真实的Modbus传感器上。
我第一次验证手写RTU程序,就是拿Modbus Slave模拟从站,配置从站地址0x01,区域选择Input Registers,起始地址0x0007,手动填入几个浮点数的十六进制值,比如温度25.5,那么0x0007寄存器存0x41CC,0x0008寄存器存0x0000。程序跑起来后,如果输出的浮点数正好是25.5,说明请求帧、应答解析、CRC校验、浮点数转换整条链路都通了。
5.2 排查链路:发出去没回应、回应CRC错误、数据解析错乱
现场排查时,我按下面顺序看问题,效率最高:
- 确认程序打印的发送帧和手册例子完全一致。有没有CRC颠倒、寄存器地址高低位写反、从站地址错误。
- 确认物理连接。RS485的A、B线不能接反,接反的结果是“发送正常,接收全无”。用万用表量一下A对B之间的电压,空闲时应大于0.2V(实际上好的差分信号在1.5V到5V之间),反了就把两根线对调。
- 收到底层数据但CRC错误。可能原因有两个:波特率、校验位配错,接收到的是乱码;或者帧中间有丢字节,多半是串口缓冲区太小、CPU调度不及时。处理方法是把read缓冲区加大到256字节以上,接收循环在500ms内尽量多取几次,而不是只read一次。
- 收到应答、CRC也对,但解析出来的数值明显不对。这种百分之八九十是字节序问题。32位数据可能是高字节在前,也可能是低字节在前,甚至可能是两个寄存器反了。用仿真工具配合已知数值,很快能试出来。
还有一个特别隐蔽的坑:传感器从站返回数据时,中间夹着其他设备的杂波。常见于RS485多设备共线时,某个从站收到请求后延迟响应,另一个设备的数据插进来。程序解析时必须严格检查从站地址、功能码、CRC,任何一项不对就丢弃整帧,不能半信半疑地用。
6. RS485方向切换、多从机轮询与容错
6.1 方向控制延时:1个字节传输时间的估算
如果你的板子用的是带自动收发切换的RS485模块,代码不用管方向。不过工业现场更常见的是用普通MAX485芯片,需要MCU的一个GPIO控制DE/RE方向:发送时拉高DE,发送完毕后拉低RE。问题在于,发送最后一个字节刚write()完,不能立刻把方向切回接收,因为串口FIFO可能还没把最后一个字节完全发出去,这时候切换方向,最后一两个字节会变成“半截”,从站根本收不到完整帧。
怎么估算切换延时?以9600波特率、10位一字符(8数据位+1起始位+1停止位)计算:
每字节传输时间 = 10 / 9600 ≈ 1.0417 ms
写完后至少要延时1.5个字节时间,也就是1.6ms左右,再切换方向。19200波特率就是0.52ms/字符,延时0.8ms。很多项目图省事直接usleep(5000),5ms也不算错,就是轮询周期会拖慢一点。轮询几十个传感器的场景,每个都多睡5ms,周期就多出几百毫秒,所以要按波特率精确计算,而不是拍脑袋写延时。
一个稳健的做法是发送后先不主动切方向,而是调用tcdrain(fd)确保内核串口缓冲区已经全部写入硬件,再延时一个字符时间,最后切方向。
6.2 多从机轮询的主状态机思路
一个主站挂十几个从站,程序不能简单for循环一个接一个读写,因为某个从站掉线会导致100ms超时,整个循环被卡住。常规做法是把轮询做成状态机,每个从站分配一个槽位,周期循环,对每个槽位设置不同的超时上限:
typedef struct { uint8_t addr; uint8_t func; uint16_t reg_start; uint16_t reg_num; float last_value; int last_scan_ms; int timeout_cnt; } modbus_slave_t;主循环一次性把所有从站的请求帧都发出去,然后统一等待应答?这是行不通的,因为RTU总线上同一时刻只能有一个主站在说话,也要求从站排队应答。实际轮询还是逐个来:发请求、等待应答(超时500ms)、解析、再发下一个。但优化空间在于:
- 给不同从站分配不同轮询周期,重要的传感器每500ms读一次,不重要的5秒读一次。
- 某个从站连续3次超时,标记为离线,但不停留在它身上,立刻跳到下一个地址。
- 把“发请求”和“等待应答”拆成两个状态,等待期间可以处理日志、网络上报,而不是空转sleep。
6.3 超时、重试与错误码处理
现场应用中,传感器偶尔没有回应很正常,线路干扰、从站重启、供电波动都会导致丢帧。所以轮询必须有重试机制。我的经验值是:失败首次重试1次,连续失败3次标记离线,等到下一个完整周期再重新尝试。为什么连续失败标记离线?因为一个从站如果连续几十秒都没有应答,基本确定是硬件问题,继续每轮重试只会浪费总线的宝贵时间。
对异常帧里的错误码,也要有对应的日志:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 01 | 非法功能 | 请求功能码从站不支持 |
| 02 | 非法数据地址 | 寄存器地址超过从站范围 |
| 03 | 非法数据值 | 写入数据超范围 |
| 04 | 从站设备故障 | 传感器自身故障 |
日志一定要带上原始接收帧的十六进制内容,不能只打印“通信错误”。我在现场就遇到过传感器偶发返回一个字节0xFF的垃圾数据,如果没有原始帧日志,这种问题根本抓不到。
7. 现场调试一段时间后,我最想提醒的三件事
第一件:开发阶段就在所有收发路径上打十六进制日志,而不是等现场出问题再补。嵌入式Linux上调试Modbus最有效的工具就是一份干净的printf日志,把每次发送帧和接收帧完整打出来。注意频率不要太高,轮询周期100ms以下时太刷屏,可以做成可开关的debug宏。
第二件:不要迷信“高字节在前”的手册描述。我验证过同一个厂家的两种型号传感器,一个按标准大端,一个故意用“寄存器低16位在前”的方式存浮点数,你要是按统一逻辑去解析,其中必有一款会读成天文数字。上电第一件事就是拍一组已知值,用仿真和实际传感器的对比确认字节序。
第三件:串口和Modbus调试要分步走,先把收发打通再去做协议解析。代码跑通一个read请求,拿到正确CRC和数值,后面再加批量轮询、断线重连、MOTT上报都顺理成章。反过来,如果一开始就把问题堆在一起,你会分不清到底是串口配置不对、CRC算错、还是浮点数解析反了。
嵌入式Linux端做Modbus RTU,说难不难,说简单也不简单。协议本身只有读读寄存器、写写寄存器这点事,难的是把物理层、串口层、协议层、应用层这条链路的每个细节都扣清楚。只要沿着“串口验证—协议验证—字节序验证—轮询容错”这条顺序走,多数问题都能在联调阶段解决,而不是带到现场。