尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
嵌入式Linux串口配置与Modbus RTU读写传感器实战
干了这么多年嵌入式Linux开发串口和Modbus这两样东西几乎是绕不开的。去年做了一套环境监测终端用IMX6ULL跑Linux外接了一堆RS485接口的温湿度、光照传感器主机通过Modbus RTU协议去轮询数据。当时在网上查资料发现要么是讲单片机裸机移植FreeModbus的要么是纯讲协议理论但没落地代码的真正把嵌入式Linux下的串口配置和Modbus RTU读写传感器这个链路讲透的文章不多。正好最近又帮朋友调了一块RK3288的板子把同样的逻辑跑了一遍趁热把整个开发过程、代码细节和踩过的坑整理出来。这篇文章适合手里有Linux开发板树莓派、IMX6ULL、RK系列、全志系列都行想通过RS485/RS232串口去对接工业传感器、PLC、电表这类Modbus RTU从站设备的开发者。我会从串口底层配置开始把termios的关键参数讲明白然后手写一个轻量级的Modbus RTU主机驱动实现03功能码读保持寄存器和04功能码读输入寄存器最后接入真实的温湿度传感器做演示。整个过程不依赖任何第三方库纯C语言实现拿到任何Linux板子上改改串口名就能跑。1. RS485通信的基础认知为什么Modbus RTU在工业现场经久不衰先说点背景。Modbus协议是Modicon公司在1979年发明的最初是为PLC通信设计的现在已经是工业自动化领域的事实标准。它的应用层协议也就是数据帧格式非常简洁底层可以跑在串口上Modbus RTU/ASCII也可以跑在以太网上Modbus TCP。RS485是我们平时最常用的物理层接口因为它是差分信号传输抗干扰能力强传输距离能达到1200米而且支持多点通信一条总线上最多能挂32个从站设备。很多人第一次接触Modbus RTU时会被各种概念绕晕什么保持寄存器、输入寄存器、线圈、离散输入、功能码、从站地址……其实拆开看非常清晰。Modbus RTU定义了四类数据表格1 Modbus四大数据对象数据对象读写属性功能码寄存器范围物理意义线圈Coil可读可写01读、05写单、15写多00001-09999开关量输出如继电器离散输入Discrete Input只读0210001-19999开关量输入如限位开关输入寄存器Input Register只读0430001-39999模拟量输入如ADC采样的温度保持寄存器Holding Register可读可写03读、06写单、16写多40001-49999参数设置、累加器值在传感器领域我们最常用的是03功能码读保持寄存器和04功能码读输入寄存器。有些传感器会把温度放在保持寄存器里也有的放在输入寄存器里具体要看厂商的数据手册。例如我手里这款环境传感器温度在寄存器地址0x0000湿度在0x0001用的就是04功能码。这没什么特别原因纯粹是厂商设计时定的。关于RS485和RS232的区别也顺便提一句。RS232是单端信号发送和接收用不同的线传输距离15米左右点对点通信。RS485是差分信号A/B两根线用方向切换来控制收发。做嵌入式Linux开发时如果板子自带RS485接口通常是一个串口加一个收发器芯片如SP3485再加一个方向控制GPIO。方向控制有两种方式一种是硬件自动切换芯片自己检测发送状态另一种是软件控制需要在发送数据前把GPIO拉高发送完再拉低。后面写程序时我会演示软件控制怎么做。2. Linux串口配置termios结构体里那些真正要命的参数在Linux下操作串口本质上是打开一个设备文件然后通过termios结构体配置参数。很多人直接用stty命令改完参数然后用open/read/write读写短时间能跑通但一旦遇到数据量稍大或者时序要求高的场景就出问题。我在实际开发中总结了一套相对稳妥的串口初始化流程下面逐步拆解。2.1 打开串口的正确姿势串口设备文件一般是/dev/ttyS0原生的串口、/dev/ttyUSB0USB转串口、/dev/ttymxc0NXP的芯片、/dev/ttySAC0三星的芯片。不同平台名字不一样但打开方式完全一样int fd open(dev_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open serial port failed); return -1; }三个关键标志不要搞错O_RDWR可读可写。O_NOCTTY防止串口成为控制终端。如果不加这个标志当你按下CtrlC时串口会收到SIGINT信号程序直接死掉。O_NDELAY非阻塞模式。加了它之后open操作不会因为DCD信号数据载波检测没就绪而阻塞。这里有个小坑open之后要再用fcntl把状态改回阻塞模式因为后续的read我们通常是希望阻塞等待数据的fcntl(fd, F_SETFL, 0);这样read就会一直等在那里直到收到数据或超时。2.2 核心配置项波特率、数据位、停止位、校验位这部分是Modbus RTU通信成败的关键。Modbus RTU标准要求8位数据位停止位可以是1位或2位校验位可以是无校验、偶校验或奇校验。实际工程中使用最多的组合是9600/8/N/1即波特率9600、8位数据位、无校验、1位停止位。struct termios options; tcgetattr(fd, options); // 读取当前参数 cfsetispeed(options, B9600); // 设置输入波特率 cfsetospeed(options, B9600); // 设置输出波特率 options.c_cflag | CLOCAL | CREAD; // CREAD打开接收CLOCAL忽略调制解调器控制线 options.c_cflag ~CSIZE; // 清理数据位设置位 options.c_cflag | CS8; // 设置8位数据位 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSTOPB; // 1位停止位 options.c_cflag ~CRTSCTS; // 禁用硬件流控 /* * 如果要用偶校验改为 * options.c_cflag | PARENB; * options.c_cflag ~PARODD; * 如果要用奇校验 * options.c_cflag | PARENB; * options.c_cflag | PARODD; */关于CLOCAL这个标志初学者经常忽略。如果不设置程序会去检测调制解调器的载波信号在纯串口直连的场景下可能导致open或read异常。所以不管什么场景CLOCAL | CREAD都要加上。2.3 两个容易忽略但影响巨大的参数VTIME和VMINVTIME和VMIN是termios里控制read行为的一对参数很多人直接抄代码忽略它们结果程序行为完全不符合预期。VMIN读取的最小字节数。read返回前至少需要读取到这么多字节。VTIME等待时间单位是0.1秒。它们俩的组合逻辑是这样的VMIN 0VTIME 0非阻塞模式read立即返回没有数据时返回0。VMIN 1VTIME 0阻塞模式read一直等直到收到1个字节才返回。VMIN 0VTIME 0有超时的读等到VTIME时间后返回没有字节数约束。VMIN 0VTIME 0read会等VMIN个字节但字节间超过VTIME时间后也会返回这是另一个坑我后面细说。做Modbus主机时我的建议是设置VMIN 1, VTIME 5。意思是至少读1个字节才返回字节间超时0.5秒。Modbus RTU的一帧报文波特率9600时大约20-30ms0.5秒的帧间超时足够区分两帧数据也不会因为从站响应稍慢而误判。options.c_cc[VTIME] 5; // 0.5秒 options.c_cc[VMIN] 1; // 至少要读到一个字节2.4 为什么要先用tcflush清掉缓冲区初始化完成后、开始通信之前最好清一次缓冲区tcflush(fd, TCIOFLUSH);这个操作会把内核缓冲区里残留的旧数据丢掉。否则上电瞬间线路上的噪声、调试时残留的脏数据都有可能被当成Modbus响应帧解析。我自己就遇到过设备刚上电程序读取到一堆0xFF开头的垃圾数据然后校验失败整个状态机被带乱。完整串口初始化函数如下int serial_init(const char *dev_path, int baudrate) { int fd open(dev_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) { perror(open); return -1; } fcntl(fd, F_SETFL, 0); struct termios options; tcgetattr(fd, options); speed_t speed; switch (baudrate) { case 9600: speed B9600; break; case 19200: speed B19200; break; case 38400: speed B38400; break; case 115200: speed B115200; break; default: speed B9600; break; } cfsetispeed(options, speed); cfsetospeed(options, speed); options.c_cflag | CLOCAL | CREAD; options.c_cflag ~CSIZE; options.c_cflag | CS8; options.c_cflag ~PARENB; options.c_cflag ~CSTOPB; options.c_cflag ~CRTSCTS; options.c_lflag ~(ICANON | ECHO | ECHOE | ISIG); options.c_oflag ~OPOST; options.c_iflag ~(IXON | IXOFF | IXANY | INLCR | ICRNL | IGNCR); options.c_cc[VTIME] 5; options.c_cc[VMIN] 1; tcsetattr(fd, TCSANOW, options); tcflush(fd, TCIOFLUSH); return fd; }这里还有两个点需要解释一下。c_lflag里关闭ICANON是关键。终端默认是规范模式canonical moderead要等到收到换行符才返回。我们做Modbus通信数据帧里可能包含任意字节值包括0x0A即换行符如果ICANON不关掉字节流会被按行处理帧结构就全乱了。c_oflag里关闭OPOST是为了防止输出时做换行符转换。c_iflag里关闭IXON/IXOFF是为了禁用软件流控避免XON/XOFF字符干扰数据。关于波特率默认9600是Modbus RTU最通用的速率工业现场大量传感器默认就是这个值。如果你的传感器支持更高波特率可以在配置时修改。不过提醒一下如果总线较长或者接线质量不好波特率越高越容易出错实际项目里跑115200就需要在硬件设备选型和布线上下点功夫了。2.5 RS485方向切换的实现方案如果你的板子是RS485接口发送数据前必须把收发器切换到发送模式发送完再切换回接收模式。最直接的方式是控制一个GPIOint rs485_set_direction(int fd, int tx) { struct rs485_config rs485conf; if (ioctl(fd, TIOCGRS485, rs485conf) 0) { // 内核不支持TIOCGRS485只能手动控制GPIO if (tx) { gpio_set_value(RS485_DIR_PIN, 1); // 拉高进入发送模式 } else { gpio_set_value(RS485_DIR_PIN, 0); // 拉低进入接收模式 } return 0; } if (tx) { rs485conf.flags | SER_RS485_ENABLED | SER_RS485_RTS_ON_SEND; rs485conf.flags ~(SER_RS485_RTS_AFTER_SEND); } if (ioctl(fd, TIOCSRS485, rs485conf) 0) { perror(TIOCSRS485); } }比较新的Linux内核4.x以上支持通过ioctl直接配置RS485模式这样串口驱动会在发送时自动拉高RTS引脚不需要应用程序干预。但很多老内核或者简化过的厂商BSP不支持那就只能手动拉GPIO。具体用哪种方式先看板子的设备树和内核串口驱动支不支持。还有一个实现方案是在设备树里配置RS485模式比如imx6ull的设备树加上linux,rs485-enabled-at-boot-time;属性。这样驱动初始化时就把RS485模式打开应用层根本不用关心方向切换。但设备树改起来和生产环境验证的成本高我建议程序里做好兼容先尝试ioctl失败再回退GPIO控制。3. Modbus RTU帧结构拆解地址、功能码、数据、CRC16的来龙去脉Modbus RTU的请求帧和响应帧结构非常规整。一次完整的主从通信流程是主机发送请求帧从站接收后执行操作再返回响应帧。如果从站不支持该功能码或执行出错返回的是异常帧。3.1 一条请求帧是怎么组成的主机发送的RTU请求帧无论功能码是什么结构都一样表格2 Modbus RTU请求帧结构字段长度说明从站地址1字节0x01-0xF70x00是广播地址从站不响应广播功能码1字节如0x03、0x04、0x06、0x10数据段N字节不同功能码含义不同CRC162字节低字节在前高字节在后01功能码的完整请求帧最多8字节我这里以04功能码读两个寄存器为例从站地址0x01功能码0x04起始寄存器地址0x0000温湿度传感器温度寄存器读取数量0x0002读两个寄存器CRC16计算后附加低字节在前原始字节就是01 04 00 00 00 02 71 CB最后的CRC是根据前面的字节算出来的。3.2 响应帧如何解析正常情况下从站响应帧结构是从站地址0x01功能码0x04字节数0x04总共4个字节的数据寄存器数据2个寄存器每个2字节高位在前大端序CRC16例如01 04 04 01 2C 01 9A 7A 4B表示两个寄存器的值分别是0x012C300和0x019A410温度就是30.0摄氏度如果协议规定缩小10倍就是实际值的10倍即300表示30.0度。如果出错从站返回的帧是从站地址功能码最高位置1例如正常是0x04异常就是0x84异常码1表示非法功能2表示非法地址3表示非法数据值CRC16实际开发中我遇到过最多的是返回0x02非法数据地址通常是因为起始寄存器地址不对或者读取数量超出了传感器的寄存器范围。3.3 CRC16-Modbus的查表法和位运算法CRC校验是Modbus RTU帧的重要组成部分。帧里的每个字节都会参与计算包括从站地址、功能码、数据段但不包括CRC本身。计算标准是CRC16-MODBUS多项式是0x8005初始值是0xFFFF。表格3 CRC16常见参数表参数值多项式0x8005初始值0xFFFF结果异或值0x0000输入输出字节序低字节在前我用的是位运算法代码不复杂直接贴在下面uint16_t modbus_crc16(uint8_t *buffer, uint16_t length) { uint16_t crc 0xFFFF; for (uint16_t i 0; i length; i) { crc ^ buffer[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc 1; crc ^ 0xA001; // 0x8005 的反向多项式 } else { crc 1; } } } return crc; }注意这里用的0xA001是0x8005反过来后的结果。有些资料直接让你用0xA001有些让你用0x8005加移位方向不同本质一样。算完之后CRC要低字节先发送高字节后发送。如果对性能有要求可以用查表法。比如主从站地址超过几十个每帧都要计算CRC查表法快得多。我开发中用的查表法生成的是256个值篇幅有限不贴了网上搜CRC16-Modbus表生成到处都是。实际指令周期不紧张的传感器轮询场景位运算法完全够用。3.4 主机状态机从发送请求到解析响应的完整流程正式写代码之前我建议先在心里过一遍状态机组帧填从站地址、功能码、寄存器地址、数量计算CRC16附加到帧尾清空串口缓冲区RS485方向切换到发送模式write发送帧RS485方向切换到接收模式read读取响应设置超时判断响应帧长度、从站地址、功能码是否匹配校验CRC是否正确解析数据段。这个流程里最容易出问题的是第7步的超时设置。从站处理请求的时间从几十毫秒到几百毫秒不等如果超时太短正常的慢速从站会被误判如果太长总线上有坏设备不响应轮询时间会非常长。我一般把总超时设为500毫秒到1秒。4. 手写一个ARM Linux上的Modbus RTU主机驱动读温湿度传感器示例下面进入实战环节。用前面初始化的串口加上一个轻量级的Modbus RTU主机封装实现读取温湿度传感器数据的功能。代码在我的IMX6ULL和RK3288板子上都实测过串口设备节点分别是/dev/ttymxc2和/dev/ttyS3。4.1 数据结构与宏定义#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include termios.h #include errno.h #include stdint.h #include sys/ioctl.h #define SERIAL_DEV /dev/ttymxc2 #define BAUDRATE 9600 #define MODBUS_BROADCAST 0x00 #define MODBUS_READ_HOLD 0x03 // 读保持寄存器 #define MODBUS_READ_INPUT 0x04 // 读输入寄存器 #define MODBUS_WRITE_SINGLE 0x06 // 写单个寄存器 #define MODBUS_WRITE_MULTI 0x10 // 写多个寄存器 #define RESPONSE_TIMEOUT_MS 500 typedef struct { int fd; uint8_t slave_addr; } modbus_t;4.2 组帧与发送核心的发送函数static void build_request_frame(uint8_t *frame, uint8_t slave, uint8_t func, uint16_t start_addr, uint16_t quantity) { frame[0] slave; frame[1] func; frame[2] (start_addr 8) 0xFF; frame[3] start_addr 0xFF; frame[4] (quantity 8) 0xFF; frame[5] quantity 0xFF; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; // CRC低字节 frame[7] (crc 8) 0xFF; // CRC高字节 }写寄存器时的帧结构有点差异06功能码是从站地址、功能码、寄存器地址2字节、数据值2字节CRC。写多个寄存器10功能码还需要额外加字节数。下面给出06功能码的组帧static void build_write_single_frame(uint8_t *frame, uint8_t slave, uint16_t reg_addr, uint16_t value) { frame[0] slave; frame[1] MODBUS_WRITE_SINGLE; frame[2] (reg_addr 8) 0xFF; frame[3] reg_addr 0xFF; frame[4] (value 8) 0xFF; frame[5] value 0xFF; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] (crc 8) 0xFF; }4.3 读取寄存器的核心逻辑int modbus_read_registers(modbus_t *ctx, uint8_t func, uint16_t start_addr, uint16_t quantity, uint16_t *dest) { if (quantity 125) { fprintf(stderr, quantity too large, max is 125\n); return -1; } uint8_t frame[8]; build_request_frame(frame, ctx-slave_addr, func, start_addr, quantity); // 清缓冲区避免读到旧数据 tcflush(ctx-fd, TCIOFLUSH); // RS485方向切换为发送如果是GPIO控制 rs485_set_direction(ctx-fd, 1); int ret write(ctx-fd, frame, 8); if (ret ! 8) { perror(write frame error); return -1; } // 发送完毕等数据发送完成后切换回接收方向 tcdrain(ctx-fd); rs485_set_direction(ctx-fd, 0); // 期望响应帧长度: 从站地址(1) 功能码(1) 字节数(1) 数据(quantity*2) CRC(2) int expected_len 3 quantity * 2 2; uint8_t response[256]; // 读取响应使用poll等待或直接read阻塞 int total 0; while (total expected_len) { ret read(ctx-fd, response total, expected_len - total); if (ret 0) { perror(read error); return -1; } if (ret 0) { break; } total ret; } if (total expected_len) { fprintf(stderr, response timeout or incomplete: %d bytes\n, total); return -1; } // 校验响应帧的从站地址和功能码 if (response[0] ! ctx-slave_addr) { fprintf(stderr, slave address mismatch: 0x%02X\n, response[0]); return -1; } if (response[1] ! func) { // 异常响应 if ((response[1] 0x80) ! 0) { fprintf(stderr, slave exception code: 0x%02X\n, response[2]); } else { fprintf(stderr, function code mismatch: 0x%02X\n, response[1]); } return -1; } // 校验CRC uint16_t crc_received response[total - 2] | (response[total - 1] 8); uint16_t crc_calc modbus_crc16(response, total - 2); if (crc_received ! crc_calc) { fprintf(stderr, CRC mismatch: received 0x%04X, calc 0x%04X\n, crc_received, crc_calc); return -1; } // 解析数据大端序 for (int i 0; i quantity; i) { dest[i] (response[3 i * 2] 8) | response[4 i * 2]; } return quantity; }4.4 主程序并发读取温度、湿度、光照最终主程序是这样的int main(void) { modbus_t ctx; ctx.fd serial_init(SERIAL_DEV, BAUDRATE); ctx.slave_addr 0x01; // 传感器的从站地址 if (ctx.fd 0) { return -1; } uint16_t values[4]; while (1) { // 读取输入寄存器从0x0000开始读3个寄存器 int ret modbus_read_registers(ctx, MODBUS_READ_INPUT, 0x0000, 3, values); if (ret 3) { float temperature values[0] / 10.0f; // 缩小10倍 float humidity values[1] / 10.0f; float lux values[2] * 1.0f; printf(%.1f, %.1f, %.0f\n, temperature, humidity, lux); fflush(stdout); } else { printf(read failed, ret%d\n, ret); } sleep(2); // 2秒轮询一次 } close(ctx.fd); return 0; }4.5 关于poll和select的一点建议上面的代码是直接用阻塞read读取响应。实际项目里我更推荐用poll或select做统一超时管理因为这样可以方便地扩展多从站轮询也可以避免read在某些极端情况下卡死。核心替换逻辑如下#include poll.h struct pollfd fds; fds.fd ctx-fd; fds.events POLLIN; int ret poll(fds, 1, RESPONSE_TIMEOUT_MS); if (ret 0) { fprintf(stderr, poll timeout\n); return -1; } if (fds.revents POLLIN) { total read(ctx-fd, response, sizeof(response)); }用poll的好处是你可以精确控制超时时间而不用依赖VTIME的0.1秒粒度。如果你要在一个H2里讲清楚多从站轮询poll是必不可少的。5. 实测结果与调参经验为什么你的帧容易超时、CRC对不上如果原封不动跑上面的代码大概率不会一次通过。下面把我当时遇到的几个典型问题列出来这些都是网上文档很少提的。5.1 第一次测试就超时的排查链路我当时第一次跑程序read一直超时从站完全不回复。排查步骤先用示波器抓串口TX引脚波形确认主机确实发出了数据。没有示波器就用逻辑分析仪几十块钱的USB逻辑分析仪够用。这一步能快速判断是不是串口驱动的问题。检查TX引脚有没有把数据发到RS485总线。很多开发板的RS485收发器需要DE/RE引脚配合确认方向控制GPIO的编号对不对。我遇到过设备树里把GPIO编号写错的情况程序往错误编号的引脚上拉高收发器一直处于接收状态数据发不出去。用modbus poll工具Windows下用或者从站模拟工具modbus slave交叉验证。把从站设备的通信参数波特率、校验位、停止位和主机程序对比经常发现有设备出厂默认是9600/E/1偶校验而程序配的是9600/N/1。挂一个Modbus主机调试工具手动发一帧01 04 00 00 00 03 XX XX看从站是否回01 04 06 ...。如果手动发有响应说明协议没问题问题出在程序组帧。整个排查链路遵循的思路是先物理层、再链路层、最后应用层。这里提醒一下RS485总线末端要接120欧姆终端电阻尤其是线长超过几米的时候。终端电阻的主要作用是匹配阻抗减少信号反射。如果环境电磁干扰大屏蔽层单点接地也加上。5.2 CRC对不上的元凶符号位和强制类型转换另一个高频问题就是CRC计算错误。C语言里如果buffer是char类型而不是uint8_t在参与位运算时会发生符号扩展。例如0x80在char里是-128异或运算后结果完全不同。所以我建议所有Modbus帧缓冲区都用uint8_t不要用char。另外CRC的字节序非常容易搞反。Modbus RTU规定CRC低字节在前高字节在后。如果你算出来是0x71CB发送时应先发0xCB再发0x71。我见过太多人按大端序发结果从站根本不理。5.3 轮询周期和多从站调度的经验值单从站轮询时2秒一次完全够用对总线负载也很友好。多从站时合理调度可以减少总线冲突。我常用的是非阻塞轮询超时记录的方式每轮依次给各从站发请求设置合理的等待时间如果某个从站连续3次超时标记为离线降低该站的轮询频率比如从1秒改为10秒避免反复超时增加无意义的总线占用。实际工业场景中还有一个细节就是相邻两次请求之间最好加一个小间隔比如10-20ms。很多从站设备处理完上一帧后需要一点恢复时间连续快速发帧容易让从站反应不过来表现就是偶发超时。6. 进阶05/06/10功能码写操作与多寄存器批量读写前面主要讲了读传感器数据但Modbus开发中写操作同样常见。比如设置传感器上报周期、校准零点、控制继电器通断都要用到写功能码。这一节把常用的写操作一起补上。6.1 05功能码写单个线圈控制继电器/开关量输出05功能码写单个线圈的帧结构从站地址、功能码0x05、线圈地址2字节、数据值2字节0xFF00表示ON0x0000表示OFF、CRC16。int modbus_write_coil(modbus_t *ctx, uint16_t coil_addr, int state) { uint8_t frame[8]; frame[0] ctx-slave_addr; frame[1] 0x05; frame[2] (coil_addr 8) 0xFF; frame[3] coil_addr 0xFF; frame[4] state ? 0xFF : 0x00; frame[5] 0x00; uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] (crc 8) 0xFF; if (write(ctx-fd, frame, 8) ! 8) { return -1; } // 响应帧跟请求帧内容完全一样长度也是8字节 uint8_t response[8]; int total read_response(ctx-fd, response, 8); ... }6.2 10功能码写多个寄存器批量设置参数10功能码稍微复杂帧结构是从站地址、功能码0x10、起始地址2字节、寄存器数量2字节、字节数1字节、寄存器数据、CRC16。假设要往从站地址0x11的设备写入起始地址0x0002开始的2个寄存器值分别为0x000A和0x0100组帧如下uint8_t frame[13]; frame[0] 0x11; frame[1] 0x10; frame[2] 0x00; frame[3] 0x02; // 起始地址高/低字节 frame[4] 0x00; frame[5] 0x02; // 写2个寄存器 frame[6] 0x04; // 数据字节数 寄存器数 * 2 frame[7] 0x00; // 第一个寄存器高字节 frame[8] 0x0A; // 第一个寄存器低字节 frame[9] 0x01; // 第二个寄存器高字节 frame[10] 0x00; // 第二个寄存器低字节 uint16_t crc modbus_crc16(frame, 11); frame[11] crc 0xFF; frame[12] (crc 8) 0xFF;写多个寄存器时数据字节数容易算错注意是寄存器数量乘以2。6.3 浮点数在Modbus里的存储与转换你可能会遇到的坑工业传感器经常会输出浮点数如32位IEEE 754格式的温度值。在Modbus寄存器里一个32位浮点数占用2个连续的16位寄存器存储方式有大端序和小端序之分。不同厂商的实现不同有些按大端字序大端字节序例如浮点数1.5存储为0x3F C0 00 00第一个寄存器是0x3FC0第二个是0x0000有些按小端字序大端字节序第一个寄存器是0x0000第二个是0x3FC0。当初我调一款进口温湿度传感器时就被这个坑过读出来的数据用大端解释是负数后来发现寄存器顺序相反。解决办法是读两个寄存器后分别按两种方式拼出4字节用memcpy转成float比较哪个在物理范围内固定用比较合理的那个。更可靠的办法是查厂商手册手册里通常会给出浮点数字节序的说明。转换代码比较简单uint16_t regs[2]; uint32_t bits (regs[0] 16) | regs[1]; // 大端字序 float value; memcpy(value, bits, sizeof(float));6.4 总线抓包工具与modbus poll实战技巧最后推荐几个工具开发过程中非常有用。modbus pollWindows下最常用的Modbus主机模拟工具可以在没有实际主机时手动发报文观察从站响应。平时我用它来单独验证从站硬件是否正常。modbus slave配合modbus poll使用可以把PC模拟成从站用来验证我们写的Linux主机程序是否正确。串口监视工具如AccessPort、sscom或者Linux下的minicom可以直接观察串口的原始字节流。调试技巧上我的习惯是先在PC上用modbus poll把从站的全部寄存器摸一遍把地址、数据类型、缩放系数搞清楚再动手写Linux程序。很多问题其实是在协议理解阶段就能避免的。7. 工程化落地要点守护进程、日志、异常恢复与可维护性开发板上跑通了功能只是第一步真正常时间稳定运行还需要解决几个工程化问题。这一节分享的是我在实际项目中总结的经验。7.1 关于进程守护和自动重连Linux板子上运行的Modbus采集进程一旦崩溃整个数据链路就断了。我用的是supervisor做进程守护配置很简单[program:modbus_gateway] command/usr/bin/modbus_gateway autorestarttrue startsecs3 startretries5 redirect_stderrtrue有几个坑填一下。如果程序崩溃是因为串口被异常占用导致open失败单纯的restart可能会一直失败需要在程序里加入退避重连逻辑比如每5秒尝试重新open。串口设备在系统休眠唤醒后可能需要重新初始化监听SIGPWR之类的信号在恢复后重跑serial_init。7.2 故障自愈通信超时后的恢复策略经常遇到的情况是总线上某个从站突然不响应了。如果程序不做处理poll会一直超时影响其他从站的采集。我的策略是每个从站维护一个连续失败计数连续失败超过3次将该从站标记为离线暂停轮询打印日志每隔一段时间比如30秒尝试对该从站发一次请求恢复响应的从站自动重新加入轮询队列。这个策略简单可靠避免了单个从站故障拖垮整个采集任务。7.3 日志设计的取舍我自己的做法是分两类日志正常运行时1分钟输出一次汇总记录每个从站的温度、湿度、成功率异常时打印完整的收发帧十六进制方便复盘。只打印有效数据不要把每一帧请求和响应都打出来否则日志量巨大而且刷屏真正要查问题时反而不方便。7.4 把Modbus数据转发给上层应用的方式嵌入式设备上采集到的Modbus数据通常不是只在本机打印就完了。常见的上行方式有MQTT发布用mosquitto库或paho库把JSON格式数据发到服务器Modbus TCP服务器把采集到的数据映射到TCP端口上让PC端用modbus poll读取文件日志按时间戳追加写入CSV/TXT适合没有网络的环境SQLite入库适合需要本地历史查询的场景。我这次做的项目采用的是MQTT转发。每个传感器一个topic比如dev/01/sensor/temp上报payload是{value: 30.1, ts: 1721213456}。这样后续的数据清洗、存储、告警都在服务端处理设备端只负责采集和上传。8. 最后再聊几个实战心得真机调试了几十个小时之后有几个体会想单独说一下。第一用logical分析仪抓波形永远比瞎猜快。遇到帧发不出去、数据对不上、从站不响应之类的问题第一步永远是确认物理层数据。我遇到过一个很隐蔽的问题RS485的A/B线在接线端子那端虚焊导致信号反射严重近距离偶发通信失败远距离完全不通。示波器一抓就发现波形振铃严重。第二程序里的超时参数要按最慢的从站设备来定不要全局一刀切。有的温湿度传感器响应只要20ms有的电表要300ms。我通常会给每个从站单独维护一个超时值在注册从站时通过配置传入。第三千万不要忽略从站设备的寄存器访问权限。有些寄存器是只读的你写06功能码过去会得到异常响应0x02。在开发阶段建议把所有寄存器类型摸清写一个配置文件记录下来后续维护会省很多时间。第四组帧时先拿计算器算一遍CRC再和程序输出对比。人手算一次就明白CRC的移位过程后面程序出问题排查起来比照抄代码快得多。modbus poll里也有CRC显示功能可以直接对照。最后说一句Modbus RTU确实是个老协议跟现在各种花哨的物联网协议比起来朴素得很但它简单、可靠、调试容易工业现场几十年积累的设备海量未来很长一段时间内都还会是控制域通信的主流。把这套串口加RTU的代码框架打磨好往后无论是接PLC、接电表、接各种传感器基本都是一天之内能搞定的活。
RELATED

相关推荐

C#部署YOLOv5四边形检测:ONNX推理与解码实战

C#部署YOLOv5四边形检测:ONNX推理与解码实战

简介:面向 .NET 与计算机视觉开发者的 C# OpenCvSharp DNN 部署 YOLOv5 不规则四边形目标检测源码包,重点解决旋转目标检测在 C# 环境下的工程落地问题,可应用于车辆斜框、遥感影像、文本行等倾斜目标的定位与识别。资源共 124 个文件&#x…

📅 2026/9/11 5:42:47
SNMP网络监控从入门到实战:设备性能、流量分析与故障诊断

SNMP网络监控从入门到实战:设备性能、流量分析与故障诊断

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/11 5:42:47
Windows平台OpenClaw AI框架安装与配置全攻略

Windows平台OpenClaw AI框架安装与配置全攻略

1. OpenClaw在Windows平台的完整安装指南 OpenClaw作为一款新兴的AI智能体开发框架,在开发者社区中逐渐流行起来。不同于常规的AI工具,它提供了本地化部署、多模型接入和自定义技能扩展等特性。本文将详细演示Windows环境下从零开始部署OpenClaw的全过程…

📅 2026/9/11 5:42:47
MORE NEWS

更多资讯

📰

嵌入式Linux下手写Modbus RTU主站:串口配置、CRC16与传感器读取实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

AionUi Preview 模块深度解析:多 Tab 文件预览编辑系统与 Agent 流式更新机制

AionUi Preview 模块深度解析:多 Tab 文件预览编辑系统与 Agent 流式更新机制 【免费下载链接】AionUi Open-source 24/7 Cowork app for OpenClaw, Hermes, Claude Code, Codex, OpenCode and 20 more CLI Agent | Customize your assistants | Team them up&#…

📰

猫抓cat-catch免费资源嗅探扩展完整指南:三分钟安装到m3u8合并下载

猫抓cat-catch免费资源嗅探扩展完整指南:三分钟安装到m3u8合并下载 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 猫抓cat-catch 是一…

📰

Onlook GitHub 集成配置指南:从 GitHub App 环境变量到 PKCS8 密钥接入的完整实践

Onlook GitHub 集成配置指南:从 GitHub App 环境变量到 PKCS#8 密钥接入的完整实践 【免费下载链接】onlook The Cursor for Designers • An Open-Source AI-First Design tool • Visually build, style, and edit your React App with AI 项目地址: https://gi…

📰

Java集合遍历避坑指南:从for循环到Stream并行流

1. 集合遍历的设计思路与选型考量 1.1 为什么同一个需求会有这么多种写法 先聊个普遍现象:很多初学者学Java时,最先接触的是for循环遍历List,后来发现还有foreach、Iterator,再后来碰到Stream流,直接懵了——到底该学…

📰

Physical AI边缘部署实战:模型量化与TensorRT在Jetson上的优化

做自动驾驶泊车、工业质检、园区巡检这类 Physical AI 项目时,很多人一开始都习惯把视频流传回云端,让服务器跑模型,再把结果返回设备端。这个架构在 demo 阶段确实没毛病,但一上线就会发现两个要命的问题:延迟不可控&…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬