
在做嵌入式Linux的项目时我经常被同事问到一个看似基础、实际上能把人折腾到怀疑人生的问题Modbus RTU 读传感器数据串口配置到底怎么搞才稳问的人往往已经踩过“单测主机正常、单测从站正常、两边一连就不正常”的经典大坑。这篇文章就把我这几年在嵌入式Linux上折腾 Modbus RTU 的经验翻出来从串口设备节点、termios 参数到协议帧解析、浮点数转换再到 RS485 方向切换和真机联调一条线讲清楚给正在做类似项目的朋友当一份可以直接照着干的参考。1. Linux串口配置设备节点、termios与权限一个都不能省1.1 找到正确的设备节点别被 ttyS0 和 ttymxc0 搞懵嵌入式 Linux 下串口设备节点的命名没有统一标准得看内核里注册的是哪种串口驱动。很多刚上手的人习惯性打开/dev/ttyS0结果发现读不到数据原因是用的开发板串口设备节点根本不叫这个名。不同平台常见的命名规律是全志、瑞芯微、部分高通平台/dev/ttyS0、/dev/ttyS1这类标准 8250 风格。NXP i.MX 系列/dev/ttymxc0、/dev/ttymxc1。STM32MP1 平台/dev/ttySTM0、/dev/ttySTM1。树莓派原生 UART/dev/ttyAMA0。USB 转串口/dev/ttyUSB0或/dev/ttyACM0这个比较容易被认出来。拿到一块新板子我建议不要猜直接查这几个地方。cat /proc/tty/drivers能看到内核当前注册了哪些串口驱动以及对应设备名前缀ls /dev/tty*看实际生成的节点dmesg | grep tty看驱动注册时打印的信息。如果是设备树配置的串口还能在设备树源码或/sys/firmware/devicetree/base/soc/...下找到 serial 节点的 status 是否设置为 okay。注意一点很多 ARM 平台把 UART 的 pin mux 默认配置成了 console 或别的功能设备树里不改 pinmuxapp 层打开设备节点并不会报错但引脚根本没有连到收发器上自然收发都是空的。这个隐藏在设备树层面的坑比报文解析难查得多。1.2 termios 参数配置直接给可用的 C 代码嵌入式 Linux 上配置串口核心是操作 termios 结构体。Modbus RTU 最常见的串口参数组合是 9600 8 N 1即波特率 9600、8 个数据位、无校验、1 个停止位。实际项目中我也会遇到 19200、115200 的传感器还有个别设备默认 7E1需要灵活处理。下面这段是我习惯用的串口初始化函数包含了对 Modbus 场景比较关键的细节#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include string.h int uart_set_attr(int fd, int baud) { struct termios tty; if (tcgetattr(fd, tty) ! 0) { perror(tcgetattr); return -1; } cfmakeraw(tty); tty.c_cflag | (CLOCAL | CREAD); // 忽略调制解调器控制线使能接收 tty.c_cflag ~CSTOPB; // 1位停止位 tty.c_cflag ~PARENB; // 无校验 tty.c_cflag ~CRTSCTS; // 关闭硬件流控 tty.c_cflag | CS8; // 8位数据位 tty.c_cc[VMIN] 0; // 不要求最少字节数 tty.c_cc[VTIME] 1; // 读超时 0.1秒 cfsetispeed(tty, baud); cfsetospeed(tty, baud); if (tcsetattr(fd, TCSANOW, tty) ! 0) { perror(tcsetattr); return -1; } return 0; }有几点我必须单独拎出来讲。第一cfmakeraw会把 ICANON、ECHO、ISIG、IEXTEN 全部关掉读串口时不会因为收到0x0D0x0A 等字符被内核预处理Modbus 帧里的任意字节都不会被误处理。但它不会自动关 CRTSCTS 流控所以我在后面手动关了。如果不关某些主控的 UART 在 CTS 引脚悬空时会卡住发送这是很隐蔽的问题。第二VMIN 和 VTIME 的搭配。VMIN0 表示只要缓冲区有数据就立即返回VTIME1 表示最多等 0.1 秒。这两个参数组合对 Modbus 的帧接收超时很关键后面讲接收状态机时我会再展开。第三tcflush(fd, TCIOFLUSH)在发送请求前清空读缓冲区和写缓冲区。因为串口缓冲区里可能残留上一次的脏数据不清会直接影响本次响应解析。我在每次读请求前都会调用一次。1.3 命令行列速排查stty 是你最好的临时工写代码之前先用命令行验证串口能不能通可以省掉很多无谓的编译调试。stty 是 Linux 自带工具直接设置串口参数stty -F /dev/ttyS1 9600 cs8 -cstopb -parenb raw -echo设置完可以用stty -F /dev/ttyS1 -a查看当前参数确认这些位都正确。然后可以用cat /dev/ttyS1挂在后台再用echo往串口发几个字节配合串口助手的回环或者逻辑分析仪看波形。这个办法尤其适合判断“到底是 app 配置错了还是根本没数据进来”。另外串口设备的访问权限也值得提前确认。很多系统里普通用户没有权限打开/dev/ttyS*。临时验证时可以chmod 666 /dev/ttyS1正式项目我建议创建 udev 规则把串口设备加入 dialout 用户组或者直接给指定用户授 ACL 权限。千万别在业务代码里用 system() 去提权那是给后续维护埋雷。2. Modbus RTU 帧格式与接收时序状态机比协议栈重要2.1 把 Modbus RTU 帧拆开看Modbus RTU 的帧结构非常简单核心就是四段从站地址、功能码、数据、CRC16 校验。一个读保持寄存器的请求帧例如从站 1 读取从寄存器地址 0 开始连续 2 个寄存器十六进制长这样01 03 00 00 00 02 C4 0B拆开来看01从站地址 1。03功能码读保持寄存器。00 00起始寄存器地址大端表示。00 02寄存器数量。C4 0BCRC16 校验注意 CRC 在帧里是低字节在前所以C4 0B表示 CRC 数值为 0x0BC4。功能码是理解 Modbus 通信的重中之重。日常传感器项目里我常用到的就几个功能码含义典型用途0x03读保持寄存器读可读写的寄存器0x04读输入寄存器读只读传感器数据0x06写单个寄存器设置参数0x10写多个寄存器批量写参数/校准值很多温湿度、压力、光照传感器会把实时测量值放在输入寄存器里用 0x04 读把配置项放在保持寄存器里用 0x03 读、0x06 或 0x10 写。拿到陌生的传感器建议先读它的 Modbus 寄存器表搞清楚每个地址的数据类型和读写属性别上来就是一个 03/04 轮询。2.2 CRC16 计算与发送字节序CRC 校验是整个帧里最不能写错的部分。Modbus 协议使用 CRC16-IBM多项式 0xA001从从站地址一直算到数据段最后一个字节不包含 CRC 本身。计算代码如下uint16_t modbus_crc16(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 低字节放前面、高字节放后面。好多刚开始写的人在这里栽跟头发出的帧校验值反了从站直接忽略。2.3 接收时序与“字节间隙”问题Modbus RTU 没有像 TCP 协议那样的长度头帧与帧之间完全靠时间间隔来划分。协议规定两个相邻帧之间的空闲间隔至少要 3.5 个字符时间帧内相邻字节间隔不能超过 1.5 个字符时间。这里涉及到一个嵌入式 Linux 用户态开发者很容易忽略的现实问题Linux 不是实时系统内核调度抖动可能达到几毫秒甚至十几毫秒。波特率 9600 时 1 个字符大约 1ms3.5 个字符约 3.5ms波特率 115200 时 1 个字符约 86 微秒3.5 个字符约 0.3ms。在内核里这个精度可能还能保证但在用户态靠 read() 来精确卡 3.5 个字符时间几乎不可能。我的做法是用分层超时策略select 或 poll 等待第一个字节到达设置总超时 200ms 到 1s收到第一个字节后后续字节通过一个 5~10ms 的“帧结束判定超时”来判断一帧是否结束。原因很简单宁可把两帧之间的间隔判断得宽松一点也不能把一帧拆成两半。如果传感器在同一毫秒内连续回复 40 字节的帧5ms 内肯定能全部到达即便内核调度偶尔抖动10ms 也基本够用。2.4 一个可以落地的接收状态机思路接收状态机不复杂关键帧状态分这么几步空闲态等待 RX 第一个字节同时启动总超时。接收态收到第一个字节后记录时间继续读。帧间隔判断两次 read 返回之间的时间差超过 5~10ms认为当前帧收完。解析态对收完的完整帧做地址匹配、长度校验和 CRC 校验。错误处理如果收到的是半截帧或者 CRC 错误清空缓冲重新回到空闲态。给一段核心循环示例uint8_t rx_buf[256]; int rx_len 0; struct timeval last_tv; while (1) { fd_set rdfs; struct timeval tv; FD_ZERO(rdfs); FD_SET(fd, rdfs); tv.tv_sec 0; tv.tv_usec 10000; /* 10ms */ int ret select(fd 1, rdfs, NULL, NULL, tv); if (ret 0) { int n read(fd, rx_buf[rx_len], sizeof(rx_buf) - rx_len); if (n 0) { if (rx_len 0) { gettimeofday(last_tv, NULL); } else { struct timeval now; gettimeofday(now, NULL); long diff_ms (now.tv_sec - last_tv.tv_sec) * 1000 (now.tv_usec - last_tv.tv_usec) / 1000; if (diff_ms 5) rx_len 0; /* 超时重启帧 */ last_tv now; } rx_len n; } } else { if (rx_len 0) { /* 认为帧结束开始解析 */ parse_modbus_frame(rx_buf, rx_len); rx_len 0; } } }这个代码在实际项目中跑了很多年注意 select 超时选 10ms帧内间隔阈值选 5ms两个值不冲突。波特率很高时可以把阈值降低到 2ms但系统负载高时容易误拆帧这个需要现场调。3. 读写传感器数据的完整实现与浮点数转换陷阱3.1 构造读请求并解析响应读取保持寄存器请求的构造比网上很多代码写得都干净直接看函数int modbus_read_regs(uint8_t slave, uint8_t func, uint16_t start, uint16_t count, uint8_t *frame) { int len 0; frame[len] slave; frame[len] func; frame[len] start 8; frame[len] start 0xFF; frame[len] count 8; frame[len] count 0xFF; uint16_t crc modbus_crc16(frame, len); frame[len] crc 0xFF; frame[len] crc 8; return len; }发送后接收响应响应帧的结构如下从站地址1 字节应与请求一致。功能码1 字节应为请求功能码。字节数1 字节表示后续数据字节数。数据区n 字节寄存器值按顺序排列每寄存器 2 字节。CRC2 字节。读取 N 个寄存器的响应数据区长度为 2*N。收到响应后先做从站地址匹配再做长度判断最后算 CRC 是否正确。只要这三步都通过基本可以认为这一帧是合法响应。还有一个容易忽略的地方异常响应。如果请求了不存在的寄存器地址从站返回的帧类型是异常响应功能码会带上最高位 0x80。例如收到81 02 C3 F1说明从站报告“非法数据地址”。常用的异常码我整理成表格排查现场问题非常有用异常码含义常见原因0x01非法功能码从站不支持该功能0x02非法数据地址寄存器地址越界0x03非法数据值写入值超出范围0x04从站设备故障从站内部异常0x06从站忙需要等待后重试0x08存储奇偶性错误从站存储区异常3.2 读温湿度传感器的完整示例假设有一个传感器从站地址 1温度以浮点数形式存放在输入寄存器 0 和 1湿度存放在输入寄存器 2 和 3。读取它们的流程是构造 04 功能码读 4 个寄存器的请求发送等响应解析出 8 字节数据再把每 4 字节组合成两个 float。下面是带超时和重试的完整读取函数int read_sensor_data(int fd, uint8_t slave, float *temp, float *hum) { uint8_t tx[8], rx[256], crc_hi, crc_lo; int tx_len modbus_read_regs(slave, 0x04, 0, 4, tx); uint16_t crc modbus_crc16(tx, tx_len); tx[tx_len] crc 0xFF; tx[tx_len] crc 8; tcflush(fd, TCIOFLUSH); write(fd, tx, tx_len); int rx_len recv_frame(fd, rx, sizeof(rx), 500); /* 500ms总超时 */ if (rx_len 5) return -1; if (rx[0] ! slave) return -2; if (rx[1] 0x80) { printf(slave exception code%02X\n, rx[2]); return -3; } /* 校验CRC */ if (rx[rx_len-2] ! crc_low || rx[rx_len-1] ! crc_high) return -4; /* 解析假设高字在前大端存储 */ uint16_t reg[4]; for (int i 0; i 4; i) { reg[i] (rx[3 i*2] 8) | rx[3 i*2 1]; } *temp regs_to_float(reg[0], reg[1], 0); *hum regs_to_float(reg[2], reg[3], 0); return 0; }3.3 4字节转浮点字节序和字序的排列组合“将4字节数据转换为浮点数”是我见过被检索最多的关键词之一可见这个坑有多普遍。Modbus 寄存器都是 16 位一个 float 32 需要占两个寄存器。问题在于不同厂家定义的寄存器顺序和字节顺序完全可能不同。假设 float 的 4 个字节在小端机器上内存排列是cd ab 00 00按大端寄存器读取时常见的排列方式有以下几种排列方式寄存器0寄存器1实际字节序高字在前0x41A00x0000AB CD 00 00低字在前0x00000x41A000 00 AB CD高字在前且每字内字节交换0xA0410x0000CD AB 00 00低字在前且每字内字节交换0x00000xA04100 00 CD AB我在现场见过大多数国内传感器是高字在前也就是0x41A0 0x0000这种但也有相当一部分设备默认低字在前。我习惯写一个可配置字序的函数调试时通过配置决定是 0 还是 1float regs_to_float(uint16_t reg0, uint16_t reg1, int swap_words) { uint32_t bits; if (swap_words) bits ((uint32_t)reg1 16) | reg0; else bits ((uint32_t)reg0 16) | reg1; float f; memcpy(f, bits, 4); return f; }如果字序对了但数据仍然不对比如温度在 20 度却读出 5 亿这种天文数字那大概率是每个寄存器内部的 2 个字节也做了交换。这时需要在读取 reg 时对字节重新排列先把(rx[3]8)|rx[4]变成(rx[4]8)|rx[3]。最稳的办法是读回一段已知的寄存器数据对照设备手册确认顺序而不是靠猜。3.4 还有一类坑整数带符号和缩放因子不是所有传感器数据都返回 float。很多设备把温度放在一个有符号 int16 寄存器里单位是 0.01 摄氏度读出来数值是 2035实际温度是 20.35 度。处理这类数据时类型转换要注意符号扩展。C 语言里直接int16_t val (int16_t)reg;即可但如果你用uint16_t去接收再强转低端平台行为还挺容易出错的。建议定义寄存器变量直接用uint16_t但赋值给整型业务变量时显式用(int16_t)转换。4. RS485方向切换与信号完整性单测都正常一连就废的根因4.1 为什么会出现“主机正常、从站正常、连起来不正常”这是 Modbus 485 现场最经典的现象。单独把 USB 转 485 接传感器Modbus Poll 能正常读到数据把 Linux 开发板的 485 接传感器怎么都读不到。问题几乎不在协议栈而在硬件和电平转换这一层。RS485 是半双工总线A/B 两线差分传输主机在发送请求后必须把发送器关闭、接收器打开才能收从站的响应。如果方向切换时序没做好发送还没结束就切到接收最后一个字节会在总线上被截断或者发送完后很长时间又没切回来从站的响应当主机根本没收。我用 RS232 和 RS485 的对比来理解这件事RS232 是点对点全双工RX 和 TX 各一根线根本不存在“方向切换”这一说RS485 上同一根线既要发又要收方向控制才是真正难的点。所以做嵌入式 Linux 的 Modbus RTU90% 的怪问题最后都能归到方向切换上。4.2 Linux下控制RS485方向有驱动的用TIOCSRS485没驱动的用GPIO现在不少 ARM 平台的串口驱动已经支持 RS485 模式内核里配置好之后可以通过串口 ioctl 直接操作。#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_AFTER_SEND; rs485conf.delay_rts_before_send 0; rs485conf.delay_rts_after_send 0; if (ioctl(fd, TIOCSRS485, rs485conf) 0) perror(TIOCSRS485);这个方式最省心的地方是驱动会在 FIFO 数据发送完成后自动处理 RTS 引脚的时序不需要在用户态精确卡时间。但不是所有内核驱动都完整支持该 ioctl有的芯片的 BSP 根本没实现。判断方法很简单调用后如果返回 ENOTTY 或 ENOTSUP就说明当前驱动不支持只能走 GPIO 方向控制路线。GPIO 方式下发送前把方向引脚拉高假设高电平为发送发送完后先tcdrain(fd)确保串口 FIFO 数据已经全部移位输出再拉低方向引脚int rs485_dir_tx(int gpio_fd, int en) { if (en) gpiod_set_value(gpio_fd, 1); else gpiod_set_value(gpio_fd, 0); return 0; } /* 发送流程 */ rs485_dir_tx(gpio_fd, 1); write(fd, tx, tx_len); tcdrain(fd); /* 等待数据完全发送出去 */ rs485_dir_tx(gpio_fd, 0);tcdrain是用户态代码里最可靠的“等到物理发送完成”的办法。它等的是内核的字节已经全部交给 UART 控制器并移位输出完毕不是 write 函数返回。很多人不知道这一点write 返回就立刻切方向导致最后一两个字节被截断CRC 校验永远不过。4.3 接线和总线端接的硬道理方向控制没问题仍然通信不上的时候就该怀疑物理链路了。先去确认 A/B 有没有接反。TIA-485 标准里 A 脚相对 B 脚为负时表示逻辑 1但很多厂家端子排 A、B 的标注习惯不一致。我通常的做法是用万用表电阻挡量不出什么因为总线是电压差分只能先按标注接不行就调换 A/B 一试。另外接好之后测一下空闲状态下的 A-B 电压正常应在 0.2V 到 0.6V 以上偏置电阻压差。如果只有几十毫伏说明总线上缺偏置对干扰敏感容易误码。终端电阻也需要重视。一条总线不管挂几个设备理论上只在首尾两端各接一个 120 欧。短距离测试几米以内不接终端电阻也能通但一旦距离超过几十米或现场有变频器干扰不接终端电阻的后果就会集中爆发成随机 CRC 错误、偶发超时。4.4 回显与自身响应干扰还有一个容易被忽略的情况如果板子的 485 收发器在发送时把数据回环到了接收端或者驱动配置了 local loopback主机会把自己刚发的请求帧当成响应帧那接收状态机永远会处理出一堆 CRC 错的垃圾。对这种问题的快速判断方法是发一帧请求后在接收缓冲区里第一段能看到和请求完全一样的字节。如果出现这种情况检查两处一是内核或驱动有没有开回环模式二是部分廉价 485 模块的“自动收发切换”设计会把发送波形的尾巴漏进接收端。解决思路就是软件过滤在解析阶段把缓冲区和发送帧对比如果完全一致就丢弃这段数据继续等真正的响应。5. 现场联调实录从模拟从站到真实传感器的验证路径5.1 别去找破解版工具开源方案完全够用很多调试文章一上来就让装 Modbus Poll/Slave然后弹出来“输入注册码”就卡住了。其实在嵌入式Linux命令行的场景下开源工具足够完成设备验证。比如mbpoll可以直接从源码编译也可以在很多发行版软件源里直接安装。用法很直观# 读从站1的输入寄存器从地址0读4个寄存器串口/dev/ttyS1波特率9600 mbpoll -a 1 -t 4 -r 0 -c 4 /dev/ttyS1 -b 9600 -p none如果你想验证自己写的从站逻辑可以用 Python 的 pymodbus 在另一台设备上模拟一个从站也可以用一个叫 diagslave 的命令行工具。模拟从站的关键是设置好寄存器区的初始值比如在寄存器 0/1 放一个 float 温度值然后用你自己写的主站代码去读这样就能控制变量单独验证协议栈。5.2 三层排查法物理层、链路层、应用层真机连不上时千万别拿着代码反复改 CRC那是最低效的。我现场调试有一套固定顺序大家可以直接抄。第一步物理层验证。用示波器或逻辑分析仪抓 A/B 两线之间的差分波形确认请求帧有没有发出去、波形幅度和边沿质量如何。没有示波器的话可以先用一个USB转485模块接电脑用串口助手看 Linux 板卡发出的字节内容。如果请求帧都出不来问题在 Linux 侧如果请求帧正常而传感器无响应先把传感器通过 USB 转 485 接到 PC 上用 Modbus Poll 或者自己写的小脚本重发同样的请求。此时能通说明传感器没问题问题在 Linux 板卡的物理层或方向切换此时也不通那就是传感器端的地址、寄存器地址、波特率有偏差。第二步链路层验证。如果物理层通了但读不到数据打开 hexdump 对收发的每个字节做记录cat /dev/ttyS1 | hexdump -C或者直接在应用代码里打印每次收到的原始帧。重点看响应帧长度对不对、CRC 对不对。如果 CRC 偶发错优先确认 RS485 方向引脚是否在 tcdrain 之前就切回接收了如果 CRC 全错八成是波特率、数据位、校验位没有和从站对齐。第三步应用层验证。物理链路和 CRC 都对但读出来的值不对这时候回到寄存器映射。先读固定地址看返回值是否在合理范围然后根据设备手册验证字节序和浮点数模型。我之前调试一款气体传感器寄存器表里明明写的是 float读回来总是 0后来才发现寄存器地址是基于 0 还是基于 1 的偏移问题。5.3 主从联合调试时的几个实用建议在我过手的多个嵌入式 Linux 项目中最后联调时最容易出问题的还是一些细节。分享几个我踩过之后养成的习惯轮询多个从站时每个从站单独设置响应超时时间。设备挂死时一个标准超时是 500ms三个设备挂死会吃掉 1.5s轮询周期被拖慢到不可接受。我会把超时分成两档正常设备 200ms 内必须响应超过 3 次重试直接标记该从站离线不阻塞后续从站轮询。日志里记录原始帧而不是只记录解析结果。现场设备距离远、环境干扰大很多规律只有把原始数据拉出来看才能发现。建议每个请求和响应的原始 hex 都打出来至少上线阶段保留打印。用时间戳工具对比请求和响应时间。Modbus 从站一般几十毫秒内就会响应如果每次都是刚好卡在超时上限返回而且数据时有时无这种“慢响应”往往不是协议问题而是从站主循环被其他任务占用。串口缓冲区残留。每次写请求前先tcflush掉旧数据这个动作看起来多余但对长时间轮询的设备非常关键。否则一旦某次响应解析失败残留的半截帧会污染下一轮引发连环错帧。5.4 最后说一个踩过最深、也最想提醒你的坑有一次现场传感器在电脑上怎么测都正常一上嵌入式板子就 CRC 错。折腾了整整两天最后用逻辑分析仪抓出来发现板卡 UART 的 TX 使能引脚电平信号在发送最后一个字节时提前被拉低了。原因不是应用代码而是设备树里这个 UART 的 RS485 模式没打开驱动并没有接管方向控制而我应用层又没做方向切换结果就是发送器在帧还没跑完时就被硬件下拉。后来在设备树里使能了 RS485 模式并让驱动自动控制 RTS 脚问题彻底消失。所以如果你在嵌入式 Linux 上做 485 通信第一件事不是写代码而是先查清楚这个 UART 在硬件上是怎么接的是 RS232 电平还是 RS485 差分方向引脚由谁控制内核驱动认不认 RS485 模式。把这些先确认清楚你的 Modbus RTU 开发就已经成功了大半。后面写的每一行代码都是在为这些物理层面的正确性买单。