ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式Linux下Modbus RTU传感器读取实战:RS485串口配置与协议解析

嵌入式Linux下Modbus RTU传感器读取实战:RS485串口配置与协议解析 做嵌入式Linux开发的人迟早会碰到这么一件事板子跑起来了系统起来了接下来就要跟外面的世界打交道。最常见的打交道方式就是串口而串口再往上走一层工业环境里被问得最多的协议就是Modbus。最近我在一个环境监测项目里需要在嵌入式Linux板上通过RS485总线读取温湿度传感器和光照传感器的数据传感器支持的就是Modbus RTU协议。这个需求听起来常规但真正做下来串口配置、协议帧解析、CRC校验、寄存器数据转换每个环节都藏着不少坑。这篇文章就把我在这个项目里的完整思路、代码和踩坑过程整理出来给正在做类似事情的同行一个参考。嵌入式上的串口开发在裸机上做和在Linux上做完全两回事。裸机是你直接操作寄存器想怎么配就怎么配Linux上你面对的是一个被内核管理起来的串口设备得通过termios这套接口去配置还要考虑设备节点、权限、驱动这些因素。再加上RS485这种半双工总线还要控制收发方向稍不留神就踩坑。这篇文章适合刚接触嵌入式Linux串口开发、需要对接Modbus RTU传感器或者仪表的工程师也适合那些已经在用Modbus但想把帧格式和校验原理吃透的开发者。1. 项目背景与整体设计思路1.1 为什么选Modbus RTU而不是Modbus TCP项目需求是读取分布在现场的多路传感器数据传感器距离主控板近的十几米远的差不多五十米。这种场景下Modbus RTU加RS485总线几乎是工业现场最成熟的方案没有之一。RS485是差分信号传输抗干扰能力强线缆走几十米完全没问题而且一条总线上可以挂32个节点用中继器还能扩对于多传感器采集场景再合适不过。Modbus TCP当然也是个选择但TCP方案要求每个传感器都要有独立的网络接口和IP地址成本高、布线复杂现场也不可能给每个传感器都拉网线。RTU方案就简单了一根双绞线串下去传感器手拉手并联在总线上主机轮询读取就行。所以这个项目里我在主控板和传感器之间确实没有犹豫直接选Modbus RTU。不过这里要提醒一句如果你的传感器本身只支持Modbus TCP或者现场已经具备成熟的以太网/工业交换机网络那选TCP也合理。两种协议的报文结构在数据层基本一样区别主要在传输层咱们做嵌入式的大多只接触RTU但心里要清楚还有TCP这个变体存在。1.2 系统架构与硬件链路整个系统的数据链路是这样走的传感器挂在RS485总线上通过双绞线接到主控板的RS485收发器芯片芯片把差分信号转成TTL电平送进主控SoC的UART引脚Linux应用层通过访问串口设备节点和应用层协议栈来完成数据读取。我用的主控板是市面上常见的ARM Cortex-A7核心板板载资源里正好有一个RS485接口原理图上看是SP3485这颗收发器芯片由一颗GPIO控制DE/RE方向。如果没有板载RS485口也可以用USB转RS485的适配器比如CH340/CP2102加MAX485的方案效果差别不大差别只在设备节点名上——板载串口通常是/dev/ttyS0或者/dev/ttyAMA0USB转出来的通常是/dev/ttyUSB0。硬件链路上的一个关键细节是终端电阻。RS485总线的两端必须各接一个120欧姆终端电阻用来匹配阻抗、减小信号反射。如果只是短距离点对点测试两三米不接也能通但拉到几十米或者挂多个节点还不接信号反射就会导致数据错乱。我的项目里距离超过十米所以在主控板端和最后一颗传感器端都加了终端电阻实测波形干净很多。还有一个接地问题RS485的A、B两根线之外最好再把各节点的信号地拉通否则共模电压过大会把收发器芯片烧掉这个后面在问题排查部分会详细说。1.3 自研协议栈还是用libmodbus这是动手前必须想清楚的第一个问题。我见过不少工程师传感器手册一到手就开始自己写帧发送、解析、校验的代码写完之后发现各种边界情况处理不干净超时重试也没有最后调试到崩溃。我的建议是能用成熟库用成熟库别重复造轮子。libmodbus是Linux下最常用的Modbus协议库支持RTU和TCP两种模式C语言编写源码干净MIT协议开源在嵌入式Linux上编译毫无压力。它的API封装得很好打开串口、设置从站地址、读写寄存器几个函数调用就搞定了。而且它把CRC校验、帧超时、响应校验这些坑都替你处理好了出问题的概率大大降低。但我也保留了自己手写部分代码原因有两个第一某些传感器对轮询时序有特殊要求库的默认行为不一定匹配你得能看懂协议细节才能调第二如果项目里跑的是RTOS或者裸机环境libmodbus用不了那手写协议栈就是基本功了。所以这篇文章里两种方案我都会给先讲清协议本身再给库的用法和手写实现大家按项目情况取舍。2. 串口配置全流程从设备节点到termios2.1 搞清楚你的串口设备节点在Linux上写串口程序第一步不是写代码而是先确认设备节点。板子启动后插上USB转串口工具用dmesg看内核日志dmesg | grep -i tty插上USB转RS485适配器后如果看到类似ch341-uart converter now attached to ttyUSB0的日志说明节点是/dev/ttyUSB0。如果用的是板载UART一般会注册成/dev/ttyS0或者/dev/ttyAMA0树莓派常见、/dev/ttymxc0NXP i.MX平台常见这个因平台而异。还有一个很实用的命令是ls -l /dev/ttyS* /dev/ttyUSB*可以列出所有串口设备。需要注意的是有些开发板的RS485接口对应的UART会被内核的串口驱动注册为/dev/ttyS0但引脚方向控制是通过GPIO实现的这种一般要自己写控制代码后面会详细说。权限问题也要提前处理。普通用户直接open(/dev/ttyUSB0, O_RDWR)大概率会报Permission denied因为设备默认属主是root。临时调试可以直接用sudo chmod 666 /dev/ttyUSB0项目正式跑的话建议把当前用户加入dialout组sudo usermod -aG dialout $USER一劳永逸。2.2 termios串口参数详解Linux用户态配置串口核心就是termios结构体。很多初学者在这里被绕晕其实拆开来看就三件事波特率、数据帧格式、读写模式。波特率比较简单用cfsetispeed和cfsetospeed设置注意输入输出波特率分开设置虽然绝大多数场景这两者一样但函数要成对调用。数据帧格式包括数据位、停止位、校验位Modbus RTU默认为8数据位、1停止位、无校验也就是常说的8N1。但有些传感器默认是偶校验比如一些进口仪表默认8E1这个必须看传感器手册不能凭经验瞎猜。读写模式是新手最容易忽略的地方。默认情况下串口会处于canonical模式也就是行缓冲模式数据等到换行符才返回这对Modbus这种二进制帧协议来说是致命的。一定要用cfmakeraw把串口设置成原生模式——不做任何字符处理、不按行缓冲、不解释特殊字符收到的字节是多少就给你多少。再配合VMIN和VTIME控制读操作的行为VMIN1, VTIME0表示至少要等到1个字节才返回VMIN0, VTIME10表示最多等1秒超时返回。对于Modbus RTU这种不定长帧协议我通常把VMIN设为1然后在应用层做超时控制这样最灵活。2.3 完整的串口初始化代码#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include termios.h #include errno.h int serial_init(const char *dev, int baud, int data_bits, char parity, int stop_bits) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios opts; memset(opts, 0, sizeof(opts)); tcgetattr(fd, opts); /* 原生模式禁用行缓冲、回显、信号字符等 */ cfmakeraw(opts); /* 波特率映射 */ speed_t speed B9600; switch (baud) { case 4800: speed B4800; break; 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(opts, speed); cfsetospeed(opts, speed); /* 数据位 */ opts.c_cflag ~CSIZE; switch (data_bits) { case 5: opts.c_cflag | CS5; break; case 6: opts.c_cflag | CS6; break; case 7: opts.c_cflag | CS7; break; default: opts.c_cflag | CS8; break; } /* 校验位 */ switch (parity) { case E: case e: opts.c_cflag | PARENB; opts.c_cflag ~PARODD; opts.c_iflag ~INPCK; break; case O: case o: opts.c_cflag | PARENB; opts.c_cflag | PARODD; opts.c_iflag ~INPCK; break; case N: case n: default: opts.c_cflag ~PARENB; opts.c_iflag ~INPCK; break; } /* 停止位 */ if (stop_bits 2) opts.c_cflag | CSTOPB; else opts.c_cflag ~CSTOPB; /* 本地连接、使能接收 */ opts.c_cflag | (CLOCAL | CREAD); /* 读行为至少1字节返回配合应用层超时 */ opts.c_cc[VMIN] 1; opts.c_cc[VTIME] 0; /* 清空缓冲区并应用配置 */ tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, opts) ! 0) { perror(tcsetattr); close(fd); return -1; } /* 清一次缓冲确保后续读到的都是新数据 */ tcflush(fd, TCIOFLUSH); return fd; }这段代码里的cfmakeraw是关键的封装它替我们做了很多事关闭ECHO、关闭ICANON行模式、关闭ISIG信号字符、关闭IXON/IXOFF软件流控、把输入输出字符处理全部置为透传。我见过有人在项目里不用cfmakeraw手工一个个设置标志位结果漏了某个IUTF8或者IXON调试的时候莫名其妙多出乱码或者丢字节。直接用cfmakeraw省心。还有一个细节O_NOCTTY这个标志。如果程序是一个后台守护进程不加这个标志打开的串口可能变成控制终端进程收到来自串口的特殊字符比如CtrlC的0x03就可能被信号打断。嵌入式项目里串口IO程序一般都是后台跑的这个标志务必加上。2.4 RS485方向控制的处理要说串口开发里最容易出问题的环节RS485方向控制绝对是榜首。RS485是半双工总线同一时刻只能有一个方向的数据在线上传输。收发器芯片的DE发送使能和RE接收使能引脚需要被控制发送时拉高DE接收时拉高RE有的芯片RE是低有效实际是拉低RE。Linux下处理RS485方向有几种方式。最规范的是用内核的RS485框架通过ioctl系统调用配置TIOCSRS485让串口驱动自动控制方向。#include linux/serial.h #include sys/ioctl.h struct serial_rs485 rs485conf; memset(rs485conf, 0, sizeof(rs485conf)); rs485conf.flags SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; rs485conf.delay_rts_before_send 1; /* 发送前延时单位ms */ rs485conf.delay_rts_after_send 1; /* 发送后延时单位ms */ ioctl(fd, TIOCSRS485, rs485conf);这种方式要求内核的串口驱动本身支持RS485控制很多工业级开发板的内核已经支持。配置之后驱动会在UART发送时自动拉高RTS/DE信号发送完再自动拉低切换为接收用户程序完全不用管方向省心。如果内核不支持那就只能手动控制GPIO了。方法是通过/sys/class/gpio导出GPIO或者用libgpiod库在每次发送前拉高方向引脚发送完成后拉低。注意发送完成后必须加一点延时再切换到接收模式因为RS485收发器芯片的切换时间大概在几百纳秒到几微秒之间但总线上最后一个字节的信号还需要一定时间稳定我实际测试时在9600波特率下加1ms的切换延时基本就稳了。手动控制GPIO的核心代码片段/* 假设方向引脚对应GPIO芯片的line 5使用libgpiod */ struct gpiod_chip *chip gpiod_chip_open_by_name(gpiochip0); struct gpiod_line *line gpiod_chip_get_line(chip, 5); gpiod_line_request_output(line, rs485_dir, 0); /* 发送前 */ gpiod_line_set_value(line, 1); usleep(1000); /* 1ms切换延时 */ write(fd, frame, len); tcdrain(fd); /* 等待数据全部发出 */ gpiod_line_set_value(line, 0);tcdrain这个函数很多人不知道。它的作用是阻塞直到输出缓冲区里的数据全部发送完毕。如果不调用它write返回后数据可能还躺在内核的发送缓冲区里你这时把方向切到接收最后一个字节就发不出去对端CRC校验必然失败。这是我踩过的实实在在的坑后面会再提。3. Modbus RTU协议核心帧格式与寄存器映射3.1 报文帧结构拆解Modbus RTU的报文帧格式非常紧凑每帧由四部分组成从站地址1字节、功能码1字节、数据段N字节、CRC16校验2字节。总长最短8字节最长没有硬性上限但实际应用中数据段一般不超过250字节。举个例子我要读取从站地址为1的温湿度传感器的保持寄存器起始地址0x0000读取2个寄存器。请求帧就是01 03 00 00 00 02 C4 0B逐字节拆开看01是从站地址03是功能码读保持寄存器00 00是寄存器起始地址00 02是读取数量C4 0B是前面6个字节的CRC16校验值低字节在前。传感器的正常响应帧类似01 03 04 12 34 56 78 A3 B1其中01是从站地址回显03是功能码回显04是数据字节数2个寄存器×2字节412 34 56 78是寄存器数据后面两个字节是CRC。如果出错从站不会回正常帧而是回异常帧功能码的最高位置1然后跟一个异常码。比如请求的寄存器地址越界从站回01 83 02 C0 F1其中830x03|0x80表示异常02是异常码含义是非法数据地址Illegal Data Address。开发的时候遇到响应帧功能码高位是1直接对照异常码表就能定位问题。帧与帧之间的间隔也有讲究。Modbus RTU规定两个相邻帧之间至少要间隔3.5个字符传输时间。9600波特率下一个字符大约1ms10bit/96003.5个字符就是3.5ms。这个间隔的作用是让从站判断一帧数据是否结束。从站通常以3.5字符时间长度的静默作为帧结束标志。所以主机在发送完一帧后至少要等待3.5字符时间再发下一帧否则从站可能把两帧粘在一起解析产生错帧。我用libmodbus调试时没太在意这个因为库内部已经处理了但自己手写协议栈时这个延时必须写进代码。3.2 CRC16校验原理与代码实现CRC16-Modbus是Modbus RTU最核心的校验算法它的多项式是0x8005初始值为0xFFFF数据位处理顺序是按位反向的即所谓reflected算法最终结果低字节在前发送。很多新手在这里栽跟头拿着从网上抄的CRC16代码用的多项式是0x1021这是CCITT的算出来的校验码怎么都对不上然后怀疑人生。正确实现是查表法效率高适合嵌入式中频繁组帧的场景#include stdint.h static uint16_t crc16_modbus(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; } /* 发送时先填充低字节再高字节 */ void append_crc(uint8_t *frame, uint16_t len) { uint16_t crc crc16_modbus(frame, len); frame[len] crc 0xFF; /* 低字节在前 */ frame[len 1] (crc 8) 0xFF; }0xA001就是0x8005反向之后的常数。这里不做数学推导记住这个结论就行。如果你不想写代码也可以用现成的开源工具计算校验值像Modbus Poll、Modbus Slave这些调试软件都自带CRC计算显示在线计算器也很多验证自己的代码时很方便。校验还有一个常见用法收到从站响应后先对整帧除了CRC字节之外的部分重新计算CRC跟收到的CRC比较如果不一致就丢弃这帧数据。这是Modbus通信可靠性的最后一道防线。我见过有人只在发送时算了CRC接收时完全不做校验结果现场有电机干扰导致数据偶尔跳变排查了很久才发现是漏了这一步。接收校验的代码在下面的实现章节里会给出完整示例。3.3 功能码与寄存器地址映射Modbus协议定义了多个功能区但咱们做传感器采集90%的情况下只需要用到两个功能码03读保持寄存器和04读输入寄存器。这两个的区别在于03读的是可读可写的保持寄存器04读的是只读的输入寄存器。很多传感器的测量值都映射为输入寄存器3xxxx但也有一些厂商为了方便用户校准把测量值同时映射成保持寄存器4xxxx。这里要特别注意寄存器地址的编号体系。Modbus协议报文里使用的地址是0基地址而设备手册或组态软件里显示的地址往往是1基地址或者带区号的地址。举例传感器手册上写温度寄存器地址40001这里的4是寄存器类型号保持寄存器0001是1基地址那么报文里的实际地址就是0x0000。如果手册上写温度寄存器地址30004输入寄存器第4个那报文里的地址就是0x0003。搞混这个关系是Modbus开发最常见的错误之一很多调试不通的案例最后都发现是地址偏移了1。我习惯在代码里加一个辅助函数做地址换算/* 将4xxxx或3xxxx的寄存器号转为帧内的0基地址 */ uint16_t regnum_to_addr(uint16_t regnum) { return regnum - 1; }除了03和04偶尔还会用到06写单个保持寄存器和16写多个保持寄存器。比如校准传感器量程、写仪表参数、控制执行器开度都是通过这两个功能码实现的。写寄存器请求帧的结构比读稍复杂一点06功能码的请求是地址寄存器地址寄存器值16功能码的请求是地址功能码起始地址寄存器数量字节数寄存器数据CRC。3.4 传感器数据类型转换16位整数与IEEE754浮点数在Linux应用层我们习惯用float表示温湿度这种连续物理量但Modbus寄存器只有16位所以传感器厂商用了各种编码方式来映射数据。最常见的有两种纯整数定标和IEEE754浮点数。纯整数定标最简单寄存器值是整数除以一个系数就是实际物理值。比如温湿度传感器湿度精度0.1%寄存器值5346除以10就是53.46%。温度如果是0.01℃精度寄存器值2234就是22.34℃。这种类型读取后直接做一次除法就行。IEEE754浮点数是另一种常见编码一个32位float放在连续的两个16位寄存器里。读取顺序可能是高字在前大端也可能是低字在前小端看传感器手册。大多数国产传感器默认高字在前也就是第一个寄存器是float的高16位第二个寄存器是低16位。转换代码#include string.h #include stdint.h /* regs[0]为高16位regs[1]为低16位 */ float regs_to_float_bigendian(uint16_t *regs) { uint32_t raw ((uint32_t)regs[0] 16) | regs[1]; float val 0.0f; memcpy(val, raw, sizeof(float)); return val; } /* regs[0]为低16位regs[1]为高16位 */ float regs_to_float_littleendian(uint16_t *regs) { uint32_t raw ((uint32_t)regs[1] 16) | regs[0]; float val 0.0f; memcpy(val, raw, sizeof(float)); return val; }注意这里用memcpy而不是直接指针强转是因为涉及内存对齐问题在ARM平台上未对齐的float访问可能触发总线错误用memcpy是最稳妥的写法。还有一种少见但存在的类型是32位有符号整数比如一些流量计、电量表同样跨两个寄存器转换逻辑就是把16位拼成32位再转成int32_t。另外有些传感器把负数用补码表示或者有符号/无符号混用这些细节都要以手册为准。我的经验是拿到一颗新传感器先用手册上给的示例数据反推编码格式确认无误再写代码。4. 读写传感器数据的代码实现4.1 方案一基于libmodbus库快速实现如果目标板上能装软件包libmodbus是效率最高的方案。在嵌入式Linux上如果用的是Buildroot或Yocto构建系统直接在配置里勾选libmodbus就会自动编译进rootfs。如果是手工交叉编译去GitHub下载源码standard autotools流程./configure --hostarm-linux-gnueabihf --prefix/usr/local/arm make make install DESTDIR$SYSROOT编译好后应用层代码非常简洁#include stdio.h #include modbus/modbus.h int main(void) { modbus_t *ctx NULL; uint16_t regs[4] {0}; int rc; /* 1. 创建RTU上下文参数依次为串口设备、波特率、校验位、数据位、停止位 */ ctx modbus_new_rtu(/dev/ttyUSB0, 9600, N, 8, 1); if (ctx NULL) { fprintf(stderr, modbus_new_rtu: %s\n, modbus_strerror(errno)); return -1; } /* 2. 如果需要启用RS485内核模式 */ modbus_rtu_set_serial_mode(ctx, MODBUS_RTU_RS485); /* 3. 设置从站地址 */ modbus_set_slave(ctx, 1); /* 4. 设置应答超时时间秒、微秒 */ modbus_set_response_timeout(ctx, 1, 0); /* 5. 建立连接 */ if (modbus_connect(ctx) ! 0) { fprintf(stderr, modbus_connect: %s\n, modbus_strerror(errno)); modbus_free(ctx); return -1; } /* 6. 读保持寄存器从地址0开始读4个寄存器 */ rc modbus_read_registers(ctx, 0, 4, regs); if (rc 0) { fprintf(stderr, read failed: %s\n, modbus_strerror(errno)); } else { printf(reg00x%04X reg10x%04X reg20x%04X reg30x%04X\n, regs[0], regs[1], regs[2], regs[3]); /* 转换浮点数 */ float temp regs_to_float_bigendian(regs[0]); float humi regs_to_float_bigendian(regs[2]); printf(temp%.2f, humi%.2f\n, temp, humi); } modbus_close(ctx); modbus_free(ctx); return 0; }modbus_set_response_timeout这个接口也被很多新手忽略。它决定了从站不响应时主站要等多久才放弃。默认值是0.5秒如果你的传感器响应慢或者总线上挂的设备多导致轮询周期长建议调大一点。还有modbus_set_byte_timeout这个参数控制字节间超时也就是帧结束判断一般情况下用默认就行。我实测下来用libmodbus在一个256字节缓冲区、4KB池里面跑Modbus RTU轮询性能完全够用CPU占用率几乎可以忽略。库的内部还自带一个简单的数据锁多线程环境下比如一个线程轮询读、一个线程处理数据用modbus_get_socket配合modbus_set_socket做线程间转移也可以但不建议搞太复杂多进程加共享内存反而更稳。4.2 方案二手写最小Modbus主站完整代码如果目标环境是RTOS或者裸机或者你想彻底掌控协议细节那就自己写。一个最小可用的Modbus RTU主站核心就四个函数串口初始化前面已给、CRC计算前面已给、发送请求帧、接收并校验响应帧。#include stdio.h #include stdint.h #include string.h #include unistd.h #include fcntl.h #include termios.h #include errno.h #include sys/select.h #include time.h /* 帧结构addr func data... crc_low crc_high */ /* 发送读保持寄存器请求功能码03 */ int modbus_read_holding_regs(int fd, uint8_t slave, uint16_t start_addr, uint16_t num, uint16_t *regs) { uint8_t tx[8]; uint8_t rx[256]; int len, expected, i; uint16_t crc_calc; struct timeval timeout; fd_set fds; /* 组请求帧slave, 03, addr_hi, addr_lo, num_hi, num_lo */ tx[0] slave; tx[1] 0x03; tx[2] (start_addr 8) 0xFF; tx[3] start_addr 0xFF; tx[4] (num 8) 0xFF; tx[5] num 0xFF; crc_calc crc16_modbus(tx, 6); tx[6] crc_calc 0xFF; tx[7] (crc_calc 8) 0xFF; /* 清空缓冲区再发送 */ tcflush(fd, TCIOFLUSH); if (write(fd, tx, 8) ! 8) { return -1; } tcdrain(fd); /* 等待发送完成尤其是RS485场景 */ /* 接收响应select等待可读 */ FD_ZERO(fds); FD_SET(fd, fds); timeout.tv_sec 1; timeout.tv_usec 0; int sel select(fd 1, fds, NULL, NULL, timeout); if (sel 0) { return -2; /* 超时 */ } len read(fd, rx, sizeof(rx)); if (len 0) { return -3; } /* 检查最小帧长addrfuncbytecount2*numcrc2 */ expected 3 2 * num 2; if (len expected || len 8) { return -4; /* 帧长不符 */ } /* 检查地址和功能码回显 */ if (rx[0] ! slave || rx[1] ! 0x03) { /* 判断是否异常帧 */ if ((rx[1] 0x80) ! 0) { printf(slave exception code: 0x%02X\n, rx[2]); return -5; } return -6; } /* 检查CRC */ crc_calc crc16_modbus(rx, len - 2); if (rx[len - 2] ! (crc_calc 0xFF) || rx[len - 1] ! ((crc_calc 8) 0xFF)) { return -7; /* CRC错误 */ } /* 提取寄存器值大端 */ for (i 0; i num; i) { regs[i] ((uint16_t)rx[3 i * 2] 8) | rx[4 i * 2]; } return num; }这个函数实现了一个完整的读保持寄存器事务。几个值得注意的点调用write前先tcflush是为了把上次可能残留的垃圾数据清掉避免影响后面的接收tcdrain在RS485场景下是必须的因为要等数据真正发出硬件才能切方向接收用select做超时控制比直接read阻塞要安全得多。正常响应帧的rx[2]是数据字节数也可以用它来判断期望数据长度比我上面的expected计算更灵活。我在实际代码里是两种都做校验先按功能码和数据数量算出expected再跟rx[2]比对双保险。4.3 加入超时重试与多传感器轮询机制工业现场环境复杂通信偶尔失败是常态所以主站程序必须有超时重试机制。我用的策略是同一请求失败后间隔100ms重试最多重试3次3次全失败就把该从站标记为离线继续轮询下一台设备等到下一轮再尝试恢复。这样不会因为一台设备故障而阻塞整条总线的轮询。typedef struct { int fd; uint8_t slave_addr; int retries; int timeout_ms; } modbus_master_t; int modbus_read_with_retry(modbus_master_t *m, uint16_t addr, uint16_t num, uint16_t *regs) { for (int attempt 0; attempt m-retries; attempt) { int rc modbus_read_holding_regs(m-fd, m-slave_addr, addr, num, regs); if (rc 0) return rc; usleep(100 * 1000); /* 100ms后重试 */ } return -1; }多传感器轮询就是简单的循环注意每台设备之间至少要留3.5字符时间的静默间隔。轮询周期的计算假设总线上挂8台设备每台读4个寄存器每轮请求加响应大约20ms9600波特率加上设备间间隔和重试余量一轮下来大概200ms左右。可以把这些参数做成配置项调试时灵活调整。现场总线上的设备地址要提前规划好。Modbus从站地址范围是1到247地址0是广播地址仅限写命令不能分配给具体设备。我在项目里把传感器按类型编址1到3号是温湿度传感器4到6号是光照传感器7号是预留的气象站。每颗传感器上电前用拨码开关或者配置工具设好地址拨码设置和程序里的地址表要对上不然就会串数据。有一回我就是新到的一台传感器没拨地址就上电了默认地址跟已有的1号冲突总线上两台上报互相干扰折腾了我半天。5. 常见问题排查与实战避坑记录5.1 排查工具与基础方法串口调试最怕瞎猜手里没工具全靠感觉调参效率极低。我常用的排查工具和手段按优先级排序逻辑分析仪是第一个推荐的。市面上二十几块的USB逻辑分析仪就够用配合开源软件sigrok/PulseView直接夹在RS485收发器芯片的RO接收输出和DI数据输入引脚上可以看到最原始的UART波形和字节流能直观判断主机到底发了什么、有没有收到从站响应、波形有没有毛刺。比在应用层printf打日志靠谱得多。第二个工具是Modbus Poll和Modbus Slave这对PC端软件。Modbus Poll模拟主站Modbus Slave模拟从站。排查问题的时候用USB转RS485把PC和传感器接起来用Modbus Poll手动发协议帧看能不能正常读到数据。如果PC端能读通、板子上读不通那问题就出在板子的串口配置或方向控制上如果PC端也读不通那就是传感器地址、参数或接线的问题板子代码完全无辜。第三个手段是Linux命令行工具快速确认串口配置。stty -F /dev/ttyUSB0 9600 -F可以查看当前串口参数。cat /dev/ttyUSB0 | xxd可以用来盲看串口上有没有自发自收的数据但要注意cat会阻塞建议配合timeout 3 cat /dev/ttyUSB0 | xxd使用。还有个更直接的Python工具pyserial写调试脚本非常方便我这里就不展开代码了。5.2 常见问题速查表现象可能原因排查方向完全收不到响应read超时从站地址错误、波特率不匹配、RS485方向未切换、A/B线接反用Modbus Poll验证PC直读能否成功检查拨码地址用逻辑分析仪看发送波形收到数据但全是乱码波特率/校验位/数据位设置与传感器不一致对照传感器手册确认串口参数看波形位数是否正确收到响应但CRC校验总失败帧截断、字节丢失、线缆干扰、方向切换时序不对检查接收缓冲大小是否够缩短线缆距离方向切换后增加延时极少数帧偶发错误RS485终端电阻缺失、共模干扰、电源纹波增加终端电阻拉通信号地传感器和主控共地电源和信号线分离布线请求一帧从站回异常帧寄存器地址越界、功能码不支持、数据数量超限对照异常码定位核对寄存器地址是0基还是1基多个设备总有一个不通地址冲突、某个从站故障拉死总线逐个断开设备排查给故障设备单独上电测试程序刚跑通过一会儿就不通了串口被其他进程占用、驱动崩溃lsof /dev/ttyUSB0查看占用dmesg查内核日志重启应用恢复5.3 我踩过的几个坑第一个坑是RS485方向切换的时序。最初用GPIO控制方向时我在write之后立刻拉低方向引脚切回接收模式结果从站响应经常收不全。后来用逻辑分析仪一看主机的最后一个字节还在TXD线上因为内核缓冲区还没flush完方向就切了。加了tcdrain之后问题消失。这个问题的隐蔽性在于波特率低9600时偶尔能通因为发送一个字节的时间够长方向切换的那点时间刚好错开了一旦波特率拉到38400或者115200问题立刻爆发。第二个坑是传感器地址的拨码开关状态和宣称值不一致。我们的传感器背面的贴纸标注的是地址1但我用Modbus Poll怎么都读不通。后来用Modbus Slave监听发现从站根本没有响应任何地址请求。拔掉重插仔细看拨码开关才注意到拨码方向跟贴纸箭头相反实际地址是16。这个问题纯粹是硬件操作层面的但排查成本极高等于把协议、驱动、接线全查了一遍才发现是开关拨错了。经验就是遇到完全读不通的情况先拿一颗传感器单独上电拿PC端工具慢速排查别一上来就怀疑代码。第三个坑是终端电阻的低频表现。我的项目现场布线有近五十米前期短距离测试一切正常拉到现场后通信开始间歇性失败。起初怀疑是干扰加了屏蔽线、共地点都改善不大。最后用示波器看总线波形发现沿的振铃非常明显判断是缺少终端电阻。主控板和传感器端各加120欧姆电阻后波形干净了通信也稳定了。这件事让我意识到终端电阻不是可选件是长距离RS485通信的基础设施千万别省。第四个坑是关于O_NONBLOCK的使用。一开始我在open时加了O_NONBLOCK配合select做读超时逻辑上没毛病。但后来发现read偶尔返回EAGAIN代码还得处理这个错误分支。后来我干脆改回阻塞模式用select做超时判断read就干净利落了。open时的O_NDELAY和O_NONBLOCK对某些USB转串口芯片的行为不一致建议统一使用阻塞模式加select的方案。5.4 稳定性的最后一道关口日志与看门狗嵌入式设备部署到现场后不是你坐在旁边盯着调试的日子了程序必须自己会报病、自己会恢复。我给这个Modbus采集程序加了三层保护。第一层是详细的分级日志。正常轮询每轮打一条debug日志包含所有设备的寄存器值和耗时通信失败打warn日志记录设备地址、失败原因、重试次数连续失败打error日志标记设备离线。日志写到syslog或者本地文件方便之后翻查。第二层是模块级看门狗。用一个独立线程监控主轮询线程的活跃度主线程每完成一轮轮询就更新一次时间戳看门狗线程检查如果超过设定周期比如10秒没有更新说明主线程卡死了可能是串口驱动异常阻塞直接触发应用重启。配合系统级的硬件看门狗/dev/watchdog应用挂了之后系统自动重启现场不用人管。第三层是异常自恢复。当某个串口请求连续失败超过一定次数程序自动做一次串口重连先close再重新open、重新配置termios。有些USB转串口芯片在总线异常后会出现假死重连能恢复大部分问题。这个操作在无人值守的现场价值巨大至少帮我少跑了三趟机房。串口和Modbus这套东西原理不复杂代码量也不大真正考验人的是对细节的把控。我现在做一个设备对接流程已经固定了先PC工具验证传感器参数再用逻辑分析仪看波形最后才动手写代码。这个顺序看着多花了一点时间实际是总耗时的最短路径因为每一步都排除了一个层面的变量。希望这篇记录能帮你少走点弯路。后面如果时间允许我再写一篇关于这套采集程序如何对接MQTT网关把数据上云的文章那又是一个有意思的话题了。
返回列表