
做嵌入式Linux上的Modbus RTU开发说难不难说简单也绝对不简单。串口配置那关过了协议这块其实就是个数据包拼装和解析的事但如果没理清帧结构、没搞定RS485的方向控制和超时机制调试起来照样一个头两个大。这篇文章我会把从串口初始化到RTU主站读写传感器数据的完整链路讲一遍包括termios的关键参数、CRC16的代码实现、功能码的选择思路以及我实际项目中踩过的一些坑给大家一个可以直接参考的实操路径。1. 整体设计与思路拆解1.1 为什么是Linux用户态而不是内核态很多刚接触嵌入式Linux的人会有个惯性思维凡是跟硬件打交道总觉得应该写内核驱动。但Modbus RTU这种场景本质上是异步串口通信Linux内核的tty子系统已经把串口驱动做得很完善了你在用户态通过open(/dev/ttyS0, ...)操作串口跟读写一个普通文件没有本质区别。这种设计的好处非常明显调试方便、崩溃不影响内核、代码可以直接在x86的开发板上跑通再交叉编译到ARM平台。实际项目中我见过不少人一上来就想着写内核模块最后陷入内核态调试的泥潭。对于Modbus这种应用层协议正确的分层思路是内核层只负责最基础的UART收发把数据放进tty缓冲用户态通过termios配置串口参数通过read/write系统调用读写数据协议层在用户态实现Modbus RTU的帧封装、CRC校验、超时重试1.2 方案选型RTU还是TCPModbus家族里RTU和TCP是两大主流TCP的优点是无需考虑串口参数、无CRC校验依赖以太网的TCP校验缺点是必须联网、布线成本高。而RTU用RS485双线制抗干扰能力强、传输距离可达1200米工业现场的传感器、PLC、电表绝大多数都原生支持RTU。所以如果你是在工业现场采集传感器数据RTU几乎是唯一正确的选择。RTU本身是一种半双工通信协议同一时刻只能有一方发送数据。这就意味着在读写传感器数据时必须严格控制RS485收发器的方向切换。很多嵌入式Linux板卡上的RS485芯片比如SP3485、MAX485的RE/DE引脚是复用的要么接在某个GPIO上要么接在UART的RTS信号上这个细节在串口配置时直接决定你数据发出去之后能不能收到回应。1.3 模块划分与整体架构从软件工程的角度我建议把整个Modbus RTU采集功能拆成三个独立模块串口管理模块负责打开、配置、关闭串口提供read/write的封装向上层屏蔽底层UART差异Modbus协议模块负责构建请求帧、解析应答帧、计算CRC16、异常处理业务逻辑模块负责具体的传感器读取策略轮询周期、数据转换、存储/上报这三个模块用struct把上下文串起来核心是一个modbus_t结构体里面保存串口文件描述符、从站地址、超时时间、错误计数等信息。这样设计的好处是如果某天你的传感器从RS485换成RS232只需要改串口管理模块协议层和业务层完全不动。2. 串口配置termios的关键细节2.1 参数选择的原理解读Modbus RTU在串口层的默认参数是波特率9600或更高如19200/38400、8数据位、1停止位、无校验8N1。这个组合是最通用的绝大多数支持Modbus协议的设备出厂默认都是这个配置。如果设备手册要求偶校验那就是8E1但这种情况在RTU里相对少见。在Linux里配置串口用的是一套叫termios的系统API核心结构体是struct termios。很多人第一次接触会被里面一堆flag搞晕其实关键的也就几个位c_cflag控制波特率、数据位、停止位、校验位、流控c_lflag关闭ICANON规范模式和ECHO进入原始模式c_iflag关闭ICRNL回车转换、IXON软件流控等所有输入转换c_oflag关闭所有输出转换c_cc[VMIN]和c_cc[VTIME]控制读操作的返回条件这个非常关键后面细说我第一次配串口的时候因为漏了设置cfmakeraw导致读回来的数据里莫名其妙多了回车换行和奇怪的字节排查了半天。这是因为默认的终端模式会把\n转成\r\n把0x0d当行结束符处理。2.2 VMIN和VTIME的微秒级控制RTU协议对时序有严格要求一个完整的RTU帧内部每个字节之间的间隔不能超过3.5个字符时间否则接收方会认为是新一帧。在9600波特率下一个字符时间约1.04ms3.5个字符就是约3.6ms。如果串口读的时候不对很容易把一帧数据拆成两段。Linux串口的读取有两种方式阻塞读和超时读。在Modbus主站场景我们绝不能用无限阻塞的读因为从站可能无响应、可能断线你不可能永远等下去。正确做法是设置VTIME超时VMIN 0, VTIME 200表示最多等待20秒VTIME单位是0.1秒VMIN 1, VTIME 50表示至少读到1个字节且后续字节间最长等待5秒VMIN 5, VTIME 0表示至少要读满5个字节对于Modbus RTU主站我推荐的配置是VMIN 0, VTIME 100即100毫秒超时。因为一个标准的RTU请求比如读保持寄存器的03功能码从站通常在10ms到50ms内就会响应。如果超过100ms还没收到数据基本可以断定是通信超时。但这里有个容易踩的坑VTIME超时不是“整帧超时”而是字节间超时。如果从站响应了前两个字节后停顿了200ms再发后续字节VMIN0, VTIME100会先返回前两个字节导致你解析半截帧。解决方法是读到第一段数据后再短暂read一次比如再给50ms直到读完整个响应帧或者用轮询方式读到返回0或EAGAIN为止。2.3 RS485方向控制RTS还是GPIO这是整个串口配置里最容易被忽略、又最致命的一环。Modbus RTU主站发送完请求帧后必须把RS485收发器切换到接收模式否则从站的响应会被自己的发送器短路掉。在Linux上控制方向切换有两种方案方案一RTS信号控制。如果板卡的RS485芯片RE/DE接到了UART的RTS引脚在打开串口后通过TIOCM_RTSioctl操作RTS引脚int flags; ioctl(fd, TIOCMGET, flags); flags | TIOCM_RTS; // 拉高RTS进入发送模式 ioctl(fd, TIOCMSET, flags); write(fd, frame, len); // 发送数据 flags ~TIOCM_RTS; // 拉低RTS切回接收模式 ioctl(fd, TIOCMSET, flags);有些驱动实现得更聪明会自动根据write和read调用切换RTS方向通过tty层的tty_port_set_cts_flow等机制这种情况下用户态不需要手动操作。但更多的板子是“半自动”的得你自己拉。方案二GPIO控制。如果RE/DE接在普通GPIO上那就需要操作GPIO比如用sysfs或libgpiod// 用libgpiod struct gpiod_line *line gpiod_line_get(...); gpiod_line_set_value(line, 1); // 发送模式 write(fd, frame, len); tcdrain(fd); // 确保数据完全发完 gpiod_line_set_value(line, 0); // 接收模式无论是哪种方案都有一个细节值得注意发送完必须等待数据全部从串口硬件移位寄存器发出后再切换方向。如果write返回后立即切方向最后几个字节可能还在缓冲区里没发完。安全做法是在write之后调用tcdrain(fd)它会阻塞等待缓冲区数据全部发送完毕。3. Modbus RTU协议实现从帧格式到数据解析3.1 RTU帧格式与CRC16校验Modbus RTU的帧结构非常简单清晰一个完整的请求帧由以下部分组成组成部分字节数说明从站地址1字节范围1~2470是广播地址主站对所有从站广播功能码1字节决定本次操作类型数据区N字节随功能码不同而变化可能是寄存器地址、数据数量、写入值等CRC16校验2字节低字节在前覆盖地址到数据区末尾的所有字节最常用的几个功能码0x03读保持寄存器Read Holding Registers工业传感器绝大多数用这个0x04读输入寄存器Read Input Registers有些设备用它区分不同数据类型0x06写单个保持寄存器一般用来下发控制指令0x10即16写多个保持寄存器批量配置参数时用实践中传感器厂家用0x03和0x04哪个都有看数据手册最靠谱。有的传感器甚至同一个物理量在保持和输入寄存器里都有副本这种情况下选哪个都行。CRC16的算法是Modbus专用的多项式是0xA001反向后的CCITT多项式初始值为0xFFFF。可以用逐位法或查表法查表法速度快、代码简单适合嵌入式环境static const uint16_t crc_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0281, 0xC240, // ... 完整256项查表数据需要从标准Modbus CRC表复制 }; uint16_t modbus_crc16(uint8_t *data, int len) { uint16_t crc 0xFFFF; for (int i 0; i len; i) { uint8_t index (crc ^ data[i]) 0xFF; crc (crc 8) ^ crc_table[index]; } return crc; }注意发送时CRC要低字节在前高字节在后。这是新手最容易犯的错误我见过有人软件算出来是对的但高低字节顺序写反了从站一直返回异常或不回应。3.2 数据解析16位整数与32位浮点数的转换Modbus寄存器里最基本的单位是16位一个寄存器。读温度、湿度、压力这类传感器数据时通常有两种情况情况一16位整数。比如某温湿度传感器温度值扩大10倍存储25.6°C存储为256读到后除以10就是真实值。这种最简单直接(int16_t)寄存器值即可注意可能有符号。情况二32位浮点数IEEE 754。很多高精度传感器比如某些气体检测模块会把浮点数拆成两个寄存器存放。这就必须搞清楚寄存器序Register Order是高位在前Big-endian最常见的ABCD顺序还是高位在后CDAB顺序。举一个我踩过的真实例子某传感器返回浮点数25.6的4个字节是41 CC CD 33十六进制如果按ABCD顺序拼成0x41CCCD33转换成float正好是25.6但如果按CDAB顺序拼成0xCD3341CC得到一个完全是垃圾值的数字。所以读32位浮点数时一定要先确认数据手册里寄存器序的说明或者用已知值反推。3.3 超时重试与错误处理Modbus RTU的主站必须有一套健壮的错误处理机制因为工业现场噪声大、从站可能断电、线缆可能松动任何时刻都可能通信失败。我的经验是采用“三明治策略”发送前检查串口状态确保上一条请求处理完毕等待响应阻塞读或轮询读超时阈值通常设100~500ms根据从站响应时间调整收到响应先做地址校验响应帧的地址必须等于请求的从站地址、功能码校验如果最高位被置1说明是异常响应、CRC校验全部通过才做数据解析异常响应帧很值得留意。如果从站返回的功能码是请求功能码0x80最高位置1说明从站检测到了错误后面的异常码字段会告诉你具体原因异常码含义常见原因01非法功能从站不支持该功能码02非法数据地址寄存器地址超出范围03非法数据值写入值超出允许范围04从站设备故障设备内部异常实际调试时如果连续重试3次还是异常码02基本可以断定是寄存器地址查错了这时候对照数据手册重新确认地址比盲目换线高效得多。4. 实操过程从初始化到读取传感器数据4.1 串口初始化完整代码这里给出一份可以直接用的串口初始化函数覆盖了上面讲的所有关键点#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include termios.h #include sys/ioctl.h #include errno.h // linux/serial.h 在部分新内核中可能不需要保留以防后续扩展 int serial_init(const char *dev, int baud, int data_bits, int stop_bits, char parity) { 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); // 设置波特率支持常见的B9600/B19200/B38400/B115200 speed_t speed; switch (baud) { case 9600: speed B9600; break; case 19200: speed B19200; break; case 38400: speed B38400; break; case 115200: speed B115200; break; default: fprintf(stderr, unsupported baud %d\n, baud); close(fd); return -1; } cfsetispeed(opt, speed); cfsetospeed(opt, speed); // 数据位 opt.c_cflag ~CSIZE; if (data_bits 8) opt.c_cflag | CS8; else if (data_bits 7) opt.c_cflag | CS7; // 6、5位不常用这里从略 // 停止位1位时清除CSTOPB2位时设置 if (stop_bits 1) opt.c_cflag ~CSTOPB; else if (stop_bits 2) opt.c_cflag | CSTOPB; // 校验位 if (parity N) { // 无校验 opt.c_cflag ~PARENB; opt.c_iflag ~INPCK; } else if (parity E) { // 偶校验 opt.c_cflag | PARENB; opt.c_cflag ~PARODD; opt.c_iflag | INPCK; } else if (parity O) { // 奇校验 opt.c_cflag | PARENB; opt.c_cflag | PARODD; opt.c_iflag | INPCK; } // 关闭硬件流控Modbus一般不用RTS/CTS流控RTS另有用途 opt.c_cflag ~CRTSCTS; // 关闭软件流控 opt.c_iflag ~(IXON | IXOFF | IXANY); // 超时VMIN0, VTIME100即100ms超时 opt.c_cc[VMIN] 0; opt.c_cc[VTIME] 100; // 清空缓冲区 tcflush(fd, TCIOFLUSH); tcsetattr(fd, TCSANOW, opt); return fd; }几个值得注意的细节打开串口时用了O_NDELAY这样即使DCD载波检测信号没有拉高也不会阻塞open。Modbus是点对点或总线通信不需要调制解调器信号。cfmakeraw是最省事的做法一行搞定所有转换和回显的关闭但注意它也会把输出处理一起关掉所以后续任何从站返回的字节都会被原样读到这正是我们想要的。关闭硬件流控CRTSCTS非常关键。有些Linux板卡的串口控制器会自作聪明地做流控导致你数据发出去后RTS状态被硬件改动影响RS485方向切换逻辑。4.2 构建读取请求帧与解析响应搭建好了串口接下来就是协议层的活了。下面是读取保持寄存器的完整实现代码覆盖了构建请求、发送、接收、CRC校验和解析int modbus_read_holding_registers(int fd, uint8_t slave_addr, uint16_t start_reg, uint16_t reg_count, uint16_t *dest) { uint8_t req[8]; uint8_t rsp[256]; uint16_t crc; // 构建请求帧地址 功能码(0x03) 起始寄存器高/低字节 数量高/低字节 CRC16(低字节在前) 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; crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] (crc 8) 0xFF; // 发送请求 int len write(fd, req, 8); if (len ! 8) { perror(write); return -1; } tcdrain(fd); // 等待发送完毕再切换RS485方向 // 读取响应。注意使用轮询方式把整帧读完整 int total 0; fd_set set; struct timeval timeout; while (total (int)sizeof(rsp)) { FD_ZERO(set); FD_SET(fd, set); timeout.tv_sec 0; timeout.tv_usec 50000; // 50ms int rv select(fd 1, set, NULL, NULL, timeout); if (rv 0) { // 超时若有数据则处理否则退出 break; } else if (rv 0) { perror(select); return -1; } int n read(fd, rsp total, sizeof(rsp) - total); if (n 0) { total n; } else if (n 0 errno ! EAGAIN) { perror(read); return -1; } } if (total 5) { fprintf(stderr, response too short: %d bytes\n, total); return -1; } // 校验从站地址 if (rsp[0] ! slave_addr) { fprintf(stderr, slave addr mismatch: req %02X, rsp %02X\n, slave_addr, rsp[0]); return -1; } // 异常帧判断 if (rsp[1] 0x80) { fprintf(stderr, modbus exception: code%02X, exception%02X\n, rsp[1], rsp[2]); return -1; } // CRC校验覆盖整个帧包括地址到数据末尾 uint16_t calc_crc modbus_crc16(rsp, total - 2); uint16_t recv_crc rsp[total - 2] | (rsp[total - 1] 8); if (calc_crc ! recv_crc) { fprintf(stderr, crc error: calc%04X, recv%04X\n, calc_crc, recv_crc); return -1; } // 解析数据rsp[2]是字节数后面的数据按大端序拼成16位寄存器值 int reg_bytes rsp[2]; if (reg_bytes ! reg_count * 2) { fprintf(stderr, byte count mismatch: %d ! %d\n, reg_bytes, reg_count * 2); return -1; } for (int i 0; i reg_count; i) { dest[i] (rsp[3 i * 2] 8) | rsp[4 i * 2]; } return 0; }4.3 实测读取温湿度传感器数据拿我实际项目里用过的一款温湿度传感器举例它是一台支持Modbus RTU的RS485设备默认波特率9600、8N1从站地址1。数据手册给出的寄存器表寄存器地址0x0000温度16位有符号整数扩大10倍单位°C寄存器地址0x0001湿度16位无符号整数扩大10倍单位%RH寄存器地址0x0002~0x0003温度32位浮点数IEEE754ABCD字节序实际跑一次读取用主站代码读寄存器0和1请求帧十六进制01 03 00 00 00 02 C4 0B解析一下从站地址01、功能码03、起始地址0000、寄存器数量0002、CRC校验C40B低字节在前。传感器正常响应01 03 04 01 0A 02 CC 9A 6C01从站地址03功能码04后续数据字节数2个寄存器每个2字节01 0A第一个寄存器值 0x010A 266除以10就是26.6°C02 CC第二个寄存器值 0x02CC 716除以10就是71.6%RH9A 6CCRC16校验值如果改用32位浮点数方式读寄存器2和3起始地址变成0x0002请求01 03 00 02 00 02 65 CB响应可能是01 03 04 41 D5 99 9A 2B 7A这里41 D5 99 9A按IEEE754解析就是26.65数据手册确认是ABCD字节序高位在前所以直接用memcpy到float变量float temp; uint8_t bytes[4] {0x41, 0xD5, 0x99, 0x9A}; memcpy(temp, bytes, 4); printf(temp %.2f\n, temp); // 输出 temp 26.65这里强调一下字节序问题每隔一段时间就会踩一次。遇到32位浮点数先在数据手册里找寄存器序说明如果手册没写就用一个已知值反推三种组合ABCD、CDAB、BADC试一遍测试次数最多三次就能确认。5. 常见问题与排查技巧实录5.1 高频问题避坑指南现象可能原因排查/解决方法open串口失败设备节点不存在、权限不足ls -l /dev/ttyS*/dmesg | grep tty确认节点加入dialout组或chmod 666数据发出去收不到响应RS485方向没切换回接收模式A/B线接反波特率不一致用万用表量A/B间电压空闲时应为2~5V差分用逻辑分析仪抓UART波形确认参数收到数据但CRC校验不对波特率误差导致bit错误帧被截断用示波器看波形匹配误差应在2%以内确认读取逻辑不会半截帧设备返回异常码02寄存器地址不对对照数据手册重新核对地址注意有些手册里地址从1开始计数需要减1才是协议地址数据偶尔跳变总线没有终端电阻、线缆过长在总线两端并联120欧姆终端电阻尽量用屏蔽双绞线读了多个寄存器数据错位寄存器序/字节序搞反用已知值反推字节序和寄存器序确认后再批量解析5.2 在线排查tcpdump和minicom的正确用法嵌入式Linux调试串口通信我最常用的工具是minicom和tcpdump用于TCP版Modbus但实际项目中更普遍的做法是先抛开协议纯粹用收发数据来定位问题。一个很有效的分层排查方法硬件层把RS485的A/B线短接或接一个485转USB模块用PC上的串口助手发数据确认板卡能收到再用板卡发数据确认PC能收到。这能快速排除线序、电平转换、收发器损坏等硬件问题。串口层板卡上写一个最简单的回环程序把收到的数据原样发回去用PC串口助手验证。如果回环正常说明UART、DMA、tty驱动都没有问题。协议层此时再把Modbus请求帧按数据手册构建发出去从站应该有正常响应。如果还没响应抓波形、查地址、查CRC一步步来。使用minicom有一个实用技巧CtrlA然后X退出CtrlA然后Z查看帮助。用它验证串口时建议把硬件流控关掉默认就是关的设置好波特率和8N1。5.3 时序问题的终极武器逻辑分析仪如果遇到非常刁钻的时序问题比如偶尔丢第一个字节、从站响应被截断市面上几十块钱的8通道逻辑分析仪配一个PulseView软件就能搞定。用法很简单把逻辑分析仪的通道0接到UART TX板卡发送脚通道1接到RX板卡接收脚注意共地。然后用PulseView的UART协议解码器设置好波特率就能看到完整的收发波形和每一个字节。我在一个项目里遇到过一个很隐蔽的问题某款ARM板卡在RS485方向切换后TX输出的第一个字节的电平建立时间不够导致从站总是漏收第一个字节。用逻辑分析仪一眼就看出来了后来在发送请求前加了一个2ms的延时问题立刻消失。这种问题如果不用波形工具纯靠猜可能几天都定位不了。最后再分享一个小技巧很多人会把Modbus RTU主站的读取周期设得很随意比如usleep(100000)固定100ms轮询一次。但在实际工业场景如果总线上挂了很多从站建议给每个从站的轮询之间加一个20~50ms的间隔。这既是为了让RS485总线有“静默时间”避免总线冲突也是给从站内部的处理留出裕量。我用过的一款老式电量采集模块连续两条请求间隔小于10ms就会偶尔无响应加了50ms间隔后跑了几个月再没出过问题。整套代码我在多个ARM Linux平台上移植过从i.MX6ULL到全志H3再到树莓派核心逻辑完全不用改顶多调整RS485方向控制的GPIO号。如果你也是在做嵌入式Linux上的Modbus数据采集先从串口参数和帧校验这两个地基打好开始比什么花哨的框架都管用。