拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式Linux下Modbus RTU实战:从设备树到RS485传感器读取

做嵌入式Linux开发的人早晚都会和Modbus打交道。我最近接手的一个项目就很典型一块基于ARM Cortex-A7的Linux工控板需要通过RS485总线挂载十几路温湿度传感器、一台三相电表和一台变频器全部走Modbus RTU协议。这类场景在工业现场到处都是设备端协议五花八门但对外提供的通信口却高度统一——不是Modbus RTU就是Modbus TCP其中RTU的占比又远高于TCP。包括我在内的很多人一开始都有个错觉既然Linux下有现成的libmodbus库直接调API不就行了但真到了现场你会发现能不能稳定读到数据往往不取决于协议库而取决于串口配置、时序控制和异常处理。这篇文章不堆API文档而是把我从设备树配置、termios参数设置、CRC16实现到实际读写传感器寄存器的完整链路完整讲一遍。适合刚把Linux系统跑起来、准备接入RS485/RS232总线的嵌入式工程师也适合想弄明白Modbus RTU底层细节、但不想直接套用现成库的同学。文中的代码和调试思路都是项目实测过的你可以直接当作起点改到自家板子上。1. 动手前先想清楚RTU和TCP的边界以及物理层选型1.1 为什么工业现场更爱用RTU而不是TCP我先说一个很反直觉的事实很多传感器和仪表明明本身具备以太网或者Wi-Fi能力但出厂时依然默认给你一个RS485口走Modbus RTU。原因很简单——成本和抗干扰。RS485总线用一对差分线传输抗共模干扰能力远强于TTL电平在工业现场几十米甚至上千米的布线距离下依然可靠。而Modbus RTU的帧结构又极其简单一个从站只需要一个UART和一个485收发芯片就能跑起来硬件成本几块钱。相比之下Modbus TCP虽然不用考虑物理层组网问题但要求设备支持以太网芯片成本高一个量级而且工业现场的网络环境往往没有办公室那么稳定。从软件开发角度看Modbus RTU和TCP的协议数据单元PDU是一样的区别只在传输层封装RTU在PDU前后加上从站地址和CRC16校验TCP则用MBAP报文头替代地址和校验。所以如果你在一套Linux板卡上既想控制现场仪表又想对接上位机系统通常的做法是板卡用RTU下接传感器再用TCP把数据上送。这就意味着RTU这端是无论如何绕不开的而且往往是整个链路易出问题的源头。1.2 TTL、RS232、RS485三种电平别搞混了我见过不止一个新手拿着USB转TTL的模块去接设备的RS485口结果怎么调都读不到数据最后发现是电平不匹配。这里必须先把概念理清楚TTL电平0~3.3V或5V只能板内短距离通信一般调试用。RS232电平±3~±15V单端传输抗干扰一般距离15米左右早期工控机串口就是这种。RS485电平差分信号A/B两线抗干扰强理论距离1200米支持多点挂接一条总线挂32个甚至更多从站。实际项目中传感器仪表几乎都是RS485接口参数默认一般足9600波特率、8数据位、无校验、1停止位也就是我们常说的8N1。这条总线上的每个从站都有一个Modbus地址主站轮询时通过地址区分是谁的数据。接线时A接A、B接B千万别接反。如果总线长度超过几十米或者挂的设备比较多还要在总线两端各接一个120欧姆终端电阻否则信号反射会导致波形畸变表现出来就是通信时好时坏、偶发CRC错误。2. 环境准备设备树里把串口和RS485方向控制配好2.1 设备树串口节点别只设个status就完事在嵌入式Linux下串口设备首先要有一个明确的设备节点。以我用的IMX6ULL平台为例UART3在设备树里长这样uart3 { pinctrl-names default; pinctrl-0 pinctrl_uart3; status okay; linux,rs485-enabled-at-boot-time; rs485-rts-delay 1 1; };这里有三点容易忽略。第一pinctrl必须把TX、RX和RTS引脚都复用成UART功能如果只用TX/RXRS485的方向控制引脚没有被正确配置后面收发就会出问题。第二linux,rs485-enabled-at-boot-time表示内核驱动在初始化时自动启用RS485模式RTS引脚会被硬件自动用来切换收发方向这是半双工RS485通信的关键。第三rs485-rts-delay配置的是RTS切换后的延时单位是毫秒一般设为1即可具体数值要跟RS485收发芯片的切换时间匹配。如果收发芯片是MAX3485这种切换时间在纳秒级1ms的延时足够。如果你用的是其他平台比如全志、瑞芯微设备树写法大同小异。核心就是确认UART控制器使能、引脚复用正确、RS485模式打开。如果板卡没有在设备树里开RS485模式也不代表不能用——你可以在应用层用ioctl动态设置我后面会讲。2.2 启动后如何确认串口设备可用设备树改完烧写系统启动后先别急着写代码。在终端里做几个快速检查ls -l /dev/ttyS* cat /proc/tty/drivers/dev/ttyS开头的设备一般是平台串口/dev/ttyUSB开头的是USB转串口。项目上用平台自带的UART设备节点就是/dev/ttyS3这种。cat /proc/tty/drivers能看到当前系统注册了哪些串口驱动确认UART3有对应的驱动条目。再用stty快速看一眼当前波特率stty -F /dev/ttyS3 -a | head -1这一步只是为了确认设备节点能打开、底层驱动正常。真正的串口参数配置我们在应用层用termios完成而不是依赖busybox的stty命令。3. termios配置把Linux串口调成“裸数据通道”3.1 open设备时的标志位选择很多初学者打开串口时喜欢加O_RDWR | O_NOCTTY这没问题但我更建议再加一个O_NONBLOCK。为什么因为Modbus RTU是半双工轮询模式主站发送请求后要等接收超时这个等待如果放在阻塞模式下一个从站无响应就能卡死整个应用线程。用非阻塞打开再配合select轮询收发控制是可控的。int fd open(/dev/ttyS3, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd 0) { perror(open serial); return -1; }注意打开之后先做一次清空操作tcflush(fd, TCIOFLUSH);这一步把内核缓冲区里残留的历史数据清掉避免上次通信残留的字节干扰本次接收。我早期调试时经常一打开串口就收到一堆乱码进了状态机然后整包错位找了半天原因其实就是没有清缓冲。3.2 termios核心参数原始模式、8N1、波特率接下来是重头戏termios的设置。完整代码如下#include termios.h #include unistd.h struct termios tio; memset(tio, 0, sizeof(tio)); tcgetattr(fd, tio); cfmakeraw(tio); /* 原始模式关闭回显和信号处理 */ tio.c_cflag | CLOCAL | CREAD; /* 忽略调制解调器状态使能接收 */ tio.c_cflag ~CSTOPB; /* 1位停止位 */ tio.c_cflag ~PARENB; /* 无校验 */ tio.c_cflag ~CSIZE; tio.c_cflag | CS8; /* 8位数据位 */ cfsetispeed(tio, B9600); cfsetospeed(tio, B9600); tio.c_cc[VMIN] 1; /* 最少读取1字节 */ tio.c_cc[VTIME] 10; /* 接收超时1秒 */ tcsetattr(fd, TCSANOW, tio); tcflush(fd, TCIFLUSH);这段配置的核心是cfmakeraw它把ICANON、ECHO、ISIG全部关掉让串口变成真正透明的字节流通道——Modbus RTU要求的就是这种模式串口本身不做任何加工收到的字节原样交给应用层解析。波特率这里我初始设9600实际项目要跟从站设备参数对齐。如果设备支持多种波特率可以在应用层把波特率做成可配置项通过配置文件或命令行参数传入。一般传感器默认是9600少数支持19200或38400但换波特率后总线上所有设备都要同步调整否则收发的波形完全没法对齐。3.3 VMIN和VTIME怎么让read不卡死VMIN和VTIME的结构体在Modbus RTU接收里非常关键。VMIN1, VTIME10表示有数据到达时至少读1字节第一字节到达后如果100ms内没有后续数据read就返回已有的字节数。这样read天然就有了“字节间超时”的能力配合Modbus的帧间隔时间可以比较轻松地区分一帧的边界。但要注意这个100ms超时对于Modbus RTU来说太长了。Modbus RTU规定帧内字节间隔不能超过1.5个字符时间在9600波特率下1.5个字符时间大约是1.56ms。所以你如果依赖VTIME来判断帧结束100ms必然会粘包。正确的做法是把VTIME设小一点比如VTIME2200ms同时配合更精密的用户态超时逻辑或者干脆把VMIN设成1、VTIME设成0完全用select用户态计时器来控制接收窗口。我后面讲帧接收时会详细说怎么设计最稳。4. Modbus RTU协议核心帧结构、功能码与CRC164.1 一帧数据到底长什么样Modbus RTU的帧结构很规整一个完整请求帧由以下几部分组成字段长度说明从站地址1字节1~2470为广播地址功能码1字节03读保持寄存器、04读输入寄存器等数据区可变寄存器地址、数量、数据内容CRC162字节低字节在前高字节在后所有多字节字段都是大端也就是高字节在前。CRC16计算的范围是从站地址到数据区末尾帧尾附加时低字节在前。举个例子如果要从站地址为1的传感器读取保持寄存器0x0000开始的2个寄存器请求帧就是01 03 00 00 00 02 C4 0B其中C4 0B就是CRC16校验值低字节C4在前高字节0B在后。我一直觉得Modbus RTU的CRC字节序是初学者最容易犯的错代码里算出来是0x0BC4发出去却写成了0B C4结果被从站拒绝。4.2 常用功能码别贪多Modbus一共有几十个功能码但对传感器采集应用来说常用的也就这几个功能码名称典型用途0x03读保持寄存器读参数配置、可写的运行数据0x04读输入寄存器读传感器采集值只读0x01读线圈读DO数字量输出状态0x02读离散输入读DI数字量输入状态0x06写单个寄存器修改单个参数0x10写多个寄存器批量下发配置参数传感器数据一般都在输入寄存器里用04功能码如果有些传感器把采集值放在保持寄存器里就用03。这两个是日常开发中最常写的。读数字量输入输出的上线/下线告警状态才用01和02。4.3 CRC16-Modbus算法实现CRC16 Modbus的算法是多项式0xA001即0x8005的反转初始值为0xFFFF。C语言实现如下uint16_t mb_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }这个算法不算复杂但我建议直接独立成文件不要跟业务逻辑混在一起。因为Modbus的CRC校验在收帧、发帧两个方向都要用收帧时算一遍跟帧尾比对发帧时算一遍塞到帧尾。我见过有人用一个全局crc变量反复传参结果多线程下crc被覆盖数据偶尔错乱排查起来非常痛苦。写成纯函数只依赖入参就没有这个隐患。验证CRC实现是否正确可以拿上面请求帧的前6字节01 03 00 00 00 02算得到0x0BC4就说明算法没问题。4.4 异常响应帧Modbus不是每次请求都会正常应答。当地址不对、功能码不支持、寄存器地址越界、写入值非法时从站会返回异常帧。结构是功能码的最高位置1然后跟一个异常码。比如主站读了一个越界的寄存器地址从站可能返回01 83 02 CRC功能码83就是0x03 | 0x8002表示非法数据地址。常见的异常码有异常码含义0x01非法功能码0x02非法数据地址0x03非法数据值0x04从站设备故障应用层解析响应时第一件事就是把func 0x80判断一下如果是置位的说明这是一包异常帧不要按正常数据解析否则会把func和02当作寄存器数据得到完全错误的结果。5. 手写一个Modbus RTU主站真正把传感器数据读回来5.1 组装请求帧读温湿度传感器的完整过程假设现场有个温湿度传感器Modbus从站地址是1内部寄存器定义为保持寄存器0x0000存放温度0x0001存放湿度温度是带符号整数实际值除以10湿度是无符号整数也是除以10。那我用功能码0x03读取这两个寄存器请求帧组装如下uint8_t req[8] {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00}; uint16_t crc mb_crc16(req, 6); req[6] crc 0xFF; /* 低字节 */ req[7] crc 8; /* 高字节 */发送出去之后正常情况下会收到一帧这样的数据01 03 04 01 2C 02 58 3A 9C逐字节拆解01是从站地址03是功能码04是后面数据区长度01 2C是温度寄存器02 58是湿度寄存器3A 9C是CRC。把0x012C转成十进制是300除以10就是30.0摄氏度0x0258是600除以10就是60.0%RH。5.2 发送与接收的时序控制半双工轮询不能乱RS485是半双工总线上同一时刻只能有一个设备发言。主站发完请求之后必须等从站回复不能立刻发下一帧否则不同设备的信号在总线上直接冲突。这也是为什么我推荐使用设备树里的RS485模式——内核驱动会在应用层write之前自动把RTS引脚拉成发送态write完成后再拉回接收态这中间的时序由驱动保证比应用层手动控制RTS靠谱得多。发送接收的核心代码可以这样写int modbus_request(int fd, uint8_t *req, uint8_t len, uint8_t *rsp, uint16_t rsp_len, int timeout_ms) { struct timeval tv; fd_set rdfs; ssize_t n; /* 清空接收缓冲防止残留数据干扰 */ tcflush(fd, TCIFLUSH); /* 发送请求帧 */ n write(fd, req, len); if (n ! len) { return -1; } tcdrain(fd); /* 等待数据全部发出 */ /* 等待响应 */ FD_ZERO(rdfs); FD_SET(fd, rdfs); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, rdfs, NULL, NULL, tv); if (ret 0) { return -1; /* 超时 */ } n read(fd, rsp, rsp_len); if (n 0) { return -1; } return n; }这段代码里的tcdrain(fd)容易被忽略它的作用是等待发送缓冲区里的数据真正从串口发送完成再进入select等待。如果不做这一步在半双工RS485下可能会出现数据还没发完就把收发方向切回接收态导致帧被截断。5.3 解析响应帧并校验收到响应后不要急着解析数据。第一步做CRC校验第二步判断功能码和返回值再进入数据处理uint16_t crc mb_crc16(rsp, n - 2); uint16_t crc_recv rsp[n - 2] | (rsp[n - 1] 8); if (crc ! crc_recv) { printf(CRC error\n); return -1; } uint8_t func rsp[1]; if (func 0x80) { printf(Slave exception, code0x%02X\n, rsp[2]); return -1; } if (rsp[0] ! slave_addr || func ! 0x03) { printf(Unexpected response\n); return -1; } uint8_t byte_cnt rsp[2]; if (byte_cnt ! 4) { printf(Data length mismatch\n); return -1; } int16_t temp (rsp[3] 8) | rsp[4]; uint16_t humi (rsp[5] 8) | rsp[6]; printf(temp: %.1f C, humi: %.1f %%RH\n, temp / 10.0, humi / 10.0);这里要注意温度是有符号数所以int16_t湿度是无符号数用uint16_t。如果传感器量程可能对应负数用int16_t解析才不会出现负温度变成655xx这样的离谱值。5.4 轮询多从站时必须加“从站状态机”现场总线上挂了十几个设备主站不可能写个for循环无脑发请求因为每个从站的寄存器数量不同、响应时间不同得有一个调度策略。我的做法是在应用层维护一个“从站结构体数组”每个元素包含从站地址、寄存器起始地址、寄存器数量、读到的数据缓冲区和上次通信时间。轮询时逐个发送请求每发出一帧等该从站响应或超时后再处理下一从站。这样既能避免总线冲突又便于随时删减某个从站。如果某个从站连续几次超时可以标记为离线但不要频繁重试否则一条掉线的设备会把总线占满拖慢整个轮询周期。我一般连续失败3次就置离线标志每隔30秒再尝一次重新上线。6. 实测中踩过的坑粘包、RS485方向切换、超时阈值6.1 串口粘包和半包是我调试中最常见的故障用Linux串口做Modbus RTU最大的坑莫过于粘包和半包。粘包出现的原因是我们无法保证一次read调用正好读到完整的一帧。从站的响应可能分两次到达比如先到了6字节过了几十毫秒又到了2字节也可能是主站读取太快把前后两帧数据一次性读出来了。一个比较稳妥的收帧策略是每次从串口读到数据后把它追加到一个环形缓冲区然后循环解析缓冲区里是否存在完整的合法帧。判断完整帧的条件有两层第一层缓冲区里至少有8个字节最小帧长第二层用CRC校验确认帧尾正确。如果缓冲区里有10字节而一帧只消耗了8字节说明后面多出来的2字节是下一帧的开始暂时留在缓冲区里。这个“边收边解析”的思路比一次性read等整帧要健壮得多。半包问题则更需要关心字节间超时。如果从站在发送数据过程中因为内部处理卡顿导致字节间隔拉大一个严格的Modbus主站会认为这是非法帧。我们在应用层可以约定一个比1.5字符时间长一些、但又不至于误判的字节间超时值比如10ms到20ms。用VTIME1100ms都嫌大我实测下来设置VMIN1、VTIME1然后用select做整体超时控制收帧的实时性已经足够了。6.2 RS485方向切换别自己瞎控制让内核来我最早一版代码是纯应用层手动控制RTS的写完之后信心满满地上板结果发现巡检时第一帧正常第二帧就偶尔出CRC错误抓波形才发现是发送结束后RTS切换太慢最后几个字节在总线上被截掉了一部分。后来我把设备树里的linux,rs485-enabled-at-boot-time打开让内核驱动接管了RTS切换问题立刻消失。如果你们的板卡没有在设备树里配置RS485模式那也可以用ioctl在应用层动态设置#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);这里有一个容易踩的坑SER_RS485_RTS_ON_SEND和SER_RS485_RTS_AFTER_SEND要结合你的RS485收发芯片实际逻辑来看。如果芯片是发送时靠RTS拉高来使能那设置SER_RS485_RTS_ON_SEND即可如果你发现配置完收发完全反了大概率是这两个标志的组合选反了对调一下就行。6.3 超时阈值怎么定才靠谱又不拖垮轮询周期Modbus RTU主站的等待响应超时设置多少完全没有统一标准因为它取决于挂在总线上的最慢设备。常见传感器厂商的文档里会写“最大响应时间50ms”但有些便宜传感器在内部要做滤波计算实测响应时间能到几百毫秒。我采取的办法是读每台设备的规格书写一个初步的超时值然后实际挂机跑一轮在日志里记录每帧的“发送到收到响应”的间隔。根据最大间隔再加50%的余量作为该从站的超时阈值。如果总线上设备很多这个超时值直接决定了整条总线的轮询周期。假设有10个从站每个超时500ms最坏情况下一轮就得5秒这个更新频率对很多控制场景是不够的。所以超时值既要够大保证慢设备不轻易被判超时又要尽量小不能占满轮询周期。6.4 好用的调试工具和“土办法”开发过程中光靠printf打日志是远远不够的因为一旦通信失败你根本不知道是数据压根没发出去还是从站回了报文但被主站丢弃了。我的调试工具箱里有三样东西第一是串口调试助手我用它直接抓取总线上收发的原始字节以十六进制显示。这样能快速确认主站发的请求帧对不对、CRC字节序对不对。第二是Modbus Poll和Modbus Slave这对模拟工具一个模拟主站去读真实传感器一个模拟从站让我的主站程序来读。用Modbus Slave模拟从站时还能故意构造异常响应验证主站的异常处理逻辑。第三是逻辑分析仪RS485总线电压是差分信号但TX/RX引脚在芯片侧是TTL电平逻辑分析仪可以直接挂在UART的TX和RX引脚上抓到完整波形连波特率是否匹配都能一眼看出来。如果你连逻辑分析仪都没有还有个土办法把RS485的A/B线拆掉直接把UART的TXD和RXD通过USB转TTL模块接到电脑串口助手这样就能不经过RS485收发芯片直接确认CPU这端的UART协议层收发是否正常。我之前遇到一个案例主站程序怎么调都收不到数据排查很久才发现是RS485收发芯片的DE/RE引脚被固件其他部分占用了根本没工作在收发状态。用这个土办法五分钟就定位到了问题。最后分享一个我在后几个项目里一直坚持的做法每个从站设备的寄存器映射表不要只记在供应商手册里希望大家把协议要点写在代码注释和文档里尤其是数据缩放比例和单位转换。Modbus本身只是个搬运工真正有价值的是数据背后的物理意义这块梳理清楚了后面做上层应用、做告警判断都会顺手很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门