ARTICLE DETAIL

资讯详情

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

嵌入式Linux Modbus RTU开发实战:串口配置、CRC校验与数据采集全解析

嵌入式Linux Modbus RTU开发实战:串口配置、CRC校验与数据采集全解析 嵌入式Linux端Modbus开发这个活儿听起来就是打开串口、发几个字节、收几个字节的事但真上手做一轮你会发现坑全藏在细节里串口参数的termios配置、RTU帧的时间间隔、CRC校验的高低字节顺序、传感器厂商喜欢给你挖的寄存器字节序陷阱哪个没处理好数据读出来就是一堆乱码或者偶发报错。我最近刚在一台ARM嵌入式Linux设备上完成了整套Modbus RTU数据采集模块设备通过RS485总线挂了好几路温湿度传感器和电量采集模块应用层需要定时轮询读取数据并上报给业务平台。这篇文章就把整个开发和调试过程完整拆开讲一遍包括串口配置、RTU协议底层细节、主站读写代码实现、以及实测现场踩到的各种坑希望能给正在做类似项目的朋友一个直接能用的参考。1. 项目背景与整体方案选择先交代一下这个项目的基本情况。设备端是一块常见的ARM Cortex-A7核心板跑的是Buildroot裁剪出来的嵌入式Linux系统内核版本5.10左右板子带两路物理UART其中一路通过板载的SP3485芯片转换成了RS485总线接口用来接工业现场的多台Modbus RTU从站设备。采集的数据包括环境温湿度、设备运行电压电流这类模拟量从站设备分别用的是不同的寄存器地址段。1.1 为什么嵌入式Linux端做Modbus开发是刚需很多从单片机转过来的朋友一开始会想这东西单片机就能干为什么非要上Linux实际场景是这样的——设备除了要采集Modbus数据还要做协议转换、本地存储、远程上报、甚至跑轻量级的边缘计算逻辑。这些任务放到单片机上开发周期长、扩展性差放到嵌入式Linux上就舒服多了应用层用标准C或者Python就能快速实现而且调试手段丰富串口工具、抓包分析、日志输出都很方便。另外还有一个现实因素现在市面上大量国产传感器和采集模块虽然通信协议五花八门但Modbus RTU几乎成了默认的通用语言。无论是温湿度变送器、电量表、还是各种工业IO模块基本都会提供Modbus RTU接入能力。所以在嵌入式Linux上掌握一套稳定的Modbus RTU主站实现意味着你可以快速对接市面上绝大多数工业传感器设备不用被厂商私有协议绑死。1.2 开发语言和协议栈的选择Modbus协议栈在Linux下有很多现成的开源库比如libmodbus用起来很方便。但我在这个项目里选择了自己实现一套精简的RTU主站协议栈原因有三个从站设备数量不多功能码只用到了03读保持寄存器、04读输入寄存器、06写单寄存器这几个用libmodbus有点杀鸡用牛刀自己实现可以对帧收发、超时、重试逻辑有完全的控制权后期排查问题更直接嵌入式设备资源有限自己写还能顺带把代码体积和依赖控制到最小。当然如果你的项目涉及的从站类型很多、功能码很杂直接用libmodbus这种成熟库会更稳妥。这个后面我专门说说什么时候该换库。1.3 总体架构和通信流程整个采集模块的逻辑比较简单清晰核心就三步打开串口设备节点如/dev/ttyS1完成termios参数配置定时轮询各个从站地址按协议格式组装请求帧通过串口发送接收从站响应帧做CRC校验、地址和功能码校验然后解析出实际数据值。这里面最容易被忽视的是第二步和第三步之间的一些隐性要求半双工总线的方向切换、帧与帧之间的静默时间、从站的响应延时这些如果没处理好轮询机制做得再漂亮实际跑起来也会频繁超时。后面我会逐个细说。2. 串口配置termios里最容易翻车的几个参数嵌入式Linux下操作串口本质上就是操作一个终端设备文件。很多人一上来就open(/dev/ttyS1, O_RDWR)然后read/write结果发现收不到数据或者数据是乱码绝大多数情况都是termios结构体没配置对。2.1 打开串口设备前先确认设备节点和复用关系这个步骤看起来基础但实际项目里踩的人不少。首先你要确认你的UART控制器在Linux内核里注册成了哪个设备节点。比如你的板子有两个物理串口有可能ttyS0对应UART0ttyS1对应UART1但也有可能某个串口被内核的console占用了或者引脚被复用作其他功能。排查方法很简单查看内核启动日志搜一下ttyS相关的输出或者直接在板子上看串口节点的波特率参数。实在不确定就把怀疑的节点都打开发数据试试用示波器或者逻辑分析仪看对应引脚有没有波形出来。还有一个容易忽略的点如果这个串口同时被系统的console占用你open的时候可能能成功但收到的数据会被console子系统劫持一部分导致数据不完整。所以做Modbus采集任务时建议选一个独立的串口如果实在只有一个串口那就要在启动参数里把console重定向到别的地方。2.2 关键termios配置项详解Linux串口配置的核心是struct termios结构体涉及几个关键字段的配置。我直接贴出项目中实际在用的配置代码每一行都有注释说明为什么这么设置#include termios.h #include fcntl.h #include unistd.h #include string.h #include sys/ioctl.h int configure_serial(int fd, int baudrate) { struct termios tty; memset(tty, 0, sizeof(tty)); if (tcgetattr(fd, tty) ! 0) { perror(tcgetattr); return -1; } // 设置波特率输入输出保持一致 speed_t speed B9600; switch (baudrate) { case 9600: speed B9600; break; case 19200: speed B19200; break; case 38400: speed B38400; break; case 115200: speed B115200; break; default: speed B9600; break; } cfsetispeed(tty, speed); cfsetospeed(tty, speed); // 关键配置8数据位、无校验、1停止位8N1 // CS8表示8位数据位PARENB置0表示无校验CSTOPB置0表示1位停止位 tty.c_cflag ~PARENB; tty.c_cflag ~CSTOPB; tty.c_cflag ~CSIZE; tty.c_cflag | CS8; // 关闭硬件流控Modbus RTU标准不依赖流控线 tty.c_cflag ~CRTSCTS; // CLOCAL忽略调制解调器控制线CREAD使能接收 tty.c_cflag | (CLOCAL | CREAD); // 关闭软件流控 tty.c_iflag ~(IXON | IXOFF | IXANY); // 关键禁用ICANON规范模式、ECHO、ISIG // 规范模式下串口收到换行符才会返回数据Modbus帧里可能有0x0A会直接导致read阻塞 tty.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); // 关闭输出处理禁止把换行符转成回车换行 tty.c_oflag ~OPOST; // 设置VTIME和VMIN // 这是read超时的关键很多超时问题都出在这里 tty.c_cc[VTIME] 10; // 单位是0.1秒这里表示1秒超时 tty.c_cc[VMIN] 0; // 非阻塞读取配合VTIME使用 // 清空缓冲区并应用配置 tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, tty) ! 0) { perror(tcsetattr); return -1; } return 0; }这段代码里最重要的就是tty.c_lflag ~(ICANON | ECHO | ISIG)这一行。如果不关闭ICANON串口会在收到换行符0x0A的时候才把数据交给read调用而Modbus RTU帧中完全可能出现0x0A这个字节结果就是你的read要么被堵住要么一次读出好几帧的数据解析起来一团糟。2.3 VMIN和VTIME的配合逻辑VMIN和VTIME这两个参数决定read的行为很多人觉得难理解我直接给你说人话VMIN 0, VTIME 0read会阻塞直到读到VMIN个字节或者超过VTIME时间按0.1秒为单位没有新数据才返回。VMIN 0, VTIME 0read会一直阻塞直到读满VMIN个字节才返回。VMIN 0, VTIME 0read立即返回但只要持续有数据系统会等待一个VTIME周期的空闲后返回相当于按串口空闲来界定一帧数据结束。在我这个项目里用了VMIN 0, VTIME 10的组合表示如果1秒内没有新数据到达就超时返回。这样配合我的帧解析逻辑每次read返回的数据可能就是半帧也可能是多帧我统一放进环形缓冲区由解析函数来切帧。这种方式的优点是read永远不会长时间阻塞主循环的轮询策略更可控。3. Modbus RTU协议帧与CRC校验的底层拆解串口通了以后下一步就是和Modbus协议打交道了。Modbus RTU的帧结构非常简单但正因为简单很多人容易掉以轻心结果在细节上反复翻车。3.1 RTU消息帧结构逐字节拆解一帧完整的Modbus RTU请求或响应结构如下字段长度说明从站地址1字节范围1~2470为广播地址功能码1字节03读保持寄存器04读输入寄存器等数据区N字节寄存器地址、数量或实际数据CRC162字节低字节在前高字节在后一个典型的读保持寄存器请求帧01 03 00 00 00 02 C4 0B拆开来看就是01从站地址为103功能码为读保持寄存器00 00起始寄存器地址为0x000000 02读2个寄存器4字节C4 0BCRC16校验值低字节C4在前高字节0B在后响应帧和请求帧结构略有不同数据区第一个字节是字节数计数。比如从站返回01 03 04 41 8A 66 66 B2 3C其中01和03和请求一致04说明后面有4个字节的数据41 8A 66 66这就是读到的原始值按IEEE 754浮点数解析后应该是约17.3B2 3CCRC16校验3.2 CRC16计算到底是查表还是逐位算Modbus RTU的CRC16校验使用的多项式是0xA001即CRC-16/MODBUS标准初始值为0xFFFF。计算的时候是对整帧数据从站地址功能码数据区做处理CRC值先发低字节再发高字节。这个字节顺序是新手最容易弄错的地方。我在项目里用的是查表法因为嵌入式Linux环境下CPU性能足够查表法代码简洁速度快。查表法需要先准备好一张256项的CRC表生成逻辑如下uint16_t crc_table[256]; void crc_init(void) { for (int i 0; i 256; i) { uint16_t crc i; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } crc_table[i] crc; } } uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { uint8_t index (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc_table[index]; } return crc; // 返回的是低字节在前的原始值 }发送的时候需要注意字节序。假设计算出的CRC值是0x0BC4发送顺序应该是先发0xC4低字节再发0x0B高字节。如果你把高低字节搞反了从站会认为CRC校验失败直接不回复你。3.3 为什么RTU帧之间要有3.5个字符的静默时间Modbus RTU标准规定两个相邻帧包括同一个帧的字节之间的间隔时间不能超过3.5个字符时间否则接收方会认为帧已经结束开始解析。反过来如果帧内两个字节之间的间隔超过了1.5个字符时间接收方也会认为帧被破坏了。这个字符时间怎么算以9600波特率为例每个字符包括起始位、8个数据位、停止位一共11位。那么1个字符的时间就是 11 / 9600 ≈ 1.146毫秒3.5个字符时间就是约4.01毫秒。也就是说在9600波特率下你的主站发完一帧后至少要等待约4毫秒才能考虑接收从站的响应吗不对这个静默时间要求的是收发双方在总线空闲状态下才能开始发送从站需要检测到总线空闲3.5个字符时间后才认为一个帧结束然后开始处理请求。实操中我给主站设计的策略是发送完请求帧后先延时至少5毫秒略大于9600波特率下的3.5字符时间然后再进入接收状态。对于波特率更高的场景比如1152003.5字符时间大约只有0.33毫秒最小静默时间不足1毫秒这时候要注意延时函数的精度最好用usleep或者nanosleep避免系统调度误差导致帧间隔超限。4. 主站读写传感器的完整代码实现协议理解了之后代码实现其实就水到渠成了。我这边把主站的核心逻辑拆成几个模块帧发送、帧接收与校验、数据解析包括浮点数转换、轮询调度。4.1 发送请求帧把参数组装成标准RTU报文发送请求帧的逻辑非常简单但要注意把寄存器地址等参数正确拆分。下面是一个完整的读保持寄存器请求的构造和发送函数int modbus_read_registers(int fd, uint8_t slave_addr, uint16_t start_addr, uint16_t reg_count, uint8_t *response, int timeout_ms) { uint8_t request[8]; request[0] slave_addr; request[1] 0x03; // 功能码读保持寄存器 request[2] (start_addr 8) 0xFF; request[3] start_addr 0xFF; request[4] (reg_count 8) 0xFF; request[5] reg_count 0xFF; uint16_t crc crc16_modbus(request, 6); request[6] crc 0xFF; // CRC低字节 request[7] (crc 8) 0xFF; // CRC高字节 // 清空接收缓冲防止上一帧残留数据干扰 tcflush(fd, TCIFLUSH); // 发送请求帧 ssize_t len write(fd, request, 8); if (len ! 8) { printf(发送失败实际发送 %zd 字节\n, len); return -1; } // 等待从站响应这里至少等待3.5字符时间 usleep(5000); // 9600波特率下预留5ms // 接收响应循环读取直到超时 return receive_response(fd, response, timeout_ms); }注意代码里我在发送前先执行了tcflush(fd, TCIFLUSH)把接收缓冲区里可能残留的旧数据清掉。这个细节很重要因为前一次通信如果超时了从站的响应可能晚到残留在串口缓冲区里下一次发送请求后再读就会把这帧残留当成新响应导致数据错乱。4.2 接收响应超时、帧完整性、CRC校验一步到位接收响应是整个流程里最讲究的部分。Modbus RTU响应帧长度是可以通过功能码和字节数计数推算出来的所以我们可以先读固定长度的字节再根据数据区长度读完整帧。我在实际项目中用了一个更简单的策略在一个循环里持续read把读到的一帧数据放入缓冲区读取间隔超过一定时间就认为帧接收完毕然后交给解析函数处理。#define MAX_RTU_FRAME_SIZE 256 typedef struct { uint8_t data[MAX_RTU_FRAME_SIZE]; uint16_t len; } rtu_frame_t; int receive_response(int fd, uint8_t *buf, uint16_t max_len, int timeout_ms) { fd_set fds; struct timeval tv; struct timeval start, now; uint16_t pos 0; gettimeofday(start, NULL); while (1) { FD_ZERO(fds); FD_SET(fd, fds); tv.tv_sec 0; tv.tv_usec 200000; // 200ms轮询间隔用于检查总超时 int ret select(fd 1, fds, NULL, NULL, tv); if (ret 0) { ssize_t n read(fd, buf[pos], max_len - pos); if (n 0) { pos n; if (pos max_len) break; continue; } } gettimeofday(now, NULL); long elapsed_ms (now.tv_sec - start.tv_sec) * 1000 (now.tv_usec - start.tv_usec) / 1000; if (elapsed_ms timeout_ms) { break; // 超时退出 } // 关键如果已经收到过数据并且距离最后一次收到数据超过20ms // 就认为这一帧接收完毕 if (pos 0) { gettimeofday(now, NULL); long gap_ms (now.tv_sec - start.tv_sec) * 1000 (now.tv_usec - start.tv_usec) / 1000; static long last_data_ms 0; // 这里简化的写法用一个静态变量记录最后一次收到数据的时间 // 实际工程中建议用单独的时间戳变量保存 } } return pos; }这段代码为了演示做了简化实际工程里我会更严谨地记录最后一次收到数据的时间戳当两次read之间的间隔超过了帧内最大允许间隔比如9600波特率下1.5字符时间约1.7ms但实际系统调度可能没那么精确一般取5~10ms就认定当前帧已经结束。这个认定帧结束的阈值很关键设得太短从站响应稍微慢一点就被误判为帧结束导致数据不完整设得太长又会在连续两帧靠得很近的时候把它们混在一起。4.3 接收后的CRC校验和异常处理拿到一帧完整数据后不能直接开始解析。第一步先校验CRC第二步检查从站地址和功能码。这里有一个常见误区很多人只校验CRC不看功能码结果把从站返回的异常码当成正常数据处理。Modbus协议规定如果从站检测到请求有误比如寄存器地址超范围响应帧的功能码最高位会被置1比如原来的03变成了0x83异常码跟在后面用来区分错误类型。下面是我常用的校验逻辑int validate_response(rtu_frame_t *frame, uint8_t expected_addr, uint8_t expected_func) { // 1. 帧长度至少需要8字节地址功能码字节计数至少2字节数据2字节CRC if (frame-len 8) { printf(帧长度不足: %d\n, frame-len); return -1; } // 2. CRC校验 uint16_t calc_crc crc16_modbus(frame-data, frame-len - 2); uint16_t recv_crc frame-data[frame-len - 1] 8 | frame-data[frame-len - 2]; if (calc_crc ! recv_crc) { printf(CRC校验失败: 计算0x%04X, 收到0x%04X\n, calc_crc, recv_crc); return -2; } // 3. 地址校验 if (frame-data[0] ! expected_addr) { printf(从站地址不匹配: 收到0x%02X, 期望0x%02X\n, frame-data[0], expected_addr); return -3; } // 4. 功能码异常判断 if ((frame-data[1] 0x80) ! 0) { printf(从站返回异常: 功能码0x%02X, 异常码0x%02X\n, frame-data[1], frame-data[2]); return -4; } if (frame-data[1] ! expected_func) { printf(功能码不匹配: 收到0x%02X, 期望0x%02X\n, frame-data[1], expected_func); return -5; } return 0; }实际开发里我会把异常码进一步打印成可读的错误信息比如01表示非法功能码、02表示非法数据地址、03表示非法数据值等方便现场快速定位问题。4.4 从原始寄存器值到真实物理量字节序和浮点数转换从站返回的寄存器值是原始的16位整数一个32位浮点数通常由两个寄存器拼起来。这里就涉及大小端的问题了。IEEE 754单精度浮点数有4个字节假设实际的浮点值是17.3十六进制表示是41 8A 66 66。不同厂商的设备在传输这些字节时的顺序可能完全不同大端顺序41 8A 66 66即先传高字节小端顺序66 66 8A 41即先传低字节有些设备还会把两个寄存器的顺序反过来先传低16位再传高16位变成66 66 41 8A我实测过的几款传感器就有分别用这三种顺序的。所以在写解析代码时一定不能想当然要先通过设备手册确认或者直接手动设置几个已知的值读出来对比确认。我项目里的浮点数转换函数长这样float bytes_to_float(uint8_t *data, int byte_order) { union { uint32_t u32; float f32; } converter; switch (byte_order) { case ORDER_ABCD: // 大端: 41 8A 66 66 converter.u32 (uint32_t)data[0] 24 | (uint32_t)data[1] 16 | (uint32_t)data[2] 8 | (uint32_t)data[3]; break; case ORDER_CDAB: // 寄存器顺序颠倒字节内部大端 converter.u32 (uint32_t)data[2] 24 | (uint32_t)data[3] 16 | (uint32_t)data[0] 8 | (uint32_t)data[1]; break; case ORDER_BADC: // 字节顺序颠倒寄存器顺序正常 converter.u32 (uint32_t)data[1] 24 | (uint32_t)data[0] 16 | (uint32_t)data[3] 8 | (uint32_t)data[2]; break; case ORDER_DCBA: // 小端: 66 66 8A 41 converter.u32 (uint32_t)data[3] 24 | (uint32_t)data[2] 16 | (uint32_t)data[1] 8 | (uint32_t)data[0]; break; default: converter.u32 0; break; } return converter.f32; }这个函数的教训来自一个很尴尬的现场调试经历。当时我读一款温湿度传感器手册上写的寄存器地址是温度0x0001、湿度0x0002结果读出来的温度值一直是个几十万的巨大数字明显是把两个寄存器的顺序搞反了。后来一查那款传感器是先返回低16位再返回高16位用ORDER_CDAB的方式解析就完全正常了。这个坑几乎每个做Modbus对接的人都会踩一次所以我特意把四种字节序都做了支持方便现场切换。5. 实测中的坑半双工切换、超时与应答延迟代码逻辑都调通之后真正拿到现场跑才发现实验室里模拟得好好的东西一接实际设备就频发超时和数据错乱。这一章我把实测中遇到的关键问题都列出来每个都附上根因分析和解决方案。5.1 发送完立刻read方向切换导致丢第一个字节RS485是半双工总线同一时刻只能有一个方向的数据在传输。常见的RS485收发器芯片比如SP3485、MAX485都有一个DE/RE引脚用于控制收发方向。很多板子会把这两个脚直接接到某个GPIO或者UART的RTS引脚上由驱动自动切换方向。但问题在于当你write完最后一个字节后驱动可能不会立刻把方向切换到接收态中间有一个短暂的空档。如果这时候你马上去read很有可能会丢从站响应帧的前几个字节——有时甚至整个帧都收不到。我在现场遇到过最典型的情况是用示波器看波形发现总线上的数据是完整的但应用层收到的帧总是少了前两个字节。排查到最后才发现是驱动切换方向有大约几百微秒的延迟而这个延迟刚好让从站的第一个响应字节到达时主站的接收器还没打开。解决办法是在write之后加一个小的延时再进入接收循环这个延时取决于具体驱动实现一般3~5毫秒就够。你也可以用ioctl去查询RS485方向控制状态或者主动控制GPIO方向引脚。如果你用的是内核里的RS485驱动可以检查一下有没有通过TIOCSRS485这个ioctl设置了SER_RS485_RTS_ON_SEND这类标志如果方向控制是硬件自动完成的延时策略基本就够用了。5.2 从站响应延迟不能只靠固定超时不同从站设备的响应时间差异非常大。有些工业模块响应速度很快发送完请求后10毫秒内就能回复但有些设备内部有采集逻辑需要先把传感器数据采集一遍才能回应响应时间可能长达200毫秒甚至更长。我在项目里遇到过一款电量采集模块读电压电流的时候响应速度很快但读功率因数的时候要等100多毫秒因为那个设备的固件在读到这个寄存器时才会去触发一次内部重新计算。如果你的轮询策略用的是统一的固定超时时间比如50毫秒那读取这类慢寄存器就会频繁超时。我的做法是给每个从站地址建一个响应时间的动态统计根据最近几次成功通信的耗时动态调整超时阈值初始值设置大一点比如300毫秒等跑了一段时间之后慢慢收敛到一个比较合理的最小值。这样既保证了慢设备不丢帧也避免了快设备每次都等到超时才返回拉低整个轮询效率。5.3 两线制RS485总线的终端电阻和总线冲突还有一个现场容易忽略的问题RS485总线在高速率下需要合适的终端电阻否则信号反射会导致误码。常见的做法是在总线最远端的两个节点之间并联一个120欧姆电阻。我的设备在实验室用短线测试的时候一切正常到了现场用了几十米的屏蔽双绞线波特率拉到38400就开始出现偶发性的CRC错误帧。后来用示波器一看总线上有严重的振铃和反射最后在从站那端加了终端电阻问题立刻消失。另外如果你的总线上挂了多个从站还要注意每个从站的地址不能冲突A/B线不能接反。这两个属于基础中的基础但每次现场出问题我都要重新确认一遍因为真有人会把同一地址配到两台设备上或者把RS485的A/B线接反导致通信时好时坏。5.4 偶发错误帧为什么一样的报文上次行这次不行这种时好时坏的问题是排查成本最高的。我总结了几个实际项目中概率最高的根因第一是主站的轮询周期和从站的内部处理时序冲突。比如你每100毫秒轮询一次而某款从站内部每200毫秒才刷新一次数据在它刷新数据的临界点去读可能就会读到不完整的寄存器组合导致解析出来的物理量跳跃特别大。这种情况不是通信错误是数据本身的一致性有问题。解决办法是连续读两次第一次做热身第二次再取数或者对连续几次的读数值做合理性过滤。第二是波特率误差累积。主站的串口时钟和从站的串口时钟存在偏差如果两者的偏差在一定范围内还能容忍但加上了数据帧长度和线缆电容之后偶尔就会出现在一个长的帧尾部出现位错误。我遇到过一款国产传感器标称支持9600波特率但实际高低温下晶振漂移很厉害9600下就是偶尔错降到4800就所有帧都正常了。第三是软件层面的接收不到问题主站应用被系统调度抢占导致接收超时。嵌入式Linux上如果进程被高优先级线程抢占或者系统负载太高你的read超时逻辑就可能误判帧未结束而提前返回拿到半截数据。这个可以通过把采集线程设为实时优先级或者用单独的独立进程做采集来规避。6. 调试工具和现场排查的经验补漏最后把调试过程中用到的工具和一些通用的排查技巧整理一下这些经验能帮你大幅缩短定位问题的时间。6.1 先用Modbus模拟软件完成单点验证在主站代码上真机调试之前强烈建议先跑一遍模拟环境。我常用的组合是电脑上用Modbus Poll模拟主站用Modbus Slave模拟从站先用它们确认设备侧的寄存器地址、数据格式、字节序都没有问题再动自己写的代码。特别是刚接触一款新传感器时先用Modbus Poll去读它的寄存器手动输入不同的起始地址和寄存器数量观察返回数据的规律可以快速确定温度和湿度的寄存器位置。确定好寄存器布局和字节序之后再把这些参数固化到自己的代码里会省掉很多来回试的功夫。我在上一个项目里就是先拿Modbus Poll和一台TCP转RS485的网关配合先把从站设备的寄存器表摸了个透然后在嵌入式板子上直接套用验证过的参数全程几乎没有返工。6.2 串口助手的抓包用法不要只看数据要看时间戳嵌入式板子上如果临时要抓串口数据我一般会用系统自带的或交叉编译好的microcom、picocom之类的小工具直接打开串口设备看原始hex。但这类工具有一个局限看不到精确的帧间隔时间。如果需要精确测量帧间隔最靠谱的还是把引脚上的信号拉出来接逻辑分析仪一般16MHz采样率以上的逻辑分析仪就够用了。看波形的时候重点观察两处帧内字节之间的间隔是否超过了1.5个字符时间帧与帧之间的间隔是否不足3.5个字符时间。波形层面对了剩下的问题基本就全在应用层代码里了。6.3 应用层里写一个hex dump日志函数这个建议看起来土但真的非常实用。在应用层做完整的数据收发日志每次发送请求和收到响应都把原始字节按hex格式打印出来同时打印CRC校验结果、解析出来的物理量值。就靠这一招我排查过好几次为什么别的模块读正常、我这个模块读偶尔异常的问题。因为十六进制日志能把通信过程完整还原出来一旦看到CRC错误的帧计算一下帧间隔或者从站地址原因往往立刻浮出水面。这个日志函数我建议做成可开关的生产环境默认关闭调试环境打开避免刷屏影响性能。void log_hex(const char *tag, uint8_t *data, uint16_t len) { printf([%s] len%d:, tag, len); for (uint16_t i 0; i len; i) { printf( %02X, data[i]); } printf(\n); }6.4 什么时候该换用libmodbus这样的成熟库前面的方案是自己实现的精简主站协议栈优点是可控性高、无外部依赖但缺点也很明显遇到不按常理出牌的设备异常处理、广播、多主机之类的扩展场景都要自己写。如果你的项目遇到下面这些情况我建议直接上libmodbus需要同时支持RTU和TCP两种模式或主站和从站两种角色需要处理的从站设备品牌种类很多每种设备的寄存器映射差异很大项目周期紧张需要尽快跑通demo后续再考虑优化团队里有同事已经熟悉libmodbus的API维护成本低。libmodbus的接口设计得很简洁打开串口后设置从站地址、直接调用modbus_read_registers就能完成一次读操作底层的帧组装、超时处理、CRC校验都封装好了。但它也有一个需要留神的地方默认的字节序是大端如果设备用的是小端需要额外用modbus_set_float_dword之类的接口做转换这部分规则依然要靠你先通过手册或者实测确认清楚。6.5 最后补一个提高轮询效率的小技巧如果从站数量多又要求采集周期尽量短可以试试把多个寄存器的读取合并成一次请求。Modbus协议允许一次读多个连续的寄存器比如读保持寄存器时起始地址设为0x0000寄存器数量设为20一次就能把20个寄存器的值全部拿回来比逐寄存器读快得多。我实际项目里就是这么做的先把每台从站上需要采集的所有物理量尽量安排在连续的寄存器区域如果设备手册的寄存器布局允许就尽量合并成一次或两次请求把每轮的通信次数降到最低。对于9600波特率的老设备这个优化带来的收益特别明显因为一次通信的耗时大头是等待静默时间和从站响应而不是真正的数据传输时间。
返回列表