简介:电子-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.c | 485 方向脚电平、串口寄存器读写 | 换板子才改 |
| 链路层 | 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 字节左右。缓冲开太大会白占 RAM,F0 系列只有 8K RAM,开 256 字节的收缓冲已经很奢侈了;开太小则会在大批量写入时截断,症状是偶发 CRC 错,很难查。
提示:RTU 和 ASCII 互斥编译,两个宏同时置 1 会导致
mb.c里的编解码函数重名冲突。切换模式后记得把mb_rtu.c或mb_ascii.c从工程文件列表里加回来或者移除。
3. 核心代码拆解:一帧 RTU 报文从串口中断到寄存器数组
协议栈真正干活的部分集中在三个动作上:判断一帧何时开始、何时结束;校验这一帧是否可信;把可信帧里的请求翻译成对寄存器的读或写。工程里这三件事分别落在 CRC 模块、定时器中断和功能码分发函数里,逐个拆开看,每一步都能对上一个具体的寄存器操作。
3.1 CRC16 查表实现与校验位置的选择
CRC16 多项式是 0xA001(反向的 0x8005),Modbus 用的就是这个。直接按位移位算,每个字节要循环 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 = 36MHz;USART1 挂在 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 晶振倍频到 72MHz,F0 很多板子直接跑内部 8MHz RC 或者 48MHz,SystemInit里的配置完全不能通用。更隐蔽的是 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 写入就行,注意别每个字节都写一次 Flash,Flash 擦写寿命有限,攒够一批或者等主站明确下发保存命令再写。
6.2 从从机到网关:把主机模式接进来
很多真实场景里,你的板子既要当从机被上位机轮询,又要当主机去读下游的传感器。工程里MB_MASTER_ENABLED这个开关就是给这个场景留的。打开之后mb.c里会多出一套请求组帧的接口,但要注意主从共用同一个串口时,必须做互斥——从机正在应答的时候不能发起主机请求,否则总线冲突。
常见做法是用一个简单的状态标志:发送完成之后置位"总线忙",T3.5 静默超时之后清零。主机发起请求前检查这个标志,忙就跳过这一轮,等下一个轮询周期。别用阻塞等待,那样从机响应会变慢,上位机那边容易判超时。
6.3 自测三件套:协议、时序、异常
改完之后怎么验证没改坏?我一般固定走三步。第一步是协议层自测,用调试工具把 0x03、0x06、0x10 各跑一遍,再加上非法地址和非法功能码,确认异常码回得对。第二步是时序测试,把波特率提到 115200,连续发一百帧,中间人为插入干扰(比如拔插一下总线),看从机能不能自己恢复。第三步是异常注入,把接收缓冲区故意改小到一个字节,看会不会 HardFault。
这三步走完,代码基本可以上现场了。从那以后我每次改完协议相关的代码,都会强制走一遍这三步,尤其是第三步——它救过我两次,一次是数组越界,一次是空指针,都是正常测试完全测不出来的。希望这套流程和这份工程,能帮你少走点弯路。
本文还有配套的精品资源,点击获取