嵌入式Linux下Modbus RTU主站开发与RS485串口配置实战

发布时间:2026/9/6 3:23:12
嵌入式Linux下Modbus RTU主站开发与RS485串口配置实战 做嵌入式Linux开发迟早会接到这样的需求现场一堆RS485接口的传感器温湿度、压力、液位之类要接到板子上用Modbus RTU协议把它们的数据读回来。我刚做这个项目时以为只是打开串口读几个字节那么简单结果在串口配置、CRC校验、RS485方向切换上踩了一圈坑才把数据稳定采集下来。这篇文章就是把这个过程完整盘一遍从Linux串口配置到Modbus RTU报文拆解再到主站读写程序实现和现场联调排错适合所有准备在嵌入式Linux上接Modbus从站设备的开发者。项目本身不复杂但细节极多任何一个环节不对读回来的都是乱码或者干脆没响应。1. 项目背景与整体方案1.1 实际场景一块板子要读一堆传感器这类项目的典型场景是这样的设备端是一块嵌入式Linux开发板可能是i.MX6ULL、全志或者是瑞芯微的方案系统跑着Linux业务逻辑包括数据采集、本地存储、上云上报。传感器端则是现场部署的各种变送器比如RS485接口的温湿度传感器、压力变送器、光照度传感器厂家不同协议不同但绝大多数都支持Modbus RTU。需求往往很直白主控板作为Modbus主站定时去读取每个从站传感器的寄存器值把数据解析成物理量再交给业务层。难点在于嵌入式Linux环境下没有现成的工控组态软件你需要自己写串口程序自己构造Modbus报文自己处理各种异常情况。看似简单实际做起来才发现坑都在细节里。1.2 为什么工业现场如此依赖Modbus RTU其实有很多总线协议可以选比如CAN、EtherCAT、Profibus甚至直接上MQTT走网络为什么还是要用Modbus RTU原因是这套组合在成本、成熟度和普及度上几乎是无可替代的。Modbus RTU走的是RS485物理层两根线A、B半双工差分传输抗干扰能力强最远能到1200米左右挂32个从站设备加中继可以更多。传感器本身只要有RS485接口基本都带Modbus RTU协议支持而且价格便宜采购周期短。嵌入式Linux板卡自带UART加一个MAX3485或者SP3485电平转换芯片就能低成本接进RS485网络不需要额外买网关。对比一下CAN总线虽然抗干扰更强但传感器端支持CAN的很少而且协议层还得自己定义Modbus TCP走以太网布线成本高现场很多传感器并不带网口。所以在中小规模的数据采集场景里嵌入式Linux RS485 Modbus RTU是最稳妥、最省钱的方案。1.3 环境准备硬件和软件都要心里有数硬件方面我这次用的是一块瑞芯微方案的Linux板卡板载三路UART其中一路被引出到RS485芯片接口是端子排。如果你手头的板子没有板载RS485也可以用USB转RS485的模块比如FTDI芯片方案的Linux下识别为/dev/ttyUSB0效果一样。软件方面跨平台调试我用的是Modbus Poll主站模拟和Modbus Slave从站模拟这两个工具写代码之前先用它们验证传感器是否正常。嵌入式Linux侧则自己写C程序直接操作串口设备节点不依赖任何第三方库。当然也有libmodbus这样的现成库可以选但手写一遍能让你真正理解协议细节后面用库时也更清楚自己到底在调什么所以我更推荐先裸写一遍再谈封装。2. Linux串口配置把UART调到能用的状态2.1 串口设备节点与硬件确认嵌入式Linux下串口设备节点的命名规则和平台相关。IMX平台通常是/dev/ttymxc0、/dev/ttymxc1瑞芯微平台是/dev/ttyS0、/dev/ttyS1USB转串口则是/dev/ttyUSB0或者/dev/ttyACM0。拿到板子第一步就是确认接的串口对应哪个节点。最笨也最可靠的方法是看dmesg启动日志dmesg | grep tty或者直接ls -l /dev/tty*。如果是USB转串口设备插拔前后对比一下设备列表就能锁定节点。还有一种情况是板卡厂商在设备树里给串口配置了别名比如aliases里把某个串口标成serial0那它就可能额外出现一个/dev/serial0的软链接实际指向的还是底层UART节点。需要注意权限问题。大部分嵌入式Linux发行版为了省事/dev/ttyS*的权限是root所有普通用户无法打开跑程序时要么用root要么给设备节点加udev规则。调试阶段我建议直接chmod 666 /dev/ttyS0先用起来部署时再规范处理。2.2 termios核心参数波特率、数据位、停止位、校验位Linux下串口配置的标准接口是termios这套东西历史悠久字段含义又多新手很容易绕晕。但如果我们只是做Modbus RTU需要关心的参数其实就那几个波特率、数据位、停止位、校验位、原始模式、超时时间。Modbus RTU最常见的帧格式是8数据位、1停止位、无校验8N1波特率常见有9600、19200、38400、115200。传感器出厂默认一般是9600或19200具体看手册不确定就用Modbus Poll逐个试。配置代码的核心部分长这样#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include string.h #include errno.h int uart_open(const char *dev, int baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open); return -1; } struct termios opt; memset(opt, 0, sizeof(opt)); if (tcgetattr(fd, opt) 0) { perror(tcgetattr); close(fd); return -1; } /* 设置为原始模式避免tty层对数据的任何加工 */ cfmakeraw(opt); 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: speed B9600; } cfsetispeed(opt, speed); cfsetospeed(opt, speed); /* 8N1: 8数据位、无校验、1停止位 */ opt.c_cflag ~PARENB; opt.c_cflag ~CSTOPB; opt.c_cflag ~CSIZE; opt.c_cflag | CS8; opt.c_cflag | CLOCAL | CREAD; /* 读超时设置后面细说 */ opt.c_cc[VMIN] 0; opt.c_cc[VTIME] 10; tcsetattr(fd, TCSANOW, opt); tcflush(fd, TCIOFLUSH); return fd; }这段代码里最关键的是cfmakeraw它把termios里所有行规程line discipline相关的东西全部关掉关闭ECHO、关闭ICANON、关闭ISIG不做回车换行转换不做流控。Modbus RTU是纯二进制协议任何一字节的数据被tty层修改都会直接导致报文错乱。如果你不用cfmakeraw就要手动设置opt.c_lflag ~(ICANON | ECHO | ISIG)还得处理c_iflag和c_oflag容易漏。所以我的建议是直接用cfmakeraw简单可靠。2.3 VMIN与VTIME读超时是怎么工作的串口读数据的阻塞行为由c_cc[VMIN]和c_cc[VTIME]控制这两个参数非常关键尤其是在Modbus这种“发一帧、等一帧”的交互模型中。VMIN表示读到多少个字节才返回VTIME表示等待超时单位是0.1秒。两者组合有几种典型情况VMIN0, VTIME0完全非阻塞没有数据立即返回0。VMIN1, VTIME0阻塞直到读到1个字节。VMIN0, VTIME10最多等1秒超时返回0。这是最常用的组合常用于Modbus主站读取响应。VMIN1, VTIME10读到1字节后启动计时器后续每次有数据就刷新计时间隔超过1秒才返回。Modbus RTU模式下从站响应一帧通常几毫秒到几十毫秒就发完了主站发送请求后不能无限等也不能读一字节就返回否则会把一帧响应拆成多次读取帧完整性无法保证。建议的做法是VMIN1, VTIME20这样读到第一个字节后开始等后续数据到达会刷新计时帧间间隔超过2秒才会返回整个响应帧基本都能被一次read完整读出。不过要注意VTIME在实际实现中是个近似值不同内核版本和驱动实现会有偏差所以正式代码里最好还是加上帧完整性判断读到数据后用select或poll继续等待一小段时间确认没有新字节到达才算一帧结束。2.4 RS485方向切换的两种做法RS485是半双工总线只能有一方在发数据。发送时要让发送使能DE/RE引脚拉高发送完毕要拉低否则会一直占着总线影响其他设备通信。这个问题在嵌入式Linux上有两种解法。第一种是硬件自动方向切换。现在很多RS485芯片都带自动方向功能比如MAX13487它会根据DI脚的电平自动控制收发方向软件层面完全不用管。这是最省心的方案我强烈推荐。如果板卡上用的是这种芯片串口配置完全不用处理方向问题。第二种是用IO口控制方向。芯片是MAX485这种需要手动控制DE引脚的那就要在设备树里把某个GPIO配置为RS485方向脚驱动里有对这个的支持比如linux,rs485-enabled-at-boot-time这类属性。代码层面也可以用ioctl配合TIOCSRS485结构体配置内核RS485模式内核会帮你自动切换方向#include linux/serial.h struct serial_rs485 rs485conf; memset(rs485conf, 0, sizeof(rs485conf)); rs485conf.flags SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; rs485conf.delay_rts_before_send 1; rs485conf.delay_rts_after_send 1; ioctl(fd, TIOCSRS485, rs485conf);配置成功之后写串口时内核会自行控制RTS引脚翻转用户程序不用关心方向。但有个坑不是所有串口驱动都支持TIOCSRS485特别是USB转串口芯片很多不支持。遇到这种情况就只能用GPIO手动拉方向了在write之前将GPIO置高写完之后置低。代码上虽然麻烦点但可控性最强现场调试RS485通信异常时用这种方案也最容易定位问题。3. Modbus RTU协议拆解帧结构、功能码与CRC3.1 RTU报文到底长什么样Modbus RTU的报文格式非常规整一帧数据由四部分组成从站地址、功能码、数据、CRC校验。从站地址1字节功能码1字节数据区长度可变CRC是2字节校验码。帧与帧之间有至少3.5个字符时间的间隔用于从站判断一帧的起始和结束。一个典型的读取请求帧长这样地址功能码起始寄存器高位起始寄存器低位寄存器数量高位寄存器数量低位CRC低位CRC高位0x010x030x000x010x000x020x950xCB含义是向地址为1的从站发送功能码0x03读保持寄存器从寄存器地址0x0001开始连续读2个寄存器。CRC是前面6个字节算出来的校验值。如果正常从站会返回类似这样的帧地址功能码字节数数据1高数据1低数据2高数据2低CRC低CRC高0x010x030x040x000x640x000x0A0x??0x??表示读回了两个寄存器值分别是0x0064和0x000A。如果出错从站返回的帧是地址、功能码最高位置1、异常码、CRC比如01 83 02 CRC其中02表示非法数据地址。3.2 常用功能码及应用场景Modbus RTU功能码很多但嵌入式采集场景真正高频用到的就这几个功能码名称用途0x01读线圈读DO开关量比如继电器状态0x02读离散输入读DI开关量比如按钮、限位开关0x03读保持寄存器读可读写的寄存器占绝大多数0x04读输入寄存器读只读寄存器很多传感器测量值在这里0x06写单个寄存器设置参数如修改从站地址0x10写多个寄存器批量设置参数实际经验里温湿度传感器、压力变送器这类仪表测量值大多放在输入寄存器0x04或者保持寄存器0x03里。先用Modbus Slave配合设备手册确认数据在哪个区域再决定用哪个功能码。3.3 CRC16-Modbus计算原理与代码实现CRC是Modbus RTU里最容易写错的部分。它使用的是一种特定的CRC16算法多项式是0xA001即标准CRC-16/MODBUS多项式0x8005反序后得到0xA001。计算的初始值是0xFFFF每个字节先和当前CRC异或然后右移8次每次判断最低位是否为1是1就和0xA001异或最后得到的CRC需要低字节在前发送。直接贴代码static uint16_t modbus_crc(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 (crc 1) ^ 0xA001; else crc 1; } } return crc; }这个函数返回的CRC是主机字节序。发送时先发低字节再发高字节比如计算结果是0xCB95发送顺序是0x95, 0xCB。很多新手在这里出错因为平时习惯了大端序直接按memcpy发到串口结果算出来的CRC对着Modbus Poll一比对总是不对原因就是字节序反了。CRC校验必须放在接收和发送两侧一起检查。发送侧算出来填到帧尾接收侧重新算一遍判断结果是否为0。我这里提供一个经验接收端把整帧包括收到的CRC一起交给CRC函数如果结果等于0说明帧完整没有错误。这个判断方法不用单独拆帧尾的CRC字节代码会简洁很多。3.4 拿真实报文一步一步解析假设现场有个RS485温湿度传感器从站地址是0x01波特率96008N1。根据手册温度值存在保持寄存器0x0001湿度值存在0x0002都是16位有符号整数实际值是寄存器值除以10。要读温度和湿度两个寄存器构造请求帧地址0x01功能码0x03起始寄存器地址0x0001寄存器数量0x0002前6字节01 03 00 01 00 02CRC用上面的函数计算结果假设是95 CB完整发送帧是01 03 00 01 00 02 95 CB从站可能返回01 03 04 02 26 01 3A 2F 7E逐个解析01从站地址03功能码和请求一致表示正常响应04数据字节数2个寄存器共4字节02 26寄存器1的值即温度0x0226 550除以10就是55.0度01 3A寄存器2的值即湿度0x013A 314除以10就是31.4%RH2F 7ECRC整个流程清晰地证明了Modbus RTU本质上就是“拼帧、发帧、收帧、拆帧”的循环并没有任何神秘之处。真正的难点反而在串口层和工程化层。串口没配好帧乱CRC没做好数据错超时没处理好程序卡死。每一项都值得单独踩一遍坑。4. 主站读写程序实现从零手写一个Modbus主站4.1 整体架构线程、队列与超时嵌入式Linux上的Modbus主站程序我建议不要全部塞在一个大循环里。轮询多个传感器时如果某个从站没响应read会阻塞后面的设备也跟着等整个采集周期被拉得很长。合理的架构是两层一个独立的采集线程专门负责串口读写和协议帧处理一个业务层通过共享缓冲区获取最新数据。采集线程内部的逻辑用状态机来实现最清晰空闲从待采集列表里取下一个从站设备。发送构造请求帧写入串口。等待响应设置超时比如500毫秒阻塞读串口。解析帧校验地址、功能码、CRC提取数据。记录状态成功则更新数据缓存失败则记录错误次数。回到空闲采集下一个设备。这种状态机和超时控制结合的方式天然就避免了单个设备故障拖垮整个采集链路的问题。某个设备连续几次超时后可以标记为离线同时把它在轮询队列里的优先级降低避免它一直占用总线。4.2 读取寄存器的完整代码实现下面是我实际项目里用的一段核心代码去掉了平台相关的初始化细节保留了完整的读寄存器逻辑#include stdint.h #include unistd.h #include fcntl.h #define FRAME_TIMEOUT_MS 500 static uint16_t modbus_crc(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 (crc 1) ^ 0xA001; else crc 1; } } return crc; } int modbus_read_registers(int fd, uint8_t slave_addr, uint16_t start_reg, uint16_t reg_count, uint8_t func, uint16_t *values, int timeout_ms) { uint8_t tx_buf[8]; uint8_t rx_buf[256]; int ret; if (func ! 0x03 func ! 0x04) return -1; tx_buf[0] slave_addr; tx_buf[1] func; tx_buf[2] start_reg 8; tx_buf[3] start_reg 0xFF; tx_buf[4] reg_count 8; tx_buf[5] reg_count 0xFF; uint16_t crc modbus_crc(tx_buf, 6); tx_buf[6] crc 0xFF; tx_buf[7] crc 8; ret write(fd, tx_buf, 8); if (ret ! 8) return -1; /* 这里用select做超时控制确保读不到响应时能及时返回 */ fd_set rfds; struct timeval tv; FD_ZERO(rfds); FD_SET(fd, rfds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; ret select(fd 1, rfds, NULL, NULL, tv); if (ret 0) return -2; /* 超时或select被中断 */ ret read(fd, rx_buf, sizeof(rx_buf)); if (ret 5) /* 最短合法响应帧至少5字节 */ return -3; /* 校验从站地址是否匹配 */ if (rx_buf[0] ! slave_addr) return -4; /* 异常码响应判断 */ if (rx_buf[1] (func | 0x80)) return -5; /* 功能码不匹配 */ if (rx_buf[1] ! func) return -6; int byte_count rx_buf[2]; if (byte_count ! reg_count * 2) return -7; /* CRC校验 */ uint16_t calc_crc modbus_crc(rx_buf, ret - 2); uint16_t recv_crc rx_buf[ret - 2] | (rx_buf[ret - 1] 8); if (calc_crc ! recv_crc) return -8; /* 解析数据Modbus寄存器大端序 */ for (int i 0; i reg_count; i) { values[i] (rx_buf[3 i * 2] 8) | rx_buf[4 i * 2]; } return reg_count; }这个函数踩坑比较多的地方在于单次select只读一次。如果从站的响应帧因为串口驱动缓存的原因被拆成了两段第一次read可能只读到一部分。实际项目里我是在select返回后再用一个小循环继续读直到帧间超过一定时间没有新数据到达再把所有字节拼起来解析。代码会变得复杂一些我建议先跑通这个基础版本再根据实际抓包结果决定要不要加帧重组逻辑。4.3 多设备轮询与调度策略当总线上挂了多个从站轮询策略就需要考虑了。最简单的做法是维护一个设备链表每个设备节点包含从站地址、寄存器起始地址、寄存器数量、上次采集时间、采集间隔、失败次数等信息。主循环每次只采集“到时间该采”的设备采集完就处理下一个。不同设备的采集频率可能不一样。比如温湿度传感器1秒采一次就够了但一个高速的振动传感器可能需要100毫秒采一次。把采集间隔做成设备属性轮询时检查时间戳就能自然实现差异化调度不需要复杂算法。有个重要的经验如果有设备连续失败不要用很短的间隔去反复重试。我通常会做一个退避策略失败一次后把该设备的重试间隔从1秒逐渐拉长到30秒直到它恢复通信再把间隔降回来。这样可以避免故障设备不断占用总线拖慢其他正常设备的采集周期。总线容量也要心里有数。9600波特率下一帧请求加响应大概需要10到20毫秒挂10个设备一轮全采大概需要200毫秒左右对于大多数传感器采集需求完全够用。但如果要采集的设备非常多或者要求毫秒级响应就需要升级到115200波特率或者拆成多条串口总线分担负载。4.4 数据转换与业务对接寄存器值读出来后不能直接用。传感器返回的一般是原始整数需要根据设备手册转换成物理量。常见转换方式有直接除以10、100很多温湿度、压力传感器这么干比如寄存器值550表示55.0度。IEEE 754浮点数某些高端传感器用两个寄存器拼一个float按大端序把4字节拼起来直接按float指针解析。查表线性变换精度要求高的场景传感器手册会给一个y kx b的校准公式。浮点数转换有一个经典陷阱设备返回的数据是ABCD这样的4字节有些设备是大端序AB CD有些是小端序CD AB寄存器内部反序甚至有的设备是字序反CD AB但每个字节又反。我有一个项目遇到过同一个厂家的不同批次传感器浮点字节序居然不完全一样最后只能加一个配置项让现场随意切换字节序。所以解析浮点数时一定先用传感器厂家提供的上位机软件和设备手册核对原始字节流不要想当然。5. 联调实测与高频问题排查5.1 实测记录从乱码到稳定采集我调这块板子时第一次上电测试程序报CRC错误数据根本读不到。用逻辑分析仪抓波形发现从站确实有响应但响应帧里多了几个字节。查了一圈问题出在VMIN和VTIME配置上我用了VMIN0, VTIME10结果read返回时只读到了响应帧的一部分CRC当然对不上。改成VMIN1, VTIME20之后问题立刻消失。第二次遇到的是RS485方向切换问题。板卡用的是MAX485需要GPIO方向控制但设备树里没配置好导致发送完成后方向引脚一直保持发送状态从站发来的数据被自己的发送电路短路接收端全是乱码。这个问题排查花了不少时间后来用示波器同时看差分波形和GPIO电平才发现GPIO在write返回后没有及时拉低。解决办法是write完之后立刻操作GPIO不能让调度器有时间插入其他任务。如果用TIOCSRS485的自动方向功能就不会有这个问题。第三次是有两路传感器分别接在不同UART上但只有一路能正常读取。查下来是第二路UART在设备树里默认被禁用status disabled需要在内核设备树里显式使能。这属于硬件资源启用的基础问题但特别容易忽略建议拿到新板子先检查设备树里所有要用到的串口节点状态。5.2 常见问题速查表现象可能原因解决方法完全没有响应接线A/B反了交换RS485的A/B线完全没有响应从站地址错误先用Modbus Slave或设备上位机确认地址完全没有响应波特率、校验位不匹配逐个尝试常用波特率9600、19200、38400、115200读回来乱码tty层没配成raw模式cfmakeraw(opt)关闭行规程转换读回来乱码RS485方向没切换检查GPIO/TIOCSRS485配置CRC总错字节序反了CRC低字节先发高字节后发CRC总错响应帧被拆分调整VMIN/VTIME或做帧重组单设备正常多设备挂掉从站地址冲突给每个从站设唯一地址避免用0作为从站地址偶尔丢数据总线无终端电阻总线两端加120欧姆终端电阻传感器无响应但Modbus Poll正常程序里没flush缓冲区发送前调用tcflush(fd, TCIOFLUSH)清掉残留数据5.3 避坑经验这些细节值得反复确认第一个tcflush在发送前必须做。串口缓冲区里可能残留上一次的响应帧或者杂讯如果不清理这些垃圾字节会被当成当前请求的响应导致帧错位。我写过一版程序write之后直接read结果读回来的是上一次的报文排查了好久才发现是缓冲区残留问题。发送前用tcflush(fd, TCIOFLUSH)清一次发送后再tcdrain(fd)确保数据全部从缓冲区写出这样收发时序才干净。第二个关于Modbus RTU的帧间隔。标准规定帧内字节间隔不能超过1.5个字符时间帧与帧之间至少要3.5个字符时间。在Linux下这个时序主要靠前一个write返回后立即发下一帧来控制程序层面很容易满足。但如果你的程序在两次写之间做了大量计算、打印日志、或者usleep了一段不小的延时碰上有严格时序要求的从站就会导致通信失败。所以写日志时要克制尤其是不能在收发关键路径上打印大段调试信息printf的开销足以破坏帧间隔。第三个数据处理要用缓冲区环形队列不要让业务层直接阻塞在串口上。嵌入式Linux虽然不像裸机那样对实时性要求苛刻但如果业务线程在等待串口数据时占用锁会让整个系统体验很差。实际项目里我习惯把Modbus采集线程和数据使用方彻底解耦采集线程只管把结果写入共享内存业务线程读到的是最近一次成功的快照两边加一个简单的互斥锁保护即可简单实用。最后再分享一个小技巧。现场调试时先不要急着写程序而是用PC机上的Modbus Poll软件直连传感器确认地址、寄存器、波特率这些基本信息全对再去碰嵌入式Linux侧的代码。这样可以快速区分问题是出在传感器配置上还是出在代码逻辑上。我见过太多同事在代码里反复调CRC、调超时结果最后发现传感器默认地址不是手册上写的“1”而是“247”属于出厂设置被改过的特殊型号。先把硬件和协议验证清楚再写代码能省掉大部分无谓的排错时间。