尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
STM32F103RE Modbus主从站例程详解:从硬件到联调避坑
简介基于STM32F103RE的Modbus RTU主站与从站完整例程面向嵌入式RS485总线应用开发与入门学习者功能码覆盖01H、02H、03H、04H、06H、10H等常用读写指令串口接收采用队列机制指令处理结构清晰便于理解和二次开发。压缩包内共1513个文件包含KEIL MDKuVision4与IAR EWARM两套完整工程核心源码以c/h文件为主另有s启动文件、hex可执行文件、html/png帮助文档及工程配置脚本体积约13.41MB可直接下载运行也可重新编译调试。目前已有1561人学习下载适合需要快速搭建Modbus通信实验环境或研发RS485设备的开发者。通过该例程可同时获得主站与从站双向通信的完整实现包含底层串口队列收发、报文解析与应答构造还附带用IAR和KEIL分别编译好的hex程序省去环境配置时间直接烧录即可验证通信效果。1. STM32F103RE上把Modbus主站和从站例程跑通先从硬件底子说起一块 72 MHz 的 STM32F103RE 开发板外挂一颗 SP3485 转成 RS-485 总线烧进 Modbus RTU 例程后它就能在同一根总线上扮演两种角色作为从站接受主站读写保持寄存器作为主站轮询其他设备并处理异常超时。听上去只是把串口数据套进一个协议壳里但实际调起来你会发现帧结束怎么判、CRC 高低字节怎么放、RS-485 方向怎么切、超时多久算失败这些参数随便错一个通信就变成了玄学。这篇笔记就是给手里有 F103RE 板子、打算对接 PLC、HMI 或采集终端的嵌入式工程师看的我会把主站和从站例程里最核心的代码拆开说明再把联调时的坑按现象、原因、解决三步写清楚。2. 硬件底子先理顺F103RE的串口资源、RS-485方向脚和3.5字符时间2.1 为什么F103RE上几乎都是Modbus RTUSTM32F103RE 这颗 Cortex-M3 芯片没有以太网 MAC官方也没有把 Modbus TCP 直接集成在外设里。如果你一定要走 Modbus TCP需要外挂 ENC28J60 或 W5500 再叠一套 TCP/IP 协议栈工程复杂度完全不一样。所以标题里的stm32f103re modbus主站和从站例程在绝大多数从业语境里指的就是串口上的 Modbus RTU。Modbus RTU 是二进制的紧凑帧格式帧短、CRC16 校验强度足够72 MHz 主频来算 CRC 压力极小。Modbus RTU 和 Modbus ASCII 也是两个容易混的概念。RTU 是二进制帧ASCII 是字符帧RTU 效率更高但调试时人眼难读ASCII 每字节都要转两个十六进制字符、效率低一半。国内工控项目里 RTU 占绝对多数例程也基本只做 RTU。所以下文所有代码都围绕 RTU 展开不再多说 ASCII。2.2 RTU帧格式和3.5字符时间的关系Modbus RTU 报文结构如下字段长度说明从站地址1字节从站地址 1~2470 为广播地址248~255 保留功能码1字节03 读保持寄存器06 写单寄存器01 读线圈05 写单线圈等数据N字节寄存器地址、数量、值等CRC162字节低字节在前高字节在后帧和帧之间的最小间隔是 3.5 个字符时间。3.5 字符时间怎么算以 9600 波特率、8 数据位、无校验、1 停止位为例一个字符的位数是 1 起始位 8 数据位 1 停止位 11 bit一个字符用时 11 / 9600 ≈ 1.146 ms3.5 个字符就是约 4.0 ms。也就是说每收到一个字节后如果在 4.0 ms 内没有下一个字节到达一个完整的 RTU 帧就结束了。如果你的代码用 1 ms 的 HAL 节拍去做这个判断9600 波特率下勉强够用到了 115200 波特率一个字符只有约 0.096 ms3.5 个字符约 0.33 ms1 ms 精度完全不够必须换定时器方案。这个点在后面的避坑章里还会专门展开。从站侧收到一帧后先校验 CRC再校验地址和功能码主站侧则是自己构帧发送然后等待匹配的应答帧。两者共用同一个 CRC16 算法所以第一步先把这个纯软件算法写好主从站都能用。2.3 引脚分配PA9/PA10和RS-485收发器在代码层面用 F103RE 的 USART1 还是 USART2取决于你板子上哪组引脚方便接 RS-485 芯片。USART1 的 TX 是 PA9、RX 是 PA10挂在 APB2 上时钟 72 MHzUSART2 的 TX 是 PA2、RX 是 PA3挂在 APB1 上时钟 36 MHz。两者区别不大但如果你想和外部设备共用调试串口常见做法是 USART1 接 RS-485USART2 留做打印。这里为了让代码少绕弯约定下面示例全部用 USART1。RS-485 收发器常见选 MAX3485 或 SP3485是 3.3 V 电平的。A、B 两线出总线DE 和 RE 通常短接成一个方向脚置高为发送置低为接收。有的板子把 RE 接地、只控制 DE这也可以但要注意切换方向到接收时收发器靠内部偏置不一定立刻稳定。A/B 之间最好有 120 欧终端电阻特别是总线两端的设备没有终端电阻在高波特率下会出现波形反射表现为 CRC 偶发错误。波特率也在这里定9600、19200 和 115200 都常见默认 9600 最稳也最容易跟 Modbus Poll 这类工具对接。帧格式 8N1 是默认但很多 PLC 走 Modbus RTU 时会配偶校验或奇校验这一点要在接线前跟对端确认否则收回来全是 CRC 错。2.4 用CubeMX生成底盘串口、方向脚和定时器CubeMX 配置步骤软件包选 STM32F103RERCC 的 HSE 选 Crystal/Ceramic ResonatorSYS 里 Debug 选 Serial Wire时钟树里把 HCLK 设到 72 MHz让系统自动调整分频USART1Mode 选 AsynchronousBaudRate 9600Word Length 8Parity NoneStop 1GPIO 拉出一个引脚比如 PC0 作为 RS485_DIR输出模式初始为 Low对应接收状态如果打算用定时器做帧间隔TIM2 使能Prescaler 和 Period 按 1 ms 或 0.1 ms 中断配置NVIC 里使能 USART1 global interrupt 和 TIM2 global interrupt生成代码后检查static void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 9600; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; HAL_UART_Init(huart1); __HAL_UART_ENABLE_IT(huart1, UART_IT_RXNE); }这段初始化里BaudRate 和 Parity 只要改错一个Modbus 主站发过去的帧从站就收不出来跟 PC 工具对不上时先核对这里。最后一行 __HAL_UART_ENABLE_IT 很重要CubeMX 默认不会在生成的代码里打开 RXNE 中断使能漏了的话接收逻辑永远不会触发。GPIO 方向脚初始化后代码里每次发送前调用 RS485_DIR_TX()发送完等待 TC 再调用 RS485_DIR_RX()这两个宏可以定义成#define RS485_DIR_PORT GPIOC #define RS485_DIR_PIN GPIO_PIN_0 #define RS485_DIR_TX() HAL_GPIO_WritePin(RS485_DIR_PORT, RS485_DIR_PIN, GPIO_PIN_SET) #define RS485_DIR_RX() HAL_GPIO_WritePin(RS485_DIR_PORT, RS485_DIR_PIN, GPIO_PIN_RESET)方向脚初始为低这个细节容易被忽略。如果上电时 DE 处于高电平F103RE 刚上电引脚还没配置时会在总线上表现为发送状态可能把一个随机字节发到总线上干扰其他设备。所以 CubeMX 里要把初始电平设成 Low然后再配置成输出。3. 从站例程拆解状态机、CRC16、帧结束判断和应答3.1 从站的功能码最小集从站例程的职责就是等待、解析、应答。功能码不需要一开始全做通常第一个里程碑只做两个03 读保持寄存器、06 写单寄存器再加 01 和 05 做线圈。功能码名称最小报文0x01读线圈请求 8 字节响应长度取决于线圈数0x05写单线圈请求 8 字节成功应答原帧返回0x03读保持寄存器请求 8 字节响应 3 2*N 字节0x06写单寄存器请求 8 字节成功应答原帧返回0x10写多寄存器请求字节数更多成功应答固定 8 字节例程里先做 03 和 06把寄存器数组跑通后再扩展其他功能码只是增加一个分支。从站内部开一个全局数组例如uint16_t holding_reg[64];作为保持寄存器映射。从站处理一帧的流程也定下来接收完成 → 校验 CRC → 比对地址 → 解析功能码 → 检查数据范围是否越界 → 组响应帧 → 方向脚切发送 → 发完切回。3.2 CRC16逐位实现uint16_t modbus_crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; for (i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }参数说明data 是待计算的数据指针len 是字节数计算范围不含 CRC 本身。调用时传入帧去掉最后两字节的部分。逐位算法适合学习和验证实际工程中如果每帧每秒都要计算成百上千次通常用查表法替代。查表法就是把每个字节的 CRC16 事先算好存成 256 项的 16 位数组把内层的 8 次位循环变成一次查表加一次异或。F103RE 72 MHz 跑逐位也毫无压力例程里为了让逻辑直白用逐位没问题。CRC 的低字节在前这条规则是 Modbus RTU 的硬性要求算出来是 0x1234帧里先放 0x34 再放 0x12。很多第一次调不通的人就是在这里翻车PC 端调试助手会直接提示 CRC 错误但自己看代码又看不出问题。3.3 从站接收用帧间隙判断一帧的结束Modbus RTU 没有帧头也没有帧尾靠时间间隙判断帧结束。下面是一个基于 HAL_GetTick 的冒烟版#define MODBUS_RX_BUF_SIZE 128 #define FRAME_GAP_MS 5 uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; uint16_t modbus_rx_len 0; volatile uint8_t modbus_frame_ready 0; uint32_t modbus_last_rx_tick 0; void USART1_IRQHandler(void) { uint8_t data; if (__HAL_UART_GET_IT_SOURCE(huart1, UART_IT_RXNE) __HAL_UART_GET_IT(huart1, UART_IT_RXNE)) { data (uint8_t)(huart1.Instance-DR 0xFF); uint32_t now HAL_GetTick(); if ((now - modbus_last_rx_tick) FRAME_GAP_MS) { modbus_rx_len 0; // 上一帧已经结束这是新帧的第一字节 } if (modbus_rx_len MODBUS_RX_BUF_SIZE) { modbus_rx_buf[modbus_rx_len] data; } modbus_last_rx_tick now; __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_RXNE); } }三个参数值得注意。FRAME_GAP_MS 取 5 ms 对 9600 波特率够用但如果波特率提到 1152003.5 字符时间只有约 0.33 ms再用 5 ms 判断会把两个连续帧拼成一帧。更稳的做法是用定时器捕获字节间间隔而不是靠 HAL_GetTick 的 1 ms 精度。对 9600 波特率的入门例程5 ms 是一个不会出错的折中值。接收缓冲 128 字节对 RTU 的常用报文足够。Modbus RTU 最大帧一般定义为 256 字节但常见读寄存器请求只有 8 字节响应最多 125 个寄存器也就是 253 字节128 字节在例程里先够用。注意如果一帧超过缓冲上限后面的字节会被丢弃调试时看现象会像随机丢包。提示Modbus 报文里的多字节整数是高字节在前CRC 是低字节在前两者不要混。这也是例程里最容易出现隐蔽 bug 的地方。3.4 主循环里处理一帧请求并构造应答一帧接收完成后modbus_frame_ready 置位。主循环每几毫秒检查一次然后解析、处理、应答void modbus_slave_poll(void) { uint8_t response[MODBUS_RX_BUF_SIZE]; uint16_t resp_len 0; uint16_t crc_recv, crc_calc; uint8_t addr, func; uint16_t reg_addr, reg_cnt, i; if (!modbus_frame_ready) return; modbus_frame_ready 0; if (modbus_rx_len 4) return; // 最短帧都是 地址功能码CRC2 crc_recv (uint16_t)(modbus_rx_buf[modbus_rx_len - 2]) | (uint16_t)(modbus_rx_buf[modbus_rx_len - 1] 8); crc_calc modbus_crc16(modbus_rx_buf, modbus_rx_len - 2); if (crc_recv ! crc_calc) return; addr modbus_rx_buf[0]; if (addr ! MODBUS_SLAVE_ADDR) return; func modbus_rx_buf[1]; if (func 0x03) { reg_addr (uint16_t)(modbus_rx_buf[2] 8) | modbus_rx_buf[3]; reg_cnt (uint16_t)(modbus_rx_buf[4] 8) | modbus_rx_buf[5]; if ((reg_addr reg_cnt) MODBUS_REG_COUNT) { response[0] MODBUS_SLAVE_ADDR; response[1] 0x83; response[2] 0x02; // 非法数据地址 resp_len 3; } else { response[0] MODBUS_SLAVE_ADDR; response[1] 0x03; response[2] (uint8_t)(reg_cnt * 2); for (i 0; i reg_cnt; i) { response[3 i * 2] (uint8_t)(holding_reg[reg_addr i] 8); response[4 i * 2] (uint8_t)(holding_reg[reg_addr i] 0xFF); } resp_len 3 reg_cnt * 2; } } else if (func 0x06) { reg_addr (uint16_t)(modbus_rx_buf[2] 8) | modbus_rx_buf[3]; holding_reg[reg_addr] (uint16_t)(modbus_rx_buf[4] 8) | modbus_rx_buf[5]; for (i 0; i modbus_rx_len; i) response[i] modbus_rx_buf[i]; resp_len modbus_rx_len; } else { response[0] MODBUS_SLAVE_ADDR; response[1] func | 0x80; response[2] 0x01; // 非法功能码 resp_len 3; } crc_calc modbus_crc16(response, resp_len); response[resp_len] (uint8_t)(crc_calc 0xFF); response[resp_len 1] (uint8_t)(crc_calc 8); RS485_DIR_TX(); for (i 0; i resp_len 2; i) { while (!(huart1.Instance-SR UART_FLAG_TXE)) ; huart1.Instance-DR response[i]; } while (!(huart1.Instance-SR UART_FLAG_TC)) ; RS485_DIR_RX(); }这里有几个关键点。第一reg_addr 和 reg_cnt 的组合要小心协议里的字节序报文里寄存器地址是高字节在前所以组合时要(buf[2] 8) | buf[3]。第二Modbus 协议手册里的寄存器编号从 1 开始报文里的地址字段从 0 开始。例程里如果用户访问地址 0001报文里写的是 0x0000映射到 holding_reg[0]。如果你直接拿报文地址当数组下标第一次读就会偏移一位。我一般定义#define MODBUS_REG_OFFSET 1实际取数时用holding_reg[reg_addr - MODBUS_REG_OFFSET]这样和 PC 端工具显示的就对上了。第三从站返回 CRC 时也是低字节在前所以先放 crc_calc 0xFF再放 crc_calc 8。发送的最后一行等 TC 再切方向这个动作我后面还会反复强调。因为 TXE 标志只代表数据从寄存器移到了移位寄存器不代表已经从 TX 引脚完全移出。如果切得太早最后一个字节会缺尾巴。3.5 用Modbus Poll验证从站从站写完后先用 PC 验证再上总线。Modbus Poll 是常见的 PC 端主站模拟工具可以配置串口、波特率、从站地址然后读 03 功能码。验证步骤USB 转 485 接 F103RE 板子的 485 端子A 接 A、B 接 BModbus Poll 里选串口、9600、8N1从站地址填例程里的 MODBUS_SLAVE_ADDR比如 0x01读保持寄存器起始地址 0数量 10如果一切正常界面里会出现 10 个寄存器值。此时用 06 功能码单点写再看寄存器值是否变化。Modbus Poll 能通说明从站的 CRC、接收、应答发送三条链路都正常。以后主站和从站联调出问题经常会回到这一步重新验证所以这个测试环境尽量保留好。4. 主站例程拆解从单次请求到多从站轮询4.1 主站不能写成发完死等主站例程和从站例程最大的区别在于从站是被动应答主站要管理什么时候发请求、发了之后等多久、没等到怎么办、多台从站怎么轮询。很多 F103RE 的例程把主站做成发一帧、死等应答、收到就继续、收不到就卡住这在 demo 里能跑接到真实总线上会把整个链路堵死。所以主站的核心是用状态机替代死等。主站状态机五个状态IDLE没有任务准备构造下一帧请求SENDING正在发送请求帧发完立刻进 WAITINGWAITING等待应答同时检查超时PROCESSING收到应答且通过 CRC 校验处理数据TIMEOUT等待超时记一次错误进入重试或跳过4.2 构帧与发送请求帧的字节序主站要向从站发读保持寄存器请求典型请求帧为 地址(1B) 功能码(1B) 起始地址(2B) 寄存器数量(2B) CRC(2B) 8 字节uint8_t req[8]; uint16_t crc; req[0] 0x01; // 从站地址 req[1] 0x03; // 功能码读保持寄存器 req[2] 0x00; // 起始地址高字节 req[3] 0x00; // 起始地址低字节 req[4] 0x00; // 寄存器数量高字节 req[5] 0x0A; // 读10个寄存器 crc modbus_crc16(req, 6); req[6] (uint8_t)(crc 0xFF); // CRC低字节在前 req[7] (uint8_t)(crc 8); RS485_DIR_TX(); for (uint8_t i 0; i 8; i) { while (!(huart1.Instance-SR UART_FLAG_TXE)) ; huart1.Instance-DR req[i]; } while (!(huart1.Instance-SR UART_FLAG_TC)) ; RS485_DIR_RX();这里的参数注意起始地址 0x0000 对应从站内部数组的第一个元素寄存器数量最大不能超过 125这是 Modbus 协议对 03 功能码响应帧 253 字节的上限决定的。如果一次读 200 个寄存器从站会回异常码需要分两次发请求。另外主站构帧时填的从站地址不要填 0广播帧主要用于写命令且不允许应答在例程里先不涉及。4.3 应答帧解析与异常码主站收到应答后不能只校验 CRC 就完事。要做四件事检查应答帧的从站地址是否等于自己请求的地址检查功能码是否等于请求的功能码或者功能码 | 0x80检查数据长度是否匹配如果出现异常响应功能码是 0x83后面的字节是异常码异常码是联调时的重要眼力0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。看到哪个码就知道问题在哪一端。简化版解析代码uint8_t mb_parse_response(uint8_t *resp, uint16_t resp_len, uint8_t req_func) { uint16_t crc_recv, crc_calc; if (resp_len 8) return MB_ERR_SHORT_FRAME; crc_recv resp[resp_len - 2] | (resp[resp_len - 1] 8); crc_calc modbus_crc16(resp, resp_len - 2); if (crc_recv ! crc_calc) return MB_ERR_CRC; if (resp[0] ! mb_current_addr) return MB_ERR_ADDR; if (resp[1] (req_func | 0x80)) return MB_ERR_EXCEPTION; // 异常响应resp[2]是异常码 if (resp[1] ! req_func) return MB_ERR_FUNC; return MB_OK; }应答帧里的寄存器值同样是高字节在前解析成 16 位整数时用(buf[i] 8) | buf[i1]。4.4 超时重试与轮询间隔的取舍Modbus RTU 主站等待应答的超时时间取决于从站处理能力。F103RE 做从站时处理速度很快但真实从站可能是 PLC 或第三方仪表处理时间几十到几百毫秒不等。我的参数经验是参数推荐值说明应答超时100~500 ms首次接入时调大跑稳后可以降重试次数2~3 次超过后标记错误并跳到下一台从站轮询间隔10~100 ms太快会让从站过载太慢响应跟不上帧间超时3.5 字符时间9600 波特率约 4 ms115200 约 0.33 ms超时判断放在主循环里发送完成后记录 start_tick每轮循环检查 HAL_GetTick() - start_tick 是否超过 timeout_ms。如果超时且重试次数没到把状态机打回 SENDING 再发一次。注意重试时请求帧里应该维护一个重试计数方便在逻辑分析仪上看主站的重发行为。4.5 多从站轮询的一个简洁实现实际项目里主站通常要轮询多个地址。最简单的做法是维护一张从站地址表状态机在 IDLE 状态切换目标地址#define POLL_TABLE_SIZE 3 uint8_t poll_table[POLL_TABLE_SIZE] {0x01, 0x02, 0x03}; uint8_t poll_index 0; uint8_t retry_cnt 0; #define MAX_RETRY 2 void modbus_master_poll(void) { static uint8_t req[8]; switch (mb_state) { case MB_IDLE: mb_current_addr poll_table[poll_index]; modbus_build_read_req(req, mb_current_addr, 0x0000, 10); RS485_DIR_TX(); modbus_send_bytes(req, 8); mb_state MB_WAITING; mb_start_tick HAL_GetTick(); break; case MB_WAITING: if (modbus_rx_ready) { modbus_rx_ready 0; if (mb_parse_response(modbus_rx_buf, modbus_rx_len, 0x03) MB_OK) { memcpy(held_data, modbus_rx_buf 3, modbus_rx_len - 5); } retry_cnt 0; poll_index (poll_index 1) % POLL_TABLE_SIZE; mb_state MB_IDLE; } else if (HAL_GetTick() - mb_start_tick mb_timeout_ms) { if (retry_cnt MAX_RETRY) { mb_state MB_IDLE; // 重新进入发送相当于重发 } else { retry_cnt 0; poll_index (poll_index 1) % POLL_TABLE_SIZE; mb_state MB_IDLE; } } break; default: break; } }这个轮询状态机把不要死等做进去了。很多主站例程之所以接上真设备就不灵就是因为等待应答时空转 HAL_Delay把整个单片机主循环卡住串口中断再及时也没用。如果做项目我一般会用 FreeRTOS 的队列或信号量来同步串口中断和主站任务但裸机例程用这个思路也一样成立中断只收数据和置标志主循环消费标志并驱动状态机。这个分离原则在把主站和从站并进同一个工程时依然管用。5. 主从站联调避坑RS-485方向切换、帧间隔和中断优先级这部分是我做 Modbus 主从站联调攒下来的血泪经验每一条都按现象、原因、解决三步写照着排查能省下不少时间。5.1 现象两块F103RE通过RS-485互联主站发请求后从站一直不应答同一个从站用 Modbus Poll 就能正常应答但自己的主站连上就不通。原因基本出在主站发送完请求后没有等 TC 就切 RS-485 方向为接收。虽然最后一个字节已经从数据寄存器移到了移位寄存器但总线驱动状态切换需要时间A/B 线上最后一位可能还没走完就被截断从站侧收到的帧 CRC 校验失败从站直接丢弃主站等不到应答。解决发送末字节后等 TC 标志位然后加 50 微秒延时再切方向。在 4.2 的代码里就是while (!(huart1.Instance-SR UART_FLAG_TC)) ;那一行。这个 50 us 延时不能省硬件上 RS-485 收发器从 DE 高切到低总线电平需要一段稳定时间如果直接用 HAL_Delay(1) 也没问题但 1 ms 的粒度会让轮询周期变慢所以用 for 循环或定时器做微秒级延时更合适。5.2 现象主站和从站都是9600波特率但每隔几帧就发生一次超时重试后又恢复用逻辑分析仪抓总线能看到主站的请求帧和从站的应答帧之间有时隔得很近有时隔得远应答偶尔被拆成两段。原因在于帧结束判断用了 HAL_GetTick 的 1 ms 精度9600 波特率下 3.5 字符时间约 4 ms看起来没问题但 HAL_GetTick 的更新依赖 SysTick 中断如果主循环里某个分支耗时超过了 1 ms串口中断里的时间戳就会滞后导致帧边界判断出错。解决把帧结束判断放到 TIM 定时器中断里。收到字节时给定时器重新赋初值定时器溢出表示帧结束。用定时器而不是 HAL_GetTick 后115200 波特率下也不会再出现拆帧。核心思路是void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_UPDATE)) { TIM_ClearITPendingBit(TIM2, TIM_IT_UPDATE); if (modbus_receiving) { modbus_rx_len 0; modbus_receiving 0; modbus_frame_ready 1; } } } // 在串口接收中断里 TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE);定时器溢出意味着距离上一个字节已经超过了一帧的间隔此时把当前缓冲认定为一帧。定时器预分频要按波特率算好9600 波特率可以用 5 ms115200 波特率建议 1 ms。这个做法更接近 Modbus 协议标准主站和从站都要用。5.3 现象从站能收到请求并构造好应答但总线上看不到完整帧逻辑分析仪只见前几个字节原因是从站发送时用 TXE 判断最后一字节是否发完TXE 置位只代表数据进入移位寄存器不代表移位寄存器已经送完。如果此时把 RS-485 方向脚切回接收总线上最后一个字节的停止位会被切断从站自己的响应帧在总线上不完整主站当然收不到。解决从站发送函数的结尾顺序应该是等 TXE 发完所有字节最后再等 TC然后切方向。这个顺序和 5.1 类似但从站侧更容易踩坑因为不少人会把 HAL_UART_Transmit 结束后就切方向而轮询模式下这个函数返回时最后一个字节可能还在移位寄存器里。用寄存器操作时记得最后补上while (!(USART1-SR UART_FLAG_TC));。5.4 现象跑一段时间后串口中断丢失收到的帧长度固定少几个字节原因通常是 USART1 中断优先级配置得比其他外设中断低或者串口中断处理时间太长导致新的字节到达 DR 时 RXNE 还没被处理数据直接覆盖。有的代码在中断里调用 HAL_GetTick 或打印调试信息这会进一步拉长中断时间丢字节几乎必然。解决在 CubeMX 的 NVIC 里把 USART1_IRQn 的抢占优先级调到 0 或 1确保串口每字节都能及时处理。中断里只做三件事读 DR、写缓冲、更新时间戳。CRC、协议解析、应答构造全部放主循环。还有一个细节如果同时开了多个串口中断它们的中断服务函数里要分别判断自己的中断标志避免互相误触发。5.5 现象03功能码读回来的寄存器值总是相差一个地址比如期望读寄存器 1 到 5实际读回来的是 0 到 4 的数据。原因是 Modbus 协议手册里的寄存器编号从 1 开始而报文里的起始地址字段从 0 开始很多例程直接拿报文里的 reg_addr 当数组下标导致整体偏移一位。这类问题最常见于从站侧主站侧的起始地址填 0 或填 1从站解析后都要统一处理。解决从站侧定义#define MODBUS_REG_OFFSET 1数组下标统一写reg_addr - MODBUS_REG_OFFSET。如果寄存器数量多且地址不连续可以用一张映射表表里存协议地址 → 数组下标。用 Modbus Slave 模拟器去验证主站时这个问题会一下暴露出来因为模拟器会严格按协议地址 1 来映射。6. 主站从站同工程的组织技巧两块板子互联自测如果只有两块 F103RE 板子想同时验证主站和从站例程我一般会在工程里放一份公共的 modbus_crc.c、modbus_frame.c再加两份角色模块用编译宏切角色。这样代码复用率高而且主站和从站用的 CRC、帧缓冲、帧结束判断逻辑完全一致联调时少一类两边计算方式不一样的诡异问题。#define MODBUS_ROLE_MASTER 1 #if MODBUS_ROLE_MASTER #define MODBUS_UART huart1 #define MODBUS_DIR GPIO_PIN_0 #else #define MODBUS_UART huart2 #define MODBUS_DIR GPIO_PIN_1 #endif用宏切角色的做法在裸机工程里最轻量。主站和从站共用同一份modbus_crc16()、同一份modbus_frame_ready检测逻辑只是主循环里调用的任务函数不同。如果你以后想升级到 FreeRTOS这个拆分也能直接映射成两个独立任务一个跑主站状态机一个跑从站状态机。自测时的接线也简单两块 F103RE 的板子USART1 做主站、USART2 做从站用杜邦线把 PA9 和 PA3 交叉、PA10 和 PA2 交叉然后共地。TTL 电平对连不需要接 RS-485 收发器但要在程序里的方向控制逻辑上继续保留这样代码和后面接 485 总线时行为一致。主站发 03 读请求从站应答主站把读到的寄存器值打印到调试串口就能看到一个完整回路。我自己做这组例程时的顺序是先用 Modbus Poll 验证从站再用 Modbus Slave 验证主站两边各自通了之后才两块板子互联。第一次直接板对板联调浪费了两个小时最后发现就是 RS-485 方向切换太快而 PC 工具天然没有这个时序问题。这个顺序帮我后来省下了大量查线时间也建议你按这个顺序来做。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

AI大模型入门:原理、应用与学习路径

AI大模型入门:原理、应用与学习路径

1. 项目概述"《每天10分钟,AI讲人话》"是一个面向AI初学者的系列科普项目,Day1的主题"AI大模型到底是什么?"旨在用通俗易懂的方式解释当前最热门的AI技术概念。这个10分钟的学习单元设计得非常巧妙——既保证了知识摄入的…

📅 2026/10/29 17:13:01
全域轨迹跟踪技术在安防监控中的创新应用

全域轨迹跟踪技术在安防监控中的创新应用

1. 项目概述:全域轨迹跟踪技术如何重塑安防体系在传统安防监控领域,我们常常面临一个核心痛点:被动响应。当事件发生时,系统只能提供事后追溯的视频片段,而无法在事态发展过程中进行主动干预。这正是全域轨迹跟踪技术要…

📅 2026/10/29 17:13:01
PHP H5游戏联运推广平台源码部署实战:从解压到佣金结算

PHP H5游戏联运推广平台源码部署实战:从解压到佣金结算

简介:面向游戏开发者与联运运营商的H5游戏联运推广平台PHP源码,基于PHP 5.6与MySQL 5.6构建,定位帮助快速搭建集游戏接入、推广管理、分成结算、数据分析于一体的运营系统。包体共2000个文件,其中608个PHP文件承担核心业务逻辑&am…

📅 2026/10/29 17:13:01
MORE NEWS

更多资讯

📰

python神经网络Keras实现GRU及其参数量

神经网络Keras实现GRU及其参数量更新时间是二零二二年五月七日十时三十四分二十七秒, 作者是。这篇文章主要是为广大的朋友们介绍了如何利用Keras框架来实现GRU神经网络, 并且还涉及到了其参数量相关的内容, 对于有需要的朋友们来说, 这个可以作为借鉴和参考的素材, 希望能够帮…

📰

CodeGraph: Open‑Taxonomy Knowledge Graph for Source Code with Wikidata Grounding

# CodeGraph: Open‑Taxonomy Knowledge Graph for Source Code with Wikidata Grounding> 论文arXiv链接:[https://arxiv.org/abs/2609.29474](https://arxiv.org/abs/2609.29474),会议:CIKM 2026 ## 一、论文主要内容总结该论文提出**CodeGraph**,这是首个面向源代码…

📰

Rationale‑Guided Policy Optimization(RGPO)论文总结与翻译

# Rationale‑Guided Policy Optimization(RGPO)论文总结与翻译> 论文信息:arXiv:2610.07342|NeurIPS 2026|[论文仓库](https://github.com/VietHoang1512/rgpo) ## 一、文章主要内容本文针对**大模型推理任务上在线强化学习(RLVR)存在的奖励稀疏问题**开展研究。在…

📰

Component and Dimension Sparsity in Transformer Refusal Mechanisms 论文总结

# Component and Dimension Sparsity in Transformer Refusal Mechanisms 论文总结> 论文信息:arXiv:2610.06903,接收于COLM 2026,作者:Vincent Siu, Glenn Grant‑Richards, Vlad Pavlovich, Yizhou Sun, Dawn Song, Chenguang Wang ## 一、文章主要内容本文针对大语言…

📰

(已解决)关于fpga的时序收敛问题记录

问题记录 1、Axi stream或者Axi Lite和逻辑通信时,因时钟时序不匹配,造成时序不收敛。 2、从别处考来的工程中,自带使用set_clock_groups -asynchronous组时,-of objects[get pins U_xxxx/xxxx…]&#xff…

📰

Druid 连接池调优:5 个步骤把连接获取与慢 SQL 压到毫秒级

Druid 连接池调优:5 个步骤把连接获取与慢 SQL 压到毫秒级 【免费下载链接】druid 阿里云计算平台DataWorks(https://help.aliyun.com/document_detail/137663.html) 团队出品,为监控而生的数据库连接池 项目地址: https://gitcode.com/gh_mirrors/dru…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

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

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

📞 💬