简介:面向嵌入式开发者的FreeModbus进阶资料,围绕STM32H743与FreeRTOS环境下的主站、从站移植展开,重点回应官方主机源码收费、开源主机协议栈稀缺的问题。内容从协议基础、FreeModbus V1.6源码结构、主从站例程说明延伸到调试工具使用、主从流程图与协议文档整理,突出主从同栈运行、接口风格统一、阻塞与非阻塞请求、自定义超时等设计,并保留较详尽注释,能帮助读者快速定位接口与调用关系,适合已具备Modbus基础、希望把主从通信落地到工业采集与设备控制项目中的工程师参考。资源包共1个PDF文件,大小约120KB,便于通读和按主题检索。已有506人学习,可用于梳理协议栈分层、状态机切换、请求调度与异常码处理,对照调试主从通信问题,并在协议原理与工程实现之间建立对应,减少移植和联调时的试错成本。
1. 从机开源、主机收费:FreeModbus 主从双栈在 STM32H743 上的落地价值
手上有一块 H743 的板子,既要当 Modbus 主站去轮询几台设备,又要当从站被上位机读数据,翻遍资料会发现一个尴尬现状:FreeModbus 这套协议栈只开源了从机,主机那半边是商业授权。这份资源包补的正是这个断点——在 STM32H743 + FreeRTOS 上,把 FreeModbus 的主站和从站放进同一套协议栈里,共用 port 层和事件机制,接口风格保持一致,常用的 03/04/06/16 功能码都覆盖。它适合已经能用串口收发、但没真正啃过 Modbus 状态机的嵌入式工程师,也适合要快速搭一个主从同体网关原型的场景。下面从目录结构、移植步骤、主从调用参数一路讲到几个血泪坑。
2. 拆开 FreeModbus V1.6 的目录:三层结构与 port 层三个必改文件
拿到freemodbus-v1.6.zip先别急着往工程里塞,这套代码的分层很清晰,搞清楚哪层能改、哪层不该动,后面移植能省掉一半时间。它的组织方式是按「协议功能 → 传输方式 → 硬件抽象」切开的,跟很多国产协议栈把东西全堆在一个.c里的写法完全不同。
2.1 从机开源部分的四层职责划分
FreeModbus V1.6 的modbus/目录大致是这么分的:
| 目录/文件 | 职责 | 移植时是否改动 |
|---|---|---|
modbus/functions/ | 功能码处理,03/04/06/16 的 PDU 编解码 | 不改 |
modbus/rtu/ | RTU 帧状态机,T3.5 分帧、CRC 校验 | 不改 |
modbus/ascii/ | ASCII 传输,一般关掉 | 不改 |
modbus/include/mbconfig.h | 编译裁剪开关 | 必改 |
modbus/port/portserial.c | 串口收发、RS485 方向 | 必改 |
modbus/port/porttimer.c | T3.5 定时器 | 必改 |
modbus/port/portevent.c | 事件队列(收帧完成/发送完成) | 视 RTOS 情况改 |
协议功能层和 RTU 状态机这两层是「不该动」的,一旦你在这里加打印、改超时判断,后面主从同栈运行时会出现很难查的时序抖动。真正要动的是mbconfig.h里的裁剪宏和port/下的三个文件。
mbconfig.h里几个开关值得先说清楚:MB_RTU_ENABLED打开、MB_ASCII_ENABLED关掉(ASCII 帧效率低,现场几乎不用);MB_FUNC_HOLDING_REGISTER_ENABLED、MB_FUNC_INPUT_REGISTER_ENABLED按实际用到的功能码开,全开会吃掉不少 Flash;MB_PDU_SIZE_MAX决定单帧最大长度,H743 的 RAM 够用,保持默认 253 就行。还有个容易忽略的MB_TIMER_TICK_PER_SECOND,它决定 T3.5 的计数基准,必须和你porttimer.c里定时器的实际频率对齐,对不上就是后面「偶发丢帧」的根源。
2.2 主机源码为什么难找,这份实现补了哪几块
FreeModbus 官方版本的主机侧是收费的,社区里能见到的替代方案大概三类:一类是直接裸写帧,自己拼[addr][func][data][crc],简单场景够用,但一旦要做多从站轮询、超时重传、异常码处理,代码就会失控;一类是移植桌面端的 libmodbus,它依赖 POSIX 的串口 API,在裸机或 RTOS 上要重写一大坨;还有一类是各家芯片厂 SDK 里自带的精简版,功能码支持往往不全。
这份FreeModbusMasterSlave(Rev1.0.0).zip走的是第四条路:不另起炉灶,而是在原有从机代码的骨架上,把主站状态机并列加进去。几个关键设计点:
第一,接口前缀统一。从机是eMBInit/eMBEnable/eMBPoll,主机侧就沿用同样的三段式命名习惯,只是加了Master标识。这意味着应用层的心智负担很低,写过从机的人看主机代码几乎不用重新学。
第二,主从可以在同一套协议栈里同时运行。从机状态机和主机状态机是两套独立的状态机,共用 port 层的串口和定时器资源,通过事件队列区分「这帧是收进来的」还是「这帧是我发出去等应答的」。
第三,请求模式分了阻塞和非阻塞两种。阻塞模式调用后就等结果,适合初始化阶段读一次配置;非阻塞模式投递完请求立刻返回,任务继续干别的,结果通过回调或状态查询拿,适合在主循环里轮询多个从站。
第四,超时时间由应用层自己传。这个设计很关键,Modbus 现场经常遇到某台设备响应慢,一刀切的固定超时要么误判、要么拖慢整轮轮询。
提示:主机侧的函数名和参数顺序,不同分支的实现会有出入,抄代码前先翻一眼头文件里的声明,别照着博客硬套。
2.3 工程结构图怎么读:三个必须动的目录
资源包里那张「工程文件结构图」建议先看五分钟再动手。它标出来的是移植切入点,大致分三块:
第一块是modbus/port/。这是协议栈和 MCU 之间的唯一接口,串口、定时器、事件队列全在这里。你把这三个文件改成 H743 的 HAL 版本,协议栈主体一行都不用碰。
第二块是应用层的四个寄存器回调。从机有保持寄存器、输入寄存器、线圈、离散量四类,每类一个回调函数,你在这里把协议栈的读写请求映射到自己的变量或数据结构上。
第三块是 FreeRTOS 的任务入口。FreeModbus 本身不是 RTOS 感知的,它只要求eMBPoll()被周期性调用,所以你要么开一个独立任务跑轮询,要么挂在某个现有任务里。常见做法是单开一个任务,优先级给到中等偏上,栈给 512 字(Word,不是 Byte)就够。
/* 从机轮询任务:任务的唯一职责就是把 eMBPoll 跑起来 */ #define MODBUS_TASK_STACK 512 #define MODBUS_TASK_PRIO ( tskIDLE_PRIORITY + 3 ) static void vModbusSlaveTask( void *pvParameters ) { ( void )pvParameters; /* 1. 初始化协议栈:RTU 模式、从机地址 1、使用 USART2、9600 8E1 */ if( eMBInit( MB_RTU, 0x01, 2, 9600, MB_PAR_EVEN ) != MB_ENOERR ) { /* 初始化失败一般是 port 层串口没配对,直接卡死比带病运行好 */ for( ;; ) { vTaskDelay( pdMS_TO_TICKS( 1000 ) ); } } /* 2. 使能协议栈,开始响应总线上的请求 */ eMBEnable(); for( ;; ) { /* 3. 轮询:内部会检查事件队列,收帧完成就解析,发送完成就切方向 */ ( void )eMBPoll(); /* 4. 这里的延时决定了最小响应延迟,别给太大 */ vTaskDelay( pdMS_TO_TICKS( 2 ) ); } }这段代码里有两个参数值得掰开说。eMBInit的第三个参数2是 port 编号,对应你portserial.c里xMBPortSerialInit的switch分支,写错就是干脆没反应。第五个参数MB_PAR_EVEN是校验方式,Modbus RTU 现场最常见的是 8E1(偶校验)和 8N2(无校验双停止位),这个参数必须和上位机、和从站设备三方对齐,任何一方不一致,表现就是「有时候能通、有时候 CRC 错」这种玄学现象。
vTaskDelay给 2ms 是经验值。给 0 会让这个任务霸占 CPU,给 10ms 以上则在高波特率下可能来不及处理连续帧。轮询周期的本质上决定了协议栈的响应延迟,不是越小越好,但绝对不能大于一个 T3.5 的时间量级。
3. 从机移植实操:USART2 + RS485 跑通第一个保持寄存器
移植这件事,配置对了一路顺,配置错一个引脚能耗一整天。这一章按「串口 → 定时器 → 回调 → 验证」的顺序走一遍,每一步都给可抄的代码和失败时的排查方向。
3.1 portserial.c:串口初始化与 RS485 方向控制
portserial.c要实现五个函数:xMBPortSerialInit、vMBPortSerialEnable、xMBPortSerialGetByte、xMBPortSerialPutByte,以及串口中断服务函数。H743 上跑 USART2 + 一个 DE 引脚控制 RS485 收发器,核心是下面这几段。
/* portserial.c —— USART2 + RS485 方向控制,基于 HAL 库 */ #include "mbport.h" #include "usart.h" /* RS485 收发器的方向引脚,实际以原理图为准 */ #define RS485_DE_HIGH() HAL_GPIO_WritePin( GPIOD, GPIO_PIN_4, GPIO_PIN_SET ) #define RS485_DE_LOW() HAL_GPIO_WritePin( GPIOD, GPIO_PIN_4, GPIO_PIN_RESET ) BOOL xMBPortSerialInit( UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity ) { /* 1. 端口号必须和 eMBInit 传进来的一致,否则这里直接拒绝 */ if( ucPort != 2 ) { return FALSE; } /* 2. 波特率重新配置,注意 HAL 结构体里要先反初始化再改 */ huart2.Init.BaudRate = ulBaudRate; /* 3. 校验位映射:带校验时字长必须设成 9bit(含校验位), 设成 8bit 会变成「8 数据位 + 校验位挤在一起」,收到的全是错的 */ switch( eParity ) { case MB_PAR_NONE: huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_2; /* 8N2,Modbus 推荐的另一种组合 */ break; case MB_PAR_EVEN: huart2.Init.Parity = UART_PARITY_EVEN; huart2.Init.WordLength = UART_WORDLENGTH_9B; huart2.Init.StopBits = UART_STOPBITS_1; break; case MB_PAR_ODD: huart2.Init.Parity = UART_PARITY_ODD; huart2.Init.WordLength = UART_WORDLENGTH_9B; huart2.Init.StopBits = UART_STOPBITS_1; break; default: return FALSE; } if( HAL_UART_Init( &huart2 ) != HAL_OK ) { return FALSE; } /* 4. 默认收方向:DE 拉低,接收使能 */ RS485_DE_LOW(); return TRUE; } /* 收发使能切换,被协议栈状态机调用 */ void vMBPortSerialEnable( BOOL xRxEnable, BOOL xTxEnable ) { if( xRxEnable ) { __HAL_UART_ENABLE_IT( &huart2, UART_IT_RXNE ); } else { __HAL_UART_DISABLE_IT( &huart2, UART_IT_RXNE ); } if( xTxEnable ) { RS485_DE_HIGH(); /* 先切方向,再开中断 */ __HAL_UART_ENABLE_IT( &huart2, UART_IT_TXE ); } else { __HAL_UART_DISABLE_IT( &huart2, UART_IT_TXE ); /* 关键:这里不能立刻拉低 DE,最后一个字节还在移位寄存器里 */ __HAL_UART_ENABLE_IT( &huart2, UART_IT_TC ); } } /* 字节级读写,协议栈按字节喂数据 */ BOOL xMBPortSerialGetByte( CHAR *pucByte ) { *pucByte = ( CHAR )( huart2.Instance->RDR & 0xFF ); return TRUE; } BOOL xMBPortSerialPutByte( CHAR ucByte ) { huart2.Instance->TDR = ( uint8_t )ucByte; return TRUE; }逻辑说明:xMBPortSerialInit只做一次配置,真正频繁调用的是vMBPortSerialEnable,协议栈在「准备接收」和「准备发送」时分别调它。xTxEnable为 FALSE 的那条分支是全篇最容易翻车的地方——很多移植代码在这里直接RS485_DE_LOW(),结果就是总线上最后一个字节被截断,从站收到的帧长少一位,回你一个 CRC 错误或者干脆不回。
参数说明:ucDataBits这个形参在 FreeModbus 里其实没被真正使用,是留给 ASCII 模式的,RTU 下忽略即可;eParity的映射关系必须和硬件字长对应上,STM32 的UART_WORDLENGTH_9B在偶校验下等效于 8 数据位 + 1 校验位,这个换算关系搞错的话,你会看到「能收到字节但全是乱码」。
串口中断里要把 TC(发送完成)中断单独处理:
/* USART2 中断服务函数 */ void USART2_IRQHandler( void ) { /* TC 置位说明移位寄存器彻底空了,这时候才能收方向 */ if( __HAL_UART_GET_FLAG( &huart2, UART_FLAG_TC ) && __HAL_UART_GET_IT_SOURCE( &huart2, UART_IT_TC ) ) { __HAL_UART_CLEAR_FLAG( &huart2, UART_FLAG_TC ); RS485_DE_LOW(); __HAL_UART_DISABLE_IT( &huart2, UART_IT_TC ); } /* RXNE 和错误标志交给 HAL 处理,它会回调到协议栈的接收状态机 */ HAL_UART_IRQHandler( &huart2 ); }3.2 porttimer.c:T3.5 定时器精度决定成败
Modbus RTU 靠时间间隔分帧:帧内字符间隔不能超过 1.5 个字符时间,帧与帧之间至少 3.5 个字符时间。FreeModbus 用一个定时器实现这个判断,porttimer.c要提供xMBPortTimersInit、vMBPortTimersEnable、vMBPortTimersDisable,以及定时器中断里的TIMERExpiredISR()。
字符时间跟波特率有关:1 个字符 = 起始位 + 8 数据位 + 校验位 + 停止位,8E1 下是 11 位。9600 波特率下 1 位约 104μs,一个字符约 1.15ms,3.5 字符约 4ms。115200 下算出来是 0.33ms,Modbus 规范规定超过 19200 波特率时统一按固定 1.75ms 处理。
/* porttimer.c —— TIM3 做 T3.5 定时,1us 计数精度 */ #include "mbport.h" #include "tim.h" BOOL xMBPortTimersInit( USHORT usTim1Timerout50us ) { /* 协议栈会按波特率算出超时值(单位 50us),这里换算成定时器计数值。 TIM3 挂在 240MHz 的 APB1 上,预分频到 1MHz,即 1us 一个计数 */ __HAL_TIM_SET_PRESCALER( &htim3, 240 - 1 ); __HAL_TIM_SET_AUTORELOAD( &htim3, ( uint32_t )usTim1Timerout50us * 50 - 1 ); __HAL_TIM_CLEAR_FLAG( &htim3, TIM_FLAG_UPDATE ); return TRUE; } void vMBPortTimersEnable( void ) { __HAL_TIM_SET_COUNTER( &htim3, 0 ); __HAL_TIM_ENABLE_IT( &htim3, TIM_IT_UPDATE ); HAL_TIM_Base_Start( &htim3 ); } void vMBPortTimersDisable( void ) { HAL_TIM_Base_Stop( &htim3 ); __HAL_TIM_DISABLE_IT( &htim3, TIM_IT_UPDATE ); __HAL_TIM_CLEAR_FLAG( &htim3, TIM_FLAG_UPDATE ); } /* 定时器溢出 = 总线空闲超过 T3.5 = 一帧结束 */ void TIM3_IRQHandler( void ) { if( __HAL_TIM_GET_FLAG( &htim3, TIM_FLAG_UPDATE ) && __HAL_TIM_GET_IT_SOURCE( &htim3, TIM_IT_UPDATE ) ) { __HAL_TIM_CLEAR_FLAG( &htim3, TIM_FLAG_UPDATE ); vMBPortTimersDisable(); TIMERExpiredISR(); /* 通知协议栈:这帧收完了 */ } }参数说明:usTim1Timerout50us是协议栈算好的超时值,单位是 50 微秒,9600 波特率下这个值大概是 80(即 4ms)。你不需要自己算,但要保证预分频后的计数频率和这个单位对得上,对不上就会出现「帧被切成两半」或者「两帧粘成一帧」。H743 主频高,预分频系数要按实际时钟树算,别照抄 F103 的代码。
3.3 寄存器回调与 Modbus Poll 验证
协议栈把 PDU 解析完,最终会调到你写的四个回调之一。保持寄存器是最常用的,先把它跑通。
/* 应用层:保持寄存器区,从机地址 1,10 个寄存器 */ #define REG_HOLDING_START 0 #define REG_HOLDING_NREGS 10 static USHORT usRegHoldingBuf[REG_HOLDING_NREGS]; eMBErrorCode eMBRegHoldingCB( UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode ) { USHORT i; /* 1. 地址越界检查,一定要做,否则指针直接飞出数组 */ if( ( usAddress < REG_HOLDING_START ) || ( ( usAddress + usNRegs ) > ( REG_HOLDING_START + REG_HOLDING_NREGS ) ) ) { return MB_ENOREG; /* 协议栈会自动回异常码 02 */ } if( eMode == MB_REG_READ ) { for( i = 0; i < usNRegs; i++ ) { USHORT usVal = usRegHoldingBuf[usAddress - REG_HOLDING_START + i]; *pucRegBuffer++ = ( UCHAR )( usVal >> 8 ); /* Modbus 是大端 */ *pucRegBuffer++ = ( UCHAR )( usVal & 0xFF ); } } else /* MB_REG_WRITE,对应功能码 06 和 16 */ { for( i = 0; i < usNRegs; i++ ) { USHORT usVal = ( USHORT )( *pucRegBuffer++ << 8 ); usVal |= ( USHORT )( *pucRegBuffer++ ); usRegHoldingBuf[usAddress - REG_HOLDING_START + i] = usVal; } } return MB_ENOERR; }逻辑说明:usAddress是 PDU 里的寄存器地址,是 0 基的。这一点必须记牢——上位机界面上写 40001,实际发到总线上的是 0x0000。eMode区分读写方向,写操作会在整帧 CRC 校验通过后才被调用一次,所以不用担心写一半的情况。
参数说明:MB_ENOREG这个返回值的意义是「寄存器不存在」,协议栈收到后会自动组一个异常响应(功能码 | 0x80,异常码 02)。如果你想返回「非法数据值」就用MB_EINVAL(异常码 03)。这两个异常码是上位机调试时最常看到的,看到 02 就查地址范围,看到 03 就查数据范围。
验证环节用资源包里的 Modbus Poll 7.0,连接参数按这张表填:
| 参数项 | 取值 | 说明 |
|---|---|---|
| Connection | Serial Port | 走真实串口或 USB 转 485 |
| Mode | RTU | 别选 ASCII |
| Port | COMx | 设备管理器里确认 |
| Baud Rate | 9600 | 与固件一致 |
| Data Bits / Parity / Stop | 8 / Even / 1 | 与eMBInit的MB_PAR_EVEN对齐 |
| Slave ID | 1 | 与eMBInit的第二个参数一致 |
| Function | 03 Read Holding Registers | 对应eMBRegHoldingCB |
| Address | 0 | 0 基地址,不是 40001 |
| Quantity | 10 | 不超过REG_HOLDING_NREGS |
| Scan Rate | 1000 ms | 调试阶段别给太快 |
| Timeout | 1000 ms | 首次调试可放大到 2000 |
连上之后如果显示Timeout,先量 RS485 的 A/B 线有没有接反,再看 DE 引脚有没有动作;显示CRC Error基本就是波特率或校验位和固件不一致;显示Illegal Address就是回调返回了MB_ENOREG。这三个现象与原因一一对应,按这个顺序查比乱改代码快得多。
4. 主机模式实战:阻塞与非阻塞请求的参数设定和任务配合
从机跑通之后,主机侧才是这份资源真正值钱的部分。主机模式的核心难点不在发帧,在于「什么时候发、发完等多久、等不到怎么办」这三件事的调度。
4.1 主站状态机与事件队列的协作方式
主站状态机跟从站是镜像关系:从站是「收帧 → 校验 → 解析 → 执行 → 应答」,主站是「组帧 → 发送 → 等应答 → 校验 → 交付」。两边共用portserial.c和porttimer.c,所以同一时刻总线上只能有一种角色在活动,这点在 4.4 节会细说。
请求投递进去之后,协议栈内部会挂起一个「待应答」的事务,然后打开接收。T3.5 定时器超时后如果还没收到完整帧,就判定超时,按配置决定重传还是直接返回错误。这里有个设计细节值得注意:主站的 T3.5 定时器语义和从站不完全一样,从站用它判帧尾,主站用它判应答超时,所以超时时间要按从站的最慢响应时间来设,不是按字符时间算。
4.2 阻塞模式:超时参数怎么给才不拖垮任务
阻塞模式适合初始化阶段读配置、或者单从站的低频轮询。调用后会一直等到结果返回,中间会让出 CPU(内部基于事件队列等待,不是死等)。
/* 阻塞模式:读 1 号从站的 0x0000 开始的 8 个保持寄存器,超时 200ms */ eMBMasterReqErrCode eErr; USHORT usRegBuf[8]; eErr = eMBMasterReqReadHoldingRegister( 0x01, 0x0000, 8, 200 ); switch( eErr ) { case MB_MRE_NO_ERR: /* 数据已经通过主站接收回调交给应用层,这里只处理成功分支 */ break; case MB_MRE_TIMEDOUT: /* 超时:从站没响应,可能是掉线、地址错、波特率不匹配 */ break; case MB_MRE_EXE_FUN: /* 从站返回了异常码,说明请求合法但被拒绝,查异常码具体值 */ break; case MB_MRE_MASTER_BUSY: /* 上一个请求还没结束,说明调用方没做串行化,这是逻辑错误 */ break; default: break; }参数说明:第一个参数是从站地址,范围 1~247,0 是广播地址(广播只能写,不能读);第二个是寄存器起始地址,0 基;第三个是寄存器数量,功能码 03 单次最多 125 个;第四个是超时毫秒数,这个值要卡在两个约束之间——不能小于从站的最慢响应时间(否则误判超时),也不能大于你的任务周期(否则整个轮询任务被拖住)。
我一般会按「从站手册标称响应时间 × 3 + 50ms」来给。比如某台设备标称响应小于 30ms,就给 150ms 左右。给 10ms 这种极限值在现场几乎必翻车,因为 RS485 收发器切换方向本身就要几十微秒,加上从站固件的处理延迟,余量必须留够。
4.3 非阻塞模式:投递即返回,结果靠回调接
非阻塞模式是主站真正好用的地方。投递完请求立刻返回,主站任务可以继续处理别的逻辑,等协议栈收到应答后通过回调通知应用层。
/* 非阻塞投递:轮询 3 台从站时,一台一台排队发,不阻塞主循环 */ static UCHAR ucPollIndex = 0; static const UCHAR ucSlaveList[3] = { 0x01, 0x02, 0x03 }; void vPollNextSlave( void ) { eMBMasterReqErrCode eErr; eErr = eMBMasterReqReadHoldingRegister( ucSlaveList[ucPollIndex], 0x0000, 4, 100 ); if( eErr == MB_MRE_NO_ERR ) { /* 投递成功,索引往后挪,结果在回调里处理 */ ucPollIndex = ( UCHAR )( ( ucPollIndex + 1 ) % 3 ); } else if( eErr == MB_MRE_MASTER_BUSY ) { /* 上一次还没回来,这一轮跳过,等下一轮再试,不要在这里死循环 */ } } /* 主站接收回调:协议栈解析完应答帧后调进来 */ eMBErrorCode eMBMasterRegHoldingCB( UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, USHORT usSlaveAddr ) { USHORT i; /* 按从站地址分发到不同的数据区,避免三台设备的数据串在一起 */ for( i = 0; i < usNRegs; i++ ) { USHORT usVal = ( USHORT )( *pucRegBuffer++ << 8 ); usVal |= ( USHORT )( *pucRegBuffer++ ); g_usSlaveData[usSlaveAddr][usAddress + i] = usVal; } return MB_ENOERR; }逻辑说明:MB_MRE_MASTER_BUSY这个返回值是非阻塞模式的核心,它告诉你「协议栈还在等上一帧的应答」。轮询逻辑必须处理它,最忌讳的写法是while(eErr == MB_MRE_MASTER_BUSY);——这等于把非阻塞硬生生用成了阻塞,还带了个死循环风险。
参数说明:回调里的usSlaveAddr是判定数据归属的唯一依据,多从站场景下必须按它分发,否则三台设备的数据会互相覆盖,这种 bug 在调试时表现为「数据偶尔跳到别的值」,极难复现。
4.4 主从同栈运行时的资源冲突与调度
主从同时跑是这份资源的一个卖点,但也带来了三个必须处理的冲突点。
第一是串口占用。同一时刻总线上只能有一个发送方,所以主站发请求期间,从机必须处于「不应答」状态。实现上通常用一个互斥量或者状态标志控制:主站发起请求前置位,收到应答或超时后清位,从机的eMBPoll检查这个标志,置位期间跳过应答逻辑。
第二是事件队列。从机和主站共用portevent.c里的事件队列,事件类型要能区分「收到一帧给从机」和「收到一帧给主站」。做法是在事件枚举里加一个角色标记,或者在主站请求期间把所有收帧事件都路由给主站状态机。
第三是定时器。T3.5 定时器只有一个,主站等应答和从站判帧尾都用它。由于两者不会同时活动(串口互斥保证了这点),定时器可以复用,但切换角色时要把计数器清零,否则会带着上一次的残留值进入新状态。
注意:主从同栈最容易出问题的是「主站超时的瞬间,从站正好收到一帧」。这时候事件队列里可能同时存在主站的超时事件和从站的收帧完成事件,处理顺序错了就会串状态。稳妥做法是把主站超时判定放在主站任务上下文里做,而不是在定时器中断里直接改状态机。
5. 避坑排查:五个让 Modbus 通信「玄学」失败的现场记录
Modbus 的坑有个共同特点:代码看着都对,就是不 통。下面这五条都是实际踩过的,按「现象 → 原因 → 解决」写,对着查能省不少时间。
5.1 物理层与方向控制的两个坑
坑一:短帧正常、长帧必挂
现象:读 2 个寄存器好好的,读 10 个就随机 CRC 错误或者超时。
原因:vMBPortSerialEnable里xTxEnable置 FALSE 时立刻拉低了 DE 引脚。数据写入 TDR 只代表「进了发送缓冲」,最后一个字节还在移位寄存器里往外吐,这时候切方向,最后 1~2 个字节直接被截断。帧越长,从站算出来的 CRC 越对不上。
解决:DE 拉低必须放到 TC(Transmission Complete)中断里做,不能放 TXE 中断,更不能放函数末尾。代码见 3.1 节的USART2_IRQHandler。
坑二:A/B 线接反了但「看起来能通」
现象:用示波器看波形正常,协议栈收到的数据全是 0xFF 或者乱码,偶尔又能收到一帧正确的。
原因:RS485 差分线 A/B 接反时,空闲电平被识别成起始位,接收器会持续检测到帧错误。有些收发器自带失效保护偏置,接反后表现为「大部分时间收不到、偶尔收到」,很容易被误判成软件问题。
解决:断开设备,用万用表量 A 对 B 的电压,空闲时应该是 A 高于 B(具体极性看收发器手册)。软件层面可以先写个最小测试:只发一个字节,用逻辑分析仪抓 MCU 的 TX 引脚和 485 芯片的 RO 引脚,对比波形是否一致,能快速定位是 MCU 侧还是总线侧的问题。
5.2 时序与调度层的三个坑
坑三:115200 波特率下丢帧率明显上升
现象:9600 下稳如老狗,改成 115200 就开始丢帧,而且丢得没规律。
原因:T3.5 定时器用了 SysTick 做基准。SysTick 是 1ms 中断,而 115200 下的 T3.5 是 1.75ms,用 1ms 精度去卡 1.75ms,量化误差直接把分帧判断搞崩了。
解决:换成独立硬件定时器,预分频到 1MHz,让 T3.5 的计数误差控制在微秒级。代码见 3.2 节,注意定时器时钟源要按 H743 的实际时钟树算,别照抄别的型号。
坑四:上位机读 40001 读出来的值是错的
现象:Modbus Poll 里地址填 40001,读回来的数据跟固件里的变量对不上,差了一位。
原因:Modbus 协议里 PDU 的地址是 0 基的,40001 是「数据模型编号」,换算成 PDU 地址是 0x0000。有些上位机软件会自动做这个减一,有些不做,取决于版本和设置。
解决:在eMBRegHoldingCB里加一句日志,把收到的usAddress打出来,对照上位机显示的地址,一眼就能看出差没差 1。确认之后,要么在回调里统一减 1,要么在上位机里改地址基准,别两边都改。
坑五:三台从站轮询,总有一台数据不更新
现象:主站轮询三台设备,前两台正常,第三台的数据一直是初始值,但单独测试第三台又是好的。
原因:非阻塞模式下没处理MB_MRE_MASTER_BUSY,前一台的请求还没回来就投递下一台,请求被丢弃,索引却往前走了。或者超时时间给得太短,慢的那台每次都被判超时,直接跳过。
解决:投递失败时索引不前进,让同一个从站下一轮重试;同时按每台设备的实际响应时间单独设超时,别用统一值。如果某台设备确实慢,可以在轮询表里给它单独标记一个更长的超时。
6. 用流程图和自测脚本反推状态机:把协议一致性握在自己手里
资源包里那两张.vsd流程图(主机流程图、从机流程图),很多人下载后就没打开过。这两张图真正的价值不是「看懂协议栈怎么写的」,而是让你能在出问题时快速定位到「卡在哪个状态」。
我的用法是这样的:调不通的时候,先在流程图里找到当前应该在的节点,然后对照代码看实际走到了哪。比如从机一直不回帧,流程图告诉你从「收帧完成」到「组应答」中间要经过 CRC 校验、地址匹配、功能码分发、回调执行四步,那就按这四步逐个打断点或者加日志,比漫无目的地猜快得多。
更进一步,可以自己写一个小脚本做协议一致性自测,不依赖 Modbus Poll 这种带界面的工具,方便放进 CI 或者自动化测试。下面这段用 Python + pyserial 手搓 RTU 帧,验证从机的响应是否符合预期:
# modbus_selftest.py —— 手工构造 RTU 帧,验证从机响应一致性 import struct import serial def crc16(data: bytes) -> bytes: """标准 Modbus CRC16,多项式 0xA001,低字节在前""" crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return struct.pack('<H', crc) def read_holding(port, slave, addr, count): """功能码 03:读保持寄存器,返回寄存器值列表或 None""" req = struct.pack('>BBHH', slave, 0x03, addr, count) req += crc16(req) port.write(req) # 应答长度 = 地址1 + 功能码1 + 字节数1 + 数据2N + CRC2 resp = port.read(5 + count * 2) if len(resp) != 5 + count * 2: return None if crc16(resp[:-2]) != resp[-2:]: return None if resp[1] & 0x80: return ('exception', resp[2]) return list(struct.unpack('>%dH' % count, resp[3:3 + count * 2])) if __name__ == '__main__': # 参数和固件里的 eMBInit 保持一致,否则第一帧就超时 ser = serial.Serial('COM5', baudrate=9600, bytesize=8, parity=serial.PARITY_EVEN, stopbits=1, timeout=0.5) result = read_holding(ser, slave=1, addr=0, count=10) if result is None: print('失败:超时或 CRC 错误') elif isinstance(result, tuple): print('从站返回异常码:', result[1]) else: print('读到的寄存器值:', result)这段脚本的关键在于它把「帧长、CRC 方向、异常码判定」三件事显式写出来了,比用现成工具更能看清协议细节。CRC 是小端序写入的,struct.pack('<H', crc)这一句写错成'>H',就会出现「从站明明回了正确的值,你这边一直报 CRC 错误」的情况。read的长度按5 + 2N算,异常响应只有 5 字节,所以要先判断功能码最高位再决定解析方式。
参数上要注意两个地方:parity必须和固件里eMBInit的第五个参数严格一致,PARITY_EVEN对应MB_PAR_EVEN;timeout给 0.5 秒是调试用的宽松值,实际测试可以压到从站标称响应时间的 2 倍左右。
我一般会把这段脚本改成参数化,一次跑完所有功能码,然后把从站换成不同厂家的设备跑一遍。凡是在别的设备上能通、在这台上不通的,基本都能归到「地址基准」「字节序」「异常码处理」这三类里。
从那以后,我每次接手新的 Modbus 项目,都强制先把这两张流程图贴在显示器边上,再用自测脚本跑一轮全功能码,确认基础通信没问题再往上叠业务逻辑——跳过这一步直接写业务,后面排查问题的时间会翻好几倍。资源包里源码、流程图、协议文档、调试工具都齐全,按简介里的位置取用即可,教程部分是付费的,只用源码和文档也够把主从两边跑通。希望帮到你。
本文还有配套的精品资源,点击获取