首页 / 资讯中心 / 文章详情

STM32 Modbus RTU从机测试:CRC与T3.5帧间隔

STM32 Modbus RTU从机测试:CRC与T3.5帧间隔 ★ FEATURED ARTICLE
简介电子-modbusTest.zip 是一份面向单片机与嵌入式初学者的 STM32 通信实验工程包聚焦在 STM32F0/F1/F2 平台上实现 Modbus 串行通信测试适合已具备 C 语言与基础外设编程能力、希望理解工业通信协议实际实现的开发者参考。压缩包共 70 个文件约 727KB包含 9 个 .c 与 9 个 .h 源文件、若干 .o/.crf/.d 编译中间文件以及 Keil 工程配置 .uvproj/.uvopt、启动文件 .s、烧录用 .hex、链接映射 .map 与列表 .lst 等从源码、工程到可执行产物较为完整。目录预览可见 MODBUS、USART、DMA、TIMER、LED、LCD 等模块划分便于梳理串口收发、寄存器读写与主从应答的实现脉络。目前已有 165 人学习适合作为 Modbus RTU 调试、工程编译下载与通信排错的入门样本。1. 从一块最小系统板说起modbusTest 到底解决什么问题有位做工业采集的开发者手里三块板子挂在同一条 485 总线上用串口助手手工敲报文排查了两天最后发现问题不在协议本身而在从机应答后没有及时释放总线。这个 modbusTest 工程就是那种先把协议跑通再谈业务的起点一份面向 STM32F0/F1/F2 系列、主从角色可切换的 Modbus RTU/ASCII 测试代码把 CRC 校验、3.5 字符帧间隔、功能码分发这些最容易翻车的环节单独拎出来给到你一个能烧进板子、用调试上位机一问一答的最小闭环。它适合刚开始接触 485 通讯的嵌入式新手跟着跑一遍也适合手上有个老工程、要把 Modbus 从机部分拆出来重写的熟手对照着看结构。它不是能直接投产的完整协议栈但它把黑匣子拆开摆在桌面上了。2. 工程骨架从压缩包目录到四层协议分工拿到压缩包解压之后第一件事不是双击打开工程文件编译而是先把目录扫一遍。原因是这类测试工程往往带着作者自己的习惯和裁剪痕迹你要先判断哪些文件是给从机用的、哪些是给主机用的、哪些是纯硬件适配层才好决定动哪里。这个工程的目录组织比较克制没有做成一个大杂烩而是按硬件相关 / 协议无关 / 业务相关三层往下拆另外单独留了一个配置头文件做编译期开关。2.1 目录清单与每个文件的职责解压后的目录大致是这样分布的不同版本可能略有出入但主干结构不会变modbusTest/ ├── Project/ 工程文件与启动文件 │ ├── startup_stm32f10x_md.s F1 中容量启动文件 │ └── modbusTest.uvprojx 集成开发环境工程 ├── Libraries/ MCU 官方外设库CMSIS StdPeriph ├── User/ │ ├── main.c 初始化顺序与主循环 │ ├── stm32f10x_it.c 中断向量实现 │ └── mb_config.h 协议栈编译期配置 ├── Modbus/ │ ├── mb.c / mb.h 协议核心帧组装与解析 │ ├── mb_rtu.c RTU 模式收发与定时 │ ├── mb_ascii.c ASCII 模式 LRC 与编解码 │ ├── mb_crc.c CRC16 查表实现 │ └── mb_port.c 与 MCU 相关的串口、定时器、485 方向脚 └── App/ ├── app_reg.c 寄存器映射表与读写钩子 └── app_task.c 业务层轮询任务这份结构里最值得关注的是mb_port.c和app_reg.c的分工。前者是唯一允许出现寄存器名和引脚号的文件后者只关心0x0001 这个地址对应我哪个变量。把这两件事分开意味着你换一块板子只需要重写mb_port.c换一套业务逻辑只需要改app_reg.c中间那一层协议代码基本不用动。实际项目里见过太多人把串口初始化直接写在协议解析函数里迁到新平台就是一场灾难这个工程一开始就规避了这个问题。Libraries目录通常是别人最不愿意看的但它决定了你的工程能不能编译通过。常见做法是把 CMSIS 的核心文件、system_stm32f10x.c 和 StdPeriph 里真正用到的那几个外设源文件GPIO、RCC、USART、TIM拉进来就行没必要整个库全加进去那样编译时间会明显变长而且容易撞上中断向量重定义的问题。2.2 四层结构一个字节从引脚到寄存器数组的路径把这个工程的调用关系画成一条链路比看目录更清楚。从下往上依次是层级所在文件职责改动频率物理层mb_port.c485 方向脚电平、串口寄存器读写换板子才改链路层mb_rtu.c帧起始判定、字节流收拢、CRC 校验基本不改协议层mb.c从机地址过滤、功能码分发、异常应答加功能码才改应用层app_reg.c寄存器地址到变量的映射每次业务变更都改这个分层的关键在于链路层不关心报文内容是什么它只负责把一串字节变成一帧完整字节协议层不关心寄存器里存的是温度还是电机转速它只按功能码把 payload 交上去。中间这条边界划清楚了后面调试的时候你就能快速定位问题在哪一层——收不到数据是物理层收到乱码是链路层收到 CRC 正确但回异常码是协议层回的数据不对才是应用层。主循环里的调用顺序也很朴素main.c里基本就是初始化完进 while(1)每轮检查一下接收上下文有没有标记为帧就绪就绪就调用一次协议处理处理完清标志。没有用 RTOS也没有用状态机框架就是最直白的轮询。对于 Modbus 这种一问一答、从机不主动发起的协议轮询完全够用反而是加 RTOS 之后要额外处理信号量和优先级反转纯属给自己找事。int main(void) { /* 顺序不能乱先时钟再 GPIO再串口和定时器 */ SystemInit(); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RS485_GPIO_Init(); /* DE/RE 方向脚默认接收 */ USART2_Config(9600); /* 485 侧串口常用 9600 起步 */ TIM3_Config(); /* T3.5 帧间隔计时100us 节拍 */ Modbus_Init(0x01); /* 从机地址见 mb_config.h */ while (1) { /* 非阻塞只有链路层判定帧收完才返回真 */ if (Modbus_Poll() MB_OK) { Modbus_SlaveProcess(); /* 内部完成解析、组帧、发送 */ } App_Task_Run(); /* 业务采集与协议解耦 */ } }这段初始化里有个容易被忽略的细节RS485_GPIO_Init()必须放在串口配置之前。485 芯片的方向脚在上电瞬间是高阻或者随机电平如果先初始化串口并打开了接收方向脚还没拉低总线上的其他从机应答就会在你这块板子上被误判成自己的数据运气不好直接进溢出中断。这也是为什么工程里把方向脚初始化单独抽成一个函数而不是塞进串口配置里——它属于物理层的前置条件。Modbus_Init(0x01)传的是从机地址实际项目里这个值通常从拨码开关或者 Flash 参数区读取不会硬编码。工程里保留这个参数是为了让你快速改地址做多机测试真实部署时记得替换掉。2.3 编译期开关RTU/ASCII 与主从角色的裁剪方式mb_config.h是这份工程的总控台里面用宏控制了几件事协议模式RTU 还是 ASCII、角色从机还是主机、是否启用异常应答、接收缓冲区大小。用宏而不是运行时变量好处是没编译进去的代码不会占 Flash坏处是切换时容易漏改配套的初始化。/* mb_config.h 关键片段 */ #define MB_RTU_ENABLED 1 /* 1: 编译 RTU 相关代码 */ #define MB_ASCII_ENABLED 0 /* ASCII 模式占用更大默认关掉 */ #define MB_MASTER_ENABLED 0 /* 主机模式做网关时再打开 */ #define MB_SLAVE_ENABLED 1 #define MB_SLAVE_ADDR_DEFAULT 0x01 #define MB_RX_BUF_SIZE 128 /* 单帧最大长度够 0x10 写 60 个寄存器 */ #define MB_TX_BUF_SIZE 128 #define MB_EXCEPTION_ENABLED 1 /* 关闭后非法请求直接丢弃不应答 */这里MB_RX_BUF_SIZE定成 128 是有依据的Modbus RTU 单帧上限 256 字节但从机接收只需要承载站号 功能码 地址 2 字节 数量 2 字节 字节数 1 字节 数据 CRC 2 字节一次写 60 个寄存器的报文也就 128 字节左右。缓冲开太大会白占 RAMF0 系列只有 8K RAM开 256 字节的收缓冲已经很奢侈了开太小则会在大批量写入时截断症状是偶发 CRC 错很难查。提示RTU 和 ASCII 互斥编译两个宏同时置 1 会导致mb.c里的编解码函数重名冲突。切换模式后记得把mb_rtu.c或mb_ascii.c从工程文件列表里加回来或者移除。3. 核心代码拆解一帧 RTU 报文从串口中断到寄存器数组协议栈真正干活的部分集中在三个动作上判断一帧何时开始、何时结束校验这一帧是否可信把可信帧里的请求翻译成对寄存器的读或写。工程里这三件事分别落在 CRC 模块、定时器中断和功能码分发函数里逐个拆开看每一步都能对上一个具体的寄存器操作。3.1 CRC16 查表实现与校验位置的选择CRC16 多项式是 0xA001反向的 0x8005Modbus 用的就是这个。直接按位移位算每个字节要循环 8 次对 72MHz 的 F1 来说完全无所谓但代码里用的是查表法说明作者考虑到了以后可能在 8MHz 甚至更低主频的 F0 上跑。查表法把 8 次移位压缩成一次异或加一次查表256 项的表占 512 字节 Flash换来的是一次 CRC 计算从几十微秒降到几微秒。/* mb_crc.c —— 256 项查表完整表略这里只保留主逻辑 */ static const uint16_t usCRCTable[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, /* ... 中间 252 项省略 ... */ 0x8081, 0x4040 }; uint16_t Modbus_CRC16(const uint8_t *pucMsg, uint16_t usLen) { uint16_t usCRC 0xFFFF; /* 初值固定 0xFFFF别改成 0x0000 */ uint16_t i; for (i 0; i usLen; i) { /* 低字节先参与运算这是 CRC16/MODBUS 与 CRC16/CCITT 的差别所在 */ usCRC (usCRC 8) ^ usCRCTable[(usCRC ^ pucMsg[i]) 0x00FF]; } return usCRC; }参数就两个报文首地址和参与校验的长度。长度要把 CRC 本身排除在外这是最常见的错法——把整帧 8 个字节含末尾 2 字节 CRC都丢进去算算出来的结果永远不等于报文里的 CRC。校验和比对的方式也要注意字节序。Modbus RTU 的 CRC 在报文末尾是低字节在前、高字节在后而 STM32 是小端你把返回的uint16_t直接强转成两个字节去memcmp恰好也是低字节在前所以能对上。但这个恰好一旦换到大端平台就会翻车正规写法是显式拆开比对uint8_t Modbus_CheckFrame(const uint8_t *pucFrame, uint16_t usLen) { uint16_t usCalc, usRecv; if (usLen 4) return 0; /* 最短合法帧地址功能码CRC */ usCalc Modbus_CRC16(pucFrame, usLen - 2); /* 不含末尾 CRC */ usRecv (uint16_t)pucFrame[usLen - 2] | ((uint16_t)pucFrame[usLen - 1] 8); return (usCalc usRecv) ? 1 : 0; }这一小段里usLen 4的判断很关键。现场调试时经常出现只收到 1~2 个字节就被判定为帧结束的情况如果不做长度下限检查usLen - 2会下溢成一个巨大的无符号数Modbus_CRC16就会越界读内存运气好读到全 0 算出个碰巧相等的 CRC运气不好直接 HardFault。这种问题在实验室里复现不出来一到总线上有干扰就随机出现非常难定位。3.2 3.5 字符静默判定定时器加法与状态机Modbus RTU 靠静默时间划分帧边界协议规定帧内字符间隔不能超过 1.5 个字符时间帧与帧之间至少 3.5 个字符时间。实现方式有两种一种是靠串口空闲中断IDLE优点是不用额外定时器缺点是在某些 STM32 型号上 IDLE 中断的触发时机不够精确而且在连续收多帧时容易漏另一种就是这份工程用的——每收到一个字节就重置一次定时器定时器溢出就认为帧结束。/* mb_rtu.c —— 接收上下文与帧结束判定 */ typedef enum { MB_RX_IDLE 0, /* 总线上没有活动 */ MB_RX_RECV, /* 正在收定时器在跑 */ MB_RX_DONE /* 收完一帧等主循环取走 */ } mb_rx_state_t; static volatile mb_rx_state_t s_eRxState MB_RX_IDLE; static volatile uint16_t s_usRxLen 0; static uint8_t s_aucRxBuf[MB_RX_BUF_SIZE]; /* TIM3 中断T3.5 到期说明总线静默够了一帧结束 */ void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); TIM_Cmd(TIM3, DISABLE); /* 关掉等下一帧再启动 */ if (s_eRxState MB_RX_RECV s_usRxLen 0) { s_eRxState MB_RX_DONE; /* 交给主循环处理 */ } } }状态机只有三个状态但每个转换点都要想清楚。MB_RX_IDLE - MB_RX_RECV发生在串口中断收到第一个字节时MB_RX_RECV - MB_RX_DONE发生在定时器溢出时MB_RX_DONE - MB_RX_IDLE发生在主循环处理完、清空长度之后。少任何一个转换下一帧就会和数据粘在一起。定时器的重装值是按波特率算的这部分在Modbus_GetT35Ticks里/* 返回 100us 节拍数定时器配置成 100us 溢出一次 */ uint16_t Modbus_GetT35Ticks(uint32_t ulBaudrate) { uint32_t ulBitUs, ulT35Us; if (ulBaudrate 19200UL) { ulT35Us 1750UL; /* 协议规定高于 19200 固定 1.75ms */ } else { ulBitUs 1000000UL / ulBaudrate; /* 一个位时间单位 us */ ulT35Us ulBitUs * 35UL; /* 3.5 个字符 35 个位时间 */ } /* 向上取整至少 1 个节拍否则定时器装 0 会立刻溢出 */ return (uint16_t)((ulT35Us 99UL) / 100UL); }这段代码里有两个参数值得你单独记一下。第一个是19200这个分界点波特率越高3.5 个字符的绝对时间越短在 115200 下只有 334 微秒普通定时器加上中断响应延迟误差可能超过这个值所以协议干脆规定高波特率一律用固定的 1.75ms。第二个是35不是 3.5——因为这里换算的是位时间一个字符 10 位1 起始 8 数据 1 停止3.5 个字符就是 35 位。注意在主循环里调用Modbus_Poll()之前如果s_eRxState已经变成MB_RX_DONE一定要先把s_usRxLen拷贝出来再清零不要在处理过程中直接改缓冲区否则组帧函数读到一半时新帧的字节可能已经开始往里写了。3.3 功能码分发0x03、0x06、0x10 三条读写路径从机侧最常用的就这三个功能码0x03 读保持寄存器、0x06 写单个寄存器、0x10 写多个寄存器。工程量再大一点会用到 0x01 读线圈和 0x04 读输入寄存器但结构完全一样照着抄就行。分发的入口函数做三件事过滤从机地址、判断功能码合法性、把请求转给对应的处理函数。/* mb.c —— 从机请求入口 */ void Modbus_SlaveProcess(void) { uint8_t ucAddr, ucFunc; uint16_t usFrameLen s_usRxLen; /* 进来先快照后面随时可能被新帧覆盖 */ if (usFrameLen 4) goto exit; /* 太短直接丢 */ if (!Modbus_CheckFrame(s_aucRxBuf, usFrameLen)) goto exit; /* CRC 错静默丢弃 */ ucAddr s_aucRxBuf[0]; /* 地址 0 是广播只处理写不回包地址不匹配直接走人 */ if (ucAddr ! MB_SLAVE_ADDR_DEFAULT ucAddr ! 0x00) goto exit; ucFunc s_aucRxBuf[1]; switch (ucFunc) { case 0x03: Modbus_ReadHolding(s_aucRxBuf, usFrameLen); break; case 0x06: Modbus_WriteSingle(s_aucRxBuf, usFrameLen); if (ucAddr 0x00) goto exit; /* 广播写不回包 */ break; case 0x10: Modbus_WriteMultiple(s_aucRxBuf, usFrameLen); if (ucAddr 0x00) goto exit; break; default: Modbus_SendException(ucFunc, 0x01); /* 异常码 01非法功能码 */ goto exit; } exit: s_usRxLen 0; s_eRxState MB_RX_IDLE; /* 一定要复位否则下一帧收不进来 */ }分发表这里用 switch 而不是函数指针数组是因为代码量小switch 编译之后就成了一张跳转表效率并不会差可读性反而更好。真正的坑在异常处理和广播处理上。异常应答的格式必须严格遵守站号 (功能码 | 0x80) 异常码 CRC只有 5 个字节。异常码也别乱写0x01 是非法功能码0x02 是非法数据地址0x03 是非法数据值0x04 是从机设备故障。见过不少工程非法地址直接回 0x01结果上位机显示不支持该功能排查方向直接跑偏。广播请求的处理也很容易翻车。地址 0x00 的意思是所有从机都执行但都不要应答如果按普通流程处理几台从机同时回包总线上就是一堆波形叠加CRC 全错。所以广播的写操作必须执行但应答必须跳过这是协议硬性要求不是可选项。读保持寄存器的实现重点是应答长度和字节数统计要对得上/* 0x03 应答站号 功能码 字节数 数据 CRC */ static void Modbus_ReadHolding(const uint8_t *pucFrame, uint16_t usLen) { uint16_t usStart, usNum, i; uint8_t aucTx[MB_TX_BUF_SIZE]; uint16_t usIdx 0; usStart ((uint16_t)pucFrame[2] 8) | pucFrame[3]; usNum ((uint16_t)pucFrame[4] 8) | pucFrame[5]; /* 数量检查1~125且不能越界不满足回异常码 02 */ if (usNum 0 || usNum 125 || (usStart usNum) REG_HOLDING_NUM) { Modbus_SendException(pucFrame[1], 0x02); return; } aucTx[usIdx] pucFrame[0]; /* 站号原样返回 */ aucTx[usIdx] 0x03; aucTx[usIdx] (uint8_t)(usNum * 2); /* 字节数 寄存器数 * 2 */ for (i 0; i usNum; i) { uint16_t usVal App_RegGetHolding(usStart i); /* 应用层取值 */ aucTx[usIdx] (uint8_t)(usVal 8); /* 大端高字节先发 */ aucTx[usIdx] (uint8_t)(usVal 0xFF); } Modbus_SendFrame(aucTx, usIdx); /* 内部补 CRC 并发出去 */ }usNum 125这个上限不是随便定的因为应答报文总长不能超过 256 字节字节数字段只有一个字节125 个寄存器 250 字节数据加上 5 个字节头尾正好 255再大就溢出。至于大端发送是 Modbus 应用层的约定和 CPU 的字节序无关ARM 是小端所以必须手工移位拆字节不能直接指针强转。3.4 RS485 方向控制与应答组帧的时序485 是半双工同一时刻只能有一个说话。发送前把 DE 拉高抢占总线发完必须立刻拉低交还。这中间的时间窗口非常窄——拉低早了最后一个字节还没移位出去就被截断拉低晚了总线多占用几毫秒主站那边就可能判超时。/* mb_port.c —— 发送完整一帧并可靠释放总线 */ static void Modbus_SendFrame(const uint8_t *pucBuf, uint16_t usLen) { uint8_t aucTmp[MB_TX_BUF_SIZE]; uint16_t usCRC, i; if (usLen 2 MB_TX_BUF_SIZE) return; memcpy(aucTmp, pucBuf, usLen); usCRC Modbus_CRC16(aucTmp, usLen); aucTmp[usLen] (uint8_t)(usCRC 0xFF); /* CRC 低字节在前 */ aucTmp[usLen] (uint8_t)(usCRC 8); RS485_TX_EN(); /* DE 1接管总线 */ for (i 0; i usLen; i) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) RESET); USART_SendData(USART2, aucTmp[i]); } /* 关键等发送完成标志 TC不是 TXE */ while (USART_GetFlagStatus(USART2, USART_FLAG_TC) RESET); RS485_RX_EN(); /* DE 0立即释放 */ }这段里最容易错的是最后那个USART_FLAG_TC。如果写成 TXE发送数据寄存器空程序会在最后一个字节还在移位寄存器里的时候就把 DE 拉低从总线上看就是末字节被削掉一半主站收到的是 CRC 错误或者帧不完整。现象非常随机——报文短的时候主站可能靠重试成功了报文一长就稳定失败很多人一开始都以为是 CRC 算错了。RS485_TX_EN()和RS485_RX_EN()的实现一般是这样具体引脚看你板子#define RS485_DE_GPIO GPIOA #define RS485_DE_PIN GPIO_Pin_8 #define RS485_TX_EN() GPIO_SetBits(RS485_DE_GPIO, RS485_DE_PIN) #define RS485_RX_EN() GPIO_ResetBits(RS485_DE_GPIO, RS485_DE_PIN)如果板子上 DE 和 RE 是连在一起的一根线很多收发器芯片默认这么接那拉高就是发送、拉低就是接收不用额外处理。如果分开接接收使能要单独拉低避免发送时自己听到自己的回声。4. 跑起来从连线到第一次成功读寄存器代码看完接下来是让它在你手上真的跑一次。这个过程里最容易出的问题不是代码而是硬件接线和参数对不上。我见过太多人拿着 485 模块接了两根线然后花一晚上怀疑自己的 CRC 算法。4.1 硬件接线与串口参数确认最小验证系统需要三样东西一块 STM32 最小系统板、一个 485 收发模块或者板载 485 接口、一个 USB 转 485 的调试器。接线就四根A 接 A、B 接 B、两端各自的电源或地按需共地、调试器的 USB 插电脑。A/B 接反是最常见的没反应原因反了不会烧芯片但一个字节都收不到先把这两根线对调试一次能省半小时。串口参数按 Modbus 标准来配工程默认值如下表参数默认值说明波特率9600长距离布线建议不超过 19200数据位8固定校验位无若选奇偶校验停止位要改 1停止位1无校验时固定 1偶校验可配 2从机地址0x01改MB_SLAVE_ADDR_DEFAULT或拨码调试器那头如果用的是带 CRC 校验的 Modbus 专用工具直接选 RTU 模式、填好站号和波特率就行如果用的是普通串口助手需要自己手敲十六进制报文比如读 0x0000 起 2 个保持寄存器01 03 00 00 00 02 C4 0B。手敲的好处是你能亲眼看到每个字节对理解协议很有帮助。提示USB 转 485 的调试器在 Windows 上会枚举成一个虚拟串口设备管理器里能看到端口号这个号在串口工具里别选错。如果插拔之后端口号变了工具会打开失败但报错信息很模糊先看设备管理器。4.2 时钟树与波特率的换算关系波特率配置这一块出问题的概率比接线还高。STM32F1 的 USART2 挂在 APB1 上默认 72MHz 主频下 PCLK1 36MHzUSART1 挂在 APB2 上PCLK2 72MHz。也就是说同样配 9600两个串口的寄存器值是不一样的。如果你是从别的工程抄了一段串口初始化过来主频或者挂载总线的假设不一样波特率就会偏偏移超过 3% 就开始偶发乱码。/* mb_port.c —— 485 侧串口初始化USART2 挂 APB1 */ void USART2_Config(uint32_t ulBaudrate) { USART_InitTypeDef stInit; GPIO_InitTypeDef stGpio; RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); /* PA2 TX复用推挽PA3 RX上拉输入 */ stGpio.GPIO_Pin GPIO_Pin_2; stGpio.GPIO_Mode GPIO_Mode_AF_PP; stGpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, stGpio); stGpio.GPIO_Pin GPIO_Pin_3; stGpio.GPIO_Mode GPIO_Mode_IPU; /* 上拉总线空闲时保持高电平 */ GPIO_Init(GPIOA, stGpio); stInit.USART_BaudRate ulBaudrate; stInit.USART_WordLength USART_WordLength_8b; stInit.USART_StopBits USART_StopBits_1; stInit.USART_Parity USART_Parity_No; stInit.USART_HardwareFlowControl USART_HardwareFlowControl_None; stInit.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, stInit); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_Cmd(USART2, ENABLE); }参数里USART_BaudRate填目标波特率标准库内部会根据当前 APB 时钟算分频系数所以你不需要自己算 BRR 寄存器。但前提是SystemInit()里的时钟配置和你以为的一致。F1 的system_stm32f10x.c里默认定义了目标主频的宏如果那个宏被改成 8MHz 而外部晶振还是 8MHz 倍频到 72MHz 的板子实际跑起来就是另一回事。验证方法很简单在主循环里翻转一个 GPIO 接示波器或者用一个已知延时的循环量出来对不对一眼就知道。接收中断里还有一个必须处理的溢出情况void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { uint8_t ucData (uint8_t)USART_ReceiveData(USART2); if (s_usRxLen MB_RX_BUF_SIZE) { s_aucRxBuf[s_usRxLen] ucData; } else { s_usRxLen 0; /* 爆缓冲整帧作废 */ } TIM_SetCounter(TIM3, 0); /* 每收一字节重置 T3.5 */ TIM_Cmd(TIM3, ENABLE); s_eRxState MB_RX_RECV; } /* 溢出错误必须读一次 DR 才能清标志否则中断会反复进 */ if (USART_GetFlagStatus(USART2, USART_FLAG_ORE) ! RESET) { (void)USART_ReceiveData(USART2); } }USART_FLAG_ORE这一段是很多示例代码漏掉的。溢出之后如果不读 DR 清标志中断会一直挂在那里主循环完全跑不动表现就是程序卡死。它出现的条件很苛刻——通常是在总线干扰或者波特率不匹配导致一个字节还没被读走下一个就来了——所以实验室很难复现现场却时不时来一次。4.3 上位机联调的第一次读写验证建议按这个顺序验证每一步失败都能对应到一个明确的排查方向第一步先不接 485用 USB 转 TTL 直接把板子的 TX 接到调试器 RX主站发一帧读请求看板子有没有回。有回说明协议栈通了问题在 485 侧没回说明问题在协议栈或串口配置。第二步接上 485 模块用调试工具发01 03 00 00 00 02正常应该收到01 03 04 XX XX XX XX CRC CRC。收到但数据不对去查app_reg.c里的寄存器映射收到异常码 0x82 02说明地址越界检查REG_HOLDING_NUM和请求的起始地址。第三步写单个寄存器01 06 00 02 12 34成功的话从机会原样返回这 8 个字节这是 0x06 的规矩不是回 4 个字节。很多人第一次看到回包和请求一模一样会以为从机没处理其实这就是正确应答。第四步用 0x03 把刚写的值读回来确认持久化生效。这一步过了基本功能就齐了。5. 避坑与排查联调时最常翻的五种车下面这五条都是这类工程在真实调试里反复出现的问题每条我都按现象 → 原因 → 解决写清楚。看的时候对照自己的现象找比从头读协议文档快得多。5.1 收到帧但完全不应答现象调试工具发出去的报文从机那边用逻辑分析仪能看到 RX 引脚上有波形串口中断也确实进了但就是没有任何回包工具一直显示超时。原因九成是地址过滤没过。MB_SLAVE_ADDR_DEFAULT和上位机填的站号对不上或者上位机用的是广播地址 0x00 但你想让它回包。另一个可能是 CRC 校验失败后代码选择了静默丢弃——协议就是这么规定的CRC 错的帧不能回异常码因为对方可能根本不是你的主站回包会造成总线冲突。解决先用单步或者串口打印把ucAddr和Modbus_CheckFrame的返回值打出来。如果地址对、CRC 也对还不回检查s_eRxState是不是卡在MB_RX_RECV没进MB_RX_DONE——也就是 T3.5 定时器根本没跑起来或者中断没使能。这种情况在把工程从 F1 移植到 F0 时特别常见因为定时器实例和中断向量名都变了。5.2 CRC 校验通过但数据错位现象报文能被正确接收和校验但从机返回的数据总是差一个或者高低字节颠倒读 0x0000 拿到的值像是 0x0001 的。原因分两种。一种是寄存器地址的偏移理解错了。Modbus 的寄存器地址从 0 开始算但有些上位机界面上显示的是4xxxx格式的 PLC 地址40001 对应实际地址 0x0000你在界面上填 1 和填 0 结果差一个。另一种是字节序问题工程内部按大端收发但应用层App_RegGetHolding返回的值如果是直接从一个 16 位 ADC 寄存器读出来的而那个外设配置成了小端对齐拼出来的 32 位数据就会高低位反。解决先固定一个已知值测试在app_reg.c里把 0x0000 硬编码成 0x1234读回来如果是 0x3412 就是字节序问题如果是 0x0000 或者别的值就是地址映射问题。地址问题就对着app_reg.c的 switch 一条条核别偷懒。5.3 多机总线上偶发丢帧现象单机测试怎么都正常一挂上三四个从机就时不时有一台不响应换个时间再试又好了。用示波器看总线偶尔能看到两个波形叠在一起。原因485 方向脚释放不及时。前面 3.4 节讲过必须要等 TC 标志。另外还有一种情况是从机应答太快——主站刚发完从机立刻就回但主站那边还没来得及把方向切回接收这一帧就被主站自己丢了。协议里对从机的响应时间没有硬性下限但工程实践中一般留 1~2ms 的延迟让主站有时间切换。解决检查Modbus_SendFrame结尾是不是用的 TC。然后在发送前加一个短延时或者把从机的应答放在定时器回调里延后几毫秒执行。另外确认总线上所有节点的波特率、校验位完全一致只要有一台配成偶校验整条总线的波形都会受影响。5.4 连续运行几小时后死机现象刚烧进去跑得好好的放那儿跑一晚上第二天早上发现板子不响应了复位一下又正常。原因接收缓冲区溢出后状态机没复位。如果某一帧因为干扰只收到了半截s_usRxLen停在某个非零值s_eRxState停在MB_RX_RECV下一帧来的时候字节直接往后追加越加越长直到爆掉MB_RX_BUF_SIZE。爆掉之后如果代码里那个s_usRxLen 0的分支写得有问题就会一直卡在溢出状态。解决在 T3.5 定时器溢出时无条件复位接收状态不管收了多少字节超时就是帧结束宁可丢掉半帧也不能让它无限累积。另外主循环里处理完一帧之后要显式s_usRxLen 0; s_eRxState MB_RX_IDLE;这个复位别指望别人帮你做。5.5 移植到 F0 后波特率偏差乱码现象同一份代码在 F1 板子上 9600 跑得好好的换到 F0 板子上收到的全是乱码示波器量波特率发现偏了 5% 以上。原因F0 和 F1 的时钟树不一样。F1 常见是 8MHz 晶振倍频到 72MHzF0 很多板子直接跑内部 8MHz RC 或者 48MHzSystemInit里的配置完全不能通用。更隐蔽的是 F0 的 USART 时钟使能在RCC_APB2PeriphClockCmd而 F1 的 USART2/3 在RCC_APB1PeriphClockCmd抄过来忘了改串口根本没时钟配置全写进了空气里。解决移植第一件事是确认SystemCoreClock变量的值和你预期一致用调试器看这个全局变量。第二件事是核对每个外设挂在哪条总线上F0 系列把 USART1 放在 APB2、其余在 APB1和 F1 部分型号有差异。第三件事是内部 RC 做时钟源时波特率误差本身就大长距离通讯建议换外部晶振或者把波特率降到 9600 以下。注意F2 系列的定时器数量和 F1 不完全对应工程里用 TIM3 做 T3.5 计时移植到某些 F0 小容量型号上可能只有 TIM1 和 TIM3也有的型号 TIM3 被别的功能占了改之前先看数据手册的复用表。6. 二次开发把这套测试代码改成一个能用的从机节点测试工程和产品代码的差别不在于协议写得多漂亮而在于边界处理够不够狠。你现在手上这份代码跑通读写没问题但要接进真实项目至少还有三件事要做寄存器表要能扩、异常情况要能兜住、换平台要能快速验证。6.1 寄存器表从 32 个扩到 200 个的做法工程里默认REG_HOLDING_NUM是 32直接用一个大数组uint16_t usHoldingReg[REG_HOLDING_NUM]存。扩到 200 个也还是数组RAM 占用 400 字节对 F1 的 20K RAM 来说可以接受。真正麻烦的是这些寄存器的语义——哪几个只读、哪几个掉电保存、哪几个写进去要立刻触发动作。我的习惯是拆成两张表一张是存储表纯数据一张是属性表每个地址对应一个标志位标明读写权限和是否持久化。/* app_reg.c —— 属性表驱动避免在读写函数里写一堆 if */ typedef struct { uint16_t usAddr; uint8_t ucAttr; /* bit0: 可读, bit1: 可写, bit2: 掉电保存 */ uint16_t usDefault; } reg_item_t; static const reg_item_t s_astRegMap[] { { 0x0000, 0x01, 0x0001 }, /* 设备类型只读 */ { 0x0001, 0x01, 0x0100 }, /* 固件版本只读 */ { 0x0002, 0x03, 0x0000 }, /* 用户参数 1读写 */ { 0x0003, 0x07, 0x0000 }, /* 用户参数 2读写且掉电保存 */ { 0x0010, 0x01, 0x0000 }, /* 运行状态只读由业务层刷新 */ }; #define REG_MAP_SIZE (sizeof(s_astRegMap) / sizeof(s_astRegMap[0])) /* 写权限检查0x06 和 0x10 都调这个 */ uint8_t App_RegCheckWrite(uint16_t usAddr) { uint16_t i; for (i 0; i REG_MAP_SIZE; i) { if (s_astRegMap[i].usAddr usAddr) { return (s_astRegMap[i].ucAttr 0x02) ? 1 : 0; } } return 0; /* 表里没有的地址一律不可写 */ }这样做的好处是以后加新寄存器只要改这张表不用去动 0x06 和 0x10 的处理函数。属性里那个掉电保存位在写成功之后触发一次 Flash 写入就行注意别每个字节都写一次 FlashFlash 擦写寿命有限攒够一批或者等主站明确下发保存命令再写。6.2 从从机到网关把主机模式接进来很多真实场景里你的板子既要当从机被上位机轮询又要当主机去读下游的传感器。工程里MB_MASTER_ENABLED这个开关就是给这个场景留的。打开之后mb.c里会多出一套请求组帧的接口但要注意主从共用同一个串口时必须做互斥——从机正在应答的时候不能发起主机请求否则总线冲突。常见做法是用一个简单的状态标志发送完成之后置位总线忙T3.5 静默超时之后清零。主机发起请求前检查这个标志忙就跳过这一轮等下一个轮询周期。别用阻塞等待那样从机响应会变慢上位机那边容易判超时。6.3 自测三件套协议、时序、异常改完之后怎么验证没改坏我一般固定走三步。第一步是协议层自测用调试工具把 0x03、0x06、0x10 各跑一遍再加上非法地址和非法功能码确认异常码回得对。第二步是时序测试把波特率提到 115200连续发一百帧中间人为插入干扰比如拔插一下总线看从机能不能自己恢复。第三步是异常注入把接收缓冲区故意改小到一个字节看会不会 HardFault。这三步走完代码基本可以上现场了。从那以后我每次改完协议相关的代码都会强制走一遍这三步尤其是第三步——它救过我两次一次是数组越界一次是空指针都是正常测试完全测不出来的。希望这套流程和这份工程能帮你少走点弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站