嵌入式Linux串口Modbus RTU主站实现:从协议到代码实战
做嵌入式Linux开发的朋友总会遇到这么一天老板递过来一个传感器模块扔下一句话“把它接上板子把数据读出来。”然后你一看模块接口是RS485协议是Modbus RTU系统是嵌入式Linux。如果你之前只写过MCU裸机程序或者只在应用层折腾过TCP/IP第一次接到这个需求大概率会有点懵。串口怎么配置、报文怎么拼、校验怎么算、数据怎么解析每一步都有坑。这篇博文就围绕这个场景把嵌入式Linux下通过串口实现Modbus RTU主站、读写传感器数据的完整思路从头到尾盘一遍内容包括串口初始化、RTU报文封装、CRC校验、代码实现以及实测中遇到的各种坑。无论你是刚从MCU转过来的嵌入式工程师还是需要临时接传感器写测试程序的Linux应用开发者都能从中找到可以直接抄的作业。先说下这个项目的基本情况和适用场景。整套方案解决的是“嵌入式Linux设备通过串口/RS485总线以Modbus RTU协议读取传感器数据”这个典型需求。说白了就是让Linux板子扮演Modbus主站传感器作为从站主站发请求帧从站回响应帧一主一从一问一答。这类用法在工业现场、农业大棚、环境监测、设备状态采集等场景非常普遍传感器的寄存器里存着温度、湿度、压力、光照等数据咱们要做的就是把这些原始值读回来转成有物理意义的工程值。整个过程拆开来看就是三个核心环节串口配置、协议组帧、数据解析。这三个环节每个都有不少细节下面一个一个说。1. 整体设计思路别急着写代码先把链路理清楚1.1 从需求看选型确定主站、物理层与寄存器模型接传感器之前先搞清楚三个问题你的设备在Modbus通信里扮演什么角色物理层走的是RS232还是RS485传感器的寄存器地址和功能码是什么这三个问题决定了后续所有的代码写法。绝大多数情况下嵌入式Linux板子做的是主站也就是主动发起请求的一方温湿度传感器、光照传感器这类设备都是从站被动响应请求。从站的设备地址一般由拨码开关或配置工具设置地址范围1到247工厂默认值常见的是1或者0xFF。物理层方面RS485用得最多因为支持多设备挂总线、传输距离远但RS485是半双工读写方向要切换具体后面细说RS232相对简单但只能一对一通信距离也短一些。寄存器模型是沟通的桥梁。Modbus协议把设备内部的数据分成了四类区线圈Coil功能码01/05、离散输入Discrete Input功能码02、保持寄存器Holding Register功能码03/06/16、输入寄存器Input Register功能码04。传感器会告诉你怎么读它的数据有的用03读保持寄存器有的用04读输入寄存器这取决于厂家实现。比如很多温湿度变送器就喜欢把温湿度放在保持寄存器里用03读而一些数据采集模块则把模拟量输入映射到输入寄存器里用04读。具体地址和寄存器数量传感器说明书里一定有“Modbus寄存器表”或“通讯协议”这一章下单前先找客服要一份别等产品到了再干瞪眼。1.2 一次完整的Modbus RTU访问链路把数据从传感器上读回来本质上是一趟来回旅程Linux应用层构造请求帧通过tty串口驱动发送到UART、RS485收发器经过线缆到达传感器传感器解析请求、访问内部寄存器再构造响应帧原路返回Linux侧收到响应帧后做解析校验无误后提取寄存器值再按公式换算成实际测量值。这个链路里任何一个环节出错表现都是“读不到数据”。可能是串口配置不对导致字节流乱码可能是RS485方向切换不及时导致收发冲突可能是CRC算错被从站丢弃可能是寄存器地址写错导致功能码异常还可能是字节序没处理导致数值离谱。把这几个环节分别拆开排查问题其实没想象中那么玄。2. 串口配置先让物理链路通起来2.1 确认串口设备从设备树到节点嵌入式Linux系统上串口设备通常以/dev/ttyS0、/dev/ttyS1、/dev/ttyAMA0或USB转串口的/dev/ttyUSB0等形式出现。不同SoC的设备树里使能了哪路UART系统里就会出现对应的节点。拿到板子第一件事先看桌面级别所有串口ls /dev/tty*如果板载串口被内核检测到一般能看到ttyS0、ttyS1这类节点。若你用的是USB转485模块插上后会出现ttyUSB0。设备树层面确认对应的uart节点状态为okay并且引脚mux正确。很多板子默认只把调试串口打开业务串口需要改设备树重新编译或者用厂商提供的配置工具使能。别以为硬件上引出了排针Linux里就一定有对应的节点两者经常对不上。还有一点容易忽略区分板载UART和USB转串口。如果是USB转串口驱动里用的是cp210x、ch341、ftdi_sio这类模块确认内核有没有把对应驱动编进去否则插上设备后/都没有新节点出现。2.2 termios配置打开串口的正确方式串口操作在Linux里就是操作文件但绝不是open一下就能直接read、write的。必须先把termios结构体配置好否则读写行为完全不可控。一个典型的8数据位、无校验、1停止位8N1配置如下#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 uart failed); return -1; } struct termios opt; tcgetattr(fd, opt); // 设置为原始模式不做行处理、不做回显 cfmakeraw(opt); opt.c_cflag | CLOCAL | CREAD; // 设置数据位8位 opt.c_cflag ~CSIZE; opt.c_cflag | CS8; // 设置无校验 opt.c_cflag ~PARENB; opt.c_cflag ~CSTOPB; // 设置波特率 cfsetispeed(opt, baud); cfsetospeed(opt, baud); // 清空缓冲区 tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, opt) 0) { perror(tcsetattr failed); close(fd); return -1; } return fd; }这里有两个细节新手特别容易踩。第一一定要先tcgetattr拿到当前配置再改直接在结构体上清零后设置很容易把内核里的默认状态搞乱。第二cfmakeraw之后终端就被改成原始模式输入不会被逐行缓冲输出也不会有额外的字符转换这对二进制串口帧来说至关重要。如果忘了这个设置默认的规范模式canonical mode会按换行符分割数据Modbus的二进制帧根本没法完整读到。2.3 波特率、校验位与流控的选择Modbus RTU的物理层参数一般是9600波特率、8数据位、无校验、1停止位写成9600 8N1。工业现场为了抗干扰有时也用19200、38400甚至115200但9600最通用。校验位这块Modbus协议里允许使用偶校验或奇校验但绝大多数传感器默认是无校验。波特率这块我要多说两句。嵌入式SoC的UART外设一般都有独立的时钟源理论上分频后可以获得标准波特率但实际使用中如果APB总线时钟不是整数倍关系实际波特率和理论值会有一点点偏差。短报文没问题如果数据量很大或者线缆很长累积的位偏差就会导致帧错误。所以如果通信不稳定先怀疑波特率精度拿示波器量一下TXD引脚的位宽8N1格式下一位的时间应该是1/波特率秒量出来差距超过3%就要检查时钟配置。流控方面嵌入式产品几乎不用硬件流控代码里显式关掉RTS/CTSopt.c_cflag ~CRTSCTS;如果用的是USB转串口模块模块内部的晶振一般比较准反而比SoC内部的UART时钟靠谱这也是为什么很多调试场景大家都喜欢用USB转485模块。2.4 RS485方向控制半双工模式下必须处理这个坑我见过太多次了。RS485是差分总线同一时刻只能有一方发送所以当Linux侧作为主站时发送完请求帧后必须把RS485收发器从发送模式切回接收模式才能收到从站的响应。这个方向切换有两种实现方式一是硬件自动切换。现在很多RS485模块集成了自动收发电路模块检测到UART发送引脚有数据流就会自动拉高发送使能发完自动切回接收。这种模块接起来省心但上电瞬间或者长帧时偶发不可靠。二是GPIO控制方向。收发器的DE/RE引脚连到SoC的一个GPIO发送前置高发送完延迟一段时间再拉低。Linux下操作GPIO可以用sysfs或libgpiod。为了保证方向切换的时序一般会在串口write之后加一点延时等数据完全从FIFO里发送出去再拉低方向引脚。延时长短和波特率有关9600波特率下一个字节大约1.04ms发送完5个字节至少等5.5ms。简单粗暴的做法是usleep几毫秒但更稳妥的方式是看驱动的tcdrain它会一直阻塞到发送缓冲区全部发送完成tcdrain(fd); gpio_set_value(gpio_fd, 0); // 发送完成切回接收如果你是纯应用层开发、没法改内核驱动常见做法是把控制GPIO和应用串口一起封装成一个小工具库收发前后操作GPIO。这里强调一点tcdrain之后的延时不能省因为RS485芯片本身还有收发切换的响应时间一般再加1到2毫秒更稳。3. Modbus RTU协议报文怎么拼、校验怎么算3.1 RTU报文格式与关键字节序Modbus RTU协议定义了一套紧凑的二进制帧格式请求帧和响应帧除了中间的数据字段基本框架一致地址码1字节 功能码1字节 数据区N字节 CRC校验2字节低字节在前数据区按功能码的不同而不同。读保持寄存器03的请求帧是这样的字段长度值示例从站地址1字节0x01功能码1字节0x03起始寄存器地址2字节0x0000寄存器数量2字节0x0002CRC低字节1字节0xC4CRC高字节1字节0x0B注意Modbus协议里的16位数据都是大端序Big-Endian也就是高字节在前。起始寄存器地址0x0000如果协议文档说温度在寄存器地址1那这里就得写0x0001。寄存器数量2表示要连续读两个寄存器。对应响应帧是字段长度值示例从站地址1字节0x01功能码1字节0x03字节数1字节0x04数据寄存器1高字节1字节0x02数据寄存器1低字节1字节0x2C数据寄存器2高字节1字节0x01数据寄存器2低字节1字节0x2CCRC低字节1字节…CRC高字节1字节…响应帧里字节数字段的值是寄存器数量乘以2这就是原始数据区长度。3.2 CRC16计算位运算法和查表法Modbus RTU的CRC是16位循环冗余校验多项式是x16 x15 x2 1也就是0xA001。算法特点是从最低字节开始逐字节逐位右移计算。初始化时CRC寄存器为0xFFFF。位运算法是最直观的实现uint16_t modbus_crc(const 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计算出来之后按高位在前发送结果从站根本不响应。Modbus RTU规范要求低字节在前记得把结果按小端序写进帧里。查表法是工程上更常用的做法因为嵌入式Linux上虽然CPU不算太弱但传感器轮询频率高时位运算法反复计算还是会有一定开销。查表法思路很简单把256个关键字节对应的CRC增量预先算好编进一个长度为256的uint16_t数组计算时一次循环搞定。网上有大量现成表格不用自己推导。实测下来查表法比位运算法快5到8倍代码长度也就多了256个字的表。3.3 功能码异常与错误响应不是每次读请求都能拿到正常响应。如果请求的寄存器地址越界、寄存器数量太多或者功能码不被支持从站会返回异常响应帧。它的功能码会跟正常响应不同最高位置1比如请求功能码是0x03异常响应的功能码就是0x83数据区是1字节的异常码。常见异常码如下异常码含义常见原因0x01非法功能码从站不支持当前功能码0x02非法数据地址寄存器地址越界或不存在0x03非法数据值寄存器数量为0或超过上限0x04从站设备故障传感器内部错误调试时看到功能码最高位为1立刻就能判断是协议层出错比在那瞎猜串口配置要高效得多。所以收发代码里收到响应帧第一件事不是解析数据而是先检查功能码看是不是异常帧。4. 代码实现完整走通RTU主站读写4.1 用libmodbus快速搭好主站如果你的项目允许引入第三方库直接用libmodbus是效率最高的选择。libmodbus是一个开源的Modbus协议栈C语言实现支持RTU和TCP两种模式接口封装得相当友好。嵌入式Linux上编译安装也简单./configure --prefix/usr/local make sudo make install如果不想装系统也可以把源码里的modbus.c、modbus-rtu.c直接拷进工程里编译。用libmodbus实现RTU主站的代码量极少#include modbus.h #include stdio.h #include errno.h int main(void) { modbus_t *ctx modbus_new_rtu(/dev/ttyUSB0, 9600, N, 8, 1); if (ctx NULL) { fprintf(stderr, modbus_new_rtu failed\n); return -1; } // 设置串口为原始模式关闭RTS/CTS modbus_rtu_set_serial_mode(ctx, MODBUS_RTU_RS485); modbus_rtu_set_rts(ctx, MODBUS_RTU_RTS_NONE); modbus_set_slave(ctx, 1); // 连接串口 if (modbus_connect(ctx) 0) { fprintf(stderr, modbus_connect failed: %s\n, modbus_strerror(errno)); modbus_free(ctx); return -1; } // 设置超时时间 struct timeval tv {1, 0}; modbus_set_response_timeout(ctx, tv); // 读取保持寄存器从地址0开始读2个寄存器 uint16_t regs[2] {0}; int rc modbus_read_registers(ctx, 0, 2, regs); if (rc 2) { printf(reg0: %u, reg1: %u\n, regs[0], regs[1]); } else { printf(read failed: %s\n, modbus_strerror(errno)); } modbus_close(ctx); modbus_free(ctx); return 0; }libmodbus的好处是已经把CRC校验、超时、帧同步这些脏活累活都处理好了对项目时间紧、不想纠结协议细节的场景很合适。但底层串口配置它也是基于termios实现的所以前面讲的串口注意事项依然有效只是封装在库内部了。还有一个必须注意的细节modbus_set_slave(1)要和传感器的实际从站地址一致。如果传感器地址是2这里还写1就会一直收不到任何响应。它的返回值是-1时需要检查errno尤其要注意EBADF串口打开失败和ETIMEDOUT超时。4.2 纯C实现主站的逻辑细节不想引第三方库或者想彻底掌控协议细节可以手动实现一个最小可用的RTU主站。核心就是一个组帧、收发、校验、解析的过程。这里我给一个完整的读寄存器流程int modbus_read_holding_registers(int fd, int slave_addr, uint16_t start_addr, uint16_t num_regs, uint16_t *out) { uint8_t cmd[8]; uint8_t resp[256]; int len; // 组帧 cmd[0] slave_addr; cmd[1] 0x03; cmd[2] (start_addr 8) 0xFF; cmd[3] start_addr 0xFF; cmd[4] (num_regs 8) 0xFF; cmd[5] num_regs 0xFF; uint16_t crc modbus_crc(cmd, 6); cmd[6] crc 0xFF; cmd[7] (crc 8) 0xFF; // 发送 write(fd, cmd, 8); // 等待并读取响应 len read(fd, resp, sizeof(resp)); if (len 5) { printf(response too short: %d\n, len); return -1; } // 校验CRC uint16_t resp_crc modbus_crc(resp, len - 2); if (((resp_crc 8) 0xFF) ! resp[len - 1] || (resp_crc 0xFF) ! resp[len - 2]) { printf(CRC mismatch\n); return -1; } // 检查功能码 if ((resp[1] 0x80) ! 0) { printf(Modbus exception 0x%02X\n, resp[2]); return -1; } // 解析寄存器值 int byte_cnt resp[2]; for (int i 0; i byte_cnt / 2; i) { out[i] (resp[3 i * 2] 8) | resp[4 i * 2]; } return byte_cnt / 2; }这段代码看起来简单实际工程里要加的地方不少。最核心的是read函数。默认情况下串口read如果没有数据会一直阻塞在那边所以要么用select/poll加超时要么设置termios里的VTIME和VMIN。VTIME是read返回前等待的时间单位是0.1秒VMIN是read返回前最少读取的字节数。我们一般将VMIN设为0、VTIME设为10也就是100毫秒超时避免从站无响应时线程被卡死opt.c_cc[VMIN] 0; opt.c_cc[VTIME] 10; // 100ms商用传感器响应时间一般都很短Modbus RTU协议规定从站收到请求后必须在3.5个字符时间内开始响应否则视为超时。9600波特率下3.5个字符大约是4毫秒所以100毫秒的超时对普通传感器足够了但如果走的是远距离无线透传就得把超时时间放宽到几百毫秒。4.3 浮点数与多寄存器拼接读到的uint16_t只是一堆原始值要变成有意义的物理量还得查传感器说明书做转换。有两种常见情况一是简单线性换算比如温度原始值是452除以10就是45.2度二是多寄存器拼接成32位浮点数比如Modbus协议里32位浮点数占两个寄存器高寄存器在前还是低寄存器在前不同厂家习惯不一样。IEEE 754浮点数转4字节这个转换在嵌入式系统里经常要用。假设从寄存器里读到两个16位值regs[0]和regs[1]先拼成4个字节再转float#include string.h #include stdint.h float regs_to_float(uint16_t h, uint16_t l) { uint32_t tmp ((uint32_t)h 16) | l; float f; memcpy(f, tmp, sizeof(f)); return f; }为什么要用memcpy而不是直接强转因为C语言里把uint32_t强制转成float只是重新解释同一块内存的位模式而memcpy是唯一严格符合标准的方式还能避免编译器在开启优化后捣乱。有些平台还要求4字节对齐memcpy天然规避了这个问题。字节序这个坑特别隐蔽。同一个传感器Modbus-RTU读回来高寄存器在前和低寄存器在前最终浮点数完全不一样一个可能读出几千倍的值一个又正常。所以拿到新传感器第一次解析浮点数据时一定要对照说明书确认寄存器字节序而不要假设全都一样。5. 调试与避坑实测中遇到的典型问题5.1 常见问题速查表现象可能原因排查思路read超时无任何响应从站地址不对、串口配置错误、RS485方向未切换先用PC调试工具发请求确认传感器地址和响应用示波器量UART TX电平收到的数据全是乱码波特率不匹配、校验位/停止位不一致核对termios配置9600 8N1是否真的设对了偶发读到错误数据电气干扰、RS485接地问题、线缆过长检查屏蔽双绞线、终端电阻是否安装CRC校验失败线上有干扰、帧长度不对、通讯距离过长抓取原始字节流人工计算CRC和接收到的CRC对比能发能收但值不对寄存器地址错了、字节序或浮点解析错误拿已知值验证解析代码先打印寄存器原始值5.2 调试工具与抓包思路嵌入式Linux下调试Modbus最实用的工具组合是PC上的Modbus调试软件加上逻辑分析仪或者示波器。先用PC的USB转485模块接传感器用第三方调试软件发请求确认传感器本身工作正常、寄存器地址和数据类型都对再回过来查Linux板子的代码。Linux命令行下最好的调试工具是tcpdump吗不是串口调试要用socat、minicom或者写一个简单的Python程序。临时抓串口字节流用Python最方便import serial ser serial.Serial(/dev/ttyUSB0, 9600, timeout1) ser.write(bytes([0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B])) data ser.read(20) print(data.hex())把十六进制的收发数据打印出来一条一条比对协议文档比一头扎进C代码里猜要高效得多。这套方法我到现在还在用排查新传感器时屡试不爽。5.3 轮询时序与多从站场景提防死等如果总线上挂了多个传感器主站就得按一定顺序轮流给每个从站发请求这就是轮询。轮询要注意两点一是每个从站的请求之间留出足够间隔Modbus协议没强制规定但最快也要等上一个从站响应完了再发下一个可以用固定的调度周期比如100毫秒轮一次所有从站二是某个从站掉线时不能让主站程序卡死在那个站上给每个从站一个最大超时时间超时后就跳到下一个从站同时记录错误状态别让单个传感器故障拖垮全总线。多从站的另一个隐藏问题是设备地址冲突。如果两个传感器的拨码地址都设成了1总线上一收到地址为1的请求两个传感器都会响应数据直接互相踩踏。这个问题在现场特别容易出现排查方法也简单把所有传感器依次断开一个一个接入总线看每个都能否正常应答。轮询周期和响应超时是一对需要权衡的夫妻。周期太短、超时太长单轮耗时就会拉长后面从站排队等得心慌周期太长、超时太短又可能误判慢传感器的响应。我一般先定超时200毫秒周期500毫秒稳定后再逐步压周期减到200毫秒左右再往下降就容易出问题。6. 收尾还是要留一手串口权限与开机自启代码全部写完、调试通了还有一个容易被忽略的要点嵌入式Linux下的非root用户能不能正常打开串口设备。很多开发板上/dev/ttyUSB0的默认权限是root:dialout或root:tty普通用户打开会报Permission denied。解决办法是把当前用户加入dialout组sudo usermod -aG dialout $USER或者直接用chmod改设备权限但产品落地时一般会在udev规则里统一处理让它固定权限并生成稳定的符号链接比如/dev/sensor_bus。具体做法是写一个/etc/udev/rules.d/99-usb-serial.rules绑定设备的 VID/PID把权限设成0666并创建软链接。这样不管插在不同的USB口上设备节点永远都是同一个程序里硬编码路径也不会出问题。还要提一句开机自启。传感器采集程序通常要常驻后台这时候别直接用nohup跑而是写一个systemd服务单元配置好依赖关系让它在网络服务就绪后再启动通过sd_notify上报心跳。就算程序崩溃了systemd也能帮你自动拉起比裸跑shell脚本不知道高到哪里去了。从我的实际经验来看嵌入式Linux下做Modbus RTU开发硬件决定下限协议理解决定上限。把串口配置、RS485方向控制、CRC计算、帧解析这些基础打牢无论以后是接几十种传感器还是升级成Modbus TCP网关都能很快上手。核心技术点并不算多难的是把每个细节都认认真真落实在代码里。这套方案我在好几个项目里都验证过了稳定跑几个月没出过毛病。如果你正准备上手这个方向直接按这个思路往下走就行遇到具体问题欢迎对照着排查。