简介:这份资源面向嵌入式开发初学者与需要快速落地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.c | RTU 模式收发与帧处理 | 必须 |
mbascii.c | ASCII 模式 | 可裁剪 |
mbtcp.c | TCP 模式 | 可裁剪 |
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 主频 72MHz,72MHz/3600=20kHz,计数周期 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~247,0 是广播地址。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 接 A,B 接 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 占用。希望帮到你。
本文还有配套的精品资源,点击获取