搞嵌入式Linux开发的人,十个里有八个迟早要碰Modbus。不管你是接温湿度传感器、读电能表,还是跟PLC对接,Modbus RTU这套玩不转,现场调试能把你耗到怀疑人生。这篇文章就把我最近一次在嵌入式Linux设备上通过串口用Modbus RTU协议读写传感器数据的完整过程摊开讲,从串口配置到底层报文计算,再到实际代码实现和踩坑记录,一条龙讲清楚。
先说这东西是什么、能解决什么问题。Modbus RTU是串行链路上应用最广的工业通信协议,物理层基于RS232或RS485,数据帧简单可靠。在Linux端,你要干的事就三件:把串口配好、按协议组帧发出去、把回包解析出来。这篇文章适合正在做嵌入式Linux应用开发的工程师,或者刚入门想搞明白"Linux下到底怎么跟Modbus设备通信"的人。我不讲学院派理论,全是从板子上跑出来的经验。
1. 项目背景与整体方案选型
1.1 需求拆解
这次的场景很典型:一块ARM Linux开发板(Cortex-A7核心,跑Buildroot裁剪出来的系统)需要读取两个 Modbus RTU 传感器,一个是环境温湿度,一个是管道压力,都是标准RS485接口,波特率9600,8数据位、无校验、1停止位。板子本身引出两路RS485,串口设备节点分别是 /dev/ttyS1 和 /dev/ttyS2。
用户的需求说出来很简单:"定时采集数据,上报平台。"但落到工程上就拆成了几块硬骨头:
- 串口底层怎么配置成"裸数据"模式,而不是被终端驱动干扰。
- 怎么组一条合法的Modbus RTU读请求帧,CRC怎么算。
- 收到一串字节流后,怎么从里面把传感器数值抠出来,还得分清字节序。
- 多传感器轮询时,单站还是多站?一次读几个寄存器?
- 超时、断线、CRC校验失败怎么处理,总不能一卡就死。
这些点,随便哪个没处理好,现场就是"数据乱跳"或者"读不到数"。
1.2 为什么选Modbus RTU而不是Modbus TCP
有朋友会问:现在不都推Modbus TCP吗,网口多方便,为什么还要碰串口RTU?我只能说,工业现场里传感器级别的设备,RS485 + Modbus RTU的存量太大了。成本极低、抗干扰强、能走几百米,而且不少现场根本没有布网线。TCP适合上位机互联和控制器之间的通信,但到了传感器这个层级,RTU仍然是事实标准。另外,不少网口转串口的网关设备,底层透传的还是RTU帧。所以做嵌入式Linux开发,RTU这关绕不过去。
选型上还有一个细节:如果板子只有标准UART(比如 16550 兼容,无FIFO硬件控制),建议在设备树或内核配置里把串口流控关掉。RS485的方向切换有几种做法,后面专门讲。这一步选错,后面调试全靠运气。
1.3 硬件层面的准备工作
硬件部分没做对,软件写得再漂亮也是白搭。我这次用的是板载SP3485(半双工RS485收发芯片),带自动方向控制电路。如果你手里板子的RS485转换芯片不带自动切换,而是需要MCU手动拉高/拉低方向引脚,那就要额外占用一个GPIO,读之前拉高发送使能,读完之后拉低。Linux下可以通过GPIO中断或者sysfs/ioctl来操作,但这会让时序控制复杂不少。我的建议是:如果是自己做板子,优先选带自动方向控制的方案;如果是买现成模块,看清楚芯片型号,别买到那种需要手动切方向的还傻乎乎以为是自己代码问题。
另外,终端电阻和总线偏置也要留意。RS485总线上如果只有一主一从两个设备,链路短、波特率不高,多数情况不加终端电阻也能通。但如果总线长了、节点多了,还是老实加上120欧终端电阻。我踩过一次坑:一条50米的线上挂了8个温湿度传感器,第一批次读出来的数据全都正常,跑到第5个就乱码,后来发现是终端电阻没加,信号反射把数据帧冲乱了。
2. Linux串口配置:termios结构体逐项拆解
2.1 串口参数最基本的几个概念
Linux下串口配置的核心就是操作struct termios结构体。很多人看到一坨c_cflag、c_iflag就头大,其实掰开了就三件事:告诉系统"这串口我用来传原始数据的,别给我处理换行符回显",然后把波特率、数据位、校验位、停止位设对,最后把读取超时机制配好。
这里必须用"原始模式",也就是cfmakeraw()。如果不调用这个函数,你可能遇到一个经典怪问题:发送的字节里只要出现\n,系统自动在你发的数据流里加一个\r,导致帧错位。更坑的是,从设备返回的数据里某些字节被内核当成了控制字符处理,导致recv返回的字节数和实际不一致。我最初接手这个项目时,上一个同事的代码就没用cfmakeraw,结果用串口助手发出来的报文是对的,但Linux程序发出去的帧就是错的。查了一上午,最后拿示波器量波形才发现多了一个字节。
2.2 完整配置代码及每个参数的含义
直接上一段我在项目里用的串口初始化函数,这段代码实测可以直接用,各位可以按自己板子的设备名替换。
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> #include <string.h> #include <errno.h> int uart_open(const char *dev, speed_t baud) { int fd = open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open serial port failed"); return -1; } struct termios options; memset(&options, 0, sizeof(options)); /* 获取当前串口参数 */ tcgetattr(fd, &options); /* 设置为原始模式,禁止系统对数据流做任何二次处理 */ cfmakeraw(&options); /* 使能接收,忽略调制解调器控制线 */ options.c_cflag |= CLOCAL | CREAD; /* 数据位:8位 */ options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; /* 无校验 */ options.c_cflag &= ~PARENB; options.c_cflag &= ~PARODD; /* 1位停止位 */ options.c_cflag &= ~CSTOPB; /* 禁用硬件流控 */ options.c_cflag &= ~CRTSCTS; /* 禁用软件流控 */ options.c_iflag &= ~(IXON | IXOFF | IXANY); /* 原始模式下的读取超时配置 */ options.c_cc[VTIME] = 10; /* 最多等待 1 秒 */ options.c_cc[VMIN] = 1; /* 至少读到 1 个字节 */ /* 设置波特率 */ cfsetispeed(&options, baud); cfsetospeed(&options, baud); /* 写入配置 */ if (tcsetattr(fd, TCSANOW, &options) != 0) { perror("tcsetattr failed"); close(fd); return -1; } /* 清空缓冲区,防止旧数据干扰 */ tcflush(fd, TCIOFLUSH); return fd; }代码里我用了O_NDELAY,这样open()不会因为DCD信号线状态而阻塞。后面CLOCAL | CREAD这两个标志必须加,CLOCAL表示不依赖串口线路上的载波信号,CREAD使能接收。少了这两个,可能出现读不到任何数据但程序也不报错的玄学问题。
VTIME和VMIN的组合值得展开讲。设置成VTIME=10, VMIN=1时,read() 会阻塞到至少收到1字节,之后如果还有数据就继续等,但总等待时间不超过1秒。我这个项目里Modbus响应一般35ms以内到,所以1秒超时绰绰有余。如果你用阻塞 read 时没有设置好超时,串口没数据时会一直卡死在 read() 上,这种问题在轮询线程里尤其致命。
2.3 串口配置常见误区
第一个经典误区就是乱设c_cflag的位段。建议每次配置前先memset,再一个个置位,不要直接在旧参数上改,否则容易残留一些莫名其妙的标志位。
第二个误区是把cfmakeraw()当成万能药,结果收发还是不对。其实cfmakeraw()做的事是把输入输出处理、回显、信号、换行转换都关掉,但它不会帮你设置波特率、数据位、停止位。有很多人调了半天发现校验位不对,其实是忘了手动设置c_cflag。
第三个误区跟多线程有关。如果程序里同时有多个线程往同一个串口fd里写数据,必须加锁,或者干脆用一个独立的发送队列。否则两个线程同时写,可能把一帧Modbus报文给交叉拼接到一块,对方设备收到的就是乱码。
3. Modbus RTU协议帧格式与CRC16计算
3.1 报文帧结构
Modbus RTU的帧结构枯燥但必须背下来。主站发起的读取请求帧长固定8字节:从站地址(1字节) + 功能码(1字节) + 起始寄存器地址(2字节,高字节在前) + 寄存器数量(2字节,高字节在前) + CRC16(2字节,低字节在前)。
从站正常响应的帧长不固定:从站地址 + 功能码 + 字节数 + 数据(2字节/寄存器) + CRC16。我这次用的温湿度传感器是单寄存器组合型,压力传感器也是类似结构,都用功能码03读保持寄存器就能搞定。有些传感器用功能码04读输入寄存器,写法一样,只是功能码不同,别搞混就行。
地址分配上,这次两路RS485都是单站连接,所以从站地址分别是0x01和0x02。如果多个设备挂同一条总线,每个设备的Modbus地址必须唯一,而且轮询间隔要设计好,因为所有从站共享同一条物理链路,同一时刻只能有一个设备在讲话。
3.2 CRC16查表法实现
Modbus RTU的CRC校验用CRC-16/MODBUS算法,多项式是0x8005,初始值0xFFFF,输出结果低字节在前(所以帧里的校验字节要高低位交换写入)。网上有很多查表法、按位计算法,我这次直接用了业界通用的查表实现,生成CRC16的速率比按位算快不少,在高频率轮询场景下更稳。
/* CRC16 查表法 */ static unsigned short crc16_table[256]; void crc16_init_table(void) { for (int i = 0; i < 256; i++) { unsigned short crc = i; for (int j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc = crc >> 1; } crc16_table[i] = crc; } } unsigned short modbus_crc16(unsigned char *buf, int len) { unsigned short crc = 0xFFFF; for (int i = 0; i < len; i++) { crc = (crc >> 8) ^ crc16_table[(crc ^ buf[i]) & 0xFF]; } return crc; }注意这里用的是0xA001,这是0x8005的逆序多项式,是Modbus标准算法精确定义的。你要是拿通用CRC16算法(比如CRC-16/X25)去算,出来的结果铁定对不上,这是新手最容易掉的一个大坑。
组装请求帧时,把算出来的CRC先放低字节,再放高字节。很多刚上手的人写反了,结果从站直接忽略这条请求,没有任何响应,卡在那干等超时。
3.3 功能码选择与IEEE754浮点转换
读取传感器数据时,功能码最常见的就是03和04。这两者的区别简单说:03读保持寄存器,04读输入寄存器。工业传感器里,配置参数通常放在保持寄存器,实时测量值通常放在输入寄存器。但我手上这批传感器比较统一,实时数据都在保持寄存器里,直接功能码03一把梭就行。
传感器返回的数据多数是16位整数。比如温湿度传感器,温度寄存器存的是扩大10倍后的值,比如245代表24.5摄氏度。但压力传感器返回的是32位IEEE754浮点数,占了两个寄存器,这时就要手动做拼接和字节序调整。
IEEE754 4字节转 float,在C语言里最稳妥的办法是memcpy,而不是强制类型转换。因为不同平台的字节序和内存对齐规则可能不一样,强制类型转换很容易翻车。我封装了一个函数:
float regs_to_float(unsigned short low, unsigned short high) { uint32_t val = 0; /* 常见传感器默认:high寄存器在前,即大端存储 */ val = ((uint32_t)high) << 16; val |= ((uint32_t)low); float f; memcpy(&f, &val, 4); return f; }这里必须提一个坑:不同厂商传感器的寄存器字节序不统一。有的传感器是 "A B C D" 直接对应 4个寄存器字节,有的则是 "C D A B" 高低16位反着来。我这次用的压力传感器,数据手册没细说,结果解析出来的浮点数大得离谱。最后是抓了设备主动上报的一帧数据,手动按浮点数试了几种字节序组合,才确定是 low/high 的换序方式。这块建议大家都养成一个习惯:拿到新传感器,先手工构造一帧响应,用串口助手模拟从站回包,把每种字节序都算一遍对比,省得现场抓瞎。
4. 传感器数据读写实操:从请求报文到浮点解析
4.1 读取保持寄存器的完整函数
这一节直接给可用的代码。我封装了modbus_read_registers()函数,职责单一:往指定地址发读请求,阻塞等待响应,返回数据长度。里面包含了组帧、发送、接收、CRC校验四个环节。
int modbus_read_registers(int fd, unsigned char slave_addr, unsigned short start_reg, unsigned short reg_count, unsigned char *rsp_buf, int rsp_buf_size) { unsigned char req[8]; int req_len = 8; req[0] = slave_addr; req[1] = 0x03; /* 功能码:读保持寄存器 */ req[2] = (start_reg >> 8) & 0xFF; req[3] = start_reg & 0xFF; req[4] = (reg_count >> 8) & 0xFF; req[5] = reg_count & 0xFF; unsigned short crc = modbus_crc16(req, 6); req[6] = crc & 0xFF; /* 低字节在前 */ req[7] = (crc >> 8) & 0xFF; tcflush(fd, TCIOFLUSH); if (write(fd, req, req_len) != req_len) { perror("write failed"); return -1; } /* 读取响应:这里按最坏情况等待 */ int total = 0; int nread = 0; while (total < rsp_buf_size) { nread = read(fd, rsp_buf + total, rsp_buf_size - total); if (nread < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) continue; perror("read failed"); return -1; } else if (nread == 0) { break; } total += nread; /* 收到一帧完整报文:地址 + 功能码 + 字节数 + 数据 + CRC2 */ if (total >= 5) { int data_len = rsp_buf[2]; /* 字节数字段 */ int frame_len = 3 + data_len + 2; if (total >= frame_len || total >= 256) break; } } if (total < 5) { fprintf(stderr, "response too short: %d\n", total); return -1; } /* CRC 校验 */ unsigned short crc_rcv = rsp_buf[total-1] << 8 | rsp_buf[total-2]; unsigned short crc_cal = modbus_crc16(rsp_buf, total-2); if (crc_rcv != crc_cal) { fprintf(stderr, "crc error: 0x%04X != 0x%04X\n", crc_rcv, crc_cal); return -1; } /* 异常响应,功能码最高位为1 */ if (rsp_buf[1] & 0x80) { fprintf(stderr, "modbus exception code: 0x%02X\n", rsp_buf[2]); return -1; } return total; }发送前先tcflush是我个人的习惯,作用是把之前可能残留的垃圾数据清掉,免得刚发完请求就收到一堆上一次会话的旧字节,干扰解析。读响应时我没有盲等固定长度,而是根据响应帧里的"字节数"字段动态判断完整帧长度,这样能兼容不同寄存器数量的响应。
4.2 响应帧解析与超时处理
上面代码里已经有"边读边判帧长"的逻辑,这里单独说超时的处理思路。Modbus RTU标准里帧与帧之间的间隔要求是"3.5个字符时间以上",也就是说从站收到主站的请求后,必须等总线静默足够久才能回复完整。Linux下我们在应用层做超时,主要靠read()的VTIME设置。之前代码里配置成1秒超时,对轮询采集完全够用。
但要注意一个细节:如果在轮询线程里连续调用read(),而某一次从站没回复,read()会阻塞到超时返回0,整个周期就被拖长了。解决思路是:把超时设短一点,比如VTIME=5(500ms),没数据就快速失败,进入下一次轮询;或者用select()/poll()做带超时的IO多路复用。传感器响应通常在50ms以内,所以500ms超时已经留了10倍余量,完全合理。
我在项目里最终是把这个读取操作放到了独立线程里,线程循环里做了计数统计:连续3次超时判定为通信异常,10秒无响应则重新初始化串口。这个策略要点在于"重新初始化时要先拉低RS485的方向线并清空FIFO",否则偶尔就会遇到从站正在忙、主站以为线路空闲就发请求,结果两条帧在总线上撞车的现象。
4.3 轮询调度与多传感器扩展
这次只有两个传感器,所以轮询逻辑很简单:一个for循环,按地址轮流读取。但如果你的项目需要接入十几个传感器,就要注意几个问题:
- 单总线上从站地址不能冲突。
- 每个从站响应时间不同,轮询周期没法一刀切,最好给每个从站单独设置"上次成功通信时间",动态调整优先级。
- 需要记录每个从站的错误计数,连续出错达到阈值要标记离线,而不是一直傻等。
我的轮询框架大致是这样:
typedef struct { unsigned char addr; char name[32]; int fd; int offline_count; int max_offline; } modbus_slave_t; int slave_poll(modbus_slave_t *slave) { unsigned char rsp[256] = {0}; int len = modbus_read_registers(slave->fd, slave->addr, 0x0000, 2, rsp, sizeof(rsp)); if (len <= 0) { slave->offline_count++; return -1; } slave->offline_count = 0; /* 解析第3、4字节为温度,第5、6字节为湿度 */ ... return 0; }扩展新传感器时,只需要在初始化列表里注册从站地址和设备名,轮询线程不用改动。这种"协议栈与设备表解耦"的设计,在项目后期维护时特别省心。
5. 调试阶段踩过的坑与排查实录
5.1 常见问题速查表
做嵌入式Linux串口调试,直接面向问题的排查表格最实用。下面这个表是我这个项目实际遇到的问题整理出来的,希望能帮你少走弯路。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 发送无响应 | 波特率/校验位/停止位与从站不一致 | 用串口助手抓从站是否收到请求,逐项核对参数 |
| 收发乱码 | 没有使用原始模式,系统改动了数据流 | 确认调用cfmakeraw(),并且没有残留ICANON |
| CRC一直不对 | 算法用错(用了标准CRC32或CRC-16/X25) | 确认用CRC-16/MODBUS,初始值0xFFFF,输出低字节在前 |
| 偶尔能读到正常数据 | RS485方向切换和时序问题 | 确认方向控制引脚状态,检查是否有硬件自动方向控制 |
| 帧长度不对 | 不同传感器的寄存器数量不一致,解析逻辑写死 | 根据响应帧的"字节数"字段动态解析,不能写死总长度 |
| 压力值大得离谱 | IEEE754浮点字节序颠倒 | 分别尝试 high/low 和 low/high 组合,用已知数据进行比对 |
| 大批量采集时丢数据 | 轮询间隔太短,从站来不及响应 | 在连续请求之间加入至少50ms的静默间隔,按从站实际响应时间调 |
| 程序重启后第一次通信失败 | 串口状态没有清理干净 | open()后先tcflush(fd, TCIOFLUSH)清空缓冲区 |
5.2 时序与485方向问题
485方向切换是天底下最玄的坑。我这板子用的是带自动方向控制的SP3485,按理说时序不用操心。但实际调试时发现,数据传输偶尔会在发完请求后立刻切到接收模式,而从站刚好在同一时刻开始回复,结果头几个字节被吞了。后来查了芯片手册,发现自动方向控制电路有个"切换延迟",虽然只有微秒级,但在高波特率下就可能碰上。
解决办法有两个方向:一是从站地址从0x01开始,预留一个长一点的间隙;二是在发完请求之后加一个usleep(1000)(1ms),让总线稳定下来再切换方向。对于9600波特率,一个字节大约1.04ms,所以1ms的延时对整体轮询影响不大,但对于稳定性提升很明显。
如果你用的是需要GPIO手动切方向的方案,一定要注意:发送请求前把方向引脚拉高(发送模式),发完之后拉低之前,必须确保串口FIFO里所有字节都已经真正移位输出完毕。否则你这边处理器认为发完了,实际物理层还在往外推最后一个字节,你抢先把方向切到接收,帧尾CRC就被自己吞了。
5.3 调试工具的使用心得
调试串口Modbus协议,光靠printf是绝对不够的。我建议双管齐下:硬件侧用逻辑分析仪或示波器抓总线波形,软件侧用PC上的Modbus调试工具做模拟主站和模拟从站。
PC端的Modbus Poll(主站模拟器)和Modbus Slave(从站模拟器)是我常用的组合。调试流程一般是:
- 先用Modbus Slave在PC上模拟传感器,按数据手册里的寄存器地址和数值类型预设好数据。
- 然后用Linux板卡上的程序去读取这个模拟从站,验证CRC计算、响应解析、浮点转换这些逻辑对不对。
- 再把真实的传感器接上,用Modbus Poll定期读取传感器寄存器,确认传感器本身的数据格式和参数配置。
- 最后再把Linux板卡程序接上真实传感器,做端到端联调。
这个流程能帮你把问题隔离到具体环节:是协议栈本身错了,还是传感器配置不对,还是硬件链路有问题。我这次就是靠"PC模拟从站 + Linux程序读"这个组合,一下子定位出CRC校验写反了高低字节的问题,而不是去现场用万用表查半天线路。
逻辑分析仪的话,我手头用的是廉价8通道的,采样率调到24MHz,抓9600波特率的串口完全够用。通过解码出的十六进制报文,你可以看到物理层上实际传输的数据,跟应用层发出的数据做对比,排查线路干扰、字节丢失这类问题特别有效。
5.4 关于阻塞和非阻塞的取舍
最后补充一个关于串口读写模式选择的个人体会。很多Linux串口教程一上来就让你用非阻塞模式,然后告诉你要配合select。但在Modbus这种"请求-响应"模式下,阻塞 + 短超时的实现更直接,代码也更易懂。非阻塞模式的主要优势在同时监听多个文件描述符的场景,比如一个程序同时管理好几路串口。
如果就是单路传感器轮询,我推荐:
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) & ~O_NONBLOCK);保持阻塞模式,配合VTIME超时控制。代码量小,也不容易出状态机Bug。只有当你需要同时处理多路串口时,才值得上poll()或者事件驱动框架。
到最后收尾再分享两个小技巧。第一,Modbus RTU的串口日志建议统一打十六进制,一帧一帧对齐,别混着ASCII打印,不然排查时自己都晕。第二,所有的传感器地址、寄存器地址、数量这些参数,最好都通过配置文件传入程序,不要写死在代码里。现场换了一个传感器,寄存器布局完全不同的情况我遇到太多了,直接改配置重启程序,比改代码重新编译快一个数量级。这些习惯养成了,做嵌入式Linux的Modbus开发会顺手很多。