嵌入式Linux串口Modbus RTU主站实现:从协议到代码实战
2026/9/7 16:17:30 网站建设 项目流程

做嵌入式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/CTS:

opt.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字节0x0002
CRC低字节1字节0xC4
CRC高字节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字节0x2C
CRC低字节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, timeout=1) 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网关,都能很快上手。核心技术点并不算多,难的是把每个细节都认认真真落实在代码里。这套方案我在好几个项目里都验证过了,稳定跑几个月没出过毛病。如果你正准备上手这个方向,直接按这个思路往下走就行,遇到具体问题欢迎对照着排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询