STM32F103+FreeRTOS实现Modbus RTU主站完整指南

📅 发布时间:2026/9/9 22:43:24
STM32F103+FreeRTOS实现Modbus RTU主站完整指南
简介面向需要实现工业设备通信的嵌入式开发者以STM32F103为平台将Modbus主站协议与FreeRTOS操作系统紧密结合解决数据采集与多任务并发处理的开发难题。压缩包共303个文件主体是C源码与头文件对应协议栈和任务管理逻辑同时包含Keil工程配置、编译中间文件以及说明文档便于直接打开工程查看与二次开发。整个包仅849KB结构清晰迁移成本低。目前已有1617人学习下载。代码覆盖串口初始化、波特率与数据格式设置、Modbus报文封装与解析、循环冗余校验、任务创建与优先级划分、信号量与消息队列同步、串口中断接收等核心环节并给出超时处理、校验错误应对等容错逻辑。借助这套代码能直观理解主站如何发起请求、解析从机响应以及如何用操作系统调度通信任务适合有C语言和单片机基础、想快速实现Modbus主站功能的开发者参考。 做Modbus主站和从站是完全两码事网上搜“stm32 modbus”出来一大片都是从机移植教程真正讲主机怎么写的反而少。这篇直接聊我最近在STM32F103上跑FreeRTOS Modbus RTU主机代码的完整思路从协议状态机到串口时序处理再到任务划分和踩坑记录基本都是可以直接抄作业的内容。代码基于标准库std v3.5Modbus部分没有用FreeModbus因为那个库本质是给从机设计的主机用起来反而别扭我干脆自己写了主机状态机配合串口空闲中断判断帧结束实测在115200波特率下跑得很稳。如果你手头正好有类似需求想把多个从站设备挂到一根485总线上做轮询采集这篇文章正好对路。1. 项目背景与整体设计思路1.1 为什么选择STM32F103 FreeRTOS做Modbus主机先说需求场景现场有一批带Modbus RTU从站协议的传感器和执行器需要用一个主控定时轮询采集数据同时还要处理本地按键、显示刷新、数据上报等杂活。如果全部用裸机大循环写状态机一多就乱成一锅粥尤其Modbus的收发超时本身就依赖精确时序再叠加上其他任务的中断延迟很容易踩到响应超时的坑。这时候上FreeRTOS就顺理成章了把Modbus轮询单独拆成一个任务用队列和信号量跟其他模块解耦代码结构清楚得多。选STM32F103不图性能图的是资料全、价格便宜、生态成熟。标准库虽然老旧但胜在稳定网上能查到的例程几乎全是标准库版本踩坑时有据可循。Cortex-M3内核没有Cache一致性那些幺蛾子跑Modbus RTU这种简单的串口协议完全够用主频72MHz处理115200波特率的报文CPU占用率低到可以忽略。1.2 主机协议栈的选型对比FreeModbus vs 自研状态机FreeModbus这个库我早期也移植过它的架构确实漂亮从机功能码处理、异常响应、广播处理全都封装好了专门为从机设备设计的。但它有个硬伤它根本不做主机主动发请求这件事你想要读一个保持寄存器得自己拼报文、自己管超时重试、自己解析响应那用FreeModbus反而多一层束缚。自研主机状态机其实不复杂Modbus RTU主机的核心逻辑就四个字发出请求、等回应。我在工程里维护了一个简单的状态枚举把“发送请求帧→等待响应→超时重发→处理应答→切换下一个从站”这个循环用有限状态机串起来每个状态里只做一件事代码量不大但可读性和可控性比硬套FreeModbus好得多。从架构上拆每帧的流转大概是这样空闲态IDLE轮询任务无帧在处理准备发下一帧请求发送态SEND把请求帧通过串口发出去发完即切换等待态等待态WAIT等待从站响应超时由软件定时器控制解析态PARSE收到完整响应帧做CRC校验和功能码校验重试态RETRY超时或校验失败累计重试次数未超限退回发送态这个状态机可以套进FreeRTOS的一个任务里也可以放在串口中断回调中驱动具体看实时性要求。2. 关键技术点拆解Modbus主站状态机与串口时序2.1 Modbus RTU帧结构与定时的关系Modbus RTU的帧格式很简洁地址码1字节、功能码1字节、数据区N字节、CRC16校验2字节。但真正决定通信可靠性的不是帧格式本身而是帧与帧之间的时间间隔。RTU协议规定一帧内字符间间隔不能超过1.5个字符时间帧间间隔必须大于3.5个字符时间。换算下来115200波特率下1个字符大约87us3.5个字符就是304us这就要求接收端必须在304us内判断出一帧数据已经结束。最直接的做法是用串口空闲中断IDLE Line Interrupt。STM32F103的USART支持检测总线空闲状态当接收线上超过一个字节时间没有新数据进来时硬件会置位IDLE标志。我们可以在中断里把“当前这一帧的字节数、缓冲区指针”交接给上层任务同时清空DMA或中断接收缓冲区等待下一帧。这套方案在Modbus主从机之间特别实用因为主机发出请求后从站的响应时间本身就有不确定性靠固定延时去等容易失真。2.2 三种帧结束判断方案对比固定延时 vs DMA空闲中断 vs 定时器超时我实际试过三种方案直接说对比结果方案实现难度CPU占用实时性可靠性固定延时如Delay 5ms后再处理最低高阻塞差在高波特率下容易误判串口空闲中断IDLE中低中断驱动好强烈推荐定时器超时如TIM捕获高中好适合没有IDLE中断的平台固定延时的坑在于如果你的主控在等待期间被其他高优先级任务抢占实际处理响应的时间点会漂移可能正好落在从站下一帧数据的中间导致拆包错位。DMA空闲中断是目前最平衡的方案DMA接收不占CPU硬件空闲中断精确判断帧尾可以说是Modbus RTU接收的标准答案。2.3 串口中断DMA的接收缓冲区设计我的工程设计是USART1接收用DMA循环模式配合IDLE中断把收到的原始字节流放进一个环形缓冲区或者一块静态数组。核心代码逻辑大致是这样#define MODBUS_RX_BUF_SIZE 256 uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; volatile uint16_t modbus_rx_len 0; volatile uint8_t modbus_frame_flag 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 先清除IDLE标志读SR再读DR USART_ReceiveData(USART1); // 关闭DMA计算已接收长度 DMA_Cmd(DMA1_Channel5, DISABLE); modbus_rx_len MODBUS_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); modbus_frame_flag 1; // 重新开启DMA DMA_SetCurrDataCounter(DMA1_Channel5, MODBUS_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }这里有几个关键细节DMA要配置成循环模式缓冲区大小要大于最大Modbus帧长度一般256字节足够IDLE中断必须在帧接收完成后尽快读取长度并复位DMA否则下一帧数据会覆盖当前缓冲区。2.4 CRC16校验的两种实现查表法 vs 位运算法Modbus RTU的CRC16算法是多项式0xA001初始值0xFFFF。网上有两种常见实现查表法和位运算法。查表法速度快但需要一张256字节的查找表适合在中断或高频轮询里用位运算法代码精简但每字节要循环8次对72MHz主频来说也是小菜一碟。我图省事直接用了位运算法代码量只有几行uint16_t modbus_crc16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; }注意Modbus RTU的CRC是低字节在前发送时先发低8位再发高8位接收校验时要把收到的CRC字节序还原成uint16_t再比较这里最容易出错。我建议在调试阶段把接收帧和CRC打印出来人工核对一遍确认字节序无误再往下开发。3. 基于标准库的工程落地实操3.1 基于STM32F103标准库的工程搭建工程基于标准库SVD v3.5工程模板可以直接用Keil MDK搭建。核心模块就三个modbus_master.c/h协议状态机、报文组包/拆包、CRC校验usart1.c/h串口初始化、DMA配置、IDLE中断处理modbus_task.c/hFreeRTOS任务入口调用协议层接口进行轮询调度启动文件用的是startup_stm32f10x_hd.s如果你用的是103C8T6这种中容量芯片要确认启动文件匹配。时钟配置直接用标准库的SystemInit()默认72MHz。3.2 串口初始化与485方向控制引脚配置RS485半双工通信需要一个方向控制引脚我用的PB12高电平发送、低电平接收。发送前拉高延时一个字节时间再发数据发送完成后必须等最后一个字节完全移位出去再拉低否则会把最后一两个字节截掉。这个问题我在调试时吃过亏用了USART_GetFlagStatus(USART1, USART_FLAG_TC)判断发送完成标志再拉低引脚才解决。void rs485_send(uint8_t *data, uint16_t len) { RS485_DE_HIGH(); // 方向切换为发送 delay_us(20); // 等待方向稳定至少大于1个字符时间 for (uint16_t i 0; i len; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, data[i]); } while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); // 等最后一个字节移位完成 RS485_DE_LOW(); // 切回接收 }如果你用MAX3485这类芯片方向切换更灵敏拉高到拉低之间最好加个微妙级延时防止330欧姆偏置电阻造成的电平震荡。3.3 主机核心代码实现读保持寄存器、写单寄存器Modbus主机最常用的两个功能码是03读保持寄存器和06写单寄存器。报文结构读保持寄存器03功能码主机发送从站地址 0x03 起始寄存器地址高8位 起始寄存器地址低8位 寄存器数量高8位 寄存器数量低8位 CRC16低字节 CRC16高字节从站响应从站地址 0x03 数据字节数 数据每寄存器2字节高字节在前 CRC16低字节 CRC16高字节写单寄存器06功能码主机发送从站地址 0x06 寄存器地址高8位 寄存器地址低8位 数据高8位 数据低8位 CRC16低字节 CRC16高字节从站响应原样回显请求帧即从站地址 0x06 寄存器地址 数据 CRC16我把组帧过程封装成了几个函数void modbus_build_read_hold(uint8_t slave_addr, uint16_t start_reg, uint16_t reg_num, uint8_t *frame) { frame[0] slave_addr; frame[1] 0x03; frame[2] (uint8_t)(start_reg 8); frame[3] (uint8_t)(start_reg 0xFF); frame[4] (uint8_t)(reg_num 8); frame[5] (uint8_t)(reg_num 0xFF); uint16_t crc modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; }解析响应时的核心判断逻辑是帧头地址是否匹配从站地址 0x80表示异常响应功能码是否匹配CRC是否正确数据长度是否符合预期03功能码下数据长度 寄存器数 × 23.4 FreeRTOS任务划分与通信机制FreeRTOS部分我拆了两个任务一个是ModbusTask优先级中等负责轮询从站一个是AppTask优先级较低负责处理采集结果和用户交互。两个任务之间通过osMessageQueuePut/osMessageQueueGet传递解析后的数据尽量避免在中断里做业务逻辑。任务实现的示例void ModbusTask(void *argument) { uint8_t frame[64]; uint16_t len; modbus_result_t result; for (;;) { // 遍历从站地址表 for (uint8_t i 0; i SLAVE_NUM; i) { len modbus_build_read_hold(slave_table[i].addr, slave_table[i].start_reg, slave_table[i].reg_num, frame); result modbus_master_poll(frame, len, slave_table[i].data[0]); if (result ! MODBUS_OK) { // 记录错误计数超限报警 } } vTaskDelay(pdMS_TO_TICKS(100)); // 轮询周期 } }这里有个细节ModbusRTU建议帧间间隔至少3.5个字符时间因此任务循环末尾的vTaskDelay(100ms)不单纯是控制轮询频率还要保证两帧之间留足间隔不能刚发完请求就立刻发下一帧。3.5 超时重试机制的实现Modbus主机没有系统性的超时管理就谈不上可靠。我在任务里用xTaskGetTickCount()实现了毫秒级超时判断等待响应最长时间一般设为500ms到1s超时未收到完整帧便计数重试重试3次仍失败则标记该从站离线并跳过进入下一个从站。TickType_t start xTaskGetTickCount(); while (modbus_frame_flag 0) { if ((xTaskGetTickCount() - start) pdMS_TO_TICKS(500)) { retry_count; if (retry_count 3) { slave_table[i].offline 1; break; } // 清空接收缓冲区重新等待 memset(modbus_rx_buf, 0, sizeof(modbus_rx_buf)); modbus_frame_flag 0; start xTaskGetTickCount(); } }4. 常见问题排查与调试技巧实录4.1 主机连接从机后通信异常单独测试都正常热词里有个非常典型的问题485主机、从机分别测试都正常一连起来就不行。这类问题十有八九出在下面四个环节A/B线接反485是差分信号A接A、B接B反了完全不通或者间歇性乱码。用万用表量静态电压A对地约2.5V~3VB对地约0V~2V压差越大信号越稳。共地问题485虽然走差分但两个设备之间最好共地特别在长距离通信时共模电压过大会烧芯片或导致波形畸变。简单做法是两个设备的GND相连。终端电阻超过几十米或者波特率较高需要在总线首尾各接一个120Ω终端电阻。第一次实测时不接也能通但一长距离就各种随机错误。方向切换时序主机把DE拉低太快从站还在回帧。这是最隐蔽的坑在示波器上看TX通道和RX通道的波形才能发现。建议在发送完成中断里切换方向或者等TC标志位置位后再延时20us拉低。4.2 Modbus Poll与Modbus Slave调试工具的使用开发阶段强烈推荐用Modbus Poll模拟从站用Modbus Slave模拟主机和你自己的代码对打。Modbus Poll的配置很简单新建连接选择RTU模式设置串口号、波特率、数据位8、停止位1、无校验从站地址填你要测试的地址功能码选03点击连接后代码里发请求Poll里就能看到寄存器数据变化调试时如果报文总是不对先把Poll收到的原始字节以HEX格式打印出来人工比对CRC值。Modbus Poll显示的是解析后的结果不是原始流配合串口助手或者逻辑分析仪才能看到完整帧。4.3 FreeRTOS下堆栈溢出检测与常见的HardFault定位FreeRTOS的每个任务默认堆栈值很容易被低估尤其Modbus任务里如果用了较大局部变量比如char buf[256]栈一溢出就出现随机HardFault。建议开启configCHECK_FOR_STACK_OVERFLOW为1或2配合vApplicationStackOverflowHook()打印任务名定位用uxTaskGetStackHighWaterMark()在运行一段时间后查看最小剩余栈深度Modbus相关任务堆栈至少给512字节按4字节对齐稳妥起见给1024字节我踩过的一个坑是DMA接收缓冲区是全局数组但协议解析时把整个帧复制进局部变量一帧最多256字节直接把512字节的任务栈冲爆。后来改成直接在全局缓冲区上解析只拷贝最终有效数据问题就消失了。记住一个原则处理大缓冲区时尽量用全局/静态内存别塞进任务栈。4.4 常见问题速查表现象可能原因解决方案完全无响应A/B接反、波特率不一致检查接线和串口参数配置间歇性乱码共地丢失、干扰大设备共地屏蔽层单端接地收到帧但CRC错误波特率偏移、帧被截断检查晶振精度确认串口中间采样点响应超时重试机制没生效、从站处理时间过长增大超时时间检查从站响应间隔偶发HardFault任务栈溢出、数组越界减少栈内大数组开启栈溢出检测发完请求立即收到回显485方向切换太快使用TC标志位后切换或延长延时4.5 实测性能数据与优化建议最终实测环境STM32F103C8T6外部晶振8MHzPLL至72MHzUSART1波特率9600现场仪表老设备支持不到115200轮询5个从站每站读4个保持寄存器单轮耗时约180msCRC校验和状态机处理约0.3ms几乎不占CPU。如果波特率较高如115200建议开启DMA的循环模式配合串口IDLE中断做帧接收CPU占用率会进一步下降。中断回调里只置标志位和长度值不进行CRC计算解析工作放在任务上下文中完成保证中断处理时间极短。5. 最后的经验之谈这套代码目前已经在两个项目里稳定跑了半年多最深的体会是Modbus主机代码的核心不是协议本身而是超时管理和状态切换的健壮性。很多网上代码能通但遇到从站偶尔抽风就卡死原因就是状态没有做超时兜底。我在状态机里给每个状态都加了超时保护任何状态停留超过1秒就直接复位回空闲态这样即使从站彻底离线主机也能正常轮询其他设备。另一个体会是调试工具要用好示波器加Modbus Poll的组合比任何代码断点都好用。串口波形上看方向切换的毛刺Poll上看应用层的时序和数据内容两者对照基本能解决95%以上的通信问题。如果还想再扩展可以考虑加上Modbus TCP网关的功能用ESP8266或者W5500把RTU转成TCP这样上位机直接用Modbus Poll走网口调试更方便。本文还有配套的精品资源点击获取