十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

嵌入式Linux下Modbus RTU主站开发实战指南

嵌入式Linux下Modbus RTU主站开发实战指南 第一次在嵌入式Linux平台上做Modbus RTU的传感器数据采集时我直接照搬了STM32上的那套思路结果串口折腾半天读不出一个有效字节。后来才发现问题根本不在协议而在串口配置——单片机里一句话就能配好的模式Linux下要对着struct termios一个一个抠c_iflag、c_cflag。这个迁移过程让我把Modbus协议和Linux串口工作机制彻底重新捋了一遍。这篇文章就把整个开发链路串起来讲一遍从串口初始化、RTU报文构建、基于超时重试的主站收发逻辑到寄存器数据解析和RS485总线调试。适合正要把传感器采集逻辑从MCU搬到嵌入式Linux上、或者刚开始接触Modbus RTU主站开发的工程师大部分代码可以直接拿去改。1. 嵌入式Linux上的Modbus开发和单片机下到底差在哪1.1 从点寄存器到读写设备节点在STM32上写Modbus主站你的对手是USART外设寄存器设置BRR算波特率、操作CR1使能收发、读SR清标志位中断里一个字节一个字节地往缓冲区塞。到了嵌入式Linux这些底层细节几乎全部被内核串口驱动屏蔽了。你面对的是/dev/ttyS0、/dev/ttyUSB0这样的设备节点操作方式就四个字open、read、write、ioctl。这个转变很多人不适应——以前能准确知道这个字节在哪个时刻发出去了现在你只知道自己调用了write至于数据什么时候真正跑到RS485总线上中间隔了内核的tty层、驱动的缓冲区、可能的DMA你无法精确感知。举个例子在MMU的加持下write返回成功只代表数据进了内核缓冲区不代表已经发完。所以后面我们的主站程序里会用到tcdrain(fd)这个函数的作用就是阻塞等待发送缓冲区里的数据全部从串口硬件上发完。习惯了裸机开发的工程师很容易忽略这一步而它恰恰是半双工RS485通信的关键。1.2 Modbus RTU在Linux里就是纯用户态协议Linux内核并没有提供什么内核级Modbus协议栈Modbus RTU本质上是一套定义在串口物理帧之上的应用层协议你要做的就是在用户态按帧格式打包请求通过串口设备节点发出去再按帧格式解析接收到的字节流。负责任地说这套逻辑放在任何一个有串口支持的操作系统上都能跑所以嵌入式Linux端Modbus的重点反而不是协议本身而是怎么把Linux的串口机制和Modbus的时序要求对齐。比如RTU协议要求帧与帧之间要有3.5个字符时间的空闲间隔否则从站会误把两帧数据当成一帧。在裸机上你可以用定时器精确卡这个时间在Linux用户态里想精确到毫秒以下基本靠不住。所以实际工程里大家普遍采用另一种策略超时判定帧结束。收到一个字节后在设定的字节间隔时间内没有新字节到达就认为这一帧完整了。这个后面会详细写。1.3 多线程带来了从容也带来了新的麻烦Linux下的开发优势也很明显。你可以开一个Modbus轮询线程负责和设备通信再开一个逻辑处理线程处理业务甚至可以把采集数据直接丢进SQLite或者通过MQTT上报。内存不再像MCU那样拮据缓冲区开大一点无所谓日志也可以随便打。但实时性上Linux没有裸机那么可控。CPU调度延迟、系统负载、等待队列唤醒时间都可能让某个读操作晚几百毫秒才返回。所以程序里的超时时间、轮询周期在设计时都要留出余量不能按理想的从站5ms响应去卡否则线上跑起来就是时好时坏的随机故障。2. 动手前先把RTU帧和功能码刻在脑子里2.1 一帧RTU报文长什么样Modbus RTU的报文结构是固定的无论你是主站还是从站线上跑的都是这个格式字段长度说明地址码1字节从站地址范围1~2470为广播地址功能码1字节区分读/写操作如0x03、0x04数据区N字节寄存器地址、数量、写入值等CRC162字节MODBUS专用CRC低字节在前一个典型的读保持寄存器请求是这样从站地址为01用功能码03从寄存器地址0x0000开始读2个寄存器。请求帧就是01 03 00 00 00 02 CRC_L CRC_H总共8个字节。对应的正常响应帧是01 03 04 data_hi data_lo data_hi data_lo CRC_L CRC_H其中0x04是数据字节数等于寄存器数乘以2。注意响应帧的第一个字节一定是从站地址第二个字节一定是你请求的功能码如果这两个对不上直接判为通信异常。这里有一个值得强化的习惯响应帧的长度其实完全可以根据功能码和请求参数推导出来。读单个寄存器时响应帧只有7个字节读N个寄存器时响应帧是5 2N个字节。我们在主站代码里会先算出期望长度再按这个长度收数而不是漫无目的地等待。2.2 该用03还是04寄存器类型不能凭感觉猜Modbus协议里最常用的两个读功能码是功能码含义典型场景0x01读线圈状态开关量输出0x02读离散输入开关量输入0x03读保持寄存器可读可写的参数寄存器0x04读输入寄存器只读的采集数据寄存器很多传感器厂家喜欢把温湿度、压力、流量等实时采集值放在输入寄存器里也就是用0x04去读。但也有一部分设备把这些值放在保持寄存器里用0x03。这个没有统一标准必须看具体设备手册。我遇到过不止一次发0x04请求到某台电力仪表返回异常码0x02非法数据地址换成0x03就正常了。所以项目开始第一步把设备手册的寄存器映射表抄到开发文档里列清楚每个地址是什么量、什么类型、什么单位后面能省掉大量排查时间。2.3 异常响应和CRC校验两个必踩的点当从站收到错误请求时它不会把请求忽略掉而是把功能码最高位置1后回给主站。比如请求功能码是0x03异常响应里就是0x83后面跟一个异常码字节0x01表示非法功能0x02表示非法数据地址0x03表示非法数据值0x04表示从站设备故障。这类异常帧主站程序必须识别出来否则你还会按正常帧长度去等数据结果就是超时。代码里建议先判断func 0x80是异常直接打印错误信息不要再往下解析。CRC校验我用的是MODBUS标准的多项式0xA001查表法虽然快一点但既然主站程序一秒钟也就轮询几十个点逐位计算完全够用代码还简单static uint16_t crc16_modbus(uint8_t *buf, int len) { uint16_t crc 0xFFFF; for (int i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }发送时先发CRC的低字节再发高字节这个顺序千万别弄反。3. 串口配置termios初始化决定通信成败3.1 打开串口的正确姿势嵌入式Linux板上串口设备节点怎么找先用dmesg | grep tty看看驱动注册了哪些设备无非是这几种情况原生的IMX、Rockchip、Allwinner等平台串口一般叫/dev/ttyS0、/dev/ttyS1也可能叫/dev/ttyAMA0、/dev/ttySAC0之类的平台名。USB转串口芯片如CH340、CP2102、FT232通常叫/dev/ttyUSB0。部分4G模组、蓝牙模组内部走的是串口但被驱动重命名成了/dev/ttyUSB2、/dev/ttyACM0这种名字。打开串口时open(dev, O_RDWR | O_NOCTTY | O_NDELAY)是标准写法。O_NOCTTY防止串口成为控制终端导致进程被信号干扰O_NDELAY避免在打开时因为DCD信号问题而阻塞。这时候很多人直接就开始read、write了这是不对的。串口设备打开后的默认属性是行规程模式带各种输入转换和回显必须先通过tcgetattr取到当前struct termios改完再用tcsetattr写回去。3.2 8N1、raw模式、关流控一个都不能少绝大多数RS485传感器出厂默认都是9600波特率、8个数据位、无校验、1个停止位也就是常说的8N1。配置termios时我建议把常用的参数映射成一个函数后续换设备只需要改参数int serial_init(const char *dev, speed_t baud, int parity, int stop_bits) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios tio; memset(tio, 0, sizeof(tio)); tcgetattr(fd, tio); cfsetispeed(tio, baud); cfsetospeed(tio, baud); /* 8个数据位 */ tio.c_cflag ~CSIZE; tio.c_cflag | CS8; /* 无校验 */ tio.c_cflag ~PARENB; tio.c_cflag ~PARODD; /* 1个停止位 */ if (stop_bits 2) tio.c_cflag | CSTOPB; else tio.c_cflag ~CSTOPB; /* 本地连接、使能接收 */ tio.c_cflag | (CLOCAL | CREAD); /* 关闭硬件流控 RTS/CTS */ tio.c_cflag ~CRTSCTS; /* 关闭软件流控转换 */ tio.c_iflag ~(IXON | IXOFF | IXANY); /* 关闭回车换行转换 */ tio.c_iflag ~(INLCR | ICRNL | IGNCR); /* 关闭输出处理不把 \n 转成 \r\n */ tio.c_oflag ~OPOST; /* 关闭canonical模式、回显和信号 */ tio.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); /* 非阻塞读的兜底超时 */ tio.c_cc[VMIN] 0; tio.c_cc[VTIME] 10; /* 1秒 */ tcsetattr(fd, TCSANOW, tio); tcflush(fd, TCIOFLUSH); return fd; }这里最关键的是把ICANON关掉。如果忘了这一条串口会按行缓冲工作你调read想读1个字节它可能要等收到换行符才把数据吐出来Modbus帧里可没有换行符结果就是永远读不够一帧。调试阶段也可以用命令行快速改串口参数和程序配置做对照stty -F /dev/ttyUSB0 9600 cs8 -parenb -cstopb raw -echo cat /dev/ttyUSB0 echo -n -e \x01\x04\x00\x00\x00\x02\x99\x31 /dev/ttyUSB0把串口配置完先不急着写完整程序用这两个命令能快速确认设备是否有响应响应数据里有没有明显的CRC错。3.3 VMIN/VTIME和RS485方向切换c_cc[VMIN]和c_cc[VTIME]是termios里最容易搞晕的两个参数这里说人话VMIN 0read至少要读满VMIN个字节才返回如果数据一直不够会一直等。VMIN 0read不等待字节数只要VTIME超时时间到哪怕一个字节没收到也会返回0。VTIME的单位是0.1秒VTIME10就是1秒超时。在Modbus主站里我们通常把read配置成VMIN0、VTIME1然后在业务代码里用select/poll控制整体响应超时这样read本身永远不会无限阻塞逻辑清晰又安全。还有一个细节如果你的RS485模块需要程序控制方向收发比如板载的SP3485那类收发器DE/RE引脚接了GPIO或者RTS。Linux下用ioctl控制RTS是常见做法int rts TIOCM_RTS; ioctl(fd, TIOCMBIS, rts); /* 置高进入发送模式 */ write(fd, buf, len); tcdrain(fd); ioctl(fd, TIOCMBIC, rts); /* 置低切回接收模式 */很多USB转485模块内部已经做了自动方向切换程序里不需要管。但用板载RS485接口、尤其是从其它硬件平台上搬过来的代码时一定要查清楚底板的收发控制是硬件自动还是软件控制这一项在项目联调阶段被漏掉的概率极高。4. 主站程序基于select超时的RTU读寄存器4.1 轮询流程拆解一个最简的Modbus RTU主站轮询流程是这样的根据从站地址、功能码、寄存器地址和数量构建请求帧。计算CRC并追加到帧尾。通过串口发送请求帧。等待从站响应期间用select做超时控制。校验响应帧的地址、功能码、长度和CRC。在数据区取出寄存器值转换成物理量。休眠一段时间后继续下一轮轮询。其中第4步是整个主站的灵魂也是最容易写崩的地方。很多入门代码直接用阻塞read从站没接或者总线故障时主站就卡死在read上整个采集线程凉凉。所以必须引入超时。4.2 请求帧构建与CRC计算请求帧构建用前面写好的crc16_modbus函数完整读寄存器函数如下int modbus_rtu_read_registers(int fd, uint8_t dev_addr, uint8_t func, uint16_t start_reg, uint16_t num_regs, uint8_t *data_out, int timeout_ms) { uint8_t req[8], resp[256]; uint16_t crc; req[0] dev_addr; req[1] func; req[2] start_reg 8; req[3] start_reg 0xFF; req[4] num_regs 8; req[5] num_regs 0xFF; crc crc16_modbus(req, 6); req[6] crc 0xFF; req[7] crc 8; if (write(fd, req, 8) ! 8) return -1; tcdrain(fd); /* 等数据发完半双工通信必须做 */ int need 5 2 * num_regs; /* 响应帧长度 */ int got 0; while (got need) { struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; fd_set rfds; FD_ZERO(rfds); FD_SET(fd, rfds); int ret select(fd 1, rfds, NULL, NULL, tv); if (ret 0) { /* 超时或出错 */ return -2; } ret read(fd, resp got, need - got); if (ret 0) continue; got ret; } /* 校验CRC和地址 */ crc crc16_modbus(resp, got - 2); if ((resp[got - 2] ! (crc 0xFF)) || (resp[got - 1] ! (crc 8))) return -3; if (resp[0] ! dev_addr) return -4; if (resp[1] 0x80) return -(int)resp[2]; /* 异常码用负数表示 */ if (resp[1] ! func) return -5; /* 拷贝寄存器数据给上层 */ memcpy(data_out, resp 3, need - 5); return need - 5; }这个函数把请求发送、响应接收、CRC校验、异常识别全部封装在了一个调用里返回值含义用负数区分各种错误业务代码里看起来非常清爽。4.3 关于超时和重试我的经验值我把超时时间分成两个维度看单次select超时一般设置200ms到500ms。Linux的调度不确定性比较大设太短容易误判设太长则故障发现慢。重试次数我习惯连续失败3次才报一次错误每次重试之间间隔200ms。如果一失败就疯狂重发在RS485总线上会造成连续占用反而影响其它从站节点。轮询周期则取决于从站的处理速度。很多传感器从站内部要执行AD采样再返回数据实际响应时间可能达到几十毫秒甚至上百毫秒所以主站轮询周期至少要比从站响应时间长否则你上一帧还没等完下一轮就又开始发请求了。我见过有人把轮询周期设成100ms去读一个响应时间200ms的设备总线利用率低不说错误日志刷个不停。4.4 主循环的组织方式实际项目里我会把modbus读取放到一个独立线程读取到的数据放进一个加锁的结构体或者环形缓冲区主线程和业务线程都能访问while (running) { ret modbus_rtu_read_registers(fd, 0x01, 0x04, 0x0000, 2, raw, 500); if (ret 0) { pthread_mutex_lock(lock); temp bytes_to_float(raw[0], raw[1], raw[2], raw[3]); pthread_mutex_unlock(lock); fail_cnt 0; } else { if (fail_cnt 3) { log_error(modbus read failed, code%d, ret); fail_cnt 0; } } usleep(500 * 1000); }这种线程模型不复杂但足够应对绝大多数工业传感器数据采集场景。5. 从寄存器字节流到真实物理量大小端与浮点转换5.1 Modbus寄存器的大端传输与MCU小端存储Modbus协议规定寄存器传输时高字节在前低字节在后。比如一个16位寄存器值0x1234线上先传0x12再传0x34。两个寄存器组合成32位数据时标准顺序是地址小的寄存器放高16位地址大的寄存器放低16位。但是MCU这边ARM和x86都是小端存储所以直接把收到的4个字节按小端拼成一个32位整数数值肯定是错的。必须先按大端拼成数值再交给后续处理。5.2 把4字节拼成float如果传感器手册明确说明数据以IEEE 754单精度浮点格式存储占用两个寄存器那么解析方式是先把4字节按大端拼成uint32_t再按位拷贝到floatstatic float bytes_to_float(uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3) { uint32_t tmp ((uint32_t)b0 24) | ((uint32_t)b1 16) | ((uint32_t)b2 8) | ((uint32_t)b3); float f; memcpy(f, tmp, sizeof(f)); return f; }比如读到4个字节41 A2 70 A4拼成0x41A270A4按IEEE 754解析出来大约是20.305。如果你拿这个函数解析出来的数据是个天大的数字或者NaN大概率不是浮点格式而是设备用的是固定点数格式。5.3 定点数、有符号与字节序变体工业传感器里更常见的其实是定点数。比如某温湿度传感器温度寄存器值0x00C8是200手册说真实值 寄存器值 / 10那实际就是20.0度。0x0007是7实际就是0.7度。负数温度一般采用有符号数表示0xFF38换算成int16_t是-200除以10就是-20.0度int16_t raw (int16_t)((raw_hi 8) | raw_lo); float temp raw / 10.0f;关于字节序还有个更阴间的点有些设备不按标准Modbus顺序会把32位数据的高低位搞反比如地址n存低16位、地址n1存高16位或者寄存器内部低字节先发。我调试过一款气象站的风速传感器手册没写清楚最后是拿一个已知温度值反推出的字节序。所以拿到新设备我强烈建议先查一下它的寄存器字节序是ABCD、CDAB还是DCBA避免在解析环节浪费半天时间。6. 调试三板斧Modbus Poll、回环测试和逻辑分析仪6.1 先用Modbus Poll/Slave把协议调通在还没有真实传感器的时候主站程序可以先跟PC上的Modbus Slave工具对接。这个工具可以虚拟出一个从站设备配置好寄存器值你的C程序发的每一帧请求它都会按规范响应这样能先把CRC、帧解析、超时这些逻辑调干净再上真实设备。反过来如果你的Linux板子是做从站的那用Modbus Poll当主站去读它也能非常快地验证自己的从站代码是否符合协议。这种先虚拟后实物的顺序很重要。当你直接拿真实传感器联调时一旦读到的数据不对问题可能同时在协议层、物理层、传感器配置三处排查起来非常痛苦。先用工具隔离变量把协议层确认无误剩下的问题就往硬件链路排查。6.2 串口回环测试五分钟排除物理层故障用一根杜邦线把TTL串口的TX和RX短接起来或者用USB转串口模块的TX/RX短接发什么就能收到什么。这一步能快速确认串口设备节点能不能正常打开波特率等参数配置是否生效发送接收路径是否完整。如果程序配置正确回环测试时发出去的数据会原样读回来。注意回环测试时CRC校验结果非常直观——你发的是完整8字节请求帧读回来也是完整8字节请求帧CRC自然也是校验通过的这能直接验证你的CRC算法对不对。但这里有个坑如果你用的是RS485转USB模块回环测试不能只短接TTL侧的TX/RX因为485模块的A/B输出是差分信号正确的回环方式是把A和B对接。如果你在TTL侧短接数据压根到不了RS485总线。6.3 逻辑分析仪抓波形让信号说话当串口参数、协议代码看起来都没问题但就是通信不稳定的时候不要用肉眼看串口调试助手的十六进制字符串猜了直接上一个逻辑分析仪。普通的8通道24MHz采样率逻辑分析仪就够用。抓UART波形时把探头接到TTL电平的TX或RX引脚设置好波特率分析仪会直接解出线上每一个字节。看RX引脚的波形你能清楚地知道从站到底有没有回数据、回的数据和请求之间间隔了多久、帧间隔是否大于3.5个字符时间。对于RS485的A/B差分线路逻辑分析仪不太好直接抓但有条件的话可以用示波器看差分波形确认终端电阻是否匹配、信号幅度和沿抖动是否正常。这些问题光看软件协议是发现不了的。6.4 用Python先跑通再移植C我个人的开发习惯是新的传感器到手先用Python快速验证协议跑通了再回头写C代码。Python的pyserial库配上几十行脚本调协议参数非常快import serial import struct import time def crc16_modbus(data: bytes) - bytes: crc 0xFFFF for b in data: crc ^ b for _ in range(8): if crc 1: crc (crc 1) ^ 0xA001 else: crc 1 return crc.to_bytes(2, little) ser serial.Serial(/dev/ttyUSB0, 9600, timeout1) req bytes([0x01, 0x04, 0x00, 0x00, 0x00, 0x02]) req crc16_modbus(req) ser.write(req) resp ser.read(9) # 1地址 1功能码 1字节数 4数据 2CRC print(resp.hex()) if len(resp) 9 and resp[1] 0x04: val struct.unpack(f, resp[3:7])[0] print(sensor value , val)脚本里把串口参数、寄存器地址、功能码拆成变量遇到读不通的情况改一个参数重新跑一次的成本比C代码低得多。等Python脚本稳定了用C重写时逻辑上已经不需要再调试协议问题专注页代码集成就好。7. RS485总线上那些阴间问题各自正常一连就废7.1 A/B接反和共地缺失九成联调故障的原凶很多项目的故障现象是主站接USB转485模块用Modbus Poll调试程序读从站一读一个准把同一套代码部署到Linux板子上板子的485口和同一个从站对接死活读不出来。这种单独都正常、连在一起不正常的故障第一优先级怀疑的就是物理接线。RS485总线虽然叫A和B但不同厂家设备对A/B的定义不完全统一。有的是A对应RX、B对应RX-有的反过来。最简单粗暴的判断方法是设备通电空闲状态下用万用表测A对GND和B对GND的电压。按RS485标准空闲时A相对B为正A对GND大概在2V到5VB对GND大概在-2V到-5V。如果你量到A比B低先交换A/B试试。第二个极容易忽略的是共地。RS485是差分信号理论上不共地也能通信但工业现场环境复杂节点间地电位差一大通信就随机丢帧。尤其当你一边是USB转485模块一边是接24V开关电源的传感器两边电源的负极没有连在一起时运行一段时间就报CRC错误。我现在的习惯是RS485连接永远把GND这根线也接上哪怕产品资料说两线制不需要地线。7.2 终端电阻、波特率与自发自收一个都不能想当然短距离几米内、单主单从的测试环境120欧姆终端电阻加不加一般都能通。但现场布线一长或者总线上接了几个从站终端电阻的影响就出来了。RS485标准要求总线两端各接一个120欧姆终端电阻用来匹配特性阻抗、抑制反射。如果你的接线上只在主站侧焊了一个120欧姆从站侧没有长线通信往往时好时坏。再就是波特率。设备手册写支持9600bps不代表它出厂就是9600。很多传感器默认是9600但也有默认19200甚至115200的。不要想当然用Modbus Poll或者逻辑分析仪确认从站实际波特率再谈别的。还有个特别隐蔽的问题叫自发自收。当RS485模块的收发切换时序没处理好或者模块自己把发送数据环回到接收端时主站在发出请求后会立刻读到自己的请求帧。程序如果没有做帧长度和地址过滤就会把这段回环数据当成响应帧的一部分CRC校验必挂或者读到请求帧后逻辑错乱把真正的响应帧后段当成新的数据。排查方法是把主站的RX线断开再发请求如果你还能从串口读回数据那就是自发自收了。7.3 我用过最顺手的故障排查顺序下面这张表是我在实际项目里沉淀下来的排查顺序每个现象对应可能的根因省得每次遇到问题都从头猜现象优先排查项验证方式完全无响应A/B接反、设备地址错误万用表量A/B电平核对设备拨码时通时断共地、终端电阻、线路长度加GND线检查120Ω终端电阻有响应但CRC错波特率不匹配、自发自收抓RX波形看字节内容偶发超时Linux调度延迟、从站响应慢调大select超时加日志看时间戳数据值异常寄存器地址/功能码选错、字节序不对对照设备手册寄存器表这套顺序解决了我手上至少四个项目的Modbus疑难杂症。遇到故障先别急着改代码把物理层问题先排除掉再回头看协议逻辑效率会高很多。说到最后还是想分享一个我个人的习惯不管时间多紧新设备到手先用Python把协议跑通再写C代码。不是我偏爱Python而是Modbus调试里最难的部分从来不是代码本身而是帧格式、寄存器语义、物理链路这些看不见摸不着的东西。Python脚本改起来快试错成本低等把设备的手感和性格摸透了C代码写起来就是一路顺风。嵌入式Linux端的Modbus开发说到底就是应用层逻辑与硬件时序配合的手艺活把这些基础细节吃透剩下的无非是经验积累和时间问题。
返回列表