嵌入式Linux下RS485总线Modbus RTU传感器数据读取实战 📅 发布时间:2026/9/11 12:55:25 👁 浏览次数: 搞嵌入式Linux也有几年了每次碰到Modbus相关需求说到底就是两件事把串口打通把报文处理好。最近刚完成一个用RK3568开发板通过RS485总线读取多路Modbus RTU传感器数据的项目从串口设备适配到协议解析攒了不少经验。这篇文章就把完整过程拉通讲一遍从串口配置、RTU报文构造到实际读写代码附带我踩过的坑和调试工具用法适合接着Linux串口开发入门的朋友也适合刚上手工控传感器采集的嵌入式工程师参考。1. 项目整体思路与方案选型1.1 为什么选Modbus RTU而不是其他协议工业现场最常见的传感器通信协议就是Modbus它分RTU和TCP两种形态。TCP适合走网络但现场传感器大多是RS485总线接口用Modbus RTU更直接。RTU传输的是二进制帧一帧报文短、解析快在9600波特率下也能稳定跑几十米工业里很多温湿度、压力、液位传感器都原生支持RTU。这次项目里传感器分布在车间不同位置布线用两芯双绞线接RS485主控端用一个USB转485模块接到Linux开发板的串口省去单独拉电源线的麻烦总线供电由传感器侧提供。RTU还有一个好处是主从结构清晰。总线上只有主机主动发请求传感器作为从机应答不会出现多设备同时抢总线的问题。对Linux应用层来说无非就是“打开串口-写请求-读应答”这一套循环没有复杂的连接态管理配合简单的轮询逻辑就能控制几十个节点。1.2 硬件连接与系统环境梳理硬件上最需要注意的就是RS485方向控制。RS485是半双工总线收发共用电线必须保证主机在发送时关闭接收接收时关闭发送。市面上很多USB转485模块内置了自动方向切换电路比如CH340T加MAX13487的方案USB插上就能用方向切换由芯片完成Linux侧不用改GPIO。但如果用SP3485这种不带自动收发切换的芯片就需要额外接一个GPIO控制RE/DE引脚发送前拉高发送完拉低否则会出现自己发自己收、总线冲突之类的怪现象。系统环境我用的是Buildroot编译的嵌入式Linux镜像内核需要启用8250串口驱动和USB-serial驱动。如果板子是ARM架构的通常默认串口节点是/dev/ttyS0、/dev/ttyS1这类USB转485设备一般是/dev/ttyUSB0。上电后先插上USB转485模块用dmesg | grep tty确认设备节点是否识别出来再用ls -l /dev/ttyUSB*看看权限很多情况下还需要把当前用户加入dialout组或者直接chmod 666不然open串口会报Permission denied。2. 串口配置先让数据通路打通2.1 排查串口占用与控制台冲突嵌入式Linux上串口最隐蔽的坑是板子的调试串口和Modbus通信串口共用同一个tty节点。很多开发板默认把/dev/ttyS0作为内核日志输出口也就是getty登录终端这时候应用层打开串口要么失败要么收到一堆乱码日志。解决办法是在内核启动参数里加上consoletty1或者直接移出consolettyS0,115200把调试串口让出来。如果只是临时测试也可以先用systemctl stop serial-gettyttyS0.service停掉登录进程再接程序。可以先运行cat /proc/tty/driver/serial查看串口占用情况也可以lsof /dev/ttyS3看谁在占用设备。确认没有占用之后再继续配参数。实际项目里我用的是/dev/ttyS3不是常见的ttyS0因为ttyS0被我保留给调试用了这样应用日志和传感器通信互不干扰。2.2 用stty验证与termios代码配置Linux下串口配置的标准接口是termios。虽然可以直接用stty -F /dev/ttyS3 9600 raw -echo这种命令行方式快速验证但最终还是要写C代码。先给出一段我封装好的串口初始化函数经过多次项目验证跟着用就行#include stdio.h #include string.h #include fcntl.h #include unistd.h #include termios.h int set_serial(int fd, int baud, int databits, char parity, int stopbits) { struct termios tty; memset(tty, 0, sizeof(tty)); if (tcgetattr(fd, tty) ! 0) { perror(tcgetattr); return -1; } cfsetospeed(tty, baud); cfsetispeed(tty, baud); tty.c_cflag ~PARENB; tty.c_cflag ~CSTOPB; tty.c_cflag ~CSIZE; tty.c_cflag ~CRTSCTS; tty.c_cflag | CLOCAL | CREAD; if (databits 8) tty.c_cflag | CS8; else if (databits 7) tty.c_cflag | CS7; if (parity E || parity e) tty.c_cflag | PARENB; if (parity O || parity o) tty.c_cflag | PARENB | PARODD; if (stopbits 2) tty.c_cflag | CSTOPB; tty.c_iflag ~(IXON | IXOFF | IXANY); tty.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); tty.c_oflag ~OPOST; tty.c_cc[VMIN] 1; tty.c_cc[VTIME] 10; tcsetattr(fd, TCSANOW, tty); tcflush(fd, TCIOFLUSH); return 0; }关键点我都注释在代码里了。CLOCAL和CREAD必须同时打开前者避免程序意外变成“挂断”状态后者是开启接收。如果不开CREAD波特率即使配对了也收不到数据。VMIN1表示读函数等到至少收到1个字节才返回VTIME10表示最多等1秒超时这两个配合起来可以防止无限阻塞。注意baud参数这里传的是termios波特率宏调用的时候要写成int fd open(/dev/ttyS3, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd 0) { perror(open); return -1; } set_serial(fd, B9600, 8, N, 1);用O_NONBLOCK打开再配合后续poll读取比直接阻塞读更优雅。如果打开时用了O_NONBLOCK后面tcsetattr之后记得把文件描述符重新设置回阻塞模式或者始终用select/poll做超时控制。2.3 关键参数背后的逻辑波特率、数据位、校验位Modbus RTU最常见的参数组合是9600, 8, N, 1波特率9600、8个数据位、无校验、1个停止位。不一定所有传感器都这样有些设备默认是19200或者带偶校验所以拿到设备第一件事翻开说明书确认从站地址、波特率和校验方式。这里解释下为什么校验位会影响RTU报文的字节格式Modbus RTU对“字符校验”只考虑奇偶校验但它同时在帧尾还有独立的CRC校验。很多论文里会强调即使使用无校验8N1CRC依然能检测错误所以工控场合用8N1完全没问题。如果设备设置成8E1数据位实际是7个有效数据位加1位偶校验这在读ASCII码字符时更常见RTU里很少用但没有特殊需求就统一按8N1跑。我习惯统一把所有传感器设置为相同的波特率比如全部9600然后写一个全局配置数组每个从站节点包含地址、功能码、寄存器起始地址和数量。这样轮询逻辑和串口参数解耦后面换传感器只需改数组。3. Modbus RTU报文拆解与CRC16实现3.1 报文帧结构与功能码Modbus RTU一帧报文由“从站地址功能码数据CRC校验”组成。地址占1字节功能码1字节数据长度由功能码决定CRC16占2字节低字节在前。帧数据之间没有分隔符完全靠时间间隔区分帧边界标准规定帧内字符间隔不超过1.5个字符时间帧间隔至少3.5个字符时间。在9600波特率下一个字符大约1ms所以接收一帧后至少要等约4ms才能处理下一帧。实际项目里我在发送请求后等待20ms再读避免把传感器应答的“后半截”吞掉。功能码里最常用的是0x03读保持寄存器、0x04读输入寄存器、0x06写单个寄存器、0x10写多个寄存器。传感器数据大多是保持寄存器也就是可以用Modbus工具通过修改寄存器值来校准零点。如果只需要读传感器用0x03和0x04就够。这里有个容易搞混的点保持寄存器是一般意义上的可读写RAM区输入寄存器是只读输入量。很多传感器手册会明确写“读取温度使用功能码03”照做就行。3.2 CRC16计算原理与C语言实现CRC校验是整个RTU报文最容易写错的地方。Modbus的CRC16是基于多项式0x8005反转形式是0xA001初始值是0xFFFF。计算时对每个字节先异或到CRC低字节再右移8次每次检测最低位如果是1就异或0xA001。最终得到的16位值在发送时先放低字节再放高字节。直接给一份能跑的按位实现逻辑简单、好调试uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }如果数据量大、轮询节点多建议改成查表法。查表其实就是把每个字节在所有余数状态下的结果预先算成256项的表格运行时一次异或加查表速度能快不少。但对低速串口来说按位版本已经绰绰有余因为瓶颈在串口传输而不是CRC计算。验证CRC的一个标准方法是把整个请求帧包括地址、功能码、数据、CRC低字节、CRC高字节重新喂给CRC函数计算得到的结果必须是0x0000。这一点可以用来排查收发两端CRC算法是否一致如果收到的应答数据里从机返回的CRC跟自己在主机侧重新算的CRC值对不上说明要么数据在传输中被干扰要么你的CRC算法实现有误。3.3 报文组装实例假设要从站地址1读起始寄存器0x0000连续读2个寄存器对应请求报文是字段值从站地址0x01功能码0x03起始地址高字节0x00起始地址低字节0x00寄存器数量高字节0x00寄存器数量低字节0x02CRC低字节0xC4CRC高字节0x0B这里的CRC值是我用上面的函数算出来的。组装代码uint8_t request[8]; request[0] 0x01; // slave addr request[1] 0x03; // function code request[2] 0x00; // start addr high request[3] 0x00; // start addr low request[4] 0x00; // reg count high request[5] 0x02; // reg count low uint16_t crc modbus_crc16(request, 6); request[6] crc 0xFF; request[7] crc 8; write(fd, request, sizeof(request));正常收到应答帧格式是地址、功能码、字节数、数据、CRC。比如返回4个字节数据整体长度是111429字节。如果返回的是异常功能码比如0x83那接下来一个字节是异常码常见的有01非法功能、02非法地址、03非法数据值收到这些基本可以确定是主站发的请求字段有问题而不是传感器坏了。4. 读写传感器数据的完整代码逻辑4.1 主站发送请求与接收应答主站程序的核心是“发请求-等应答-校验CRC-解析数据”。我在实际项目里写了一个读寄存器的通用函数输入从站地址、寄存器起始地址和数量输出解析好的寄存器数组。int modbus_read_regs(int fd, uint8_t slave, uint16_t start_reg, uint16_t count, uint16_t *regs_out) { uint8_t req[8]; uint8_t rsp[256]; uint8_t crc_calc[256]; req[0] slave; req[1] 0x03; req[2] start_reg 8; req[3] start_reg 0xFF; req[4] count 8; req[5] count 0xFF; uint16_t crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] crc 8; // 清空串口缓冲防止读到上次残留数据 tcflush(fd, TCIOFLUSH); if (write(fd, req, 8) ! 8) { perror(write); return -1; } // 等待应答用poll做超时 struct pollfd pfd; pfd.fd fd; pfd.events POLLIN; int ret poll(pfd, 1, 200); if (ret 0) { printf(poll timeout, slave %d no response\n, slave); return -1; } int n read(fd, rsp, sizeof(rsp)); if (n 5) { printf(response too short: %d bytes\n, n); return -1; } // 校验CRC memcpy(crc_calc, rsp, n - 2); uint16_t rsp_crc modbus_crc16(crc_calc, n - 2); if (rsp_crc ! 0) { printf(CRC error\n); return -1; } // 检查地址和功能码 if (rsp[0] ! slave || rsp[1] ! 0x03) { printf(addr or function code mismatch\n); return -1; } // rsp[2]是字节数数据区紧跟着 int byte_count rsp[2]; if (byte_count ! count * 2) { printf(byte count mismatch: %d\n, byte_count); return -1; } for (int i 0; i count; i) { uint8_t hi rsp[3 i * 2]; uint8_t lo rsp[4 i * 2]; regs_out[i] (hi 8) | lo; } return 0; }这里有个容易被忽略的细节tcflush必须在发送前调用而不是发送后。如果发送后再清串口缓冲可能把从机已经发来的应答也清掉导致永远收不到数据。我一开始就是在write之后tcflush折腾了半天。正确的顺序是清缓冲-发送-等待-读取。4.2 解析寄存器数据并转成浮点读回来的寄存器是16位无符号整数具体怎么变成物理量要看传感器手册。有些传感器一个寄存器就是一个小数比如温度值25.6直接编码成256这时候只需要除以10。但很多专业的传感器用两个寄存器拼成32位IEEE754浮点数比如温湿度、压力传感器常用这个格式。32位浮点数在Modbus里的字节序特别乱常见的大端模式是把高16位放在起始寄存器低16位放在第二个寄存器但也有反过来放的。写了个通用转换函数float regs_to_float(uint16_t high, uint16_t low) { uint32_t tmp ((uint32_t)high 16) | low; float f; memcpy(f, tmp, sizeof(f)); return f; }如果读出来的数值明显不对比如温度变成几千万多半是寄存器高低字顺序反了把high和low对调再试。还有一种情况是两个寄存器分别表示整数部分和小数部分比如整数寄存器25、小数寄存器6表示25.6这时就要用整数加小数除以精度来算。总之所有数据解析必须以设备手册为准这个环节没有“通用万能公式”。我习惯在调试阶段先把读到的寄存器原始值打印出来人工带一两个传感器的零点值算一遍确认公式无误后再固化代码。千万别一上来就封装得特别复杂否则排查问题时会多很多噪音。4.3 多传感器轮询与超时处理一个RS485总线上通常挂多个传感器主站需要周期性地挨个查询。最简单的轮询就是for循环typedef struct { uint8_t addr; uint16_t start_reg; uint16_t count; float value; } sensor_node_t; sensor_node_t sensors[] { {0x01, 0x0000, 2, 0}, {0x02, 0x0000, 2, 0}, {0x03, 0x0002, 1, 0}, }; while (1) { for (int i 0; i 3; i) { uint16_t regs[2] {0}; int ret modbus_read_regs(fd, sensors[i].addr, sensors[i].start_reg, sensors[i].count, regs); if (ret 0) { sensors[i].value regs_to_float(regs[1], regs[0]); // 记录时间戳或入库 } else { // 记录错误计数连续失败多次可以告警 } usleep(50000); // 轮询间隔50ms } sleep(1); // 整体周期1秒 }这个方案看起来简单但有两个坑一是每个从站之间必须留足时间间隔不能发完请求立刻发下一帧RS485从机处理和返回需要时间二是要用usleep控制总周期不然串口全速循环会占用大量CPU虽然Linux系统能扛住但在低配板子上还是省着用比较好。如果从站超过10个建议把轮询拆成两个线程采集线程负责读传感器数据业务线程负责使用最新数据中间用环形缓冲区或共享内存传递。不要在一个线程里既采集又做复杂业务一旦业务卡住传感器数据就会断层。如果串口读放在单独的线程主线程崩溃或重启时不会影响串口接收。5. 调试工具、常见坑与实测心得5.1 用命令行工具辅助验证在嵌入式板子上可以通过modpoll这个命令行工具先验证传感器地址和寄存器地址是否配置正确。modpoll是一个主站模拟工具可以指定串口设备、波特率、从站地址和寄存器地址直接读数据modpoll -m rtu -b 9600 -p none -a 1 -r 0 -t 4 /dev/ttyS3-t 4表示读取保持寄存器-t 3表示读取输入寄存器。如果传感器是浮点可以加-f参数。这种先人工验证一遍的方式能快速判断串口和传感器是不是正常避免自己代码还没写完就陷入排查循环。反向工具是开一个Modbus从站模拟器比如socat配合mbpoll或者直接在PC上跑Modbus Slave工具再让嵌入式板子当主站去读。我调试时常用PC上的Modbus Slave模拟传感器把寄存器值设成固定数据然后板子程序去读这样即使手头没有真实传感器也能把协议栈调通。5.2 常见问题与排查速查表根据最近几年做Modbus项目的经验把碰到的典型问题整理成了表格方便遇到问题直接对号入座。现象可能原因排查方式open串口失败设备节点被占用或权限不足lsof /dev/ttyS3加入dialout组发请求无应答从站地址不对、波特率不匹配、接线错误modpoll工具交叉验证示波器看波收到数据但CRC报错串口被噪声干扰、收发方向切换太慢降低波特率改用屏蔽双绞线增加帧间延时应答只有部分字节读取函数超时太短、VMIN/VTIME配置不当增加超时时间使用poll等待首字节正确后续错位帧边界判断错误上一帧残留发送前tcflush按3.5字符时间切分帧多个从站互相干扰从站地址重复、485总线没有终端电阻检查地址唯一性在总线末端加120Ω电阻电压不足导致无应答传感器供电不足用独立电源给传感器供电避免共地干扰5.3 实测心得与建议最后分享几个属于“文档里不会写但实际很关键”的小经验。一是不要相信传感器默认地址一定是1很多设备出厂地址是1但有些厂商默认是255必须先读说明书或用工具扫描一下。二是串口波特率在9600时很稳定但在115200时如果RS485线太长或屏蔽不好非常容易出CRC错误能用9600就别追求高速。三是在嵌入式Linux上如果发现串口接收偶尔“吞字节”优先检查内核的8250驱动是否启用了FIFO和硬件流控很多开发板的设备树里默认把流控引脚配置错了导致收发异常。实际项目里我还遇到过一个很隐蔽的问题传感器应答帧长度不定有的设备在异常时会返回5字节短帧我一开始read用固定长度读取结果每次都卡在读超时。后面改成先读前三个字节地址、功能码、字节数根据字节数再读剩余长度才彻底解决。这个“二次读取”的思路在处理工业设备时特别实用。做嵌入式Linux的Modbus开发本质上不是协议有多难而是串口细节和数据处理够不够细心。把这套流程跑熟之后再换其他支持Modbus RTU的设备基本就是改寄存器地址的事。我也还在继续完善这套代码后面打算加入自动识别从站地址和异常重传机制让轮询逻辑更健壮。