那台 ARM 工控板第一次在温室大棚里打通 Modbus RTU 链路的时候,我盯着终端里刷出来的十六进制报文看了半天,终于确认采集到的土壤温湿度数据和旁边的手持表完全一致。从嵌入式Linux端的串口配置、RTU协议帧构造到最终读取传感器数据,整个流程踩了不少坑,今天把完整路径和定位思路写出来,给准备做类似项目的朋友一个可参考的模板。
先说清楚这篇文章解决什么问题:假设你手上有一块跑着 Linux 的 ARM 板子,外接了一堆 RS485 总线上的 Modbus RTU 传感器,比如温湿度、土壤水分、光照变送器,你需要写一个采集程序把数据读回来。项目涉及的操作不外乎三件事:把 Linux 串口配置成正确的 8N1 模式、按 Modbus RTU 协议组帧和解析、处理现场总线上的各种异常。本文不会去贴一堆八竿子打不着的理论,就按实际开发顺序把每一步讲透。
1. 自研还是套库:先把这个场景想清楚
1.1 这个需求是从什么场景冒出来的
我那段时间接的是农业灌溉项目:大棚里布置了二十多个土壤温湿度传感器,全部挂在一条 RS485 总线上,数据要统一汇总到一块 Cortex-A7 级别的 ARM 工控板。板子上跑的是 Linux 4.9 内核,资源不算宽裕,还要承担水泵控制、日志存储、本地页面展示这些任务,余量其实不大。传感器的通信协议很统一——Modbus RTU,从站地址分别是 1 到 24,波特率 9600,数据格式 8N1,日常操作只有读寄存器。
实际上这个场景在工控现场非常多见。你可能已经注意到,网上搜"嵌入式Linux Modbus"能出来一堆库,比如 libmodbus、FreeModbus 移植,甚至有人直接建议上 Qt 的 Modbus 模块。面对这些方案,第一反应是"用现成的库肯定更省事",但这恰恰是我在这类项目里始终保持警惕的地方。
1.2 我判断自研而不是引库的三个依据
第一个依据是协议使用面非常窄。我只需要读输入寄存器或保持寄存器,对应的功能码只有 0x03 和 0x04,偶尔要写参数也就是 0x06、0x10。整个通信过程是纯主从问答式,没有并发请求,没有多线程抢占,根本用不上 libmodbus 里那一整套复杂状态机。引入它反而要处理交叉编译依赖、版本兼容、线程安全这些问题,为了两个功能码去养一头大象,没必要。
第二个依据是嵌入式环境的调试成本。自研代码的核心逻辑只有 200 行左右,出问题可以在任意位置塞打印,每一帧收发都能看到原始字节。而 libmodbus 的封装层次多,库内部做了缓冲和重试,一旦现场数据不对,你很难判断是库的问题还是自己的用法问题,排错链路会拉得很长。
第三个依据是从站数量固定、寄存器表固定。传感器型号选定了,寄存器地址和功能码就是一张死表,不会出现那种复杂的动态组态需求。如果项目里要适配十几种不同厂家的设备、寄存器表经常变,那我也会老老实实去用成熟的库。换句话说,自研的前提是"需求边界稳定",不是所有项目都适合手写。
1.3 项目硬件链路长什么样
硬件链路其实很常规:ARM 主控板的某个 UART 串口出来 TTL 电平,经过板上的 SP3485 这类 RS485 收发器转成差分信号,然后接到传感器总线。以我当时用的 i.MX6ULL 平台为例,对应的是/dev/ttymxc3,外接 485 收发器后引出 A/B 两根线。这个链路听着简单,但每一步都可能埋雷——设备树没配置好、收发器方向控制脚没接对、共地问题、终端电阻缺失,任何一个环节掉链子,应用层代码写得再漂亮也是白搭。
所以我把这个项目的开发顺序总结成四层:物理层(串口/485 电气)、协议层(Modbus 帧与 CRC)、应用层(读写与数据解析)、业务层(轮询/上报)。后面三个章节完全按这个顺序展开,这也是我建议每个嵌入式 Linux Modbus 项目都遵循的节奏。
2. 串口配置:设备节点、termios 与物理层验证
2.1 先确认设备树和串口节点,别急着 open()
在嵌入式 Linux 上写串口程序,第一步不是open("/dev/ttyS0"),而是确认内核到底把你的串口注册成了哪个设备节点。不同平台命名差异很大:i.MX 系列一般是/dev/ttymxc0~/dev/ttymxc7,瑞芯微的很多 BSP 直接映射成/dev/ttyS0几个节点,全志也是/dev/ttyS0,树莓派则是/dev/ttyAMA0和/dev/ttyS0并存。如果按网上的教程凭印象去 open 一个不存在的节点,大概率只是浪费时间。
我当时踩过一个真实的坑:板卡厂商的 BSP 默认只使能了调试串口 ttymxc0,ttymxc3 的引脚复用根本就没在设备树里配。我在应用层 open/dev/ttymxc3居然成功了——因为 Linux 的设备节点是静态创建的,就算硬件引脚没配置,open 也不会报错。但数据从 TX 引脚就是出不去,因为那个引脚根本没有被复用成 UART 功能。这种问题用示波器或逻辑分析仪看 TX 引脚,会发现电平纹丝不动。
排查思路是这样的:先看/dev/下节点是否存在,再看内核启动日志里有没有对应串口的注册信息,最后检查设备树里对应的 uart 节点 pinctrl 配置和status是否okay。我最终在 dts 里补上了这样的配置才解决问题:
&uart3 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_uart3>; status = "okay"; }; &iomuxc { pinctrl_uart3: uart3grp { fsl,pins = < MX6UL_PAD_UART3_TX_DATA__UART3_DCE_TX 0x1b0b1 MX6UL_PAD_UART3_RX_DATA__UART3_DCE_RX 0x1b0b1 >; }; };改完设备树重新编译 dtb,重启之后再用stty -F /dev/ttymxc3 -a检查,串口才算真正可用了。所以我的建议是:遇到串口收发异常,先怀疑设备树和引脚复用,不要一上来就改应用层代码。
2.2 termios 关键配置点:8N1 与 raw 模式
Linux 用户态配置串口本质就是操作struct termios。Modbus RTU 是二进制帧协议,要求串口工作在 raw 模式:不能有回显、不能把 0x03 当成 Ctrl-C、不能做任何输入输出转换。cfmakeraw()这个函数能把 ICANON、ECHO、ISIG、IEXTEN 这些行规程处理全部关掉,是 Modbus 串口初始化的基础。
下面这段是我项目里的串口初始化函数,参数可以直接参考:
static int uart_setup(int fd, int baudrate) { struct termios opts; speed_t speed = B9600; if (tcgetattr(fd, &opts) < 0) { perror("tcgetattr"); return -1; } cfmakeraw(&opts); switch (baudrate) { 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 |= (CLOCAL | CREAD); /* 不依赖调制解调器控制线,使能接收 */ opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; /* 8 个数据位 */ opts.c_cflag &= ~PARENB; /* 无校验 */ opts.c_cflag &= ~CSTOPB; /* 1 个停止位 */ if (tcsetattr(fd, TCSANOW, &opts) < 0) { perror("tcsetattr"); return -1; } tcflush(fd, TCIOFLUSH); return 0; }几个关键位要理解,不然出问题不知道往哪查。CLOCAL表示驱动不依赖 DCD 等调制解调器线路,这在嵌入式板卡上尤其重要,否则某些串口在没有接标准串口设备的时候 open 行为会很奇怪。CREAD打开接收使能。CS8是 8 位数据位,~PARENB是无校验,~CSTOPB是 1 位停止位,合起来就是传感器默认的 8N1。波特率收发两端都设置,防止有的驱动只认其中一边。
还有一个细节:cfmakeraw()之后VMIN=1、VTIME=0,也就是说read()至少会阻塞等到 1 个字节。Modbus 请求-响应需要严格的超时控制,所以我后面的读操作不是直接依赖read()阻塞,而是用poll()设置超时时间,超时后主动判定从站无应答。这个设计对轮询多个从站的场景非常关键,否则一个从站掉线就能把你的采集线程卡死。
2.3 先用 stty 和回环测试验证物理通道
写应用层代码之前,先在 shell 里用命令验证串口通道。查看当前参数:
stty -F /dev/ttymxc3 -a重点关注speed 9600 baud、cs8 -cstopb -parenb这三项。如果不对,可以直接用 stty 临时设置:
stty -F /dev/ttymxc3 9600 cs8 -cstopb -parenb raw注意这里raw参数对应的就是 cfmakeraw 的效果。设置完先做一个最简单的回环测试:把串口的 TX 和 RX 引脚短接,然后在 shell 里发字节,看能否原样收回来。
echo "hello" > /dev/ttymxc3 cat /dev/ttymxc3屏幕上回显 hello,说明内核里这个串口节点的收发链路是通的。但这里要特别提醒,回环测试通过只代表 UART 本身没问题。如果板子上接了 485 收发器,还得继续验证 RS485 方向切换和总线接线,这部分我放到第 5 节详细说。回环测试的价值是帮你先排除掉一半的故障面,让后续 Modbus 调试只面对协议层和电气层的问题。
3. Modbus RTU 协议细节:帧格式、功能码与 CRC16
3.1 RTU 报文结构:主从问答,没有帧头帧尾
Modbus RTU 是主从问答式协议,同一时刻总线上只能有一个主站发起请求,从站收到合法请求后才应答,从站之间不通信。这个特性决定了 485 半双工总线上的仲裁很简单:主站控制节奏,从站被动响应。
RTU 帧格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1 字节 | 1~247,0 为广播地址,从站应答时回自己的地址 |
| 功能码 | 1 字节 | 0x03/0x04/0x06/0x10 等 |
| 数据 | N 字节 | 寄存器地址、数量、实际数据,大端字节序 |
| CRC16 | 2 字节 | 校验前面所有字节,低字节在前发送 |
一个典型读保持寄存器请求帧是 8 个字节:01 03 00 00 00 02 C4 0B。拆开看就是从站地址 0x01、功能码 0x03、起始寄存器地址 0x0000、寄存器数量 0x0002、CRC16 校验值。C4 0B 是低字节在前的典型例子——你按常见 CRC 算法算出来结果是 0x0BC4,发送时要先发 0xC4 再发 0x0B。
响应帧格式:01 03 04 41 20 00 00 xx xx,第二个字节是功能码,第三个字节是后面携带的数据字节数(寄存器数量 × 2),后面跟实际寄存器数据,最后两位是 CRC16。由于没有帧头帧尾,RTU 从站是靠字符间隔来识别帧边界的:一帧内部两个字符间隔不能超过 1.5 个字符时间,一帧结束到下一帧开始要间隔至少 3.5 个字符时间。9600 波特率下,1 个字符约 1.1ms,3.5 字符时间差不多 4ms,所以主站连续发两帧请求之间最好留 5ms 以上的静默,否则部分从站会把两帧当成一帧处理。
3.2 功能码选型:读传感器数据该用 03 还是 04
传感器读寄存器最常见的两个功能码是 0x03(读保持寄存器)和 0x04(读输入寄存器)。教科书里的区分是:保持寄存器可读可写,一般放配置参数;输入寄存器只读,适合放实时采集数据。但现实世界没这么守规矩,不少温湿度变送器把测量值放在保持寄存器里,功能码也支持 0x03 和 0x04 同时可用,两个功能码读出来的寄存器表还一样。
我的选型经验是:先看传感器手册的寄存器表,手册里通常会写"支持功能码 03",照做就行。只写了寄存器编号没写功能码,就先用 03 试,如果返回异常码 0x01,再换成 04。不要一看到"传感器数据"就默认一定是 04,我在现场见过太多因为纠结这个细节浪费时间的例子。
顺带说下其他功能码的用途,方便你以后扩展:
| 功能码 | 名称 | 典型用途 |
|---|---|---|
| 0x01 | 读线圈 | 读取开关输出状态 |
| 0x02 | 读离散输入 | 读取干接点输入 |
| 0x03 | 读保持寄存器 | 读取可读可写的寄存器区 |
| 0x04 | 读输入寄存器 | 读取只读的传感器数据区 |
| 0x06 | 写单个寄存器 | 修改单个参数 |
| 0x10 | 写多个寄存器 | 批量下发参数 |
3.3 手写 CRC16 计算与低字节在前的坑
CRC16-Modbus 是 Modbus RTU 最容易写错的地方。算法特征:初始值 0xFFFF,多项式 0xA001,每个字节先和 CRC 低 8 位异或,再右移 8 次,如果最低位是 1 就异或多项式。下面是位运算法实现,嵌入式场景完全够用:
uint16_t crc16_modbus(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; size_t i; int bit; for (i = 0; i < len; i++) { crc ^= data[i]; for (bit = 0; bit < 8; bit++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }计算完的返回值是一个 16 位整数,比如 0x0BC4。RTU 协议规定发送时低字节在前,所以组帧时要这样填:
frame[len++] = crc & 0xFF; /* 先发低字节 0xC4 */ frame[len++] = crc >> 8; /* 再发高字节 0x0B */这个顺序问题非常阴险。如果你发成了0B C4,从站收到会算出一个错误的 CRC,然后直接丢弃这一帧,表现为"主站发了请求但什么响应都没有"。尤其是第一次上手的朋友,很容易拿着标准 CRC 算法算完直接把返回整数按大端塞进帧里。我建议组帧封装统一处理 CRC 填充,不要把发送顺序暴露在业务代码里,这样能少踩一半的坑。
3.4 寄存器地址:40001 和 0x0000 到底什么关系
传感器手册里经常出现一批让人迷惑的地址,比如"温度寄存器地址 40001,湿度寄存器地址 40002",但你在组帧时填的却是0x0000和0x0001。这里涉及老式 PLC 地址体系的遗留习惯:4 开头的地址代表保持寄存器区,40001 对应协议地址 0x0000,40002 对应 0x0001。同理,3 开头代表输入寄存器区,30001 对应协议地址 0x0000。
转换规则很简单:如果手册给定地址是 4xxxx,协议地址就是 4xxxx - 40001;如果是 3xxxx,协议地址就是 3xxxx - 30001。如果手册直接写的是 0x0000、0x0001,那就直接用。很多人犯的错误是把 40001 直接当成协议地址填进去,结果从站回一个 0x02 非法数据地址异常,还以为是传感器坏了。其实从站是在说"你问的地址不在我的寄存器表里"。
4. 读写传感器数据的 C 代码实现
4.1 串口打开与初始化封装
看完了底层配置,代码就可以逐层搭起来了。我的做法是打开串口和配置参数合成一个函数,返回打开的 fd,整个采集进程只持有这一个 fd,多个从站通过总线轮询共用同一根串口。
这里有个细节:open 时建议加O_NONBLOCK,防止在极端情况下 open 本身阻塞。配置完 termios 后用fcntl清掉O_NONBLOCK,让后续逻辑统一走 poll + read 的超时控制模式:
static int uart_open(const char *dev, int baudrate) { int fd = open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) { perror("open"); return -1; } fcntl(fd, F_SETFL, 0); /* 恢复阻塞模式 */ if (uart_setup(fd, baudrate) < 0) { close(fd); return -1; } return fd; }4.2 构造读请求与读取完整响应
组帧的逻辑不复杂,关键是模块化。我会把 CRC 填充放到一个函数里,避免每次组帧都手动处理字节序:
static int modbus_read_request(uint8_t *frame, uint8_t slave, uint8_t func, uint16_t reg_addr, uint16_t reg_cnt) { frame[0] = slave; frame[1] = func; frame[2] = reg_addr >> 8; frame[3] = reg_addr & 0xFF; frame[4] = reg_cnt >> 8; frame[5] = reg_cnt & 0xFF; uint16_t crc = crc16_modbus(frame, 6); frame[6] = crc & 0xFF; frame[7] = crc >> 8; return 8; }发送后要读取响应。正常响应长度是固定的:地址 1 字节 + 功能码 1 字节 + 字节数 1 字节 + 寄存器数据reg_cnt * 2字节 + CRC 2 字节,总共5 + reg_cnt * 2字节。我建议不要用一次read()去赌能读完整帧,而是循环读取直到凑够期望字节数,配合poll()设置超时:
static int modbus_read_response(int fd, uint8_t *resp, int expect_len, int timeout_ms) { int n = 0; while (n < expect_len) { struct pollfd pfd = { .fd = fd, .events = POLLIN }; int ret = poll(&pfd, 1, timeout_ms); if (ret <= 0) { if (ret == 0) fprintf(stderr, "recv timeout, got %d bytes\n", n); else perror("poll"); return -1; } int r = read(fd, resp + n, expect_len - n); if (r < 0) { perror("read"); return -1; } n += r; } return n; }注意超时时间的选择。标准 Modbus 从站响应是毫秒级,但部分传感器在收到读请求后会先触发一次测量再返回数据,响应延迟可能到 100ms 以上。我初始设 300ms,现场如果出现偶发超时,优先调大这个值,而不是怀疑协议代码。还有个细节:poll()的超时参数会在循环里按每次剩余时间重新计算,简单点可以直接每次都传同一个值,因为正常情况下几毫秒内响应就完整到达了,不会真等满多次。
4.3 数据解析:16 位整型与 IEEE754 浮点的字节序处理
数据解析是很多人头疼的环节,但原理其实很简单。传感器数据常见的形态有两种。
第一种是 16 位整型直接表示,比如湿度扩大 10 倍存成 0x01F4,代表 50.0%。这种情况直接拼接两个字节:
uint16_t raw = (uint8_t)buf[0] << 8 | (uint8_t)buf[1]; float humidity = raw / 10.0f;第二种是 32 位 IEEE754 浮点,占两个连续寄存器。Modbus 惯例是大端序,第一个寄存器是高 16 位,第二个寄存器是低 16 位。响应数据从resp[3]开始是寄存器内容,解析时要严格按这个顺序:
uint16_t reg_hi = ((uint8_t)data[0] << 8) | (uint8_t)data[1]; uint16_t reg_lo = ((uint8_t)data[2] << 8) | (uint8_t)data[3]; uint32_t bits = ((uint32_t)reg_hi << 16) | reg_lo; float value = 0.0f; memcpy(&value, &bits, sizeof(value));这里一定要用memcpy把整数位模式重新解释成浮点,而不是直接value = (float)bits,因为(float)bits做的是整型到浮点型的数值转换,会把0x41C80000从 1101004800 转成一个天文数字,而不是你想要的 25.0。ARM 上虽然也可以直接用 union,但memcpy是最没有编译器歧义的做法。
我排查过不少"温度变成了 1.2e-38"的现场故障,最后都是浮点字节序反了:把低 16 位当成了高 16 位。所以这种转换一定要写一个独立函数,并且加上注释,避免后续维护的人一高兴就把顺序给调了。
4.4 异常响应与重试策略
Modbus 从站出错时不会回正常响应,而是回一个异常帧:功能码变成原功能码 + 0x80,后面跟一个异常码。比如你发01 03 00 00 00 02,从站错误响应是01 83 02 C0 F1,其中 0x83 表示功能码 0x03 加 0x80,0x02 是异常码。解析异常码在现场调试时特别有用:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 0x01 | 非法功能 | 从站不支持该功能码 |
| 0x02 | 非法数据地址 | 寄存器地址越界,或把 40001 当协议地址填了 |
| 0x03 | 非法数据值 | 寄存器数量、组合非法 |
| 0x04 | 从站设备故障 | 传感器内部错误或者掉线 |
异常响应也是 5 个字节,所以收到响应后先判断resp[1]是否大于 0x80,如果是就进异常处理分支。我的重试策略是每从站连续超时 3 次就判定离线,记录一条错误日志后跳到下一个从站,绝不在单个从站上死等。这样一条总线上即使有两三个传感器坏了,其他传感器照样能按周期采集,用户体验差别很大。
5. 现场调试:工具、日志与 485 电气层避坑
5.1 Modbus Poll 和 Modbus Slave 的正确用法
先说调试工具。我调试 Modbus 主站采集程序时,最顺手的组合是 Modbus Poll 和 Modbus Slave。Modbus Poll 是主站模拟工具,用来测试现场传感器是否正常;Modbus Slave 是从站模拟工具,用来在没有真实传感器时验证你自己的采集代码。这两款工具都有商业授权和试用模式,正版覆盖的场景对项目开发完全够用,不要去碰什么破解注册码之类的灰色渠道。
标准操作流程是这样的:现场传感器接好线之后,先用 Modbus Poll 连接真实传感器,配置好串口参数、从站地址、功能码、起始地址和寄存器数量,如果能读到正常的温度值,说明物理链路、传感器配置、协议参数全部没问题。然后把同样的参数填进你自己的程序里,如果读不到数据,问题就锁定在自己的代码上;如果也能读到,那整套链路就算通了。
反过来,程序开发阶段没有硬件时,用 Modbus Slave 模拟一个从站,按传感器手册的寄存器表把数据填进去,让你的采集代码先跑通逻辑。这个习惯能省去大量在现场用真实设备试错的时间——万一你的 CRC 顺序搞反了,在模拟从站上一眼就能看出来,而现场可能要和接线问题混在一起纠结半天。
5.2 抓包定位:问题到底在物理层还是协议层
现场最耗时间的往往是"软件说不关我事,硬件说不是我的锅"这种互相甩锅的局面。我的建议是遇到疑难杂症直接抓包,用数据说话。
抓包的常见手段有三种:逻辑分析仪挂在 485 收发器输出侧的 A/B 引脚上,直接解析差分信号里的数据帧;示波器看波形质量,检查幅值、上升沿、终端电阻匹配;如果用 USB 转 485 工具接到电脑上,用串口助手一类软件抓取总线字节流。抓包数据出来后对比"主站发的帧"和"从站收到的帧"是否一致。
这里有个 485 总线特有的诡异现象:你在 A/B 引脚上抓包,经常能看到主站发出的请求帧后面跟着一串回声一样的字节,这是半双工模式下发送引脚的回读信号。如果代码里对接收中断处理得不干净,可能把自发自收的字节当成了从站响应,导致数据错乱。所以我的采集逻辑里规定:写完成请求后,必须留一个延时窗口让总线稳定,然后再进入接收状态,这个窗口在 9600 波特率下至少 1ms 到 2ms。很多 485 收发器芯片的切换时间达不到这个要求时,就需要靠软件延时来补。
5.3 485 电气层容易踩的四个坑
第一个坑是 A/B 接反。RS485 的 A 和 B 是差分对,接反后的典型表现是"完全无响应"或者"偶发乱码"。判断方法很简单:用万用表测 A 相对 B 的空闲电压,正常状态 A 比 B 高 2~5V,如果你测出来是负的,基本就是接反了。
第二个坑是终端电阻。按照规定,RS485 总线最远两端要各并联一个 120Ω 终端电阻,用于阻抗匹配。短距离(十米以内)不接也能跑,一旦距离拉长到几十米,就会出现随机错误帧。但注意不是每个设备都要接,接多了会拉低差分电压,反而增加误码率。
第三个坑是共地问题。RS485 是差分信号,抗共模干扰能力本身不错,但如果系统之间不共地,共模电压过高就可能击穿收发器。长距离或者跨设备供电的现场,建议两端共地或者直接用带隔离的 485 模块。这个坑属于"平时没事,一出事就是烧芯片"的类型,特别值得提前防护。
第四个坑是收发切换时序。Linux 串口驱动没有专门的 DE/RE 引脚控制,最常用的做法是拿一个 GPIO 接 485 芯片的 DE 端,发送前置高,发送完毕延时后拉低。如果你的板子用了 MAX13487 这种带自动收发切换的芯片,可以不用在软件里控制方向,但要在低速波特率下确认芯片自动切换时间是否满足要求。手动控制 GPIO 的示意逻辑:
static void rs485_set_de(int fd, int enable) { /* 以高级别说明意图,实际平台可用 libgpiod 或 sysfs 接口实现 */ if (enable) write(gpio_fd, "1", 1); else write(gpio_fd, "0", 1); }关键点是发送完后不要立刻把 DE 拉低,最好等最后一位数据真正从移位寄存器发完,再延时 1~2ms,给从站一个建立响应的时间窗口。如果 DE 切换太早,很可能把最后一个字节的半位截掉,从站算 CRC 就会出错。这个坑我亲眼见过多次,排查方向完全在协议层,结果问题出在硬件控制时序上。
6. 实际案例:24 个温湿度传感器轮询与数据落地
6.1 从规格书里找寄存器表
拿一款很常见的 RS485 温湿度变送器举例,规格书上一般写着:默认从站地址 1,波特率 9600,8N1,支持功能码 03。寄存器表如下:
- 地址 0x0000:温度值,IEEE754 浮点,占两个寄存器
- 地址 0x0002:湿度值,IEEE754 浮点,占两个寄存器
有的手册会写成 40001 对应温度高 16 位、40002 对应温度低 16 位、40003 对应湿度高 16 位、40004 对应湿度低 16 位。按第 3 节说的转换关系,40001 对应协议地址 0x0000,所以实际组帧时起始地址就是 0x0000,寄存器数量 4,正好一次读完温度和湿度。
用 Modbus Poll 验证时,填的寄存器地址也是 0,长度 4,功能码 03,从站地址 1,波特率 9600。能读到数据之后,把同样的参数写进代码,就可以进入联调阶段。
6.2 一次完整读取的数据流
假设要读取 1 号从站的温湿度,代码执行过程是这样的:
- 通过
uart_open("/dev/ttymxc3", 9600)打开串口,底层配置成 8N1 raw 模式。 - 调用
modbus_read_request()组帧,得到 8 字节请求:01 03 00 00 00 04 44 0A。 write()发送请求。- 调用
modbus_read_response(),期望字节数5 + 4 * 2 = 13,等待响应。正常情况下收到01 03 08 ...共 13 字节。 - 从响应数据第 3 个字节开始,解析 4 个寄存器的值,前两个拼成温度浮点,后两个拼成湿度浮点。
- 校验 CRC、打印结果。
我这里再强调第 4 步的一个细节:期望字节数要在发送前就算好,因为响应帧长度由寄存器数量唯一确定。如果收到的字节数多于期望数,大概率是总线时序有问题,混入了自发自收的数据;如果少于期望数,基本就是超时截断。这两种情况都要记录日志,方便后续定位。
6.3 多从站轮询的设计思路
项目里有 24 个从站,轮询主循环的思路很简单:把从站配置组织成一张表,循环遍历每个从站,依次执行"组帧-发送-读取-解析"。每个从站维护一个离线计数和离线状态,连续三次超时才标记离线,避免瞬时干扰导致误判。
struct sensor_node { uint8_t slave_addr; uint16_t reg_addr; uint16_t reg_cnt; int offline_cnt; float values[8]; };轮询周期要根据传感器测量特性设置。土壤传感器测量周期往往要 1 到 2 秒,你 100ms 轮询一次也没有用,寄存器里的值不一定更新,反而白白增加总线负载。我这里把每个从站的采集周期统一配置成 3 秒,一条总线 24 个从站轮询一轮大约 10 秒左右,完全满足自动灌溉的控制需求。这里要结合业务场景去设置,而不是一味追求快。
数据拿到之后怎么用就是业务层的事情了。轻量做法是直接写 SQLite,重一点可以打包成 JSON 走 MQTT 上报到云端。我不在这里展开,核心是想说明:Modbus 采集本身只是数据链路的最后一段,后续的数据清洗、异常告警、自动控制才是真正拉开差距的地方。但万丈高楼从地起,底层帧都读不对,后面全白搭。
6.4 项目收尾后的几点体会
回头看这个项目,最值得分享的体会是调试顺序的重要性。我严格按照"串口物理层 → 协议层 → 应用层 → 业务层"的节奏推进,每一步都用工具验证通过再进入下一步,看起来慢,实际是最快的路径。好多同事一上来就写采集程序,遇到问题全堆在一起排查,反而花了数倍时间。
最后再分享一个现场排查的小技巧:程序里所有收发帧都打完整十六进制日志,并且带时间戳。Modbus 调试到后期,90% 的问题靠日志就能定位,根本不用上示波器。我在项目里保留了完整的帧日志开关,默认关闭、调试时打开,一行日志排查现场问题的效率远高于厚厚一沓文档。