简介:面向已有单片机与RTOS基础、希望系统掌握Modbus主从通信的嵌入式开发者,这份资料围绕FreeModbus协议栈展开,重点回应从机开源而主机源码收费、网络上缺少完整可参考主机实现的痛点。内容以PDF形式汇总,包含FreeModbus V1.6源码说明、在STM32H743+FreeRTOS上移植的主站与从站例程及详尽注释,并涉及主机与从机在同一协议栈运行、常用Modbus方法支持、阻塞与非阻塞请求模式及自定义超时等关键设计,同时配有调试工具、主机使用说明、主从机流程图、协议文档和工程文件结构图,便于对照理解协议栈分层、接口风格与移植细节。压缩包共1个PDF文件,约120KB,体量轻巧却覆盖源码说明、移植代码、流程图与文档等多类材料。已有506人学习下载,适合需要快速搭建STM32+FreeRTOS+FreeModbus实验环境、梳理主从站调用流程并查漏补缺的初中级嵌入式工程师参考。
1. 移植前先认清一个事实:FreeModbus V1.6 只有从机,主机得自己补
去年做模拟项目X,STM32H743 的板子上跑着 FreeRTOS,要同时挂三个 Modbus 从站采数据,还得让上位机把这颗 MCU 当从站读。最省事的想法是拿 FreeModbus V1.6 一把梭,结果从机跑得挺顺,主机那半边代码翻遍了只有一句提示:主站模式不在这份源码里。这就是大多数人的第一次翻车——部署前没确认协议栈的能力边界,装完才发现要换栈或者自己补。FreeModbus V1.6 是干净的从机实现,体积小、移植层清晰,适合先验证串口和 T3.5 时序;主机侧得另外接一块源码。手头这套资料的价值就在这儿:V1.6 原版从机,加一份在 STM32H743 + FreeRTOS 上验证过的主站扩展,主从同栈并存,还配了说明文档和流程图。如果你正在 STM32 上做 Modbus 主从一体,下面这套装法可以直接照着走。
2. 环境版本对齐:STM32H743 + FreeRTOS + FreeModbus V1.6 到底装什么
装 FreeModbus 最容易犯的错不是代码写错,是版本没对齐。社区里能搜到的移植笔记,一半是 F103 + 裸机的,一半是 V1.5 的,直接套到 H743 + FreeRTOS 上,编译能过,跑起来就是收不到帧。所以第一步不是打开 Keil,而是把下表这几样东西先定死,后面所有问题都能回溯到这张表上。
2.1 硬件与工具链版本组合
| 组件 | 选的版本 | 选择理由与注意点 |
|---|---|---|
| MCU | STM32H743VIT6 | 示例工程基于 H7 系列,F4/F1 只需替换 port 层 |
| HAL 库 | STM32CubeH7 1.11.x | 与 CubeMX 生成代码保持同一版本,避免 HAL_UART 接口签名差异 |
| RTOS | FreeRTOS v10.4.x(CMSIS-RTOS v2 封装) | v10 之后 xTaskCreate 参数与队列 API 稳定,够用 |
| 协议栈 | FreeModbus V1.6 + 主站扩展 Rev1.0.0 | 从机用 V1.6 原版,主站用扩展包,风格一致 |
| IDE | Keil MDK 5.36 及以上,ARMCC V6 | V6 编译器对__weak和枚举类型检查更严,早发现类型问题 |
| 串口 | USART2,115200 8N1 | 从站与主站各占一路串口时再扩 USART3 |
| 定时器 | TIM6 或 TIM7(基本定时器) | 只做 T3.5 计时,不占用通用定时器 |
| 上位机调试 | Modbus Poll 7.0 / Modbus Slave 7.0 | 一个当主一个当从,双端对拷最直观 |
表里有三个点值得多说两句。FreeRTOS 版本为什么卡在 v10 而不是更新的版本?因为 V1.6 的移植层里,事件通知用的是自定义队列,队列项大小和阻塞时间单位都是 tick,v10 之后的 API 完全兼容,再往上换版本收益不大但改动面变大。HAL 库为什么强调跟 CubeMX 同版本?因为HAL_UART_Receive_IT、HAL_UART_Transmit_IT在不同小版本之间对pData的处置有过调整,混版本编译出来的行为很难排查。定时器为什么用 TIM6/TIM7?基本定时器没有输入捕获、没有 PWM,不会被别的外设抢走,做纯计时最省心。
还有一点是编译器。ARMCC V6 对eMBParity这类枚举的隐式转换比 V5 严格,FreeModbus 里不少老代码是UCHAR和枚举直接互转的,V5 能过的在 V6 下会报警告甚至错误。我一般会在工程选项里把这类警告先看清楚,不要一键忽略,因为其中一个警告往往就是后面收不到帧的根因。
2.2 源码目录:哪些文件必须进工程,哪些坚决不进
V1.6 原始包解压后目录很规整,但 demo 目录里塞了 AVR、BARE、WIN32 一堆平台,全加进 Keil 工程会让编译器去找不存在的头文件。正确做法是只取modbus目录,然后自己建一个port目录放三个移植文件。
FreeModbus-V1.6/ ├── modbus/ │ ├── include/ # 全部头文件,必须进 Include Path │ ├── rtu/ # mbrtu.c mbrtucfg.h mbrtufunc.c mbrtu_m.c │ ├── ascii/ # 用不到可整目录排除 │ ├── tcp/ # 用不到可整目录排除 │ └── functions/ # mbfunccoils.c mbfuncdisc.c mbfuncholding.c ... ├── demo/ │ └── BARE/ │ ├── port/ # 抄这里的 portserial.c porttimer.c portevent.c 当模板 │ └── demo.c # 只作参考,不要直接编进工程 └── tools/ # 文档与流程图,不进编译加进 Keil 工程的清单是:modbus/include下的头文件全部进 Include Path;modbus/rtu下四个 c 文件;modbus/functions下按需保留功能码文件,用不到的功能码直接不加,能省下几 KB Flash;自己写的port目录三个文件。modbus/ascii和modbus/tcp一律排除,eMBInit里如果传了MB_ASCII而没编 ascii 目录,链接阶段才会报错,那时候再回头删更折腾。
主站扩展包是另一套目录,结构跟从机保持一致,多出mb_master.c、mbrtu_m.c、mbfunc_m.c一类文件。加工程时注意主站和从机共用include下的mb.h、mbport.h,如果两份头文件路径同时进 Include Path,编译器会拿到先搜到的那份,症状是莫名其妙的函数重复定义或者结构体大小不一致。我的做法是只保留一份公共头文件目录,主站特有的头文件单独放一个子目录,路径顺序排在公共目录之后。
2.3 mbconfig.h:三个开关决定你最终编译出什么
mbconfig.h是 FreeModbus 的总闸,几乎所有"编进去了但没反应"的问题都能在这里找到答案。
/* mbconfig.h 关键裁剪,RTU 从机 + RTU 主机 */ #define MB_RTU_ENABLED 1 /* 用 RTU,必开 */ #define MB_ASCII_ENABLED 0 /* 关掉,省代码空间 */ #define MB_TCP_ENABLED 0 /* 板子上不跑以太网就关 */ /* 从机支持的功能码,用不到的置 0,链接时会裁掉对应 c 文件 */ #define MB_FUNC_READ_COILS_ENABLED 1 /* 0x01 */ #define MB_FUNC_READ_DISCRETE_INPUTS_ENABLED 1 /* 0x02 */ #define MB_FUNC_READ_HOLDING_REGISTER_ENABLED 1 /* 0x03 */ #define MB_FUNC_READ_INPUT_REGISTER_ENABLED 1 /* 0x04 */ #define MB_FUNC_WRITE_REGISTER_ENABLED 1 /* 0x06 */ #define MB_FUNC_WRITE_MULTIPLE_REGISTERS_ENABLED 1 /* 0x10 */ /* 从机地址范围与缓冲区,按实际点数给,别一拍脑袋写 1024 */ #define MB_PDU_SIZE_MAX 256 #define MB_FUNC_HANDLERS_MAX 8这段配置有两个容易忽略的连带关系。功能码开关置 0 之后,协议栈收到对应请求会回异常码MB_EX_ILLEGAL_FUNCTION,而不是不响应,调试工具上看到的是异常帧,不是超时,这两个现象指向完全不同的问题。MB_FUNC_HANDLERS_MAX必须大于等于你实际打开的功能码数量,写小了会在初始化时直接断言失败,看起来像死机。缓冲区大小按你实际寄存器点数给,给太大浪费 RAM,给太小则多寄存器读会截断。
主站侧还有一组独立的配置,超时时间、最大并发请求数、是否启用阻塞模式,都在主站的头文件里。默认超时我一般设 200ms,RS485 半双工换向加上从站响应时间,100ms 以下在 115200 波特率上很容易误判超时。
3. 从机移植落地:portserial / porttimer / portevent 三件套怎么填
FreeModbus 的移植层只暴露三个文件,协议栈本身完全不关心你用的是哪颗 MCU。这三个文件填对了,从机基本就活了;填错了,现象千奇百怪但根因都在这三处。下面按"串口 → 定时器 → 事件"的顺序写,这个顺序也是我建议的调试顺序,先保证能收到字节,再保证能判断帧结束,最后才谈事件通知。
3.1 portserial.c:中断收发与 RS485 换向
串口层要做的事只有三件:初始化、把收到的字节喂给协议栈、把协议栈要发的字节发出去。RS485 半双工还多一件:发送时拉高 DE,发送完成拉低。
/* portserial.c —— STM32 HAL 版 RTU 串口移植 */ #include "port.h" static UART_HandleTypeDef *pUart = &huart2; /* 从站占用 USART2 */ static UCHAR ucRxByte; BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { /* 数据位固定 8,校验位由 CubeMX 配好,这里只做参数校验 */ if (ucDataBits != 8) { return FALSE; } /* 关键:注册协议栈的字节回调,收到一个字节就交给 mbrtu.c 的状态机 */ pxMBFrameCBByteReceived = xMBRTUReceiveFSM; pxMBFrameCBTransmitterEmpty = xMBRTUTransmitFSM; /* 开单字节中断接收,不用 DMA,帧边界靠 T3.5 判断 */ return (HAL_UART_Receive_IT(pUart, &ucRxByte, 1) == HAL_OK) ? TRUE : FALSE; } /* 每收到一个字节:喂给协议栈 + 重启 T3.5 定时器 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == pUart) { (void)xMBPortEventPost(EV_FRAME_RECEIVED); /* 置接收事件 */ vMBPortTimersEnable(); /* 帧内还有字节,T3.5 重头计时 */ (void)HAL_UART_Receive_IT(pUart, &ucRxByte, 1); } }pxMBFrameCBByteReceived这两个函数指针是协议栈和硬件之间唯一的桥,赋值必须在xMBPortSerialInit里完成,而且要在开中断之前。我见过把赋值写在eMBEnable()之后的版本,第一帧必定丢,因为中断先进来了,指针还是空的。vMBPortTimersEnable()放在每个字节之后重启,是 RTU 判断帧结束的核心机制:只要 3.5 个字符时间内没有新字节,就认为这一帧收完了。少了这一句,从机会把多帧拼成一帧,CRC 永远错。
发送侧和换向控制合在一起:
/* 发送一个字节,同时把 485 方向脚拉成发送态 */ BOOL xMBPortSerialPutByte(CHAR ucByte) { RS485_DE_HIGH(); /* 先换向,再发数据 */ if (HAL_UART_Transmit_IT(pUart, (uint8_t *)&ucByte, 1) != HAL_OK) { return FALSE; } return TRUE; } /* 发送完成中断:最后一个字节出完了,切回接收态 */ void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart == pUart) { RS485_DE_LOW(); /* 晚一步切回会吃掉从站回包 */ pxMBFrameCBTransmitterEmpty(); /* 通知协议栈继续发下一字节 */ } }换向时机是这个文件里最容易翻车的地方。RS485_DE_LOW()如果放在HAL_UART_Transmit_IT调用之后立刻执行,数据还没出完方向就切回接收,从站收到的最后几个字节是错的。正确位置是发送完成回调里,也就是TC中断之后。有些 HAL 版本的HAL_UART_TxCpltCallback在最后一个字节写入 DR 就触发了,而不是真正移位完成,这时候要在回调里加一点延时或者直接查TC标志。这个细节在 9600 波特率下不明显,115200 下必现。
3.2 porttimer.c:T3.5 到底该算多少微秒
T3.5 是 RTU 帧间隔,3.5 个字符时间。规范里给了两条规则:波特率大于 19200 时固定 1750us,小于等于 19200 时按实际字符时间算。很多人只记住 1750us,在 9600 波特率下直接用,结果帧被切碎。
/* 一个字符按 11 bit 算:起始 1 + 数据 8 + 校验 1 + 停止 1 */ static USHORT usT35TimeOut50us(ULONG ulBaudRate) { ULONG ulT35us; if (ulBaudRate > 19200UL) { ulT35us = 1750UL; /* 规范建议值,直接固定 */ } else { ulT35us = (11UL * 3500000UL) / ulBaudRate; /* 3.5 字符 × 1e6 / 波特率 */ } /* 定时器时基是 50us,换算成计数值 */ return (USHORT)(ulT35us / 50UL); } BOOL xMBPortTimersInit(USHORT usTimeOut50us) { /* TIM6 预分频到 50us 计数,ARR 由协议栈传入的计数值决定 */ __HAL_TIM_SET_PRESCALER(&htim6, (SystemCoreClock / 20000UL) - 1); __HAL_TIM_SET_AUTORELOAD(&htim6, usTimeOut50us); __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); HAL_NVIC_SetPriority(TIM6_IRQn, 5, 0); /* 优先级低于串口中断 */ HAL_NVIC_EnableIRQ(TIM6_IRQn); return TRUE; }usT35TimeOut50us这个函数名里的 50us 不是随便定的,FreeModbus 的移植层约定用 50us 作为基准时基,所有平台的 porttimer 都按这个约定实现。你在 9600 波特率下算出来大概是 4011us,除 50 得 80,写进 ARR 就是 80。校验位有无会影响字符长度,无校验时是 10 bit,严格算应该用 10,但用 11 偏保守,多等一点不会错帧,少等一定会。
定时器中断里只做一件事:
void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); (void)xMBPortEventPost(EV_FRAME_RECEIVED); /* T3.5 到期 = 一帧结束 */ vMBPortTimersDisable(); /* 一次性,别让它一直跑 */ } }事件宏名在不同版本里可能是EV_FRAME_RECEIVED或者EV_READY,按你手上的mbport.h为准。中断优先级我给的是 5,比串口低,比普通任务高。高于串口会导致串口字节丢失,低于configMAX_SYSCALL_INTERRUPT_PRIORITY会导致在中断里调用 FreeRTOS API 时触发断言。
3.3 portevent.c:用 FreeRTOS 队列把事件递出去
事件层最简单,一个队列,两个函数。
/* portevent.c —— 用 FreeRTOS 队列承载协议栈事件 */ static QueueHandle_t xMbEventQueue = NULL; BOOL xMBPortEventInit(void) { /* 队列深度 4 足够,事件只有"帧到了""帧发完了"几种 */ xMbEventQueue = xQueueCreate(4, sizeof(eMBEventType)); return (xMbEventQueue != NULL) ? TRUE : FALSE; } BOOL xMBPortEventPost(eMBEventType eEvent) { /* 中断里调用,必须用 FromISR 版本且不能阻塞 */ BaseType_t xWoken = pdFALSE; (void)xQueueSendFromISR(xMbEventQueue, &eEvent, &xWoken); portYIELD_FROM_ISR(xWoken); return TRUE; } BOOL xMBPortEventGet(eMBEventType *eEvent) { /* 阻塞等待,超时给 portMAX_DELAY,让 eMBPoll 所在任务睡到有事件为止 */ return (xQueueReceive(xMbEventQueue, eEvent, portMAX_DELAY) == pdTRUE) ? TRUE : FALSE; }这里有个坑:xMBPortEventPost在中断里被调用,一旦你在里面用了xQueueSend而不是xQueueSendFromISR,FreeRTOS 会在运行时检测到并进断言,或者更糟,静默破坏队列。另一个坑是xMBPortEventGet的阻塞时间,写 0 会让eMBPoll变成忙等,CPU 占用 100%,看起来像死循环;写portMAX_DELAY才是正确姿势,前提是eMBPoll跑在独立任务里,不能放在定时器回调里调。
3.4 从机寄存器回调与启动顺序
三件套填完之后,还要给协议栈提供寄存器访问回调,否则读上来的全是 0 或者直接回异常码。
/* 保持寄存器读回调,协议栈按起始地址和数量调进来 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { if (eMode == MB_REG_READ) { for (USHORT i = 0; i < usNRegs; i++) { USHORT usVal = usHoldReg[usAddress + i]; /* 应用侧寄存器数组 */ *pucRegBuffer++ = (UCHAR)(usVal >> 8); /* Modbus 是大端 */ *pucRegBuffer++ = (UCHAR)(usVal & 0xFF); } } else { for (USHORT i = 0; i < usNRegs; i++) { usHoldReg[usAddress + i] = (USHORT)(pucRegBuffer[0] << 8 | pucRegBuffer[1]); pucRegBuffer += 2; } } return MB_ENOERR; }字节序是最常见的低级错误。Modbus 线上是高字节在前,而 STM32 是小端,直接memcpy过去读出来的值高低字节是反的,现象是上位机读到 0x0100 而不是 0x0001。地址也要注意,Modbus Poll 里填的 40001 对应协议里的地址 0,工具会自动减 1,但如果你在回调里又减了一次,就会整体偏移一位。
启动顺序固定三步,顺序错了会初始化失败:
/* 从机启动:初始化 → 使能 → 任务循环 */ eMBErrorCode eStatus = eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_NONE); if (eStatus != MB_ENOERR) { Error_Handler(); } eStatus = eMBEnable(); /* 使能后才开始收帧 */ if (eStatus != MB_ENOERR) { Error_Handler(); } for (;;) { (void)eMBPoll(); /* 内部阻塞在事件队列上 */ }eMBInit的第二个参数是从站地址,第四第五个是波特率和校验方式,必须和 CubeMX 里 UART 的配置完全一致,两边不一致的表现是能收到帧但回的数据全错。eMBEnable之前不要开串口中断,否则第一帧会在协议栈还没准备好时到来。
4. 主站补齐:自研主机栈怎么接进同一套工程
从机跑通只是第一步,真正难的是主站。FreeModbus V1.6 没有主站实现,这套资源里的主站扩展跟从机共用一套风格和接口,好处是你不用在两个栈之间来回切思维。装的时候按"源码放哪 → 请求怎么写 → 主从怎么共存"三步来。
4.1 主站源码位置与接口对齐
主站文件单独放一个目录,跟modbus/rtu平级,不要混进去。混进去的直接后果是从机的mbrtu.c和主站的mbrtu_m.c里各有一份同名静态变量,链接器报重复定义。
modbus/ ├── include/ # 主从共用的 mb.h mbport.h ├── rtu/ # 从机 RTU 实现 ├── functions/ # 从机功能码 └── master/ # 主站扩展,独立目录 ├── include/ # 主站头文件,Include Path 排在公共目录之后 ├── mbrtu_m.c # 主站 RTU 状态机 ├── mbfunc_m.c # 主站功能码封装 └── mb_master.c # 主站调度与请求接口接口风格跟从机保持一致的意思是:从机用eMBInit/eMBEnable/eMBPoll,主站就是eMBMasterInit/eMBMasterEnable/eMBMasterPoll,参数顺序、返回值类型都对齐。这样应用层写起来不用记两套范式。初始化时主站需要额外传一个超时基准值,我一般给 200ms,含义是单次请求从发出到判定超时的总时长。
4.2 阻塞还是非阻塞:请求模式怎么选
这套主站扩展提供的核心能力是两种请求模式。阻塞模式适合顺序采集:发一条读寄存器命令,任务原地等结果,拿到数据再发下一条。非阻塞模式适合并发或高实时性场景:请求发出去立刻返回,结果通过回调或者标志位通知。
/* 阻塞模式:读 1 号从站 0x0000 开始的 8 个保持寄存器,超时 200ms */ USHORT usRegBuf[8]; eMBMasterReqErrCode eErr = eMBMasterReqReadHoldingRegister( 0x01, /* 从站地址 */ 0x0000, /* 寄存器起始地址 */ 8, /* 寄存器数量 */ usRegBuf, /* 结果缓冲区 */ 200); /* 自定义超时,单位 ms,写 0 用默认值 */ if (eErr == MB_MRE_NO_ERR) { /* 数据已经在 usRegBuf 里,按大端解出来 */ }eMBMasterReqReadHoldingRegister返回的枚举值要分清楚三类:MB_MRE_NO_ERR表示成功;MB_MRE_TIMEDOUT表示从站没回应,检查接线、地址、波特率;MB_MRE_EXE_FUNC一类表示从站回了异常帧,说明从站收到了但功能码或地址越界。第三种情况很多人当成通信故障去查线路,方向完全错了。
非阻塞模式的写法是发完就返回,结果在eMBMasterPoll里被处理:
/* 非阻塞:发出去立即返回,结果靠轮询状态位取 */ eErr = eMBMasterReqReadHoldingRegister(0x02, 0x0000, 4, usRegBuf, 200); if (eErr == MB_MRE_NO_ERR) { /* 请求已成功入队,不代表数据已到 */ } /* 主站任务循环里统一收割结果 */ for (;;) { (void)eMBMasterPoll(); if (xMasterResultReady) { /* 处理 usRegBuf */ xMasterResultReady = 0; } vTaskDelay(pdMS_TO_TICKS(1)); }超时参数为什么可配?因为不同从站的响应速度差别很大,继电器模块可能 5ms 就回,电表要 300ms。全局一个超时值要么误杀慢从站,要么让快从站的故障发现得太晚。给每条请求单独传超时,是这套接口里最实用的设计。
4.3 主从同栈共存的资源划分
"支持主机与从机在同一协议栈运行"这句话听着美好,落地时要解决一个物理问题:串口归谁。同一路 USART 不可能既做主站又做从站,因为主站要主动发起,从站要被动响应,方向冲突。
我的划分方式是:主站占 USART2,从站占 USART3,各自有独立的 port 实例。这套代码里两个实例通过编译宏区分,MB_MASTER_PORT和MB_SLAVE_PORT分别指向不同的串口句柄和定时器。如果你板子上只有一个串口能用,那主从只能分时切换,靠一个应用层状态机控制,切换时要把两边的状态机都复位——这个做法在实测里比较别扭,能加串口就加串口。
任务划分上,从机eMBPoll和主站eMBMasterPoll各跑一个任务,优先级给一样,都是osPriorityNormal。从机任务优先级不能太低,否则主站请求打过来的时候从机正在被低优先级任务占着 CPU,响应超时。两个任务栈我各给 512 字(2KB),实测够用,但如果你的寄存器回调里有浮点运算或者printf,得往上加。
4.4 主站请求调度:别在中断里发请求
主站请求必须在任务上下文里发,不能在定时器中断里发。原因很简单,请求函数内部会拿互斥量、操作队列,中断上下文里调直接触发断言。定时采集的正确写法是软件定时器回调里只发一个信号量,任务拿到信号量再发请求。
/* 定时采集:定时器只发信号,任务里发请求 */ void vSampleTimerCallback(TimerHandle_t xTimer) { xSemaphoreGive(xSampleSem); /* 中断/定时器上下文里只做这个 */ } void vMasterTask(void *pvParam) { for (;;) { if (xSemaphoreTake(xSampleSem, portMAX_DELAY) == pdTRUE) { USHORT usBuf[8]; if (eMBMasterReqReadHoldingRegister(0x01, 0, 8, usBuf, 200) == MB_MRE_NO_ERR) { /* 解析并存入共享区 */ } } } }多个从站轮询时,把请求做成一个地址表,循环遍历,每个从站超时独立。一个从站掉线不要影响其他从站的采集节奏,这是现场最容易出问题的地方——某个从站断线,主站卡在它身上等 200ms,其他从站的刷新周期全被拖长。
5. 常见问题排查:移植现场的 5 条血泪记录
下面这几条都是我在 H743 + FreeRTOS 上实际撞过的,现象、原因、解决一条条写清楚,遇到类似症状可以直接对号入座。
第一条:上位机一直 Timeout,从机毫无反应。现象是 Modbus Poll 发请求后等不到任何回包,串口助手能看到请求帧确实到了 MCU。原因多半在 T3.5 定时器上:TIM6 的 NVIC 中断没开,或者预分频算错导致计数值永远到不了 ARR。解决方法是先用示波器或者翻转 GPIO 确认定时器中断到底进没进,再核对usT35TimeOut50us算出来的值。还有一种情况是从站地址不匹配,eMBInit里写的地址跟 Modbus Poll 里填的不一样,请求会被静默丢弃——Modbus 协议规定地址不匹配不回任何东西,很容易误判成硬件故障。
第二条:收到帧但 CRC 校验失败,串口助手对比原帧发现少字节。现象是协议栈日志里频繁出现 CRC 错,用逻辑分析仪抓线上数据是完整的。原因基本锁定在 H7 的 D-Cache 上:如果用 DMA 接收串口数据,DMA 写的是内存,CPU 读的是 Cache,两者不一致。解决方法是接收缓冲区放到.dma_buffer段并在 DMA 启动前做SCB_CleanInvalidateDCache(),或者干脆不用 DMA,改成单字节中断接收。我在 H743 上吃过这个亏,排查了一整天才想到 Cache 这层。
第三条:从站能读保持寄存器,读线圈就回异常码 01。现象是功能码 03 正常,功能码 01 返回0x81 0x01。原因是对应的MB_FUNC_READ_COILS_ENABLED没开,或者开了但mbfunccoils.c没加进工程。解决方法是打开mbconfig.h里的宏,并把对应的 c 文件加进 Keil 工程组。这类问题还有一个变种:MB_FUNC_HANDLERS_MAX设得比实际开启的功能码数量小,初始化时断言失败,看起来像上电就死机。
第四条:跑三五分钟后 HardFault,或者队列断言。现象是刚上电一切正常,跑一段时间才崩,崩之前可能伴随通信异常。原因是任务栈溢出,eMBPoll里调了寄存器回调,回调里如果用了sprintf或者大数组,默认的 128 字栈根本扛不住。解决方法是用 FreeRTOS 的uxTaskGetStackHighWaterMark看水位,把从机任务栈加到 512 字以上。另一个诱因是事件队列深度给太小,主站高频发请求时队列满了丢了事件,协议栈状态机卡在中间态。
第五条:主站和从站都使能后,从机收不到自己的请求,或者主站收到自己发的帧。现象是主从在同一块板子上互相干扰。原因是两个协议栈实例共用了同一路串口的中断回调,或者共用了同一份pxMBFrameCBByteReceived指针。解决方法是每个实例独立的串口句柄、独立的中断回调、独立的 port 文件,用宏做编译分流。如果资源紧张必须共用一个串口,那就得在应用层做严格的分时,切换前把两个状态机都复位,这个方案我只在没有多余串口时才用。
6. 装完怎么验证:用 Modbus Poll 与 Modbus Slave 双端对拷
代码编过、烧进去、LED 闪了,不代表协议栈通了。验证要按"先单端、再双端、最后回归"的节奏走,每一步都有明确的通过标准,不然出了问题根本分不清是移植层还是应用层。
6.1 先验从机:用 Modbus Poll 当主站读一遍
把 Modbus Poll 7.0 装好,连接参数设成和从机一致:串口号、115200、8 位数据、无校验、1 位停止、超时 200ms。功能码选 03 Holding Register,Slave ID 填eMBInit里写的从站地址,起始地址填 0,数量填 8。
通过的标志是 Poll 窗口里Traffic一栏显示Response,不是Timeout,且数值和你代码里初始化的寄存器数组一致。这一步不通过,先用串口助手发一帧手工构造的请求验证硬件通路,再回来查协议栈。功能码 03 通过之后,把 01、02、04、06、16 依次过一遍,每个功能码都要单独验,因为不同功能码可能被编译开关裁掉了。
提示:Modbus Poll 里的地址有偏移,填 40001 和填 0 指向同一个寄存器,选一种风格一致到底,别混着填。
6.2 再验主机:用 Modbus Slave 模拟一个从站
把 Modbus Slave 7.0 开在同一条总线的另一端(或者用 USB 转 485 接回来),设成从站模式,Slave ID 填 0x01,创建 8 个保持寄存器并填上特征值,比如 0x1111 到 0x8888。然后让 MCU 跑主站请求,打断点或者用串口打印看usRegBuf里的值。
通过的标准是缓冲区里读到的值和 Modbus Slave 里填的特征值逐字节对应,高字节在前。如果读到的值字节序反了,回到 3.4 节的回调检查;如果返回MB_MRE_TIMEDOUT,检查从站地址和串口接线;如果返回异常码,检查 Modbus Slave 里有没有开启对应的功能码。
主机侧还要验两件事:一是超时参数,把 Modbus Slave 关掉,主站应该在 200ms 左右返回MB_MRE_TIMEDOUT而不是一直卡住;二是连续请求,让它循环读 1000 次,看有没有偶发的 CRC 错或者丢帧,偶发错误往往指向中断优先级或者换向时序。
6.3 上电回归清单
每次改完 port 层代码,我都会把下面这张表过一遍,五分钟能省下后面几小时的排查。
| 检查项 | 通过标准 | 失败先看哪里 |
|---|---|---|
| 串口能收字节 | 串口助手自发自收正常 | UART 初始化、GPIO 复用 |
| T3.5 定时器 | 翻转 GPIO 能看到中断周期性进入 | TIM 预分频、NVIC 使能 |
| 从机 03 功能码 | Modbus Poll 显示 Response | MB_FUNC_READ_HOLDING_REGISTER_ENABLED |
| 从机 06 功能码 | 写进去再读出来值一致 | eMBRegHoldingCB的写分支 |
| 主机阻塞请求 | 缓冲区数据与从站一致 | 字节序、超时值 |
| 主机超时处理 | 从站掉线 200ms 内返回 TIMEDOUT | 超时传参是否被覆盖成 0 |
| 连续跑 30 分钟 | 无 HardFault、无队列断言 | 任务栈水位、事件队列深度 |
从那以后我每次移植完 FreeModbus,都会先跑一遍这张清单再交给应用层开发,尤其是主机超时那一项——不验的话,现场从站掉线时主站会整个卡住,这种问题在现场排查成本极高。这套资源的包里除了 V1.6 原版源码,还带了 STM32 + FreeRTOS 的主从示例工程、主机使用说明、主从流程图和调试工具,移植时对着流程图走一遍能少走不少弯路。希望帮到你。
本文还有配套的精品资源,点击获取