2026/10/28 23:32:11

FreeModbus在STM32F103上的裸机移植与Modbus RTU从站实现

FreeModbus在STM32F103上的裸机移植与Modbus RTU从站实现 简介这份资源面向嵌入式开发初学者与需要快速落地Modbus通信的工程师提供FreeModbus协议栈在STM32F103裸机环境下的完整移植工程。内容围绕Modbus RTU通信展开涵盖UART参数配置、中断服务例程调整、时钟与寄存器适配等关键环节并附有一份Word移植说明文档梳理硬件连接、软件配置、代码结构、读写寄存器示例及常见问题排查思路便于对照工程理解协议栈调用方式。压缩包共257个文件以c源码、h头文件、o目标文件、d依赖文件及crf交叉引用文件为主另含uvprojx工程、sct分散加载、hex固件、map映射与doc说明文档整体约6.97MB目录结构贴近Keil实际工程。目前已有1243人学习下载适合希望掌握FreeModbus裸机移植、验证读写功能并优化通信性能的开发者参考。1. FreeModbus 在 STM32F103 上裸机移植从零跑通从站的最小闭环手头有一块 STM32F103 最小系统板想让它作为 Modbus RTU 从站跟上位机或 PLC 通信最省事的路径不是自己撸协议栈而是把 FreeModbus 移植到裸机上。这个标题里的关键词——FreeModbus、STM32F103、裸机、移植——指向的正是这件事不跑 RTOS直接在主循环里驱动协议栈用 USART 收发电文用定时器判断帧间隔。它解决的是「设备端没有操作系统、资源紧张、但必须支持标准 Modbus 协议」的典型诉求适合做工业采集模块、传感器变送器、小型控制板的嵌入式工程师。裸机移植的好处是代码量小、启动快、没有任务调度带来的时序抖动代价是所有超时和状态机都得自己用定时器和中断兜住。下面按「先立住原理、再动手复现、最后排坑」的顺序拆开讲。2. FreeModbus 裸机移植的底层逻辑与工程骨架2.1 为什么裸机移植比 RTOS 移植更考验时序设计FreeModbus 本身是协议栈它不关心你跑在什么系统上只要求你提供几个底层接口串口收发、定时器、事件通知。在 RTOS 下协议栈的轮询可以放在一个独立任务里用信号量或消息队列唤醒裸机下没有任务调度协议栈的eMBPoll()必须被周期性调用而 Modbus RTU 的帧边界判断完全依赖 3.5 个字符时间的静默间隔。这意味着定时器的精度和中断优先级直接决定通信是否稳定。STM32F103 的 USART 支持空闲中断IDLE配合定时器可以较准确地捕获帧结束。常见做法是USART 接收中断逐字节存缓冲区同时启动或重置一个定时器当总线静默超过 3.5 个字符时间定时器溢出置位帧结束标志主循环检测到标志后调用协议栈解析。这套机制在 9600 波特率下3.5 字符时间约 4ms定时器用 1ms tick 就能覆盖但在 115200 波特率下3.5 字符时间只有约 300μs必须用更高精度的定时器或直接依赖 USART 的 IDLE 中断。裸机移植的另一个关键是中断优先级。USART 接收中断和定时器中断不能互相打断导致缓冲区竞争。我一般把 USART 中断设为抢占优先级 1定时器中断设为抢占优先级 2确保字节接收不被定时器打断。如果反过来高速通信时可能丢字节。2.2 工程目录与关键文件清单拿到 FreeModbus 源码后不要急着往 Keil 里塞。先理清哪些文件必须保留哪些可以裁剪。FreeModbus 官方包通常包含modbus核心目录、port移植目录、demo示例目录。裸机移植只需要核心目录加自己写的 port 文件。文件/目录作用是否必须mb.c协议栈主状态机必须mbrtu.cRTU 模式收发与帧处理必须mbascii.cASCII 模式可裁剪mbtcp.cTCP 模式可裁剪mbport.h移植接口声明必须portserial.c串口移植层自己实现porttimer.c定时器移植层自己实现portevent.c事件通知层裸机可简化裁剪时注意mb.c里通过宏MB_RTU_ENABLED、MB_ASCII_ENABLED、MB_TCP_ENABLED控制编译把不用的置 0 即可。portevent.c在裸机下可以不用操作系统的事件机制直接用一个全局标志变量代替但要注意临界区保护。2.3 串口与定时器的移植接口实现FreeModbus 要求移植层提供以下函数xMBPortSerialInit、vMBPortSerialEnable、xMBPortSerialPutByte、xMBPortSerialGetByte、xMBPortTimersInit、vMBPortTimersEnable、vMBPortTimersDisable、pxMBPortCBTimerExpired。裸机下这些函数直接操作 STM32 的寄存器和中断。以 USART1 为例初始化代码大致如下// portserial.c #include stm32f10x.h #include mbport.h static volatile uint8_t ucRxBuffer[256]; static volatile uint16_t usRxCount 0; static volatile uint8_t ucFrameReady 0; BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 TX 复用推挽 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PA10 RX 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate ulBaudRate; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity (eParity MB_PAR_NONE) ? USART_Parity_No : (eParity MB_PAR_ODD) ? USART_Parity_Odd : USART_Parity_Even; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); return TRUE; } void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART1); if (usRxCount sizeof(ucRxBuffer)) { ucRxBuffer[usRxCount] ch; } // 每收到一个字节重置定时器用于 3.5 字符间隔检测 vMBPortTimersEnable(); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }这段代码里xMBPortSerialInit配置了 USART1 的 GPIO、波特率、校验位和中断。注意校验位要和 Modbus 参数一致否则协议栈解析会出错。接收中断里每收到一个字节就重置定时器这是实现 3.5 字符间隔的关键——如果总线持续有数据定时器一直被重置不会触发帧结束当数据停止定时器溢出通知协议栈一帧结束。定时器移植层用 TIM2 实现// porttimer.c #include stm32f10x.h #include mbport.h static USHORT usTimerT35_50us 0; BOOL xMBPortTimersInit(USHORT usTim1Timerout50us) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; usTimerT35_50us usTim1Timerout50us; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 72MHz / 3600 20kHz即 50us 计数一次 TIM_TimeBaseStructure.TIM_Period usTim1Timerout50us; TIM_TimeBaseStructure.TIM_Prescaler 3600 - 1; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); return TRUE; } void vMBPortTimersEnable(void) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); TIM_SetCounter(TIM2, 0); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); } void vMBPortTimersDisable(void) { TIM_Cmd(TIM2, DISABLE); TIM_ITConfig(TIM2, TIM_IT_Update, DISABLE); } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); TIM_Cmd(TIM2, DISABLE); // 通知协议栈定时器超时 pxMBPortCBTimerExpired(); } }参数说明usTim1Timerout50us是 FreeModbus 根据波特率计算出的 3.5 字符时间对应的 50μs 倍数。比如 9600 波特率下3.5 字符时间约 4ms即 80 个 50μs。定时器预分频设为 3600-1是因为 STM32F103 主频 72MHz72MHz/360020kHz计数周期 50μs。这个值必须准确否则帧间隔判断会偏。2.4 主循环里怎么调用协议栈裸机下没有任务eMBPoll()必须被反复调用。典型主循环int main(void) { eMBErrorCode eStatus; // 初始化系统时钟、GPIO 等 SystemInit(); // 初始化协议栈地址设为 1波特率 9600无校验 eStatus eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); if (eStatus ! MB_ENOERR) { while (1); } // 使能协议栈 eMBEnable(); while (1) { // 轮询协议栈处理接收、发送、超时 (void)eMBPoll(); // 这里可以放自己的应用逻辑但不要阻塞太久 } }eMBInit的参数依次是模式、从站地址、端口号、波特率、校验方式。从站地址范围 1~2470 是广播地址。eMBEnable之后协议栈才开始响应。主循环里eMBPoll不能阻塞否则会影响帧间隔判断。如果应用逻辑耗时较长建议用状态机拆分或者把应用逻辑放在定时器中断里做简单标记主循环只做轻量处理。3. 从零搭建 Keil 工程并跑通第一个 Modbus 从站3.1 新建工程与源码裁剪打开 Keil MDK新建工程选 STM32F103C8 或对应型号。添加启动文件、CMSIS 核心文件、标准外设库。然后把 FreeModbus 源码复制到工程目录下只添加mb.c、mbrtu.c、mbport.h以及自己写的portserial.c、porttimer.c、portevent.c。在mbconfig.h里配置#define MB_RTU_ENABLED 1 #define MB_ASCII_ENABLED 0 #define MB_TCP_ENABLED 0 #define MB_RTU_NUM_VALUES 16 #define MB_QUEUE_LENGTH 16MB_RTU_NUM_VALUES决定支持多少个寄存器根据实际需求改。MB_QUEUE_LENGTH是事件队列长度裸机下可以设小一点。3.2 寄存器回调函数的实现FreeModbus 通过回调函数读写寄存器和线圈。必须实现eMBRegHoldingCB、eMBRegCoilsCB、eMBRegDiscreteCB、eMBRegInputCB四个函数。以保持寄存器为例// 保持寄存器回调 eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus MB_ENOERR; USHORT iRegIndex; // 地址范围检查假设从 0 开始共 16 个寄存器 if ((usAddress 0) (usAddress usNRegs 16)) { iRegIndex usAddress; switch (eMode) { case MB_REG_READ: // 主机读从寄存器数组拷贝到缓冲区 while (usNRegs 0) { *pucRegBuffer (UCHAR)(usRegHoldingBuf[iRegIndex] 8); *pucRegBuffer (UCHAR)(usRegHoldingBuf[iRegIndex] 0xFF); iRegIndex; usNRegs--; } break; case MB_REG_WRITE: // 主机写从缓冲区拷贝到寄存器数组 while (usNRegs 0) { usRegHoldingBuf[iRegIndex] *pucRegBuffer 8; usRegHoldingBuf[iRegIndex] | *pucRegBuffer; iRegIndex; usNRegs--; } break; } } else { eStatus MB_ENOREG; } return eStatus; }注意 Modbus 寄存器是 16 位大端格式高字节在前。usRegHoldingBuf是全局数组应用逻辑直接读写它就能和主机交换数据。地址检查必须做越界返回MB_ENOREG否则协议栈会返回异常码。3.3 用 Modbus Poll 验证通信编译下载后用 USB 转 485 模块连接 STM32 的 USART1。打开 Modbus Poll设置从站地址 1、功能码 03、起始地址 0、数量 16、波特率 9600、无校验。如果一切正常应该能看到寄存器数据。如果超时先检查硬件接线A 接 AB 接 B共地。然后检查波特率是否一致校验位是否匹配。再不行就用示波器看 USART 的 TX 引脚有没有波形确认 STM32 确实在发送。常见问题是 485 收发切换方向控制。如果用的是 MAX485 芯片需要一根 GPIO 控制 RE/DE 引脚。发送前拉高发送完拉低。FreeModbus 的vMBPortSerialEnable里可以处理void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { // 使能接收拉低 DE GPIO_ResetBits(GPIOA, GPIO_Pin_8); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); } else { USART_ITConfig(USART1, USART_IT_RXNE, DISABLE); } if (xTxEnable) { // 使能发送拉高 DE GPIO_SetBits(GPIOA, GPIO_Pin_8); USART_ITConfig(USART1, USART_IT_TXE, ENABLE); } else { USART_ITConfig(USART1, USART_IT_TXE, DISABLE); } }发送中断里逐字节写USART-DR发完最后一个字节后拉低 DE。这个切换时机很关键拉低太早会截断最后一个字节拉低太晚会影响接收下一帧。4. 裸机移植 FreeModbus 的避坑与排查清单4.1 通信超时但示波器有波形现象Modbus Poll 一直超时但示波器能看到 STM32 的 TX 有数据。原因通常是波特率偏差或校验位错误。STM32F103 用外部晶振时USART 波特率寄存器计算有舍入误差9600 下误差很小但 115200 下如果晶振不是 8MHz 或 12MHz误差可能超过 2%。解决用RCC_GetClocksFreq确认实际 PCLK2 频率重新计算USART_BRR值或者换用更高精度的晶振。校验位也要和主机一致Modbus RTU 常用无校验但有些设备用偶校验。4.2 接收数据不完整或粘包现象主机发 8 个字节STM32 只收到 6 个或者两帧数据粘在一起。原因3.5 字符间隔定时器没起作用或者接收中断被更高优先级中断打断。解决检查vMBPortTimersEnable是否在每次接收中断里调用检查定时器中断优先级是否低于 USART 中断检查主循环里eMBPoll调用间隔是否过长导致帧结束标志没及时处理。如果主循环里有延时函数必须去掉或改短。4.3 写寄存器后读出来没变现象用功能码 06 写单个寄存器返回正常但用 03 读回来还是旧值。原因eMBRegHoldingCB里写模式的处理有误比如字节顺序搞反或者地址偏移算错。解决在写回调里加打印确认usAddress和usNRegs是否符合预期检查usRegHoldingBuf是否被其他代码覆盖确认MB_REG_WRITE分支确实执行了。另外注意有些主机写寄存器时地址是从 1 开始的而协议栈内部从 0 开始需要在回调里做偏移。4.4 多从站时地址冲突现象总线上挂多个从站单独测试都正常一起挂就通信混乱。原因从站地址重复或者某个从站的 485 芯片一直处于发送状态占用总线。解决逐个确认从站地址唯一检查vMBPortSerialEnable里 DE 引脚初始状态确保上电后默认为接收如果用了隔离 485 模块检查使能引脚极性是否一致。4.5 定时器中断频繁触发导致主循环卡顿现象通信正常但主循环里其他任务响应变慢。原因定时器中断里调用了pxMBPortCBTimerExpired这个函数会触发协议栈状态机如果中断太频繁主循环被大量打断。解决确认定时器只在接收数据时使能空闲时关闭pxMBPortCBTimerExpired里只置标志具体处理放到eMBPoll里做。FreeModbus 默认就是这样设计的但移植时容易直接在中断里跑状态机。5. 进阶用状态机优化裸机下的多任务响应裸机移植 FreeModbus 后主循环里除了eMBPoll往往还要处理按键、显示、采集等任务。如果这些任务耗时较长会拖慢协议栈响应。我一般用一个简单的时间片轮询框架把任务拆成状态机每个状态执行时间控制在 1ms 以内。typedef struct { uint8_t state; uint32_t timer; } Task_t; Task_t taskSample {0, 0}; void TaskSample_Poll(void) { switch (taskSample.state) { case 0: // 启动 ADC 转换 ADC_SoftwareStartConvCmd(ADC1, ENABLE); taskSample.state 1; break; case 1: if (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) ! RESET) { uint16_t val ADC_GetConversionValue(ADC1); // 更新到保持寄存器 usRegHoldingBuf[0] val; taskSample.state 0; taskSample.timer 0; } break; default: taskSample.state 0; break; } } int main(void) { // ... 初始化 ... eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { eMBPoll(); // 协议栈轮询必须高频调用 TaskSample_Poll(); // 应用任务状态机非阻塞 } }这样每个任务单次执行时间很短不会影响eMBPoll的调用频率。验证方法用示波器测 USART TX 引脚看响应帧的延迟是否稳定或者用 Modbus Poll 连续读 1000 次统计超时次数。如果超时率低于 0.1%基本可用。另一个技巧是把eMBPoll放在定时器中断里调用比如 1ms 一次。但要注意中断里不能做耗时操作且eMBPoll内部有临界区需要确保中断优先级配置正确。我一般还是放在主循环用状态机保证实时性。最后说一个血泪教训裸机移植时mbconfig.h里的MB_RTU_NUM_VALUES别设太大否则协议栈的缓冲区会占用大量 RAM。STM32F103C8 只有 20KB RAM设 16 个寄存器够用设 128 个就可能栈溢出。调试时如果发现程序跑飞先看 map 文件里的 RAM 占用。希望帮到你。本文还有配套的精品资源点击获取