在实际的嵌入式项目里,Linux 端通过串口对接 Modbus RTU 传感器,是我见过最普遍、也是最容易踩坑的场景之一。很多做单片机出身的朋友第一次转到 Linux 环境,往往卡死在“串口怎么配”“报文怎么发”“数据怎么解析”这几个环节。这篇文章不聊虚的,直接围绕串口配置、RTU 协议帧、传感器数据读写三个核心点展开,把整个开发链路拆开揉碎,每一步都给出能直接落地的参考实现。
先把项目背景交代清楚:嵌入式 Linux 设备作为 Modbus 主站,通过串口(通常是 RS-232 或 RS-485)连接一个或多个 Modbus RTU 从站传感器(比如温湿度变送器、压力变送器、水质监测探头),主站定时轮询从站的数据寄存器,解析出物理量后用于本地显示或通过网络上传。这个场景在农业物联网、工业现场采集、环境监测等项目中极其常见。适合刚接触嵌入式 Linux 应用开发、有 C 语言基础、需要对传感器做数据采集的工程师参考。
1. 整体思路与方案选型:为什么用串口和 Modbus RTU
1.1 嵌入式 Linux 下选 Modbus RTU 而不是 Modbus TCP
很多人在项目刚开始时都纠结过一个问题:传感器明明支持 Modbus TCP,为什么还要用 RTU?这里我直接说结论,在设备端做数据采集时,Modbus RTU 通常比 TCP 更合适,原因有三。
第一,物理链路简单。RTU 走串口,一根双绞线或者两根普通导线就能完成通信,RS-485 总线甚至支持一条总线上挂 32 个从站设备。TCP 则需要网线、交换机或路由器,在工业现场布线成本高、维护麻烦。比如农业大棚里布的温湿度传感器,用 485 总线串起来比拉网线方便得多。
第二,实时性和确定性更好。RTU 是主从轮询机制,主站控制通信节奏,从站只有被问到才会应答,不会像 TCP 那样有连接管理和突发数据流的问题。在数据采集频率固定的场景下,RTU 的通信时序完全可预测,调试起来也更直观。
第三,硬件成本低。RS-485 收发芯片(比如 SP3485、MAX485)几毛钱一颗,而以太网 PHY 芯片成本高一个数量级,对于量大且对带宽不敏感的传感器,选用 RTU 是更经济的方案。
这里补充一个选型细节:如果你的传感器同时支持 RS-232 和 RS-485 接口,优先选 RS-485。232 是点对点通信,距离短(十几米左右),电平是 ±12V 逻辑,抗干扰能力一般;485 是差分信号,理论通信距离可达千米级别,抗共模干扰能力强,支持多设备挂载。工业现场传感器的首选接口就是 485。
1.2 主从轮询机制与设备结构梳理
Modbus RTU 是标准的主从协议,总线上只能有一个主站,多个从站。通信的发起方永远是主站,从站之间不能直接通信,从站也不能主动向主站发数据。这个机制决定了应用层的设计模式:主站需要周期性发起查询帧,等待从站响应,然后解析数据。
在设计具体方案前,先明确几个关键变量:
- 从站地址:每个传感器有唯一的 Modbus 地址,范围 1~247,0 为广播地址
- 功能码:03(读保持寄存器)、04(读输入寄存器)、06(写单个寄存器)、16(写多个寄存器),传感器数据采集主要用 03 和 04
- 寄存器地址:传感器手册会标明每个物理量对应的寄存器地址和数据类型(16 位整数、32 位浮点等)
- 波特率和数据格式:常见的有 9600、19200、115200,数据格式多为 8N1(8 数据位、无校验、1 停止位),也有 8E1 或 8O1,必须与从站匹配
以实际项目为例:一个温湿度传感器,从站地址为 1,波特率 9600,数据格式 8N1。温度值存放在 0x0001 和 0x0002 两个连续寄存器中,以 32 位浮点格式存储(高字节在前);湿度值存放在 0x0003 和 0x0004。我们要做的就是通过功能码 03,从地址 0x0001 开始,读取 4 个寄存器(8 字节数据),然后拆分解析出温度和湿度。
1.3 用库还是手写协议栈:libmodbus 与裸开发的取舍
开发 Modbus RTU 通信有两种路子:用现成的 libmodbus 库,或者自己实现协议帧和解包逻辑。这里必须说清楚,不要一上来就盲目选库,两者有明确的适用场景。
libmodbus 是 Linux 下最常用的 Modbus 库,封装了串口配置、报文封装、CRC 校验、响应解析等全套逻辑,调用modbus_new_rtu()创建上下文,modbus_set_slave()设置从站地址,modbus_read_registers()直接读寄存器,开发效率非常高。如果你用的是成熟的传感器,寄存器地址固定,用 libmodbus 两三小时就能跑通。
自己实现协议栈则适合三种情况:一是目标环境不方便移植第三方库(比如资源极度受限的嵌入式系统);二是需要对协议底层的每个字节有绝对控制权,比如要适配自定义的非标准寄存器类型;三是为了深度理解协议本身的运作机制。手写协议栈的核心工作量在 CRC16 校验和字节序处理上,一旦写好,以后任何平台都能复用。
我个人的建议是:项目着急、传感器规整,直接用 libmodbus;如果是长期的产品迭代、或者想彻底掌握协议细节,两种方式都值得做一遍。后面我会把两种方案的实现都过一遍,先讲串口配置,这是无论如何都绕不开的底层基础。
2. 串口配置核心细节:termios 是绕不过的一关
2.1 打开串口:设备节点检查与文件描述符设置
在嵌入式 Linux 上,串口设备通常以/dev/ttyS*、/dev/ttyUSB*、/dev/ttymxc*等节点存在。具体是哪个节点,取决于平台和串口扩展方式。比如 i.MX6ULL 的原生 UART 对应/dev/ttymxc0,用 USB 转串口芯片(CH340、CP2102)则对应/dev/ttyUSB0。开发前先确认设备节点是否存在:
ls -l /dev/tty*实际项目里还要注意权限问题。普通用户访问串口经常遇到Permission denied,临时解决方法是把用户加入 dialout 组:
sudo usermod -a -G dialout $USER打开串口用标准 POSIX 接口,注意要设置O_NOCTTY(防止串口成为控制终端)和O_NDELAY(非阻塞打开,避免打开时因 DCD 信号问题卡住)。代码里我一般这么写:
int fd = open("/dev/ttymxc0", O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open serial port failed"); return -1; }打开后在正式读写前,如果用了 O_NDELAY,需要调用fcntl(fd, F_SETFL, 0)恢复为阻塞模式,否则后续read()和write()的行为会变得不可预期。这是一个非常容易忽略的细节。
2.2 波特率、数据位、停止位、校验位:一个参数都不能错
串口通信的参数匹配是 Modbus RTU 通信成功的前提。任何一端参数不匹配,收到的都是乱码或直接无响应。Linux 下串口参数由termios结构体控制,配置流程可以归纳为四步:取原值、改参数、清缓冲、写回。
先直接给出一段完整的配置函数,实测可以直接用:
#include <termios.h> #include <unistd.h> #include <fcntl.h> #include <string.h> int serial_set_param(int fd, int baudrate, int data_bits, int stop_bits, char parity) { struct termios options; /* 1. 获取当前串口配置 */ if (tcgetattr(fd, &options) != 0) { perror("tcgetattr failed"); return -1; } /* 2. 配置波特率 */ speed_t speed; switch (baudrate) { 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(&options, speed); cfsetospeed(&options, speed); /* 3. 配置数据位、停止位、校验位 */ options.c_cflag |= (CLOCAL | CREAD); // 启用接收,忽略调制解调器控制 // 数据位 options.c_cflag &= ~CSIZE; switch (data_bits) { case 5: options.c_cflag |= CS5; break; case 6: options.c_cflag |= CS6; break; case 7: options.c_cflag |= CS7; break; case 8: options.c_cflag |= CS8; break; default: options.c_cflag |= CS8; break; } // 停止位 if (stop_bits == 2) { options.c_cflag |= CSTOPB; } else { options.c_cflag &= ~CSTOPB; } // 校验位 switch (parity) { case 'N': case 'n': options.c_cflag &= ~PARENB; options.c_iflag &= ~INPCK; break; case 'E': case 'e': options.c_cflag |= PARENB; options.c_cflag &= ~PARODD; options.c_iflag |= INPCK; break; case 'O': case 'o': options.c_cflag |= PARENB; options.c_cflag |= PARODD; options.c_iflag |= INPCK; break; } /* 4. 原始模式:关闭所有输入输出处理 */ options.c_iflag &= ~(ICANON | IEXTEN | ECHO | ECHONL | ISIG); options.c_oflag &= ~OPOST; options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); /* 5. 启用硬件流控关闭,软件流控关闭 */ options.c_cflag &= ~CRTSCTS; options.c_iflag &= ~(IXON | IXOFF | IXANY); /* 6. 读取超时设置 */ options.c_cc[VTIME] = 0; // 不等待 options.c_cc[VMIN] = 1; // 至少读 1 字节 /* 7. 清空缓冲并应用配置 */ tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, &options) != 0) { perror("tcsetattr failed"); return -1; } return 0; }这段配置有几个关键点必须理解,否则排查问题时会一头雾水。
CLOCAL 和 CREAD 标志必须设置。CLOCAL 表示不监控调制解调器状态线,否则打开串口后如果 DCD 信号不对,驱动会尝试close()端口;CREAD 表示启用接收。这两个标志不设置,串口会“开着却收不到数据”。
数据位和停止位的组合是有讲究的,Modbus RTU 标准中一般用 8N1。为什么不用 7E1?因为 Modbus 报文是二进制数据,8 位数据位才能完整表达 0x00~0xFF 的所有字节。7 位模式下只能传输 ASCII 字符,不适合二进制协议。
RAW 模式配置至关重要。如果不关闭ICANON、ECHO、OPOST这些标志,串口驱动会对数据做额外处理。比如OPOST会把\n转成\r\n,直接污染 Modbus 报文;ECHO会把发送的数据回显到接收缓冲区,导致读到的数据和自己发出去的混在一起。我在调试中见过不下五次这种“数据错位”的问题,根源都是忘了设置 raw 模式。
2.3 RS-485 收发切换与方向控制
如果你的板子用的是 RS-485 接口,还需要处理一个独特的问题:485 是半双工通信,发送数据时要把收发芯片切换到发送模式,发送完再切回接收模式。处理方式取决于硬件电路设计,有三种典型情况。
第一种是自动方向控制。现在很多开发板的 485 电路加了自动切换芯片(如 MAX13487),硬件根据总线电平自动控制方向,软件完全不用关心。这种情况最省心,代码里按普通串口读写即可。
第二种是 GPIO 控制方向。比如用DIR引脚接在某个 GPIO 上,发送前拉高,发送完后拉低。这种情况要注意时序:必须等数据完全从 FIFO 发出后再拉低方向引脚,否则最后一个字节会被截断。tcdrain() 函数就是干这个的,它会阻塞直到发送缓冲区清空。
gpio_set_value(dir_pin, 1); // 切到发送模式 write(fd, frame, frame_len); tcdrain(fd); // 等待数据全部发出 gpio_set_value(dir_pin, 0); // 切回接收模式第三种是 RTS 硬件流控控制方向,通过TIOCM_RTS或ioctl操作。有些 Linux 串口驱动支持把 RTS 自动用作 485 方向控制,这需要在设备树或驱动层面配置,应用层同样只需按普通串口操作。具体实现依赖平台,这里不展开。
需要注意的是,485 方向切换的时序是“玄学”高发区。切换过早会丢数据,切换过晚会导致接收不到从站的响应字节。如果用 GPIO 控制,建议在主站发送完最后一字节后,先tcdrain()再切换方向,并且根据波特率留出 1~2 字节的传输时间余量。
3. Modbus RTU 协议帧格式与功能码实操
3.1 报文帧结构与字节顺序
Modbus RTU 的报文帧非常紧凑,没有起始符和结束符,靠“静默时间”来分隔帧。标准规定:帧内字节间隔不能超过 1.5 个字符时间,帧间静默至少 3.5 个字符时间。实际实现中,主站发送完一帧后,只需要等待从站的响应帧即可,帧间静默由主站的轮询间隔保证。
帧结构分为四段:
| 组成 | 长度 | 说明 |
|---|---|---|
| 地址码 | 1 字节 | 从站地址,0x01~0xF7 |
| 功能码 | 1 字节 | 03/04/06/16 等 |
| 数据段 | N 字节 | 寄存器地址、数量、数据等 |
| CRC | 2 字节 | CRC16 校验,低字节在前 |
以“读保持寄存器”为例,读取从站地址 1,起始寄存器 0x0001,数量 4,请求帧为:
01 03 00 01 00 04 CRC_LOW CRC_HIGH功能码 03 的数据段由三部分组成:寄存器起始地址(2 字节)、寄存器数量(2 字节,注意是数量减 1 还是实际数量,取决于传感器手册,Modbus 标准中是实际数量 1~125)。CRC 校验码按整帧计算,先算低字节后算高字节。这是 RTU 和 ASCII 模式最大的区别,ASCII 模式有明确的帧头和帧尾,RTU 没有。
从站正常响应帧为:
01 03 08 [8字节寄存器数据] CRC_LOW CRC_HIGH功能码 03 的响应数据段是:本帧数据字节数(1 字节,这里是 4 个寄存器 × 2 字节 = 8)+ 寄存器值。异常响应帧为:
01 83 02 CRC_LOW CRC_HIGH最高位置 1 表示异常,后面的02是异常码(这里表示非法数据地址)。理解异常码的含义,对调试传感器协议特别有帮助。
3.2 CRC16 校验的计算与验证
CRC16 是 Modbus RTU 的命门,算错一个字节,从站就会静默不响应。Modbus 采用的 CRC16 算法参数为:多项式 0x8005,初始值 0xFFFF,结果异或 0x0000,输入输出不反转,也就是标准 Modbus CRC16。
这里直接给出一个高效查表法实现,比逐位计算快很多,适合在 Linux 应用层使用:
static const unsigned char crc_table_hi[] = { 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, // ... 完整 256 项表略 }; static const unsigned char crc_table_lo[] = { 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, 0x04, // ... 完整 256 项表略 }; unsigned short crc16_modbus(unsigned char *buffer, unsigned int length) { unsigned char crc_hi = 0xFF; unsigned char crc_lo = 0xFF; unsigned int i; for (i = 0; i < length; i++) { unsigned char index = crc_hi ^ buffer[i]; crc_hi = crc_lo ^ crc_table_hi[index]; crc_lo = crc_table_lo[index]; } return (crc_hi << 8) | crc_lo; }查表法的原理是将逐位计算的过程预先算好存入表,运行时用“当前 CRC 高字节异或输入字节”查表更新 CRC,效率是逐位法的数倍。如果不想维护查表,也可以用逐位法,数据量不大的场景性能也够用:
unsigned short crc16_modbus_bitwise(unsigned char *data, unsigned int len) { unsigned short crc = 0xFFFF; unsigned int i, j; for (i = 0; i < len; i++) { crc ^= data[i]; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc = crc >> 1; } } } return crc; }注意发送时 CRC 低字节在前。也就是crc & 0xFF先发,crc >> 8后发。很多新手在这里栽跟头,把高字节先发出去,结果从站全部忽略。
3.3 常用功能码:03 读保持寄存器、04 读输入寄存器
Modbus 协议定义了多个功能码,但传感器数据采集最常用的就是两个:功能码 03(读保持寄存器)和功能码 04(读输入寄存器)。
区别在哪里?保持寄存器(Holding Register)通常用于可读可写的参数,比如传感器的校准值、量程设置、修正系数;输入寄存器(Input Register)通常是只读的实时测量值,比如当前温度、湿度、压力。不同传感器厂家的手册定义不同:有些传感器把测量值放在保持寄存器,有些放在输入寄存器。所以开工前一定要仔细看传感器手册,确认物理量对应的寄存器区。
以我接过的一个实际案例为例,某土壤湿度传感器的手册写得很直白:“测量值(水分含量)位于输入寄存器 0x0002,32 位浮点,IEEE 754 标准”。那么用功能码 04 读取:
请求帧:
01 04 00 02 00 02 CRC_LOW CRC_HIGH其中00 02是起始寄存器地址(高字节在前),00 02是读取的寄存器数量(2 个寄存器存 32 位浮点)。响应帧:
01 04 04 [4字节数据] CRC_LOW CRC_HIGH注意04是数据字节数,因为 2 个寄存器 = 4 字节。这里必须看文档确认寄存器字序是大端还是小端。Modbus 协议标准规定寄存器地址高字节在前,多寄存器数据默认高字在前,但有些国产传感器并不按套路出牌,实测时如果解析出来是乱值,优先检查字节序。
功能码 16(写多个寄存器)和功能码 06(写单个寄存器)一般用于配置传感器参数,比如设置从站地址、修改波特率、执行校零操作。这类写操作在高频采集场景中不常用,但在产品初始化流程里会用到。写法上,06 是“地址 + 寄存器 + 值”,16 还要带上数据字节数。
4. 传感器数据读写实现:从寄存器到物理量
4.1 组装请求报文并解析响应
理解了帧格式之后,读写传感器的完整逻辑就清晰了。这里给出一个不依赖 libmodbus 的纯 C 实现思路,可以直接嵌入到自己的应用代码中。
读取传感器数据的完整流程分为四步:
第一步,组装请求帧。填充从站地址、功能码、寄存器地址、寄存器数量,调用 CRC16 计算函数得到校验值,按“低字节在前”顺序附加到帧尾。
int build_read_frame(unsigned char slave_addr, unsigned char func, unsigned short reg_addr, unsigned short reg_count, unsigned char *frame) { int len = 0; frame[len++] = slave_addr; frame[len++] = func; frame[len++] = (reg_addr >> 8) & 0xFF; frame[len++] = reg_addr & 0xFF; frame[len++] = (reg_count >> 8) & 0xFF; frame[len++] = reg_count & 0xFF; unsigned short crc = crc16_modbus(frame, len); frame[len++] = crc & 0xFF; frame[len++] = (crc >> 8) & 0xFF; return len; }第二步,发送请求帧并等待响应。发送用write(),等待响应时最忌讳无脑 sleep。正确做法是使用select()或poll()设置超时时间,超时时间一般设为 500ms 或 1000ms。为什么不能 sleep 固定时间?因为如果从站没接或者地址错误,sleep 完再读就是白白浪费时间,而 select 可以提前返回。
int send_and_receive(int fd, unsigned char *send_buf, int send_len, unsigned char *recv_buf, int recv_max, int timeout_ms) { struct timeval tv; fd_set fds; int ret; /* 清空旧数据,避免读到上一次的残留响应 */ tcflush(fd, TCIFLUSH); /* 发送 */ ret = write(fd, send_buf, send_len); if (ret != send_len) { return -1; } tcdrain(fd); // 等待发送完成 /* 等待响应 */ FD_ZERO(&fds); FD_SET(fd, &fds); tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; ret = select(fd + 1, &fds, NULL, NULL, &tv); if (ret == 0) { /* 超时,说明从站没响应 */ return -2; } if (ret < 0) { return -3; } /* 读取响应 */ ret = read(fd, recv_buf, recv_max); return ret; }第三步,校验响应帧。先检查响应帧的地址是否是对应的从站地址,再检查功能码的最高位是否置 1(异常帧),然后用 CRC16 重新计算整个响应帧并比对收到的 CRC。这里有一个经验细节:如果是从站地址正确但功能码异常,说明请求格式可能有问题;如果响应帧的 CRC 错误,说明干扰严重或者波特率不匹配导致数据错位。
int check_response(unsigned char *recv_buf, int recv_len, unsigned char slave_addr, unsigned char func) { if (recv_len < 5) return -1; // 帧太短 if (recv_buf[0] != slave_addr) return -2; // 地址不匹配 /* 异常帧判断 */ if (recv_buf[1] & 0x80) { /* 异常码在 recv_buf[2],可打印日志 */ return -3; } if (recv_buf[1] != func) return -4; // 功能码不匹配 unsigned short crc_recv = recv_buf[recv_len - 2] | (recv_buf[recv_len - 1] << 8); unsigned short crc_calc = crc16_modbus(recv_buf, recv_len - 2); if (crc_recv != crc_calc) return -5; // CRC 校验失败 return 0; }第四步,从响应帧中提取数据。功能码 03/04 的响应中recv_buf[2]是数据字节数,recv_buf[3]开始是寄存器数据。我们要做的就是把原始字节组合成有意义的物理量。
4.2 4 字节寄存器数据转浮点数
传感器数据最常见的一种格式是 IEEE 754 单精度浮点数(32 位),存储在 2 个连续的 16 位寄存器中。转换过程说起来很简单,就是 4 个字节组合成一个 float,但实际项目中几乎每个人都会在字节序上栽一次跟头。
假设响应帧中的数据段为recv_buf[3]、recv_buf[4]、recv_buf[5]、recv_buf[6],对应寄存器 1 高字节、寄存器 1 低字节、寄存器 2 高字节、寄存器 2 低字节(注意 Modbus 大端模式)。多数传感器采用“大端字节序,且 32 位浮点高字在前”的排列,也就是说这 4 个字节天然就是 float 在内存中的大端表示。
Linux 上最稳妥的转换方法是自己拼接,不要直接用指针强转,避免字节序不确定的问题:
#include <stdint.h> #include <string.h> float four_bytes_to_float(unsigned char b0, unsigned char b1, unsigned char b2, unsigned char b3) { uint32_t tmp = ((uint32_t)b0 << 24) | ((uint32_t)b1 << 16) | ((uint32_t)b2 << 8) | (uint32_t)b3; float result; memcpy(&result, &tmp, sizeof(float)); // 使用 memcpy 避免别名问题 return result; }注意不要这么写:
float result = *(float *)&tmp;虽然绝大多数场景能跑通,但严格来说这属于类型别名违规,某些优化等级下 gcc 的行为是未定义的。用 memcpy 没有性能损耗,编译器会优化成几个移动指令。
转换完成后,还要确认“字节序是否反转”。有些传感器是低字在前,也就是寄存器 1 存的是浮点数的低 16 位,此时需要交换字节顺序再转换。前面这个拼接函数里的 b0~b3 顺序需要调整成b3、b2、b1、b0。我强烈建议开发时把原始寄存器值和转换后的浮点数同时打印出来,方便快速判断字节序问题:
printf("raw bytes: %02X %02X %02X %02X -> float: %.2f\n", b0, b1, b2, b3, result);如果 b0~b3 和浮点数值对不上号,调换顺序再试。
4.3 多从机轮询调度框架设计
一个总线上挂了多个传感器时,主站需要依次轮询各个从站。轮询框架的设计核心在于“串行 + 超时 + 状态机”。
串行是指同一时刻总线上只能有一个请求,不能并发发送。多线程情况下必须用互斥锁保护串口的写操作,否则两个线程同时发送请求会造成总线数据交叉,从站完全无法解析。
超时处理方面,每个从站的响应时间可能不同。同一条总线上的设备,建议统一超时时间,比如 500ms。如果某个从站故障,不要让整个轮询卡死,要跳过它继续轮询下一个。轮询周期的计算方式是:所有从站请求耗时之和 + 每个从站超时时间 × 故障从站数 ≤ 目标轮询周期。举个实际例子:3 个从站,每个正常响应 50ms,故障 1 个,超时 500ms,那么一轮最坏耗时 = 2×50 + 1×500 = 600ms,轮询周期至少设为 1 秒才能保证稳定性。
状态机的应用场景是:同一个从站的数据可能有不同的寄存器区,需要分步读取。比如一个气象站设备,地址 1 的温度在寄存器 0x0001,湿度在 0x0003。可以一次读 4 个寄存器,也可以分两次读。如果分两次读,轮询状态机就有 4 个状态:读温湿度、切换从站、错误重试、跳过。用状态机的好处是代码结构清晰,方便扩展新的传感器类型。
轮询线程和业务线程之间用共享缓冲区传递数据。共享缓冲区需要加锁或使用无锁环形队列。嵌入式 Linux 平台上我一般用 pthread 互斥锁加条件变量,数据更新后通过条件变量通知业务线程。安全性和实时性能满足要求。
单从站单组寄存器读取的完整代码可以浓缩为一个函数:
int modbus_read_float(int fd, unsigned char slave, unsigned short reg, float *result) { unsigned char send_buf[8]; unsigned char recv_buf[64]; int send_len, recv_len, ret; send_len = build_read_frame(slave, 0x04, reg, 2, send_buf); recv_len = send_and_receive(fd, send_buf, send_len, recv_buf, sizeof(recv_buf), 500); if (recv_len < 0) { return recv_len; } ret = check_response(recv_buf, recv_len, slave, 0x04); if (ret != 0) { return ret; } *result = four_bytes_to_float(recv_buf[3], recv_buf[4], recv_buf[5], recv_buf[6]); return 0; }这个函数可以作为整套采集逻辑的基石,扩展不同厂家传感器时,只需要增加对应的寄存器地址和解析规则,不需要动底层通信框架。
5. 常见问题与排查技巧实录
5.1 串口打不开或设置生效失败
遇到串口打不开,先检查三件事:设备节点对不对、权限够不够、有没有被别的程序占用。
嵌入式 Linux 上最容易出问题的是设备节点对不上。比如用 USB 转 485 模块,插入后设备名可能是/dev/ttyUSB0,但如果之前已经插了一个设备,新插入的可能变成/dev/ttyUSB1。建议在代码里加一个启动时自动扫描逻辑,或者手动确认dmesg | grep tty的输出。
权限问题前面已经提过,dialout组是最常见的解法。还有一种情况是系统里用了modemmanager服务,会自动占用串口做探测,导致应用打开失败或打开后被异常关闭。嵌入式系统如果装机区这个服务,直接禁掉:
sudo systemctl stop ModemManager sudo systemctl disable ModemManager配置生效失败,也就是tcsetattr返回错误,多半是因为文件描述符已经失效,或者串口被关闭。另外注意tcsetattr的TCSANOW参数表示立即生效,如果改成TCSADRAIN会等所有数据发送完才生效,在某些场景下结果不同。建议先 TCSANOW,如果出现参数不一致再尝试其他方式。
5.2 发出的报文没响应:从波形、接线、地址三个方向排查
主站发了请求,从站完全不回,这是最让人头疼的问题。按我习惯的排查顺序,从三个方向逐一排除。
第一查硬件波形。用示波器或逻辑分析仪抓 TTL 电平端(如果是 RS-485,查 A/B 差分线)。看有没有帧波形输出,波形是否清晰,电平是否为有效范围。如果没有波形,问题在主站发送链路;有波形但从站无响应,问题在从站侧或接线。8051 时代的老工程师喜欢“示波器一挂,问题一半”,这个思路在 Linux 下同样适用,串口波形是最终的事实依据。
第二查接线。RS-485 接线最容易出错的是 A/B 反接,反接后从站完全收不到数据。还要检查终端电阻,长距离传输通信线两端应该各接一个 120Ω 匹配电阻。有些从站设备出厂默认接入了终端电阻,如果总线上挂了很多设备,终端电阻数量过多反而会拉低信号质量。另外 RS-485 还需要共地,特别是有多个设备通过不同电源供电时,地电位差会导致通信不可靠。
第三查地址和协议参数。从站地址是否在 1~247 范围内?请求帧中填的地址和从站拨码开关或配置软件设置的地址是否一致?波特率、数据位、停止位、校验位是否和从站要求一致?有一个经验:如果从站能收到请求但无法解析,大部分原因是 CRC 错误或参数不匹配。可以先用手头的 Modbus 调试工具(比如 Modbus Poll 或简单的串口助手 + CRC 计算器)手动发送一帧验证,如果能通,说明问题出在代码;不能通,问题出在硬件或配置。
5.3 偶发 CRC 错误和数据错位
“十次通信八次正常,两次数据乱掉”这种情况,多半是干扰或时序问题。
先排查干扰。RS-485 走的是差分信号,本身抗共模干扰能力不错,但如果线缆屏蔽层没有单端接地,强电线路与通信线走在同一个线槽里,或者电源纹波大,都可能导致偶发误码。解决办法是换屏蔽双绞线、通信线远离动力线、在 A/B 线间加 TVS 管或 120Ω 电阻。
再排查时序。主站发送完请求后,不能在请求帧结束的同时立即切到接收模式,因为 485 收发芯片有切换延迟。切换过早,从站第一个响应字节的前几个 bit 会被吃掉,导致响应帧不完整;切换过晚,第一个字节也收不到。用 GPIO 控制方向时,建议发送完成后延时 1~2 个字节时间再切接收,具体延时和波特率相关。
数据错位还有一种常见原因是阻塞模式下read()只读到了部分数据。Modbus RTU 的响应帧长度是不固定的,一次select()返回并不代表整个帧都到了。正确做法是循环读取,直到满足“收到完整帧”的条件(可以通过解析数据字节数字段判断长度),或者直接按最大期望字节数读取,累加直到长度满足。这需要实现一个简单的状态解析器,从缓冲区提取完整帧再交给上层。如果把响应原封不动按read()返回的长度解析,偶发错位几乎是必然的。
结语与实操心得
写了这么多,最后分享几点我在实际项目中的体会。
第一个体会是:串口配置一定要做成“可配置 + 可打印”。我在产品里会让串口参数从配置文件中读取,同时在启动时打印实际的 termios 参数和配置的期望值,两边对不上时一眼就能发现。做嵌入式调试,最怕的就是“代码看着没问题”,但设备就是不通,这时候能系统性地排查比“碰运气改参数”效率高得多。
第二个体会是:调试 Modbus 通信,一定要给自己准备趁手的工具。Modbus Poll 这个主站模拟工具极其好用,可以把传感器接到电脑上独立验证它的报文格式和寄存器定义,确认传感器侧没有问题后,再拿嵌入式设备做对接。反过来,如果嵌入式设备的报文和电脑发的一模一样,传感器却无响应,那问题就限定在硬件链路上了。这个“三分法”排查逻辑帮我省了大量时间。
第三个体会是:协议细节要建自己的 check-list。比如 CRC 低字节在前、寄存器地址大端、浮点字节序、功能码异常码含义、寄存器区和传感器手册的对应关系,这些细节零散且容易遗忘,每次对接新传感器时逐项确认,基本可以避免 90% 以上的低级错误。
Modbus RTU 在嵌入式 Linux 上的开发并不复杂,但细节密度很高。把这个基础打牢,后续无论是接更多种类的传感器、做更复杂的采集控制逻辑,还是扩展 Modbus TCP 网关,都会顺畅很多。希望这篇分享能帮你少走一些弯路。