news 2026/10/15 15:30:43

STM32 UART Modbus RTU主机模式:从帧结构到状态机的完整落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 UART Modbus RTU主机模式:从帧结构到状态机的完整落地

简介:面向工业自动化通信领域的嵌入式工程师,这份资源提供了一套基于STM32微控制器和UART串口的Modbus RTU主机模式实现方案,适用于需要主控多个从设备进行数据采集与控制的RS485总线场景。内容包括UART波特率、数据位、停止位及校验位的配置方法,Modbus RTU帧结构、功能码与CRC校验原理,以及主机发送请求、接收响应、异常处理和超时重试的完整代码逻辑。压缩包共2个文件,分别是头文件和源文件,整体大小仅8KB,代码精简、注释清晰,便于直接移植到实际项目中。已有581人学习下载,适合正在从事工业控制、仪器仪表或自动化设备通信的开发人员参考。通过研读源码,可以掌握Modbus主机状态机的设计思路、定时器超时处理机制以及响应帧解析技巧,并在此基础上进一步扩展多从机轮询和故障诊断功能,提升工程实战能力。

1. stm32 + uart + modbus(rtu模式) 主机模式:让单片机主动发请求,别再等从机找上门

多数刚接触 Modbus 的人第一反应是去移植一个现成从机协议栈,结果遇到“我主动去读一台温控仪表的数据”这种需求时直接卡住:协议栈只响应、不发起。stm32 + uart + modbus(rtu模式) 主机模式解决的就是这个缺口——让单片机定时向总线上的每一台从机发出读请求,收下回帧、校验、解析,再拿着数据去做显示或控制。你可以把它理解成一个迷你主站,适合那些没有工控触摸屏、又要跑 Modbus 轮询的嵌入式项目。

这篇文章会把主机侧该处理的帧结构、CRC、串口收发、超时重试和踩过的坑全部写透,照着抄就能跑起来。内容按一个完整主机的落地顺序排:先懂帧,再配硬件,最后写状态机。

2. 从协议帧到 CRC:主机侧先背熟的底层材料

2.1 先读懂 RTU 帧:地址、功能码、数据、校验的排布

Modbus RTU 是二进制帧,没有起始符,也没有帧结束符。主机和从机靠“静默时间”判断一帧是否结束:帧与帧之间的空闲间隔不能小于 3.5 个字符时间,帧内两个字节之间的间隔不能超过 1.5 个字符时间。这也是为什么做主机最好不要用简单的“收满固定长度就处理”的思路——你提前不知道这个从机会回几个字节,只能靠总线上出现空闲中断来判断“帧发完了”。

RTU 帧的标准排布是:从机地址(1字节) + 功能码(1字节) + 数据域(N字节) + CRC校验(2字节)。数据域内容随功能码变化,但地址永远是第一个字节,CRC 永远在最后,而且是低字节在前、高字节在后。

举个例子,主机要读地址为 0x01 的从机的保持寄存器,起始寄存器地址 0x0000,读 10 个寄存器,请求帧长这样:

字节位置内容说明
00x01从机地址,范围 1~247
10x03功能码,读保持寄存器
20x00寄存器地址高字节
30x00寄存器地址低字节
40x00寄存器数量高字节
50x0A寄存器数量低字节
6CRC低字节由前 6 个字节计算
7CRC高字节同上

这里第一个坑潜伏在寄存器地址和数量的字节顺序上:Modbus 规定多字节数据都是高字节在前,也就是大端。如果你把地址 0x000A 写成了低字节在前,从机会当你访问一个不存在的寄存器,大概率回你一个非法地址异常帧。

从机应答帧的格式是:地址 + 功能码 + 数据字节数 + 数据 + CRC。注意第三个字节是“数据域的字节数”,不是寄存器个数。读 10 个保持寄存器会回 20 个字节数据,所以应答帧第三个字节应该是 0x14。

2.2 主机最常用的功能码:0x03 读保持寄存器与 0x10 写多寄存器

主机模式下你真正高频使用的功能码没有那么多。读方面用 0x03 读保持寄存器最多,少数场景会读输入寄存器,对应功能码 0x04。写方面,单寄存器用 0x06,多寄存器用 0x10。

0x06 写单个寄存器的请求帧是:地址 + 0x06 + 寄存器地址(2) + 写入值(2) + CRC。从机收到后会把同样的帧原样回给主机,所以判断写成功可以直接比较回帧和请求帧是否逐字节相等。

0x10 写多个寄存器的请求帧稍微复杂:地址 + 0x10 + 起始寄存器地址(2) + 寄存器数量(2) + 字节数(1) + 数据(N) + CRC。这里字节数是寄存器数量乘以 2,因为一个寄存器是 16 位。比如写 3 个寄存器,数据域就是 6 个字节,字节数字段写 0x06。

从机应答 0x10 的帧会比较短:地址 + 0x10 + 起始地址(2) + 寄存器数量(2) + CRC,一共 8 个字节。它不会回写具体数据,只确认“起始地址和数量”。有些设备在这个回帧里还能带上实际写入失败的寄存器数量,但绝大多数常规设备不这么干。

另外一个必须记住的公共规则:当从机收到一个非法的请求时,回帧的功能码会在原功能码基础上把最高位置 1。比如你发 0x03,它回 0x83,第三个字节是异常码:0x01 非法功能、0x02 非法数据地址、0x03 非法数据值。主机解析应答时,必须先看这个高位标志,不然你会把异常帧当正常数据帧去解析,CRC 算出来还可能是对的,但数据全是垃圾。

2.3 主机侧 CRC16 校验:算到字节级才叫落地

CRC16-Modbus 的算法固定,多项式是 0x8005 的反射形式 0xA001,初始值 0xFFFF。下面这段按位计算的函数不依赖查表,放在任何工程里都能跑,主机一秒钟轮询几十个从机也吃不了多少 CPU。

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; } void modbus_append_crc(uint8_t *frame, uint16_t len) { uint16_t crc = modbus_crc16(frame, len); frame[len] = (uint8_t)(crc & 0xFF); // 低字节在前 frame[len + 1] = (uint8_t)(crc >> 8); // 高字节在后 }

逻辑说明:第一个参数是待校验的数据指针,第二个参数是参与校验的字节数。CRC 计算过程中对每个字节做 8 次移位和异或,判断最低位决定是否与 0xA001 异或。函数返回值是带符号的 CRC 值,注意它是低字节在前地落在帧里。

参数说明:modbus_append_crc 里传的 len 是“请求帧中 CRC 之前已有的字节数”。比如前面那个读请求帧,前 6 个字节已经填好,调用 modbus_append_crc(frame, 6) 之后,frame[6] 是 CRC 低字节,frame[7] 是 CRC 高字节。你不需要手动填这两个位置,函数会覆盖写入。

自测标准值:对字符串 "123456789" 计算 CRC16-Modbus,结果应该是 0x4B37。如果函数算出来不是这个值,说明多项式或者初始值写错了。低字节 0x37,高字节 0x4B,这在实际抓包验证时非常有用。

3. 搭好物理链路:UART 参数与 RS485 收发控制

3.1 串口参数表:8E1、波特率和从机匹配

Modbus RTU 最常见的物理层是 RS485,电气上是两根差分线 A/B,半双工。串口参数里最容易出问题的是“校验位”和“停止位”的组合。很多工控设备默认是 9600 8E1,也就是 8 个数据位、偶校验、1 个停止位;也有一大批设备是 9600 8N1。主机侧的 UART 参数必须跟总线上的从机完全一致,否则从机收不到帧或者回帧全乱。

使用 STM32 的 HAL 库时,在 CubeMX 里通常这样配:

配置项推荐值说明
ModeAsynchronous异步串口
Baud Rate9600 / 19200 / 38400跟从机一致,默认先试 9600
Word Length8 Bits固定
ParityEven 或 None优先试 Even,工控设备默认多
Stop Bits1即使配了校验位也选 1

单独说一个容易迷茫的点:当你在 CubeMX 里选了 Parity Even 时,物理帧其实是 8 个数据位加 1 个校验位,再算上起始位和停止位,一个字符总共 11 位。STM32 的硬件会自动拆分,你不用手工处理校验位,但计算 3.5 个字符时间时必须用 11 位而不是 10 位。

常用的 3.5 字符时间参考:9600 波特率约 4.01ms,19200 约 2.0ms,38400 约 1.0ms,115200 约 0.33ms。主机做帧间延时、判断总线空闲时都按这个数量级来。

GPIO 方面,我一般给 RS485 收发器准备一个控制脚:高电平切到发送,低电平切到接收。很多板上用 PA4 做这个用途,但换成任意空闲 GPIO 都行。注意 DE 和 RE 两个引脚通常接在一起,共用一个控制信号。

3.2 RS485 收发方向:DE 引脚的“等最后一个字节”策略

半双工方向切换是主机模式最容易翻车的硬件点。发送时把 DE 拉高,数据发完再把 DE 拉低回到接收。问题是“发完”这两个字没有你想的那么简单。

HAL_UART_Transmit 这个函数返回时,并不代表最后一个字节已经从 TX 引脚发出去了。它只代表最后一个字节被写进了发送数据寄存器,移位寄存器里可能还在往外吐这个字节的最后几个 bit。如果这时立刻把 DE 拉低,总线瞬间被释放,从机那边看到的是一个被人为截断的帧——尾部少了 1~2 个位,CRC 根本对不上,从机直接丢弃。

正确做法是发送结束之后等待发送完成标志 TC(Transmission Complete),再切换方向。TC 位置 1 表示移位寄存器彻底把最后一个 bit 送出去了。

static void rs485_send_frame(UART_HandleTypeDef *huart, uint8_t *frame, uint16_t len) { RS485_DE_ENABLE(); // 切到发送方向 HAL_UART_Transmit(huart, frame, len, 200); // 阻塞发送,注意它不等 TC while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) { // 等待移位寄存器完全发完最后一字节 } RS485_DE_DISABLE(); // 切回接收方向 }

逻辑说明:RS485_DE_ENABLE 和 RS485_DE_DISABLE 是你自己实现的宏,底层就是 GPIO 写高低电平。HAL_UART_Transmit 的第三个参数是阻塞超时,单位毫秒,这里给 200ms 足够。

参数说明:while 等待 TC 理论上可能会死等,但串口故障时这里会卡住。更稳妥的做法是在循环里加一个超时计数,比如等到 HAL_GetTick 超过 50ms 就强制跳出并标记发送失败。现场调试时如果发现回帧偶尔丢失,第一个要查的就是这段代码有没有写透。

慢速波特率下这个等待时间不能忽略:9600 波特率下,一个字节大约 1.15ms,等待 TC 最多会多花一个多毫秒,时序上是可接受的。主机轮询本身就不该把 1ms 当大事。

3.3 接收链路选择:空闲中断是主机模式的默认解

主机接收回帧的最大特点是你不知道从机会回多少字节。主站见过的最小合法应答是 5 个字节(异常帧),最大可以是 256 字节(250 个数据字节加头尾)。你不能靠固定长度接收,而要靠“总线空闲了”判断一帧结束。

STM32 的 UART 外设自带 IDLE 检测:一帧数据接收完成后,总线空闲超过一个字节时间,硬件就会置 IDLE 标志并触发中断。这跟 Modbus RTU 的“字符间隔判断帧结束”思路天然匹配。

我推荐的初始化顺序是:发送请求帧之后,立即打开接收监听,使用 HAL_UARTEx_ReceiveToIdle_IT 等待整帧数据。中断回调里拿到的 Size 就是从机回帧的总长度。

HAL_UARTEx_ReceiveToIdle_IT(&huart1, modbus_rx_buf, MODBUS_RX_BUF_SIZE);

这段代码放进主程序初始化或者每次发送请求前。逻辑说明:huart1 是工程里实际使用的串口句柄,modbus_rx_buf 是接收缓冲区,MODBUS_RX_BUF_SIZE 给 256 足够覆盖最大回帧。函数启动后,UART 会一直收,直到检测到空闲中断,然后回调函数被触发。

回调里只需要做三件事:记录长度、置标志位、重新启动接收。重新启动这一步最容易漏,漏了之后第二次请求就再也收不到回帧。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { modbus_rx_len = Size; // 记录本次回帧字节数 modbus_rx_new = 1; // 置位,通知主循环解析 HAL_UARTEx_ReceiveToIdle_IT(&huart1, modbus_rx_buf, MODBUS_RX_BUF_SIZE); // 重新挂接收 } }

逻辑说明:回调函数在空闲中断后由 HAL 库触发,Size 是不包含任何附加信息的原始字节数。置位 modbus_rx_new 后,主循环会在下一次轮询里处理这帧数据。处理完之前不要清空缓冲,否则还没被解析的回帧就被覆盖了。

这行的顺序很关键:必须先重新挂接收,再处理旧数据。因为 Modbus 主机的下一次请求可能问得非常快,如果先做解析再做重新挂接收,新的回帧可能从总线打进来而没有任何接收监听在运行。这个竞态在低速主频的单片机上更容易暴露。

4. 主机状态机:从发请求到解回帧的完整骨架

4.1 数据结构先行:请求缓冲、应答缓冲、状态位

写主机程序前先把数据区定好。我用一个请求帧缓冲区、一个回帧缓冲区、一个接收长度变量和一个状态标志位,就够撑起单从机的主机逻辑。

#define MODBUS_RX_BUF_SIZE 256 #define MODBUS_MAX_RETRY 3 uint8_t modbus_tx_buf[64]; // 请求帧,最大 64 字节足够 uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; // 回帧缓冲 volatile uint16_t modbus_rx_len = 0; // 回帧长度 volatile uint8_t modbus_rx_new = 0; // 新回帧标志 uint8_t modbus_retry_cnt = 0; // 当前请求的重试次数 uint32_t modbus_tick_start = 0; // 发送完成时刻 uint32_t modbus_timeout_ms = 500; // 应答超时时间

逻辑说明:modbus_tx_buf 复用同一块数组拼不同请求,每次发送前重新覆盖。modbus_rx_new 是关键同步点,回调置 1,主循环处理完清 0。

参数说明:超时时间建议先给 500ms。Modbus 标准本身没有规定主机应答超时,但从机内部处理周期通常在 10ms 到 100ms 之间,加上慢速波特率的帧传输时间,500ms 能覆盖绝大多数设备。有些行为古怪的从机在写寄存器之后会花 300ms 处理内部事务,超时给短了就得背锅。

4.2 状态机细化:空闲、等应答、解析、重试

主机模式不建议用阻塞式 HAL_UART_Transmit 配合 HAL_UART_Receive 一直傻等。更稳的是在 main 循环里反复调用一个 modbus_poll 轮询函数,内部维护状态机。

typedef enum { MB_STATE_IDLE = 0, MB_STATE_WAIT_ACK, MB_STATE_DONE, MB_STATE_FAULT } mb_state_t; mb_state_t mb_state = MB_STATE_IDLE; void modbus_poll(void) { if (mb_state == MB_STATE_WAIT_ACK) { if (modbus_rx_new) { if (modbus_parse_frame()) { mb_state = MB_STATE_DONE; } else { mb_state = MB_STATE_FAULT; } modbus_rx_new = 0; } else if (HAL_GetTick() - modbus_tick_start > modbus_timeout_ms) { mb_state = MB_STATE_FAULT; } } }

逻辑说明:状态机只在等应答态工作。发送完成后进入 MB_STATE_WAIT_ACK,主循环每次进来检查有没有新回帧,有就去解析;没有就查超时。

参数说明:HAL_GetTick 返回系统 ms 计数,modbus_tick_start 在发送完的瞬间打点。超时判断用当前时间减打点时间,超过 modbus_timeout_ms 就把状态置为故障。重试次数不放在状态里,单独用 modbus_retry_cnt 记。

重试逻辑在调用方实现:

void modbus_request_read(uint8_t addr, uint16_t reg, uint16_t count) { if (mb_state != MB_STATE_IDLE) return; // 上一笔还在处理,直接丢弃新请求 uint8_t len = modbus_build_read_hold(addr, reg, count, modbus_tx_buf); rs485_send_frame(&huart1, modbus_tx_buf, len); modbus_tick_start = HAL_GetTick(); modbus_retry_cnt = 0; mb_state = MB_STATE_WAIT_ACK; }

逻辑说明:请求帧构造完成后发出去,然后立刻打点并切到等待状态。这里有个细节:如果上一次请求还没有结束,新请求直接 return 掉,避免同时有两帧在总线上跑,Modbus 不允许这种情况。

参数说明:addr 是从机地址,reg 是寄存器地址,count 是寄存器数量。函数返回值 len 是请求帧总字节数,包含 CRC。你需要在外部加一个判断:解析返回 MB_STATE_DONE 后才把 modbus_rx_buf 里的数据复制到应用层变量,解析失败则重试或者计数故障。

再补一个重试包装,我一般把“发请求 + 失败重试三次”归纳为一个函数,这样业务代码只关心最终结果。

uint8_t modbus_master_read_hold(uint8_t addr, uint16_t reg, uint16_t count) { for (uint8_t try = 0; try < MODBUS_MAX_RETRY; try++) { modbus_request_read(addr, reg, count); while (mb_state == MB_STATE_WAIT_ACK) { modbus_poll(); } if (mb_state == MB_STATE_DONE) { return 1; } } return 0; }

逻辑说明:for 循环内部每轮重新发一次请求,然后原地轮询状态机直到这一轮结束。MB_STATE_DONE 说明这次成功,MB_STATE_FAULT 就重来下一轮。三次都失败,函数返回 0,业务层自己决定报警还是继续。

参数说明:MODBUS_MAX_RETRY 定成 3 是折中方案,重试太密集会把总线占用得很厉害,旁边从机的响应周期会被拖乱。如果你的总线上有十几个从机,建议把它降成 2,每次失败后至少等一个完整轮询周期再重试。

4.3 解析回帧:异常码、CRC、数据长度一个都不能漏

解析函数承担四道检查:长度下限、地址匹配、功能码校验、CRC 校验。顺序很重要,我先看长度和地址,再看功能码高位置位情况,CRC 放最后。

uint8_t modbus_parse_read_hold(uint8_t addr, const uint8_t *buf, uint16_t len) { if (len < 7) return 0; // 最少:地址+功能码+字节数+2字节数据+CRC if (buf[0] != addr) return 0; // 地址必须匹配 if ((buf[1] & 0x80) != 0) return 0; // 高位为1:这是异常帧,业务层再细看 if ((buf[1] & 0x7F) != 0x03) return 0; // 功能码必须是0x03 uint16_t crc_recv = (uint16_t)buf[len - 2] | ((uint16_t)buf[len - 1] << 8); if (modbus_crc16(buf, len - 2) != crc_recv) return 0; uint8_t byte_cnt = buf[2]; // 数据字节数 if (byte_cnt != (len - 5)) return 0; // 回帧总长 = 5 + 数据字节数 return 1; }

逻辑说明:第 5 行先看功能码的最高位,从机回异常帧时这里会是 1。第 8 行把最后两个字节组装成 CRC 值,注意组装顺序要按照帧内顺序低字节在前来。第 10 行检查数据字节数字段和实际长度的关系,这能挡住一些把回帧长度算错的串口噪声。

参数说明:buf 是接收缓冲区指针,len 是回调里记下的回帧长度。如果解析通过,数据区就是 buf[3] 开始,一共 byte_cnt 个字节。读保持寄存器时,每两个字节是一个 16 位寄存器值,高字节在前,低位在后。

这个解析函数只做了“保底检查”。实际项目里,从机回帧的寄存器数量可能跟你请求的不一致,特别是某些国产设备实现不规范,所以不要默认数据字节数一定等于请求寄存器数的两倍。

5. 主机模式避坑:5 个能让总线瞬间瘫痪的现场级问题

5.1 切回接收的瞬间,最后一字节被截断

现象:主站发请求后,从机完全没有响应,逻辑分析仪上看到请求帧最后一个字节只发了一半就没有电平变化了。

原因:HAL_UART_Transmit 返回后立刻把 DE 拉低,UART 移位寄存器里最后一个字节还没有发完。

解决:在拉低 DE 之前等待 TC 标志置位,也就是前面 rs485_send_frame 里 while 等待的写法。这算是我最早期的血泪经验:当时排查了整整一个下午,以为是波特率问题,最后用示波器看波形才发现是尾巴被切了。

5.2 CRC 计算正确但回帧一直报异常码 0x03

现象:CRC 函数用在线工具核验没问题,但是主机发出去之后从机回了 0x83 0x03,也就是非法数据值。

原因:CRC 值在帧内的字节序放反了。Modbus 规定 CRC 低字节在前,很多参考代码习惯大端输出,直接把返回值高字节先放进帧里,从机算出来 CRC 对不上,自然拒绝。还有一种隐蔽场景:发送完 CRC 后马上切 DE,导致最后一个校验位没发完整,从机也会报非法数据值。

解决:先发低字节,再发高字节,就是 modbus_append_crc 里 frame[len] 和 frame[len+1] 的顺序。验证手段:用“123456789”标准测试串算一遍,CRC 应该是 0x4B37,低字节 0x37 在前。抓到总线报文时,肉眼看到帧尾应该先是 0x37 再是 0x4B。

5.3 回帧明明到了,主机却报超时

现象:从机响应时间大约 50ms,主机超时设了 100ms,结果偶尔还是超时,加大到 300ms 就消失了。

原因:超时计时起点不对。我在最早版本里把 modbus_tick_start 放在 HAL_UART_Transmit 调用之前,包含了整个请求帧发送时间。9600 波特率下发 8 个字节要将近 10ms,如果从机边界响应慢一点,再加上接收中断调度延迟,100ms 根本不够。

解决:把 modbus_tick_start 的赋值挪到发送完成后、DE 切回接收之后。同时给超时设置一个底限:9600 波特率下不要低于 200ms。这不是标准要求,是现场经验——总线上如果挂着变频器,它的响应周期可以蹿到 150ms。

5.4 回帧长度总是对,内容却时不时跳变

现象:主机第一轮解析正常,第二轮开始数据错位,某些字节变成 0xFF 或者 0x00,CRC 还偶尔能通过。

原因:接收缓冲区和上层解析共用同一个数组,HAL_UARTEx_ReceiveToIdle_IT 重新挂接之后,如果主循环还没处理完上一帧,新回帧直接写入了同一片缓冲区,旧数据被覆盖,长度和内容就错位了。

解决:双缓冲。一轮请求用 bufA,下一轮用 bufB,解析完成后再切换。或者更简单的办法:回调里把回帧立刻拷贝到一块独立解析缓冲区,类似 memcpy,保证解析时数据不被覆盖。这属于典型的“缓冲管理没跟上中断节奏”的坑,不是协议本身的坑。

5.5 总线上两台从机共享同一个地址,主站开始收到混合帧

现象:接入第二台从机后,主站每轮请求的结果都在两个值之间跳变,CRC 偶发错误,有时候甚至出现地址匹配却功能码错乱。

原因:两台从机地址相同,同时收到请求后同时应答,两个发送器同时驱动 RS485 总线造成数据碰撞。

解决:逐台上电排查,检查每台设备的地址拨码或者寄存器设置,确保地址唯一。主机侧加一个“地址巡检”逻辑:每次新设备接入时先发一个短请求确认地址没有冲突,冲突就直接拒绝该设备加入轮询表。这个问题在协议栈层面解不了,只能靠现场管理。

6. 多从机轮询调度:把单次读写升级成一张可维护的设备表

单从机跑通之后,很自然会遇到总线上挂多台从机的情况。这时候逐条写请求代码会变成一堆重复逻辑,我习惯先建一张设备表,把所有读写需求集中声明。

typedef struct { uint8_t addr; // 从机地址 uint8_t func; // 功能码 uint16_t reg; // 起始寄存器 uint16_t count; // 寄存器数量 uint16_t interval_ms; // 轮询间隔 uint32_t last_tick; // 上次请求时间 uint8_t *result_buf; // 解析结果存放位置 uint8_t fail_cnt; // 连续失败次数 } mb_poll_item_t;

结构体参数说明:addr 和 func 决定发给谁、干什么;interval_ms 是这个设备的轮询周期,我通常给温控仪表 500ms,给变频器 1000ms,给小型 IO 模块 200ms。result_buf 指向一个和 count 匹配的 16 位数组,解析成功后直接按地址写进去。

调度函数只做一件事:遍历设备表,找有没有到期的记录,有就发送请求:

void mb_poll_schedule(mb_poll_item_t *items, uint8_t item_cnt) { for (uint8_t i = 0; i < item_cnt; i++) { if (mb_state != MB_STATE_IDLE) return; if (HAL_GetTick() - items[i].last_tick >= items[i].interval_ms) { items[i].last_tick = HAL_GetTick(); modbus_request_read(items[i].addr, items[i].reg, items[i].count); if (modbus_wait_done(1000)) { memcpy(items[i].result_buf, &modbus_rx_buf[3], items[i].count * 2); items[i].fail_cnt = 0; } else { items[i].fail_cnt++; } } } }

逻辑说明:每次只处理一个到期设备,处理完立即 return 出循环,避免同一时刻向总线塞两帧请求。modbus_wait_done 内部循环调用 modbus_poll,直到状态机进入 DONE 或 FAULT。

批量读优化建议:如果一个从机上有十几路寄存器要读,不要拆成 10 条记录,而是用一条 0x03 读连续寄存器记录,一次把连续地址读回来,然后映射到应用层结构体。Modbus 单帧最多读 125 个寄存器,覆盖绝大多数设备的参数区。我见过有人把 20 路温度数据拆成 20 次单点读,整条总线被占得满满的,其他从机全被饿死,这属于典型的“协议会用但不会调度”。

组网阶段我习惯先做一个最小验证:一台 USB 转 485 线接一台从机,主机发送读请求,电脑串口助手看回帧。然后再接第二台,逐步往总线上加设备。每一台加进来都验证地址唯一和 CRC 正常,而不是等 20 台一起上再排查。用设备表之后,加从机只改结构体数组,不动协议逻辑,后续维护会舒服很多。这也是我现在每个 Modbus 项目都会先搭轮询表再写业务代码的原因。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:30:38

Unity3D串口通信调试指南:从SerialPort到避坑实战

简介&#xff1a;面向需要对接Arduino、各类传感器等外部硬件设备的Unity3D开发者&#xff0c;提供一套无需经过Java层中转、直接在C#脚本中操作Android串口的完整示例。资源覆盖了串口打开、波特率与数据位等参数配置、数据发送接收、串口关闭等核心流程&#xff0c;并给出拼包…

作者头像 李华
网站建设 2026/10/15 15:30:19

OpenCV 5 vs OpenCV 4:一次讲清所有差异

1.引言 OpenCV 5 不是“4.x 加功能”&#xff0c;而是把二十多年积累的技术债一次性清算&#xff0c;并顺手把 DNN 推理引擎推倒重写——前者决定你要不要改代码&#xff0c;后者决定你要不要升版本。 先看时间线&#xff0c;把版本关系理清楚&#xff1a; 2.总览对比图 …

作者头像 李华
网站建设 2026/10/15 15:30:16

RN8302B智能电表芯片驱动开发与校表实战解析

简介&#xff1a;针对智能电表领域资料较少的RN8302B芯片&#xff0c;这份程序资料包面向有一定嵌入式基础的开发者&#xff0c;旨在帮助其理解芯片特性、通信接口、电源管理及误差校准等关键开发点。压缩包共9个文件、约3.01MB&#xff0c;主要包含C语言源码&#xff08;.c/.h…

作者头像 李华
网站建设 2026/10/15 15:30:14

高校就业小程序全栈开发实战与技术解析

1. 项目背景与核心价值高校就业服务小程序作为连接应届毕业生与用人单位的数字化桥梁&#xff0c;正在成为校园就业指导工作的标配工具。这个毕业设计项目以07523高校为案例&#xff0c;打造了一个集职位推荐、简历投递、宣讲会预约、就业指导于一体的轻量化平台。不同于传统就…

作者头像 李华
网站建设 2026/10/15 15:30:00

Python 与 PyCharm 下载安装全过程

一、Python 下载与安装 1.打开 Python 官方网站 www.python.org&#xff0c;进入下载页面&#xff0c;选择适配 Windows 系统的 Python 安装包进行下载。 2.双击打开 Python 安装程序并选择 Python 的安装位置&#xff0c;点击安装等待进度即可完成。 3 安装结束后&#xff0c;…

作者头像 李华
网站建设 2026/10/15 15:28:10

USB3.1协议实战精要:物理层、LTSSM与事务层协同调优

1. 为什么这份USB3.1协议笔记值得你花时间细读USB3.1协议学习笔记——这六个字背后&#xff0c;不是一纸枯燥的文档堆砌&#xff0c;而是一条横跨硬件设计、固件开发、系统集成与故障排查的实操生命线。我接触过太多项目&#xff1a;某高校实验室做的高速图像采集设备&#xff…

作者头像 李华