
最近做环境监测网关一块跑嵌入式Linux的ARM板需要同时采集几路RS485传感器数据。设备手册一水儿写着Modbus RTU9600,8N1站号1、2、3。原以为就是个串口收发的活真调起来才发现串口配置、RS485方向切换、CRC16校验、read超时、帧边界判定每一环都可能吃掉数据。这篇文章把我在嵌入式Linux上通过串口用Modbus RTU读写传感器数据的完整过程摊开讲包含可抄的代码和排坑链路。适合刚接触串口开发、对接温湿度/压力/液位等Modbus RTU传感器设备的工程师参考。1. 需求入场为什么多数传感器厂家默认给出Modbus RTU1.1 RS485物理层和Modbus协议层是什么关系嵌入式Linux设备要采集现场数据最常见的传感器输出就是4~20mA、0~10V或者RS485数字信号。4~20mA传一个点没问题可当你要接温湿度、风速、气压、水电表好几路时模拟量的布线成本和采集精度都很尴尬。RS485总线挂上去每个从站一个地址两根线串起来就行这是工业现场这么多年攒下来的经验。RS485解决的是“信号怎么传”A/B两根差分线抗共模干扰能力强几十上百米距离没问题一条总线理论上能挂32个甚至更多节点。Modbus RTU解决的是“数据怎么组织”帧格式、功能码、CRC校验都规定好。把RS485类比成电话线Modbus RTU就是双方约定好的“语言”一问一答每次通信就是一次完整的信息交换。协议层次分清楚之后很多问题就好定位了。通信不通时先别怀疑协议先确认物理层通不通协议解析出错时再把Modbus RTU帧结构拿出来逐字节比对。1.2 手写主站还是直接上libmodbus接下来说说选型。Linux下做Modbus主站很多人第一反应是libmodbus没错libmodbus封装好了RTU和TCP交叉编译也容易能用就直接用。但我在板子上接USB转串口和原生UART时遇到过两个情况一是某些精简系统环境下加一个动态库进去比较麻烦二是RS485自动方向切换或特殊延时需求必须自己控制收发时序。libmodbus能设置响应超时但物理层方向切换它管不着。所以这篇我选择手写一个精简RTU主站帧结构、CRC、超时逻辑都在自己手里排查问题会痛快很多。如果项目工期紧、功能码又多直接用libmodbus并没有问题。自己写的最大收益不是省掉依赖而是通过把“串口数据”和“Modbus帧”彻底拆开来理解遇到非标设备时能快速变通。2. termios串口初始化8N1、RS485方向切换和开机脏数据2.1 把串口从“终端模式”切成“原始模式”Linux里的串口本质上是一个终端设备默认行为带着行规程、回显、信号处理这些“历史包袱”。如果不做任何配置你read到的可能不是设备发来的原始字节而是经过终端驱动加工过的数据这也是很多初学者说串口收到的数据“看起来像是换行被吞了”的原因。我一般用cfmakeraw把串口切成原始模式再按需修改几个标志位。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #include termios.h #include errno.h static int serial_open(const char *dev, speed_t baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial); return -1; } struct termios tty; memset(tty, 0, sizeof(tty)); if (tcgetattr(fd, tty) ! 0) { perror(tcgetattr); close(fd); return -1; } cfmakeraw(tty); tty.c_cflag | CLOCAL | CREAD; /* 不处理modem控制线允许接收 */ tty.c_cflag ~CSTOPB; /* 1位停止位 */ tty.c_cflag ~PARENB; /* 无校验 8N1 */ tty.c_cflag ~CRTSCTS; /* 关闭硬件流控 */ cfsetispeed(tty, baud); cfsetospeed(tty, baud); tty.c_cc[VMIN] 0; tty.c_cc[VTIME] 1; /* read最多等100ms */ tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, tty) ! 0) { perror(tcsetattr); close(fd); return -1; } return fd; }CLOCAL CREAD这一组必须加上不然某些板卡会因DCD信号问题导致open阻塞或read一直等不到数据。波特率参数传的是B9600、B115200这种宏不是数字9600这点写的时候容易顺手写错。另外嵌入式板子的串口节点名称五花八门通用PC风格是ttyS0NXP i.MX常见ttymxc0/ttymxc1树莓派老系统是ttyAMA0USB转串口则是ttyUSB0/ttyACM0。代码里最好做成配置文件或者启动参数不要硬编码节点名否则每个板子都要改源码。2.2 RS485半双工方向切换的两种做法RS485是半双工收发器芯片一般有DE和RE引脚。发数据时要把发射使能拉起来发完再拉下去否则收发器一直在接收状态自己发的帧也会被就地接收或者发送时占用总线影响其他设备。Linux下控制这个方向有两条路。第一条是内核ioctl方式要求你的UART驱动支持SER_RS485。代码大致是这样#include linux/serial.h struct serial_rs485 cfg; memset(cfg, 0, sizeof(cfg)); cfg.flags SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; cfg.delay_rts_before_send 1; /* 单位ms */ ioctl(fd, TIOCSRS485, cfg);不是所有板子的驱动都支持这个ioctl用之前先查芯片手册和内核设备树。我遇到过支持TIOCSRS485但设备树里没开485模式的ioctl返回0但实际方向没拉起来最后还是在设备树里加了rs485-rts-active-high属性才解决。第二条是GPIO手动切换通用性最强。做法是发数据前先拉高DE引脚write之后调用tcdrain等发送FIFO清空再拉低DE释放总线static int rs485_set_dir(int gpio_fd, int high) { if (gpio_fd 0) return write(gpio_fd, high ? 1 : 0, 1); return 0; } rs485_set_dir(gpio_fd, 1); write(fd, txbuf, len); tcdrain(fd); /* 等待最后一个字节真正发完 */ rs485_set_dir(gpio_fd, 0);很多人栽在这里write返回只代表数据进了内核发送缓冲不代表已经发完立刻拉低DE会导致最后一个字节被截断从站收不完整帧CRC必然错。tcdrain就是为这个场景准备的。2.3 打开串口后的第一件事清缓存有些板卡在应用启动之前TX/RX线上已经有噪声。如果你不把它清掉等会儿读响应时会从缓冲里先读到一串脏数据整个帧解析就乱套了。tcflush(fd, TCIOFLUSH)在open之后、第一帧发送之前做一次成本为零但非常关键。我用USB转串口时还遇到过另一个坑设备插拔后系统重新枚举ttyUSB0可能变成ttyUSB1应用还傻傻地打开旧节点。调试时如果发现“代码没变但串口突然不通”先跑一下ls /dev/ttyUSB*和dmesg | grep tty说不定只是节点漂了。3. 帧结构、CRC16与功能码先懂协议再动代码3.1 RTU帧结构Modbus RTU一帧报文由四部分组成从站地址、功能码、数据区、CRC16校验CRC低字节在前。读保持寄存器是0x03功能码下面这张表把请求和响应对照起来看方向从站地址功能码数据区CRC16请求1字节1字节起始寄存器地址2字节 寄存器数量2字节2字节响应1字节1字节字节数1字节 寄存器数据N字节2字节举个最常见的请求读从站1、起始寄存器0、连续2个保持寄存器帧就是01 03 00 00 00 02 C4 0B其中01是从站地址03是功能码00 00是寄存器起始地址00 02是寄存器数量C4 0B是CRC16。这个请求帧很多工程师都记熟了也常被当作CRC16函数自测的标准输入。对应的响应大概是01 03 04 01 2C 00 EA CRC低字节 CRC高字节04表示后续数据总共4个字节也就是2个16位寄存器后面01 2C和00 EA是两个寄存器的原始值。地址1~247是合法的从站地址0是广播地址一般用在写操作读操作不要用广播。3.2 CRC16 Modbus的具体实现Modbus CRC16是多项式0x8005反转成0xA001、初值0xFFFF、输出不异或的一种变体不是文件校验用的CRC32也不是CRC-CCITT。网上很多人拿通用CRC库来算算出来永远对不上就是这个原因。static uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }注意返回值是主机字节序的uint16_t组帧时低字节放前面校验时也要按低字节在前比较。如果觉得位运算逐字节算太慢可以预生成256项查表法本质一样。嵌入式平台CPU性能普遍一般但帧长通常只有8~255字节位运算版本的耗时基本可以忽略。3.3 常用功能码03/06/16分别做什么Modbus功能码很多日常传感器数据采集其实用不了几个功能码名称典型用途0x01读线圈读取开关量输出0x02读离散输入读取只读开关量0x03读保持寄存器读取测量值和参数0x04读输入寄存器读取只读模拟量0x06写单个寄存器设置单个参数0x10写多个寄存器批量设置参数工业传感器所谓“测量值”十有八九塞在保持寄存器里所以0x03是最常打交道的功能码。0x06和0x10多用于改设备地址、设置量程、清零累计值这类参数操作。具体某个值放在哪个寄存器、是16位还是32位这些信息只能看厂家手册协议本身不关心你读的是温度还是压力。3.4 浮点数和字节序读出来的数据怎么还原Modbus一个寄存器16位所以32位浮点数要占两个寄存器。协议没有强制规定四个字节的排列顺序很多国外PLC习惯大端传输高字节在前、高字在前。但国产设备经常把两个字颠倒或者每个字内部字节顺序也换。读出来数值明显离谱时先把原始16进制贴到在线转换器里把四种排列顺序都试一遍通常马上就能对上。下面这段代码演示了两种最常见的字序组合static float regs_to_float(uint16_t r0, uint16_t r1, int word_swapped) { uint32_t raw; if (!word_swapped) raw ((uint32_t)r0 16) | r1; /* 高字在前 */ else raw ((uint32_t)r1 16) | r0; /* 低字在前 */ float f; memcpy(f, raw, 4); return f; }温湿度传感器这类便宜的设备很多直接输出整数加缩放系数。比如温度寄存器原始值0x012C等于300缩放系数0.01实际温度就是30.00℃。要是温度是负的寄存器还会以有符号数存储读出来之后要转成int16_t再乘缩放系数用uint16_t强行读会把负数读成几万。4. 从零手写RTU主站select超时、CRC校验、寄存器解析4.1 最小数据结构我在工程里不会把Modbus搞得很重一个结构体描述请求就够了typedef struct { uint8_t slave; /* 从站地址 */ uint8_t func; /* 功能码 */ uint16_t reg; /* 寄存器起始地址 */ uint16_t count; /* 寄存器数量 */ uint32_t timeout_ms;/* 响应超时 */ } modbus_req_t;4.2 发送请求与读回响应发送的逻辑很简单按帧结构填字节算CRC然后处理RS485方向切换。static int mb_send_req(int fd, int gpio_fd, const modbus_req_t *req, uint8_t *txbuf) { int pos 0; txbuf[pos] req-slave; txbuf[pos] req-func; txbuf[pos] req-reg 8; txbuf[pos] req-reg 0xFF; txbuf[pos] req-count 8; txbuf[pos] req-count 0xFF; uint16_t crc modbus_crc16(txbuf, pos); txbuf[pos] crc 0xFF; txbuf[pos] crc 8; if (gpio_fd 0) write(gpio_fd, 1, 1); ssize_t n write(fd, txbuf, pos); if (gpio_fd 0) { tcdrain(fd); write(gpio_fd, 0, 1); } return n; }read这边用select做超时不要在默认阻塞模式下傻等万一设备没应答你的线程会卡死在那里。static int mb_recv_resp(int fd, uint8_t *rxbuf, int maxlen, uint32_t timeout_ms) { fd_set rfds; struct timeval tv; FD_ZERO(rfds); FD_SET(fd, rfds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int ret select(fd 1, rfds, NULL, NULL, tv); if (ret 0) return 0; /* 超时或错误 */ int len 0; while (1) { uint8_t tmp[128]; ssize_t n read(fd, tmp, sizeof(tmp)); if (n 0) break; if (len n maxlen) break; memcpy(rxbuf len, tmp, n); len n; } return len; }读循环里不断read直到读取返回0或超时是因为串口是字节流一帧响应可能在两次调度里才到达。VIN0 VTIME1配合selectread最多等100ms就返回不会无限阻塞。4.3 整体调用示例读取温湿度传感器static int read_temp_humid(int fd, int gpio_fd, uint8_t slave, float *temp, float *humid) { modbus_req_t req { slave, 0x03, 0x0000, 2, 200 }; uint8_t txbuf[16], rxbuf[64]; int n mb_send_req(fd, gpio_fd, req, txbuf); if (n 8) return -1; int rlen mb_recv_resp(fd, rxbuf, sizeof(rxbuf), req.timeout_ms); if (rlen 5) return -2; if (rxbuf[0] ! slave || rxbuf[1] ! 0x03) return -3; if (rxbuf[2] ! 4) return -4; uint16_t crc modbus_crc16(rxbuf, rlen - 2); if (((uint8_t)(crc 0xFF) ! rxbuf[rlen - 2]) || ((uint8_t)(crc 8) ! rxbuf[rlen - 1])) return -5; uint16_t temp_raw ((uint16_t)rxbuf[3] 8) | rxbuf[4]; uint16_t humid_raw ((uint16_t)rxbuf[5] 8) | rxbuf[6]; *temp (float)temp_raw * 0.01f; *humid (float)humid_raw * 0.01f; return 0; }示例里假设温度寄存器和湿度寄存器连在一起且都在起始地址0。实际寄存器地址和缩放系数以手册为准。建议把超时、地址错误、功能码错误、CRC错误分开返回错误码联调时一眼能看出问题在哪一环。4.4 帧间隔问题更严谨的读法要考虑Modbus RTU的帧间隙协议规定帧结束要有3.5个字符时间的静默。9600波特率下一个字符约1.04ms3.5T大约3.6ms。读完一帧后最好再等一小段时间确认没有后续数据或者用“静默时间”来切帧。对于低速轮询单个传感器直接select超时整包读也够用但总线上设备多、噪声大时建议把帧间隙逻辑加进解析否则可能把前一个响应的尾巴当成下一帧的开头。5. 现场排查八类问题无应答、CRC错、数据丢的完整定位思路5.1 先做自发自收任何串口Modbus调不通第一步都是把TX/RX短接或者把RS485收发器设成自发自收模式发什么应该原样收到什么。如果自发自收都不对问题在串口参数或硬件连接别急着查协议。自发自收的正确操作顺序是先用stty把串口设置好然后用printf发一帧已知报文再用hexdump收。若收到的字节和发送的完全一致串口收发链路就是通的。这个测试5分钟能做完能排除一大半低级问题。5.2 无应答的排查顺序设备完全没反应时按下面这张表逐项排查可能原因验证方法解决方案波特率/校验位/停止位不一致PC上用串口调试工具连同样参数读一次改成设备手册指定的参数设备地址设置错误用调试工具从1扫到247拨码开关或写寄存器改地址RS485的A/B接反交换A/B两根线试验规范接线总线被其他节点占用逐个断开从站排查故障节点传感器在上电初始化中等几秒再轮询启动后延时PC上如果有一个能正常读数的工具直接拿它和板子对比。PC能通而板子不通说明设备没问题是板子侧的帧或物理链路不对。5.3 CRC校验错CRC错一半是算法问题一半是收到帧不完整。自测方法很简单拿已知请求01 03 00 00 00 02 C4 0B把你的CRC16函数对前6个字节计算看结果是不是0x0BC4注意低字节在前所以帧里的顺序是C4 0B。算出来不对就说明函数写错了。如果函数没问题但实际接收时CRC总是错多半是read把一帧拆分成多次或者丢了尾部字节。标准响应长度是3 2 * 寄存器数 2比如读2个寄存器应该是9字节。把收到的原始字节按hex打出来数一数少字节基本就是串口读取时机或者缓冲覆盖问题。5.4 丢字节与串口缓冲Linux不是实时系统应用层read不及时内核串口FIFO会被覆盖。降低丢包概率的方法有三招一是把VTIME/VMIN调好让read尽快返回二是收到select的POLLIN事件后立刻把缓冲读空三是设备树里给UART配DMA模式减少中断处理压力。实测下来9600波特率一帧也就10字节左右用select加一次多读基本不会丢。但如果系统里跑着大量高优先级任务还是建议把串口中断优先级和DMA开起来。应用层如果同时轮询多个串口设备最好每个串口一个接收线程不要在同一个线程里一次读完A再读B串口不会帮你做跨设备事务保证。5.5 物理层共地、终端电阻和线缆RS485虽然用差分信号但收发器共模电压范围有上限。距离只有几米时A/B两根线不共地一般也能通距离稍微一长设备之间地电位差一大通信就会抽风。正规接法是所有节点之间共地总线最远端并一个120欧终端电阻线用双绞线。有时候一主一从实测正常把第三个设备挂上去就乱排查点通常是终端电阻匹配或者分支线过长。485总线是串联型拓扑别拉成星形分支越短越好。5.6 数据读回来了但数值离谱寄存器地址、字节序、缩放系数是最容易出错的三个地方。有些PLC风格的传感器手册会把寄存器地址写成40001这种“PLC地址”实际协议地址要减1比如40001对应协议地址0。字节序问题把两字颠倒试试。缩放系数有些设备输出0.1你按0.01算数据就差10倍。还有一种情况是负数。温度传感器在冬天很容易出现0xFF38这种值如果你用uint16_t去读得到的是65336而不是-200再乘缩放系数就离谱了。正确做法是先转成int16_t再乘缩放系数。6. 联调工具组合没有总线分析仪也能把链路调通6.1 用stty和hexdump裸看串口帧Linux命令行自带的三件套就能做最基本的帧观察不需要买分析仪stty -F /dev/ttyUSB0 9600 raw -echo cs8 -cstopb -parenb hexdump -C /dev/ttyUSB0先起hexdump再在另一个终端用printf发请求printf \x01\x03\x00\x00\x00\x02\xc4\x0b /dev/ttyUSB0这样能直接看到设备回复的原始字节用于确认板子发的帧对不对、设备回没回非常直观。用stty配置完之后可以用stty -a -F /dev/ttyUSB0回看当前参数免得某些标志位没设对设备端一直不认。6.2 PC调试工具做交叉验证PC上装一个Modbus调试工具USB转RS485一头插电脑另一头对传感器。这类工具能配置波特率、校验、从站地址、寄存器地址能读保持寄存器也能看原始帧用来验证传感器本身没问题非常方便。我常用的流程是先在PC侧把传感器调通记住正确报文再到板子上用相同参数重放。如果板子收不到用hexdump把板子发出去的帧抓出来和PC发的对比很快就能分出是协议组帧问题还是串口物理链路问题。USB转RS485适配器用CH340、CH341、FTDI这类常规芯片居多Linux内核基本都认。插上之后先查lsusb和dmesg | grep tty确认枚举出的节点别搞错。6.3 自测清单项目收尾前我会把下面这份清单过一遍能省掉大量现场返工时间串口设备节点是否存在当前用户是否有读写权限termios是否已是原始模式8N1或设备指定格式是否和手册一致open之后是否调用了tcflush清理缓冲区RS485方向切换顺序是否正确write前拉高DEwrite后tcdrain再拉低CRC16是否用已知帧01 03 00 00 00 02 C4 0B自测过请求帧里的寄存器地址和数量是否与手册一致响应帧的地址、功能码、长度、CRC是否逐项校验PC调试工具能否在同样参数下正常读数现场布线是否共地是否接终端电阻A/B是否接反最后说个我自己的土办法所有Modbus RTU调试我都坚持把发出去的帧和收到的帧原样打印成hex不要打印字符串格式。多做几次之后你很快就能记住常见请求的固定CRC值比如01 03 00 00 00 02 C4 0B以后看到请求不完整一眼就能发现。调试这种挂在485总线上的设备物理层和协议层分开验证比什么高级工具都管用。