ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux下Modbus RTU从串口配置到报文解析的完整实践

嵌入式Linux下Modbus RTU从串口配置到报文解析的完整实践 搞嵌入式Linux的人迟早都会碰到Modbus RTU这根线。我去年接过一个现场项目板子上要同时采集一条RS485总线上挂的三台温湿度变送器和一台压力传感器传感器全是Modbus RTU协议波特率9600、8N1主控跑的是嵌入式Linux。那会儿刚好赶上现场联调前前后后在串口配置、报文解析、浮点数转换这些地方踩了不少坑前三天基本都耗在读不懂报文和通信时通时断上。这篇文章就把那台设备从串口初始化到完整读出传感器数据的思路和路子整理一遍给后面要在嵌入式Linux下做Modbus RTU开发的兄弟做个参考。如果你是第一次在嵌入式Linux下做串口采集或者以前只玩过单片机、STM32上的Modbus这篇对你应该都管用。内容覆盖串口配置的几个层级、RTU帧结构和功能码、CRC16的计算细节、浮点数组合规则再配合PC端工具验证和C语言的完整实现尽量做到照着做就能把数据读出来。1. 整体方案设计与选型拆解1.1 为什么选Modbus RTU而不是Modbus TCP现场设备十有八九是RS485接口像温湿度变送器、压力传感器、电量表、阀门执行器这些仪表内部就是一颗普通MCU跑一个Modbus从站协议对外通过TTL UART转成RS485差分信号。RS485总线一条线上可以挂32个设备标准负载下用地址区分主站轮询读取。Modbus RTU和Modbus TCP的区别简单说就是一个走串口、一个走以太网。RTU的报文是二进制编码带CRC16校验对串口这种容易受干扰的通道很重要TCP的报文去掉了CRC因为以太网底层已经做了校验。工业现场如果设备都在一个车间里、距离不远、设备数量几十个以内用RS485RTU是性价比最高的方案线缆成本低抗干扰能力强一对双绞线能传1200米。如果设备分散在不同区域或者要对接上位机软件、云平台那才需要考虑Modbus TCP网关或直接上TCP设备。我在这个项目里选RTU还有一个原因传感器的数据量很小每个设备读4到10个寄存器就够9600波特率下一条报文往返大概二三十毫秒轮询4台设备不到200毫秒完全满足现场2秒刷新一次的要求。实时性完全够没必要上TCP增加复杂度。1.2 用户态实现还是内核态实现嵌入式Linux下做Modbus RTU通信有两种路数一种是在内核态写串口驱动或者用现有的工业IO驱动框架另一种是在用户态直接操作串口设备文件用纯C或者Python封装协议。我强烈建议用户态实现理由很实在。Modbus只是个应用层协议底层就是普通的UART字节流Linux的串口驱动已经非常成熟用户态用open、read、write、ioctl这几个系统调用就能完成全部收发。内核态写驱动的开发效率低、调试麻烦、出问题还可能把系统搞崩除了对时序要求极其苛刻的场景比如要做硬实时运动控制根本没有必要。有人会问Linux不是实时系统串口收发会不会丢字节实测下来标准的Linux内核在正常负载下UART的RX FIFO配合中断收几十个字节的RTU帧是丢不了的。串口波特率9600时一个字节大约1.04ms一帧最长也就几十毫秒级别内核线程哪怕被调度延迟几个毫秒也没问题。真正要担心的不是丢字节而是read的超时处理——下文会详细讲VTIME和VMIN怎么配。1.3 硬件连接与RS485方向切换RS485是半双工通信两根线A和B靠差分电压表达0和1。接线时A接A、B接B这个一句话的事现场至少有一半的通信故障出在这里。A和B接反不会烧设备但是完全不通。判断方法也很简单拿万用表量A-B之间的电压空闲状态下应该在2V到6V之间如果量出来是负的说明A、B反了。比接反更隐蔽的是方向切换问题。RS485芯片比如MAX3485、SP3485有一个DEDriver Enable引脚发送时要拉高把TTL电平转成差分信号推上总线接收时要拉低让485芯片处于监听状态。很多现成的串口转RS485模块比如USB转485的线芯片内部已经自动处理了方向切换不需要软件干预。但如果你的板子是TTL UART直接接485芯片的那DE引脚就要由某个GPIO控制通常复用UART的RTS脚。软件控制方向切换的时序是发送前先拉高RTS然后write数据数据发完再拉低RTS。这个时序如果没处理好会出现一种很典型的现象自己的板子发完请求后立刻切到接收但485芯片还有个发送完成延迟最后几个字节还没推完就被切走了导致从站收到残缺帧或者主站自己收到自己发出去的半截数据。这类问题后面会展开讲。本文的例程默认你用的是模块方案DE已自动处理但如果你的硬件是独立485芯片需要自己控制方向我会在代码里标注清楚该在哪里加GPIO操作。2. 串口配置的完整细节2.1 设备节点与系统识别嵌入式Linux下串口设备节点有几种命名的可能/dev/ttyS0、/dev/ttyS1CPU自带的UART一般SoC的文档里会写明对应关系/dev/ttyUSB0、/dev/ttyUSB1USB转串口芯片常见的有CH340、CP2102、FT232/dev/ttyAMA0树莓派等平台上的PL011 UART/dev/ttymxc0、/dev/ttyFIQ0个别厂商SoC会自定义命名拿到一块新板子第一步就是确认哪个节点对应你要用的那个物理串口。插入USB转串口后执行dmesg | tail -20能看到类似ch341-uart converter now attached to ttyUSB0的日志。如果是CPU自带的UART要查芯片手册或者设备树dts里serial节点的别名比如status okay那一路就是启用状态。权限问题也经常卡人。默认情况下/dev/ttyS0的属主是root:dialout普通用户没有读写权限。开发时最省事的办法是把当前用户加进dialout组执行sudo usermod -aG dialout $USER重新登录后生效。产品发布时一般用root跑服务就没有这个问题了。2.2 stty命令快速验证串口写代码之前先用stty命令把串口配一遍能验证硬件链路是不是通的。命令如下stty -F /dev/ttyUSB0 9600 cs8 -cstopb -parenb raw -echo拆开解释一下9600是波特率cs8表示8个数据位-cstopb表示1个停止位cstopb是2个停止位加负号就是1个-parenb表示无校验parenb是启用校验raw表示原始模式不做任何行编辑处理-echo是关闭回显。这条命令基本就是Modbus RTU标准的串口参数9600、8N1。配完以后可以用stty -F /dev/ttyUSB0 -a查看当前参数确认配置生效。实测的时候把485模块的A、B端短接发数据能自己收回来说明串口的TX和RX链路没问题。不过短接只是单板测试不能完全代表总线上的通信质量。2.3 代码里的串口配置termios结构体C语言里配置串口用的是termios结构体这套接口来自POSIX标准Linux和大部分Unix系统通用。标准Modbus RTU的串口参数是波特率9600或115200等其他值、8数据位、无校验、1停止位对应代码如下#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include string.h #include errno.h #include sys/ioctl.h int uart_open(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios opt; memset(opt, 0, sizeof(opt)); tcgetattr(fd, opt); /* 设置为原始模式 */ cfmakeraw(opt); /* 波特率 */ cfsetispeed(opt, baud); cfsetospeed(opt, baud); /* 8N1: 8数据位、无校验、1停止位 */ opt.c_cflag ~PARENB; /* 无校验 */ opt.c_cflag ~CSTOPB; /* 1停止位 */ opt.c_cflag ~CSIZE; opt.c_cflag | CS8; /* 8数据位 */ opt.c_cflag | (CLOCAL | CREAD); /* 忽略modem控制线使能接收 */ /* 关闭流控 */ opt.c_cflag ~CRTSCTS; /* 原始模式时要关掉这些 */ opt.c_iflag ~(IXON | IXOFF | IXANY | ICRNL | INLCR | IGNCR); opt.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); opt.c_oflag ~OPOST; /* 超时设置VTIME单位0.1秒VMIN为最少读取字节数 */ opt.c_cc[VTIME] 5; /* 500ms超时 */ opt.c_cc[VMIN] 0; /* 非阻塞读超时返回 */ tcsetattr(fd, TCSANOW, opt); tcflush(fd, TCIOFLUSH); return fd; }这里的cfmakeraw是快速把终端设成“裸”模式避免波特率设置之外的各种行处理干扰二进制数据。VTIME和VMIN这两个参数需要重点理解VMIN0时read会等待VTIME指定的时间单位0.1秒如果这段时间内没有数据到达就返回0VMIN1时read会阻塞直到至少读到1个字节VTIME是收到第一个字节后的“字节间超时”。Modbus RTU主站建议用VMIN0VTIME5这种超时返回的配置这样read不会永久卡住方便自己做超时判断。2.4 RS485模式的内核配置如果你的硬件是独立的RS485收发器而且DE引脚复用了RTSLinux内核提供了原生的RS485支持可以直接通过ioctl配置不用手动操作RTS#include linux/serial.h struct serial_rs485 rs485conf; memset(rs485conf, 0, sizeof(rs485conf)); rs485conf.flags SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; /* * SER_RS485_RTS_ON_SEND发送时RTS拉高发送完自动拉低 * 如果你的硬件是发送时拉低用SER_RS485_RTS_AFTER_SEND */ rs485conf.delay_rts_before_send 0; rs485conf.delay_rts_after_send 0; ioctl(fd, TIOCSRS485, rs485conf);这个ioctl要求底层串口驱动实现了RS485支持。现在的i.MX、STM32MP1、全志等主流平台内核都支持。更彻底的办法是在设备树里配置uart4 { pinctrl-names default; pinctrl-0 pinctrl_uart4; linux,rs485-enabled-at-boot-time; rs485-rts-active-low; /* 或者rts-active-high看硬件决定 */ status okay; };设备树配好了之后系统启动时串口就自动处于RS485模式不用在应用程序里再调ioctl。但注意设备树配的是“启动即启用”如果同一个串口有时走RS232有时走RS485那还是用应用层ioctl动态切换更灵活。3. Modbus RTU协议拆解与实操要点3.1 报文帧结构解析Modbus RTU的帧结构非常紧凑一条完整的报文是这样字段长度说明地址码1字节从站地址1-247有效0为广播地址功能码1字节告诉从站要干什么数据段N字节寄存器地址、数量、数据等CRC162字节校验码低字节在前举一个最典型的例子读取1号从站的保持寄存器起始地址0x0000对应协议里的40001数量1个。请求帧01 03 00 00 00 01 84 0A01从站地址03功能码读保持寄存器00 00起始寄存器地址16位00 01寄存器数量16位84 0ACRC16校验低字节84在前高字节0A在后如果一切正常从站返回01 03 02 02 2C B8 2301从站地址03功能码02返回的数据字节数2个寄存器就是2字节实际是1个寄存器2字节02 2C寄存器值十六进制0x022C十进制556B8 23CRC16如果出错从站返回01 83 02 C0 F1功能码最高位置10x83后面的02是异常码表示非法数据地址。常见的异常码有01非法功能、02非法数据地址、03非法数据值、04从站设备故障排查时对照协议手册就知道问题在哪。这个例子基本涵盖了你日常99%的读传感器场景。写操作功能码06、16的帧结构类似只是数据段换成了要写的值。3.2 常用功能码与传感器应用场景Modbus协议定义了很多功能码但你实际开发中碰到的就几个010x01读线圈对应离散输出状态020x02读离散输入对应开关量输入030x03读保持寄存器16位可读写040x04读输入寄存器16位只读050x05写单线圈060x06写单寄存器150x0F写多线圈160x10写多寄存器温湿度传感器、压力变送器这类仪表多数用功能码03或04读数据。有些厂家把校准参数放在保持寄存器里测量值放在输入寄存器里有些则全部塞在保持寄存器里。具体看产品手册没有统一标准。我建议你拿到设备第一步就是翻手册看寄存器地址表搞清楚两个问题测量值在哪个功能码下的哪个地址原始值要不要换算比如实际温度原始值/10。这里有个巨坑要提醒很多仪表手册的寄存器地址是按PLC习惯写的比如“湿度寄存器地址40001”但实际报文里地址段要填0x0000。40001是PLC的Modbus地址映射习惯对应协议层的地址偏移量永远是从0开始。如果你照着手册填40001转成的16进制0x9C41去读必定返回异常码02。正确做法是设备手册里写40001报文中地址填0x0000写40002就填0x0001以此类推。3.3 CRC16计算逐位法与查表法Modbus RTU的CRC16是多项式0xA001即标准CRC16-IBM的反向多项式初始值为0xFFFF。计算规则是每个字节先和CRC低字节异或然后右移8次每次检测最低位如果是1就和0xA001异或。逐位法的代码很简单适合理解原理uint16_t modbus_crc(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t 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; }逐位法每字节要循环8次数据量大时性能一般。嵌入式Linux主频动辄几百兆其实无所谓但如果你之后要移植到8位单片机上建议换成查表法预先生成256个CRC值每次查表加两次异或就处理完一个字节。查表法的表可以运行时生成也可以直接固化到数组里网上资料很多这里不展开。发送时CRC要低字节在前、高字节在后。以请求帧01 03 00 00 00 01为例计算得到的CRC是0x0A84发送顺序是84 0A。接收端校验时可以对整帧包括CRC两个字节重新计算CRC如果结果为0说明校验通过。这也是Modbus CRC算法的数学特性比“重新计算完再和接收到的CRC比较”少一步操作。我在代码里用的是后者因为可读性更好判断也直观。3.4 浮点数与多寄存器数据组合传感器测出来的温度、压力、流量很多是浮点数。Modbus寄存器是16位一个浮点数32位要占两个寄存器。以IEE754单精度浮点数为例32位分成符号位、指数位、尾数位两个寄存器按顺序拼接起来还原。字节序问题在这里最容易栽跟头。不同厂商的设备寄存器里的字节排列不一样常见的有四种组合寄存器顺序字节序含义寄存器A高字节、A低字节、B高字节、B低字节ABCD大端序A低、A高、B低、B高CDAB小端序B高、B低、A高、A低BADC字节、字都反转B低、B高、A低、A高DCBA字反转大端序对应到C代码里常见的转换写法有两种。假设寄存器值存在regs[0]和regs[1]里/* 方法一内存直接拼接假设小端主机且寄存器按高字节在前 */ uint8_t bytes[4]; bytes[0] (regs[0] 8) 0xFF; bytes[1] regs[0] 0xFF; bytes[2] (regs[1] 8) 0xFF; bytes[3] regs[1] 0xFF; float val; memcpy(val, bytes, 4); /* 方法二位移拼接 */ uint32_t raw ((uint32_t)regs[0] 16) | regs[1]; float val; memcpy(val, raw, 4);实测中我发现如果方法一读出来数值是几百上千万这种明显不合理的就试试方法二再不对就换寄存器顺序。我最后写了个小工具函数输入四个可能的组合结果都打出来一眼就能认出哪个是合理温度值。这个方法强烈建议保留下来换个设备后再也不用对着字节猜。另外有些设备不仅字节序特殊还带符号位或缩放因子。比如压力传感器量程0-100kPa原始值除以100才是实际kPa。这类换算一定要仔细看说明书。3.5 时序要求3.5字符时间与轮询间隔Modbus RTU协议规定帧内字节间隔不能超过1.5个字符时间帧与帧之间至少要有3.5个字符时间的空闲。这个约束是为了防止把前后两帧粘连在一起。9600波特率下一个字符8数据位起始位停止位无校验是11位一位时间是1/9600≈104.17us一个字符约1.146ms3.5个字符约4ms。如果发送端和接收端的帧间隔小于3.5字符时间接收端会把两帧合并成一帧校验直接失败。嵌入式Linux下因为系统调度write数据一般是连续的帧内问题不大帧间问题主要出在你自己代码里连续发送两条请求时要在两条请求之间加至少4ms9600波特率下的间隔。轮询间隔要留足从站的响应时间。老旧仪表处理一条请求可能要几十毫秒有些甚至更慢。我习惯是把超时时间设为200ms到500ms轮询间隔设500ms以上。太快了除了暴露时序问题没有意义传感器数据本身变化也不快。4. 从工具验证到代码实现4.1 先用PC工具打通链路写Linux端代码之前我强烈建议先用PC端的Modbus工具把传感器验证一遍。Modbus Poll是Windows上最常用的主站模拟工具界面直接功能码、地址、数量都可视化配置。连接设置里选串口、波特率、数据位、校验位、停止位再填从站地址和功能码点连接就能看到寄存器实时刷新。关于Modbus Poll的授权网上搜“modbus poll密钥”能找到一堆注册码但很多是失效的而且来源不明的注册机有安全风险。建议直接用官方30天试用版到期后用开源的QModMaster、ModbusScope替代功能完全够用。我后来在Linux主机上调试就是用QModMaster界面比Modbus Poll还清爽。工具验证这一步的作用是确认传感器本身没问题寄存器地址对的数据格式也确认了。这时候再写Linux代码出问题就能锁定在“自己的代码”这一层不至于两边同时出问题排查难度直接减半。我调试时经常是Linux程序、PC工具同时开着连同一个从站比对读出来的数据是否一致。4.2 Python快速原型先用Python写一个快速原型验证协议逻辑比直接写C效率高很多。这里用minimalmodbus库它内部已经封装好了CRC计算和串口读写代码简练import minimalmodbus import time instrument minimalmodbus.Instrument(/dev/ttyUSB0, 1) # 地址1 instrument.serial.baudrate 9600 instrument.serial.bytesize 8 instrument.serial.parity minimalmodbus.serial.PARITY_NONE instrument.serial.stopbits 1 instrument.serial.timeout 0.5 while True: try: # 功能码3读保持寄存器地址0数量2带符号整数 val instrument.read_registers(0, 2, signedTrue) print(raw:, val) # 拼浮点数 raw ((val[0] 0xFFFF) 16) | (val[1] 0xFFFF) data struct.unpack(f, struct.pack(I, raw))[0] # 大端 print(float:, data) except Exception as e: print(error:, e) time.sleep(1)Python的struct.unpack在处理字节序上比C直观得多。f表示大端浮点数I是4字节无符号整数。如果你设备的字节序是CDAB之类改成对应的pack格式解包就行。Python原型跑通了再用C实现目标平台是嵌入式Linux。这样做的原因是嵌入式板子上未必有Python环境而且C程序部署起来更省资源启动快、占用内存小适合做后台服务。4.3 C语言实现串口读取函数C实现分几步走打开串口、配置串口、构建请求帧、发送、等待响应、校验CRC、解析数据。这里给出一个完整的最小可运行框架#include stdio.h #include stdint.h #include string.h #include unistd.h #include fcntl.h #include termios.h #define SERIAL_DEV /dev/ttyUSB0 #define SLAVE_ADDR 0x01 #define BAUDRATE B9600 #define TIMEOUT_MS 500 static uint16_t modbus_crc(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t 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; } static int uart_init(const char *dev) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) return -1; struct termios opt; memset(opt, 0, sizeof(opt)); tcgetattr(fd, opt); cfmakeraw(opt); cfsetispeed(opt, BAUDRATE); cfsetospeed(opt, BAUDRATE); opt.c_cflag ~PARENB; opt.c_cflag ~CSTOPB; opt.c_cflag ~CSIZE; opt.c_cflag | CS8; opt.c_cflag | (CLOCAL | CREAD); opt.c_cflag ~CRTSCTS; opt.c_iflag ~(IXON | IXOFF | IXANY); opt.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); opt.c_oflag ~OPOST; opt.c_cc[VTIME] 5; opt.c_cc[VMIN] 0; tcsetattr(fd, TCSANOW, opt); tcflush(fd, TCIOFLUSH); return fd; } static int read_holding_registers(int fd, uint8_t addr, uint16_t start, uint16_t count, uint16_t *regs) { uint8_t req[8]; uint8_t resp[256]; int len 0; /* 构建读保持寄存器请求帧 */ req[0] addr; req[1] 0x03; req[2] (start 8) 0xFF; req[3] start 0xFF; req[4] (count 8) 0xFF; req[5] count 0xFF; uint16_t crc modbus_crc(req, 6); req[6] crc 0xFF; req[7] (crc 8) 0xFF; /* 发送请求 */ write(fd, req, 8); tcdrain(fd); /* 等待数据全部发出 */ /* 读取响应 */ len read(fd, resp, sizeof(resp)); if (len 5) return -1; /* 响应最短也要5字节异常帧*/ /* CRC校验 */ uint16_t recv_crc resp[len-2] | (resp[len-1] 8); uint16_t calc_crc modbus_crc(resp, len-2); if (recv_crc ! calc_crc) return -2; /* 判断异常码 */ if (resp[1] 0x80) return -3; /* 解析寄存器值 */ uint8_t byte_cnt resp[2]; for (int i 0; i byte_cnt / 2; i) { regs[i] (resp[3 i*2] 8) | resp[4 i*2]; } return byte_cnt / 2; } int main(void) { int fd uart_init(SERIAL_DEV); if (fd 0) { printf(open %s failed\n, SERIAL_DEV); return -1; } uint16_t regs[8]; for (;;) { int n read_holding_registers(fd, SLAVE_ADDR, 0, 2, regs); if (n 2) { uint32_t raw ((uint32_t)regs[0] 16) | regs[1]; float temp; memcpy(temp, raw, 4); printf(temp %.2f\n, temp); } else { printf(read failed, code%d\n, n); } usleep(500 * 1000); /* 轮询间隔500ms */ } close(fd); return 0; }这段代码在绝大多数嵌入式Linux板子上可以直接编译运行。编译命令gcc -o modbus_rtu modbus_rtu.c如果你的板子是ARM架构的交叉编译环境把gcc换成arm-linux-gnueabihf-gcc之类的前缀就行。Debian/Ubuntu主机上可以用gcc直接编译出x86版本先在电脑上跑通再交叉编译到板子。这样调试链路短问题定位快。4.4 多设备轮询与超时处理一条RS485总线挂多个从站时主站按地址依次轮询。代码逻辑不复杂就是逐个地址调用read_holding_registers函数。但我建议加两个设计一是把从站地址和寄存器配置抽成结构体数组方便增删设备二是做好超时和异常计数连续失败多少次要告警不能无限重试。typedef struct { uint8_t addr; uint8_t func; uint16_t start_reg; uint16_t reg_count; float scale; } sensor_cfg_t; sensor_cfg_t sensors[] { {0x01, 0x03, 0x0000, 2, 0.1}, /* 温湿度1原始值/10 */ {0x02, 0x04, 0x0000, 2, 1.0}, /* 压力传感器 */ {0x03, 0x03, 0x0002, 2, 0.1}, /* 温湿度2 */ };读取时循环遍历这个数组每个设备独立计数。某个设备连续3次超时就把它的状态标记为离线继续轮询下一个不要让一个坏设备阻塞整条总线。Modbus是半双工的协议本身没有“同时响应”的机制同一时刻只能有一个设备在说话所以轮询是最稳妥的方式。5. 常见问题与排查实录5.1 485主从机单独测都正常接一起就不通这是最经典的问题我在现场遇到过很多次。单独测主机、单独测从机都正常接一起就没反应原因基本在物理层优先级从高到低排列第一A/B接反。单独测的时候用的是USB转485模块自带的线A/B是明确的接自己的设备时线序一搞混就完蛋。排查方法用万用表量一下两边的A/B电压极性是否一致。空闲时A对B的电压应该是正的2V-6V两边一致才行。第二没共地。RS485是差分信号理论上不依赖地线但实际长线传输时不同设备之间电位差过大会导致共模电压超出发收芯片的容忍范围通常-7V到12V轻则通信不稳定重则烧芯片。一条屏蔽双绞线里挤一根地线把所有设备的GND连起来能解决很多诡异问题。第三缺少终端电阻。总线两端各并一个120欧电阻用于消除信号反射。短距离几十米不加也能通距离一长就不稳定。现场如果只有两台设备把两端的终端电阻都加上最稳妥。第四RS485方向切换时序。这个问题在Linux下常见。如果你用的485模块是RTS控制方向的而应用程序没有设置RS485模式那发送时方向没切过去数据根本没上总线。排查方法用示波器或者逻辑分析仪抓A/B差分信号确认发送时有没有波形。5.2 read超时或收到数据CRC错误链路是通的但程序经常超时或者收到的帧CRC校验不过这个排查顺序我建议这样来先看串口参数是否匹配。从站是9600 8N1你配了19200或者校验位设成了偶校验收到的数据全是乱码。这个用stty命令和示波器都能确认。再看是偶发错误还是规律性错误。偶发CRC错误大概率是电磁干扰检查线缆是不是和动力线走一起了485线要用双绞线。规律性错误比如每次都是最后一个字节错很大概率是字节间隔问题。我在一块板子上遇到过write完立即read接收端把上一帧的最后一个字节和下一帧的请求帧粘连在一起导致请求帧CRC失败。解决办法是read之前先tcflush清掉缓冲。还有一个容易被忽略的点Linux系统调度导致read超时。VMIN0、VTIME5配置下read最多阻塞500ms。系统负载高的时候UART中断处理延迟可能从站响应已经到FIFO里了但read还没来得及执行等执行时数据已经在了一般不会丢。但如果你用的是非标准波特率比如50万VTIME的最小精度是0.1秒可能不够用这时要考虑用select超时机制替代read的VTIME。5.3 Modbus Poll能读到数据Linux程序读不到PC工具正常而自己的程序不正常说明从站没问题问题在自己的代码常见三层原因。第一层是权限和路径。确认/dev/ttyUSB0存在且当前用户有权限用ls -l /dev/ttyUSB0看属主和权限组或者直接sudo运行程序试试。第二层是串口配置。Modbus Poll连接 успешно不代表你程序里的termios配置就对了。最靠谱的办法是在程序里把配置完的串口参数打印出来和Modbus Poll的设置逐项比对。重点检查波特率、校验位、数据位、停止位。第三层是CRC计算或报文构造。我用过一个笨办法程序里把要发送的请求帧按十六进制打印出来和Modbus Poll的“发送报文”面板对照一模一样才能往下查。曾经遇到过CRC的低字节和高字节反了的事情从站直接丢弃就是靠这个办法发现的。5.4 浮点数读出来是天文数字数据能读回来了但数值完全不对常见两种情况寄存器顺序不对。设备手册说湿度存寄存器40001、40002你把40001当高字节、40002当低字节读出来的数当然乱。试着交换顺序或者四个组合都试一遍。我习惯写个小函数把四种组合全部打印出来一眼就能圈定正确结果。符号位问题。有些传感器测负温度时寄存器的值是补码表示的有符号整数。你用无符号方式解析-5℃就变成了65531再按浮点组合就是天文数字。用int16_t去解释寄存器值先把符号位处理对再组合浮点数这个问题就解决了。另外一个新手容易迷糊的点寄存器值本身就带了缩放关系。比如温度测量芯片内部是0.01℃分辨率寄存器值2325表示23.25℃。你要是直接当成2325.0℃读取再拿去拼浮点数结果肯定离谱。先按整数读出原始值再乘以缩放系数这个顺序不要反了。5.5 热词里的“freemodbus”和从站场景参考搜索引擎里经常有人搜“STM32F103通过RS232基于FreeModbus v1.6移植实现Modbus RTU”这类内容的思路反过来也很有参考价值。FreeModbus是从站协议栈跑在单片机上让设备被上位机读写。嵌入式Linux设备如果作为从站被PLC或上位机读写完全可以参考FreeModbus的状态机设计思路一个完整帧的接收状态机IDLE、RECEIVING、COMPLETE配合串口字节中断。而作为主站时我反而建议不引入协议栈直接按本文的手写帧实现。主站逻辑简单直接无非是“组帧-发-等-解析”自己写可控性最强尤其适合轮询多台设备时自定义超时和重试策略。协议栈虽好但引入之后一旦出问题多一层排查成本。主从站分开看选择也不同。6. 几个值得养成的开发习惯6.1 日志里永远打印原始十六进制代码写多了之后我发现最高效的调试手段不是调试器而是日志里那几行十六进制。发送前打印请求帧收到响应打印响应帧CRC算出来也打出来。比对报文是最直接的定位方式。推荐在代码里加两个宏DEBUG开关一开就打印#define LOG_HEX(tag, buf, len) do { \ printf(%s:, (tag)); \ for (int i 0; i (len); i) \ printf( %02X, (buf)[i]); \ printf(\n); \ } while (0)调完一个设备就把调试输出关掉保留错误时的报文打印方便现场抓问题。6.2 写一个可复用的协议封装层做了几个项目之后我发现Modbus RTU主站逻辑高度相似完全可以抽出一个独立的模块文件比如modbus_master.c把串口初始化和Modbus协议分开。后期换平台只改串口底层那几十行协议层不动。一个稳定的协议封装层配合单元测试在新的产品上直接就能复用比每次从头写要省一半时间。6.3 预留余量的重要性嵌入式Linux设备做Modbus从站时上位机可能以各种节奏来读设备端串口接收缓冲一定要足够大。Linux的tty layer有4KB的缓冲一般够用。但你在应用层recv时考虑到主站可能一次发送多条请求比如写参数时先写多个寄存器最好循环read直到超时把这一批请求完整取出再逐条处理。轮询响应时也要预留足够的接收缓冲区最长的RTU响应帧是读125个寄存器时返回252字节数据5字节头尾缓冲区至少256字节起步。做嵌入式这一行串口通信是基本功Modbus RTU又是实际项目里最高频的串口协议之一。把底层原理吃透踩过的坑记下来后面做其他串口协议DL/T645、自定义私有协议、甚至简单的YModem升级都能举一反三。串口的本质就是收发字节流协议层怎么解析都建立在稳定可靠的收发上。这也是我为什么在这篇文章里花大篇幅讲termios配置和CRC校验的原因基础打牢了上层协议怎么变都不怕。
返回列表