简介:这是一份面向嵌入式开发者的FreeModbus移植实战资料,核心解决在FreeRTOS实时操作系统上集成FreeModbus协议栈、并实现与西门子组态屏通信的问题。资源包含50个文件,其中35个C源码和15个头文件,涵盖主库与从库的Modbus协议实现、串口移植、定时器移植、事件管理以及用户寄存器接口定义等模块,压缩包仅110KB,轻量紧凑,便于直接阅读和二次开发。已有2326人学习下载。资料中完整呈现了FreeRTOS下FreeModbus移植的关键文件结构,如portserial.c、porttimer.c、portevent.c以及modbus_user.c等,并配套用户回调与寄存器映射示例,能帮助读者快速理解RTOS任务、队列、信号量如何与Modbus协议栈衔接。对于正在学习嵌入式实时系统或需要将工业通信协议集成到项目中的开发者,这份资源能提供清晰的移植路径和可参考的代码框架。 前段时间把一个带RS485接口的温控模块从裸机程序迁到FreeRTOS上,顺手把原来的自写Modbus协议栈换成了FreeModbus。这次基于FreeRTOS的FreeModbus移植,前后大概折腾了两个晚上,踩了几个不深不浅的坑,把思路和代码细节整理一下。如果你也是在STM32上跑FreeRTOS,同时又需要给设备加一个Modbus RTU从站功能,这篇东西应该能帮你省下不少排查时间。
1. 移植前先想清楚:FreeRTOS与FreeModbus怎么分工
1.1 项目需求与选型思路
我做的是一个小型温控设备,MCU用的是STM32F103C8T6,板子上带一个RS485接口,需要把温度、湿度、加热器状态这些寄存器值上报给上位机组态软件。原来裸机时代写过一个简化版Modbus,功能单一,只能读保持寄存器,CRC校验和超时处理都很粗糙。这次因为要接入多个传感器采集任务、LCD显示任务和按键任务,决定上FreeRTOS,协议栈干脆换成了FreeModbus。
FreeModbus这个开源协议栈在工控圈子里用得很多,最大优势是协议解析、异常码生成、CRC校验这些脏活累活都做好了,留给我们的只有底层串口收发和定时器时基。它支持Modbus RTU、ASCII和TCP,我这次只用RTU从机模式。选择它而不是自己继续造轮子,理由是协议栈的可靠性直接关系到上位机通信是否顺畅,自写协议又要处理帧边界又要处理异常帧,验证成本太高。
1.2 FreeRTOS和FreeModbus的分工逻辑
很多第一次接触这两个东西组合的人会有一个疑问:FreeModbus不是本来就带了自己的事件循环吗,为什么还要塞进FreeRTOS里?其实FreeModbus的eMBPoll函数确实是一个不断轮询的状态机,裸机上可以在主循环里调用,但放在FreeRTOS里之后,这个轮询就被封装成一个独立任务。
我的理解是:FreeRTOS负责提供多任务调度、任务间通信和中断延迟处理,而FreeModbus主要负责串口字节流的状态机解析。两者之间通过事件来交互。串口收到一帧完整数据后,底层ISR通知Modbus任务去调用eMBPoll,协议栈内部会去读取缓冲区里的数据、校验CRC、解析功能码、回调用户寄存器读写函数,最后组织响应帧再由串口发送出去。
这种结构的最大好处是Modbus通信不会阻塞其他任务,温度采集、显示刷新照常运行。另一个好处是Modbus从机地址、波特率、校验方式这些参数可以在运行时通过eMBInit重新配置,非常灵活。
2. 源码结构拆解:真正要改的其实只有port层
2.1 官方包里需要拿哪些文件
FreeModbus源码目录看起来有点多,但真正需要加入工程的文件其实很少。以官方1.6版本为例,我整理了一份文件清单,照着加到MDK或者STM32CubeIDE工程里就行:
| 文件目录 | 需要加入的文件 | 作用 |
|---|---|---|
| demo/…/port | port.h、portevent.c、portserial.c、porttimer.c | 底层移植文件,需要自己改写 |
| include | mb.h、mbconfig.h、mbproto.h等 | 协议栈对外接口和配置 |
| rtu | mbrtu.c、mbrtu.h | RTU模式封装 |
| functions | mbfunccoils.c、mbfuncdisc.c、mbfuncholding.c、mbfuncinput.c | 功能码处理 |
| tcp | 不需要(RTU模式不需要) | — |
如果你是直接从官方demo工程里复制,需要注意demo工程里有些stm32示例代码是配合老标准外设库写的,和现在的HAL库不兼容。我建议只保留协议栈核心目录,port层四个文件根据自己工程重写,别直接搬demo里的实现。
2.2 port层每个文件的职责
port.h主要定义数据类型的重映射,比如BOOLEAN、UCHAR、USHORT这些,FreeModbus内部不使用标准C的类型,而是通过port.h映射到具体平台。STM32上直接typedef成unsigned char、unsigned short、unsigned long即可。这里有个容易忽略的点:USHORT必须定义为16位无符号整型,因为Modbus寄存器就是16位宽度,定义成int会出大问题。
portserial.c是串口收发底层,需要实现串口初始化、使能接收/发送、接收中断处理、发送完成中断处理这几个函数。我这次用HAL库的UART DMA功能重写了这个文件。
porttimer.c是实现Modbus RTU帧超时判断的关键,协议栈会根据这个时基判断一帧数据是否结束。典型做法是用一个硬件定时器产生50us的Tick中断。
portevent.c是事件机制,裸机版本通常用标志位实现,在FreeRTOS里可以直接用任务通知或者事件标志组实现。我偷懒用了最简单的方式:在中断里置一个全局变量,Modbus任务轮询时判断这个变量。虽然不够优雅,但实际运行很稳。
2.3 应用层需要关心的三个接口
真正需要应用层调用的接口不多:eMBInit负责初始化协议栈参数,eMBEnable启动从机,eMBPoll是轮询入口。再加上几个寄存器读写回调函数,比如eMBRegHoldingCB对应03/06/16功能码的保持寄存器操作。
不过eMBInit内部会在portserial和porttimer里注册中断,所以底层实现的函数名前缀必须和协议栈预期一致,否则编译后会链接不上。
3. STM32F103上的完整移植实操
3.1 CubeMX配置工程
我这次用STM32CubeMX生成基础工程,勾选了FreeRTOS,串口1设置为异步模式,使能USART1全局中断。RS485方向控制引脚选了一个普通GPIO,PA8。定时器方面,我选TIM3作为FreeModbus时基,配置为内部时钟,定时周期50us。
CubeMX里有两个设置直接影响移植成败。一个是在NVIC设置中把USART1全局中断优先级设置为5,TIM3中断优先级设置为6,两个都必须低于FreeRTOS可管理中断上限。另一个是FreeRTOS的堆大小,我设了8KB,默认的4KB在设备和串口任务多的时候容易不够。
CubeMX生成的代码会自动创建FreeRTOS的默认start任务,我后面会把Modbus任务挂到同一个线程里或单独再建一个任务。注意CubeMX生成的HAL时间基准默认使用SysTick,而FreeRTOS也需要SysTick,两者会冲突。建议在CubeMX的SYS设置里把Timebase Source改成TIM6或者TIM7,否则系统一跑起来就进HardFault。
3.2 串口DMA收发实现
FreeModbus的RTU模式本质上是一字节一字节喂给协议栈的,但底层接收可以用DMA来降低CPU占用。我的做法是串口DMA接收+空闲中断判断帧结束,这样一帧Modbus报文到达后会一次性被搬到缓冲区,再由空闲中断通知Modbus任务处理。
初始化代码如下:
static void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 9600; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); HAL_UART_Receive_DMA(&huart1, ModbusRxBuf, MODBUS_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); }关键是最后一行的空闲中断使能。DMA工作在循环模式或正常模式都可以,我推荐用正常模式。每次收到一帧数据后,在空闲中断里读取DMA剩余计数,算出实际接收长度,然后停止DMA等待处理完再重新开启。
串口中断处理函数这样写:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_DMAStop(&huart1); uint16_t rxLen = MODBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); ModbusRxLen = rxLen; ModbusRxFlag = 1; vTaskNotifyGiveFromISR(ModbusTaskHandle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } HAL_UART_IRQHandler(&huart1); }这里有几个细节。第一,Modbus RTU的帧之间是连续发送的,空闲中断能很精准地识别出一帧结束。第二,DMA停止后必须清理状态,否则下一次启动接收时会卡在busy状态。第三,发送RS485方向切换不能在中断里反复拉高拉低,我是在任务里根据发送完成标志来控制的。
3.3 Modbus定时器时基的实现
porttimer.c是帧超时判断的核心。FreeModbus内部用T35_50US这个宏决定定时器时基是否按50us计算,但真正重要的是定时器中断周期配置。在9600波特率下,3.5个字符时间约4.01ms,协议栈靠定时器计数来判定一帧是否结束。时基越短,帧边界判断越准,但中断开销也越大。
我的定时器初始化代码:
void vMBPortTimerInit(void) { htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz htim3.Init.Period = 50 - 1; // 1MHz / 50 = 20kHz,即50us周期 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim3); HAL_TIM_Base_Start_IT(&htim3); }定时器中断里只需要做一件事:调用协议栈的超时处理函数,让状态机推进。
void TIM3_IRQHandler(void) { HAL_TIM_IRQHandler(&htim3); }协议栈在定时器中断中会检查是否已经超过3.5字符时间,如果超时且收到了非空数据,就触发接收完成事件。这里的优先级很讲究,定时器中断必须能打断串口中断吗?不一定。但如果两者优先级配置不当,可能导致帧结束事件丢失。我实际测试中是让串口中断优先级高于定时器中断,这样接收缓慢时不丢字节。
3.4 Modbus任务的创建和回调注册
在FreeRTOS任务中创建Modbus任务:
void ModbusTask(void *argument) { eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); for (;;) { eMBPoll(); if (ModbusRxFlag) { ModbusRxFlag = 0; HAL_UART_Receive_DMA(&huart1, ModbusRxBuf, MODBUS_RX_BUF_SIZE); } vTaskDelay(pdMS_TO_TICKS(1)); } }任务栈大小建议分配256 words以上,因为eMBPoll内部会调用一些回调函数,如果回调里处理浮点数,栈需求会更大,干脆给512 words,运行时刻用栈高水位做监测。
寄存器回调函数比较关键,以保持寄存器为例:
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { uint16_t regIndex = usAddress - 1; if (eMode == MB_REG_WRITE) { for (int i = 0; i < usNRegs; i++) { holdingRegs[regIndex + i] = (USHORT)((pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]); } } else { for (int i = 0; i < usNRegs; i++) { pucRegBuffer[i * 2] = (UCHAR)(holdingRegs[regIndex + i] >> 8); pucRegBuffer[i * 2 + 1] = (UCHAR)(holdingRegs[regIndex + i] & 0xFF); } } return MB_ENOERR; }注意寄存器的地址偏移。Modbus协议地址从1开始,而C数组从0开始,不减去1的话写一次寄存器就会越界。另外Modbus数据是大端序,所以高低字节换算不能搞反。
4. 移植调试中那些容易翻车的细节
4.1 串口收到数据但eMBPoll没反应
这个问题我一开始遇到过,现象是上位机发送请求后设备完全无响应。排查发现底层DMA确实收到了数据,但协议栈没有解析出完整帧。问题出在porttimer中断没有正常启动,导致协议栈永远等不到帧结束事件。
检查链路不复杂:先看串口DMA有没有把数据搬进缓冲区,再看定时器中断有没有进入,最后在eMBPoll入口打一个断点,确认Modbus任务有被调度。如果是用任务通知唤醒的方式,还要确认通知值有没有被清除。
还有一种低级错误是串口波特率配置错了。9600和19200肉眼难辨,用逻辑分析仪看波形最直观。如果手头没有分析仪,可以在串口中断里做一个字节计数器打印出来。
4.2 FreeRTOS中断优先级导致HardFault
这个坑基本每个人都会踩。FreeRTOS对中断安全管理很严格,如果串口中断优先级比configMAX_SYSCALL_INTERRUPT_PRIORITY对应的优先级更高,在中断里调用任务通知相关API就会触发断言或HardFault。
我刚开始把串口中断优先级设成0,也就是最高优先级,结果一收到数据就死机。解决方法是打开FreeRTOSConfig.h,把configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为5,然后让串口和定时器中断优先级都大于等于5。对于STM32F103来说,数值越大优先级越低,所以设成5或6是安全的。
另外,如果CubeMX生成的HAL时间基准和FreeRTOS共用SysTick,也会出现系统卡死。我当时把HAL的Timebase Source改到TIM6后,问题才消失。
4.3 RS485方向切换时机不对
RS485是半双工总线,方向控制引脚必须在上位机请求到来前处于接收状态,在设备准备响应前切换为发送状态。很多人直接在主库里把RS485方向控制放在发送函数前拉高,发送完成后立刻拉低,结果响应帧被自己截断,或者对方收不到。
我的做法是在任务里发送前拉高方向引脚,调用HAL_UART_Transmit_DMA后等待DMA传输完成中断,在完成中断里拉低方向引脚。这样方向切换和DMA完成时机是严格同步的。如果板子上的RS485芯片有自动方向切换功能,会省很多事,但大部分国产模块还是需要手动控制。
4.4 调试工具与验证顺序
协议栈移植完成后不要直接拿组态软件联调,先用Modbus Poll或者Modscan这类主站模拟工具做最小验证。验证顺序按照功能码来:先读保持寄存器(03),再读输入寄存器(04),最后测试写单个寄存器(06)和写多个寄存器(16)。每通过一个功能码再进入下一个,能大幅缩小问题范围。
Modbus Poll里能看到详细的收发日志,包括报文和响应耗时,对定位CRC错误、地址不匹配、寄存器地址偏移都有帮助。如果上位机返回超时,先检查设备侧有没有收到请求帧,再检查响应帧有没有发出去,分两步排查,不要上来就改协议栈配置。
最后再说一个我个人的小习惯
每次移植完FreeModbus,我都会在eMBRegHoldingCB里故意写一个固定的测试寄存器,比如地址0x10,默认值0x55AA。先用Modbus Poll写入一个值,再读回来,确认整个收发链路都是通的。这样做的好处是能迅速区分是通信链路问题还是业务逻辑问题。
还有一个建议,刚上电时最好通过串口打印一行FreeModbus版本和从机地址,方便后面现场联调。别小看这行日志,很多次现场调试都是在没有仿真器的情况下靠它确认程序是否跑到了正确位置。
本文还有配套的精品资源,点击获取