ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32 Modbus RTU从站实现与RS485收发要点

STM32 Modbus RTU从站实现与RS485收发要点

简介:这是一份面向单片机与嵌入式学习者的 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 字节长度由功能码决定
CRC162 字节低字节在前,高字节在后

真正的约束在于:从站只有靠 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 字符
96001.042 ms3.65 ms1.146 ms4.01 ms
19200521 µs1.82 ms573 µs2.00 ms
38400260 µs0.91 ms286 µs1.00 ms
11520086.8 µs304 µs95.5 µs334 µ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 从站状态机:没收到完整帧之前,什么判断都别做

这是新手最容易翻车的地方。很多人一收到第一个字节就开始判断地址、判断功能码,地址不对马上回一个异常帧——结果总线上出现了两个节点同时讲话。正确的顺序是严格串行的:

  1. 收字节,累积到缓冲区,每来一个字节重置超时计时;
  2. 静默超过阈值,认为一帧结束;
  3. 先算 CRC,不过就静默丢弃,一个字节都不回;
  4. CRC 过了再看地址,不是自己(且不是 0)也静默丢弃;
  5. 地址匹配了,才进功能码分发;
  6. 功能码不支持、参数越界,这时才回异常帧。

前两步是收,后四步是判。把“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~8R单位 0.1,有符号按补码处理
20保持寄存器输出控制R/W0 关闭,1 开启
0~15线圈继电器状态R/W0x05/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>
返回列表