☰
嵌入式Linux Modbus RTU传感器读写:串口配置与协议解析
2026/10/25 17:09:21 网站建设 项目流程

做嵌入式Linux项目,最容易被卡住的往往不是业务逻辑本身,而是那些看起来基础、实际坑很多的“连接层”工作。拿Modbus RTU来说,协议本身不复杂,但一旦落到真实硬件上——串口参数怎么配、帧和帧之间怎么切分、CRC算对了没、传感器返回的字节到底怎么拼成float——每一个环节都能让调试进度直接归零。这篇就围绕“嵌入式Linux端用Modbus RTU读写传感器”这个场景,把我从串口配置到协议解析的完整思路、代码和踩坑记录都摊开来讲。

1. 整体设计与思路拆解:先把技术底座想清楚

1.1 这个场景为什么绕不开Modbus RTU

工业现场里,温度、湿度、压力、液位、气体浓度这些传感器,十有八九都带Modbus RTU接口。原因很朴素:Modbus RTU跑在RS-485物理层上,两线制布线,几十个设备挂一条总线,协议帧极小,对MCU主频要求低,而且Modbus协议本身是公开免费、无授权费的,从PLC到单片机到嵌入式Linux设备都能实现。对这个行业来说,低成本、高可靠、通用性强,就是硬道理。

从嵌入式Linux侧看,Modbus RTU更是“低成本接入存量设备”的标准答案。假设你手里有一批旧厂房里的温湿度传感器,它们只支持Modbus RTU,你要把它们接入新的Linux网关做数据采集上云,那你的网关就得跑一个Modbus主站,按地址去轮询每一台从机。这个“主站”的身份,就是这次开发的核心。

1.2 方案选型:轮询式主站是最稳的起点

Modbus主站有两种典型设计思路:一种是“事件驱动型”,依赖中断和回调,收到响应立刻解析,适合从机数量多、需要并发响应的场景;另一种是“轮询式”,主站按顺序向各从机发请求,等响应、超时、重试,然后切下一个从机。

在嵌入式Linux上做传感器采集,我强烈建议从轮询式主站起步。原因很直接:轮询式代码是一段线性流程,请求发出、等待、解析、下一个,逻辑清晰,调试方便,出问题你一眼就能看出卡在哪一步。事件驱动的逻辑在工业现场反而容易引入竞态问题,比如某个从机响应慢了,后续请求还在往外发,RS-485总线上就乱套了。轮询式天然就不存在这种并发冲突,对初学者和工程落地都更友好。

1.3 串口配置为什么是决定成败的第一关

Modbus RTU跑在串口上,串口参数必须严格对齐:波特率、数据位、停止位、校验位,从机和主机的每一个参数都要完全一致。常见的组合是9600/8N1,也有19200/8E1或38400/8N1,具体以传感器手册为准。这些参数一旦有一个对不上,收到的就是一堆乱码,甚至什么都收不到。

但在嵌入式Linux上,配置串口还牵涉到另一个层面的问题:Linux把串口抽象成终端设备,默认带了一堆行规程处理,比如回车换行转换、特殊字符解释、回显、缓冲等。Modbus RTU是二进制协议,一个字节0x0A被当成换行符处理,整个协议就直接崩了。所以,串口配置的第一要务是切换到“原始模式”(raw mode),把Linux终端的所有“贴心服务”全部关掉,让数据赤裸裸地流过。

2. 串口配置实操:termios就是这套系统的核心钥匙

2.1 termios关键配置项解析

在Linux上配置串口,绕不开termios结构体。它看起来字段很多,但真正要动的就那么几个。

首先是c_cflag,控制模式标志,这里设置波特率、数据位、停止位、校验位,以及是否使用硬件流控。Modbus RTU在RS-485上一般不使能硬件流控,靠软件控制收发切换就够了(后面会单独提RS-485方向切换的坑)。

然后是c_iflag、c_oflag、c_lflag,分别是输入模式、输出模式和本地模式标志。这三组默认值对二进制数据都是灾难:c_iflag里的IXON/IXOFF会拦截Ctrl+S/Ctrl+Q这样的软件流控字节,c_oflag里的OPOST会做输出后处理(把\n变成\r\n),c_lflag里的ICANON会把串口变成行缓冲模式,必须收到换行符才把数据交给应用。这些全部要关掉。

最后是c_cc数组,特别是VMIN和VTIME,这两个参数决定了read()的行为模式。VMIN=0、VTIME=1表示超时轮询,每0.1秒如果没数据就返回0,适合实现非阻塞读取;VMIN=1、VTIME=0表示阻塞等一个字节;工业上我常用VMIN=0、VTIME=1配合select的超时机制,双保险。

2.2 串口初始化代码:一套可以直接抄的模板

下面是嵌入式Linux下串口初始化的完整C代码,关键配置全部注释了:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <errno.h> int uart_open(const char *dev, int baud) { int fd = open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open serial"); return -1; } struct termios opt; memset(&opt, 0, sizeof(opt)); tcgetattr(fd, &opt); // 切换到原始模式:关闭所有行规程处理 cfmakeraw(&opt); // 波特率映射:不同平台B开头的宏定义可能不同 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(&opt, speed); cfsetospeed(&opt, speed); // 8N1:8数据位,无校验,1停止位 opt.c_cflag &= ~PARENB; // 清除校验位 opt.c_cflag &= ~CSTOPB; // 1位停止位 opt.c_cflag &= ~CSIZE; // 清除数据位标志 opt.c_cflag |= CS8; // 8位数据位 // 使能接收,忽略调制解调器控制线 opt.c_cflag |= (CLOCAL | CREAD); // 禁用硬件流控 opt.c_cflag &= ~CRTSCTS; // 关闭输入软流控和换行处理 opt.c_iflag &= ~(IXON | IXOFF | IXANY); opt.c_iflag &= ~(INLCR | ICRNL | IGNCR); // 关闭输出后处理 opt.c_oflag &= ~OPOST; // 关闭本地回显和行缓冲 opt.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); // VMIN=0, VTIME=1,非阻塞超时模式 opt.c_cc[VMIN] = 0; opt.c_cc[VTIME] = 1; tcsetattr(fd, TCSANOW, &opt); tcflush(fd, TCIOFLUSH); // 清空输入输出缓冲,避免脏数据 return fd; }

这段代码我用了很多次,基本是“标准答案”级别的写法。注意open时用了O_NOCTTY,这个标志很重要——如果不加,当你的程序没有控制终端时,这个串口可能会变成你的控制终端,一旦收到Ctrl+C之类的信号,程序就被干掉了。O_NDELAY则保证open()不会因为等待DCD信号而阻塞,工业串口设备不一定拉DCD线。

2.3 实测验证:先用回环测试确认底层通没通

配好串口后,不要急着接Modbus,先做一次回环测试。把串口的TX和RX用一根杜邦线直接短接,然后用你刚刚写的程序或者一个小工具发一帧数据,看能不能原样收回来。如果回环测试通过,说明串口驱动、termios配置、读写路径都是通的;如果不通,问题出在串口本身,跟Modbus一点关系都没有。

回环测试我习惯用一段极简代码实现,甚至可以不用写代码,直接命令行操作:

# 发送端:从一个终端发送0x01 0x03 echo -e '\x01\x03' > /dev/ttyS0 # 接收端:另一个终端读取 cat /dev/ttyS0 | xxd

但注意,这种方式没配置原始模式,cat端可能受行规程影响,最好还是用stty配置:

stty -F /dev/ttyS0 9600 raw -echo

回环通了,再往下走,不然就是白白浪费时间在协议层排查物理层的问题。

3. Modbus RTU数据处理:从字节流到有效数据

3.1 RTU帧格式拆解:地址、功能码、数据、CRC

Modbus RTU的报文结构非常清晰,一帧完整的请求/响应如下:

字段长度说明
从站地址1字节1-247,0为广播地址
功能码1字节03读保持寄存器、04读输入寄存器等
数据区不定寄存器地址、数量、具体数据等
CRC162字节低字节在前,高字节在后

传感器数据读取最常用的两个功能码是0x03(读保持寄存器)和0x04(读输入寄存器)。差别在语义上:0x04读的是输入寄存器,通常映射传感器的实时测量值,比如当前温度、湿度;0x03读的是保持寄存器,通常映射设备的配置参数,比如报警阈值、量程。但注意,不同厂商的Modbus从机对这两个功能码的定位可能不一样,一定要以传感器手册里给出的寄存器表和功能码说明为准。

请求读传感器数据的帧格式如下:设备地址(1字节) + 0x03/0x04(1字节) + 起始寄存器地址高字节 + 起始寄存器地址低字节 + 寄存器数量高字节 + 寄存器数量低字节 + CRC低字节 + CRC高字节。总共8字节。

响应帧则是:设备地址(1字节) + 功能码(1字节) + 数据字节数n(1字节) + n字节数据 + CRC低字节 + CRC高字节。比如读2个寄存器(4字节数据),响应就是 1 + 1 + 1 + 4 + 2 = 9字节。

3.2 CRC16-Modbus校验实现的两种方式

Modbus RTU的CRC校验算法是CRC16-Modbus,多项式是0x8005,初值是0xFFFF。计算时,低字节在前、高字节在后发送。我一般用位运算法,代码简单、可读性好,跑在嵌入式Linux上效率完全够:

#include <stdint.h> uint16_t crc16_modbus(const 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计算每秒要执行几十上百次,位运算法虽然也能跑,但查表法更从容。

3.3 从响应字节还原传感器数据:字节序和类型转换是重灾区

Modbus RTU传输多字节数据时,先传高字节、再传低字节,也就是所谓的大端序(big-endian)。但真实世界永远比教科书复杂:很多传感器内部是小端存储,有些甚至按字交换字节序(word swap),这就导致同样的4个字节,在不同设备上解析结果完全不同。

举个例子,一个温湿度传感器返回16个寄存器(32字节),其中温度占2个寄存器(4字节),湿度占2个寄存器(4字节)。原始字节可能是 0x41 0x8A 0x00 0x00,这是float 17.25的大端表示。但如果你直接按小端拼,或者按16位寄存器高低位反了来拼,得到的就完全不是这个数。

我在实际项目中踩过最大的坑就是浮点数的字节序组合。理论上应该按“寄存器顺序排列,每寄存器高字节在前”来拼,实际操作中有的设备却是低字在前,还有的是所有字节整体逆序。所以,读到一个传感器的数据后,第一件事不是往数据库里写,而是先把原始字节打印出来,和手册给出的示例值对比,确认字节序。这一步能省下后面几天排查的时间。

3.4 完整读写流程:构建请求、收发、校验、解析

核心的Modbus RTU读寄存器函数长这样,整合了请求构建、发送、超时接收和CRC校验:

int modbus_read_registers(int fd, uint8_t slave_addr, uint8_t func, uint16_t start_reg, uint16_t reg_count, uint8_t *out_data, int max_len) { if (max_len < reg_count * 2) return -10; uint8_t req[8]; req[0] = slave_addr; req[1] = func; req[2] = (start_reg >> 8) & 0xFF; req[3] = start_reg & 0xFF; req[4] = (reg_count >> 8) & 0xFF; req[5] = reg_count & 0xFF; uint16_t crc = crc16_modbus(req, 6); req[6] = crc & 0xFF; req[7] = crc >> 8; // 清空串口缓冲,避免读到上一次残留数据 tcflush(fd, TCIOFLUSH); int n = write(fd, req, 8); if (n != 8) return -1; // 使用select等待响应,超时时间由调用方控制 fd_set rfds; struct timeval tv; FD_ZERO(&rfds); FD_SET(fd, &rfds); tv.tv_sec = 0; tv.tv_usec = 200000; // 200ms超时 int sel = select(fd + 1, &rfds, NULL, NULL, &tv); if (sel <= 0) return -2; // 超时或无数据 uint8_t resp[256]; int len = read(fd, resp, sizeof(resp)); if (len <= 0) return -3; // 校验响应:地址、功能码 if (resp[0] != slave_addr || resp[1] != func) return -4; // 异常响应处理:功能码最高位置1表示从机报错 if (resp[1] & 0x80) return -5; // 校验CRC uint16_t recv_crc = crc16_modbus(resp, len - 2); uint16_t frame_crc = resp[len - 2] | (resp[len - 1] << 8); if (recv_crc != frame_crc) return -6; int data_len = resp[2]; if (data_len > max_len || data_len + 5 != len) return -7; memcpy(out_data, &resp[3], data_len); return data_len / 2; // 返回寄存器数量 }

select+超时的组合非常关键。Modbus RTU是半双工问答式协议,主站发完一帧,必须在规定时间里等到从站响应。从站响应时间因设备而异,有的几十毫秒就回了,有的要一两百毫秒。超时设太短,慢速设备总是读失败;设太长,整个轮询周期被拉长,采集实时性又受影响。我一般先设200ms,实测调整。

3.5 为什么TCP和RTU收发细节不同

搜Modbus资料的时候经常会看到Modbus TCP和Modbus RTU放在一起比较。简单说,TCP模式跑在以太网上,报文里没有CRC校验(因为TCP/IP协议栈自己保证可靠性),多了MBAP报文头;RTU模式跑在串口上,靠CRC校验保证数据完整性。对嵌入式Linux做传感器采集来说,如果传感器是RS-485接口,那就是RTU;如果是网口,那才是TCP。两者报文结构、校验方式都不同,开发时千万别混用。

4. 实战:读一个温湿度传感器的完整代码

4.1 需求设定与寄存器映射

假设我们要从一个从站地址为0x01的温湿度传感器读取数据。根据手册,起始寄存器地址是0x0000,读取2个保持寄存器(功能码0x03),每个寄存器16位:

  • 寄存器0x0000:温度值,扩大10倍存储,比如25.6℃存储为256
  • 寄存器0x0001:湿度值,扩大10倍存储,比如60.3%存储为603

这种情况下不需要处理复杂的float字节序,直接两个uint16_t就能还原出真实值。这是工业传感器最常见的做法,比float省字节,也规避了字节序混乱的坑。

4.2 完整主循环代码

#include <stdio.h> #include <stdint.h> #include <unistd.h> int main(void) { int fd = uart_open("/dev/ttyS3", 9600); if (fd < 0) { fprintf(stderr, "open /dev/ttyS3 failed\n"); return 1; } uint8_t raw_data[4]; while (1) { int nregs = modbus_read_registers(fd, 0x01, 0x03, 0x0000, 2, raw_data, sizeof(raw_data)); if (nregs == 2) { uint16_t temp_raw = (raw_data[0] << 8) | raw_data[1]; uint16_t humi_raw = (raw_data[2] << 8) | raw_data[3]; float temp = temp_raw / 10.0f; float humi = humi_raw / 10.0f; printf("temp=%.1f C, humi=%.1f %%RH\n", temp, humi); } else { printf("read failed, err=%d\n", nregs); } sleep(1); // 1秒轮询一次 } close(fd); return 0; }

在实际项目中,这个while循环通常会换成带调度的状态机,比如用定时器触发轮询,把采集任务放进单独的线程池,避免阻塞主业务。但对于第一版功能验证,这样的线性循环是最容易确认协议通没通的。

4.3 RS-485方向控制的隐性坑

如果你的Linux设备通过USB转RS-485或者板载RS-485收发器接传感器,那注意一个坑:RS-485是半双工差分总线,发送的时候要占用总线,发完必须立刻释放让给接收。很多RS-485芯片(如MAX3485)的方向控制引脚由DE/RE控制,Linux端的串口驱动一般会自动处理,但有些便宜USB转485模块不会自动切换,需要应用层拉GPIO控制方向。

判断你的模块是否需要软件控制方向很简单:看它是“自动收发切换”还是“手动方向控制”。自动切换的模块内部有延时电路,发送完后自动切回接收,使用起来就像全双工串口一样。手动的就需要在write()前拉高方向引脚,write()结束后拉低。

如果遇到“发出去的请求正常,但就是收不到响应”,而且用示波器看TX引脚有数据、A/B线上也有差分信号,却收不到回包,大概率就是方向切换没做好。这个现象在485场景里太常见了,排查优先级很高。

5. 常见问题与排查技巧实录

5.1 常见问题速查表

现象可能原因排查方法
串口打不开设备节点不存在、权限不足检查/dev/ttyS*是否存在,用chmod或udev规则加权限
发请求无响应从站地址错误、波特率不一致、RS-485方向未切换先用Modbus Poll工具接电脑验证从站;短接TX/RX做回环测试
收到数据但CRC校验失败波特率误差、干扰、接线过长或未接地用示波器看波形;降低波特率到9600;检查屏蔽层和终端电阻
返回数据时好时坏从站地址冲突、总线多设备干扰只接一个设备排除干扰;总线上加120Ω终端电阻
浮点数解析成天文数字字节序错误、寄存器宽度理解错误打印原始字节,对照手册示例值逐个字节核对
偶发收不到响应超时设置太短、从站处理时间长增大select超时到500ms试验;确认从站响应时间参数

5.2 排查思路:先物理层,再协议层

我在现场调试最强调的原则是:不要把问题想复杂,先把链路分层。

第一步,物理层。确认TX/RX/地线接对了没有。RS-485的话A接A、B接B,别接反了;地线最好共地,不然共模电压可能把数据打在墙里。

第二步,驱动层。用stty确认串口参数和代码里配的一致:

stty -F /dev/ttyS3 -a

再看波特率、数据位、停止位、校验位。这一步能发现很多“我以为配了但实际没生效”的问题。

第三步,工具层。把Linux设备当成转发器,用逻辑分析仪或示波器抓到总线上的原始波形,确认请求帧真的发出去了、格式对不对。如果手里没有示波器,可以用另一个串口工具监听TX线上的数据,或者让从站设备接电脑上的USB转485,用Modbus Poll软件直接和传感器通信,验证传感器本身是好的。

第四步,协议层。用上面代码里的模组,先读一个固定寄存器验证帧结构,成功了再读实际数据。

5.3 寄存器地址偏移的问题

有些传感器文档手册里的寄存器地址是“PLC地址”,比如40001,对应Modbus协议里的地址其实是0x0000(40001-40001=0)。同理,30001对应的是0x04功能码的第0号寄存器。如果你把40001直接填进请求帧的起始寄存器地址里,那就会去读一个不存在的寄存器,返回异常或无效数据。

我见过不少新手栽在这里。解决办法很简单:读传感器手册时,先搞清楚它给的寄存器编号是“协议地址”还是“数据地址”,如果是40001、30001这种开头连数字编号的,减去起始偏移再填进请求帧。

5.4 总线只有一个设备时也要接终端电阻吗

RS-485总线的终端电阻是为了阻抗匹配、消除反射。短距离(几米内)、低速(9600)单设备场景,不接终端电阻也可能跑得通,但距离一旦超过十几米或者现场有变频器、电机这类干扰源,不接终端电阻就会出现间歇性通信失败,表现为“单独测试都好,挂上总线就不行”,而且随机性很强,半天找不出规律。

所以我的习惯是:无论多短的通信距离,都在总线两端各配一个120Ω终端电阻,低成本,但能把很多诡异问题扼杀在摇篮里。

6. 轮询多设备时的调度设计

6.1 多从机轮询的节奏控制

如果一条总线上挂了多台传感器,主站要按地址轮询。最简单的做法是for循环挨个读,实话说,设备少(比如3个以内)时这么做没毛病。但设备多了以后要注意两个问题:一是每台设备的响应时间不同,统一用同一个超时阈值会造成浪费;二是一旦某台设备离线,每次轮询都要等满超时时间,后面所有设备都会被拖慢。

我在项目里的做法是给每台从机维护一份状态记录,包括连续失败次数、平均响应时间、当前超时阈值。连续失败次数超过阈值就标记为“离线”,降低轮询它的频率;某台设备的超时时间也按它的历史响应时间动态调整,而不是一刀切。这不是什么复杂算法,但对提高现场采集能效很有效。

6.2 串口读写与其他业务的并发

嵌入式Linux上,如果串口采集线程和应用主线程并发,要注意串口文件描述符的线程安全。最简单的方案是:所有的串口读写都集中在同一个线程里,其他线程通过消息队列或共享内存把“读哪个寄存器”的需求传进来,采集线程负责执行Modbus请求并把结果放回结果区。这样避免了多个线程同时操作同一个fd导致的乱序。

如果一定要多个线程共用一个fd,那就必须加锁,而且write操作和后面的select等待要放在同一个临界区里,防止一个线程写入请求后,另一个线程抢先去select,把属于第一个线程的响应给读走了。

6.3 数据解析层的隔离

Modbus协议层拿到的是寄存器值,业务层关心的是物理量。这两层建议严格分离。比如,寄存器值是uint16_t的256,业务层需要的是25.6℃,那换算逻辑(除以10)应该放在独立的解析模块里,而不是掺杂在Modbus收发代码中。这样当传感器换了型号、换算公式变了,只需要改解析层,协议层完全不动。这个习惯在项目后期维护时价值极大。

7. 最后分享几个小经验

串口和Modbus相关的问题,绝大多数都不是“协议不懂”,而是“细节没对齐”。波特率、从站地址、寄存器地址、字节序、超时时间,每一个参数都需要跟手册严格核对,每一个坑都对应一行你在Debug时流过的眼泪。

我在实际调试中最有效的工作方式,是编写调试工具时保留一个“原始帧日志”开关。打开这个开关后,每一帧发出的请求和收到的响应都以十六进制形式打印出来。这样一旦哪里出问题,直接从日志里看原始字节,比反复猜测快得多。很多问题其实一眼就能在原始帧上看出端倪,比如CRC位置放反了、寄存器地址高低字节对调了,在hex日志里都是明晃晃的。

另外建议你在正式开发前先买一个USB转RS-485的调试工具,电脑上装好Modbus Poll配合测试,先把从站的寄存器读一遍,确认功能码、地址、字节序都摸清了,再写Linux端的代码。这样整个嵌入式端的开发会顺畅很多,毕竟排查问题的首要前提是,你知道“对的答案”长什么样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询