简介:这是面向嵌入式开发者的FreeModbus移植资源包,聚焦在FreeRTOS实时操作系统下实现Modbus主/从站通信,适用于需要与西门子组态屏等上位机进行数据交换的工业控制场景,也适合正在学习协议栈移植的嵌入式工程师参考。资源包共50个文件,以35个C源文件和15个头文件组成,内容覆盖FreeModbus协议核心、FreeRTOS端口适配层的串口驱动、定时器与事件管理,以及用户寄存器映射和回调处理示例,整体压缩包仅110KB,结构紧凑便于逐模块研读。目前已有2326人学习下载。通过该资源可以帮助读者快速厘清FreeModbus在FreeRTOS中的分层移植思路,获得串口读写任务化、软件定时器替代硬定时器、信号量同步事件等关键实现参考,同时借助用户接口代码降低二次开发门槛,减少协议栈整合时的常见问题。 干这行的人多多少少都遇到过这种场景:新项目要求设备支持Modbus RTU通讯,你翻开FreeModbus的demo,照着裸机例程跑通了,功能验证没问题。结果后面需求越塞越多,主循环里既要刷LCD、又要扫按键、还得处理另一路报文,eMBPoll()被挤得喘不过气,主机那边动不动就报超时。我是在一次给现场设备加通信协议时被逼得换了思路——把FreeModbus从裸机轮询改成FreeRTOS环境下的独立任务。这篇文章就是那次移植的完整记录,包括源码结构怎么理解、串口和定时器怎么改、任务怎么建、以及我踩过的几个坑。适合手里有STM32基础、用过FreeModbus裸机版、想把它跑在FreeRTOS上的朋友参考。
1. 为什么要把FreeModbus搬到FreeRTOS上
1.1 裸机版的痛:主循环不可控
裸机跑FreeModbus的标准姿势很简单:初始化完外设之后,在while(1)主循环里反复调用eMBPoll(),剩下的时间分给其他模块。这套玩法在项目单一、主循环足够快的时候没什么问题,因为eMBPoll()本身是个非阻塞的状态机,只要一帧数据到达后能及时被轮询到,协议处理就不会出错。
问题是主循环不是你自己说了算的。按键要消抖、屏幕要刷新、告警要判断、另外一路串口可能还有自己的数据要解析,每一个功能都会拉长主循环的执行周期。Modbus RTU是个讲究时机协议,主站发完一帧从站必须在规定时间内应答,一旦你在主循环里某段代码卡了几个毫秒,eMBPoll()就可能错过处理窗口,主机侧表现就是"响应超时"。你说加个定时器中断去调eMBPoll?裸机中断里跑协议栈风险更大,临界区、重入问题都能让你排查到怀疑人生。
1.2 上了RTOS之后,数据流变成事件驱动
把FreeModbus搬运到FreeRTOS之后,最大的变化不是代码量,而是数据流的形态。裸机版是"主循环轮询",FreeRTOS版是"事件触发"——串口每收到一个字节,底层中断负责把数据塞进缓冲区,同时启动一个帧间隔定时器;定时器溢出中断表示一帧数据已经结束,这时候才通知Modbus任务去处理。整个流程完全中断驱动,不依赖主循环的快慢。
如此一来,显示、按键、其他业务全部回归自己的任务,跟Modbus互不干扰。Modbus任务只需处理"收到一帧"这个事件,处理完继续挂起等待,CPU占用几乎为零。调度器的优先级机制也解决了一个麻烦事:Modbus的应答时限是硬性的,只要把Modbus任务优先级提上去,它就一定能抢占到CPU,不会因为别的任务忙而迟到。
2. 移植前要摸清的家底:源码与接口
2.1 FreeModbus源码的三块结构
下载FreeModbus源码包,解压后你会发现目录其实很清晰,就三块:
- modbus/:协议栈核心代码,包括mb.c、mb.c、mbfunc.c、mbascii.c、mbrtu.c这些,这一部分基本不用动,它就是协议规则的实现。
- port/:平台相关接口,真正需要移植的就是这三个文件:port.h、portserial.c、porttimer.c。
- demo/:官方demo工程,包括ARMCM3等平台示例,可以作为移植起点参考。
如果打个比方,协议栈核心是"裁判",它只负责按Modbus规则吹哨,不管场地长什么样;port层是"场地",负责把串口收发、定时器这些硬件能力接进裁判的规则体系。裁判的哨子只有一个——它通过一系列函数指针和宏定义来操控底层,你只要把"场地"铺好,裁判的判罚就自然成立。
2.2 port层的接口清单
打开port文件夹,里面需要实现的接口可以整理成一张清单:
| 文件 | 接口 | 作用 |
|---|---|---|
| port.h | ENTER_CRITICAL_SECTION / EXIT_CRITICAL_SECTION | 定义临界区进出方式,在FreeRTOS下可复用其自身的临界区API |
| port.h | MB_BIG_ENDIAN / MB_LITTLE_ENDIAN | 定义字节序,STM32为小端 |
| portserial.c | eMBPortSerInit / eMBPortSerClose | 串口初始化和关闭 |
| portserial.c | xMBPortSerPutByte / xMBPortSerGetByte | 单个字节的收发 |
| portserial.c | vMBPortSerEnable / vMBPortSerDisable | 收发使能/禁止,用于eMBPoll处理时屏蔽新中断 |
| porttimer.c | xMBPortTimingInit | 定时器初始化 |
| porttimer.c | vMBPortTimersEnable / vMBPortTimersDisable | 定时器使能/禁止 |
| porttimer.c | vMBPortTimersTICK | 定时器中断服务函数 |
移植的本质工作,就是把这张表上的接口用你手头的STM32外设实现一遍,并且保证时序上的正确性。
3. 核心移植步骤:串口、定时器、任务
3.1 串口驱动:收发全部走中断
串口层是所有移植工作的基础,它负责让数据"进得来、出得去"。我这里以STM32的HAL库为例,但逻辑同样适用于标准库。
初始化部分要做的事:串口波特率、中断优先级、收发引脚。这里有个关键点——串口中断的优先级必须低于FreeRTOS可管理系统调用的最高中断优先级,一般工程里配置为configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY以下。如果你把串口中断优先级设得比FreeRTOS的管理上限还高,临界区就没法屏蔽它了,调度器内部的共享数据可能被破坏,程序会随机死机。
// 串口初始化(HAL库) static void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 9600; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&huart2); // 使能接收中断和发送完成中断 __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE); __HAL_UART_ENABLE_IT(&huart2, UART_IT_TC); }收发字节的两个函数最直接,往数据寄存器写或者读就行:
BOOL xMBPortSerPutByte(BYTE ucByte) { huart2.Instance->DR = ucByte; return TRUE; } BOOL xMBPortSerGetByte(BYTE *pucByte) { *pucByte = huart2.Instance->DR; return TRUE; }中断的服务函数是移植的关键。接收中断必须调用协议栈提供的prvvUARTRxISR(),发送完成中断必须调用prvvUARTTxISR()。这两个函数是port层与协议栈核心的联络员,你漏掉一个,Modbus就收发不起来了:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_RXNE) != RESET) { __HAL_UART_CLEAR_IT(&huart2, UART_IT_RXNE); prvvUARTRxISR(); } if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) != RESET) { __HAL_UART_CLEAR_IT(&huart2, UART_IT_TC); prvvUARTTxISR(); } }3.2 定时器层:3.5字符时间怎么算
Modbus RTU协议规定,两个连续字节之间的间隔超过3.5个字符时间,就认为一帧数据结束。FreeModbus正是利用这一点,在收到第一个字节后启动定时器,每次收到新字节就把定时器清零重新开始,定时器一旦溢出,就表示帧结束了。
3.5字符时间怎么算?以常见的9600波特率、8N1格式为例:一个字符包含1个起始位、8个数据位、1个停止位,一共10个bit,所以一个字符的传输耗时 = 10 / 9600 ≈ 1.04ms,3.5个字符就是约3.65ms。把这个时间换算成定时器计数:
// 假设定时器时钟为72MHz #define TIMER_CLOCK_HZ 72000000UL #define TIMER_PRESCALER 71 // 分频后1MHz,即1us一个计数 #define T35_CHAR_TIME_US 3650 // 9600波特率下3.5字符约需3650us初始化时把定时器配成向上计数模式,周期设为3650,使能和禁止函数分别对应启动和停止定时器:
void vMBPortTimersEnable(void) { __HAL_TIM_SET_COUNTER(&htim4, 0); __HAL_TIM_SET_AUTORELOAD(&htim4, T35_CHAR_TIME_US); __HAL_TIM_CLEAR_FLAG(&htim4, TIM_FLAG_UPDATE); HAL_TIM_Base_Start_IT(&htim4); } void vMBPortTimersDisable(void) { HAL_TIM_Base_Stop_IT(&htim4); }定时器中断里要调用协议栈的vMBPortTimersTICK(),这个函数会设置帧完成的内部标志位:
void TIM4_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim4, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim4, TIM_FLAG_UPDATE); vMBPortTimersTICK(); } }这里有个工程上的小建议:不建议复用FreeRTOS的SysTick来做这个帧间隔定时器,因为SysTick的节拍一般配成1ms或更高,3.65ms的精度要求1ms粒度的定时器比较勉强,而且SysTick被FreeRTOS占用,操作起来容易互相干扰。独立用TIM2/TIM3/TIM4这类通用定时器,精度和隔离性都好得多。
3.3 任务封装:从轮询到事件驱动
串口和定时器铺好之后,到了最关键的一步:在FreeRTOS里把Modbus处理逻辑封装成一个任务。
先说一个常见的错误做法:建一个任务,里面写个死循环,无脑重复调用eMBPoll(),然后配合vTaskDelay(1)。这样能跑,但代价是CPU白白空转,而且eMBPoll()在没有完整帧时也会执行一些无效的系统检查,高负载时浪费明显。更符合RTOS思路的做法是:让任务挂起,等事件通知再起来干活。
vMBPortSerDisable()和vMBPortSerEnable()的作用则在任务里体现:处理一帧数据期间,屏蔽新的串口中断,等eMBPoll返回再恢复,避免数据还没处理完就被新帧打断。
我用的方案是事件组。定时器溢出中断里设置一个帧结束标志位,任务等待这个标志:
EventGroupHandle_t xModbusEvents; #define EVT_MB_FRAME_RECEIVED (1 << 0) void vModbusTask(void *pvParameters) { eMBInit(MB_RTU, 0x01, 0, 9600, MB_PARITY_NONE); eMBEnable(); while (1) { xEventGroupWaitBits(xModbusEvents, EVT_MB_FRAME_RECEIVED, pdTRUE, pdFALSE, portMAX_DELAY); vMBPortSerDisable(); eMBPoll(); vMBPortSerEnable(); } }创建任务的时候,把优先级放到中上水平。我实测下来,在STM32F103这类M3核心上,Modbus任务优先级比空闲任务高、比硬实时任务低一点即可,因为Modbus本身的响应时限是毫秒级,不需要抢在最前面,只要保证它能在需要时立刻抢占低优先级业务任务就行:
xTaskCreate(vModbusTask, "modbus", 256, NULL, 4, NULL);栈大小建议先给256字(也就是1KB),实际使用后通过任务栈高水位标记查看余量再调整。
4. 回调函数与寄存器映射的实现要点
4.1 四个标准回调函数的角色
FreeModbus协议栈和你的业务程序之间,通过四个回调函数做数据交换。主站读你设备的寄存器,最终会落到这些回调里:
| 回调函数 | 对应Modbus功能 | 使用场景 |
|---|---|---|
| eMBRegHoldingCB | 03读保持寄存器、06写单寄存器、16写多寄存器 | 设备参数、运行设置 |
| eMBRegInputCB | 04读输入寄存器 | 测量值、实时数据 |
| eMBRegCoilsCB | 01读线圈、05写单线圈、15写多线圈 | 开关量输出 |
| eMBRegDiscreteCB | 02读离散输入 | 开关量输入 |
每个回调的返回值直接映射到Modbus异常响应:返回MB_ENOERR表示正常,返回MB_ENORESPONSE时不回复任何数据,返回MB_ILLEGAL_DATA_ADDRESS等会对应异常码。很多初学者调试时发现主站报"非法数据地址",就是回调里没处理地址范围判断。
4.2 寄存器表与多任务读写保护
回调函数里需要操作一组寄存器数组,比如我常用的保持寄存器表:
static uint16_t usRegHoldingBuf[64]; eMBErrorCode eMBRegHoldingCB(uint8_t *pucRegBuffer, uint16_t usAddress, uint8_t usNRegs, eMBRegisterMode eMode) { eMBErrorCode eStatus = MB_ENOERR; uint16_t i; if ((usAddress >= 0) && (usAddress + usNRegs <= 64)) { if (eMode == MB_REG_READ) { for (i = 0; i < usNRegs; i++) { *pucRegBuffer++ = (uint8_t)(usRegHoldingBuf[usAddress + i] >> 8); *pucRegBuffer++ = (uint8_t)(usRegHoldingBuf[usAddress + i]); } } else // MB_REG_WRITE { for (i = 0; i < usNRegs; i++) { usRegHoldingBuf[usAddress + i] = (uint16_t)(*pucRegBuffer++ << 8); usRegHoldingBuf[usAddress + i] |= *pucRegBuffer++; } } } else { eStatus = MB_ILLEGAL_DATA_ADDRESS; } return eStatus; }还有一个容易忽略的点:如果项目里另有业务任务会修改同一块寄存器数组,就必须考虑读写互斥。Modbus回调在任务上下文被调用时,可以加一个FreeRTOS互斥量来保护寄存器表;如果是在中断里被调用,那就只能靠临界区。我自己习惯用一个独立任务专门管理寄存器数据,Modbus任务只做透传,两者通过消息队列交互,这样数据一致性最好,不过项目初期可以先在回调里加临界区,代码简单,踩坑少。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
| 上电后设备无响应 | 串口中断优先级高于FreeRTOS可管理中断级别,进入临界区后中断仍触发 | 检查中断优先级配置,确保低于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY |
| 主站偶发超时 | eMBPoll任务优先级过低,被业务任务抢占 | 提高Modbus任务优先级,或降低业务任务优先级 |
| 一帧数据被拆成多帧 | 3.5字符定时器时间设置过短 | 用示波器或逻辑分析仪抓串口波形,按波特率重新计算定时周期 |
| 收到指令但从不回复 | vMBPortSerDisable/Enable没有配对调用 | 检查eMBPoll调用前后是否对称,禁止期间串口中断被屏蔽导致数据丢失 |
| 调试时频繁进HardFault | 任务栈溢出 | 启动FreeRTOS栈溢出检测,或读取任务栈高水位标记,确认栈是否够用 |
5.2 排查顺序与心得
遇到Modbus通讯问题,我自己的排查顺序是:先用串口助手单独发一帧标准Modbus报文,看从站有没有应答。没有应答,就检查串口底层的收发对不对,在中断服务函数里打点,确认有没有进接收中断;能进中断但没应答,查定时器的溢出时间是否和波特率匹配;应答了但主站还报错,那就是回调函数的地址或者数据处理逻辑出了问题。
踩过一次比较隐蔽的坑:早期移植时,定时器使能函数里忘了清Update标志,导致定时器启动瞬间立刻触发一次中断,eMBPoll在一个空帧状态下被唤醒,虽然不致命,但每次接收都会多个无意义的处理周期,性能瓶颈正好卡在高频轮询的场景上。后来在vMBPortTimersEnable里加了一句__HAL_TIM_CLEAR_FLAG(&htim4, TIM_FLAG_UPDATE),问题就消失了。这类细节,官方demo里不会明确告诉你,但在实际工程里特别重要。
另外一个建议就是多利用FreeRTOS自带的调试能力。比如跟踪Modbus任务的栈高水位,我用uxTaskGetStackHighWaterMark()打印任务剩余栈字数,发现原本分配256字在复杂报文处理时只剩不到50字余量,果断把栈加到384字。这类提前预判,能省下后期排查随机死机的大量时间。
做这个移植最深的体会是:不要一口气把所有功能都搬上去,先把03/06功能码跑通,确认单帧读写正常,再加04输入寄存器、01线圈,最后再考虑多帧连续读写和广播地址。每一步验证扎实了,整体系统就是稳的。FreeModbus加FreeRTOS这套组合,放到今天依然是中小型设备最实用、成本最低的通讯方案之一,希望这篇记录能帮你少走几步弯路。
本文还有配套的精品资源,点击获取