大部分做嵌入式Linux的人,第一次接触Modbus RTU应该都是类似经历:电脑上用串口调试助手发报文,从站设备响应得很干脆,数据清清楚楚摆在屏幕上;等把同一套逻辑搬到开发板上,自己写代码一发出去,要么石沉大海,要么收到的全是乱码。我最初调这套东西的时候,卡了整整两天,最后发现竟然是串口配置里少了CLOCAL和CREAD,白白浪费了一堆时间。
这篇内容不打算讲太多高大上的架构设计,就围绕“嵌入式Linux端如何把Modbus RTU真正跑起来”这条线,从硬件准备、串口配置、报文组帧、CRC16校验、主站程序实现,到实机调试的常见坑,一次性说透。文章里的代码基本都是可以直接抄去改动用的,适合正在做传感器数据采集、工业网关、设备联网这类项目的朋友。
1. 开发前的硬性准备:硬件选型与串口识别
1.1 板卡和传感器之间的电气链路
嵌入式Linux开发板要挂Modbus RTU设备,最常见的有两种接法:
一是通过USB转485模块,比如CH340/FT232加MAX485,插到板子的USB口上,驱动起来之后会生成/dev/ttyUSB0。二是直接用板载UART引脚接一个RS485收发器芯片,比如SP3485、MAX3485,这时生成的设备节点一般是/dev/ttyS0、/dev/ttyS3、/dev/ttymxc0这类。
如果是做原型验证,USB转485最省事,插上就能用,而且PC端和嵌入式端完全共用同一套逻辑。但如果是做产品级项目,我会建议用板载UART加外部RS485收发器的方案,原因也很直白:USB转串口芯片那一层多多少少会引入延迟抖动,而且断电重启后设备节点可能漂移,不适合长期稳定运行。另外,板载UART还能配合GPIO做RS485方向控制,这是后面会详细讲的重点。
1.2 系统里能不能看到串口节点
拿到板子后,第一步要先确认串口节点名称。运行以下命令:
ls -l /dev/tty* dmesg | grep -i tty不同芯片平台命名差异很大:
| 平台 | 常见串口节点 |
|---|---|
| 树莓派 | /dev/ttyAMA0、/dev/ttyS0 |
| 全志H3/H5系列 | /dev/ttyS0、/dev/ttyS3 |
| i.MX6系列 | /dev/ttymxc0、/dev/ttymxc3 |
| RK3288/RK3399 | /dev/ttyS0、/dev/ttyS4 |
这里面有个特别容易踩的坑:很多开发板的默认启动参数里带了console=ttymxc0,也就是说这个串口被内核日志占用了。如果你把Modbus接在那个口上,日志消息会直接和Modbus帧混在一起,导致CRC校验永远不对。排查方法很简单:先看一下内核启动命令行。
cat /proc/cmdline如果看到了类似console=ttymxc0,115200这样的字眼,尽量换一个没被占用的串口,或者在/boot/uEnv.txt、extlinux.conf里把console参数改掉再重启。我自己一般会把console留着一个串口单独用,另一个串口专门接传感器,物理上分开最省心。
权限问题也顺带一提:普通用户直接open("/dev/ttyS3")可能会报Permission denied,可以先把当前用户加入dialout组,或者临时chmod 666 /dev/ttyS3,测试阶段这么做方便,产品上写个udev规则是正道。
2. Termios串口参数:一行不对全程白干
Linux下操作串口,绕不开termios结构体。Modbus RTU用到的串口,要求是8数据位、1停止位、无校验(8N1),波特率常见9600、19200、38400这些。但实际配置起来,有几个隐藏细节,稍不注意就配置无效。
2.1 一份可直接使用的串口初始化函数
以下是我在项目里惯用的打开和配置方式:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <string.h> #include <errno.h> static int uart_open(const char *dev, speed_t baud) { int fd = open(dev, O_RDWR | O_NOCTTY); if (fd < 0) { perror("open uart"); return -1; } struct termios tio; memset(&tio, 0, sizeof(tio)); /* 原始模式:不做行处理、不做回显、不做任何转换 */ cfmakeraw(&tio); /* 打开本地连接和接收使能,这两个标志必须同时置位 */ tio.c_cflag |= CLOCAL | CREAD; /* 数据位 8 */ tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; /* 1 位停止位 */ tio.c_cflag &= ~CSTOPB; /* 无校验 */ tio.c_cflag &= ~PARENB; tio.c_cflag &= ~PARODD; /* 关闭硬件流控,RS485总线不需要 RTS/CTS */ tio.c_cflag &= ~CRTSCTS; /* 读取方式:最少1个字节,超时通过select控制 */ tio.c_cc[VMIN] = 1; tio.c_cc[VTIME] = 0; /* 设置波特率 */ cfsetispeed(&tio, baud); cfsetospeed(&tio, baud); if (tcsetattr(fd, TCSANOW, &tio) < 0) { perror("tcsetattr"); close(fd); return -1; } tcflush(fd, TCIOFLUSH); return fd; }注意我把VMIN=1、VTIME=0,意思是要read到一个字节才返回,没有超时。Modbus这种帧式协议,最好别依赖read的阻塞超时,而是在外面用select或者poll设定超时窗口,这样超时控制更精确,也不会因为一个字节的空等卡死整个进程。
2.2 为什么CLOCAL和CREAD少一个都不行
这是串口配置里最不起眼但又最容易踩的坑。CLOCAL表示忽略调制解调器控制线,不打开的话,如果DCD电平不对,open之后串口可能不会真正工作。CREAD是开启接收使能。很多简化的串口教程里只设置了波特率、数据位就完事,一旦对端数据帧稍长、或者加了RS485收发器,就可能出现“能发不能收”的诡异现象。
还有一个值得注意的:cfmakeraw会一次性帮你把ICANON、ECHO、ISIG这些标志全关掉。如果没有走cfmakeraw,而是手动去配置,很容易漏掉ICANON。在规范模式下,串口收到的0x0D、0x0A会被当成行结束符,直接截断你接收缓冲区里的Modbus帧,CRC自然怎么都对不上。
2.3 RS485方向控制:一半以上的坑都在这里
RS485是半双工总线,同一个时刻只能处于发状态或收状态。板载UART的TXD和RXD是独立的,RS485收发器把差分信号转换成串口信号后,必须是DE/RE引脚拉高时发送,拉低时接收。如果这个方向切换没做好,最常见的现象就是发送波形残缺、接收端收到自己发出去的数据片段、或者别人发数据时自己这边一点反应都没有。
有几种实现方案:
- 自动方向切换芯片:例如MAX13487这类芯片,检测到TXD起始位自动拉高DE,不考虑做方向控制的最省心。
- GPIO控制:用板子上任意一个GPIO接到DE/RE引脚,发送前拉高,发送完成后拉低。
- 内核级
TIOCSRS485ioctl:前提是串口驱动支持,比如部分i.MX和全志平台可以通过ioctl切方向。
绝大多数时候我们都是用GPIO控制。我在调试时一般这样操作:
static void rs485_dir_tx_enable(int fd, int on) { /* 以某板卡GPIO导出后的路径为例,实际情况按平台改动 */ if (fd < 0) return; write(fd, on ? "1" : "0", 1); }发送完整流程就是:
static int modbus_send_frame(int uart_fd, int gpio_fd, uint8_t *frame, int len) { rs485_dir_tx_enable(gpio_fd, 1); usleep(200); /* 给方向切换留出时间 */ int ret = write(uart_fd, frame, len); tcdrain(uart_fd); /* 必须等发送FIFO清空 */ rs485_dir_tx_enable(gpio_fd, 0); usleep(200); return ret; }这里tcdrain特别关键。write只是把数据写进内核驱动缓冲区,并不代表字节已经全部从TXD脚发出去了。如果写完立刻切回接收方向,最后一两个字节可能还在FIFO里,结果就是对方收到的帧少尾巴,CRC校验必然失败。
3. RTU报文与CRC16:先把协议底子打牢
3.1 RTU帧结构和功能码
Modbus RTU的帧格式很简洁,一帧由四部分组成:
| 组成 | 字节数 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 范围1-247,0为广播 |
| 功能码 | 1字节 | 03读保持寄存器、04读输入寄存器等 |
| 数据区 | 可变 | 具体数据,主站发查询时是寄存器地址和数量 |
| CRC16 | 2字节 | 对前面所有字节计算,低字节在前发送 |
读取传感器数据时,我用得最多的是03功能码,读取保持寄存器;如果传感器手册明确写的是输入寄存器,那就用04。两者报文结构完全一致,只是功能码不同、寄存器地址空间不同。
举个例子,要从地址为1的温湿度变送器上读取2个保持寄存器(从寄存器0x0000开始),主站发送帧是八字节:
01 03 00 00 00 02 C4 0B从站正常响应格式:
01 03 04 [数据字节1] [数据字节2] [数据字节3] [数据字节4] [CRC低位] [CRC高位]这里04表示后面有4个数据字节,正好是2个16位寄存器的内容。
3.2 CRC16-Modbus实现:逐位法就够用
CRC16-Modbus的算法特征是多多项式0xA001,初值0xFFFF。实现方式有查表法和逐位法。嵌入式Linux上CPU性能足够,逐位法代码最简洁,也不依赖大表格,我直接在项目里用下面的版本:
static uint16_t crc16_modbus(const uint8_t *data, int len) { uint16_t crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }注意报文里CRC是“低字节在前”,比如上面那帧01 03 00 00 00 02的CRC16算出来是0x0BC4,在帧里要写成C4 0B。这个字节序搞反是新手最常犯的错,一旦写反,从站会直接丢弃你的查询帧,表现为主站发完数据后一直等不到任何响应。
我用一个简单的验证方法:先在电脑上用一串帧数据跑一遍CRC算法,打印出结果,再用市面上任何一款Modbus调试工具去看它发出来的帧尾是不是一致的。工具能对上,代码就基本没问题。
3.3 帧间间隔别忽略
Modbus RTU还有一个容易被轻视的规则:一帧内部字节与字节之间的间隔不能超过1.5个字符时间,两帧之间至少要留出3.5个字符时间。以9600波特率、8N1为例,一个字符大约1.042ms,3.5字符就是3.65ms左右。也就是说,主站从发完查询到开始接收,再到下一轮查询,中间不能连续不断地无间隙发帧。Linux的调度和USB转串口的延迟都会放大这个问题,所以轮询频率不要拍脑袋设得太高。我一般轮询间隔至少给200ms到500ms,多从机情况下单站超时时间也控制在100ms到500ms之间,既稳定又不至于太久。
4. 主站程序逐个字节落地:查询、接收、解析
4.1 构建查询帧
功能码03的查询帧格式很固定,直接用一个函数填字节就行:
static int build_read_holding_regs(uint8_t slave, uint16_t start, uint16_t count, uint8_t *frame) { frame[0] = slave; frame[1] = 0x03; frame[2] = start >> 8; frame[3] = start & 0xFF; frame[4] = count >> 8; frame[5] = count & 0xFF; uint16_t crc = crc16_modbus(frame, 6); frame[6] = crc & 0xFF; frame[7] = crc >> 8; return 8; }寄存器地址和寄存器数量都是大端在前(高字节先发),这一点和CRC的“低字节在前”完全不同,容易搞混,我写在注释里提醒自己。
4.2 接收响应并做超时保护
串口读取不能直接read阻塞到底,因为从站可能掉线、可能响应异常。用select是最稳妥的方式:
static int modbus_recv_frame(int uart_fd, uint8_t *buf, int maxlen, int timeout_ms) { fd_set fds; FD_ZERO(&fds); FD_SET(uart_fd, &fds); struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; int ret = select(uart_fd + 1, &fds, NULL, NULL, &tv); if (ret <= 0) return ret; return (int)read(uart_fd, buf, maxlen); }这里有一个经验:Modbus响应帧每个字节之间也可能存在毫秒级间隙,如果只调用一次read,可能会遇到“读到3个字节就返回了,CRC还在路上”的情况。所以最好采用累积读取的方式,设定一个相对较长(比如50ms)的尾部等待窗口,直到一段时间内没有新字节,或者收到预期帧长再停止,再或者直接循环读到超时。
实现思路是:先select等待第一个字节,之后只要还有新数据,就继续读;如果连续一个时间段没有再到达数据,认为这一帧结束了。
4.3 响应帧校验与数据解析
收到一帧后,按顺序做这几件事:
- 校验从站地址是不是自己查询的那个地址;
- 检查功能码是否是0x03,如果收到0x83,说明从站返回了异常,要看后面的异常码;
- 校验CRC;
- 校验
字节数字段是否等于4 + 2 * 寄存器数量(这里是读取2个寄存器,所以是4); - 根据寄存器字节还原数值。
int n = modbus_recv_frame(uart_fd, rx, sizeof(rx), 500); if (n > 0) { uint16_t crc_calc = crc16_modbus(rx, n - 2); if ((rx[n - 2] != (crc_calc & 0xFF)) || (rx[n - 1] != (crc_calc >> 8))) { printf("CRC error\n"); return -1; } if (rx[0] != slave || rx[1] != 0x03) { printf("addr or func error\n"); return -1; } int reg_cnt = rx[2] / 2; /* 从第3字节开始,每2个字节构成一个寄存器 */ }4.4 把寄存器值还原成浮点数
传感器里很多物理量是以IEEE 754单精度浮点数存储的,一个float占4字节,也就是两个Modbus保持寄存器。这里有个非常高发的问题:不知道是“高字在前”还是“低字在前”。
我见过两种典型:
- 有的传感器把第一个寄存器作为float的高16位,第二个作为低16位,拼起来就是大端序;
- 也有的设备把第一个寄存器作为低16位,第二个作为高16位。
同一个浮点数0x41B80000(十进制23.0),按顺序不同,还原结果完全不同。我一般会写两个函数都试试,哪个结果符合物理意义就用哪个:
static float regs_to_float(uint16_t reg1, uint16_t reg2, int hi_word_first) { uint32_t raw; if (hi_word_first) raw = ((uint32_t)reg1 << 16) | reg2; else raw = ((uint32_t)reg2 << 16) | reg1; float val; memcpy(&val, &raw, sizeof(float)); return val; }如果设备手册没写清楚,最有效的方法是把传感器放到已知温湿度的环境里,读到寄存器原始值后,跟IEEE 754在线转换对照一下,马上就能判断字节序和寄存器顺序。比如拿到寄存器原始值是0x41B8和0x0000,无论怎么调,最终都应该还原出23.0左右,用这个方式验证最直观。
4.5 简单的主轮询循环
单个从站轮询温湿度,完整主循环可以是这样:
int main(void) { int uart_fd = uart_open("/dev/ttyS3", B9600); if (uart_fd < 0) return -1; /* gpio_fd 按实际平台初始化,这里示意 */ int gpio_fd = -1; while (1) { uint8_t tx[8]; int tx_len = build_read_holding_regs(0x01, 0x0000, 2, tx); if (modbus_send_frame(uart_fd, gpio_fd, tx, tx_len) < 0) { printf("send fail\n"); sleep(1); continue; } uint8_t rx[64]; int n = modbus_recv_frame(uart_fd, rx, sizeof(rx), 500); if (n > 0) { /* 地址、功能码、CRC校验... */ uint16_t reg_a = ((uint16_t)rx[3] << 8) | rx[4]; uint16_t reg_b = ((uint16_t)rx[5] << 8) | rx[6]; float temp = regs_to_float(reg_a, reg_b, 1); printf("temp = %.2f\n", temp); } else { /* 超时处理:打印错误或继续下一轮 */ } usleep(500 * 1000); } }多从机轮询时,本质就是按地址列表循环执行上面的过程,每个从站用独立的超时窗口,一个设备没响应不能拖死整个流程。
5. 实机调试中高发问题与定位思路
5.1 能发不能收,先复查方向控制和A/B接线
这是我碰到频率最高的情况。代码看起来没有任何问题,串口开了、帧发了,从站就是静默。此时不要急着怀疑从站坏了。
第一个核查点:RS485的A、B线是不是接反了。A接A,B接B是常识,但很多传感器端子的丝印标得并不直观。遇到不明设备时,我习惯先在PC上用USB转485配合串口调试助手和Modbus轮询工具直接连从站,PC通了,再回来查板子方向控制;PC也不通,就从物理接线查起。
第二个核查点:方向切换的GPIO。发送时必须把DE/RE拉高,接收拉低。有些摊档上GPIO电平反相,你以为是高有效,实际上是低有效。把收发两个阶段分别打点、用万用表量一下DE/RE引脚电平,是排查最快的方式。
第三个核查点:总线末端电阻。短距离(几米)直接传一般没问题,但如果你把设备放在长线的末端,或者现场电磁环境复杂,建议在总线两端各接一个120欧终端电阻。工业现场多节点时,这个问题会突然变成“时好时坏”。
5.2 CRC老校验失败,用回环自测锁定问题层
当发现收到的帧CRC总是不过,有一个很实用的分层定位法:先做UART回环自测。
把串口的TXD和RXD直接短接(如果是RS485芯片,也可以把A/B短接或者用USB转485板的TXD和RXD对接),然后程序发出任意一串字节,看是否能原样收回来。如果回环收到的字节和发出的不一致,说明串口参数本身都不对,先查波特率、数据位、校验位、停止位。如果回环正常,但挂上从站之后CRC就错,那问题多半在RS485方向切换时序上,方向切慢了会截掉帧尾。
还有一种不容易发现的情况:从站发送响应帧时,主站这边的接收只调用了一次read,响应帧被拆成了两次到达,第一次只读到一部分字节,CRC字节还没到。这时候你校验失败,并不是总线数据坏了,而是读取不完整。解决方式就是我前面说的累积读取,把帧尾巴等齐了再校验。
5.3 寄存器数值是天文数字?字节序背锅
数据能读出来,但解析结果严重偏离物理量,比如温度读成1.2e-20、-8498234这种,不用犹豫,寄存器顺序或字节序拼错了。可以先打印寄存器原始值,看是不是能观察到数值变化的情况。比如传感器温度从20度升到21度,原始寄存器值如果呈线性增长,说明那是一个定点数或整数;如果跳变很奇怪,那多半是浮点数,需要换回IEEE 754解析。
我在项目里遇到过一款变送器,明明是同一个传感器型号,两个版本固件的寄存器字节序完全相反。所以最稳妥的姿势就是:新设备到手,先读原始寄存器,拿到已知物理量对比,在代码里加一个字节序可配置项,而不是写死。
5.4 某个从站掉线拖垮整条轮询链路
多从站总线中,如果某台设备掉电、或者地址配置冲突,主站的请求没人响应,如果代码里使用的是无限阻塞的read,整个轮询循环就会卡在那。这里尤其要强调:每个从站的接收必须要有独立超时,超时了就记录错误,继续轮询下一台设备。掉线的设备单独告警,不要影响其他设备的数据刷新。
6. 联调工具与后续扩展
6.1 Modbus Poll和Modbus Slave怎么配合用
联调阶段,我强烈建议先在PC上把协议跑顺,再移植到Linux程序里。
- Modbus Poll可以当作PC端主站,用来直接读取传感器寄存器,验证设备地址、功能码、寄存器地址、寄存器数量这些参数是不是对的;
- Modbus Slave可以当作PC端从站,模拟一个传感器响应你的Linux程序,这样可以在没有真实硬件的情况下,先把Linux端的收发逻辑、CRC校验、超时处理全部调通,再对接真实设备。
这两步做好了,实机上基本上只剩物理接线和方向切换的问题。别一上来就把Linux程序和真实传感器直接连起来调试,出了问题不好分层。
6.2 手写帧还是引入libmodbus
不少朋友会问:既然有现成的libmodbus库,为什么还要手写RTU帧?我的看法是分阶段:
- 如果项目只是快速出demo,用libmodbus能省很多时间,API也优雅;
- 如果做的是资源受限的嵌入式Linux系统,或者需要深度裁剪、定制超时策略,手写帧完全没有额外依赖,代码也就几十行,反而更好维护。
我自己现在的习惯是:手写CRC16和帧解析,因为Modbus RTU太简单了,引入第三方库反而增加了系统里不需要的复杂度。但如果你要同时管理多从站、多寄存器区、频繁读写大量寄存器,同时还要支持RTU转TCP网关,那么直接基于libmodbus做上层封装会更高效。
6.3 从单点采集到多点轮询的扩展方向
传感器单点读通之后,项目通常会快速走向多设备轮询、协议上报、数据存储这几步。给你一个我自己实践下来的扩展思路:
- 设备配置表:用一个数组或配置文件保存从站地址、寄存器起始地址、寄存器数量、采样间隔、字节序参数,整个程序只写一套通用的轮询引擎;
- 接收数据统一进环形缓冲区或者消息队列,解析线程和业务线程分离,避免IO阻塞影响数据上报;
- 如果后续要对接云平台,可以再加一个TCP网关,把Modbus RTU帧封装成Modbus TCP,上位机直接用网络调试工具连上来拉数据。
我做这套嵌入式Linux Modbus采集模块的经历,最大的体会就是:Modbus RTU本身不复杂,复杂的是串口底层状态、字节时序和现场环境这些边边角角的问题。尤其是RS485方向切换那一步,看起来只差几行代码,实际上决定了你能不能稳定地读完一帧数据。
最后分享一个很有用的小习惯:在工程里加一个“原始帧打印”开关,debug模式打开后,把每一轮发送的查询帧和接收的响应帧都以十六进制直接打印到终端。很多看似玄学的通讯异常,只要盯着原始字节流看几轮,基本都能定位出来。我至今保留着这个习惯,调试效率比任何调试工具都管用。