☰
STM32H743+FreeRTOS下FreeModbus主从同栈移植实战
2026/11/2 13:57:39 网站建设 项目流程

简介:面向已具备STM32与FreeRTOS基础、希望系统掌握Modbus主从通信的嵌入式开发者,这份PDF文档围绕FreeModbus协议栈的移植与使用展开,兼顾入门理解与工程落地。针对官方仅开放从机源码、主机源码收费且优质开源主机较少的现状,内容重点说明在STM32H743+FreeRTOS平台上运行主站与从站的实现思路:新增主机源码与原有从机风格及接口保持一致,支持主从在同一协议栈运行,并提供阻塞/非阻塞请求模式、自定义超时及常用Modbus方法调用,例程注释较为详尽,便于对照理解任务调度下的收发处理。文档还梳理主机与从机流程图、协议要点、工程文件结构及调试工具的使用说明,便于读者理解协议栈分层、移植步骤和联调排错。包内共1个PDF文件,约120KB,轻量便于查阅;已有506人学习,适合需要快速建立FreeModbus整体认识并深入主从站开发的中级读者。

1. 一块 H743 板子上的 Modbus,卡住的往往不是协议

去年接手一个 STM32H743 + FreeRTOS 的采集板,任务很简单:一边被上位机当成从站读数据,一边当主站去问下面几个 Modbus 从机。第一反应是上 FreeModbus,结果打开 V1.6 源码才发现,从机部分是全的,主机部分是要额外授权的。换了几个网上流传的主机实现,风格和从机对不上,回调、事件、超时各写一套,注释还少,改一处崩一片。后来拿到一份在 STM32H743 + FreeRTOS 上把 FreeModbus 主站和从站一起移植好的工程(Rev1.0.0),主机接口沿用从机那套 eMB 开头的命名,主从能在同一份协议栈里跑,注释也写得比较细。这篇就按我当时装通它的顺序,把版本选择、端口层移植、主从同栈配置和排错过程写清楚,适合手上已经有 H7 或 F4 板子、正准备把 Modbus 协议栈塞进 FreeRTOS 工程的人。

2. 装前清点:FreeModbus V1.6 与 STM32H743 工具链怎么对齐

动手之前先把材料摊开看一遍,这一步花十分钟,后面能省两小时。这份资源里能直接用的东西分三类:原始协议栈源码、已经移植好的工程、以及验证用的上位机工具。三类东西的版本要能对上,否则编译期不报错、运行期出玄学问题,排查成本极高。

2.1 三份核心文件各管一段,别混着用

FreeModbus V1.6 的压缩包是协议栈本体,目录结构基本是modbus/下面按传输层分rtu/、ascii/、tcp/,功能码实现在functions/,对外头文件在include/,另外还有一个port/目录放需要你自己填的移植文件(portserial.c、porttimer.c、portevent.c)。这份源码只带从机状态机,主机部分不在里面,这一点必须先认清,否则会一直在源码里找eMBMasterInit找不到。

移植包FreeModbusMasterSlave(Rev1.0.0)才是真正要编进工程的东西。它在这个基础上补了主机状态机,并且做到了两点:主机接口和从机接口的调用风格一致,主机从机能编译进同一个工程。这一点对后期维护很关键,你不需要为两套协议写两套回调注册逻辑。

调试工具是 Modbus Poll 7.0 和 Modbus Slave 7.0,一个当主站、一个当从站,配上虚拟串口或者 USB 转 485,可以在不接真实从机的情况下把链路先跑通。文档部分包括主机使用说明、主从流程图(.vsd)和一份整理过的 Modbus 应用协议文档,流程图在排查状态机跑飞时比看代码快。

文件用途装的时候放在哪
freemodbus-v1.6.zip原始从机协议栈,作为对拍参考单独解压,不进工程
FreeModbusMasterSlave(Rev1.0.0).zip实际编译的主从工程解压后作为工程根目录
Modbus Poll 7.0 / Modbus Slave 7.0上位机对拖验证装在 PC 上,不参与编译
FreeModbus 主机使用说明.md主机 API 与调用顺序放工程 docs 目录随手查
主从流程图 .vsd状态机走向需要 Visio 或兼容工具打开

提示:原始 V1.6 源码不要直接覆盖到移植包里。两份代码的port/目录和include/头文件都有差异,覆盖之后编译能过、跑起来收不到应答,这类问题最难查。

2.2 工具链和工程骨架先定死版本

这份移植工程的目标芯片是 STM32H743,跑 FreeRTOS。工具链上我一般会固定成一套:STM32CubeIDE 或者 Keil MDK 二选一,不要中途换;HAL 库跟随 CubeMX 生成的版本,不要手工替换成别的分支;FreeRTOS 用 CubeMX 里自带的中间件版本,与FreeRTOSConfig.h配套。之所以强调「定死」,是因为 FreeModbus 的端口层会直接操作寄存器和中断,HAL 版本一变,HAL_UART_Receive_IT之类的行为和状态机就可能变,你会在一个和协议完全无关的地方卡半天。

时钟要提前确认。H743 主频跑到 480MHz 时,定时器时钟源和串口时钟分频都要跟着改,尤其是用 TIM 产生 RTU 的 3.5 字符定时,预分频算错会导致帧边界判断整体偏移,表现为「短帧能通、长帧丢包」。这一步在 CubeMX 的 Clock Configuration 页面确认一遍,把系统时钟、APB 分频记下来,后面写porttimer.c要直接用。

编译选项上有两个容易忽略的点。一是-Wall要开,FreeModbus 里有些UCHAR/USHORT的隐式转换,开了警告能提前发现截断;二是浮点 ABI 和printf浮点支持对这份协议栈没用,不要为了打印调试打开-u _printf_float,会白占几 K Flash。构建命令在 Makefile 工程下大概是这样:

# 干净重建,避免旧的目标文件掩盖端口层编译错误 make clean && make -j8 2>&1 | tee build.log # 只关心链接期和端口层相关的报错 grep -nE "undefined reference|multiple definition" build.log # 看 Flash / RAM 占用,判断协议栈加进来之后还剩多少余量 arm-none-eabi-size build/*.elf

第一条的clean不是仪式感。改过portserial.c里的宏或者头文件包含顺序之后,增量编译有可能漏编,症状是「明明改了却没生效」,很多所谓的玄学问题都是这里来的。第三条看的是.text和.bss,FreeModbus 主从同栈大概会占掉十几 K Flash 和几 K RAM,H743 不缺这点资源,但如果你的工程已经堆了不少中间件,还是确认一下余量。

2.3 目录落位:把 modbus 文件夹整体搬进工程

工程骨架建好之后,把移植包里的modbus/整个目录复制到工程的Middlewares/下,不要拆散放进Src/。原因是协议栈内部大量使用相对路径包含,比如#include "mbport.h",拆开之后你要么改一堆 include,要么在编译选项里加一堆-I。

添加头文件搜索路径时按这个顺序:modbus/include、modbus/rtu、modbus/functions、port。顺序有讲究,port放最后,保证协议栈自带的mbport.h先被找到,你自己的端口实现在同名文件上不会意外遮蔽。

源文件加入编译列表的时候,modbus/ascii/和modbus/tcp/可以先不加。RTU 场景用不到,加进来只会让链接体积变大;等真的要做 Modbus TCP 网关再补。functions/下按功能码拆分的文件建议全加,主机侧的读写保持寄存器、读写线圈、读写输入寄存器这些方法都依赖它们,少一个就是链接期undefined reference。

注意:portevent.c在 FreeRTOS 环境下有两种写法,一种用信号量,一种用事件组。移植包里给的是其中一种,如果你的工程里已经在别处用了同名的事件对象,要改名,否则会出现两个模块互相抢同一个队列的诡异现象。

3. 移植实操:在 STM32H743+FreeRTOS 上把 port 层写通

协议栈本体是平台无关的,真正决定能不能跑起来的是port/下那三个文件。这一章按「串口 → 定时器 → 任务 → 启动顺序」的顺序写,每一步都给可抄的代码,代码后面的说明别跳过,参数取舍基本都在说明里。

3.1 portserial.c:中断里只做搬运,不做判断

串口这一层的职责很窄:初始化外设、开关收发使能、在中断里把字节交给协议栈。判断帧头帧尾、算 CRC 都是协议栈的事,端口层多写一行逻辑,后面就多一个查不出来的 bug。

/* portserial.c —— STM32H743 + HAL,只保留关键路径 */ BOOL bMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { huart1.Instance = USART1; huart1.Init.BaudRate = ulBaudRate; /* 必须与 eMBInit 传入的波特率一致 */ huart1.Init.WordLength = UART_WORDLENGTH_8B; /* Modbus RTU 固定 8 数据位 */ huart1.Init.Parity = (eParity == MB_PAR_NONE) ? UART_PARITY_NONE : UART_PARITY_EVEN; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; if (HAL_UART_Init(&huart1) != HAL_OK) { return FALSE; } /* 接收用逐字节中断,帧边界交给 porttimer 判定 */ __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); return TRUE; } /* 由协议栈在收/发阶段调用,控制中断使能即可 */ void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); } else { __HAL_UART_DISABLE_IT(&huart1, UART_IT_RXNE); } if (xTxEnable) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_TXE); } else { __HAL_UART_DISABLE_IT(&huart1, UART_IT_TXE); } } /* 串口中断:收到字节就喂给协议栈,发送空中断就取下一个字节 */ void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)) { UCHAR ucByte = (UCHAR)(huart1.Instance->RDR & 0xFF); (void)xMBPortSerialGetByte((CHAR *)&ucByte); /* 内部会重启 3.5 字符定时 */ } if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TXE)) { CHAR cByte; if (xMBPortSerialPutByte(&cByte) == TRUE) { huart1.Instance->TDR = (uint8_t)cByte; } else { __HAL_UART_DISABLE_IT(&huart1, UART_IT_TXE); /* 帧发完,关发送中断 */ } } HAL_UART_IRQHandler(&huart1); /* 清标志,别删 */ }

bMBPortSerialInit里四个参数是从eMBInit里透传下来的,其中ucPort在单串口方案里通常被忽略,多串口方案里用它区分 USART1/USART2。校验位要和实际接线一致,现场见过从站配的是偶校验、主站按无校验发,结果每个字节都错位,现象是「通信灯闪但一直超时」。

vMBPortSerialEnable只做中断开关,不做电平翻转。如果你用的是 RS485 半双工,收发方向控制(DE/RE 引脚)不要放在这里翻,放在xMBPortSerialPutByte发完最后一个字节之后拉低,或者干脆用串口的硬件方向控制。放在vMBPortSerialEnable里会因为使能顺序问题导致第一帧被自己吃掉。

中断处理里那行HAL_UART_IRQHandler别删。有些教程说自己在上面已经清了标志就不用调,实际 H7 的串口错误标志(ORE、FE)必须走 HAL 的清除路径,否则一次溢出之后接收就彻底停摆,症状是「跑几分钟后突然不再响应,复位才好」。

3.2 porttimer.c:3.5 字符时间算错,长帧必挂

RTU 靠帧间静默判断边界,3.5 个字符时间没到就认为这一帧结束。这个时间随波特率变,所以协议栈的xMBPortTimersInit参数单位是 50us 的计数,具体毫秒数由协议栈按波特率算好传进来,端口层只管把它变成定时器的 ARR 和 PSC。

/* porttimer.c —— 定时器产生 3.5 字符超时,单位 50us */ BOOL xMBPortTimersInit(USHORT usTim1Timerout50us) { /* 计数频率设成 2MHz,即 1 个计数 = 0.5us,便于把 50us 换算成 100 个计数 */ uint32_t uwTimClock = HAL_RCC_GetPCLK1Freq(); /* H7 上 APB 预分频不为 1 时,定时器时钟是 PCLK 的 2 倍,这里要判一下 */ if ((RCC->D2CFGR & RCC_D2CFGR_D2PPRE1) != 0U) { uwTimClock *= 2U; } TIM3->PSC = (uwTimClock / 2000000U) - 1U; TIM3->ARR = (uint32_t)usTim1Timerout50us * 100U - 1U; /* 50us / 0.5us = 100 */ TIM3->DIER |= TIM_DIER_UIE; /* 只开更新中断 */ TIM3->CR1 |= TIM_CR1_CEN; HAL_NVIC_SetPriority(TIM3_IRQn, 6, 0); HAL_NVIC_EnableIRQ(TIM3_IRQn); return TRUE; } void TIM3_IRQHandler(void) { if (TIM3->SR & TIM_SR_UIF) { TIM3->SR = ~TIM_SR_UIF; /* 清标志 */ TIM3->CR1 &= ~TIM_CR1_CEN; /* 单次触发,停表 */ (void)xMBPortTimersExpired();/* 通知协议栈:帧结束 */ } }

关键在ARR的换算。协议栈传来的是「多少个 50us」,我把计数频率定在 2MHz,一个计数 0.5us,所以乘 100。如果你的工程里 PSC 算出来不是整数,取整之后累积误差会在高波特率下放大,115200 时 3.5 字符大约 334us,误差超过 50us 就可能把一帧拆成两帧,现象是「数据偶尔缺几个字节」。

APB 分频那个判断是血泪经验。H7 的定时器时钟在 APB 预分频不为 1 时会自动翻倍,CubeMX 生成的代码里不一定帮你处理,直接拿HAL_RCC_GetPCLK1Freq()去算 PSC,波特率低的时候看不出来,115200 以上就明显偏快或偏慢。

定时器还得提供两个辅助函数:vMBPortTimersEnable打开计时、vMBPortTimersDisable停表。前者在收到每个字节时被调用(重置静默计时),后者在帧处理完调用。这两个函数体就是置位/清零CR1的CEN位,别忘了进函数先把CNT清一下,否则上一帧残留的计数值会让本帧提前超时。

3.3 FreeRTOS 任务划分与中断优先级

协议栈本身不创建任务,它靠eMBPoll()或eMBMasterPoll()驱动状态机。在裸机里这是放主循环,在 FreeRTOS 里就得单独开一个任务,否则你的应用任务一阻塞,Modbus 就停摆。

/* 协议栈轮询任务:优先级不要给太高,它只是搬运状态机 */ static void vModbusTask(void *pvParameters) { (void)pvParameters; for (;;) { /* 从机轮询 + 主机轮询,谁使能了就轮谁,内部有时间戳判断 */ (void)eMBPoll(); (void)eMBMasterPoll(); /* 1ms 节拍足够,RTU 的最小时隙远大于此 */ vTaskDelay(pdMS_TO_TICKS(1)); } } /* 创建:栈 512 words ≈ 2KB,优先级 4,低于通信收包任务、高于界面刷屏任务 */ xTaskCreate(vModbusTask, "mb", 512, NULL, 4, &hMbTask);

栈给 512 words 是按「主从同栈 + 回调里做少量数据处理」估的。如果你在寄存器回调里调用了sprintf或者做了浮点运算,加到 768 甚至 1024,栈溢出的表现是随机的 HardFault,和 Modbus 一点关系都看不出来。

中断优先级是另一个翻车高发区。FreeRTOS 里凡是会调用xxxFromISR的中断,NVIC 优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY。假设这个宏配的是 5,那么串口和定时器中断的抢占优先级就设成 6 或更大。

/* FreeRTOSConfig.h 里通常长这样,数值越小优先级越高 */ #define configPRIO_BITS 4 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 /* 于是端口层里这么设,6 >= 5,安全 */ HAL_NVIC_SetPriority(USART1_IRQn, 6, 0); HAL_NVIC_SetPriority(TIM3_IRQn, 6, 0);

反过来的写法(把串口设成 3)编译不报错,运行时偶尔进 HardFault,或者xQueueSendFromISR直接把调度器状态搞乱。这个坑我在两块板子上各踩过一次,第二次是因为换了工程忘了改。

3.4 从机回调与主机初始化:启动顺序决定第一帧能不能出去

FreeModbus 从机要注册四个回调,主机侧对应读写保持寄存器、线圈这些方法也各有一组接口。回调里不要做耗时操作,把数据拷进拷出就行,业务逻辑丢给别的任务。

/* 从机保持寄存器回调:上位机读 40001 起始的寄存器时进这里 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { if ((usAddress < REG_HOLDING_START) || (usAddress + usNRegs > REG_HOLDING_START + REG_HOLDING_NREGS)) { return MB_ENOREG; /* 越界必须返回这个,主站才会收到异常码 02 */ } USHORT usRegIndex = usAddress - REG_HOLDING_START; if (eMode == MB_REG_READ) { while (usNRegs-- > 0) { *pucRegBuffer++ = (UCHAR)(g_usHoldingReg[usRegIndex] >> 8); *pucRegBuffer++ = (UCHAR)(g_usHoldingReg[usRegIndex] & 0xFF); } } else { while (usNRegs-- > 0) { g_usHoldingReg[usRegIndex] = (USHORT)(*pucRegBuffer++ << 8); g_usHoldingReg[usRegIndex] |= (USHORT)(*pucRegBuffer++); } } return MB_ENOERR; }

注意字节序。Modbus 寄存器是大端,pucRegBuffer里高字节在前,写成小端之后上位机读到的是 0x3412 这种值,看着像数据错乱,其实协议栈没错。

启动顺序这块,从机和主机各两步,先初始化再使能,中间不要插别的:

eMBErrorCode eStatus; /* 1. 从机:地址 0x01,串口 0,115200,无校验 */ eStatus = eMBInit(MB_RTU, 0x01, 0, 115200, MB_PAR_NONE); if (eStatus != MB_ENOERR) { Error_Handler(); } /* 2. 从机使能:内部会打开接收中断,这一刻起总线上问 0x01 就有应答 */ eStatus = eMBEnable(); if (eStatus != MB_ENOERR) { Error_Handler(); } /* 3. 主机:同一份协议栈里初始化主站状态机,串口按实际接线选 */ eStatus = eMBMasterInit(MB_RTU, 1, 115200, MB_PAR_NONE); if (eStatus != MB_ENOERR) { Error_Handler(); } /* 4. 主机使能 */ eStatus = eMBMasterEnable(); if (eStatus != MB_ENOERR) { Error_Handler(); }

eMBInit的第二个参数是从机地址,主机发来的请求里地址对不上就不应答,调试时先把广播地址 0 排除掉。第三个参数是端口号,对应你在bMBPortSerialInit里对ucPort的解释,得和硬件接线一致。

主机接口的命名和从机保持一致,这套 Rev1.0.0 里是eMBMasterInit/eMBMasterEnable/eMBMasterPoll这一组,和你手上源码的mb.h、mb_master.h对一遍再用,不同版本可能有细微差别。初始化之前务必先把vModbusTask建起来,顺序反了会导致前几帧请求没人轮询,表现为「上电后第一次读超时,第二次正常」。

提示:主从同时使能时,eMBPoll和eMBMasterPoll必须都调。只调一个的话另一侧状态机会一直挂在等待事件上,看起来像「主机能读从机,但从机被上位机读不到」。

4. 主从同栈配置:阻塞与非阻塞请求模式怎么选

主站那一侧最容易写错的是调用方式。这套协议栈给应用层留了两种请求模式:阻塞等结果、非阻塞之后查状态。选错了不会编译报错,但会让你的任务调度变得很难看。

4.1 阻塞还是非阻塞,看你在哪个上下文里发请求

阻塞模式的调用形如「发出去 → 等应答或超时 → 返回错误码」,适合在你自己的业务任务里一步步来,代码直观。非阻塞模式发出去就返回,应答到了之后通过回调或者状态查询取结果,适合在中断上下文或者高优先级任务里发起,不想被一个慢从站拖住。

/* 阻塞式:在采集任务里顺序读三个从站,超时 200ms */ eMBErrorCode eErr; USHORT usValue[4]; eErr = eMBMasterReqReadHoldingRegister(0x02, 0x0000, 4, 200); if (eErr == MB_ENOERR) { /* 结果已经通过主站回调填进缓冲区,这里直接取 */ usValue[0] = g_usMasterBuf[0]; } else if (eErr == MB_ETIMEDOUT) { g_uTimeoutCnt++; /* 从站掉线计数,用于上层告警 */ }

eMBMasterReqReadHoldingRegister的四个参数分别是从站地址、起始寄存器、寄存器个数、超时时间(单位取决于移植实现,多数按毫秒算)。超时不要给太小,RS485 总线上一帧往返加从站处理,115200 下 50ms 已经算紧,我一般按「从站最坏响应时间 × 2」设,200ms 起步。

非阻塞的写法是把请求发出去之后立刻返回MB_EPENDING之类的状态,然后在主站回调里处理结果。注意非阻塞模式下缓冲区不能复用,同一个从站连着发两个请求,第二个会覆盖第一个还没被取走的数据,表现为「偶尔读到上一轮的旧值」。

模式调用返回适用场景注意
阻塞成功或超时后才返回业务任务里顺序采集超时别设过小,会拖慢任务
非阻塞立即返回进行中高优先级任务、中断里发起缓冲区要独立,不能并发复用
重试上层自己循环从站偶发丢帧重试间隔要大于 3.5 字符时间

重试策略别塞进协议栈。常见做法是在业务层写个小循环,失败两次再报警,不要把重试次数做成协议栈参数,否则从站真掉线时你会看到总线被自己刷满。

4.2 主从跑在同一份协议栈里,资源怎么切

「支持主机与从机在同一协议栈运行」这句话,容易理解成同一根串口上又当主又当从。物理上同一时刻只能有一个方向,所以资源切分有两种落法。

第一种是两个串口:USART1 做从站,接上位机或者触摸屏;USART2 做主站,接下面的传感器。这份工程的端口层需要能区分端口号,bMBPortSerialInit里按ucPort分支初始化不同的 UART,中断里也要按外设分别取字节。这种方案最省心,主从互不干扰,调试时两边可以同时挂上位机看。

第二种是同一个 RS485 口分时复用:本机平时作为主站轮询下位机,上位机来问的时候切换成从站应答。这种方案要在应用层做总线仲裁,谁的请求先到谁用,切换间隙要留够 3.5 字符时间。移植包里给了这种用法的接口,但没有做仲裁逻辑,需要自己在业务层补。没把握的话别一上来就搞这个,先把两个串口的方案跑通,再合并。

不管哪种方案,eMBPoll和eMBMasterPoll都要在同一个任务里轮着调,别开两个任务各调一个。两个任务同时碰协议栈内部的状态机会互相踩,症状是随机丢帧、偶尔收到不属于自己的应答。

注意:主从共用一份portevent.c时,事件队列的长度要按「主站一个在途请求 + 从站一个待处理帧」来算,默认长度 1 的队列在主从同栈下会丢事件。

4.3 对拖调试:先用 Modbus Poll 7.0 和 Modbus Slave 7.0 把链路验证掉

板子还没接真实从机之前,先用 PC 上的工具把两个方向都验证一遍,能把硬件问题和协议问题分开。

验证板子当从站:USB 转 485 接到板子的从站串口,打开 Modbus Poll 7.0,连接参数设成 115200、8 数据位、无校验、1 停止位,Slave ID 填你在eMBInit里设的地址,功能码选 03,起始地址 0,数量 4,轮询间隔 1000ms。能看到寄存器值在刷新,说明从机链路通了。读不到的话先看 Poll 右下角的错误计数,是 Timeout 还是 Exception,前者查接线和波特率,后者查地址范围。

验证板子当主站:打开 Modbus Slave 7.0,建一个从站 ID 0x02,定义 4 个保持寄存器并填上固定值,串口参数和板子主站串口一致。板子上电后如果主站轮询正常,Slave 软件左下角会显示请求计数在涨。这一步能通,说明主站状态机和超时处理都没问题。

两个工具同时开的时候要占两个串口,用两根 USB 转 485 最省事。只有一个转换器的话就先验从机、再验主站,别想着来回切,切换过程中协议栈还在跑,日志会乱。

5. 排错记录:跑不通时先查这五处

Modbus 的排错有个好处,现象和原因基本能对上号。下面五条是我在这套工程上实际遇到过的,按「现象 → 原因 → 解决」记下来,遇上了直接对号入座。

5.1 通信层的三类异常

现象一:上位机一直超时,板子侧接收中断进不去。原因多半是 RS485 方向控制没切过来,或者 DE 引脚电平一直是接收态导致发送数据根本没上总线。用示波器量 A/B 线差分电平时能直接看出来:发送期间应该是明显的差分跳变,如果一直是静态电平,问题就在方向控制。解决方式是把 DE 控制挪到发送完成的回调里,或者直接换成带自动方向控制的收发器。另一种可能是串口引脚复用没配对,CubeMX 里 USART1 的 TX/RX 要设成复用推挽和上拉输入,配成普通 GPIO 就没有波形。

现象二:短帧能通,超过 8 个寄存器就丢包。这是 3.5 字符定时算错或者中断里处理太慢的典型表现。长帧的字节多,如果定时器在帧中间误触发一次,协议栈会认为帧结束,CRC 自然对不上。先确认xMBPortTimersInit里 APB 分频的翻倍判断有没有写,再看串口中断里有没有做耗时操作。我见过在接收中断里直接调printf的写法,115200 下必然丢帧。解决是把中断里的处理压到最少,只做取字节和重启定时器,其他事丢给任务。

现象三:偶尔收到异常码 02 或者 03。异常码 02 是非法数据地址,回去查回调里的地址范围判断,usAddress是协议地址(从 0 开始)还是寄存器编号(从 40001 开始),这两者差 1 和差 40001,很容易写混。异常码 03 是非法数据值,多半是请求的寄存器数量超了从站允许的上限,比如一次读 200 个寄存器,很多从站只支持 125 个。解决办法是在回调里把上限判断写死,越界直接返回MB_ENOREG,别让协议栈自己去猜。

5.2 移植与运行时的两类异常

现象四:跑几分钟到几十分钟后突然不再应答,复位后恢复。这类问题最难查,因为现象不稳定。常见原因有两个:一是串口溢出错误没清,H7 的 ORE 标志必须通过读 SR 再读 DR 的序列清除,绕开 HAL 自己写中断处理很容易漏;二是任务栈溢出,协议栈回调里做了一点字符串处理就爆了。排查方式是把configCHECK_FOR_STACK_OVERFLOW打开,挂上vApplicationStackOverflowHook打印任务名,同时把串口错误回调HAL_UART_ErrorCallback里加个计数器。我一般会在验证阶段挂两个全局计数变量,出问题时直接看:

/* 验证阶段挂上去,问题定位完再删 */ volatile uint32_t g_uart_err_cnt = 0; /* 溢出/帧错误次数,正常应长期为 0 */ volatile uint32_t g_mb_timeout_cnt = 0; /* 主站请求超时次数 */ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { g_uart_err_cnt++; /* 非 0 就说明物理层或波特率有问题 */ } }

现象五:主站读到了数据,但值和从站显示的对不上。先查字节序,Modbus 是大端,回调里拼寄存器时要高字节 << 8 | 低字节。再查数据类型,从站那边用两个寄存器拼一个 32 位浮点的时候,字序和字节序是两回事,有的设备高字在前有的低字在前。最后查一点:主站回调里拿到的是原始寄存器数组,不是已经转换好的工程量,别在回调里直接当温度值用。

6. 验证跑通:三步把主从链路验收掉

装完之后我不看编译有没有警告,也不看串口有没有打印,只走三步验收,三步全过才算这套环境装好了。

第一步,板子当从站,用 Modbus Poll 7.0 连续读 500 次。重点不是读通一次,是看错误计数。500 次里允许有 0 到 2 次超时(现场总线有干扰),超过就说明 3.5 字符定时或者中断优先级还有问题。同时打开调试器在eMBRegHoldingCB里下断点,确认进来的usAddress和上位机填的起始地址对得上,差一位就是地址基准搞错了。

第二步,板子当主站,用 Modbus Slave 7.0 模拟两个不同 ID 的从站,让主站轮流读。这一步看的是多从站轮询下的超时处理:把其中一个从站的 ID 改掉,主站应该在对它超时之后继续读另一个,而不是整个任务卡死。如果卡死,说明阻塞模式用在了不该用的地方,改成非阻塞或者给每个从站单独的超时。

第三步,主从同时开,让上位机读板子的同时板子读模拟从站,跑半小时。这一步最容易暴露主从同栈的资源冲突。验收表格按这个填:

检查项通过标准不通过先看哪
从站单次读写值与写入一致,无异常码回调地址范围、字节序
从站 500 次连续读错误计数 ≤ 2定时器换算、串口中断耗时
主站单从站轮询请求计数持续增长主机使能顺序、超时参数
主站多从站轮询一个掉线不影响其他阻塞/非阻塞选择
主从同时运行双向都无丢帧,运行 30 分钟稳定事件队列长度、端口资源切分

再补一个习惯:验证阶段把g_uart_err_cnt和g_mb_timeout_cnt打到串口日志里,每 10 秒一行。这两个数长期为 0,才说明物理层和协议层都干净;如果错误计数缓慢增长,别急着上线,先换根线或者把波特率降一档试试。

从那以后我每次把 Modbus 协议栈往新板子上搬,都强制自己先跑完这三步再动业务代码,尤其是「500 次连续读」那一步,它能把九成的定时器和中断配置问题提前暴露出来,比在业务层查数据错乱省太多时间。这套 FreeModbus 主从同栈的源码、移植工程、流程文档和两个调试工具都在同一份资源里,按项目名去找就能拿到,照着上面顺序走一遍,基本能一次装通。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询