嵌入式Linux下Modbus RTU串口通信实战:从配置到传感器数据读取

嵌入式Linux下Modbus RTU串口通信实战:从配置到传感器数据读取 搞嵌入式Linux开发的人十个里有八个迟早要碰Modbus。不管你是接温湿度传感器、读电能表还是跟PLC对接Modbus RTU这套玩不转现场调试能把你耗到怀疑人生。这篇文章就把我最近一次在嵌入式Linux设备上通过串口用Modbus RTU协议读写传感器数据的完整过程摊开讲从串口配置到底层报文计算再到实际代码实现和踩坑记录一条龙讲清楚。先说这东西是什么、能解决什么问题。Modbus RTU是串行链路上应用最广的工业通信协议物理层基于RS232或RS485数据帧简单可靠。在Linux端你要干的事就三件把串口配好、按协议组帧发出去、把回包解析出来。这篇文章适合正在做嵌入式Linux应用开发的工程师或者刚入门想搞明白Linux下到底怎么跟Modbus设备通信的人。我不讲学院派理论全是从板子上跑出来的经验。1. 项目背景与整体方案选型1.1 需求拆解这次的场景很典型一块ARM Linux开发板Cortex-A7核心跑Buildroot裁剪出来的系统需要读取两个 Modbus RTU 传感器一个是环境温湿度一个是管道压力都是标准RS485接口波特率96008数据位、无校验、1停止位。板子本身引出两路RS485串口设备节点分别是 /dev/ttyS1 和 /dev/ttyS2。用户的需求说出来很简单定时采集数据上报平台。但落到工程上就拆成了几块硬骨头串口底层怎么配置成裸数据模式而不是被终端驱动干扰。怎么组一条合法的Modbus RTU读请求帧CRC怎么算。收到一串字节流后怎么从里面把传感器数值抠出来还得分清字节序。多传感器轮询时单站还是多站一次读几个寄存器超时、断线、CRC校验失败怎么处理总不能一卡就死。这些点随便哪个没处理好现场就是数据乱跳或者读不到数。1.2 为什么选Modbus RTU而不是Modbus TCP有朋友会问现在不都推Modbus TCP吗网口多方便为什么还要碰串口RTU我只能说工业现场里传感器级别的设备RS485 Modbus RTU的存量太大了。成本极低、抗干扰强、能走几百米而且不少现场根本没有布网线。TCP适合上位机互联和控制器之间的通信但到了传感器这个层级RTU仍然是事实标准。另外不少网口转串口的网关设备底层透传的还是RTU帧。所以做嵌入式Linux开发RTU这关绕不过去。选型上还有一个细节如果板子只有标准UART比如 16550 兼容无FIFO硬件控制建议在设备树或内核配置里把串口流控关掉。RS485的方向切换有几种做法后面专门讲。这一步选错后面调试全靠运气。1.3 硬件层面的准备工作硬件部分没做对软件写得再漂亮也是白搭。我这次用的是板载SP3485半双工RS485收发芯片带自动方向控制电路。如果你手里板子的RS485转换芯片不带自动切换而是需要MCU手动拉高/拉低方向引脚那就要额外占用一个GPIO读之前拉高发送使能读完之后拉低。Linux下可以通过GPIO中断或者sysfs/ioctl来操作但这会让时序控制复杂不少。我的建议是如果是自己做板子优先选带自动方向控制的方案如果是买现成模块看清楚芯片型号别买到那种需要手动切方向的还傻乎乎以为是自己代码问题。另外终端电阻和总线偏置也要留意。RS485总线上如果只有一主一从两个设备链路短、波特率不高多数情况不加终端电阻也能通。但如果总线长了、节点多了还是老实加上120欧终端电阻。我踩过一次坑一条50米的线上挂了8个温湿度传感器第一批次读出来的数据全都正常跑到第5个就乱码后来发现是终端电阻没加信号反射把数据帧冲乱了。2. Linux串口配置termios结构体逐项拆解2.1 串口参数最基本的几个概念Linux下串口配置的核心就是操作struct termios结构体。很多人看到一坨c_cflag、c_iflag就头大其实掰开了就三件事告诉系统这串口我用来传原始数据的别给我处理换行符回显然后把波特率、数据位、校验位、停止位设对最后把读取超时机制配好。这里必须用原始模式也就是cfmakeraw()。如果不调用这个函数你可能遇到一个经典怪问题发送的字节里只要出现\n系统自动在你发的数据流里加一个\r导致帧错位。更坑的是从设备返回的数据里某些字节被内核当成了控制字符处理导致recv返回的字节数和实际不一致。我最初接手这个项目时上一个同事的代码就没用cfmakeraw结果用串口助手发出来的报文是对的但Linux程序发出去的帧就是错的。查了一上午最后拿示波器量波形才发现多了一个字节。2.2 完整配置代码及每个参数的含义直接上一段我在项目里用的串口初始化函数这段代码实测可以直接用各位可以按自己板子的设备名替换。#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include string.h #include errno.h int uart_open(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial port failed); return -1; } struct termios options; memset(options, 0, sizeof(options)); /* 获取当前串口参数 */ tcgetattr(fd, options); /* 设置为原始模式禁止系统对数据流做任何二次处理 */ cfmakeraw(options); /* 使能接收忽略调制解调器控制线 */ options.c_cflag | CLOCAL | CREAD; /* 数据位8位 */ options.c_cflag ~CSIZE; options.c_cflag | CS8; /* 无校验 */ options.c_cflag ~PARENB; options.c_cflag ~PARODD; /* 1位停止位 */ options.c_cflag ~CSTOPB; /* 禁用硬件流控 */ options.c_cflag ~CRTSCTS; /* 禁用软件流控 */ options.c_iflag ~(IXON | IXOFF | IXANY); /* 原始模式下的读取超时配置 */ options.c_cc[VTIME] 10; /* 最多等待 1 秒 */ options.c_cc[VMIN] 1; /* 至少读到 1 个字节 */ /* 设置波特率 */ cfsetispeed(options, baud); cfsetospeed(options, baud); /* 写入配置 */ if (tcsetattr(fd, TCSANOW, options) ! 0) { perror(tcsetattr failed); close(fd); return -1; } /* 清空缓冲区防止旧数据干扰 */ tcflush(fd, TCIOFLUSH); return fd; }代码里我用了O_NDELAY这样open()不会因为DCD信号线状态而阻塞。后面CLOCAL | CREAD这两个标志必须加CLOCAL表示不依赖串口线路上的载波信号CREAD使能接收。少了这两个可能出现读不到任何数据但程序也不报错的玄学问题。VTIME和VMIN的组合值得展开讲。设置成VTIME10, VMIN1时read() 会阻塞到至少收到1字节之后如果还有数据就继续等但总等待时间不超过1秒。我这个项目里Modbus响应一般35ms以内到所以1秒超时绰绰有余。如果你用阻塞 read 时没有设置好超时串口没数据时会一直卡死在 read() 上这种问题在轮询线程里尤其致命。2.3 串口配置常见误区第一个经典误区就是乱设c_cflag的位段。建议每次配置前先memset再一个个置位不要直接在旧参数上改否则容易残留一些莫名其妙的标志位。第二个误区是把cfmakeraw()当成万能药结果收发还是不对。其实cfmakeraw()做的事是把输入输出处理、回显、信号、换行转换都关掉但它不会帮你设置波特率、数据位、停止位。有很多人调了半天发现校验位不对其实是忘了手动设置c_cflag。第三个误区跟多线程有关。如果程序里同时有多个线程往同一个串口fd里写数据必须加锁或者干脆用一个独立的发送队列。否则两个线程同时写可能把一帧Modbus报文给交叉拼接到一块对方设备收到的就是乱码。3. Modbus RTU协议帧格式与CRC16计算3.1 报文帧结构Modbus RTU的帧结构枯燥但必须背下来。主站发起的读取请求帧长固定8字节从站地址(1字节) 功能码(1字节) 起始寄存器地址(2字节高字节在前) 寄存器数量(2字节高字节在前) CRC16(2字节低字节在前)。从站正常响应的帧长不固定从站地址 功能码 字节数 数据(2字节/寄存器) CRC16。我这次用的温湿度传感器是单寄存器组合型压力传感器也是类似结构都用功能码03读保持寄存器就能搞定。有些传感器用功能码04读输入寄存器写法一样只是功能码不同别搞混就行。地址分配上这次两路RS485都是单站连接所以从站地址分别是0x01和0x02。如果多个设备挂同一条总线每个设备的Modbus地址必须唯一而且轮询间隔要设计好因为所有从站共享同一条物理链路同一时刻只能有一个设备在讲话。3.2 CRC16查表法实现Modbus RTU的CRC校验用CRC-16/MODBUS算法多项式是0x8005初始值0xFFFF输出结果低字节在前所以帧里的校验字节要高低位交换写入。网上有很多查表法、按位计算法我这次直接用了业界通用的查表实现生成CRC16的速率比按位算快不少在高频率轮询场景下更稳。/* CRC16 查表法 */ static unsigned short crc16_table[256]; void crc16_init_table(void) { for (int i 0; i 256; i) { unsigned short crc i; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc crc 1; } crc16_table[i] crc; } } unsigned short modbus_crc16(unsigned char *buf, int len) { unsigned short crc 0xFFFF; for (int i 0; i len; i) { crc (crc 8) ^ crc16_table[(crc ^ buf[i]) 0xFF]; } return crc; }注意这里用的是0xA001这是0x8005的逆序多项式是Modbus标准算法精确定义的。你要是拿通用CRC16算法比如CRC-16/X25去算出来的结果铁定对不上这是新手最容易掉的一个大坑。组装请求帧时把算出来的CRC先放低字节再放高字节。很多刚上手的人写反了结果从站直接忽略这条请求没有任何响应卡在那干等超时。3.3 功能码选择与IEEE754浮点转换读取传感器数据时功能码最常见的就是03和04。这两者的区别简单说03读保持寄存器04读输入寄存器。工业传感器里配置参数通常放在保持寄存器实时测量值通常放在输入寄存器。但我手上这批传感器比较统一实时数据都在保持寄存器里直接功能码03一把梭就行。传感器返回的数据多数是16位整数。比如温湿度传感器温度寄存器存的是扩大10倍后的值比如245代表24.5摄氏度。但压力传感器返回的是32位IEEE754浮点数占了两个寄存器这时就要手动做拼接和字节序调整。IEEE754 4字节转 float在C语言里最稳妥的办法是memcpy而不是强制类型转换。因为不同平台的字节序和内存对齐规则可能不一样强制类型转换很容易翻车。我封装了一个函数float regs_to_float(unsigned short low, unsigned short high) { uint32_t val 0; /* 常见传感器默认high寄存器在前即大端存储 */ val ((uint32_t)high) 16; val | ((uint32_t)low); float f; memcpy(f, val, 4); return f; }这里必须提一个坑不同厂商传感器的寄存器字节序不统一。有的传感器是 A B C D 直接对应 4个寄存器字节有的则是 C D A B 高低16位反着来。我这次用的压力传感器数据手册没细说结果解析出来的浮点数大得离谱。最后是抓了设备主动上报的一帧数据手动按浮点数试了几种字节序组合才确定是 low/high 的换序方式。这块建议大家都养成一个习惯拿到新传感器先手工构造一帧响应用串口助手模拟从站回包把每种字节序都算一遍对比省得现场抓瞎。4. 传感器数据读写实操从请求报文到浮点解析4.1 读取保持寄存器的完整函数这一节直接给可用的代码。我封装了modbus_read_registers()函数职责单一往指定地址发读请求阻塞等待响应返回数据长度。里面包含了组帧、发送、接收、CRC校验四个环节。int modbus_read_registers(int fd, unsigned char slave_addr, unsigned short start_reg, unsigned short reg_count, unsigned char *rsp_buf, int rsp_buf_size) { unsigned char req[8]; int req_len 8; req[0] slave_addr; req[1] 0x03; /* 功能码读保持寄存器 */ req[2] (start_reg 8) 0xFF; req[3] start_reg 0xFF; req[4] (reg_count 8) 0xFF; req[5] reg_count 0xFF; unsigned short crc modbus_crc16(req, 6); req[6] crc 0xFF; /* 低字节在前 */ req[7] (crc 8) 0xFF; tcflush(fd, TCIOFLUSH); if (write(fd, req, req_len) ! req_len) { perror(write failed); return -1; } /* 读取响应这里按最坏情况等待 */ int total 0; int nread 0; while (total rsp_buf_size) { nread read(fd, rsp_buf total, rsp_buf_size - total); if (nread 0) { if (errno EAGAIN || errno EWOULDBLOCK) continue; perror(read failed); return -1; } else if (nread 0) { break; } total nread; /* 收到一帧完整报文地址 功能码 字节数 数据 CRC2 */ if (total 5) { int data_len rsp_buf[2]; /* 字节数字段 */ int frame_len 3 data_len 2; if (total frame_len || total 256) break; } } if (total 5) { fprintf(stderr, response too short: %d\n, total); return -1; } /* CRC 校验 */ unsigned short crc_rcv rsp_buf[total-1] 8 | rsp_buf[total-2]; unsigned short crc_cal modbus_crc16(rsp_buf, total-2); if (crc_rcv ! crc_cal) { fprintf(stderr, crc error: 0x%04X ! 0x%04X\n, crc_rcv, crc_cal); return -1; } /* 异常响应功能码最高位为1 */ if (rsp_buf[1] 0x80) { fprintf(stderr, modbus exception code: 0x%02X\n, rsp_buf[2]); return -1; } return total; }发送前先tcflush是我个人的习惯作用是把之前可能残留的垃圾数据清掉免得刚发完请求就收到一堆上一次会话的旧字节干扰解析。读响应时我没有盲等固定长度而是根据响应帧里的字节数字段动态判断完整帧长度这样能兼容不同寄存器数量的响应。4.2 响应帧解析与超时处理上面代码里已经有边读边判帧长的逻辑这里单独说超时的处理思路。Modbus RTU标准里帧与帧之间的间隔要求是3.5个字符时间以上也就是说从站收到主站的请求后必须等总线静默足够久才能回复完整。Linux下我们在应用层做超时主要靠read()的VTIME设置。之前代码里配置成1秒超时对轮询采集完全够用。但要注意一个细节如果在轮询线程里连续调用read()而某一次从站没回复read()会阻塞到超时返回0整个周期就被拖长了。解决思路是把超时设短一点比如VTIME5500ms没数据就快速失败进入下一次轮询或者用select()/poll()做带超时的IO多路复用。传感器响应通常在50ms以内所以500ms超时已经留了10倍余量完全合理。我在项目里最终是把这个读取操作放到了独立线程里线程循环里做了计数统计连续3次超时判定为通信异常10秒无响应则重新初始化串口。这个策略要点在于重新初始化时要先拉低RS485的方向线并清空FIFO否则偶尔就会遇到从站正在忙、主站以为线路空闲就发请求结果两条帧在总线上撞车的现象。4.3 轮询调度与多传感器扩展这次只有两个传感器所以轮询逻辑很简单一个for循环按地址轮流读取。但如果你的项目需要接入十几个传感器就要注意几个问题单总线上从站地址不能冲突。每个从站响应时间不同轮询周期没法一刀切最好给每个从站单独设置上次成功通信时间动态调整优先级。需要记录每个从站的错误计数连续出错达到阈值要标记离线而不是一直傻等。我的轮询框架大致是这样typedef struct { unsigned char addr; char name[32]; int fd; int offline_count; int max_offline; } modbus_slave_t; int slave_poll(modbus_slave_t *slave) { unsigned char rsp[256] {0}; int len modbus_read_registers(slave-fd, slave-addr, 0x0000, 2, rsp, sizeof(rsp)); if (len 0) { slave-offline_count; return -1; } slave-offline_count 0; /* 解析第3、4字节为温度第5、6字节为湿度 */ ... return 0; }扩展新传感器时只需要在初始化列表里注册从站地址和设备名轮询线程不用改动。这种协议栈与设备表解耦的设计在项目后期维护时特别省心。5. 调试阶段踩过的坑与排查实录5.1 常见问题速查表做嵌入式Linux串口调试直接面向问题的排查表格最实用。下面这个表是我这个项目实际遇到的问题整理出来的希望能帮你少走弯路。现象可能原因排查方法发送无响应波特率/校验位/停止位与从站不一致用串口助手抓从站是否收到请求逐项核对参数收发乱码没有使用原始模式系统改动了数据流确认调用cfmakeraw()并且没有残留ICANONCRC一直不对算法用错用了标准CRC32或CRC-16/X25确认用CRC-16/MODBUS初始值0xFFFF输出低字节在前偶尔能读到正常数据RS485方向切换和时序问题确认方向控制引脚状态检查是否有硬件自动方向控制帧长度不对不同传感器的寄存器数量不一致解析逻辑写死根据响应帧的字节数字段动态解析不能写死总长度压力值大得离谱IEEE754浮点字节序颠倒分别尝试 high/low 和 low/high 组合用已知数据进行比对大批量采集时丢数据轮询间隔太短从站来不及响应在连续请求之间加入至少50ms的静默间隔按从站实际响应时间调程序重启后第一次通信失败串口状态没有清理干净open()后先tcflush(fd, TCIOFLUSH)清空缓冲区5.2 时序与485方向问题485方向切换是天底下最玄的坑。我这板子用的是带自动方向控制的SP3485按理说时序不用操心。但实际调试时发现数据传输偶尔会在发完请求后立刻切到接收模式而从站刚好在同一时刻开始回复结果头几个字节被吞了。后来查了芯片手册发现自动方向控制电路有个切换延迟虽然只有微秒级但在高波特率下就可能碰上。解决办法有两个方向一是从站地址从0x01开始预留一个长一点的间隙二是在发完请求之后加一个usleep(1000)1ms让总线稳定下来再切换方向。对于9600波特率一个字节大约1.04ms所以1ms的延时对整体轮询影响不大但对于稳定性提升很明显。如果你用的是需要GPIO手动切方向的方案一定要注意发送请求前把方向引脚拉高发送模式发完之后拉低之前必须确保串口FIFO里所有字节都已经真正移位输出完毕。否则你这边处理器认为发完了实际物理层还在往外推最后一个字节你抢先把方向切到接收帧尾CRC就被自己吞了。5.3 调试工具的使用心得调试串口Modbus协议光靠printf是绝对不够的。我建议双管齐下硬件侧用逻辑分析仪或示波器抓总线波形软件侧用PC上的Modbus调试工具做模拟主站和模拟从站。PC端的Modbus Poll主站模拟器和Modbus Slave从站模拟器是我常用的组合。调试流程一般是先用Modbus Slave在PC上模拟传感器按数据手册里的寄存器地址和数值类型预设好数据。然后用Linux板卡上的程序去读取这个模拟从站验证CRC计算、响应解析、浮点转换这些逻辑对不对。再把真实的传感器接上用Modbus Poll定期读取传感器寄存器确认传感器本身的数据格式和参数配置。最后再把Linux板卡程序接上真实传感器做端到端联调。这个流程能帮你把问题隔离到具体环节是协议栈本身错了还是传感器配置不对还是硬件链路有问题。我这次就是靠PC模拟从站 Linux程序读这个组合一下子定位出CRC校验写反了高低字节的问题而不是去现场用万用表查半天线路。逻辑分析仪的话我手头用的是廉价8通道的采样率调到24MHz抓9600波特率的串口完全够用。通过解码出的十六进制报文你可以看到物理层上实际传输的数据跟应用层发出的数据做对比排查线路干扰、字节丢失这类问题特别有效。5.4 关于阻塞和非阻塞的取舍最后补充一个关于串口读写模式选择的个人体会。很多Linux串口教程一上来就让你用非阻塞模式然后告诉你要配合select。但在Modbus这种请求-响应模式下阻塞 短超时的实现更直接代码也更易懂。非阻塞模式的主要优势在同时监听多个文件描述符的场景比如一个程序同时管理好几路串口。如果就是单路传感器轮询我推荐fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) ~O_NONBLOCK);保持阻塞模式配合VTIME超时控制。代码量小也不容易出状态机Bug。只有当你需要同时处理多路串口时才值得上poll()或者事件驱动框架。到最后收尾再分享两个小技巧。第一Modbus RTU的串口日志建议统一打十六进制一帧一帧对齐别混着ASCII打印不然排查时自己都晕。第二所有的传感器地址、寄存器地址、数量这些参数最好都通过配置文件传入程序不要写死在代码里。现场换了一个传感器寄存器布局完全不同的情况我遇到太多了直接改配置重启程序比改代码重新编译快一个数量级。这些习惯养成了做嵌入式Linux的Modbus开发会顺手很多。