嵌入式Linux Modbus RTU串口通信实战:从寄存器读取到多设备轮询 做嵌入式Linux的Modbus RTU开发难点其实不在协议本身而在串口这一层的稳定性和工程化落地。我这次接手的项目是给一套环境监测设备做传感器数据采集主控是ARM架构的Linux开发板传感器走RS485总线用Modbus RTU协议通信。前后折腾了小两周从串口配置到多设备轮询再到把数据稳定上传给上层应用中间踩了不少坑也积累了一些可以直接抄作业的经验。这篇就把整个过程拆开写清楚尤其是串口初始化、RTU报文构造、CRC校验、超时重试这些容易被忽略但又直接影响通信质量的地方。这篇内容适合正在做Linux串口通信的嵌入式开发者或者是刚接触Modbus RTU、想快速上手传感器读取的硬件工程师。我会先把方案选型的逻辑讲透再给出一套可以直接复用的C语言实现最后附上调试和排查经验。1. 项目整体思路与方案选型1.1 为什么选Modbus RTU而不是其他协议这个项目里传感器输出接口是RS485而RS485物理层上最常见的通信协议就是Modbus RTU。Modbus协议家族里其实还有Modbus TCP和Modbus ASCII但RS485总线场景下Modbus RTU是最合适的原因是它的数据帧紧凑、实时性好而且几乎所有工业传感器都原生支持不需要额外做协议转换。这里有个概念要分清RS485是物理层标准它规定了电压差、接线方式、传输距离这些电气特性但不管数据格式。Modbus RTU是应用层协议它规定了数据怎么组织、怎么校验、怎么寻址。就好比RS485是一条公路Modbus RTU是公路上跑的货车的装载规则。所以我们需要做的事就是在Linux系统里把串口当做一个普通的文件来操作往里面写符合Modbus RTU格式的字节流再从里面读取从设备返回的字节流。这一点理解透了后面所有代码就都顺理成章了。Modbus RTU是主从架构一主多从。一条总线上可以挂多个传感器设备每个设备有一个地址主机也就是我们的Linux开发板发起请求从机传感器应答。这个模式非常适合数据采集场景因为它天然解决了总线冲突问题谁先说话、谁回应、怎么区分不同传感器协议层面都定好了。1.2 硬件与软件环境选型硬件方面我用的是全志T113系列的开发板不过这部分不重要只要是嵌入式Linux系统串口设备节点一般就是/dev/ttyS0、/dev/ttyS1或者/dev/ttyUSB0这类路径。RS485转串口通常需要外接一个收发器芯片比如MAX485或者在板子上已经集成好了。要注意RS485是半双工通信同一时刻只能收或者只能发所以软件里要做方向切换。有的硬件方案是通过GPIO控制收发方向有的则是自动切换这个需要看具体板子。软件方案上我直接基于Linux系统调用open、read、write和ioctl来操作串口没有用libmodbus库。选择自己写的原因有三个。第一项目里只需要读保持寄存器和写单个寄存器这两个功能用libmodbus有点大材小用第二libmodbus虽然封装得好但调试时不好看清底层收发的原始字节出了问题定位慢第三手写协议栈能让我把RTU帧格式和CRC校验吃透后续如果换协议或者要支持更复杂的寄存器配置改起来也灵活。当然如果项目时间紧、要跑的协议功能多直接用libmodbus也完全可以这个看取舍。我自己写了一套串口配置和Modbus收发代码编译环境是arm-linux-gnueabihf交叉编译器代码量不大核心就三个文件serial.c串口配置、modbus.cRTU报文构造与解析、main.c业务逻辑。2. 串口配置的细节与实操要点2.1 Linux串口编程的基本流程Linux把所有设备都抽象成文件串口也不例外。操作串口的步骤是固定的open打开设备文件tcgetattr获取当前终端参数修改termios结构体中的参数tcsetattr写入新的配置然后就可以read和write了。看起来简单但坑都在termios结构体里。termios结构体里有几个关键的成员c_cflag控制波特率、数据位、停止位、校验位这些基本参数c_iflag和c_oflag控制输入输出处理我们做原始数据通信要把它们全部清零不能让系统对数据做任何转换c_lflag控制本地模式也要清零尤其要关掉ICANON规范模式否则串口数据会按行缓存收不到完整帧。比较隐蔽的是VMIN和VTIME这两个参数它们在c_cc数组里控制read的行为。VMIN表示最少读取多少字节才返回VTIME表示最多等待多长时间。对于Modbus RTU来说我的经验是设置VMIN 1VTIME 1这样每次read至少读到1个字节就返回如果1秒内没数据也会超时返回。如果VMIN设得太大比如设为8那么read会一直阻塞直到收到8个字节但Modbus RTU的响应帧长度不定很可能造成丢包。还有一个必须注意的点open串口文件时要用O_RDWR | O_NOCTTY其中O_NOCTTY告诉系统不要让这个串口成为控制终端否则你按键盘CtrlC信号会被发到串口上。有些教程还会加O_NONBLOCK但这个要在非阻塞模式接收时用实际项目里我用阻塞模式配合超时控制逻辑更简单。2.2 关键参数波特率、数据位、校验位、停止位Modbus RTU的参数配置本身并不复杂就是经典的“9600,8N1”也就是波特率9600、8个数据位、无校验、1个停止位。这几乎是工控行业默认配置绝大多数传感器出厂也默认这个参数。当然也可以改比如有些设备用19200甚至115200但要注意RS485总线在长距离传输下波特率越高越不稳定9600在几百米内基本没问题。串口参数和传感器要一一对起来。有些传感器支持通过拨码开关或者配置指令修改参数如果你在调试时发现读回来的数据全是乱码先别急着改程序要确认传感器的波特率、校验位是否和串口配置一致。我一个同事当初折腾了半天结果是传感器默认是偶校验而他在程序里配成了无校验字节错位导致报文解析完全失败。另外TTY设备有个比较坑的行为是它在打开时会自动设置串口的默认参数所以你必须在自己代码里完整执行tcgetattr→ 修改 →tcsetattr的流程不能只修改c_cflag就不管其他成员。尤其需要注意把c_iflag、c_oflag、c_lflag全部清零否则可能出现数据被系统自动回显、自动换行、被当作EOF等情况。举个我遇到过的例子如果不关掉BRKINT串口上只要出现一个break信号系统就会给进程发送SIGINT程序直接被中断这种错误极难排查。2.3 串口配置代码示例下面这段代码是我实际项目里用的串口初始化函数直接贴出来给大家参考。核心是set_serial_attr这个函数接收设备路径、波特率和校验方式三个参数返回文件描述符。#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include termios.h #include string.h #include errno.h static int set_serial_attr(int fd, int baud, char parity) { struct termios options; 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 rate: %d\n, baud); return -1; } if (tcgetattr(fd, options) ! 0) { perror(tcgetattr); return -1; } /* 清除所有行控和输入输出处理标志 */ options.c_iflag 0; options.c_oflag 0; options.c_lflag 0; options.c_cflag ~CSIZE; /* 清除数据位设置 */ options.c_cflag | CLOCAL | CREAD; /* 设置数据位 */ options.c_cflag | CS8; /* 8位数据 */ /* 设置校验位 */ switch (parity) { case N: options.c_cflag ~PARENB; break; case E: options.c_cflag | PARENB; options.c_cflag ~PARODD; break; case O: options.c_cflag | PARENB; options.c_cflag | PARODD; break; default: fprintf(stderr, Unsupported parity: %c\n, parity); return -1; } /* 设置停止位: 1个停止位 */ options.c_cflag ~CSTOPB; /* 设置波特率 */ cfsetispeed(options, speed); cfsetospeed(options, speed); /* VMIN1, VTIME1 */ options.c_cc[VMIN] 1; options.c_cc[VTIME] 1; /* 把修改后的配置写回系统 */ if (tcsetattr(fd, TCSANOW, options) ! 0) { perror(tcsetattr); return -1; } return 0; } int serial_open(const char *dev, int baud, char parity) { int fd open(dev, O_RDWR | O_NOCTTY); if (fd 0) { fprintf(stderr, open %s failed: %s\n, dev, strerror(errno)); return -1; } if (set_serial_attr(fd, baud, parity) ! 0) { close(fd); return -1; } return fd; }这段代码的关键点在于所有标志位的处理都是“清零”优先也就是先清掉可能残留的旧配置再设置我们需要的新配置。波特率我用了cfsetispeed和cfsetospeed分别设置输入和输出速度虽然Linux里一般两者相同但保险起见都设置了。CLOCAL的作用是不让串口线路上的状态变化影响进程CREAD则是使能串口接收。这一步漏掉的话写能成功但读永远等不到数据。3. Modbus RTU协议核心解析与数据帧构造3.1 报文格式与常用功能码Modbus RTU的报文格式非常固定所有帧都由四部分组成地址码、功能码、数据、CRC校验。地址码占1字节取值范围1到2470是广播地址功能码占1字节比如读取保持寄存器是0x03写单个寄存器是0x06数据部分长度取决于功能码CRC是2字节由地址码、功能码和数据字段统一计算得到。以读传感器数据最常用的功能码03为例主站发送的请求帧长度固定是8字节字段字节数说明地址码1目标从站地址如 0x01功能码10x03起始地址2从哪个寄存器开始读寄存器地址从0开始高字节在前寄存器数量2读多少个寄存器1表示读1个高字节在前CRC162低字节在前注意这点和协议里其他字段相反假设我们要读取地址为1的传感器上寄存器地址0x0000开始的1个寄存器请求帧就是01 03 00 00 00 01 84 0A。其中84 0A是CRC低字节在前。看到这里应该明白RTU协议就是纯字节的拼装没有任何复杂的转义规则这也正是它高效可靠的原因。从站正常应答帧格式则是地址码 功能码0x03 数据字节数 寄存器数据 CRC。比如01 03 02 02 2B 39 86其中02 2B是两个字节按高字节在前拼出来就是十六进制0x022B十进制555这就是传感器测出来的某个值。这里必须强调CRC的低字节在前规则。我在刚开始写协议栈的时候忽略了这个细节把CRC按高字节在前的顺序填结果连续两天都读不出正确数据直到我用串口助手一帧一帧对比才发现。Modbus RTU的CRC校验是全报文除CRC自身外统一计算并且发送时低字节在前这一点和协议里地址、数据的高字节在前正好相反非常容易踩坑。3.2 CRC校验的落地实现CRC16算法的实现方式有很多种查表法速度最快、代码最清晰适合嵌入式场景。这里给出Modbus RTU标准CRC16的查表实现。计算范围和初始化值是固定的对地址码、功能码、数据字段逐字节做异或和右移处理初始值是0xFFFF。static unsigned short crc16_modbus(const unsigned char *data, int len) { static const unsigned short crc_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0281, 0xC240, 0xC601, 0x06C0, 0x0781, 0xC740, 0x0500, 0xC5C1, 0xC480, 0x0441, /* 这里只列出了前16项实际使用需要完整256项 */ }; unsigned short crc 0xFFFF; for (int i 0; i len; i) { unsigned char index (unsigned char)(crc ^ data[i]); crc (crc 8) ^ crc_table[index]; } return crc; }实际项目里我没有手打256项表推荐大家用开源工具生成的完整表网上搜“CRC16 MODBUS table”就能找到。用这个函数计算出CRC后把低字节放在报文倒数第二个位置高字节放在最后一个位置。为了验证算的是不是对可以用上一节的例子01 03 00 00 00 01这6个字节算出来的CRC应当是0x0A84发送时填84 0A。如果你用串口助手发这8个字节给从站它应该会正确应答。还要注意CRC的计算长度有一个经典坑发起请求时CRC只算从地址码到数据字段的字节通常我们的请求帧有6个或者4个字节需要参与计算地址1字节 功能码1字节 数据字段若干字节。写代码时最容易犯的错是len传了整个缓冲区大小把还没填充CRC的空字节也算了进去这样算出来的CRC全是错的。我的经验是先构造好帧内容明确知道参与CRC计算的字节数再调用函数填充CRC。4. 传感器数据读写从读寄存器到多设备轮询4.1 读取传感器数据的完整实现串口配置好了CRC也搞定了接下来就是把这两块拼起来实现真正的读写。这里我用一个modbus_read_registers函数来实现功能码03的完整流程核心步骤是构造请求帧 → 计算CRC → 通过串口发送 → 等待接收响应帧 → 校验地址码、功能码、CRC → 解析寄存器数据。typedef struct { int fd; /* 串口文件描述符 */ unsigned char addr; /* 从站地址 */ } modbus_t; int modbus_read_registers(modbus_t *ctx, unsigned short reg_addr, unsigned short reg_count, unsigned short *dest) { unsigned char req[8]; unsigned char resp[256]; int len, i; /* 构造请求帧 */ req[0] ctx-addr; req[1] 0x03; req[2] (unsigned char)(reg_addr 8); req[3] (unsigned char)(reg_addr 0xFF); req[4] (unsigned char)(reg_count 8); req[5] (unsigned char)(reg_count 0xFF); unsigned short crc crc16_modbus(req, 6); req[6] (unsigned char)(crc 0xFF); /* 低字节 */ req[7] (unsigned char)(crc 8); /* 高字节 */ /* 清理串口接收缓冲避免读取到上次残留数据 */ tcflush(ctx-fd, TCIFLUSH); if (write(ctx-fd, req, 8) ! 8) { perror(write); return -1; } /* 等待并读取响应帧 */ len read(ctx-fd, resp, sizeof(resp)); if (len 0) { return -1; } /* 校验响应帧的地址码、功能码 */ if (resp[0] ! ctx-addr || resp[1] ! 0x03) { return -1; } /* 校验CRC */ unsigned short resp_crc resp[len-2] | (resp[len-1] 8); unsigned short calc_crc crc16_modbus(resp, len - 2); if (resp_crc ! calc_crc) { return -1; } /* 把数据字节拼成寄存器值 */ int bytes resp[2]; for (i 0; i reg_count; i) { dest[i] (resp[3 i * 2] 8) | resp[4 i * 2]; } return reg_count; }这段逻辑里有个特别重要的细节是发送前调用了tcflush(fd, TCIFLUSH)。为什么必须这样做因为RS485总线上如果上一次通信超时了接收缓冲区里可能残留了半个无效帧下次发送前不清理干净读取时就会把旧数据当成新响应导致解析混乱。这个函数是Linux串口编程里的老朋友了TCIFLUSH表示只刷新输入缓冲不会影响输出。实际使用下来它能解决很多莫名其妙的脏数据问题。read返回值len一定不能只看是不是大于0就完事还要检查它是不是等于期望的响应长度。Modbus RTU的响应帧长度可以推算出来读N个寄存器时响应帧总长度是N * 2 5字节。如果len小于这个长度说明发生了粘包或者丢字节应该认为本次通信失败具体怎么重试在后面章节讲。解析寄存器值时的细节是字节序。Modbus协议规定寄存器数据高字节在上所以resp[3]左移8位后加上resp[4]。传感器厂商手册一般会明确说明寄存器里的数值格式有的是16位有符号整数有的是两个寄存器拼接成一个32位浮点数。这次项目里大部分传感器是16位有符号的直接按这个方式解析就行了但如果你遇到的是浮点数传感器就需要把相邻两个寄存器值拼成一个32位数再通过memcpy转成float。这种大端小端不一致的问题在Modbus开发中太常见了一定要先确认传感器手册的数据格式。4.2 多设备轮询与超时处理一条RS485总线上挂了多个传感器时我们需要做轮询。最简单的方式就是一个for循环每隔一段固定的时间依次访问每个从站地址。比如系统里有3个传感器地址分别是1、3、5那就依次调用modbus_read_registers读取它们的数据。轮询周期要看传感器的响应速度和你对数据实时性的要求太密集会造成总线拥塞太稀疏会错过数据变化我一般控制在100ms到500ms轮询一轮。轮询的一个关键坑是单设备卡死导致整个轮询中断。如果某个传感器掉线了read可能会一直阻塞在那里后面的设备就永远读不到数据。解决方法是给每次读取设置总超时时间还是利用VMIN和VTIME。当VTIME设为1时单位是0.1秒也就是说read最多等0.1秒就返回我们可以根据返回的字节数判断是成功还是超时。但我更推荐的做法是把超时控制放到业务层调用read前先记录系统时间戳读取后如果解析发现异常主动重试重试次数超过3次就判定设备离线然后继续轮询下一个设备。实际项目里我把轮询逻辑写成了一个简单的状态机每个设备维护一个“在线/离线”状态。在线设备如果连续5次通信失败就标记为离线离线设备仍然会周期性地给它发请求一旦恢复通信就重新标记为在线。这样做的好处是维护人员可以通过日志看到每个传感器的健康状态而不是整体系统一片维护。轮询间隔怎么定也需要仔细斟酌。假设一条总线上有10个设备每个设备的响应时间是20ms那么一轮轮询最少需要200ms考虑到线路切换和程序处理开销设置500ms的轮询间隔是安全的。有的传感器响应很慢可能要几百毫秒才回复这时候就要把轮询周期拉长或者适当降低每个设备的请求频率。通信不是越快越好稳定跑一天不出错才是目标。4.3 调试工具与现场测试开发阶段一定要善用PC上的串口调试工具。我常用的方法是先用USB转RS485模块连接传感器在PC上用串口助手下发标准的Modbus RTU请求帧比如01 03 00 00 00 01 84 0A看传感器有没有正确应答。这一步能确认传感器本身的参数和地址还能验证CRC算得对不对。还有一个很实用的调试路径是用Python来快速验证通信链路。在PC上写一个简单的Python脚本用pyserial库发送Modbus RTU请求能很快确认传感器返回的数据内容。Python脚本跑通了再把它翻译成C代码移植到嵌入式Linux上能少走很多弯路。在嵌入式Linux端我习惯在代码里加一个“原始帧打印”的调试开关发送和接收的每一帧数据都以十六进制格式打印出来。这样在真机调试时一眼就能看出请求帧构造有没有问题、响应帧解析有没有错位、CRC对不对。等项目稳定运行后再把这个开关关掉以免影响实时性。另外RS485总线在工业现场最容易出问题的就是屏蔽线接地和终端电阻匹配。终端电阻一般选120欧姆接在总线的两端作用是消除信号反射。如果总线上通信时好时坏、偶发CRC错误多半是网络布线问题而不是软件问题。所以调试时别只顾着看代码用万用表量一下A/B线之间的电压确认在空闲状态时电压差大于200mV这个信息能帮你排除一半的物理层问题。5. 常见问题与排查技巧实录5.1 串口通而不稳定的排查最让人头疼的现象不是完全不通而是有时能读到数据、有时读不到或者读到的数据偶尔是错的。这种情况我从几个方向排查按优先级排下来分别是硬件接线、串口参数、协议帧格式、程序逻辑。先看硬件。RS485是差分信号A、B两条线不能接反接反了完全不通倒还好最怕是接触不良时通时断。再一个就是共地问题如果传感器的地线和控制板的地线没有连接电平参考点不一致通信稳定性会很差。我遇到过一次这个情况表现为传感器能响应但正确率只有70%当时费了好大劲才发现是地线没接。其次看串口参数。用tcgetattr把当前串口的实际配置打出来确认数据位、停止位、校验位是不是真的设成了目标值。有的系统里串口被其他程序打开过残留的配置没有完全被覆盖就容易出问题。协议层面的排查依靠打印原始帧。如果你发的请求帧是01 03 00 00 00 01 84 0A但传感器就是不回可能有两个原因一是从站地址不对传感器实际地址是2而不是1二是CRC算错了。针对CRC我建议直接用在线CRC计算工具算一遍不要完全相信自己的手算结果。5.2 从Modbus报文到传感器值的换算传感器返回的寄存器值十有八九不能直接用需要根据传感器量程和分辨率做换算。比如某温度传感器的寄存器值是0x022B也就是十进制555而它的量程是-40℃到120℃ 输出分辨率是0.1℃那实际温度就是555 * 0.1 55.5℃。这个换算关系必须从传感器厂商手册里找到不能猜。寄存器值可能是无符号整数、有符号整数、甚至是IEEE 754浮点数。有符号和浮点的处理我在前面提到过这里再强调一次。16位有符号整数的判断方法是先看值的第15位如果是1说明是负数需要减去65536。比如0xFFFF应该是-1如果直接当无符号数解析就成了65535。对于32位浮点数需要把两个相邻寄存器按高字在前、低字在后的顺序拼成4字节再转成float这个过程受主机字节序影响很大。嵌入式Linux系统一般是小端序而Modbus是大端序转换时要注意别弄反了。我封装了一个工具函数modbus_read_float内部先按大端序把4个字节拼好再用memcpy转成float这样就绕开了主机字节序的差异代码可移植性更好。5.3 避坑清单最后整理一份我在这个项目里踩过的坑每一条都对应着实际调试中的一段血泪史分享出来供大家参考。问题现象根本原因解决办法串口能打开但read永远超时没有设置CLOCAL | CREAD系统没有使能串口接收在c_cflag中显式加上两个标志位收到的数据总是少几个字节VMIN设置过大导致read等待超过数据长度设置VMIN 1VTIME 1数据有回显发送的字节被自己收到c_lflag未清零规范模式被使能把c_iflag、c_oflag、c_lflag全部清零读到的寄存器值全是0xFFFF寄存器值是有符号负数直接按无符号解析根据手册判断是否为有符号数字做补码转换通信时好时坏CRC错误频繁传感器与主控地线未连接或者总线上缺少终端电阻检查共地、接入120欧终端电阻还有一个很重要的工程习惯在嵌入式Linux里串口设备的权限可能会限制普通用户访问很多开发板默认/dev/ttyS0只有root用户能读写。调试时可以直接用root跑程序但正式部署时最好把运行程序的用户加到dialout用户组里或者在/etc/udev/rules.d/下写个udev规则设置设备节点的权限免得换了运行环境就莫名奇妙打不开串口。我个人的体会是Modbus RTU开发大部分时间不是花在写通信代码上而是花在排查那些“看起来是通信问题、实际上是环境问题”的坑上。串口配置的每个标志位、CRC的字节序、RS485的电气特性任何一个环节出了偏差最终现象都指向同一句话“数据不对”。但只要把底层这些细节吃透遇到问题的时候用打印原始帧配合分段验证的思路去排查大部分问题都能在半小时内定位。最后说一个小技巧调试阶段在代码里加一个“从站地址扫描”功能自动从1到247依次发送请求看看哪些地址有响应。这个功能能帮你快速确认总线上一共挂了几个设备也能验证设备号配置是否和预期一致。等到正式上线时这个功能可能就用不上了但开发期它能让你少跑无数次现场去翻设备铭牌。