在嵌入式Linux设备上做Modbus RTU开发是我这些年接触最多的一类工业物联网需求。主控是一块跑着Linux的ARM板卡环境里排着一堆温湿度传感器、风速传感器、PM2.5传感器通讯接口清一色RS485协议清一色Modbus RTU。人家传感器手册写得很简单“波特率96008数据位无校验1停止位读保持寄存器起始地址0x0102返回两个寄存器高字节在前。”但等你真把串口数据读回来发现问题全在细节里CRC校验不过、寄存器字节顺序反了、浮点数解析出来是天文数字、RS485收发切换慢了一拍导致应答丢帧。这篇文章就把我在嵌入式Linux端做Modbus RTU读传感器数据的完整过程拆开讲串口配置、协议细节、代码实现、联调验证和现场踩坑一次说清楚。1. 为什么在嵌入式Linux上Modbus开发不能只靠“调库”1.1 这个需求来自哪类项目做工业数据采集和物联网网关的时候设备选型常常是这种配置主控用NXP i.MX6ULL、全志T113、瑞芯微RV1126这类板卡跑完整的嵌入式Linux系统负责协议转换、数据上传下位是若干Modbus RTU从机设备比如温湿度变送器、风速仪、土壤墒情传感器、智能电表。任务落到应用层工程师头上就是写一个C程序通过串口主动轮询这些从机把寄存器里的数据读回来再拼成JSON或者MQTT包往上送。这类项目有个共同特点从机地址、寄存器地址、寄存器数量、字节顺序这些参数只有拿到现场设备的Modbus寄存器表才能确定。所以先搞清楚协议细节比急着选库更重要。1.2 第三方库能省事但你仍然得懂串口和帧细节网上搜“嵌入式Linux Modbus”最常见的是libmodbus。这个库确实写得不错跨平台支持RTU和TCP函数封装也清晰。我早期项目也用它但后来遇到几个现实问题现场出现无法解释的通信故障库内部把串口设置成什么样、超时机制怎么工作你很难一眼看穿。某些交叉编译环境工具链较老libmodbus依赖的cmake配置会折腾一阵。供应商给的传感器偶发返回异常帧库的报错信息不够直接调试效率低。所以我的做法是核心的RTU主站程序直接用标准C手写只依赖Linux系统调用。串口用open()打开用termios配置自己组装Modbus请求帧自己校验CRC16自己解析应答。这样整个数据链路都在自己手上每一帧发出的是什么、收到的是什么打日志一清二楚。出问题的时候能快速定位是协议解析问题还是硬件时序问题。这不是说libmodbus不能用而是建议先弄懂RTU帧和串口底层再用库。明白原理之后用什么东西都是工具问题。2. 串口配置Modbus能否稳定运行首先看这里2.1 接线与设备节点RS232、RS485、TTL怎么选很多新手栽的第一个跟头不是代码是物理接线。嵌入式Linux板卡上通常引出的是TTL串口3.3V电平而工业传感器接口常见RS485或RS232。TTL和RS485不能直接对接板子上没有收发器的话需要外接一个TTL转RS485模块。模块一般有四个引脚VCC、GND、TXD、RXD有的带DE/RE方向控制脚。接线时要注意共地模块的GND必须和板子的GND连在一起否则通讯一长就随机出错。设备节点一般是/dev/ttyS0、/dev/ttymxc0这类名字不同内核平台命名不一样。插上USB转串口之后会多出/dev/ttyUSB0开发调试阶段经常用它临时顶上。2.2 termios参数从规范模式切换到raw模式Linux串口默认不是为Modbus这种裸数据设计的。默认的规范模式canonical mode会对输入做行缓冲、回车换行转换、特殊字符处理而这正是Modbus帧解析的大敌。Modbus RTU主站和从站之间交换的是二进制帧中间任何一个字节都可能和特殊字符冲突所以必须把串口切到原始模式。下面这段代码是基础建议直接抄入自己的项目#include termios.h #include unistd.h #include fcntl.h int uart_open(const char *dev, int baud) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open); return -1; } struct termios opts; if (tcgetattr(fd, opts) ! 0) { perror(tcgetattr); return -1; } cfmakeraw(opts); // 关键切到 raw 模式 opts.c_cflag | CLOCAL | CREAD; opts.c_cflag ~CRTSCTS; // 关闭硬件流控 // 8 数据位 opts.c_cflag ~CSIZE; opts.c_cflag | CS8; // 无校验 opts.c_cflag ~PARENB; // 1 停止位 opts.c_cflag ~CSTOPB; // 波特率 cfsetispeed(opts, baud); cfsetospeed(opts, baud); // 原始读取read 返回 0 表示没有数据返回 0 表示实际读取字节数 opts.c_cc[VMIN] 0; opts.c_cc[VTIME] 10; // 1 秒超时 tcsetattr(fd, TCSANOW, opts); tcflush(fd, TCIOFLUSH); return fd; }这里的baud参数直接传B9600、B19200这种宏。有些传感器支持更高波特率但工业现场9600和19200用得最多。2.3 读取策略非规范模式、VMIN和VTIME串口配置里最容易忽略的是VMIN和VTIME这两个参数决定了read()阻塞多久、读多少字节才返回。VMIN0VTIME10非阻塞读内核等待最多1秒有数据就返回实际读到的字节数没数据也返回0。适合Modbus主站轮询。VMIN1VTIME0阻塞读必须读到至少1个字节才返回。如果从站永久不回应程序会卡死在read()上。做Modbus主站必须用第一种。因为主站发出请求后本来就要处理“从站无响应”的情况read返回0正好当成超时处理。VTIME的单位是0.1秒VTIME10是1秒项目里配置1秒比较合适因为标准的Modbus RTU从机响应时间一般在几十毫秒内超过1秒基本就可以认为通信故障。2.4 串口开起来之后做几个动作验证代码写完不要急着解析传感器数据先用最简单的收发验证串口是通的。我常用的做法是打开两个串口一端接板卡串口另一端接USB转串口插到电脑用串口工具往板卡发数据板卡程序原样打印收到的十六进制字节。板子这边程序大概这样while (1) { int n read(fd, buf, sizeof(buf)); if (n 0) { for (int i 0; i n; i) printf(%02X , buf[i]); printf(\n); write(fd, buf, n); // 回显 } usleep(10000); }电脑端发出“01 03 00 00 00 01 84 0A”如果板子原样打印出来再回显说明TTL串口和电平转换链路是通的。到了这一步才进入真正的Modbus协议层面。3. Modbus RTU协议层帧结构、CRC16和功能码3.1 RTU帧格式一个请求由什么组成Modbus RTU没有复杂的分隔符靠“静默间隔”来分帧帧内字节间隔不能超过1.5个字符时间帧与帧之间至少要间隔3.5个字符时间。实际工程里这个间隔已经很难手动维持了9600波特率下1个字符约1ms程序里只要不做多余延时一般能满足。一个完整的RTU主站请求帧是这样的字段长度说明从站地址1字节取值范围1~2470是广播地址功能码1字节03读保持寄存器、04读输入寄存器、06写单个寄存器、16写多个寄存器数据段可变寄存器地址、数量或写入数据等CRC162字节低字节在前高字节在后例如读取从站地址0x01的保持寄存器起始地址0x0000数量1个01 03 00 00 00 01 84 0A。前面6个字节是地址、功能码和数据后面84 0A是CRC16。注意CRC发送顺序是低字节84在前高字节0A在后。3.2 CRC16计算按位算法和查表法CRC16-Modbus是这个协议最容易写错的地方。多项式是0x8005初始值是0xFFFF结果要异或0x0000也就是不额外异或。按位算法逻辑清楚代码量也不大uint16_t modbus_crc16(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; }0xA001是0x8005的反向多项式适合软件逐位计算。查表法更快但嵌入式单次轮询也就几十个字节的吞吐量按位算法完全够用且代码更透明调试方便。一个实用的自检方法把要发送的整帧含从站地址、功能码、数据段喂给这个函数得到的CRC低位字节在前、高位字节在后拼到帧尾。接收时把从站地址到数据段全部字节再算一次CRC跟收到的两个CRC字节比较同时注意接收时CRC是低字节在前。3.3 功能码03/04/06/16读取传感器最常用的几个功能码含义主要用于0x03读保持寄存器读取可读写的寄存器区很多传感器配置和校准值在这0x04读输入寄存器读取只读的测量值区比如温度、湿度、压力0x06写单个寄存器修改一个配置项比如改变从站地址0x10写多个寄存器批量写参数比如修改量程很多传感器把实测数据放在输入寄存器里用功能码04也有部分厂商混着来手册说用03就读03。最稳妥的做法是看它寄存器表的起始地址范围60000以上一般是保持寄存器30000段是输入寄存器这个分段来自Modicon约定现在很多新设备不完全遵守还是要以手册为准。应答帧格式也好理解。以04功能码为例正常应答字段长度从站地址1字节功能码1字节字节数1字节等于寄存器数×2寄存器数据N×2字节每寄存器高字节在前CRC162字节异常应答则把功能码最高位置1并带一个异常码比如请求读一个不存在的寄存器地址从站可能回01 84 02 CRC其中02表示非法数据地址。程序里必须能识别这种异常帧否则会把错误数据当成正常数据解析。4. 完整实现从请求帧到传感器数据解析4.1 构造读取输入寄存器的请求我的传感器手册是这样的Modbus从站地址0x01波特率9600读取输入寄存器起始地址0x0007数量8个寄存器。返回值前2个寄存器合成一个32位无符号整数是温度放大10倍的值后2个寄存器是相对湿度放大10倍的值。这类传感器很喜欢把多个测量值连续摆在一起读一次拿8个寄存器省事。构造请求帧uint8_t frame[8]; frame[0] 0x01; // 从站地址 frame[1] 0x04; // 功能码读输入寄存器 frame[2] 0x00; // 起始地址高字节 frame[3] 0x07; // 起始地址低字节 frame[4] 0x00; // 寄存器数量高字节 frame[5] 0x08; // 寄存器数量低字节 uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; // CRC低字节 frame[7] (crc 8) 0xFF;寄存器地址高字节在前是Modbus规定的大端格式。很多工程师在这里出过问题直接把地址0x0007赋值成frame[2]0x07导致发送帧变成从地址0x0700开始读数据自然不对。4.2 发送、接收和整帧校验发送很简单write全部8字节即可。但注意write返回值必须等于8否则说明串口出现部分写入这种属于异常情况需要重试。接收比发送讲究。RTU一个帧最多256字节传感器读32个寄存器的应答也就67字节。我通常用一个固定缓冲区分几次read累积因为串口数据可能会分片到达也可能一个read就收到了完整帧。处理逻辑uint8_t rsp[256]; int rsp_len 0; uint8_t tmp[64]; int n; uint32_t start get_tick_ms(); while (get_tick_ms() - start 500) { // 整体超时500ms n read(fd, tmp, sizeof(tmp)); if (n 0) { memcpy(rsp rsp_len, tmp, n); rsp_len n; if (rsp_len 5) { // 至少收到地址功能码字节数CRC int expect_len 0; // 功能码低字节末位不是1正常应答是 rsp[1] if ((rsp[1] 0x80) 0) { expect_len 3 rsp[2] 2; // 地址功能码数据长度CRC } else { expect_len 5; // 异常应答固定5字节 } if (rsp_len expect_len) { // 帧接收完整 break; } } } }这里有个容易被忽略的点read返回的字节数不一定就是完整的一帧有可能是半帧也可能把两帧的尾和下一帧的头拼在一起。所以不能read一次就立刻解析要累积到expect_len再校验CRC。4.3 把寄存器数据还原成温度/湿度浮点数这一步是好多人的“重灾区”。假设应答数据段是08 00 13 88 00 00 C2 98前两个寄存器高字节在前合并uint32_t temp_raw (rsp[3] 24) | (rsp[4] 16) | (rsp[5] 8) | rsp[6] ?等等这里要看清楚。应答帧从索引3开始是第一个寄存器高字节索引4是第一个寄存器低字节索引5是第二个寄存器高字节索引6是第二个寄存器低字节。正确的合并方式uint32_t temp_raw (rsp[3] 16) | (rsp[4] 8) | rsp[5]; // 错 // 正确两个相邻寄存器合成32位值 uint32_t temp_raw ((uint32_t)rsp[3] 24) | ((uint32_t)rsp[4] 16) | ((uint32_t)rsp[5] 8) | ((uint32_t)rsp[6]); float temp (float)temp_raw / 10.0f;我在现场项目里遇到过一种更诡异的传感器它的32位浮点数用“寄存器低16位在前”的方式存放也就是第一个寄存器存的是浮点数低16位第二个寄存器存高16位。这类和标准大端不同的情况非常坑必须在联调时先用已知值验证不能想当然用大端解析。真正的IEEE754浮点数解析也可以直接用memcpyuint32_t raw ((uint32_t)rsp[3] 24) | ((uint32_t)rsp[4] 16) | ((uint32_t)rsp[5] 8) | (uint32_t)rsp[6]; float value; memcpy(value, raw, sizeof(value));这样得到的float就是传感器返回的原始浮点值。用memcpy而不是强制类型转换是因为直接做强制类型转换会改变字节序解释规则在ARM小端处理器上很容易出问题。4.4 一个可直接编译的读取示例下面是一个完整可运行的示例读取从站地址0x01的输入寄存器解析前两个寄存器为32位浮点数打印出来#include stdio.h #include stdint.h #include string.h #include fcntl.h #include unistd.h #include termios.h #include time.h static uint32_t tick_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec * 1000 ts.tv_nsec / 1000000; } static uint16_t modbus_crc16(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; } static int uart_open_all(const char *dev) { int fd open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) return -1; struct termios opts; tcgetattr(fd, opts); cfmakeraw(opts); opts.c_cflag | CLOCAL | CREAD; opts.c_cflag ~CRTSCTS; opts.c_cflag ~CSIZE; opts.c_cflag | CS8; opts.c_cflag ~PARENB; opts.c_cflag ~CSTOPB; cfsetispeed(opts, B9600); cfsetospeed(opts, B9600); opts.c_cc[VMIN] 0; opts.c_cc[VTIME] 10; tcsetattr(fd, TCSANOW, opts); tcflush(fd, TCIOFLUSH); return fd; } int main(void) { int fd uart_open_all(/dev/ttyS0); if (fd 0) { printf(open uart failed\n); return 1; } uint8_t frame[8]; frame[0] 0x01; frame[1] 0x04; frame[2] 0x00; frame[3] 0x07; frame[4] 0x00; frame[5] 0x02; // 只读2个寄存器相当于一个32位浮点数 uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] (crc 8) 0xFF; printf(send:); for (int i 0; i 8; i) printf( %02X, frame[i]); printf(\n); write(fd, frame, 8); tcflush(fd, TCIFLUSH); // 读之前清一次输入缓冲避免收到旧数据 uint8_t rsp[256]; int rlen 0; uint32_t start tick_ms(); while (tick_ms() - start 500) { uint8_t tmp[32]; int n read(fd, tmp, sizeof(tmp)); if (n 0) { memcpy(rsp rlen, tmp, n); rlen n; if (rlen 9) break; // 正常应答111429 } } printf(recv:); for (int i 0; i rlen; i) printf( %02X, rsp[i]); printf(\n); if (rlen 9 (rsp[1] 0x80) 0) { uint16_t recv_crc rsp[rlen - 2] | (rsp[rlen - 1] 8); uint16_t calc_crc modbus_crc16(rsp, rlen - 2); if (recv_crc ! calc_crc) { printf(crc error\n); return 1; } uint32_t raw ((uint32_t)rsp[3] 24) | ((uint32_t)rsp[4] 16) | ((uint32_t)rsp[5] 8) | (uint32_t)rsp[6]; float val; memcpy(val, raw, sizeof(val)); printf(value: %.2f\n, val); } else { printf(invalid response or exception\n); } close(fd); return 0; }这个例子里的应答接收简化了只判断rlen 9实际项目里应该像第4.2节那样计算expect_len。为什么我在示例里要简化主要是先让链路跑通确认能收到一个完整的正常应答再逐步完善健壮性。工程开发讲循序渐进一步到位反而容易排查不了问题。5. 联调验证仿真从站加真实抓包5.1 本地用一个从站模拟器验证没有真传感器的时候也可以在PC上把程序跑起来用一个Modbus从站模拟软件比如Modbus Slave模拟传感器。PC上起一个虚拟串口工具比如用socat -d -d pty,raw,echo0 pty,raw,echo0创建一对虚拟串口一个给模拟软件用一个给我们的程序用也可以在嵌入式板卡上直接接一个USB转RS485工具连到一个真实的Modbus传感器上。我第一次验证手写RTU程序就是拿Modbus Slave模拟从站配置从站地址0x01区域选择Input Registers起始地址0x0007手动填入几个浮点数的十六进制值比如温度25.5那么0x0007寄存器存0x41CC0x0008寄存器存0x0000。程序跑起来后如果输出的浮点数正好是25.5说明请求帧、应答解析、CRC校验、浮点数转换整条链路都通了。5.2 排查链路发出去没回应、回应CRC错误、数据解析错乱现场排查时我按下面顺序看问题效率最高确认程序打印的发送帧和手册例子完全一致。有没有CRC颠倒、寄存器地址高低位写反、从站地址错误。确认物理连接。RS485的A、B线不能接反接反的结果是“发送正常接收全无”。用万用表量一下A对B之间的电压空闲时应大于0.2V实际上好的差分信号在1.5V到5V之间反了就把两根线对调。收到底层数据但CRC错误。可能原因有两个波特率、校验位配错接收到的是乱码或者帧中间有丢字节多半是串口缓冲区太小、CPU调度不及时。处理方法是把read缓冲区加大到256字节以上接收循环在500ms内尽量多取几次而不是只read一次。收到应答、CRC也对但解析出来的数值明显不对。这种百分之八九十是字节序问题。32位数据可能是高字节在前也可能是低字节在前甚至可能是两个寄存器反了。用仿真工具配合已知数值很快能试出来。还有一个特别隐蔽的坑传感器从站返回数据时中间夹着其他设备的杂波。常见于RS485多设备共线时某个从站收到请求后延迟响应另一个设备的数据插进来。程序解析时必须严格检查从站地址、功能码、CRC任何一项不对就丢弃整帧不能半信半疑地用。6. RS485方向切换、多从机轮询与容错6.1 方向控制延时1个字节传输时间的估算如果你的板子用的是带自动收发切换的RS485模块代码不用管方向。不过工业现场更常见的是用普通MAX485芯片需要MCU的一个GPIO控制DE/RE方向发送时拉高DE发送完毕后拉低RE。问题在于发送最后一个字节刚write()完不能立刻把方向切回接收因为串口FIFO可能还没把最后一个字节完全发出去这时候切换方向最后一两个字节会变成“半截”从站根本收不到完整帧。怎么估算切换延时以9600波特率、10位一字符8数据位1起始位1停止位计算每字节传输时间 10 / 9600 ≈ 1.0417 ms写完后至少要延时1.5个字节时间也就是1.6ms左右再切换方向。19200波特率就是0.52ms/字符延时0.8ms。很多项目图省事直接usleep(5000)5ms也不算错就是轮询周期会拖慢一点。轮询几十个传感器的场景每个都多睡5ms周期就多出几百毫秒所以要按波特率精确计算而不是拍脑袋写延时。一个稳健的做法是发送后先不主动切方向而是调用tcdrain(fd)确保内核串口缓冲区已经全部写入硬件再延时一个字符时间最后切方向。6.2 多从机轮询的主状态机思路一个主站挂十几个从站程序不能简单for循环一个接一个读写因为某个从站掉线会导致100ms超时整个循环被卡住。常规做法是把轮询做成状态机每个从站分配一个槽位周期循环对每个槽位设置不同的超时上限typedef struct { uint8_t addr; uint8_t func; uint16_t reg_start; uint16_t reg_num; float last_value; int last_scan_ms; int timeout_cnt; } modbus_slave_t;主循环一次性把所有从站的请求帧都发出去然后统一等待应答这是行不通的因为RTU总线上同一时刻只能有一个主站在说话也要求从站排队应答。实际轮询还是逐个来发请求、等待应答超时500ms、解析、再发下一个。但优化空间在于给不同从站分配不同轮询周期重要的传感器每500ms读一次不重要的5秒读一次。某个从站连续3次超时标记为离线但不停留在它身上立刻跳到下一个地址。把“发请求”和“等待应答”拆成两个状态等待期间可以处理日志、网络上报而不是空转sleep。6.3 超时、重试与错误码处理现场应用中传感器偶尔没有回应很正常线路干扰、从站重启、供电波动都会导致丢帧。所以轮询必须有重试机制。我的经验值是失败首次重试1次连续失败3次标记离线等到下一个完整周期再重新尝试。为什么连续失败标记离线因为一个从站如果连续几十秒都没有应答基本确定是硬件问题继续每轮重试只会浪费总线的宝贵时间。对异常帧里的错误码也要有对应的日志异常码含义常见原因01非法功能请求功能码从站不支持02非法数据地址寄存器地址超过从站范围03非法数据值写入数据超范围04从站设备故障传感器自身故障日志一定要带上原始接收帧的十六进制内容不能只打印“通信错误”。我在现场就遇到过传感器偶发返回一个字节0xFF的垃圾数据如果没有原始帧日志这种问题根本抓不到。7. 现场调试一段时间后我最想提醒的三件事第一件开发阶段就在所有收发路径上打十六进制日志而不是等现场出问题再补。嵌入式Linux上调试Modbus最有效的工具就是一份干净的printf日志把每次发送帧和接收帧完整打出来。注意频率不要太高轮询周期100ms以下时太刷屏可以做成可开关的debug宏。第二件不要迷信“高字节在前”的手册描述。我验证过同一个厂家的两种型号传感器一个按标准大端一个故意用“寄存器低16位在前”的方式存浮点数你要是按统一逻辑去解析其中必有一款会读成天文数字。上电第一件事就是拍一组已知值用仿真和实际传感器的对比确认字节序。第三件串口和Modbus调试要分步走先把收发打通再去做协议解析。代码跑通一个read请求拿到正确CRC和数值后面再加批量轮询、断线重连、MOTT上报都顺理成章。反过来如果一开始就把问题堆在一起你会分不清到底是串口配置不对、CRC算错、还是浮点数解析反了。嵌入式Linux端做Modbus RTU说难不难说简单也不简单。协议本身只有读读寄存器、写写寄存器这点事难的是把物理层、串口层、协议层、应用层这条链路的每个细节都扣清楚。只要沿着“串口验证—协议验证—字节序验证—轮询容错”这条顺序走多数问题都能在联调阶段解决而不是带到现场。