前阵子接手一个现场设备接入的活儿,需要在嵌入式Linux板子上通过RS485总线采集一批Modbus RTU协议的传感器数据。原本以为就是个简单的串口读写,结果真上手之后才发现坑不少——串口参数、RTU帧格式、CRC校验、超时处理、多设备轮询,每一个环节都有讲究,稍不注意就是数据乱码、通信超时或者设备直接无响应。这篇文章就把整个开发过程从头到尾梳理一遍,包括串口配置的具体步骤、RTU模式下读写传感器寄存器的完整实现、调试工具的选择以及我踩过的那些坑,希望能给正在做类似项目的朋友提供一份可以直接参考的实操记录。
先交代一下项目背景和适用场景。如果你正好在负责嵌入式Linux上的设备接入工作,比如通过RS485/RS232总线连接温湿度传感器、电量采集模块、PLC或者其他支持Modbus RTU协议的终端设备,那这篇文章基本就是为你准备的。即使你之前没接触过Modbus,只要有一些C语言和Linux系统编程的基础,跟着下面的思路走一遍,也能独立完成一个稳定可用的Modbus RTU通信模块。
1. 整体方案设计与技术选型思路
1.1 为什么选Modbus RTU而不是Modbus TCP或自定义协议
做嵌入式设备接入时,第一件事其实是协议选型。Modbus这个协议在工业现场的地位,基本相当于串口通信领域的普通话——绝大多数传感器、采集器、PLC都原生支持。而Modbus RTU和Modbus TCP的区别,简单来说就是传输通道不同,RTU走串口(RS232/RS485),TCP走以太网。
我在这个项目里选择RTU,最直接的原因就是现场传感器全部挂在RS485总线上。RS485总线在工业现场太常见了,支持多点通信、传输距离可达1200米、抗干扰能力强,比RS232实用得多。另外一个原因是RTU帧结构紧凑,数据密度高,在波特率不高的情况下仍然能有不错的吞吐表现。
对比一下几种方案的适用场景:
- 如果设备就在板子旁边、距离短、一对一通信,RS232 + Modbus RTU完全够用,配置最简单。
- 如果设备分布在较远距离、需要挂多台从机,RS485 + Modbus RTU是标准答案,我这次就是这种。
- 如果现场已经有以太网布线,或者需要跨设备、跨系统采集数据,Modbus TCP更方便,不需要关心串口参数,直接socket连接就行。
自定义协议不建议轻易考虑,除非你有非常特殊的传输需求。原因很实际:Modbus是公开协议,调试工具极其丰富,任何一款上位机软件都能直接对着报文分析问题,而自定义协议一旦出问题,你只能对着逻辑分析仪和协议文档一点点啃。
1.2 系统级架构:应用层直接操作串口还是引入协议栈
另一个关键决策是:直接在应用层写串口读写代码自己拼Modbus帧,还是引入现成的Modbus协议栈(比如libmodbus)。
我的选择是用libmodbus库。理由很直接:Modbus RTU虽然帧结构不复杂,但细节非常多——CRC16校验、从机应答超时判定、RTU模式的帧间隔时间(1.5字符和3.5字符时间)、异常响应处理、多从机轮询的时序控制,这些如果全部自己裸写,开发周期会拉长不少,而且出bug的概率非常高。
举个具体例子,RTU模式要求主机在发送一帧数据之前,总线必须空闲至少3.5个字符时间;发送完一帧后,从机通常需要一些处理时间才会回复。这个时序如果处理不好,要么从机根本收不到完整命令,要么主机把从机上一帧的残留数据当成当前应答,解析出来的数据全是乱的。libmodbus在底层把这些细节都封装好了,我只需要关心业务逻辑。
当然,另一个选项是在Kernel驱动层直接用Linux自带的serial driver,然后在应用层做所有协议处理。这种方式的好处是完全可控,坏处是工作量大,而且对调试经验要求很高。对于绝大多数应用场景,libmodbus足够稳定和高效,没有必要自己重造轮子。
整体方案一句话总结:RS485转串口硬件 + Linux串口驱动 + libmodbus协议库 + 应用层业务逻辑。
2. 环境准备与串口基础配置
2.1 硬件连接检查要点
硬件连接是整个项目的地基,我见过太多人上来就调软件,最后发现是接线问题。
RS485是半双工通信,两线制,A和B接反是最常见的低级错误。另外RS485总线两端需要接终端电阻(通常120欧姆),用来匹配传输线阻抗、减少信号反射。如果总线上只挂了一台设备且距离很近,终端电阻可以暂不接;但如果线缆超过几十米或者多台设备级联,一定要在两端各接一个120欧姆电阻。
串口调试之前,可以用万用表量一下A-B之间的电压。静态时正常应该在0.2V到6V之间,如果接近0V,多半是总线短路或者没有上拉/下拉偏置电阻。很多RS485转串口模块内部已经集成了偏置电路,但如果用的是自己设计的板子,可能需要外加偏置电阻保证空闲时总线电平处于确定状态。
2.2 Linux串口设备节点确认
在主流的嵌入式Linux系统里,串口设备节点通常是/dev/ttyS*(原生串口)、/dev/ttyUSB*(USB转串口)或者/dev/ttyAMA*(树莓派等平台的串口)。接到调试板之后,第一步先确认设备节点存在:
ls -l /dev/ttyS* /dev/ttyUSB* /dev/ttyAMA* 2>/dev/null如果有多个串口拿不准是哪一路,可以用dmesg | grep tty查看内核日志。比如插上USB转串口模块后,通常能看到类似usb 1-1: FTDI USB Serial Device converter now attached to ttyUSB0的日志。
如果确认设备节点存在但读写权限不足,一般有两种原因:当前用户不在dialout或tty用户组里,或者udev规则没有给设备分配足够权限。简单粗暴的验证方式:
sudo chmod 666 /dev/ttyUSB0但这是临时做法,正规做法是把用户加到对应组,或者编写适合自己项目的udev规则。注意,不同Linux发行版和嵌入式系统可能有差异,生产环境不要让应用直接依赖chmod 666这种权限设置,工作目录和日志路径等也尽量绕开权限坑。
2.3 串口参数:波特率、数据位、校验位、停止位
Modbus RTU的常规串口参数组合是:波特率9600或19200、8个数据位、无校验(None)、1个停止位,有的设备也支持偶校验。具体参数要以传感器手册为准,但绝大多数设备默认是9600 8N1。
需要注意的是,帧格式中的奇偶校验位如果确定了,帧结尾的停止位数量也会受影响——某些配置下校验位会占用一位,这时停止位可能相应调整。举个例子,有的设备手册写的是9600 8E1(偶校验+1停止位),和9600 8N1在帧总长度上就有细微差别。串口参数不匹配时最常见的现象就是收到乱码或者完全没有响应,因为帧格式对不上。
在Linux C代码里,串口参数设置一般用termios结构体完成。核心步骤包括打开串口、设置波特率、设置字符大小和控制标志、关闭流控、设置超时等。一份简洁的串口初始化和配置示例大致是下面这样:
#include <stdio.h> #include <fcntl.h> #include <termios.h> #include <unistd.h> #include <string.h> int set_serial_attr(int fd, int baudrate, int data_bits, char parity, int stop_bits) { struct termios opts; memset(&opts, 0, sizeof(opts)); tcgetattr(fd, &opts); switch (baudrate) { case 9600: cfsetispeed(&opts, B9600); cfsetospeed(&opts, B9600); break; case 19200: cfsetispeed(&opts, B19200); cfsetospeed(&opts, B19200); break; case 115200: cfsetispeed(&opts, B115200); cfsetospeed(&opts, B115200); break; default: fprintf(stderr, "unsupported baudrate: %d\n", baudrate); return -1; } opts.c_cflag |= CLOCAL | CREAD; switch (data_bits) { case 8: opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; break; case 7: opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS7; break; default: return -1; } switch (parity) { case 'N': opts.c_cflag &= ~PARENB; opts.c_iflag &= ~INPCK; break; case 'E': opts.c_cflag |= PARENB; opts.c_cflag &= ~PARODD; opts.c_iflag |= INPCK; break; case 'O': opts.c_cflag |= PARENB; opts.c_cflag |= PARODD; opts.c_iflag |= INPCK; break; default: return -1; } switch (stop_bits) { case 1: opts.c_cflag &= ~CSTOPB; break; case 2: opts.c_cflag |= CSTOPB; break; default: return -1; } opts.c_cflag &= ~CRTSCTS; opts.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); opts.c_iflag &= ~(IXON | IXOFF | IXANY); opts.c_oflag &= ~OPOST; opts.c_cc[VTIME] = 0; opts.c_cc[VMIN] = 0; tcsetattr(fd, TCSANOW, &opts); tcflush(fd, TCIOFLUSH); return 0; }这里有几处值得展开说明。
CLOCAL和CREAD基本是标配。CLOCAL忽略调制解调器控制线,CREAD启用接收,否则数据读了白读。c_lflag清除ICANON是必须的——如果不开原始模式而是保持行模式,串口会等收到换行符才把数据交给应用,而Modbus RTU的二进制帧里很可能出现0x0A(换行字符),这就会导致数据被截断。VTIME和VMIN在非阻塞读取策略下都设为0,确保read()在没数据时立即返回,方便后续控制轮询超时。
作为参考数据,这里列出不同波特率下每个字节的传输时间,方便理解后续的RTU帧间隔参数:
| 波特率 | 每字节时间(近似) | 3.5字符时间 |
|---|---|---|
| 9600 | 约1.04毫秒 | 约3.64毫秒 |
| 19200 | 约0.52毫秒 | 约1.82毫秒 |
| 115200 | 约0.087毫秒 | 约0.30毫秒 |
这个表在后面聊RTU超时设置时很有用。不同的串口没有统一性能差异,但波特率直接决定帧间隔的基准时间,一帧数据处理的时间窗口要留足余量。
2.4 串口打开与关闭的易错点
打开串口时,普通open(path, O_RDWR | O_NOCTTY)就够了,O_NOCTTY表示该串口不作为控制终端。真正容易忽略的是O_NONBLOCK参数——有些项目在打开时加了非阻塞标志,结果后面的read()行为全变了,返回-1加上EAGAIN错误码,排查起来一头雾水。
我习惯的作法是:打开时不加O_NONBLOCK,在termios里通过VTIME/VMIN控制阻塞超时;需要非阻塞轮询时再单独用select()或poll()来做超时管理。这样行为最直观,也最容易控制。
另一个容易忽略的点是程序退出时要调用tcflush(fd, TCIOFLUSH)清空串口缓冲区,否则残留数据可能干扰下一次打开时的第一次read()。
3. Modbus RTU协议核心细节
3.1 RTU消息帧结构
Modbus RTU的消息帧由四部分组成:从机地址(1字节)、功能码(1字节)、数据区(N字节)、CRC16校验(2字节,低字节在前)。
最常见的主站读命令是功能码0x03(读保持寄存器),比如要读从机地址为1的设备、从寄存器地址0x0000开始连续读两个寄存器,那么发送帧就是:
01 03 00 00 00 02 C4 0B01:从机地址03:功能码,读保持寄存器00 00:起始寄存器地址(高字节在前)00 02:寄存器数量C4 0B:CRC16校验值(低字节C4在前,高字节0B在后)
从机正常应答的帧格式是:
01 03 04 数据高字节 数据低字节 数据高字节 数据低字节 CRC低 CRC高其中04是返回的字节数,后面跟着实际数据。寄存器数据通常是16位,一个寄存器占两个字节,高端字节在前(大端序)。多字节数据类型在Modbus协议里基本都是大端序。
3.2 CRC16校验的代码实现
CRC校验是RTU模式最容易出错的地方。协议规定使用CRC16,多项式为0xA001(即标准多项式0x8005按位反转后的结果),初始值为0xFFFF,低字节在前发送。
一个经典的查表法实现如下:
#include <stdint.h> static uint16_t crc_table[256]; static void crc16_init(void) { for (uint16_t 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; } } static uint16_t crc16_modbus(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF]; } return crc; }如果不想用查表法,逐位计算的方式也能实现,只是速度慢一些,但逻辑更透明,方便调试和理解。查表法适合对性能有要求的场景,两者算出来的结果一样。
关于CRC有一个非常经典的坑:很多Modbus工具软件显示的CRC是按字节流的十六进制字符串排列的,比如C4 0B看起来是两个普通字节,但实际校验值是一个16位整数。如果你自己在高位低位上搞反,从机就会因为校验不通过而直接丢弃命令。最快的排查办法是用现成的Modbus调试工具抓一帧正常报文,对比自己发出的报文差异。
3.3 功能码0x04读输入寄存器和0x03读保持寄存器的区别
很多传感器除了支持读保持寄存器0x03,还支持读输入寄存器0x04。两者的区别在于寄存器类型不同:
0x03读取的是保持寄存器(Holding Register),这类寄存器通常可读可写,保存的是设备运行中的配置参数或状态值。0x04读取的是输入寄存器(Input Register),通常是只读的,存放传感器的实时测量值,比如温度、湿度、电压、电流。
做数据采集时,如果0x03读出来的数据不对或全为0,换个思路试试0x04,说不定一次就通了。有些传感器甚至把关键数据同时映射在两套寄存器里,但数值范围有差异,需要看手册确认。
3.4 异常响应码,读懂从机在说什么
从机在收到无法处理的请求时,不会保持沉默,而是会返回一帧异常响应。格式是把功能码的最高位置1,然后附加一个异常码。比如主站发送01 03 00 00 00 02 C4 0B,如果寄存器地址越界,从机可能返回:
01 83 02 C0 F183表示这是对03功能请求的异常响应02是异常码,表示非法数据地址
常见异常码要记熟:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 01 | 非法功能码 | 从机不支持该功能码 |
| 02 | 非法数据地址 | 寄存器地址越界或不存在 |
| 03 | 非法数据值 | 请求的数量等参数不合法 |
| 04 | 从机设备故障 | 设备内部错误或忙碌 |
用libmodbus时,如果对方返回异常帧,调用modbus_read_registers()会返回-1,通过errno和modbus_strerror()可以拿到具体原因。如果自己拼报文,收到0x83这种响应就要意识到不是数据错了,而是从机拒绝了请求。
4. 基于libmodbus实现RTU读写
4.1 libmodbus的安装与跨编译注意事项
libmodbus在桌面Linux上安装非常简单,直接apt install libmodbus-dev或者源码编译都行。但在嵌入式Linux上,一般需要交叉编译。因为不同项目的工具链千差万别,这里不贴具体路径,只强调两个关键点:
- 配置时建议显式关闭不需要的测试和文档相关选项,只保留RTU和TCP两个后端,能减少体积。
- 交叉编译时务必确认头文件路径和库文件路径指向正确的工具链目录,避免编译出来链接到了宿主机的glibc上。
标准的三步走是:
./configure --host=arm-linux-gnueabihf --prefix=/usr/local/arm-linux/modbus-install make make install--host参数指定目标平台的工具链前缀,--prefix是安装路径。把生成的libmodbus.so拷贝到嵌入式系统,头文件放在交叉编译器的include目录下即可。
4.2 RTU模式初始化与参数设置
libmodbus中RTU模式的初始化代码很简洁:
#include <modbus/modbus.h> modbus_t *ctx = NULL; ctx = modbus_new_rtu("/dev/ttyS0", 9600, 'N', 8, 1); if (ctx == NULL) { fprintf(stderr, "modbus_new_rtu failed: %s\n", modbus_strerror(errno)); return -1; } modbus_set_slave(ctx, 1); struct timeval resp_timeout; resp_timeout.tv_sec = 0; resp_timeout.tv_usec = 500000; // 500ms modbus_set_response_timeout(ctx, &resp_timeout); int rc = modbus_connect(ctx); if (rc != 0) { fprintf(stderr, "modbus_connect failed: %s\n", modbus_strerror(errno)); modbus_free(ctx); return -1; }这里面有几个参数要解释清楚。
modbus_new_rtu()的第二个参数是波特率,第三个是校验位,第四个是数据位,第五个是停止位。校验位用字符表示,'N'、'E'、'O'。modbus_set_slave()设置要访问的从机地址。注意,在RTU模式下这步不能省略。modbus_set_response_timeout()设置的是等待从机应答的超时时间。这个值既不能太大(影响轮询效率),也不能太小(某些慢速设备来不及回复)。我自己实测下来,9600波特率下500毫秒是一个比较稳妥的起点,如果设备响应慢再加大。
4.3 读取传感器寄存器的完整流程
读取保持寄存器的代码可以这么写:
uint16_t regs[8]; int rc = modbus_read_registers(ctx, 0x0000, 8, regs); if (rc < 0) { fprintf(stderr, "read failed: %s\n", modbus_strerror(errno)); } else { printf("read %d registers:\n", rc); for (int i = 0; i < rc; i++) { printf("reg[%d] = 0x%04X (%d)\n", i, regs[i], regs[i]); } }如果传感器数据在输入寄存器上,把modbus_read_registers换成modbus_read_input_registers即可。函数返回的是成功读取的寄存器个数,失败时返回-1。
这里想提醒一个点:读取数据的合法性不能只看返回值,还要结合传感器手册确认数据格式。比如很多温湿度传感器的温度值做了10倍或100倍放大,读出来的原始值是256,实际温度可能是25.6℃。再比如有的传感器把数据拆成多个寄存器,一个寄存器存整数部分,另一个存小数部分,需要自己拼接。这些规则手册里都有,不要想当然。
4.4 写入寄存器与多从机轮询
写单个保持寄存器用modbus_write_register(),写多个用modbus_write_registers()。最常见的对PLC的写操作,比如把数字量输出模块的通道值写给保持寄存器,一个调用就能完成。
多从机轮询时,常见的处理方式是依次调用modbus_set_slave(ctx, slave_id),然后对每个从机分别读写。但有个隐藏的性能问题:如果使用同一个串口,从机切换后最好重新设置超时或清空缓冲区,避免串口里残留上一台设备未读完的数据,影响判断下一个设备的应答。
一个标准的多从机轮询流程伪代码如下:
int slave_ids[] = {1, 2, 3, 4}; for (int i = 0; i < 4; i++) { modbus_set_slave(ctx, slave_ids[i]); // 可以根据需要清除接收缓冲区 // modbus_flush(ctx); int rc = modbus_read_registers(ctx, start_addr, count, regs); if (rc < 0) { // 记录日志,继续下一台设备,不要中断整个轮询 } }读取失败时不要因为单台设备异常就让整个采集流程停止,工业现场经常出现个别设备掉线的情况,健壮性很重要。
5. 常见问题与排查技巧实录
5.1 串口收到数据全是乱码
串口收到乱码,80%的原因出在串口参数不匹配上。先核对波特率、数据位、校验位、停止位是否与传感器手册一致。如果参数完全正确,再用示波器或逻辑分析仪看RS485差分信号波形,确认A/B线接线和终端电阻。
另一个隐蔽原因是TTL电平与RS485电平的电平转换模块供电不足。我曾经遇到过一款USB转485模块,接5V供电时一切正常,接到3.3V平台时因为电平转换芯片供电偏低导致信号畸变,表现就是随机乱码,排查了很久。
最后,别忘了确认串口驱动工作正常。可以用一个简单的回环测试:把串口的TX和RX短接,直接通过串口工具发送数据看是否能原样收到。如果回环正常,说明是远端设备或总线问题;如果回环都乱,那就是本端串口的问题。
5.2 发送正常但收不到设备应答
这种情况经常会把矛头指向Modbus地址或寄存器地址,但我的排查顺序一般是:
- 先确认总线上的设备地址是否正确。如果总线上有多个设备且地址重复,应答会冲突,设备可能不休眠也不回复。
- 用Modbus调试工具手动发送一帧报文,看看设备有没有响应。手动发送的意义在于排除了应用层时序和代码逻辑的干扰,直接验证链路。
- 检查应答超时时间是否设置得过短,尤其是低速设备或长距离总线上。9600波特率下,一帧12字节的应答需要大约12毫秒,加上设备内部处理时间,超时设置小于100毫秒就很容易误判失败。
- 确认自己的发送方向是不是对的。RS485是半双工,如果发送和接收方向切换控制没做好(自动收发切换电路失效或者用的软件控制方向引脚),可能根本收不到数据。
5.3 CRC校验一直不对
CRC不对,先不要怀疑算法,先检查你的CRC字节序和整个帧的组装方式。很多人在调CRC时把校验码放在报文的最前面或者把高低字节搞反,导致从机直接丢弃帧。
一个比较实用的自查方法:用现成的Modbus工具(比如Modbus Poll/Slave,或者在线CRC计算器)生成标准报文,然后和自己程序里生成的帧做逐字节对比,尤其注意最后一个字节是不是低字节在前。
如果你的项目从其他平台迁移而来,还要注意一个细节:不同语言里整数默认的字节序不同,比如C语言在x86上整数的内存布局是小端序,但Modbus协议规定寄存器值和CRC在帧内是大端序(CRC是低字节在前这种特殊处理),很多人在迁移时栽在这里。
5.4 多台从机轮询时偶发读取失败
偶发失败是RS485总线上最容易遇到的问题,排查起来也最头疼。我遇到过的典型原因有三种:
- 总线阻抗不匹配,导致信号反射。解决方法是正确接入终端电阻。
- 主机发送完一帧之后没有给总线留出足够的“安静时间”,从机还没反应过来,主机就开始发下一帧。RTU协议里要求帧间隔不小于3.5字符时间,libmodbus内部虽然会处理,但如果你在同一个串口上自己叠加了其他逻辑,就可能破坏这个时序。
- 大功率设备启动导致地电位漂移,总线通信被干扰。解决方法通常是把RS485总线和动力线分离布线、采用带隔离的RS485模块。
偶发问题比完全不通更麻烦,建议在应用层加失败重试机制,并记录错误日志,方便离线分析发生频率和触发条件。
5.5 设备返回的数据明显不合理
有一个经典案例:读出来的寄存器数组里数值在正常范围之外,比如温湿度传感器报了个450℃。后来查手册发现,传感器在未完成初始化或测量超范围时会返回一个特定错误码,比如0xFFFF或0x8000。应用层如果不做数值范围过滤,这些异常值就会直接进入业务逻辑。
另一个可能性是地址偏移不对。Modbus寄存器地址在协议上是16位的,不同厂商的地址表示方式不一样。有的手册直接写寄存器地址0x0000,有的先给个类似“40001”这样的Modbus映射地址——按Modicon的习惯,40001对应的是数据地址0x0000。拿手册里的映射号直接当寄存器地址去写代码,偏差就来了。说白了这是一个“地址偏移”问题,多对几份手册,确认好地址映射关系。
5.6 调试工具推荐
调试Modbus RTU,我常用的工具是:
- Modbus Poll(主站模拟工具):可以手动或周期发送请求,直观查看从机返回的寄存器值,非常适合验证设备本身是否正常。
- Modbus Slave(从站模拟工具):用于模拟从机设备,验证你的主机程序是否正确组装了请求帧。
- cutecom / minicom:纯串口工具,适合查看裸报文,对于排查串口参数是否正确很有用。
- 逻辑分析仪:如果你的板子上能引出RS485差分信号,逻辑分析仪直接看波形是最彻底的排查手段,能直接看出帧间隔、字节间隔是否满足RTU时序要求。
这里有一点要注意:用Modbus Poll这类工具调试时,它占用的串口一定是空闲的,不要在应用进程还占用串口的时候启动这些工具,否则会串口打开失败或数据错乱。
6. 项目里的一个实测案例:读三台温湿度传感器
为了把上面的内容串起来,分享一个实际测试例子。现场用一块嵌入式Linux板子(串口设备/dev/ttyS1)接了三台RS485温湿度传感器,从机地址分别是1、2、3,波特率9600,8N1。目标是每2秒轮询一次三台设备,读取温湿度。
传感器手册给出温度保持寄存器地址是0x0001,湿度保持寄存器地址是0x0002,数据格式是实际值的10倍。从机数量多时,为了减少总线占用,最好把温度、湿度两个寄存器一次性连续读出来,而不是分两次单点读取,这样总线效率更高,实时性更好。
以下是核心主循环的实现思路:
#define TEMP_REG 0x0001 #define HUMI_REG 0x0002 #define POLL_INTERVAL_MS 2000 uint16_t temp_raw, humi_raw; while (1) { for (int i = 0; i < 3; i++) { int slave = i + 1; modbus_set_slave(ctx, slave); uint16_t regs[2]; int rc = modbus_read_registers(ctx, TEMP_REG, 2, regs); if (rc == 2) { temp_raw = regs[0]; humi_raw = regs[1]; printf("slave %d: temp = %.1f C, humi = %.1f %%RH\n", slave, temp_raw / 10.0, humi_raw / 10.0); } else { printf("slave %d read failed: %s\n", slave, modbus_strerror(errno)); } // 两次请求之间留一个短暂间隔,避免连续请求对总线压力过大 usleep(50000); } usleep(POLL_INTERVAL_MS * 1000); }实际跑起来之后发现两个问题:
第一个是第一次运行轮询周期经常超过2秒。原因是最开始把modbus_connect放在循环外面没错,但每次modbus_set_slave之后没有清空串口缓冲区,偶发情况下从机会返回上一轮的残留数据,导致解析出来数值错乱。解决办法是在modbus_set_slave之后、发送请求之前调用modbus_flush(ctx),把脏数据清掉。
第二个问题是长时间运行偶尔会出现某台设备连续一轮无响应,下一轮又恢复正常。后来排查发现是总线上终端电阻松动,插紧之后连续跑48小时没有再出现。这个案例说明,不要一看到软件层偶发错误就觉得是代码问题,硬件连接的稳定性往往更重要。
7. 项目落地后的几点体会
回到项目本身,抛开具体代码,我最想分享的其实是两个经验层面的判断。
第一,协议栈能省就省。Modbus RTU是一个成熟的工业标准,它的帧格式、CRC算法、异常处理机制都有公开且严谨的定义。如果项目的核心业务不是“造一个Modbus协议栈”,而是“采集传感器数据并做业务处理”,那直接用libmodbus这样的成熟封装,把省下来的时间花在业务逻辑和稳定性测试上,性价比非常高。从零裸写协议当然能加深理解,但作为从业者要学会衡量投入产出。
第二,串口调试说到底是一个分层定位的过程。遇到通信问题,先确认硬件层(接线、电平、终端电阻),再确认串口参数层(波特率、数据位、校验位、停止位、流控),然后确认协议层(帧格式、CRC、超时),最后才是应用层逻辑。这个顺序不要乱,不要一上来就怀疑自己的解析代码,不然很容易在一个环节里转圈。
这个项目后续如果要扩展,方向也很明确:增加Modbus TCP网关,让采集数据能直接上报到上位机或云平台;或者引入配置文件,把从机地址、寄存器映射表和数据换算规则做成可动态下发,而不是硬编码在程序里。对于现场设备数量多、型号杂的环境,数据点表的可配置化能大大减少后面改固件的频率。
用现场踩坑换来的经验永远是印象最深的。希望这篇记录能让你在做嵌入式Linux Modbus开发时少走几步弯路。