简介:这是一份面向单片机与嵌入式学习者的 STM32 Modbus 通信测试工程包,定位于帮助具备 C 语言与 Keil 使用基础的开发者理解并实践 STM32F0/F1/F2 系列上的串口通信与 Modbus 主从站开发。压缩包共 70 个文件,约 727KB,包含 C 源文件与头文件、Keil 工程及选项配置、编译中间产物、HEX 固件、MAP/LST 映射与列表文件,以及若干备份文件;其中源码部分覆盖 USART、DMA、定时器、LCD、LED 与 Modbus 等模块,便于对照工程结构梳理外设初始化、帧收发和调试流程。已有 165 人学习,适合用来参考 Modbus RTU/ASCII 的帧格式、寄存器读写、应答与超时错误处理。读者可从中获得可直接打开的工程框架、HEX 验证文件与编译产物,结合串口终端和调试器复现通信过程,也可将其作为课程设计、工业控制入门实验或嵌入式协议栈学习的参考素材。
1. 从一块只会 printf 的板子说起
手头一块 STM32F103 最小系统板,平时只干两件事:串口打日志、定时器闪灯。现在要接一台只认 Modbus RTU 的现场仪表,翻完协议手册才发现,八十多页文档里能直接抄的只有一张帧格式表——超时怎么量、地址怎么判、异常怎么回,全靠自己填。电子-modbusTest.zip 补的就是这块空白:不引协议栈,一个串口加一个定时器,把 RTU 帧的收、校验、分发、应答整条链路跑通,附带一份可以直接对打的从站例程。它适合两类人——刚上手 STM32 串口中断、想找个真实协议练手的;以及手上已经有 F0/F1/F2 板子、想快速验证一块 485 接口到底通不通的。代码量不大,但每一行都踩在 Modbus 的规则上,改动之前得先知道规则是什么。
2. 先把帧结构和对时逻辑钉死:Modbus RTU 的三条硬约束
很多人拿到这份工程第一反应是直接改寄存器地址、改波特率、烧进去试。能通就行——直到现场多挂两台设备、线缆拉到三十米,才发现偶发超时根本查不出来。问题几乎都出在帧边界判断上,而不是业务代码。这一章的三条约束,是后面所有代码的地基。
2.1 一帧只有四段,CRC 是唯一能自证的东西
标准 RTU 帧的结构非常窄,只有四段:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1 字节 | 1~247 有效,0 是广播,248~255 保留 |
| 功能码 | 1 字节 | 决定后面数据段怎么解释 |
| 数据段 | 0~252 字节 | 长度由功能码决定 |
| CRC16 | 2 字节 | 低字节在前,高字节在后 |
真正的约束在于:从站只有靠 CRC 才能判断这一帧是不是发给自己的、有没有被干扰。地址字段是明文,谁都能读,但只有 CRC 过了,这帧才可信。所以校验函数必须一次算完整帧(包括末尾两个 CRC 字节),结果为 0 才算通过。
/* 多项式 0xA001,初值 0xFFFF,一次算完整帧 */ uint16_t modbus_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; while (len--) { crc ^= (uint16_t)*buf++; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; /* 内部值,发送时要拆成低字节、高字节依次送出 */ }参数只有两个:待校验缓冲区首地址、字节数。发送侧调用时传入帧长 - 2,把算出来的值拆成crc & 0xFF和crc >> 8两个字节追加到末尾;接收侧调用时传入完整帧长,判断返回值是否为 0。这个“收发用同一个函数、只改长度参数”的写法,是避免 CRC 高低字节搞反最省心的做法——反了的话接收侧永远算不出 0,现象就是主站每次重试都超时,而你手算又是对的。
提示:CRC 表可以预先算好 256 项放进 Flash,速度能快三到五倍。但在 9600 波特率下,一帧最长 256 字节,位运算版本耗时才几十微秒,完全够用。先别为这点性能上查表,把逻辑跑通再说。
2.2 3.5 字符时间是一个能被量出来的数字
Modbus RTU 用“静默间隔”断帧:一帧结束后至少安静 3.5 个字符时间,下一帧才开始。这个 3.5 不是玄学,是可以直接算出来的,因为一个字符占多少位是确定的。8N1(无校验)每字符 10 位,8E1(偶校验)每字符 11 位,乘以 3.5 再除以波特率就是微秒数。
| 波特率 | 8N1 单字符 | 3.5 字符 | 8E1 单字符 | 3.5 字符 |
|---|---|---|---|---|
| 9600 | 1.042 ms | 3.65 ms | 1.146 ms | 4.01 ms |
| 19200 | 521 µs | 1.82 ms | 573 µs | 2.00 ms |
| 38400 | 260 µs | 0.91 ms | 286 µs | 1.00 ms |
| 115200 | 86.8 µs | 304 µs | 95.5 µs | 334 µs |
规范里还有一条容易被忽略的补充:波特率高于 19200 时,固定用 1750 µs 作为帧间隔。原因是高波特率下 3.5 个字符只有几百微秒,MCU 的中断响应抖动、线路上的容性延迟都可能让这个值失真,反而不如一个固定值稳。
/* 计算断帧阈值,单位 us */ uint32_t mb_t35_us(uint32_t baud) { if (baud > 19200) { return 1750; /* 规范建议:高于 19200 一律用固定值 */ } /* 按 8E1 的 11 bit/字符估算;如果你的从站是 8N1,把 11.0f 改成 10.0f */ return (uint32_t)((3.5f * 11.0f * 1000000.0f) / (float)baud); }这个函数在初始化时算一次,结果存进全局变量,后面中断和主循环都读它。别在中断里反复浮点运算,F0 系列没有 FPU,软浮点一次要几百个周期,放在高频中断里就是灾难。
2.3 从站状态机:没收到完整帧之前,什么判断都别做
这是新手最容易翻车的地方。很多人一收到第一个字节就开始判断地址、判断功能码,地址不对马上回一个异常帧——结果总线上出现了两个节点同时讲话。正确的顺序是严格串行的:
- 收字节,累积到缓冲区,每来一个字节重置超时计时;
- 静默超过阈值,认为一帧结束;
- 先算 CRC,不过就静默丢弃,一个字节都不回;
- CRC 过了再看地址,不是自己(且不是 0)也静默丢弃;
- 地址匹配了,才进功能码分发;
- 功能码不支持、参数越界,这时才回异常帧。
前两步是收,后四步是判。把“CRC 和地址都过了”当成进入协议层的门禁,后面所有的分支判断才有意义。反过来,只要这两关有一关没过,从站就当自己没听见——这条规则写进代码里,能省掉现场调试时一半的抓包分析。
/* 协议层入口,返回值只用于统计,不影响行为 */ uint8_t mb_handle_frame(const uint8_t *buf, uint16_t len) { if (len < 4) { /* 最短帧:地址 + 功能码 + CRC 两字节 */ return 0; } if (modbus_crc16(buf, len) != 0) { /* 整帧校验,不为 0 直接丢弃 */ mb.stat.crc_err++; return 0; } uint8_t addr = buf[0]; if (addr != MB_SLAVE_ADDR) { /* 注意:广播地址 0 不回应答 */ return 0; } mb.stat.frame_ok++; switch (buf[1]) { case 0x03: return mb_fc03(buf, len); case 0x06: return mb_fc06(buf, len); case 0x10: return mb_fc10(buf, len); default: return mb_exception(buf[1], MB_EX_ILLEGAL_FUNCTION); } }这段代码里mb.stat那三个计数器看着没用,但到第 6 章会发现它们是把手工调试变成自动回归的关键。先埋进去,后面改起来不用动协议层。
3. 在 STM32F0/F1/F2 上把收发链路搭起来
协议规则清楚了,接下来是把它落到具体的串口上。F0、F1、F2 三个系列在这一层的差异主要在时钟树和库封装,寄存器行为是一致的:F1 用标准外设库,F0 常用 HAL 或 LL,F2 介于两者之间。代码逻辑可以照搬,外设初始化的函数名要按手里的库改。
3.1 三种接收方案的取舍:先看你的波特率和主循环负载
| 方案 | CPU 占用 | 断帧依据 | 适用场景 | 主要问题 |
|---|---|---|---|---|
| RXNE 轮询 | 极低 | 主循环里的软件计时 | 主循环有稳定毫秒节拍、波特率 ≤ 9600 | 主循环一忙就漏字节 |
| 逐字节中断 + 定时器 | 中 | 每收一字节清零计数器 | 9600~115200 通用,代码最直观 | 高波特率下中断频率高 |
| DMA + IDLE 中断 | 低 | 串口空闲线检测 | 115200 以上、数据量大 | 半包时 IDLE 不触发,会漏帧 |
我的取舍很明确:调试阶段用逐字节中断 + 定时器,量产再考虑 DMA。原因是逐字节方案里每一个字节的到达时间都是可见的,出了问题能直接在中断里打断点;DMA 方案一旦断帧判断出错,你只能看到“缓冲区里多了半帧”,很难定位是硬件还是逻辑。这份工程默认给的是第二种,改 DMA 也不难,下面给个参考。
3.2 逐字节中断加 1MHz 定时器:最笨但最稳的一版
思路是让 TIM3 以 1MHz 自由计数,每收到一个字节就把计数器清零。这样计数器的当前值天然就是“距离最后一个字节过了多少微秒”,主循环只要拿它跟 t3.5 比一下就知道帧有没有结束。
/* 1) 串口:9600 8N1,开接收中断。F0 上把 USART1 换成对应实例即可 */ void mb_uart_init(uint32_t baud) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_9; /* TX */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; /* RX */ gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); USART_InitTypeDef u; USART_StructInit(&u); u.USART_BaudRate = baud; u.USART_WordLength = USART_WordLength_8b; u.USART_StopBits = USART_StopBits_1; u.USART_Parity = USART_Parity_No; /* 现场仪表多用偶校验,改成 USART_Parity_Even */ u.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; u.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &u); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }校验位这一行要特别注意:USART_Parity_Even时字长必须设成 9b,否则 STM32 会把校验位当成数据位,收到的每个字节都错位。这是 F1 上一个很经典的坑,改完校验一定要同步改字长。
/* 2) TIM3:1MHz 自由计数,用来量字节间隔 */ static volatile uint8_t tim_wrap = 0; void mb_tim_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseInitTypeDef tb; TIM_TimeBaseStructInit(&tb); tb.TIM_Prescaler = (uint16_t)(SystemCoreClock / 1000000) - 1; /* 计数频率 1MHz */ tb.TIM_Period = 0xFFFF; /* 约 65ms 回绕一次 */ tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tb); TIM_ClearITPendingBit(TIM3, TIM_IT_Update); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); TIM_Cmd(TIM3, ENABLE); NVIC_EnableIRQ(TIM3_IRQn); }分频值按SystemCoreClock / 1000000 - 1算,72MHz 主频下就是 71,计数频率正好 1MHz,读出来的计数值单位就是微秒。如果主频是 48MHz,同样公式算出 47,不用改代码。
/* 3) 收一个字节就刷新一次时间基准 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = (uint8_t)USART_ReceiveData(USART1); TIM_SetCounter(TIM3, 0); /* 关键:以最后一个字节为零点 */ tim_wrap = 0; if (mb.rx_len < MB_RX_MAX) { mb.rx_buf[mb.rx_len++] = ch; } else { mb.rx_len = 0; /* 超长直接放弃,避免缓冲区被撑爆 */ } } } void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); tim_wrap = 1; /* 空闲已超过 65ms,肯定断帧 */ } }tim_wrap这个标志不能省。计数器只有 16 位,1MHz 下 65ms 就绕回 0 了。如果不处理回绕,一帧收完之后主循环恰好卡了 70ms,再读计数器会得到一个很小的值,于是这帧永远等不到“结束”,下一帧来了直接粘在后面,CRC 必挂。
/* 4) 主循环里判帧尾,交给协议层 */ void mb_poll(void) { if (mb.rx_len == 0) { return; } uint32_t idle_us = TIM_GetCounter(TIM3); if (tim_wrap || idle_us >= mb.t35_us) { mb_handle_frame(mb.rx_buf, mb.rx_len); mb.rx_len = 0; } }mb_poll放在while(1)里每圈调用一次,不需要精确周期,因为判据是“距离最后一字节的绝对时间”,主循环慢一点只会让帧处理延迟,不会误判。这是这个方案比“主循环软件计时”强的地方。
3.3 RS485 方向控制:等 TC 而不是 TXE,这是生死线
TTL 串口没有方向问题,一挂上 485 收发器就必须显式控制 DE/RE。通常把 DE 和 RE 短接,用一个 GPIO 控制:高电平发送,低电平接收。
#define RS485_DE_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_8) #define RS485_DE_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_8) void mb_send(const uint8_t *buf, uint16_t len) { RS485_DE_HIGH(); /* 先切方向,再写数据 */ for (uint16_t i = 0; i < len; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET) { /* 等发送数据寄存器空 */ } USART_SendData(USART1, buf[i]); } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET) { /* 必须等 TC:最后一个 bit 完全移出移位寄存器 */ } RS485_DE_LOW(); /* 再切回接收 */ }TXE和TC的区别是这套代码里最容易忽略的一处。TXE置位只说明数据寄存器空了,可以写下一个字节,但此时移位寄存器里可能还有一到两个字节正在往外发。这时候拉低 DE,收发器提前关断,最后那一两个字节直接被截断,主站收到的就是 CRC 错误的坏帧。现象很有意思:9600 波特率下是“偶尔失败”,因为字节在移位寄存器里停留的时间长,正好被主循环的其他逻辑掩盖;115200 下几乎每帧都挂。
注意:如果现场用的是自动方向控制的 485 芯片(内部按波特率自动切换),这段 GPIO 代码要整段去掉,否则会和芯片内部的时序打架。判断方法是量一下芯片的 DE 引脚,如果它不接 MCU 就能自己跳变,就是自动方向型。
4. 功能码落地:0x03、0x06、0x10 和 0x80 异常
收发链路通了,接下来是业务层。这份测试工程覆盖的是最常用的三个功能码:读保持寄存器、写单个寄存器、写多个寄存器。线圈和离散量(0x01/0x05/0x0F)结构类似,只是把 16 位寄存器换成了按位寻址,理解了寄存器这套,线圈照搬即可。
4.1 寄存器映射表:把业务变量摆到连续地址上
先从一张表开始,别急着写代码。地址规划错了,后面所有功能码都要跟着改。
| 地址(十进制) | 类型 | 名称 | 读写 | 说明 |
|---|---|---|---|---|
| 0 | 保持寄存器 | 设备型号 | R | 固定值,用于主站识别 |
| 1 | 保持寄存器 | 固件版本 | R | 高字节主版本,低字节次版本 |
| 2 | 保持寄存器 | 从站地址 | R/W | 写入后需掉电保存才生效 |
| 10~17 | 保持寄存器 | 采样值 1~8 | R | 单位 0.1,有符号按补码处理 |
| 20 | 保持寄存器 | 输出控制 | R/W | 0 关闭,1 开启 |
| 0~15 | 线圈 | 继电器状态 | R/W | 0x05/0x0F 操作 |
| 256~259 | 保持寄存器 | 诊断计数器 | R | 帧数、CRC 错误数等,见第 6 章 |
地址不连续是刻意的。把功能相近的变量放在一段连续地址里,主站一次 0x03 就能批量读走;把诊断类和业务类隔开,是为了以后扩展时不用重排地址——现场设备一旦部署,寄存器地址就是对外接口,改一次要通知所有对接方。
typedef struct { uint16_t holding[ <p> <a href="https://download.csdn.net/download/weixin_38744207/11677072" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>