嵌入式Linux下Modbus RTU串口通信开发实战:从原理到代码
1. 背景与项目目标1.1 为什么要在嵌入式Linux上做Modbus开发这两年工业物联网和智能硬件的项目越来越多传感器数据的采集成了嵌入式开发者的必修课。很多项目从单片机平台迁移到嵌入式Linux平台原因也很直接Linux上有文件系统、有网络协议栈、有丰富的调试工具跑起业务逻辑比裸机舒服太多。但传感器数据采集这一层大家通常还是选择Modbus协议尤其是RTU模式因为工业现场大量传感器、采集器、仪表都默认支持它。我在嵌入式Linux端做Modbus RTU开发时通常链路是这样的开发板的UART串口接一个RS485或RS232转接模块转接模块再连到传感器的Modbus从机接口应用层通过串口设备节点如/dev/ttyS0、/dev/ttyUSB0发送RTU格式的查询帧解析从机返回的数据帧拿到寄存器值后换算成实际的物理量。这套方案在工业控制、农业大棚监测、机房动环监控、冷链运输等场景中都非常典型。我写这篇文章是想把串口配置、协议帧格式、CRC校验、读写传感器数据这一整套流程讲透给那些正要从STM32这类单片机转向嵌入式Linux的工程师以及已经在Linux上写应用但没接触过串口和Modbus的开发者提供一份可以直接照着做的参考。1.2 在动手之前需要明白的几个关键点我先说几个容易让新手懵掉的概念后面会反复用到。Modbus RTU是一种主从架构的串行通信协议物理层通常跑在RS485或RS232上。主机也就是我们的嵌入式Linux设备发起请求从机传感器响应。一条RS485总线上可以挂多个从机每个从机有唯一的地址所以主机发送的每一个请求帧里都必须包含从机地址。串口配置是整个流程的第一个坑。Modbus RTU对串口参数有明确要求虽然在很多实现里波特率、数据位、校验位、停止位是可以配置的但常见的组合是9600波特率、8数据位、无校验、1停止位简写为8N1。关键是我们在Linux层面配置串口时一定要把raw模式设置好不能让终端驱动去处理奇偶校验、回显、特殊字符这些事情否则收发数据会被各种干扰。还有一个非常容易忽略的点帧与帧之间的间隔。Modbus RTU协议规定一帧数据和下一帧数据之间至少要有3.5个字符时间的静默间隔。Linux系统不是实时系统用户态程序的调度延迟不稳定所以推荐的实现思路是用单独的线程循环收发串口层设置超时应用层做好状态机或者简单的帧切分校验才能保证通讯稳定性。2. 方案选型与整体架构设计2.1 用户态开发还是内核驱动很多刚接触嵌入式Linux的人会问串口是不是要写内核驱动答案是通常不需要。Linux内核已经把串口驱动做得很完善了我们要做的是在用户态用标准POSIX终端接口termios去配置和读写串口设备。用户态开发的好处是显而易见的开发效率高调试方便出了Bug可以直接用gdb跑不用折腾内核编译和模块加载。对于Modbus RTU这种波特率通常在9600到115200之间的低速通信场景用户态完全能跑出稳定的效果前提是我们在应用层把时序和帧处理做好。我见过不少人非要走内核驱动路线结果维护成本和调试难度都成倍上升实际收益几乎为零。除非你面对的是一块特殊硬件、需要极低延迟的硬实时响应否则一律推荐用户态方案。2.2 开源库选择libmodbus还是手写嵌入式Linux下做Modbus开发最常被提到的开源库是libmodbus。它支持RTU和TCP模式API简洁很多项目直接拿它来用。但我在实际项目里除非时间非常紧张否则更倾向于在libmodbus的基础上二次封装或者干脆根据协议规范手写一个轻量级的读写函数。原因有两个。第一libmodbus的RTU模式内部有超时处理但它的超时计算和Linux用户态调度结合时在一些低功耗或高负载设备上会出现偶发的超时误判。第二工业现场的需求五花八门有时你需要发送非标准的读命令有时需要自己控制帧间隔这时手写一套反而更灵活。还有一个很现实的因素如果项目对代码体积、静态扫描、功能安全有要求依赖一个第三方库意味着要引入一整套代码审计和版本管理流程。手写一个RTU读写模块代码量其实不大核心就三块串口配置、CRC16校验、报文拼装与解析。这三块搞清楚之后用起来反而更有底。结合我的经验这篇文章会按手写的方式来拆解。搞清楚底层原理之后再用libmodbus就只剩查API文档的功夫了。2.3 线程模型与整体流程设计Modbus RTU的数据收发是严格的一问一答模式。主机发一帧请求后必须等待从机返回收到响应后再发下一帧。所以应用层最简单的线程模型是这样主业务线程负责解析业务逻辑决定要读哪些寄存器生成Modbus请求。串口读写线程负责把请求帧写入串口等待并读取响应帧做CRC校验后把有效数据交给业务线程。使用独立的串口读写线程可以避免主线程里其他业务阻塞导致串口超时处理不及时。在主业务线程中我用一个队列把请求传给串口线程串口线程同步等待响应并返回结果。这种模型写起来简单逻辑清晰也方便后期加断线重连、重试机制。3. 嵌入式Linux串口配置详解3.1 打开串口设备节点在Linux下操作串口的第一步就是把设备节点打开。设备节点通常在/dev目录下比如/dev/ttyS0、/dev/ttyUSB0、/dev/ttymxc2等。不同的平台和设备名差异很大一般可以通过dmesg命令查看驱动打印来确认。打开串口的时候要同时指定O_RDWR读写和O_NOCTTY不让串口成为控制终端如果不想让open操作被阻塞可以再加O_NDELAY标志。示例代码如下#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include string.h #include errno.h int serial_open(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { printf(open %s failed: %s\n, dev, strerror(errno)); return -1; } if (fcntl(fd, F_SETFL, 0) 0) { printf(fcntl F_SETFL failed: %s\n, strerror(errno)); close(fd); return -1; } return fd; }这里的思路是先用非阻塞方式打开设备避免在端口不存在或硬件异常时卡住打开成功后再用fcntl把文件描述符恢复为阻塞模式后续的read操作就会在有数据到达时返回。3.2 termios结构体配置与raw模式Linux串口的所有通信参数都是通过termios结构体配置的。第一次写串口程序的人很容易被这个结构体里的各种标志位搞晕但其实核心要做的就三件事配置波特率、配置数据位/停止位/校验位、设置raw模式。我写了一个通用函数覆盖了三种常见配置8N1、8E1、8O1。代码如下int serial_config(int fd, speed_t baud, int data_bits, int stop_bits, char parity) { struct termios options; if (tcgetattr(fd, options) 0) { printf(tcgetattr failed: %s\n, strerror(errno)); return -1; } cfsetispeed(options, baud); cfsetospeed(options, baud); options.c_cflag | (CLOCAL | CREAD); options.c_cflag ~CSIZE; switch (data_bits) { case 8: options.c_cflag | CS8; break; case 7: options.c_cflag | CS7; break; default: return -1; } switch (parity) { case N: options.c_cflag ~PARENB; options.c_iflag ~INPCK; break; case E: options.c_cflag | PARENB; options.c_cflag ~PARODD; options.c_iflag | INPCK; break; case O: options.c_cflag | PARENB; options.c_cflag | PARODD; options.c_iflag | INPCK; break; default: return -1; } if (stop_bits 2) options.c_cflag | CSTOPB; else options.c_cflag ~CSTOPB; /* raw mode */ options.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); options.c_oflag ~OPOST; options.c_iflag ~(IXON | IXOFF | IXANY | BRKINT | ICRNL | INLCR); options.c_cc[VMIN] 1; options.c_cc[VTIME] 1; tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, options) 0) { printf(tcsetattr failed: %s\n, strerror(errno)); return -1; } return 0; }这段代码里有个细节值得展开说VMIN和VTIME的组合。我设置的是“最少读取1个字节等待时间0.1秒”这意味着read函数会一直阻塞直到读到至少1个字节或者等满100毫秒超时。这个配置很关键因为它让串口层有了一个天然的帧超时保护避免在从机没有响应时用户程序一直死等。3.3 串口参数验证与调试技巧配置完串口之后强烈建议先用串口回环测试验证配置是否生效。所谓回环测试就是把串口TX和RX引脚短接或者通过USB转串口模块把收发短接然后程序发什么串口就收什么。我在调试时常用的验证方法很简单写一个十几行的小工具配置好串口后循环往串口写一个测试字符串同时用read读取并打印出来。如果回环测试通过说明串口配置没有问题Modbus通讯结果不对就不用怀疑串口层了。另外在调试Modbus的时候很多人不知道Linux下可以直接用stty命令查看和修改串口参数记录一下这个命令stty -F /dev/ttyUSB0 -a这条命令会打印当前串口的波特率、数据位、校验位、停止位等参数。排查问题时先确认应用层配置的参数和物理设备的实际参数是否一致能省掉很多无谓的折腾。4. Modbus RTU协议帧结构拆解4.1 从机地址、功能码与数据域在写代码之前一定要把RTU的帧结构彻底搞清楚。一帧完整的Modbus RTU请求或响应由从机地址、功能码、数据域和CRC校验四大部分组成。从机地址1个字节范围1到2470是广播地址。功能码1个字节表示要执行的操作比如读保持寄存器是0x03写单个寄存器是0x06写多个寄存器是0x10。数据域长度不定包含寄存器起始地址、寄存器数量、写入值等数据。CRC校验2个字节低字节在前高字节在后对从机地址到数据域末尾的所有字节做CRC16计算。对于读保持寄存器功能码0x03主机发往从机的请求帧格式是固定的一共8个字节从机地址、0x03、起始寄存器地址高字节、起始寄存器地址低字节、寄存器数量高字节、寄存器数量低字节、CRC低字节、CRC高字节。假设我们要读取从机地址为1的设备上从寄存器地址0x0000开始的2个寄存器01 03 00 00 00 02 C4 0B其中C4 0B是CRC16校验结果。注意CRC在帧里的发送顺序是低字节在前这个顺序写反的话从机根本不会响应。4.2 从机响应的数据格式正常的响应帧格式如下从机地址、0x03、字节数、寄存器数据、CRC。字节数字段表示后面跟着多少个数据字节。读取2个寄存器字节数就是4返回4个字节的数据。比如从机返回01 03 04 02 8B 01 2A 6A 1E这帧数据解析起来很直接地址是01功能码是03字节数是04数据是0x02 0x8B 0x01 0x2ACRC是6A 1E。数据域里每两个字节对应一个寄存器值0x0000寄存器的高字节是0x02低字节是0x8B组合起来是0x028B换算成十进制就是651。再根据传感器的量程和分辨率换算公式就能得到实际的物理量。需要注意的是如果读的是浮点数格式的数据传感器通常会占用两个连续寄存器4个字节按IEEE 754标准拼成浮点数。字节序是float高字在前还是低字在前不同的传感器厂商实现还不一样这个我后面专门讲。4.3 CRC16算法的原理与实现细节Modbus RTU使用的CRC校验是CRC16-IBM/MODBUS算法多项式为0xA001初始值是0xFFFF。计算流程如下将16位CRC寄存器初始化为0xFFFF。依次取报文中的每个字节与CRC寄存器的低字节进行异或。将结果右移1位最高位补0。如果移出位是1则将CRC寄存器与0xA001进行异或。重复步骤3共8次处理完一个字节。所有字节处理完毕后的CRC寄存器值低字节在前发送。对应的C语言实现如下uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; uint8_t i; while (len--) { crc ^ *data; for (i 0; i 8; i) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这个算法是Modbus RTU通信中手脚最容易出问题的地方。我遇到过好几种情况有人把CRC计算范围多算了CRC自身有人发送时高低字节顺序搞反还有人把多项式写成0x8005那是CRC16-CCITT用的结果从机死活不响应。有一个很实用的调试技巧计算出来的CRC和帧尾的CRC对不上时可以用mei qian的在线CRC计算工具验证一下或者写一个小的测试程序把抓到的完整帧丢进去验证CRC解析对不对。数据链路层没问题了才有资格去怀疑传感器和接线问题。5. 读写传感器数据的完整实现5.1 构造读寄存器请求帧基于前面的帧结构分析我写了一个通用的构造读请求函数。它接收从机地址、寄存器起始地址和寄存器数量生成完整的RTU请求帧并附加CRC校验。int modbus_build_read_request(uint8_t slave_addr, uint16_t start_addr, uint16_t reg_count, uint8_t *buf) { int len 0; uint16_t crc; buf[len] slave_addr; buf[len] 0x03; buf[len] (uint8_t)(start_addr 8); buf[len] (uint8_t)(start_addr 0xFF); buf[len] (uint8_t)(reg_count 8); buf[len] (uint8_t)(reg_count 0xFF); crc modbus_crc16(buf, len); buf[len] (uint8_t)(crc 0xFF); buf[len] (uint8_t)(crc 8); return len; }这里有一个我自己踩过的坑在把16位变量拆成高低字节时一定要用(uint8_t)显式截断否则当变量的高字节不为零时会形成整型提升导致buf里写入的值不符合预期。5.2 接收与解析响应帧响应帧的接收是实现里最考验细节的地方。因为串口是字节流没有明确的帧边界所以我们必须自己根据协议来切帧。我的做法是先用一个超时循环读取收集尽可能多的字节然后做两层判断。第一层检查帧头和长度字段是否匹配第二层对整帧数据验证CRC。int modbus_read_response(int fd, uint8_t *rx_buf, int max_len, int timeout_ms) { int rc; int len 0; fd_set fds; struct timeval tv; uint8_t byte; while (len max_len) { FD_ZERO(fds); FD_SET(fd, fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; rc select(fd 1, fds, NULL, NULL, tv); if (rc 0) { if (len 0) return -2; /* timeout, no data */ break; } rc read(fd, byte, 1); if (rc 1) { rx_buf[len] byte; /* check frame integrity */ if (len 3 len (uint8_t)(rx_buf[2]) 5) { uint16_t crc modbus_crc16(rx_buf, len - 2); if ((uint8_t)(crc 0xFF) rx_buf[len - 2] (uint8_t)(crc 8) rx_buf[len - 1]) { return len; } else { /* CRC mismatch, reset */ printf(crc check failed\n); len 0; } } } } return len; }这段代码用select实现带超时的字节读取避免read阻塞导致程序卡死。当收满一帧判断依据是地址1字节功能码1字节字节数1字节后面跟着字节数对应的数据再加2字节CRC时就做CRC校验。校验通过就返回帧长度校验失败就清空缓冲区重新积累。5.3 写寄存器数据保持寄存器写入实际项目中除了读传感器数据还经常需要写寄存器比如控制继电器、设定阈值、校准参数。这里我用最常见的写单个保持寄存器功能码0x06来演示。写单个寄存器的请求帧格式是从机地址、0x06、寄存器地址高低字节、写入值高低字节、CRC。比如往地址为0x0001的寄存器写入0x006401 06 00 01 00 64 19 C6从机的正常响应帧和请求帧一模一样我们可以通过比对响应帧和请求帧是否完全一致来判断写入是否成功。下面是写入函数的实现int modbus_write_single_reg(int fd, uint8_t slave_addr, uint16_t reg_addr, uint16_t value) { uint8_t req[8]; uint8_t rsp[8]; int len; uint16_t crc; req[0] slave_addr; req[1] 0x06; req[2] (uint8_t)(reg_addr 8); req[3] (uint8_t)(reg_addr 0xFF); req[4] (uint8_t)(value 8); req[5] (uint8_t)(value 0xFF); crc modbus_crc16(req, 6); req[6] (uint8_t)(crc 0xFF); req[7] (uint8_t)(crc 8); if (serial_write(fd, req, 8) ! 8) { printf(write failed\n); return -1; } len modbus_read_response(fd, rsp, 8, 1000); if (len ! 8) { printf(write reg response timeout or invalid len%d\n, len); return -1; } if (memcmp(req, rsp, 8) ! 0) { printf(write reg response mismatch\n); return -1; } return 0; }有一点必须提醒写完寄存器之后最好等待几毫秒再发下一帧。很多传感器写入寄存器后需要时间把数值写入EEPROM或Flash连续操作太快会导致写入失败。我在实际调试中用过一款温湿度传感器连续两次写寄存器之间的间隔至少要50毫秒否则第二次写入就一点反应都没有。5.4 浮点数与多寄存器数据的处理前面提到很多传感器把浮点数存储在多个连续的寄存器中最常见的是占用2个寄存器、4个字节。比如说用Modbus读取一个温湿度传感器的温度值返回的原始寄存器可能是0x41 0x2A 0xE1 0x47这样的4个字节需要按照IEEE 754标准把字节拼成float类型。拼接的顺序是个大坑。不同的厂家对字节序的处理不同常见的有两种A-B-C-D顺序寄存器1高字节、寄存器1低字节、寄存器2高字节、寄存器2低字节。C-D-A-B顺序寄存器2高字节、寄存器2低字节、寄存器1高字节、寄存器1低字节。如果拼出来的数值明显是天文数字或者负数大概率就是字节序反了。我建议在测试阶段先读取已知数值的寄存器打印出原始字节对照传感器的数据手册确认字节顺序然后再写换算逻辑。下面是一个灵活处理字节序的示例#include math.h #include string.h float modbus_bytes_to_float(const uint8_t *buf, int order) { uint8_t bytes[4]; if (order 0) { bytes[0] buf[0]; bytes[1] buf[1]; bytes[2] buf[2]; bytes[3] buf[3]; } else { bytes[0] buf[2]; bytes[1] buf[3]; bytes[2] buf[0]; bytes[3] buf[1]; } float val; memcpy(val, bytes, 4); return val; }使用memcpy而不是直接指针强转是为了避免在ARM等非x86平台上出现未对齐访问的问题这一点在嵌入式开发中要特别注意。5.5 完整读写例程读温湿度传感器数据把前面各个模块组合起来就是一个完整的读温湿度传感器数据的实例。假设传感器从机地址是0x01温度寄存器起始地址是0x0000湿度寄存器起始地址是0x0002每个值占2个寄存器出厂默认波特率96008N1。int main(void) { int fd; uint8_t req[8]; uint8_t rsp[64]; int len; float temp, hum; fd serial_open(/dev/ttyUSB0, B9600); if (fd 0) { printf(serial open failed\n); return -1; } serial_config(fd, B9600, 8, 1, N); while (1) { /* read temperature */ len modbus_build_read_request(0x01, 0x0000, 2, req); serial_write(fd, req, len); len modbus_read_response(fd, rsp, sizeof(rsp), 1000); if (len 7) { temp modbus_bytes_to_float(rsp[3], 1); printf(temp %.2f\n, temp); } usleep(100000); /* read humidity */ len modbus_build_read_request(0x01, 0x0002, 2, req); serial_write(fd, req, len); len modbus_read_response(fd, rsp, sizeof(rsp), 1000); if (len 7) { hum modbus_bytes_to_float(rsp[3], 1); printf(hum %.2f\n, hum); } usleep(1000000); } close(fd); return 0; }这段代码里我特意在两次读操作之间加了100ms的延时在两次循环之间留了1秒的间隔。这一方面是给传感器处理时间另一方面也是让总线上保持合理的静默时间避免频繁请求导致传感器复位或应答异常。6. 实际调试中的问题与排查技巧6.1 上位机模拟与抓包工具在嵌入式设备上调试Modbus最痛苦的问题是程序在设备上跑但你看不到数据交互的细节。我的建议是先用PC上的Modbus Poll或类似的调试软件配合USB转RS485模块把传感器当作从机把PC当作主机先验证传感器的寄存器地址是否正确、数据格式是否清楚。这个步骤看起来多余但实际上能节省大量时间。很多问题其实不是Linux串口或协议栈的锅而是传感器的寄存器地址写错了、数据格式理解反了、接线不对。先用PC调试软件把这些问题排除掉再回到板子上调试思路就清晰很多。6.2 硬件层排查RS485方向控制与接线如果程序完全没响应第一优先级检查硬件链路。RS485是半双工通信数据发送和接收共用一对差分线需要方向控制。很多USB转RS485模块用自动换向芯片没问题。但开发板自带的RS485接口有些需要应用程序控制DE/RE引脚来切换收发方向。我踩过这个坑第一次用某品牌开发板的RS485接口串口发出数据后方向引脚没有拉高总线上根本发不出有效差分信号传感器自然没有反应。后来查阅原理图才发现要额外操作一个GPIO控制RS485收发方向在write之前拉高在write之后延时几个字节时间再拉低。还有一种很隐蔽的问题A、B线接反。RS485是差分信号A接A、B接B才能通信接反了信号翻转主机发出去的数据字完全对不上。排查的时候拿万用表量一下A、B之间的电压或者直接调换两根线试试很快就能确认。6.3 通讯超时与重试策略嵌入式Linux因为有调度延迟串口通讯偶尔出现超时是非常正常的。我见过很多同学一遇到超时就把超时时间加大其实这是不对的思路。正确的做法是设置一个合理的超时时间一般建议200ms到1s超时后主动重试重试2到3次仍然失败再向业务层报错。重试策略的设计也要结合具体设备。有些传感器对频繁快速重试很敏感连续请求会导致它内部看门狗复位或进入异常状态。我在重试之间通常加100到200ms的间隔。另外超时后串口缓冲区里可能还残留半帧数据重试前一定要把缓冲区清空否则残留数据和新的响应帧拼接在一起帧解析必然会出问题。int serial_send_request_and_wait(int fd, uint8_t *req, int req_len, uint8_t *rsp, int rsp_max, int retries) { int i; int len; for (i 0; i retries; i) { tcflush(fd, TCIFLUSH); if (serial_write(fd, req, req_len) ! req_len) { printf(write failed\n); continue; } len modbus_read_response(fd, rsp, rsp_max, 500); if (len 0) return len; printf(recv timeout, retry %d/%d\n, i 1, retries); } return -1; }这个函数的重点在于发送前调用tcflush清空接收缓冲区这样上一次超时残留的残帧就不会干扰本次响应。这个看似简单的操作在实际项目中帮我省下了大量排查时间。6.4 常见异常与解决方案速查表我把实际项目中遇到的高频问题整理成了表格方便大家排查时逐条对照。现象可能原因排查与解决方案完全无响应接线错误、RS485方向未控制、从机地址错误先用PC调试工具验证传感器是否正常再检查接线和GPIO方向控制收到数据但CRC校验失败串口参数不匹配、帧间隔异常、干扰导致误码检查波特率/校验位连接短地线降低波特率测试响应超时但传感器指示灯正常请求帧有误、寄存器地址或数量越界用Modbus Poll先测试正确报文逐字节比对请求帧能读取但数据明显不对字节序错误、数据域偏移搞错、浮点数拼接顺序不对读取已知值寄存器打印原始字节对照数据手册确认字节序连续读取几次后无响应请求太频繁、传感器处理不过来增加请求间隔在0.5到2秒之间调整写的值没生效写寄存器地址错误、写后需要延时、可能有关键保护寄存器确认写寄存器地址写完后延时100ms以上再读回验证6.5 日志驱动的调试方法最后分享一个经验开发Modbus应用时一定要把日志打印做充分。我习惯在发送一帧请求时把整帧数据按十六进制打印出来收到一帧响应时同样打印原始字节同时打印解析后的寄存器值。有些人觉得这样太啰嗦等到上了真机出了问题才后悔没有日志。Modbus调试本质上就是“抓交互、对协议、查环境”这三板斧标准的十六进制日志能让你在远程维护现场问题时不用亲自到场也能滤清大半问题。我建议日志至少包含这几个信息时间戳、收发方向、原始帧内容十六进制、解析结果、耗时。日志输出可以用printf重定向到文件也可以发到日志服务器。在调试阶段开全量日志正式运行阶段再调整日志级别能省去很多反复烧录固件的痛苦。对了还有一个容易被忽略的点串口设备节点把串口数据同时打印到控制台的现象。如果系统把串口配置成了内核调试串口比如很多开发板的/dev/ttyS0默认是console你直接用它收发Modbus数据会发现收到的数据杂乱无章甚至会串入系统启动日志。解决方法很简单换一个非console串口或者在设备树/内核启动参数里把console从该串口移除这个操作一定要在调试之前确认好。