FreeModbus 在 STM32 上跑从机模式,网上教程一抓一大把,可真要把这套栈改成主机来用,情况就完全两样了。这个库天生是给从机设计的,最典型的表现就是:底层串口中断把数据收上来之后,直接交给从机状态机去解析,从头到尾没有一个出口能把数据递给你自己的应用层。而主机场景恰恰需要自己接管从机返回的响应数据,这时候"数据接收函数传递"就成了绕不开的核心问题——说白了,你得想清楚怎么让底层收完的一帧数据,通过函数指针回调,安全地送到你上一层的业务逻辑里。
我是在一个基于 STM32F103 的串口网关小项目里踩完这条路的。设备要当 Modbus RTU 主机,周期性轮询下挂的温湿度变送器和三相电能表,把寄存器数据读回来后拼包上送给上位机。起初也想直接复用 FreeModbus 改造成主机,结果发现接收链路完全没有函数传递机制的预留,最后只能自己动手把整个接收流程捋了一遍。这篇文章就按我实际改造的顺序,把原理、函数传递设计、关键代码和踩坑记录一次讲透。
1. 项目背景:为什么 FreeModbus 主机模式必须自己改接收
1.1 库的设计重心天然偏向从机
FreeModbus 是嵌入式领域应用最广的开源 Modbus 协议栈之一,但它从诞生起就明确只实现了从机功能。官方文档里写得很直白:主机功能需要使用者自己扩展。社区里确实有人做过带主机功能的 fork 版本,但一直没有被主线采纳,所以绝大多数项目里你拿到的还是那个纯从机栈。
从机栈的代码结构也印证了这一点。核心状态机 eMBPoll() 被主循环周期调用,RTU 模式下真正干活的接收函数是 xMBRTUReceive()。它的流程是:串口收到字节,中断服务函数把字节放进缓冲区;帧超时定时器判定一帧结束;然后状态机把帧取出来,做地址匹配、功能码分发、CRC 校验,最后组织响应发回去。整个过程严丝合缝,但完全是按"从机应答主机"的模型设计的——数据进来,处理完,响应出去,中间没有任何环节把原始帧数据暴露给应用层。
这么做的后果就是:你想做主机,底层驱动可以用,但数据到手之后往哪放、用什么函数去处理、怎么和原来的从机逻辑共存,全得自己设计。这正好就是我们标题里说的"数据接收函数传递"问题的来源。
1.2 数据接收函数传递的三个核心矛盾
我梳理下来,主机模式下做数据接收函数传递,必须想清楚三个问题。
第一,帧缓冲区的归属权。数据存在底层驱动的缓冲区里,还是由应用层开一块专用缓冲区?这个决定直接影响回调函数签名。底层持有缓冲区,回调就得接收指针和长度;应用层持有缓冲区,底层就只负责往指定地址填数据。我实际采用的是前者,因为底层驱动对接收时序的了解最清楚,由它管理缓冲区可以减少数据被半路覆盖的风险。
第二,帧完成回调的触发时机。Modbus RTU 帧没有长度字段,帧边界全靠字节间隔来界定,所以帧超时定时器必须可靠。超时时间算短了,会把半个帧当成完整帧交付;算长了,可能把两帧相邻数据拼成一帧。这两种错误都会让上层解析直接崩掉。
第三,回调函数的执行上下文。帧超时发生在定时器中断里,那么业务解析到底在中断里做,还是只在中断里置标志位、放到主循环里做?这个问题新手最容易忽略,但在有 RTOS 或者高优先级中断的场景下,处理不当会导致数据竞争和响应延迟。我自己的原则是:中断里只收数据和置标志,解析和回调一律挪到主循环,后面会详细讲为什么。
2. 原理拆解:Modbus RTU 接收链路与帧超时判定
2.1 从串口中断到帧边界判定
先回到最底层。STM32 串口收到数据会触发 RXNE 中断,你在中断服务函数里读走数据,这是标准操作。FreeModbus 的 portserial.c 也是这么干的,但它额外多做了一件事:每收到一个字节,就重置一次帧超时定时器。
为什么必须重置定时器?因为 Modbus RTU 的帧边界不是靠帧头帧尾标志位,而是靠静默时间划分的。规约规定,一帧内部两个字节之间的间隔不能超过 1.5 个字符时间,帧与帧之间的间隔必须大于 3.5 个字符时间。所以接收端只要检测到总线上空闲超过 3.5 个字符时间,就认为当前帧收完了,可以进入处理流程。
实现上就是经典的"收到字节清零重计"套路:每次中断收字节,把定时器计数器归零重新开始;定时器一旦溢出,说明总线已经安静了足够久,这一帧到此结束。
2.2 3.5 字符时间计算与定时器参数
这里是最容易算错的地方,尤其是超过 19200 波特率之后,规约有一套特殊规定。
先看低速情况。以 9600 波特率、8 数据位、无校验、1 停止位为例,一个字符是 1 起始位 + 8 数据位 + 1 停止位,共 10 位。1 位的传输时间是 1/9600 秒,约 104.2 微秒,一个字符就是约 1.042 毫秒。3.5 个字符约等于 3.65 毫秒,工程项目里一般取整到 4 毫秒。
波特率提高后情况就变了。115200 波特率下,一个字符约 86.8 微秒,3.5 个字符约 304 微秒。这个时间短到定时器精度很难覆盖,稍微有点中断抖动就会误判。所以 Modbus 规约在超过 19200 波特率时给出了固定推荐值:帧间隔 t3.5 取 1750 微秒,字符间隔 t1.5 取 750 微秒,不再按波特率折算。这一点很多移植教程都没讲清楚,照着 304 微秒去配定时器,大概率会把好端端的帧切成两半。
我整理的工程推荐值表格如下:
| 波特率 | 字符时间 | 理论 3.5 字符时间 | 工程推荐超时 |
|---|---|---|---|
| 9600 | 约 1.04 ms | 约 3.65 ms | 4~5 ms |
| 19200 | 约 0.52 ms | 约 1.82 ms | 2~3 ms |
| 57600 | 约 0.17 ms | 约 0.61 ms | 按规约取 1.75 ms |
| 115200 | 约 0.087 ms | 约 0.30 ms | 按规约取 1.75 ms |
我项目里用的是 9600 波特率,TIM2 配成 100 微秒一个 tick,预装载值 40,溢出时间正好 4 毫秒,实测非常稳。如果你用 115200,建议直接按规约的 1.75 毫秒配置,宁可让中断晚一点触发,也不能把帧切错。
2.3 FreeModbus 原有接收流程为什么不可直接复用
FreeModbus 原有的接收流程和上面说的基本一致,区别在于帧超时事件直接喂给了从机状态机。具体来说,eMBRTUPoll() 周期性被调用,它检查一个内部标志位,这个标志在定时器中断里被置位,表示帧接收完毕;一旦置位,状态机就调 xMBRTUReceive() 把帧取出,进入从机处理逻辑。
这个流程对从机没毛病,但主机模式下有三个绕不开的痛点。
第一,数据流是单向的。从机处理完请求,帧数据就丢弃了,应用层完全看不到原始帧内容。第二,没有响应超时概念。主机发完请求后从机没回应,你得自己有一套超时重发机制,原库根本没有这东西。第三,中断里做了太多事。部分移植版本在定时器中断里不只是置标志,还会参与状态切换,这给后续的"函数传递出口改造"增加了复杂度。
所以我的最终思路是:保留串口和定时器驱动的底层框架,但把"帧接收完毕"这个事件的出口改掉,从"直接进从机状态机"改成"通过函数指针回调到主机模块"。这个改动量不大,但需要先把调用关系彻底理清。
3. 方案设计:数据接收函数传递的回调机制
3.1 回调函数:数据接收的"快递员"
函数传递在 C 语言里最常见的落地方式就是回调函数。本质上是把一个函数地址登记到某个地方,底层在特定时间点通过这个地址去调用你的函数。数据接收场景下,典型的回调签名长这样:
typedef void (*ModbusMasterRxCb)(uint8_t *frame, uint16_t len);这个函数指针就是数据接收的"快递员":底层收完一帧,调用它,把你注册的处理函数叫醒,同时把帧缓冲区指针和长度交给你。
但这里有一个关键设计决策:回调到底在哪里执行。你有两个选择,一个是在定时器中断里直接调用回调,另一个是中断里只置标志位、回调放到主循环执行。我强烈建议选第二个。
原因很直接:定时器中断的执行环境很苛刻,中断里不能做耗时操作,不能调用可能阻塞的函数,不能长时间访问可能被其他中断修改的共享资源。而 Modbus 帧解析、CRC 校验、寄存器数据整理这些都是耗时操作,放在中断里会拉高系统响应延迟,严重的直接导致串口丢字节。我在项目里就遇到过:回调里做了较多数据处理,结果串口接收中断被延迟,115200 波特率下开始随机丢数据。改成主循环执行之后,问题彻底消失。
3.2 接收上下文结构体怎么设计
底层和应用层之间需要一个数据结构来沟通,我把这个结构体叫做接收上下文。一个经过实际验证的版本是这样:
typedef struct { uint8_t *pFrameBuf; /* 帧缓冲区指针 */ uint16_t bufSize; /* 缓冲区容量 */ uint16_t rxIndex; /* 当前接收字节计数 */ volatile uint8_t frameReceived; /* 帧接收完成标志 */ ModbusMasterRxCb rxCallback; /* 回调函数指针 */ } ModbusMasterRx_t; static ModbusMasterRx_t s_masterRx; static uint16_t s_frameLen;这里有几个细节必须说清楚。frameReceived 必须加 volatile,因为它会在中断里写入、在主循环里读取,不加 volatile,优化器可能把读取结果缓存到寄存器,导致主循环永远看不到新标志。rxCallback 是函数指针,在应用层初始化阶段通过注册接口传入。
注册接口非常简洁:
void ModbusMasterRx_RegisterCallback(ModbusMasterRxCb cb) { s_masterRx.rxCallback = cb; }应用层在 main 里调用:
ModbusMasterRx_RegisterCallback(HandleModbusResponse);这样底层收完一帧,就通过 s_masterRx.rxCallback 回调到应用层的 HandleModbusResponse。数据传递链路自下而上,路径清晰,而且函数的注册和调用完全解耦——底层只关心指针存不存在,不关心回调里具体做什么。
3.3 主从共存场景的处理思路
实际项目里,一台设备既当主机又当从机的情况非常普遍。我做的网关就是这样:对上,它是上位机的从机,随时响应读寄存器请求;对下,它是传感器的主机,周期性发指令读数据。
两种模式共用一条串口总线,接收链路就需要一个切换开关。我的做法是加一个模式标志:
static volatile uint8_t s_commMode = MODE_SLAVE; /* 默认从机模式 */ void Modbus_SetMode(uint8_t mode) { s_commMode = mode; }串口中断里的收字节逻辑完全不变,还是照常收、照常重置定时器。但帧超时事件触发后,先判断当前模式:从机模式走原来的 eMBRTUPoll() 流程,主机模式走我们自己的回调。
这里有一个容易踩的坑:FreeModbus 从机模式下的收发逻辑和主机模式是冲突的,不能在同一串口上让两个状态机同时跑。所以实际调度上,主机轮询到从机响应的这段时间,要把从机功能暂时停掉,循环里只执行主机逻辑;等主机这一轮操作完成,再切回从机模式。切换要够快,否则上位机那边会等超时。
4. 实操步骤:从串口初始化到回调解析完整代码
4.1 串口和定时器初始化配置
串口驱动直接用标准外设库,配置重点是开启 RXNE 中断,并且给中断一个合适的优先级。下面是我项目里的初始化函数:
void BSP_USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_9; /* TX */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; /* RX */ gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = baudrate; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); }定时器用 TIM2 做帧超时检测,配置成 100 微秒一个 tick:
void BSP_TIM2_Init(void) { TIM_TimeBaseInitTypeDef tim; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); tim.TIM_Prescaler = 7200 - 1; /* 72MHz / 7200 = 10kHz */ tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_Period = 40 - 1; /* 100us * 40 = 4ms */ tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &tim); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, DISABLE); nvic.NVIC_IRQChannel = TIM2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 2; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); }预分频 7200、周期 40,是针对 72MHz 主频算出来的:定时器计数频率 10kHz,每 tick 100 微秒,溢出时间正好 4 毫秒,符合 9600 波特率下 3.5 字符时间的工程推荐值。注意 TIM2 初始状态是关闭的,等第一个字节进来再启动,这样才能做到"字节到达即开始计时"。
4.2 中断服务函数改造:收字节与置标志
串口中断里只做两件事:收字节、重置定时器计时。
void USART1_IRQHandler(void) { uint8_t byte; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { byte = USART_ReceiveData(USART1); if (s_masterRx.rxIndex < s_masterRx.bufSize) { s_masterRx.pFrameBuf[s_masterRx.rxIndex++] = byte; } /* 重置帧超时定时器 */ TIM_Cmd(TIM2, DISABLE); TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); } }接收数据寄存器读完后 RXNE 标志会自动清除,不需要额外软件清标志。缓冲区满之后直接丢弃新字节,避免数据覆盖,这是每个接收驱动都必须提前做好的防线。
定时器中断的职责更纯粹:判定帧结束,保存帧长度,置完成标志。
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); TIM_Cmd(TIM2, DISABLE); if (s_masterRx.rxIndex > 0) { s_frameLen = s_masterRx.rxIndex; s_masterRx.rxIndex = 0; s_masterRx.frameReceived = 1; } } }这里有个细节我当初吃过亏:一开始我在中断里直接置位 frameReceived 后就去清 rxIndex,结果主循环读数据发现长度已经变成 0。所以必须先把帧长度存到 s_frameLen 里,再清接收计数。s_frameLen 是静态全局变量,主循环里读取时配合 frameReceived 标志一起使用,不用担心读到中间态。
主循环里的轮询函数这样写:
void ModbusMaster_Poll(void) { if (s_masterRx.frameReceived) { s_masterRx.frameReceived = 0; if (s_masterRx.rxCallback) { s_masterRx.rxCallback(s_masterRx.pFrameBuf, s_frameLen); } } }这个函数放在主循环或者 RTOS 任务里周期性调用,间隔建议在 1 到 10 毫秒之间,保证中断置完标志后能及时被处理。
4.3 响应解析与 CRC 校验的完整逻辑
回调函数拿到帧指针和长度之后,要做三件事:CRC 校验、地址检查、功能码分发。以最常见的读保持寄存器(功能码 0x03)响应为例:
static void HandleModbusResponse(uint8_t *frame, uint16_t len) { uint16_t crcCalc, crcRecv; uint8_t slaveAddr, funcCode; uint16_t regCount, i; /* 最短响应帧:地址(1) + 功能码(1) + 字节数(1) + CRC(2) = 5 */ if (len < 5) { return; } crcCalc = CRC16_Modbus(frame, len - 2); crcRecv = (uint16_t)frame[len - 2] | ((uint16_t)frame[len - 1] << 8); if (crcCalc != crcRecv) { return; } slaveAddr = frame[0]; funcCode = frame[1]; if (slaveAddr != SENSOR_1_ADDR || funcCode != 0x03) { return; } regCount = frame[2] / 2; /* 字节数换算成寄存器个数 */ for (i = 0; i < regCount; i++) { sensorData[i] = (frame[3 + i * 2] << 8) | frame[4 + i * 2]; } dataReady = 1; /* 通知主业务逻辑数据已更新 */ }CRC 计算函数用的是 Modbus 标准的 CRC-16/IBM 变体:
uint16_t CRC16_Modbus(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; uint16_t i; uint8_t j; for (i = 0; i < len; i++) { crc ^= data[i]; for (j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }关于 CRC 顺序这里必须多提醒一句:Modbus RTU 帧里的 CRC 是低字节在前、高字节在后,跟很多人习惯的"大端"正好相反。校验的时候对前 len-2 个字节算 CRC,再把最后两个字节按"低字节 + 高字节左移 8 位"拼出来比对。顺序反了通常表现为"每次收到的帧 CRC 都不对",而且从数据内容上看不出任何异常,这是排查时最容易让人抓狂的问题之一。
4.4 主机轮询与响应超时管理
接收回调跑通之后,还需要一套完整的轮询调度,否则主机也只是一个"会收数据但没有灵魂"的壳子。我的轮询周期是 500 毫秒,每轮读一次温湿度,再读一次电能表,核心是一个带超时处理的状态转移:
void ModbusMaster_Task(void) { static uint32_t lastTick; uint32_t now = HAL_GetTick(); if (now - lastTick >= 500) { lastTick = now; ModbusMaster_SendReadRequest(SENSOR_1_ADDR, 0x0000, 2); s_requestPending = 1; s_requestTick = now; } if (s_requestPending && (now - s_requestTick > RESPONSE_TIMEOUT_MS)) { /* 响应超时,准备重发或放弃本轮 */ s_requestPending = 0; ModbusMaster_SendReadRequest(SENSOR_1_ADDR, 0x0000, 2); s_requestPending = 1; s_requestTick = now; } }超时时间的选择要结合实际从机设备的响应特性。有些老式仪表响应慢,能拖到几百毫秒,如果超时设太短,会出现"从机明明回了,但主机已放弃等待"的尴尬局面。我一般从 200 毫秒起步,实测之后再微调。更重要的是,轮询周期必须大于最慢从机的响应时间加上帧传输时间,否则前一个请求的响应还没回来,下一个请求已经发出去了,总线直接乱套。
5. 避坑实录:常见问题排查与调试技巧
5.1 帧被切碎:定时器超时值设错了
第一个典型问题是 9600 波特率下偶尔收不到完整帧,收到的数据里还夹杂着乱码。排查下来发现是定时器周期设得太短。我一开始照着"3.5 个字符 = 3.65 毫秒"的原始理论值配,想着从机响应快,把超时设成了 2 毫秒,结果只要从机发送过程中有一个字节间隙稍微拉长,就被误判成新帧开始,后半段数据全丢。
解决方式很简单:超时放宽到 4 到 5 毫秒。Modbus 规约里 3.5 字符时间是"最小帧间隔下限",实际设备发送时的字节间隙大多比这个宽松,你设得稍大一点不会影响通信,反而更稳。但也不能无限放大,否则两个连续的请求响应会被拼成一帧,解析照样出错。
5.2 回调执行位置:中断里到底能不能做事
有朋友问过:我直接在定时器中断里调回调,好像也跑得挺正常,为什么非要多此一举挪到主循环?
能跑和跑得好是两回事。中断里调回调,一旦回调里有耗时操作,整个系统的中断响应延迟就会飙升。我用逻辑分析仪实测过,回调里做了一百多个寄存器的浮点运算后,USART 中断响应延迟从正常的几微秒拉到了几十微秒,在 115200 波特率下,这个延迟足以让接收 FIFO 溢出。如果非要中断里做,至少把回调写得极短,并且中断优先级设计成可嵌套抢占。但最稳妥的方案,还是那句老话:中断只置标志,主循环干重活。
5.3 回调函数里的阻塞逻辑是隐形炸弹
还有一次踩坑是在回调函数里直接调用了操作系统延时。看起来逻辑很顺:收到响应、延时处理、再发下一个请求。结果回调一执行,整个系统就像卡住了一样。原因很直接:回调运行在主循环上下文,延时把主循环堵死了,其他任务全部饿死。更糟的是,如果延时期间新的串口数据正好进来,缓冲区会被下一帧覆盖,你手里的数据就废了。
正确做法是回调里只做数据拷贝和状态记录,把耗时数据处理踢到专门的状态机里。拷贝用 memcpy 就够了,然后把一个"数据已更新"的全局标志置 1,主业务逻辑看到标志再处理。这样回调执行时间能控制在微秒级,系统整体调度完全不受影响。
5.4 数据竞争:收数据和读数据的拉扯
回调函数在主循环被调用,但串口中断随时可能往缓冲区写新数据。如果你在处理当前帧的同时,新一帧已经开始写入,就会读到被覆盖的残缺数据。
我采用的解决方案是"关中断拷贝":回调里先关闭串口接收中断,用 memcpy 把数据搬进临时变量,再恢复中断。实测没有任何问题,而且关中断的时间只有拷贝那几个微秒,不会造成丢帧。如果接收频率很高,还可以升级成双缓冲机制——一个缓冲区在收,另一个在处理,处理完了交换角色,彻底消除竞争窗口。
5.5 主从切换时状态机打架
最后说一个一主一从切换的大坑。从机模式下的 eMBRTUPoll() 状态机一直在主循环里被周期调用,如果切换到主机模式时没有把它停掉,它仍然会尝试解析总线上的数据。而这时候总线上的数据其实是主机自己发出的请求或者从机的响应,解析结果自然乱七八糟,从机逻辑完全错乱。
我最终的代码结构是显式二选一:
void App_MainLoop(void) { if (s_commMode == MODE_SLAVE) { eMBPoll(); /* 从机栈轮询 */ } else { ModbusMaster_Poll(); /* 主机轮询 */ } }两种模式在调用层面彻底隔离,切换时不会出现状态机交叉执行的问题。这里还建议把切换函数也做成原子操作,关中断改标志再开中断,防止在切换瞬间被串口中断打断。
调试这套主机接收逻辑时,我最大的体会是:FreeModbus 主从切换这件事,难点不在代码量,而在对接收链路的理解。你把"帧超时"这个事件抓在自己手里,用回调函数把数据递出去,整个主机的数据通道就通了。函数传递本身不复杂,一个函数指针加一个注册接口,绕来绕去就这点东西,但背后的执行时机、上下文选择和缓冲区管理,才是真正决定稳定性的关键。
最后再分享一个实操小技巧:调试 Modbus 主机通信时,串口抓包工具比逻辑分析仪好用得多。我之前用逻辑分析仪看波形,虽然能确认字节发送时序,但帧内容解析太费劲。后来换成 USB 串口抓包,从机侧挂一个 USB-TTL 转换器,把总线数据实时打印出来,帧格式、字节间隔、CRC 对不对一眼就能看出来。调试效率直接翻倍。如果你正准备和 FreeModbus 主机模式死磕,建议先把接收回调跑通,再考虑轮询与超时管理,一步一步来,这个坑并没有想象中那么深。