ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103裸机移植FreeModbus RTU协议栈实战指南

2026/9/4 2:45:05 拓冰建站 浏览量
STM32F103裸机移植FreeModbus RTU协议栈实战指南 简介本资源是面向嵌入式初学者与STM32开发者的FreeModbus协议栈裸机移植工程聚焦STM32F103平台实现Modbus RTU从站功能解决工业通信中协议栈集成难、底层适配不清晰等实际问题。压缩包共257个文件含48个C源文件如stm32f10x_usart.c、system_stm32f10x.c等外设驱动与系统初始化代码、46个头文件h、49个编译中间文件o/d/crf及Keil工程核心文件uvprojx、uvoptx、axf、hex、sct等完整覆盖从硬件配置、UART中断适配、FreeModbus端口层重写到可运行固件的全链路代码结构包体大小为6.97MB。已有1240人学习下载配套Word移植说明文档详述硬件连接、CubeMX配置要点、寄存器读写示例及典型通信异常排错方法读者可直接复用工程框架、理解modbus_callbacks回调机制并基于该基础快速扩展多从站或定制功能。1. 项目缘起为什么要在STM32F103上折腾FreeModbus裸机移植最近在做一个工业数据采集的小项目主控芯片选用了经典的STM32F103C8T6也就是大家常说的“蓝桥杯”或者“最小系统板”上的那颗芯片。项目需要和上位机一台工控机通过RS485总线通信协议自然就定在了Modbus RTU。一开始图省事想找个现成的库网上搜了一圈发现很多例程要么是基于HAL库的要么是跑在FreeRTOS上的。但我这个项目功能简单实时性要求也不高为了节省那点宝贵的RAM和Flash决定采用裸机Bare-Metal方案。于是FreeModbus这个开源、轻量且专为嵌入式设计的Modbus协议栈就成了不二之选。然而理想很丰满现实却很骨感。官方和社区提供的FreeModbus移植例程大多针对特定开发板或复杂的操作系统环境直接拿来用在STM32F103的裸机项目上总会遇到各种水土不服的问题定时器配置对不上、串口中断打架、移植接口函数不知道往哪填……这个过程就像拼一张没有说明书的乐高零件都有但怎么组合起来能跑通得自己摸索。今天我就把这次完整的移植过程、踩过的坑以及最终稳定运行的配置从头到尾梳理一遍目标是让你拿到这份“攻略”后能快速在STM32F103裸机环境下把FreeModbus跑起来。2. FreeModbus协议栈骨架与我们的移植靶心在动手改代码之前我们必须先搞清楚FreeModbus这个“房子”的结构以及我们需要给它打造的“地基”即硬件平台是什么样子的。FreeModbus采用了一种非常清晰的分层架构核心协议逻辑如PDU解析、功能码实现是平台无关的而所有与硬件、操作系统相关的部分都被抽象成一组接口函数集中放在port文件夹里。这正是移植工作的核心靶区。对于STM32F103裸机移植我们需要关注port目录下的这几个关键文件portevent.c/.h: 事件管理接口。在裸机中没有操作系统的事件标志组或消息队列我们通常用全局变量标志位来模拟“事件”。porttimer.c/.h: 定时器接口。Modbus RTU要求严格的3.5个字符的帧间超时T3.5和字符间超时T1.5这需要一个精准的定时器通常是硬件定时器。portserial.c/.h: 串口通信接口。这是数据收发的物理层需要实现串口的初始化、发送、接收以及中断服务函数。port.h: 一些全局的配置和宏定义比如使能哪些功能码MB_FUNC_READ_HOLDING_ENABLED。我们的移植工作90%的精力都将花在重新实现或修改上述port系列文件让它们适配STM32F103的固件库这里我使用标准外设库SPL和裸机轮询/中断的执行模式。同时还需要一个正确的main.c来初始化所有硬件并启动协议栈。注意网上很多资源是.rar或.zip包里面可能包含针对其他芯片或IDE的工程。我们的目标不是直接运行那个工程而是提取出FreeModbus的核心源码modbus文件夹和我们需要的port模板然后将其整合到我们自己的STM32工程中。3. 工程奠基创建你的裸机工程与FreeModbus源码引入首先你需要一个能正常编译、下载、运行基础代码比如点亮LED的STM32F103裸机工程模板。无论你用的是Keil MDK、IAR还是STM32CubeIDE这一步是基础。这里我以Keil MDK环境为例。新建工程与基础配置在Keil中为你的STM32F103芯片创建一个新工程选择正确的器件型号。添加必要的启动文件startup_stm32f10x_md.s和标准外设库SPL的核心文件如misc.c,stm32f10x_gpio.c,stm32f10x_rcc.c,stm32f10x_usart.c,stm32f10x_tim.c等。引入FreeModbus核心源码在你的工程目录下例如\User\FreeModbus放入从官方或可靠来源下载的FreeModbus源码。重点关注以下文件夹\modbus 这是协议栈的核心包含mb.c,mbfunc.c等通常不需要修改。\modbus\rtu Modbus RTU模式的核心文件mbrtu.c。\modbus\ascii 如果你用ASCII模式需要这个否则可以忽略。\modbus\tcp TCP模式裸机一般不用。\port我们的主战场。你可以找一个其他平台的port例程作为参考但里面的代码几乎要全部重写。在Keil工程中添加文件分组在Keil的Project窗口中建立类似“FreeModbus/Core”、“FreeModbus/RTU”、“FreeModbus/PORT”的分组将对应的.c文件添加进去。别忘了把头文件路径\modbus\include,\port添加到工程的“Include Paths”中。解决基础编译错误此时编译肯定会报一堆错主要是port里的函数未定义。别慌这正说明我们找对了地方。接下来我们就逐一攻克这些port接口。4. 硬件驱动层适配定时器、串口与GPIO的配置FreeModbus协议栈的运行严重依赖两个硬件外设一个用于通信的UARTUSART和一个用于计时的TIMER。在STM32F103上我们通常这样选择串口 (USART) 选择其中一个USART如USART1、USART2、USART3连接到RS485收发器如MAX3485。需要配置为波特率9600, 19200等、8位数据位、1位停止位、无校验Modbus RTU格式。关键点在于要使能接收中断让每个接收到的字节都能及时触发中断交给FreeModbus处理。定时器 (TIMER) 选择一个基本定时器如TIM2、TIM3、TIM4。将其配置为向上计数模式预分频器和自动重载值ARR要根据你的系统时钟频率计算以产生一个固定的时基例如50us或100us的中断。这个定时器中断将用于驱动FreeModbus内部所需的延时计时。以USART2和TIM3为例配置代码骨架如下// 在 main.c 或专门的 bsp_usart.c 中 void USART2_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // 2. 配置GPIO: PA2-TX, PA3-RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置USART参数 USART_InitStructure.USART_BaudRate 9600; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, USART_InitStructure); // 4. 使能接收中断 USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 5. 配置NVIC NVIC_InitStructure.NVIC_IRQChannel USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 6. 使能USART USART_Cmd(USART2, ENABLE); } void TIM3_Config(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 使能时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 2. 计算定时器参数。假设系统时钟72MHz目标定时中断周期为50us。 // 预分频器 PSC 72 - 1计数器时钟为 1MHz (72MHz/72)。 // 自动重载值 ARR 50 - 1即每50个计数周期产生一次中断 (50us * 1MHz 50)。 uint16_t PrescalerValue (uint16_t)(SystemCoreClock / 1000000) - 1; // 得到1MHz的计数器时钟 TIM_TimeBaseStructure.TIM_Period 50 - 1; // 50us中断 TIM_TimeBaseStructure.TIM_Prescaler PrescalerValue; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 3. 使能定时器更新中断 TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 4. 配置NVIC NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 5. 先不启动定时器由FreeModbus控制启停 // TIM_Cmd(TIM3, ENABLE); }RS485收发控制 这是一个极易忽略但至关重要的细节。RS485是半双工需要控制收发器如MAX3485的DE发送使能引脚。在发送数据前拉高DE发送完成后拉低DE切换回接收模式。这个控制逻辑必须严格否则会导致总线冲突或无法接收。通常用一个普通的GPIO如PB12来控制DE引脚。5. Port层核心实现事件、定时器与串口的裸机改造这是移植中最具挑战性的部分。我们需要让FreeModbus的核心逻辑能与我们裸机环境下的硬件中断和全局变量协同工作。5.1 事件管理 (portevent.c)在操作系统中xMBPortEventPost和xMBPortEventGet用于投递和等待事件。在裸机中我们用全局变量eEvent和eEventState来模拟。// portevent.h 中定义 typedef enum { EV_READY, // 准备就绪 EV_FRAME_RECEIVED, // 帧接收完成 EV_EXECUTE, // 执行请求 EV_FRAME_SENT // 帧发送完成 } eMBEventType; // portevent.c 中实现 static eMBEventType eQueuedEvent; // 当前事件 static BOOL xEventInQueue; // 事件队列状态标志裸机简化版 BOOL xMBPortEventInit( void ) { xEventInQueue FALSE; return TRUE; } BOOL xMBPortEventPost( eMBEventType eEvent ) { // 简单地设置事件和标志位 eQueuedEvent eEvent; xEventInQueue TRUE; return TRUE; } BOOL xMBPortEventGet( eMBEventType * eEvent ) { BOOL xEventHappened FALSE; if( xEventInQueue ) { *eEvent eQueuedEvent; xEventInQueue FALSE; // 取出后清除标志 xEventHappened TRUE; } return xEventHappened; }5.2 定时器管理 (porttimer.c)定时器接口是RTU模式超时判断的关键。我们需要实现定时器的启动、关闭、中断服务函数并在中断中调用FreeModbus提供的回调函数。// porttimer.c static USHORT usTimerTimeout; // 超时时间以50us为单位 BOOL xMBPortTimersInit( USHORT usTim1Timerout50us ) { // 保存超时时间供启动时加载到定时器 usTimerTimeout usTim1Timerout50us; // 硬件定时器初始化已在 main.c 中完成 (TIM3_Config) return TRUE; } void vMBPortTimersEnable( void ) { // 加载超时值到ARR寄存器并启动定时器 TIM_SetAutoreload(TIM3, (usTimerTimeout * 50) - 1); // 假设50us时基 TIM_SetCounter(TIM3, 0); // 计数器清零 TIM_Cmd(TIM3, ENABLE); // 使能定时器 } void vMBPortTimersDisable( void ) { TIM_Cmd(TIM3, DISABLE); // 关闭定时器 } // TIM3中断服务函数在 stm32f10x_it.c 中实现 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 调用FreeModbus的定时器超时处理函数 (void)pxMBPortCBTimerExpired(); } }提示pxMBPortCBTimerExpired是FreeModbus内部定义的一个函数指针在协议栈初始化时被赋值。我们只需要在定时器中断里调用它即可它会自动判断是字符超时还是帧间超时。5.3 串口管理 (portserial.c)串口接口负责数据的收发。我们需要实现初始化、发送单个字节、使能/禁用接收中断以及最关键的中断服务函数。// portserial.c BOOL xMBPortSerialInit( UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity ) { // 这里的参数可能与我们的硬件配置不完全匹配我们主要用它们来做校验。 // 实际的硬件初始化USART2_Config我们在main函数里做。 // 此函数可以简单返回TRUE或者根据参数进行一些软校验。 if( ulBaudRate ! 9600 ) { // 示例校验 return FALSE; } return TRUE; } BOOL xMBPortSerialPutByte( CHAR ucByte ) { // 发送一个字节阻塞式 USART_SendData(USART2, (uint16_t)ucByte); while(USART_GetFlagStatus(USART2, USART_FLAG_TC) RESET); // 等待发送完成 return TRUE; } BOOL xMBPortSerialGetByte( CHAR * pucByte ) { // 从接收缓冲区读取一个字节非中断环境下由中断服务函数填充缓冲区 // 裸机移植中这个函数通常是在串口中断服务函数里直接调用回调函数不单独使用。 // 可以留空或返回FALSE。 return FALSE; } void vMBPortSerialEnable( BOOL xRxEnable, BOOL xTxEnable ) { // 控制串口中断的使能。这是关键 if( xRxEnable ) { USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); // 使能接收中断 } else { USART_ITConfig(USART2, USART_IT_RXNE, DISABLE); // 关闭接收中断 } // 发送通常不需要中断控制因为我们用阻塞发送 } // USART2中断服务函数在 stm32f10x_it.c 中实现 void USART2_IRQHandler(void) { if(USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { uint8_t ucByte USART_ReceiveData(USART2); // 读取接收到的字节 // 调用FreeModbus的串口接收回调函数传入收到的字节 (void)pxMBPortCBTimerExpired(); // 先处理可能的定时器逻辑有些移植版本需要 (void)pxMBFrameCBByteReceived(); // 核心将字节送入协议栈解析 USART_ClearITPendingBit(USART2, USART_IT_RXNE); } // 处理其他中断... }关于pxMBFrameCBByteReceived 和定时器回调类似这也是一个FreeModbus内部函数指针。串口每收到一个字节就在中断里调用它协议栈会内部缓冲并分析这个字节判断帧的开始与结束。6. 主程序逻辑与协议栈的初始化和运行当所有port接口实现完毕后main.c里的逻辑就变得清晰了。// main.c #include mb.h #include mbrtu.h // 声明你的硬件初始化函数 extern void USART2_Config(void); extern void TIM3_Config(void); extern void RS485_DE_GPIO_Config(void); // 初始化RS485控制引脚 // 定义Modbus从站地址和寄存器 #define MB_SLAVE_ADDR 0x01 #define MB_REG_HOLDING_START 0x0000 #define MB_REG_HOLDING_NREGS 10 USHORT usRegHoldingBuf[MB_REG_HOLDING_NREGS] {0}; // 保持寄存器数组 int main(void) { eMBErrorCode eStatus; // 1. 系统基础初始化时钟、延时等 SystemInit(); // ... 其他初始化 // 2. 初始化硬件外设 RS485_DE_GPIO_Config(); // 必须先初始化GPIO USART2_Config(); TIM3_Config(); // 3. 初始化FreeModbus协议栈RTU模式从站地址为1 eStatus eMBInit(MB_RTU, MB_SLAVE_ADDR, 0, 9600, MB_PAR_NONE); if( eStatus ! MB_ENOERR ) { // 初始化失败处理错误如点亮错误灯 while(1); } // 4. 配置回调函数告诉协议栈我们的寄存器在哪里 eStatus eMBRegisterArea(MB_REG_HOLDING, (UCHAR *)usRegHoldingBuf, MB_REG_HOLDING_START, MB_REG_HOLDING_NREGS); if( eStatus ! MB_ENOERR ) { // 注册失败 while(1); } // 5. 使能Modbus协议栈 eStatus eMBEnable(); if( eStatus ! MB_ENOERR ) { // 使能失败 while(1); } // 6. 主循环不断调用协议栈轮询函数 for(;;) { (void)eMBPoll(); // 必须不断轮询以处理接收到的请求和超时事件 // 这里可以添加你的其他应用任务 // 例如更新 usRegHoldingBuf 中的值 // usRegHoldingBuf[0] Read_Sensor_Value(); } }eMBPoll()函数是裸机移植的灵魂。它内部会检查事件标志我们模拟的xEventInQueue处理接收到的字节流解析Modbus PDU执行对应的功能码如读保持寄存器0x03并组织响应帧。在主循环中必须高频调用它。7. 调试与排坑那些让我抓狂的典型问题移植过程不可能一帆风顺。下面是我遇到并解决的一些典型问题希望能帮你节省时间。问题一通信完全无反应上位机收不到任何响应。排查思路硬件链路 用万用表测RS485的A、B线电压差。静态时B-A应有约200mV电压代表空闲状态1。发送数据时应有明显跳变。检查MAX3485的VCC和GNDDE/RE控制引脚电平是否正确。控制引脚时序 这是最易出错的地方在xMBPortSerialPutByte发送第一个字节之前必须拉高DE引脚在最后一个字节发送完成USART_FLAG_TC置位之后延迟一小段时间几微秒再拉低DE。过早拉低会切断最后一个字节的发送过晚拉低会错过应答。建议在发送函数前后控制DE。协议栈未使能 确认eMBEnable()返回MB_ENOERR。中断优先级 确保串口接收中断的优先级高于定时器中断。如果定时器中断打断了串口中断可能导致字节丢失帧不完整。问题二上位机能收到响应但CRC校验错误。排查思路波特率不匹配 确认STM32的波特率计算和上位机设置完全一致。STM32的USART波特率计算对时钟精度敏感特别是使用内部RC振荡器时误差较大建议使用外部晶振。数据位/停止位/校验位不匹配 检查USART_InitStructure中的配置是否与Modbus主站设置一致。Modbus RTU通常是8N1。字节顺序问题 FreeModbus内部处理多字节数据如寄存器值时默认是大端序Big-Endian即高字节在前。如果你的上位机期望小端序需要在应用层读写usRegHoldingBuf时进行转换或者修改mbfunc.c中eMBFuncReadHoldingRegister等函数的实现。问题三只能响应一次后续请求无反应。排查思路事件标志未正确清除 检查portevent.c中的xMBPortEventGet函数在取出事件后是否将xEventInQueue设为FALSE。如果标志位一直为真协议栈会认为一直有事件待处理卡死在内部分状态机中。定时器未正确重启 在RTU模式下每收到一个字节定时器都应重新加载计时。确保在串口中断调用pxMBFrameCBByteReceived()之前或之后有重新启动定时器的逻辑通常回调函数内部会处理。检查vMBPortTimersEnable是否被正确调用。缓冲区溢出 虽然FreeModbus有内部缓冲区但如果主站发送帧速度过快或从站处理太慢可能导致状态机混乱。确保主循环eMBPoll()被足够频繁地调用。问题四程序运行一段时间后死机。排查思路中断服务函数ISR处理时间过长 串口和定时器中断服务函数必须非常短小精悍。只做最必要的操作读数据、调用回调、清标志绝不能在ISR中进行复杂计算、延时或打印调试信息。栈溢出 FreeModbus内部会使用一些栈空间。如果你的工程本身栈设置startup文件中的Stack_Size就很小比如默认的0x400再加上中断嵌套可能导致栈溢出。将栈大小适当调大如0x600或0x800试试。内存访问越界 检查usRegHoldingBuf等数组的边界。如果主站请求的寄存器地址超出你注册的范围协议栈默认会返回错误但如果你自己的应用代码不当心写越界就会导致内存破坏。8. 进阶优化与功能扩展当基础通信稳定后可以考虑以下优化和扩展让你的Modbus从站更健壮、更实用。8.1 实现多个寄存器区域FreeModbus允许你注册多个区域的同类型寄存器。例如除了保持寄存器你还可以实现输入寄存器只读如ADC采样值、线圈读写如控制继电器、离散输入只读如开关状态。只需分别调用eMBRegisterArea进行注册并在对应的回调函数如果使能了MB_FUNC_READ_INPUT_ENABLED等中提供数据指针。8.2 添加写寄存器回调默认情况下协议栈直接修改你提供的usRegHoldingBuf数组。但有时我们希望在写入发生时立即执行某个动作如改变PWM输出。可以修改mbfunc.c中的eMBFuncWriteHoldingRegister函数在写入数组后调用一个你自己定义的钩子Hook函数。8.3 移植到不同的USART或TIMER如果你需要将Modbus移到USART1或TIM2上只需修改portserial.c和porttimer.c中所有硬编码的外设访问如USART2-DRTIM3-ARR以及对应的中断服务函数名和NVIC配置。最好将这些硬件相关的部分用宏定义在port.h中提高可移植性。8.4 与裸机任务调度器结合如果你的裸机程序本身有一个简单的任务调度器如基于SysTick的时间片轮询可以将eMBPoll()放入一个中低优先级的任务中定期执行而不是放在超级循环里。这样能更好地整合多个后台任务。8.5 性能与稳定性监测可以在串口发送完成中断USART_IT_TC中拉低DE引脚实现更精确的时序控制。此外可以添加看门狗IWDG以防程序跑飞在eMBPoll()和主循环中定期喂狗。移植FreeModbus到STM32F103裸机环境是一个深入理解Modbus协议栈和单片机底层驱动的好机会。整个过程的关键在于理解FreeModbus通过port层抽象了哪些硬件依赖然后用自己的硬件驱动去填补这些抽象。当你看到Modbus Poll等测试软件成功读写到你芯片里的寄存器时那种成就感是无可替代的。希望这篇长文能成为你移植路上的详细地图祝你一次成功本文还有配套的精品资源点击获取