ARTICLE DETAIL

资讯详情

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

嵌入式Linux Modbus RTU实战:串口配置、帧格式与C语言实现

嵌入式Linux Modbus RTU实战:串口配置、帧格式与C语言实现 做嵌入式Linux开发迟早会和Modbus RTU打交道。不管是工业现场的温湿度变送器、压力传感器、电表还是农业大棚里的光照度、土壤墒情采集器挂在RS485总线上用Modbus RTU协议通信的设备占了绝大多数。这篇文章我就用实际项目里的完整流程来拆解这件事从串口配置到RTU协议组帧再到从传感器里把数据读回来全程用C语言实现给出可以直接抄走的代码和踩坑记录。无论你是刚接触嵌入式的学生还是被项目追着跑的在职工程师只要手头有一块跑着Linux的开发板想接一个Modbus从机传感器这篇文章都能帮你少走弯路。读完你就能明白Modbus RTU其实是一个非常朴素的协议串口配置也不是什么玄学真正坑人的往往是那些藏在细节里的时序和字节序问题。1. 项目拆解一条数据链路要打通哪些环节很多新手拿到这个需求会直接开始写代码结果往往是串口打不开、数据读不到、CRC校验老失败。我个人的习惯是先把整条链路画出来搞清楚数据从传感器到应用层要经过哪几个环节再逐个击破。1.1 整体架构从传感器到应用层的数据通路这个项目的硬件连接很简单传感器通过RS485总线和嵌入式Linux设备相连如果板子上没有原生RS485接口通常会经过一个USB转RS485模块或者TTL转RS485模块。Linux系统里会多出一个串口设备节点一般是/dev/ttyUSB0、/dev/ttyS0这类。数据流向是这样的应用层程序打开串口设备按照Modbus RTU协议格式组装一帧请求通过串口发送给传感器传感器作为Modbus从机解析请求后返回一帧响应程序再解析这帧响应从寄存器数据字段里提取出我们想要的温度和湿度等工程值。这中间凡是任何一个环节出错都会导致最终数据不对。比如串口参数配置和传感器实际设置不一致请求帧CRC算错或者响应帧解析时字节序没注意都会得到完全错误的结果。所以必须把整个流程拆解成可以独立验证的模块逐个测试通过之后再整合。1.2 为什么是Modbus RTU选型对比与适用场景在工业设备通信领域Modbus是应用最广泛的协议之一它有RTU、ASCII、TCP三种模式。有人会问既然Linux设备网络能力这么强为什么不用Modbus TCP非要走串口的RTU模式这里面有几个很现实的原因。首先是硬件成本RS485总线只需要两根线布线简单、抗干扰能力强在工业现场可以跑上千米而以太网线缆和交换机的成本明显更高。其次是设备端的兼容性市面上大量传感器、电表、仪表都只做了RS485接口加Modbus RTU协议你没法要求一个几百块的变送器去支持以太网。最后是实时性和确定性串口通信没有网络协议栈带来的不确定性在嵌入式Linux上通过合理配置一帧请求的响应时间是可以精确估算的。Modbus RTU还有一个非常大的优势一主多从。一条RS485总线上可以挂多个传感器每个传感器设置不同的地址主机按地址轮询即可。当然这也会引出轮询周期、总线仲裁等问题后面我再说。2. Modbus RTU协议细节帧格式、功能码与CRC校验Modbus RTU本身是一个非常简单的协议但恰恰因为简单很多人反而容易忽略细节。我见过不少同学拿着协议手册看了一遍就上手写代码结果帧格式里从机地址和CRC的顺序搞反读回来的数据怎么都不对。这一节把协议核心部分完整讲透。2.1 RTU帧格式一个字节都不能错Modbus RTU的报文结构非常清晰由地址域、功能码、数据域和CRC校验四部分组成具体如下字段长度说明从机地址1字节范围1~2470x00为广播地址功能码1字节指示执行的操作如03读保持寄存器数据域N字节具体请求/响应的数据内容CRC校验2字节低字节在前、高字节在后这里特别要注意的是CRC字节的顺序。Modbus RTU协议规定CRC16校验值传输时先发低字节再发高字节。举个具体例子如果计算出来的CRC值是0xC40B那么帧里先写0x0B再写0xC4。这个顺序很多人第一次写都会搞反我用一个简单方法记忆接收端校验时把收到的完整帧含CRC重新代入计算函数得到的CRC结果必须等于0x0000否则就是校验失败。帧与帧之间还有时间间隔要求。Modbus RTU规定一帧结束到下一帧开始之间传输时间必须满足至少3.5个字符时间的静默间隔。这个细节在Linux下用系统调用select做超时等待时如果直接把超时设置得太短就可能把从机的响应截成半包导致解析失败。2.2 功能码与寄存器模型03读、04读、06写Modbus协议把设备内部的数据模型分成了四个区分别是线圈Coil、离散输入Discrete Input、保持寄存器Holding Register和输入寄存器Input Register。传感器数据通常放在寄存器区我们最常用的功能码就是03读保持寄存器和04读输入寄存器。这里有个常见混淆点。保持寄存器是可读可写的一些设备的配置参数放在里面输入寄存器是只读的传感器采集到的实时数据通常放在输入寄存器区。但也有些传感器把所有数据都放在保持寄存器里甚至同一个地址在手册里同时标注了03和04功能码的映射关系。所以拿到一个传感器第一件事是查它的通讯协议手册确认使用哪个功能码、起始地址从哪里开始。如果单条读取多个连续的寄存器一次请求最多可以读取125个保持寄存器。请求帧格式是这样的从机地址 0x03 起始地址高位 起始地址低位 寄存器数量高位 寄存器数量低位 CRC低字节 CRC高字节。响应帧则是从机地址 0x03 字节数 数据每寄存器2字节高字节在前 CRC低字节 CRC高字节。2.3 CRC16校验的代码实现CRC16是Modbus RTU协议里唯一的纠错机制用于检测数据在传输过程中是否被干扰。它的计算原理是初始值0xFFFF对帧中从从机地址开始的每个字节低位先与多项式0xA001进行异或运算循环8次。这段代码在任何嵌入式Linux环境下都可以直接编译运行static uint16_t modbus_crc16(const uint8_t *buffer, uint16_t length) { uint16_t crc 0xFFFF; for (uint16_t i 0; i length; i) { crc ^ buffer[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }发送请求帧时对前6个字节从机地址功能码数据计算CRC然后把低字节放到第6位、高字节放到第7位。收到响应帧时对除最后两个字节以外的内容重新计算CRC和响应帧携带的CRC逐字节比较完全一致才说明这一帧数据是可信的。我建议把CRC校验封装成一个独立函数请求和响应的校验都复用它。实际调试中CRC错误往往不是算法写错而是物理链路问题导致的字节丢失或错乱这个问题我会在最后一节详细展开。3. Linux串口配置termios结构体与关键参数在Linux下做串口通信绕不开termios这个系统库。很多刚从单片机转过来的朋友会不太适应因为单片机上配置串口通常只需要改几个寄存器的值而Linux的termios结构体里字段多、概念杂一不小心就被绕晕。其实只要抓住几个核心参数配置并不复杂。3.1 串口打开与基础设置Linux把串口设备当作文件来处理用open函数打开。打开串口时常用的标志是O_RDWR | O_NOCTTY | O_NDELAY。其中O_NOCTTY用于防止串口成为控制终端O_NDELAY则告诉系统打开设备时不要因为等载波信号而阻塞。不过这里有一个初学者容易踩的坑如果使用了O_NDELAY打开后续没有任何额外处理就直接readread可能会立即返回0或者-1。这是因为O_NDELAY实际上让文件描述符处于非阻塞模式。为了避免这个诡异的坑我一般先不带O_NDELAY打开串口等termios全部配置完以后再通过fcntl去设置需要的阻塞或非阻塞模式。串口打开后的第一件事是用tcgetattr获取当前的配置然后修改最后用tcsetattr写回。这个套路和寄存器配置很像就是读-改-写三步。3.2 参数配置详解波特率、数据位、停止位、校验位Modbus RTU最常见的串口参数组合是9600波特率、8数据位、无校验、1停止位缩写为9600 8N1。但实际项目中有的传感器默认波特率是4800、19200甚至115200数据位也可能有7位的特殊情况。所以拿到设备第一件事仍然是查手册搞清楚出厂默认参数。在termios结构体里配置波特率用cfsetispeed和cfsetospeed注意Modbus RTU是半双工通信收发波特率必须一致所以同时设置输入和输出。数据位、停止位、校验位通过c_cflag的位域组合来设置常用代码如下struct termios options; tcgetattr(fd, options); cfmakeraw(options); options.c_cflag | CLOCAL | CREAD; options.c_cflag ~CSTOPB; // 1位停止位 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSIZE; options.c_cflag | CS8; // 8位数据位 cfsetispeed(options, B9600); cfsetospeed(options, B9600); tcsetattr(fd, TCSANOW, options);这里有一个非常重要的细节我建议无条件调用cfmakeraw函数。它的作用是把串口设置成原始模式禁用所有输入输出处理和特殊字符解析。如果不调用它串口可能会对接收到的特殊字节进行处理比如把0x0D转换成0x0A这种自动转换对于我们这种面向二进制协议的通信来说是致命伤。用了cfmakeraw以后数据是什么样进来就什么样出去。3.3 超时控制与数据读取策略串口读取有几个层次。最简单的是直接阻塞read程序会一直等到有数据才返回。这在多线程或严格时序的工业场景里往往不合适因为你不知道从机什么时候响应也不知道响应帧有多长很容易导致程序卡死在那里。我推荐用select加超时控制来读取数据。select的本质是让程序等待文件描述符上的事件最多等待我们设定的时间超时后立即返回。这样既能保证不长期阻塞又能给从机留出足够的响应时间窗口。读取Modbus RTU响应帧的时候还有一个细节需要处理。响应帧的长度我们是已知的因为功能码03/04的响应帧里会包含一个字节数域指示数据部分的字节长度。但下层串口驱动可能一次read只返回部分数据也可能把两次响应的数据合并成一次read返回这就是典型的半包和粘包问题。处理思路是用select等待第一字节到达后根据帧格式推导出总长度再循环读取直到收满整个帧。4. 传感器数据读写实现完整C代码理论部分讲完现在进入动手环节。这一节我以一温湿度传感器为例假设从机地址是0x01温度保存在保持寄存器0x0000湿度保存在保持寄存器0x0001波特率96008N1。我们使用03功能码一次读取两个寄存器然后从响应中解析温度值和湿度值。4.1 串口初始化与基础工具函数串口初始化函数直接复用第三节的配置逻辑只是需要把波特率映射表做出来因为不同环境传入的波特率值需要转换成termios对应的宏。下面是完整的初始化和发送接收基础函数int uart_init(const char *dev, int baudrate) { int fd open(dev, O_RDWR | O_NOCTTY); if (fd 0) { perror(open serial port failed); return -1; } struct termios options; tcgetattr(fd, options); cfmakeraw(options); options.c_cflag | CLOCAL | CREAD; options.c_cflag ~CSTOPB; options.c_cflag ~PARENB; options.c_cflag ~CSIZE; options.c_cflag | CS8; speed_t baud B9600; switch (baudrate) { case 4800: baud B4800; break; case 9600: baud B9600; break; case 19200: baud B19200; break; case 115200: baud B115200; break; default: baud B9600; break; } cfsetispeed(options, baud); cfsetospeed(options, baud); tcsetattr(fd, TCSANOW, options); tcflush(fd, TCIOFLUSH); return fd; }发送函数就是普通的write但注意Modbus RTU要求发送和接收之间必须有间隔发送完不要急着立刻读取给从机留下处理请求的时间。接收函数我用select来做超时控制下面给出的是带超时参数的单次read封装int uart_read_with_timeout(int fd, uint8_t *buf, int buf_size, int timeout_ms) { fd_set fds; struct timeval tv; FD_ZERO(fds); FD_SET(fd, fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, fds, NULL, NULL, tv); if (ret 0) { return 0; // 超时无数据 } return read(fd, buf, buf_size); }4.2 读取温湿度传感器请求、响应、解析全流程现在写核心函数它的作用是向从机发送读保持寄存器请求然后解析返回的数据。代码里我把每一步的细节都做了注释方便对照协议逐字段理解int read_temp_humi(int fd, uint8_t slave_addr, int16_t *temp_raw, int16_t *humi_raw) { uint8_t tx[8]; tx[0] slave_addr; // 从机地址 tx[1] 0x03; // 功能码读保持寄存器 tx[2] 0x00; // 起始地址高位 tx[3] 0x00; // 起始地址低位从0x0000开始读 tx[4] 0x00; // 寄存器数量高位 tx[5] 0x02; // 寄存器数量低位读2个 uint16_t crc modbus_crc16(tx, 6); tx[6] crc 0xFF; // CRC低字节 tx[7] crc 8; // CRC高字节 if (write(fd, tx, sizeof(tx)) ! sizeof(tx)) { perror(write failed); return -1; } usleep(50 * 1000); // 给从机留出处理时间 uint8_t rx[256]; int total_len 0; int timeout_ms 500; // 第一段读取等待首个字节 int n uart_read_with_timeout(fd, rx total_len, sizeof(rx) - total_len, timeout_ms); if (n 0) { fprintf(stderr, no response from slave\n); return -2; } total_len n; // 根据响应帧格式计算总长度 // 响应帧长度 地址1 功能码1 字节数1 数据N CRC2 if (total_len 3) { int data_len rx[2]; int expected_len 3 data_len 2; while (total_len expected_len total_len (int)sizeof(rx)) { n uart_read_with_timeout(fd, rx total_len, sizeof(rx) - total_len, 100); if (n 0) { break; // 超时提前退出 } total_len n; } } // 校验从机地址和功能码 if (rx[0] ! slave_addr) { fprintf(stderr, slave address mismatch\n); return -3; } if ((rx[1] 0x80) ! 0) { fprintf(stderr, slave returns exception code: 0x%02x\n, rx[1]); return -4; } if (rx[1] ! 0x03) { fprintf(stderr, unexpected function code: 0x%02x\n, rx[1]); return -5; } // 校验CRC uint8_t crc_len total_len - 2; uint16_t calc_crc modbus_crc16(rx, crc_len); uint16_t recv_crc rx[total_len - 2] | (rx[total_len - 1] 8); if (calc_crc ! recv_crc) { fprintf(stderr, crc mismatch: calc0x%04x recv0x%04x\n, calc_crc, recv_crc); return -6; } // 解析数据字段每寄存器2字节高字节在前 if (total_len 7) { *temp_raw (int16_t)((rx[3] 8) | rx[4]); *humi_raw (int16_t)((rx[5] 8) | rx[6]); } return 0; }这个函数把整个请求响应周期都覆盖了从组装帧、发请求、等待响应、拼包、地址检查、功能码检查、CRC校验到最终数据解析一整套流程清晰可复现。4.3 多寄存器数据合并与字节序处理解析response里数据字段时最容易犯的错误是字节序处理。Modbus RTU协议规定寄存器数据在传输时高字节在前低字节在后。也就是说收到0x01、0x15两个字节组合出来的值应该是0x0115而不是0x1501。如果把0x0115当整数看十进制就是277。那这个277怎么换算成温度呢要看传感器的协议手册。常见的做法是使用定点数表示比如0.01℃的分辨率那么真实温度就是277 × 0.01 27.7℃。还有些传感器返回的是补码形式的有符号数比如负温度会返回0xFF6A这时如果用uint16_t解析就会得到65386变成非常大的正数正确方式是使用int16_t来接收。如果是读取电表、流量计这类设备可能需要跨字节拼接多个寄存器来组成32位浮点数或者64位长整型具体是按照IEEE 754标准组装还是按整数处理必须以设备手册为准。我在项目里遇到过一个超声波流量计它的瞬时流量是一个32位浮点数存储在4个连续寄存器中顺序是AB CD的寄存器顺序解析的时候我先把4个字节按高字节在前的顺序拼成uint32_t再通过memcpy转成float变量。这一层转换逻辑虽然不复杂但如果不照着手册精确核对数据永远不对。5. 现场调试经验与常见问题处理我始终觉得工程里最耽误时间的不是写代码而是排查那些莫名其妙的通信问题。这一节把我在实际调Modbus RTU设备时遇到的高频问题整理出来给出快速定位和解决的方法。5.1 通讯不上先查物理层地址、波特率、线序如果你发出去的请求帧石沉大海一句话都收不回来先不要怀疑代码去查物理连接。第一个最容易被忽略的问题是设备地址。很多传感器出厂默认地址是1但也有些厂家用的是247、255或者其他值必须确认设备和代码里的slave_addr一致。第二个是波特率。传感器出厂默认值可能是9600也可能被上一个使用者改成19200甚至115200了。如果你不确定用串口助手分别用9600、4800、19200试着发同样的请求帧看哪次能收到回帧收到就说明波特率对了。地址和波特率都排查完还没有响应再测硬件接线。RS485是A/B两根线A接A、B接B接反了虽然偶尔也能收到乱码数据但绝大多数情况是完全没有响应。还有一个容易踩的坑是共地问题。如果传感器和开发板分别用两套电源供电而两个地没有连接在一起RS485通信极不稳定表现为时好时坏、CRC错误率特别高。解决办法是让两个设备的GND连在一起或者干脆使用同一路电源供电。5.2 CRC错误与半包/粘包问题CRC校验失败是Modbus调试中最常见的现象。如果校验算法本身没问题CRC错误往往意味着接收到的字节和传感器实际发出的字节不一致。分析思路是排查干扰。RS485走的是差分信号抗干扰能力已经很强但恶劣的工业环境下还是可能出现信号畸变尤其在电缆过长、又没有终端电阻的情况下。解决手段包括使用双绞屏蔽线屏蔽层单端接地在总线两端各并联一个120欧姆终端电阻。如果你的线缆非常长把波特率从9600降到4800也能显著降低误码率。半包粘包问题更多出在软件处理上。我刚开始写的时候用的是单次read结果一次只读到一部分数据后面直接解析失败。后来改成先计算期望总长度再轮询补齐的方式这个问题就彻底解决了。还有一个额外的细节是从机处理请求需要时间不同厂家的设备响应速度差异很大有的十几毫秒有的上百毫秒。select的超时时间建议设在200到500毫秒之间既不会等太久也不会因为从机响应偏慢而误判为超时。5.3 RS485方向切换与收发时序如果用的板子自带RS485接口GPIO方向切换这个坑很有可能会遇到。RS485是半双工总线同一时刻只能收或者只能发硬件上通过一个RE/DE引脚来控制方向。Linux应用层没法直接操作这个引脚一般是通过给驱动传递某个特殊命令或者操作对应的GPIO来实现。我用过的一个方案是发送完以后主动调用一次usleep让出几十毫秒的时间窗口保证发送方向已经切换到接收方向之后再启动select等待响应。很多主流USB转RS485模块会自动处理方向切换如果你是这类模块不需要额外关心。但如果使用的是板载RS485必须确认驱动和硬件电路的方向控制逻辑。提一个我自己调过的实际问题有一次用板载RS485和传感器通信读取响应老是差一两个字节用示波器量后发现RS485总线上发送帧的末尾被截断了。原因是发送完最后一个字节后程序立刻把RE/DE切换回接收而移位寄存器里的最后一个字节还没完全发出去硬生生被截掉了一部分。解决方法是发送完最后一字节后延迟一个字符时间再切换方向比如9600波特率下一个字符时间大约是1.1毫秒实际给5到10毫秒余量就足够稳定了。在调试过程中还有一个让我印象很深的问题。有一款传感器从机响应速度非常慢需要将近250毫秒才能返回数据。最开始我把select超时设置为200毫秒结果每次都报超时。后来查手册才知道这款设备在接收到请求后要先内部测量再返回数据整个过程需要200毫秒以上。如果你遇到类似情况先把超时时间放大到500毫秒测试稳定之后再缩小优化时序。超时设置不是越小越好太短反而会漏掉正常响应。还有一个处理大字节序问题的实用技巧。如果要对多个寄存器拼成一个浮点数强烈建议先打印每一帧的原始十六进制数据再做转换不要直接跳到最后一步。我的一次调试中温度数据从modbus poll工具读出来是正常的但在我自己写的程序里解析出来就是负数。最后打印hex数据才发现传感器返回的是有符号补码的负温度而我最开始用了uint16_t解析底层的字节并没有丢纯粹是我类型用错了。打印原始数据永远是排查这类问题的第一手段。最后分享一个工具使用的建议。在纯软件调试阶段完全可以先在PC上用modbus poll之类的模拟主机软件连上传感器确认硬件设备和寄存器地址没有问题再写Linux端的代码。这样能帮你把问题一分为二一旦Linux端读数不对问题就锁定在自己的代码侧而不是硬件环境。反过来如果手头没有真实传感器也可以用modbus slave模拟一个从机把自己的代码连上去测试CRC和报文解析逻辑非常方便。我做这类项目的一点体会是Modbus RTU通信的代码量其实不大难点在于对时序的细微把控。从串口参数的确认到CRC的字节顺序再到响应超时的设置每一处都有可能成为bug的来源。按我这篇文章里讲到的流程先确认物理层再验证协议层最后做应用解析一步步排查下来绝大多数通信问题都能在半小时内定位清楚。
返回列表