嵌入式Linux下Modbus RTU开发实战:从串口配置到传感器采集

发布时间:2026/9/9 10:38:53
嵌入式Linux下Modbus RTU开发实战:从串口配置到传感器采集 干过嵌入式Linux的人十有八九都躲不开Modbus。这协议老但工业现场全是它环境监测、设备采集、能源管理随便一个项目里都有几十个传感器挂在485总线上等着你读。我这次要聊的就是嵌入式Linux端做Modbus RTU开发这件事从串口配置到组帧收发再到实测中踩过的坑一条线走下来。这东西看着简单真正调起来能让人卡上两三天问题往往不在协议本身而在串口配置和物理链路上。文章适合刚入门嵌入式Linux应用开发、或者想把手头设备接进Modbus网络的朋友参考代码和排查思路都可以直接抄。1. 为什么在嵌入式Linux上做Modbus RTU先想清楚链路再动手1.1 典型应用场景我接触最多的场景是嵌入式Linux设备作为采集网关通过RS485总线去读挂载在现场的传感器。传感器可能是温湿度、压力、液位、电量表、气体检测仪五花八门但共同点是它们都支持Modbus RTU协议。嵌入式Linux设备在这套体系里充当主机Master定时轮询各个从机Slave拿到数据之后要么本地处理要么通过网口/4G模组上报到平台。相比MCU方案嵌入式Linux做这件事的优势很明显跑Linux的系统一般都有网口、USB、存储、更强大的CPU你可以同时处理协议转换、数据缓存、远程配置。而且应用程序用C或者Python写都行调试方便得多。我实际项目中用的是一块ARM Cortex-A7核心板Linux内核版本4.19串口通过板载UART引出再接SP485EE芯片转成RS485电平用的就是标准的termios接口。这里要提醒一句在做任何Modbus代码之前先确认Linux内核里对应UART驱动是否正常。怎么确认ls /dev/ttyS* /dev/ttyUSB* /dev/ttyAMA*看看设备节点在不在。板载串口一般是ttyS0、ttyS1或者ttyAMA0USB转串口芯片是ttyUSB0、ttyUSB1。设备节点都不存在的话后面全是白搭。1.2 物理层选型TTL、RS232还是RS485很多新手在这里犯迷糊以为Modbus RTU就等同于RS485。其实Modbus RTU是应用层协议物理层可以用RS232、RS485甚至光纤。我们做工业传感器采集绝大多数选RS485原因就三点差分传输抗干扰能力强工业现场电机、变频器带来的电磁干扰能把TTL电平打得千疮百孔但485能扛得住。支持多点挂载一条总线上可以并联32个从站设备常规驱动能力下扩展方便。传输距离远1200米没问题现场布线非常友好。RS232只能点对点速率和距离也受限所以基本都是调试用很少拿去接现场仪表。TTL电平是板级通信用的直接引出的话线一长就废而且TTL电平跟外部设备的RS485电平不匹配必须通过收发器芯片转换。选型上我的建议很简单近距离、少量设备调试USB转RS485模块最省事正式项目、设备固定安装优先用板载UART加SP485/MAX485这类收发器稳定性和抗干扰能力比USB方案强得多。实测中USB转485模块在恶劣电磁环境下偶尔会出现通信中断、设备枚举丢失的问题板载UART就很少出这种状况。1.3 硬件接入USB转串口与板载UART用USB转485模块的时候Linux下一般不需要额外装驱动。市面上最常见的芯片是CH340、CP2102、FT232和CH343内核里都自带驱动插上去就会自动创建ttyUSB0节点。用板载UART的时候需要注意信号方向。UART的TX/RX是TTL电平接到SP485收发器之后芯片的A/B两个引脚才是485差分信号。A接对方AB接对方B这个一旦接反通信必然失败。判断方法很简单万用表量一下A-B之间的电压空闲状态下应该在-1.5V到-5V之间对应逻辑1通信时会有跳动。如果量出来是正电压说明A/B反了或者总线上只有你这一个设备在自发自收。另外要注意板载UART的设备树配置确保串口没有被分配给别的功能。我在一个平台上一开始用ttyS2怎么配置都不出数据后来查设备树才发现这个UART被分配给了蓝牙模组换到空闲的ttyS3就好了。2. 串口配置实战stty快速验证与termios完整配置2.1 先用stty验证串口链路通不通写代码之前先用命令行工具把串口物理链路验证一遍能省掉大量排查时间。Linux下最常用的就是stty。# 查看当前串口参数 stty -F /dev/ttyUSB0 -a # 设置9600波特率、8数据位、无校验、1停止位、原始模式 stty -F /dev/ttyUSB0 9600 cs8 -cstopb -parenb raw -echo # 监听串口收到的数据十六进制显示 cat /dev/ttyUSB0 | xxd把从站设备接好主站往总线上发一帧查询报文如果cat能收到响应数据说明物理链路、波特率、数据位这些都是对的。反过来你也可以用echo往串口发数据看从站有没有反应。这一步最重要的价值是把问题一分为二如果stty模式下能通那就是你程序里配置的问题如果stty模式下也不通那是硬件链路或者从站的问题代码写得再花哨也没用。我调设备时一直是这个习惯先命令行验证再进代码调试效率高很多。2.2 termios结构体与关键配置项嵌入式Linux下串口编程核心就是termios结构体。这个结构体包含四个主要标志位集合分别是c_iflag输入模式标志Modbus收发必须关掉ICRNL、IXON这些转换否则数据会被内核吃掉或改写。c_oflag输出模式标志要关掉OPOST输出转换不然换行符会被自动转换。c_cflag控制模式标志波特率、数据位、校验位、停止位都在这里设置。c_lflag本地模式标志要关掉ICANON和ECHO否则串口会进入行模式你必须读到换行符才返回数据这对二进制协议来说是致命的。新手最容易踩的坑就是忘了关ICANON。默认情况下终端设备是带行缓冲的你调用read()等数据如果对方发来6个字节但后面没有换行符read会一直等在那里表现就是程序卡死了。从机独个测试时也可能表现成能收到但不返回全部数据。最省事的做法是直接用cfmakeraw()这个函数它会一次性把所有相关标志位设置成原始模式。2.3 完整的串口初始化代码下面是一份我一直在用的串口初始化代码Modbus RTU项目可以直接抄。#include stdio.h #include fcntl.h #include termios.h #include unistd.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) { printf(open %s failed: %s\n, dev, strerror(errno)); return -1; } struct termios opts; if (tcgetattr(fd, opts) ! 0) { perror(tcgetattr); close(fd); return -1; } cfmakeraw(opts); cfsetispeed(opts, baud); cfsetospeed(opts, baud); opts.c_cflag | CLOCAL | CREAD; // 启用接收忽略MODEM控制线 opts.c_cflag ~CSIZE; opts.c_cflag | CS8; // 8个数据位 opts.c_cflag ~PARENB; // 无校验 opts.c_cflag ~CSTOPB; // 1个停止位 opts.c_cflag ~CRTSCTS; // 禁用硬件流控 // VMIN和VTIME配合实现带超时的读 opts.c_cc[VMIN] 1; // 最少读1个字节才返回 opts.c_cc[VTIME] 5; // 每个字节之间最长等待500ms tcsetattr(fd, TCSANOW, opts); tcflush(fd, TCIOFLUSH); return fd; }关于VMIN和VTIME的取值实际项目里要注意一下。VMIN表示read返回前必须读到的字节数VTIME的单位是0.1秒表示等待超时。如果代码用的是select()或者poll()做超时控制那VTIME设多少无所谓可以设成0如果直接阻塞在read()上那VTIME的值就决定了单帧等待的最长时间。我的习惯是Modbus RTU这种一问一答的协议用select加100ms超时最舒服。从站响应一般都在几十毫秒内超时设太长会导致轮询周期变慢设太短则在从站繁忙时容易误判超时。100到300毫秒是一个比较合理的区间。3. Modbus RTU协议帧拆解从寄存器映射到CRC16校验3.1 四种数据模型与常用功能码Modbus协议定义了四种数据对象理解这个非常关键因为很多新手搞不清保持寄存器和输入寄存器的区别线圈Coil可读可写的位对应功能码01读和05写单个、0F写多个。离散输入Discrete Input只读的位对应功能码02。保持寄存器Holding Register可读可写的16位寄存器对应功能码03读和06写单个、10写多个。输入寄存器Input Register只读的16位寄存器对应功能码04。传感器和仪表设备实际用的最多的就是03和04。区别在于03读的是可写参数比如设定值、校准参数04读的是测量值比如温度、压力、电流。你在读一个传感器数据时先看说明书上是说输入寄存器还是保持寄存器再选择功能码。常用功能码我整理成表功能码功能说明操作对象01读线圈线圈02读离散输入离散输入03读保持寄存器保持寄存器04读输入寄存器输入寄存器05写单个线圈线圈06写单个保持寄存器保持寄存器0F写多个线圈线圈10写多个保持寄存器保持寄存器3.2 一帧完整的RTU报文长什么样Modbus RTU帧结构固定为四段从站地址1字节、功能码1字节、数据段N字节、CRC16校验2字节低字节在前。举个实际例子。我要读取地址为1的从站它的输入寄存器起始地址0x0000连续读2个寄存器对应一个32位浮点数比如温度请求帧主站发01 04 00 00 00 02 71 CB逐字节拆解01从站地址1号设备。04功能码读输入寄存器。00 00起始寄存器地址高字节和低字节从0号寄存器开始。00 02寄存器数量读2个。71 CBCRC16校验值低字节在前。响应帧从站回01 04 04 41 20 00 00 7B 8E逐字节拆解01从站地址。04功能码。04数据段字节数4个字节正好是2个寄存器。41 20 00 00寄存器数据两个16位拼成一个32位值对应浮点数10.0。7B 8ECRC16。注意看CRC的字节序计算出来的CRC发送时先发低字节再发高字节。我见过不少人在这一步栽跟头明明数据都对但从站就是不响应最后发现是CRC高低字节发反了。3.3 CRC16计算逐位法实现Modbus RTU的CRC16算法多项式是0x8005初始值为0xFFFF。推荐用查表法速度快代码还简短。但理解原理的话逐位法更好懂我在调试的时候也常用逐位法来验证CRC计算对不对。下面是逐位法实现实测和Modbus Poll、从站设备的计算结果完全一致#include stdint.h uint16_t modbus_crc16(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 1; crc ^ 0xA001; // 0x8005反射后的多项式 } else { crc 1; } } } return crc; }使用的时候要注意调用modbus_crc16()对请求帧从站地址到数据段末尾做校验结果返回的uint16_t变量低8位是先发送的CRC低位高8位是后发送的CRC高位。组装帧时按这个顺序放进去就好。有个容易混淆的点网上有些代码返回的CRC是高字节在前如果你直接拿别人代码拼帧可能会踩坑。验证方法很简单用标准报文01 04 00 00 00 02算CRC结果应该是0xCB71发送时写成71 CB。拿这个当测试用例算不对的地方一眼就看出来了。3.4 响应帧解析与浮点数转换响应帧收到后不能直接拿来用要先做几件事校验从站地址是否和请求的一致。检查功能码最高位如果响应帧功能码是0x84说明从站返回了异常后面那个字节是错误码。常见的有01非法功能、02非法地址、03非法数据、04从站设备故障。用CRC校验整个帧计算值和帧尾两字节不一致就丢弃。确认数据字节数符合预期。通过校验之后解析数据。Modbus寄存器是16位的一个寄存器能表示0~65535的整数。但很多传感器数据是浮点数或者32位整数这时就要读两个连续的寄存器拼成32位再转换。#include string.h // 假设buf[3]开始是数据段一共4字节2个寄存器 uint16_t reg1 (buf[3] 8) | buf[4]; uint16_t reg2 (buf[5] 8) | buf[6]; // 方式1用memcpy做IEEE 754浮点解析 uint32_t raw ((uint32_t)reg1 16) | reg2; float value; memcpy(value, raw, sizeof(float)); // 方式2读取32位无符号整数 uint32_t integer_value ((uint32_t)reg1 16) | reg2;这里最大的坑是字节序。不同厂家的设备对32位数据的寄存器排列顺序定义不一样有的设备高16位在低地址寄存器有的反过来。如果发现读出来是个天文数字先考虑字节序是不是反了。我调试过一个德国品牌的传感器数据手册里明确写了CDAB模式也就是说实际值是reg2_high || reg1_low这种方式排列的直接把代码改成按设备手册说明解析就通了。4. 读写传感器数据的完整代码流程4.1 完整流程打开、组帧、发送、等待、解析把前面的知识串起来写一个读传感器输入寄存器的完整流程。以地址为1的从站、读起始地址0x0000的2个输入寄存器为例。#include stdio.h #include fcntl.h #include termios.h #include unistd.h #include string.h #include errno.h #include sys/select.h #include stdint.h #define MODBUS_READ_INPUT_REG 0x04 #define SLAVE_ADDR 0x01 #define REG_START 0x0000 #define REG_COUNT 0x0002 static int uart_fd -1; // 前面定义过的 uart_open() 和 modbus_crc16() 放在这里 int modbus_request(uint8_t slave, uint8_t func, uint16_t reg, uint16_t count) { uint8_t req[8]; req[0] slave; req[1] func; req[2] reg 8; req[3] reg 0xFF; req[4] count 8; req[5] count 0xFF; uint16_t crc modbus_crc16(req, 6); req[6] crc 0xFF; req[7] crc 8; tcflush(uart_fd, TCIOFLUSH); // 清空缓存避免读到上次残留数据 int n write(uart_fd, req, 8); if (n ! 8) { printf(write fail, n%d\n, n); return -1; } tcdrain(uart_fd); // 等待发送完成 return 0; } int modbus_read_response(uint8_t *buf, int buf_size) { fd_set fds; struct timeval tv; FD_ZERO(fds); FD_SET(uart_fd, fds); tv.tv_sec 0; tv.tv_usec 200000; // 200ms超时 int ret select(uart_fd 1, fds, NULL, NULL, tv); if (ret 0) { return -1; // 超时或出错 } int n read(uart_fd, buf, buf_size); if (n 0) { return -1; } return n; } int main() { uart_fd uart_open(/dev/ttyUSB0, B9600); if (uart_fd 0) return -1; uint8_t resp[64]; int cmd_ok 0; for (int i 0; i 3; i) { // 失败重试3次 if (modbus_request(SLAVE_ADDR, MODBUS_READ_INPUT_REG, REG_START, REG_COUNT) 0) { int n modbus_read_response(resp, sizeof(resp)); if (n 0) { // 校验从站地址和CRC if (resp[0] SLAVE_ADDR) { uint16_t crc modbus_crc16(resp, n - 2); uint16_t recv_crc resp[n - 2] | (resp[n - 1] 8); if (crc recv_crc) { // 成功解析数据 uint16_t reg1 (resp[3] 8) | resp[4]; uint16_t reg2 (resp[5] 8) | resp[6]; float value; uint32_t raw ((uint32_t)reg1 16) | reg2; memcpy(value, raw, sizeof(float)); printf(data %f\n, value); cmd_ok 1; break; } } } } usleep(100000); // 下次重试前延时100ms } if (!cmd_ok) { printf(Modbus read failed\n); } close(uart_fd); return 0; }这段流程对03功能码、06功能码、10功能码同样适用只要改一下功能码和数据段的组帧方式就行。4.2 超时控制的正确姿势Modbus RTU是主从式协议主机发出请求后必须等待从站响应。从站的响应时间取决于设备本身的处理能力常见范围是几毫秒到上百毫秒。所以超时控制的策略非常重要。用select()做超时控制是嵌入式Linux下最正统的做法原因有三不阻塞主流程、超时可控、代码清晰。上面代码里tv.tv_usec 200000表示200毫秒超时这个值可以按现场情况调整。还有一个很多老工程师都知道的细节从站响应帧内部字节与字节之间是有时间间隔要求的。标准Modbus RTU规定帧内字节间隔不能超过1.5个字符时间帧间间隔不能小于3.5个字符时间。9600波特率下1.5个字符大约是1.6ms左右。在实际读取时如果两次read()之间隔了很久才收到后续字节就不能算同一帧。不过我们代码里用一次read()读全响应接收缓冲区也够大这个细节在绝大多数场景下可以忽略除非你去处理一些极慢的从站设备。4.3 解析响应数据并转换为浮点数解析部分我再多补充一句。有些传感器的浮点数格式虽然是IEEE 754标准但寄存器的排列顺序不是固定的常见的有三种AB CD高16位在前低16位在后即reg1是高字。就是前面代码里的方式。CD AB低16位在前高16位在后即reg1是低字。这时要反过来赋值uint32_t raw ((uint32_t)reg2 16) | reg1;字节内交换有时还要把每个寄存器的两个字节再换一次比如寄存器内存的是BA DC。遇到数据不正常先写个小工具用各种字节序排列都试一遍很快就能定位设备的字节序规则。不要上来就怀疑是自己CRC算错了或者串口丢数据99%的浮点数解析问题都是字节序搞反了。5. 实测中我踩过的四个经典坑5.1 主机从机单独测试都正常连起来就是不通这个场景我在热搜词里看到不止一次自己也碰到过。现象特别诡异用USB转485模块接电脑做主机电脑上的测试工具能正常读到传感器数据传感器单独测试返回的数据也完全正确。但主机和从机用线连起来死活不通。排查顺序是这样的第一查A/B是否接反。485总线是差分信号A接A、B接B是最基本的要求。很多现场的接线端子颜色不统一红色不一定就是A。用万用表量电压正常空闲状态A相对于B应该是负电压典型值-2V到-6V。如果量出来是正的那肯定有一端接反了。第二查地线。RS485是差分信号理论上不依赖地线但实际应用中如果总线上各个设备的参考地电位不一致会导致共模电压超标严重时直接烧毁收发器轻微时表现为通信时好时坏。解决办法把所有设备的GND连在一起。第三查终端电阻。总线两端要各接一个120Ω终端电阻。距离短、设备少时不接也能跑但超过几十米或者设备多了信号反射会让波形失真表现为数据偶发错误。我一般只有在总线长度超过50米时才考虑加终端电阻。第四也是很多人忽略的USB转485模块和现场设备虽然波特率都设置成9600但数据位、校验位、停止位不一致。比如主机配置成8E18数据位偶校验1停止位从站实际是8N18数据位无校验1停止位这种不匹配在单独测试时可能因为容错而勉强通过但接在一起后就完全不通。用stty -F /dev/ttyUSB0 -a确认一下当前串口参数再和从站说明书的参数对比。5.2 USB转485模块的收发方向切换这个坑相当隐蔽。RS485是半双工通信收发共用一对差分线。很多USB转485模块在硬件上做了自动收发切换你只管发数据就行模块自己控制方向。但也有部分模块需要外部引脚控制DE/RE方向或者自动切换的速度不够快。现象是主机发数据后立即进入接收状态结果从站响应回来的前几个字节被丢掉了。表现得很像CRC错误因为你收到的帧不完整CRC校验肯定不过。解决办法有两种。一种是在发送完成后加一个小延时比如2到5毫秒再开始读串口。另一种是用tcdrain(fd)确保数据全部从驱动程序发出后再读。我代码里就是两个都用tcdrain确保发送FIFO清空然后延时2ms再进入select等待。这段延时是实测出来的有些便宜的USB转485模块需要更久才能把方向切回接收。5.3 串口权限与设备号漂移嵌入式Linux板子上跑应用经常会碰到open(/dev/ttyUSB0, O_RDWR)返回权限错误。这是因为当前用户不在dialout组或者uucp组里而串口设备默认组权限是这些组。usermod -a -G dialout $USER执行完需要重新登录才能生效。还有一种是设备号漂移板子上插了两个USB转485模块重启之后ttyUSB0和ttyUSB1的对应关系换了程序打开的设备节点就变成另一个设备。稳定做法是写udev规则根据USB设备的VID/PID绑定固定设备名。# /etc/udev/rules.d/99-usb-serial.rules SUBSYSTEMtty, ATTRS{idVendor}1a86, ATTRS{idProduct}7523, SYMLINKttySensor0这样不管你插几个设备/dev/ttySensor0永远指向那个CH340模块。项目里如果用了多个USB转串口设备这个规则强烈建议加上不然现场运维会疯掉。5.4 抓串口日志定位问题的思路调试Modbus通信最怕的就是猜。代码改来改去没有依据纯粹浪费时间。我的习惯是在调试阶段打开串口日志把发送和接收的每一字节都打出来。// 发送前打印 printf(TX: ); for (int i 0; i 8; i) printf(%02X , req[i]); printf(\n); // 接收后打印 printf(RX: ); for (int i 0; i n; i) printf(%02X , buf[i]); printf(\n);日志一旦打开很多问题一眼就能看出来。比如TX数据看着正常但RX一直收不到说明链路或者从站有问题RX有数据但CRC不对说明有干扰或者字节被截断RX数据全是0xFF或者0x00多半是A/B接反或者总线电平有问题。如果板子上的程序不方便打日志可以在电脑上用Modbus Poll模拟主机再用USB转485接进同一总线。Modbus Poll能直接看到通信报文、错误码和响应时间非常直观。调试时先用上位机确认总线没问题再把问题定位到设备端代码效率能翻一倍。6. 多从机轮询与项目工程化改造6.1 串行轮询设计与超时保护真实项目里不会只读一个传感器一条485总线上挂七八个从站是常态。这时就要做轮询调度。逻辑很简单每个从站依次发送读请求等到响应或者超时后处理数据然后进入下一个从站。工程上要注意的是不能让一个挂掉的从站把整条总线堵死。如果一个从站不响应主站等它200ms超时总线上如果有8个从站都故障一轮轮询就要浪费1.6秒。所以超时时间设置很关键我一般设成100ms到200ms轮询周期可预期。轮询结构可以抽象成一张表从站地址功能码起始寄存器寄存器数量数据格式1040x00002float AB CD2030x00014int32 x23040x00002float CD AB程序初始化时加载这张表循环里就按表格内容逐条发送请求解析响应后存到对应的数据缓冲区里。这样新增一个从站或者调整寄存器地址只需要改配置表不动代码逻辑。6.2 错误分类与日志记录轮询一段时间后你会发现通信偶尔会出错这是485总线现场的常态。关键是把错误分类并做好记录超时从站没回应。可能是设备断电、地址错误、总线断开。CRC错误收到了数据但校验失败。可能是电磁干扰、波特率不匹配、收发方向切换丢失字节。异常响应从站回了一个异常码比如02表示你读的寄存器地址不存在。这是你程序里的寄存器地址表跟设备手册对不上。字节错乱收到的帧长度不对数据段不完整。实际的工程做法是给每个从站维护错误计数连续错误超过N次就上报一次告警而不是每次出错都刷日志。比如我在项目里是连续3次读取失败才记录一条ERROR日志这样就避免了从站断电时日志疯狂刷屏的尴尬。6.3 什么时候直接上libmodbus我前面讲的是手写协议的完整流程因为理解协议细节是基本功。但真正做项目我建议优先考虑libmodbus这个开源库。apt install libmodbus-dev库的API很简洁#include modbus/modbus.h modbus_t *ctx modbus_new_rtu(/dev/ttyUSB0, 9600, N, 8, 1); modbus_connect(ctx); modbus_set_slave(ctx, 1); uint16_t regs[2]; modbus_read_input_registers(ctx, 0x0000, 2, regs); modbus_close(ctx); modbus_free(ctx);libmodbus内部已经处理了超时、重试、CRC校验、报文分包这些繁琐的逻辑而且它对RTU帧的字节间隔做了正确的判断比我们自己用select实现更可靠。手写代码的好处是灵活可控、没有依赖但也意味着你要自己处理所有边界情况。我的建议是产品原型验证、快速Demo用libmodbus省时间如果是要深度定制比如特殊的错误处理流程、或者对接不标准设备的字节序那就自己写。对嵌入式Linux开发者来说两种方案都掌握是基本要求因为面试和实际项目里都会被问到。调试Modbus这套流程我个人的心得是硬件链路永远是排在第一位的代码反而简单。先把物理层调到能稳定通信再谈协议和业务逻辑。遇到问题时打开日志看原始字节别靠猜。串口配置、CRC、字节序、超时这四件事搞明白Modbus RTU这块就基本拿下了。