简介:基于 STM32F103 的 Modbus 主机完整工程代码,以 FreeRTOS 作为实时调度框架,适合工业控制、物联网等领域中需要学习或部署串行主从通信的嵌入式开发者,也可作为课程设计或产品原型的参考实现。代码实现了 Modbus RTU 主站核心功能,包括串口初始化、帧编解码与 CRC 校验、请求任务和响应解析任务之间的信号量/消息队列同步、串口中断接收以及超时和 CRC 错误处理,任务划分清晰,可直接参照移植用于二次开发。压缩包采用 7z 格式,共 303 个文件;其中 61 个 C 源文件与 95 个头文件构成协议栈和任务主体,62 个 o 文件及 axf、hex、map 等调试产物帮助还原编译与运行现场,整体大小仅 849KB,轻量易用。已有 1617 人学习浏览。工程保留 HAL 库配置文件、FreeRTOS 配置和调试信息,不仅提供可运行实例,还能帮助开发者理解 Modbus 主站的设计思路、任务划分与异常处理机制,适合做功能扩展和代码移植。 如果你接过一个活儿,要让一块STM32F103同时跑FreeRTOS,还得当Modbus RTU主机去轮询一串仪表,那你大概率会发现:网上搜“stm32f103 modbus”出来的全是从机例程,搜“freertos modbus”又都是各种半成品。真正能落地的、能直接抄的主机代码,确实不多。
这篇就是我在实际设备上跑通的方案总结。核心思路很直接:用STM32F103标准库V3.5,把FreeRTOS跑起来,然后用“任务+事件组+串口中断”的方式实现Modbus RTU主机,去轮询从站的保持寄存器。项目里要接的从站包括电能表、IO模块、温湿度传感器,都是标准Modbus RTU设备。如果你也在做类似的数据采集、设备监控控制器,这篇文章应该能帮你省掉不少自己踩坑的时间。
1. 整体设计与方案选型
1.1 为什么不是裸机状态机,而是FreeRTOS
很多老的Modbus主机代码是裸机写的,主循环里一个状态机,加一堆标志位。对单站点场景够用,但从站一多、功能一复杂就麻烦:轮询超时、串口收发、数据处理、显示刷新全挤在一个循环里,逻辑稍多点就乱。
上了FreeRTOS以后,思路就清晰很多:轮询从站是一个独立任务,数据处理是一个任务,人机交互又是一个任务。串口中断只需要把收到的字节丢进队列或者环形缓冲,解析工作放在任务里做。这样各个模块之间的耦合度低,后续加功能也不至于把原逻辑搞崩。
从硬件成本上说,STM32F103C8T6这种级别的MCU跑FreeRTOS完全没压力,RAM消耗通常在1-2KB,Flash也就多个5-8KB,剩下的资源还是够用的。
1.2 主机协议栈为什么不直接用现成的
别人问我为什么不用FreeModbus,我的回答是:FreeModbus本身是偏向从机实现的,虽然也能魔改成主机,但改动量不小,而且出来之后维护成本高。主机协议栈其实比从机简单得多,核心就是三个功能码:读寄存器(03)、写单个寄存器(06)、写多个寄存器(10)。正常设备轮询用03,参数下发用06或10,这就覆盖了90%以上的需求。
自己写还有一个好处:报文格式、超时策略、重试机制都能根据现场情况灵活调。工业现场调试的时候,能用Modbus Poll这类工具模拟从站,快速验证主机发的报文对不对,比折腾什么重型协议栈舒服多了。
2. 工程搭建与FreeRTOS移植要点
2.1 标准库V3.5和编译器的一些坑
我用的还是STM32标准库V3.5,配合Keil MDK。很多人说标准库过时了,但做小项目、做维护,标准库的资料量和稳定性依然是巨大优势,CubeMX+HAL反而在DEBUG时会多一点绕路的成本。
有一点要提醒:如果你用的Keil MDK版本比较新,编译器很可能是AC6。AC6和AC5对代码的语法检查更严格,标准库里有些隐式类型转换会报警告。还有,如果你从网上拷来的代码里有#pragma pack这种结构体对齐指令,务必确认它在AC6下的行为是否符合预期,否则结构体指针直接强制转型解析Modbus报文时,会踩字节对齐的雷。
SysTick在FreeRTOS里默认用来做系统节拍,时钟初始化用SystemInit之后就不要再随便改SystemCoreClock的值。标准库V3.5自带的SystemCoreClock = 72000000,FreeRTOS的configCPU_CLOCK_HZ要保持一致,否则软件定时器、延时全是乱的。
2.2 内存分配:heap_4和任务栈规划
FreeRTOS的内存堆heap_4是默认选择,碎片和分配效率平衡是最好的。我一般把configTOTAL_HEAP_SIZE设置成12KB左右,对F103C8T6这种20KB RAM的芯片来说留的余量还行。如果外设多、环形缓冲多,建议直接上F103RCT6或者F103ZET6,64KB RAM就不用抠这几百字节了。
任务栈建议按这个经验值开:
- modbus_task轮询任务:256字,约1KB,足够跑协议栈和状态机。
- data_process_task数据处理任务:256字。
- 显示/按键任务:128字也能跑,但建议给256字。
栈开太小会出现各种诡异问题:函数调用深度一大,局部变量把栈顶冲掉,最常见的就是串口解析到一半,栈直接飞了。调试阶段可以在每个任务循环里调用uxTaskGetStackHighWaterMark查看剩余栈空间,把栈大小调到合理值。
2.3 串口和485方向控制的细节
Modbus RTU主机一般走RS485,所以串口配置是USART2或USART3,8位数据、无校验、1位停止位,波特率9600或19200。串口使用中断接收,有空闲中断就用空闲中断,没有就字节中断,配合稍后说的队列机制。
RS485的方向控制引脚(DE/RE)是关键,很多“主机发出去从机收不到”的问题就出在这里。发送前,先把DE拉高,让RS485芯片进入发送模式;数据全部发送完成,再拉低DE,让总线回到接收模式。注意拉低的时机不能太早,最好在发送完最后一个字节之后再等几十微秒,否则最后一个字节会被截断。
调试的时候用示波器同时看TX、RX和DE三个信号,能很清楚地看到方向切换的时序。我还见过有人把DE接到PA8上,结果PA8默认复用成了MCO输出,方向控制引脚怎么拉都不对——这类引脚冲突问题,布线前最好对着原理图过一遍。
3. Modbus RTU主机协议栈核心实现
3.1 报文格式和CRC16校验
Modbus RTU的报文格式不复杂,但细节要卡准。每个报文由地址码、功能码、数据和CRC组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| 从站地址 | 1字节 | 1-247,主机发起时填目标从站地址 |
| 功能码 | 1字节 | 0x03读寄存器、0x06写单寄存器、0x10写多寄存器 |
| 数据 | N字节 | 根据功能码变化 |
| CRC16 | 2字节 | Modbus专用CRC,低字节在前,高字节在后 |
CRC16的算法是固定套路,多项式是0xA001。我一般用查表法,速度比逐位快很多:
uint16_t modbus_crc16(uint8_t *buffer, uint16_t length) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < length; i++) { crc ^= buffer[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }发送时先发低字节,再发高字节,这是Modbus RTU的固定要求,千万别发反了。测试的时候用Modbus Poll模拟从站,能直接看出来主机发的报文对不对。
3.2 三个核心功能码的报文构造
03功能码是最常用的。比如要读从站地址为1的设备,从寄存器地址0开始连续读4个寄存器(8个字节),请求报文是这样的:
01 03 00 00 00 04 44 09其中01是从站地址,03是功能码,00 00是起始寄存器地址高字节/低字节,00 04是寄存器数量,44 09是前面的CRC16。
如果响应正常,从站会返回:
01 03 08 xx xx xx xx xx xx xx xx CRC_L CRC_H08是返回的字节数,等于寄存器数乘以2。如果从站报异常,功能码最高位会置1,比如83,后面跟一个异常码:01非法功能、02非法地址、03非法数据值。
06功能码写单个寄存器,报文结构是地址+功能码+寄存器地址+数据值+CRC。10功能码写多个寄存器,报文要带字节长度和寄存器数据。这三种功能码覆盖了绝大多数Modbus RTU主站的业务场景。
有个容易忽略的限制:03功能码一次读取的寄存器数量最多125个,因为响应报文有长度限制。如果你要读的数据超过125个寄存器,必须分批读,不然从站返回的异常的会让你排查半天。
3.3 状态机设计:发送、等待、校验、完成
Modbus RTU主机软件核心是一个状态机,最简单的实现有四态:空闲、等待响应、校验、完成。
typedef enum { MB_STATE_IDLE, MB_STATE_WAIT_RESP, MB_STATE_CHECK, MB_STATE_COMPLETE } mb_state_t;工作时,主机先组装请求报文,通过485发送出去,然后进入等待响应状态。等收到完整一帧响应后,切换到校验状态做长度检查和CRC校验,通过后进入完成状态,把解析出来的寄存器数据存到共享缓冲区里,然后状态机回到空闲状态,准备下一轮轮询。
超时处理是这个状态机的关键。Modbus RTU从机响应时间通常很短,工业设备一般50-200ms内都会回复。我用FreeRTOS的tick计数做超时基准,在发送完成后记录当前tick数,在等待状态下每次循环检查是否超过设定的超时时间(比如200ms),超了就重发或跳过该从站。
4. FreeRTOS任务中怎么跑Modbus主机
4.1 串口接收:中断里丢数据,任务里解析
串口接收一定要用中断,不能在主任务轮询中阻塞等待。我的做法很直接:USART接收中断里,每收到一个字节就调用一次xQueueSendFromISR把字节放进队列,解析任务阻塞在xQueueReceive上。
为什么不用信号量而用队列?因为队列天然能缓存未及时处理的数据。如果某个时刻中断连续来了20个字节,解析任务还没来得及处理,队列可以把字节先存住,不丢数据。信号量只能告诉你“来数据了”,数据本身还得靠缓冲区接住,多一套机制。
在中断里千万别调用xQueueSend这种非中断版本API,必须用带FromISR后缀的版本,参数里带上pxHigherPriorityTaskWoken,并在中断结束后做一次portYIELD_FROM_ISR。很多刚开始用FreeRTOS的人在这上面翻过车。
4.2 事件组还是信号量:怎么把收发流程串起来
Modbus主机任务的伪流程是:构建请求、发数据、等串口收到完整响应、解析校验、更新数据。如果只用队列,任务无法区分“收到的是完整一帧”还是“收到了一半”。
我常用的方案是事件组。发完请求之后,等待两个事件:响应事件或超时事件。串口接收中断在每收到一个字节时写入队列,然后在空闲中断(或按帧间隔判断)里设置了EVT_RX_FRAME事件位;解析任务在收到这个事件后,再清空队列里的数据组装成一帧。
有了事件组,任务可以这样组织:
EventBits_t bits = xEventGroupWaitBits(mb_event_group, EVT_RX_FRAME | EVT_TIMEOUT, pdTRUE, pdFALSE, pdMS_TO_TICKS(200));这样,Modbus主机任务在等待期间不会无限期阻塞,超时了可以重发或切换从站。整个协议栈跑在低优先级的modbus_task里,不会死等串口数据,CPU利用率很健康。
4.3 任务优先级怎么定
任务优先级设置没有绝对标准,但有个基本方向:串口中断优于任务,处理实时性高的任务优先级高于机械轮询任务。
我项目里排序是:按键/显示任务低优先级,modbus_task中优先级,data_process_task中高优先级。如果增加了一个屏幕刷新任务,建议把刷新放低优先级,别让它和Modbus任务抢CPU。
共享缓冲区要注意防竞争。比如data_process_task读寄存器数据,modbus_task在后台更新数据,两者同时操作同一片内存时,用二值信号量或互斥量保护。不过如果你的数据只是简单的整数拷贝,F103做32位读写本身是原子的,风险不大,但写结构体数据时还是加锁稳妥。
5. 常见问题排查与实用调试技巧
5.1 主机连不上从机,先查接线再查报文
“485主从分开测都正常,一连在一起就不正常”这个问题,我遇到不下十次。先用万用表量A、B两根线的电压,静态时A相对B高2-5V是正常的。如果A、B接反,从机肯定没有响应。其次是共地,485是差分信号,看似不需要地线,但现场共模电压不一致会导致通信不稳,建议把设备的地连一下。
如果接线没问题,就用Modbus Poll模拟从站来验证主机。Modbus Poll的界面很简单,选好串口、波特率、从站地址,就能看到主机发过来的请求报文,还能手动模拟从站响应。这样能快速确认是主机报文有问题还是从站响应有问题。
5.2 串口乱码、丢字节、CRC不过
这种问题依次排查三个环节:
- 时钟配置:F103的外部晶振如果焊的是8MHz,你却在RCC配置里按12MHz算,波特率就会差得一塌糊涂。先用示波器看PWM输出或MCO引脚,确认系统时钟没问题。
- 中断优先级:FreeRTOS要求能用API的中断优先级不能高于
configMAX_SYSCALL_INTERRUPT_PRIORITY,如果你把串口中断设成了0(最高优先级),调用FromISR系列API反而会出各种系统崩溃。 - CRC校验失败:多半是报文不完整,尤其是485方向脚切回接收太晚,响应帧最后一个字节丢了。在拉低DE前加一点延时,或者改用TXE+TXC完成中断来控制方向脚。
5.3 FreeRTOS任务卡死和堆栈溢出
任务卡死最常见的原因是队列或信号量等待超时设置成无限阻塞,而另一个任务因为某种原因永远不释放资源。Modbus从站异常时杳无音信,串口又没有数据,如果此时等待队列的xQueueReceive用了portMAX_DELAY,任务就挂死了。所以Modbus相关等待一律要设置超时。
堆栈溢出建议直接打开FreeRTOS的configCHECK_FOR_STACK_OVERFLOW=2,这个检查使用tick中断来检测栈指针是否越界,比较可靠。同时,在任务循环里周期调用uxTaskGetStackHighWaterMark打印剩余栈空间,新任务刚跑起来的时候是最容易露出马脚的。
我在实际项目里最后又加了一个统计任务,把每个任务的栈水位、任务状态、运行次数都通过串口打印出来,并且把Modbus轮询超时次数记录下来。现场设备出问题时,让运维把调试日志发回来,那个“超时次数”分分钟能定位是哪台从站偶尔不响应。调试阶段再习惯用Modbus Poll做模拟从站,配合FreeRTOS的栈水位监控,这套组合拳打下来,Modbus主机+FreeRTOS的项目基本不会有大坑。如果你后续要扩展Modbus TCP,也是同样的状态机思路,只是把串口收发换成网络收发,那又是另一个话题了。
本文还有配套的精品资源,点击获取