☰
STM32F407 HAL库移植FreeModbus从机协议栈实战指南
2026/10/16 12:54:16 网站建设 项目流程

简介:这是一份基于STM32F407与HAL库移植FreeModbus从机协议的完整工程资源,适用于具备一定STM32开发经验、需要在工业通信场景中快速接入Modbus RTU总线的嵌入式开发者。资源包共687个文件,其中以C源码(384个)和头文件(155个)为主体,涵盖HAL库驱动、FreeModbus协议栈及用户应用代码;另含.s启动文件、.icf链接脚本、MDK5工程文件(uvprojx/uvoptx)和编译输出文件(axf/hex),打开工程即可编译调试。压缩包大小6.36MB,目前已有1940人学习。工程基于YS-F4Pro开发板实现,默认使用串口中断与HAL_UART_RxCpltCallback回调处理Modbus请求,支持03读保持寄存器、04读输入寄存器、05写单线圈、10写多寄存器等常用功能码,并提供了寄存器映射表与从机事件处理示例。通过对照源码可掌握FreeModbus在HAL库下的移植步骤、串口配置与超时处理思路,适合作为学习Modbus从机开发或项目快速起步的参考模板。

1. 从一块开发板到一条产线:FreeModbus 移植到底解决什么问题

做现场设备调试的工程师,迟早会被一个问题卡住:控制器和上位机之间用什么协议说话。各家私有协议难维护,Wi-Fi 方案在工业现场不够稳,最后大家不约而同回到 Modbus 这条老路。而 STM32F407 配合 HAL 库,再移植一套 FreeModbus 从机协议栈,是这条路上性价比最高、踩坑最少的一种组合。

本文要讲的不是 Modbus 协议的理论课,而是把 FreeModbus 从机协议栈跑在 STM32F407 上的完整落地路径。包括 HAL 库下串口和定时器的底层对接、寄存器映射表怎么写、三个最容易翻车的隐藏坑,以及最后用串口工具手动验证从机程序的完整方法。

这套方案的目标读者很明确:手里有 F407 开发板或产品样机,想让设备具备标准 Modbus RTU 从机能力,但又不想从零写协议栈的工程师。全文不依赖任何第三方库,只用 ST 官方 HAL 库和 FreeModbus 源码,跟着步骤走即可在开发板上跑通。

2. 移植前先想清楚:FreeModbus 的架构和三个底层依赖

2.1 FreeModbus 的目录结构与角色划分

FreeModbus 从机协议栈的代码量不大,核心协议逻辑和硬件底层的接口分得很干净。拿到源码后,真正需要动手改的只有两个文件:portserial.c 和 porttimer.c,外加一个配置头文件 mbconfig.h。

// mbconfig.h 中三个与从机功能直接相关的配置宏 #define MB_SLAVE_RTU 1 // 启用 RTU 模式,ASCII 模式一般用不上 #define MB_FUNC_READ_INPUT_REG 1 // 读输入寄存器,传感器采集常用 #define MB_FUNC_WRITE_MULTIPLE_REG 1 // 写多个保持寄存器,上位机参数下发靠这个

如果只做数据采集,只需要把读输入寄存器和读保持寄存器两个宏打开就够了。如果要做参数下发,写单个寄存器和写多个寄存器的宏也要打开。这里建议全开,因为后期调试时上位机可能随时用不同功能码读写数据,功能码被禁用后会返回非法功能码错误,排查起来容易误判为移植问题。

2.2 串口层:HAL 库中断收发与 FreeModbus 的事件回调

FreeModbus 的串口层有四个必须实现的接口。HAL 库下最省事的做法是用中断收发,配合 HAL_UART_RxCpltCallback 和 HAL_UART_TxCpltCallback 两个回调函数驱动协议栈。接收用单字节中断逐字节接收,每收到一字节就调用一次接收事件处理函数。发送则用中断方式逐字节发送,一字节发完再取下一字节。

// portserial.c 中的串口发送使能接口,HAL 库下的典型实现 void vMBPortSerialEnable(BOOL xTxEnable, BOOL xRxEnable) { if (xRxEnable) { HAL_UART_Receive_IT(&huart2, (uint8_t *)ucByte, 1); } if (xTxEnable) { HAL_UART_Transmit_IT(&huart2, (uint8_t *)ucByte, 1); } }

这个接口是 FreeModbus 协议栈在需要切换收发方向时调用的。xRxEnable 为真时启动一个单字节的接收中断,xTxEnable 为真时启动一个单字节的发送中断。HAL_UART_Receive_IT 是 HAL 库提供的第一轮接收使能函数,每完成一字节接收,协议栈会再从串口收到的新字节里继续调用这个函数,实现持续的接收流。

2.3 定时器层:3.5 字符时间的实现和时钟源的选型逻辑

Modbus RTU 协议规定帧与帧之间的间隔必须大于等于 3.5 个字符时间,FreeModbus 协议栈内部用这个时间来判断一帧数据是否接收完毕。这个时间由底层定时器负责测量。关键点是:不同波特率下,3.5 字符时间对应的实际毫秒数不一样。9600 波特率下大约是 4ms,115200 波特率下大约是 0.4ms。如果定时器时基精度不够,高波特率下就会频繁误判帧结束,导致通信成功率大幅下降。

// porttimer.c 中定时器初始化接口的核心逻辑 BOOL xMBPortTimersInit(UCHAR ucT35US) { // ucT35US 是协议栈根据波特率算出的 3.5 字符时间,单位是微秒 // 常见做法是用 TIM6/TIM7 这类基础定时器产生 1ms 时基 // 在定时器中断里对毫秒计数,同时对比微秒阈值 return TRUE; }

如果波特率不超过 19200,用 HAL_GetTick() 配合 SysTick 的 1ms 时基勉强能跑。但注意:9600 波特率下 4ms 的超时窗口,SysTick 的 1ms 分辨率误差约 25%,高负载时主循环被阻塞就会错过超时判定。我一般建议直接使用 TIM6/TIM7 这样的基础定时器,把时基设成 100us 或 1ms,用一个计数器累积到 ucT35US 对应的时间后触发复位。

3. 把协议栈挂到 F407 上:串口接收中断、定时器中断和协议栈初始化的完整对接

3.1 串口接收中断回调:每个字节都要喂给协议栈

HAL 库的中断回调机制和标准外设库略有不同。标准外设库在串口中断服务函数里直接读 DR 寄存器,HAL 库则是在中断里调用对应的回调函数。移植 FreeModbus 时,要在 HAL_UART_RxCpltCallback 里把接收到的字节交给协议栈的接收事件处理函数,同时启动下一字节的接收。

// 串口接收完成中断回调,放在 stm32f4xx_it.c 或 main.c 中都行 volatile uint8_t ucByte = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { /* 把收到的字节交给 FreeModbus 协议栈处理 */ prvvUARTRxISR(); // 协议栈的字节接收事件入口 /* 立刻开启下一字节接收,保证连续接收不丢字节 */ HAL_UART_Receive_IT(&huart2, (uint8_t *)&ucByte, 1); } }

这段代码里最关键的一行是 HAL_UART_Receive_IT 的再次调用。HAL 库的单字节接收中断在收到一个字节后会自动关闭,如果不在回调里重新启动,串口就只会收到第一帧数据的第一个字节,后续数据全部丢失。这是 HAL 库移植 FreeModbus 最常见的现象,后面避坑章节会专门展开。

3.2 定时器中断回调:超时复位是帧结束判定的核心

FreeModbus 对定时器只要求做一件事——计时到点后调用一次超时事件处理函数。这个超时事件就是 3.5 字符时间的间隔判定。定时器中断回调的实现思路是:用计数器不断累加,当计数值超过设定的阈值时,调用协议栈的超时处理函数并复位计数器。

// 假设 TIM6 配置为 100us 中断一次,预装载值 100 对应 100us // 通过判断累计时间来触发超时事件 static volatile uint16_t usTimerCount = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { usTimerCount++; /* 每 100us 检查一次是否超过 3.5 字符时间 */ if (usTimerCount >= usT35TimeValue) { usTimerCount = 0; /* 通知协议栈当前帧接收超时 */ prvvTIMT35ExpiredISR(); } } }

初始化时要把 usT35TimeValue 计算成定时器中断次数的阈值。例如 9600 波特率下 3.5 字符时间约 4ms,100us 时基对应阈值 40。115200 波特率下约 0.4ms,对应阈值 4。阈值过大会导致帧间隔判定变慢,表现在通信上就是高频率读写时偶发超时;阈值过小则会在一帧数据内部出现误判,把完整的一帧拆成两段。

3.3 协议栈初始化与主循环调度:eMBInit 和 eMBPoll 的正确姿势

初始化代码放在 main 函数里,顺序有讲究。先初始化串口和定时器的底层硬件,再调用 eMBInit 注册从机地址、波特率和串口资源,最后调用 eMBEnable 使能协议栈。主循环里必须周期性调用 eMBPoll,让协议栈有机会处理接收缓存里的数据帧。

// main.c 中的初始化示例 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART2_UART_Init(); MX_TIM6_Init(); /* eMBInit 的参数依次是:从机地址、串口硬件指针、波特率、校验方式 */ eMBInit(MB_RTU, 0x01, &huart2, 9600, MB_PARITY_NONE); eMBEnable(); while (1) { /* eMBPoll 建议放在主循环,或者放到 1ms 时间片里 */ eMBPoll(); } }

eMBInit 的第二个参数是从机地址,范围 1 到 247,0 地址是广播地址一般不用于单设备应答。第四个参数波特率要和 FreeModbus 内部的波特率预设值匹配,否则 3.5 字符时间算不对,直接表现就是上位机能收到设备响应但校验经常出错。eMBPoll 的调用频率建议不低于 1ms 一次,如果放到 10ms 时间片里,高波特率下收到请求帧后不能及时响应,上位机会报超时。

4. 寄存器映射表是关键工程:从 usRegHoldingBuf 到业务数据的接口设计

4.1 保持寄存器与输入寄存器的回调函数实现

FreeModbus 协议栈不关心寄存器数据存在哪里,它只调用四个回调函数让用户提供数据地址。实现时最直接的做法是定义一个全局数组作为寄存器存储区,然后在回调里把协议栈要读写的地址映射到数组下标。

// modbus_reg.c 中的寄存器存储区定义 static uint16_t usRegHoldingBuf[64] = {0}; // 保持寄存器,支持读写 static uint16_t usRegInputBuf[32] = {0}; // 输入寄存器,只读 eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT usRegIndex = usAddress - 1; // Modbus 地址从 1 开始,数组下标从 0 开始 if ((usRegIndex + usNRegs) > 64) { return MB_REG_SIZE_ERROR; // 越界时必须显式返回错误 } if (eMode == MB_REG_WRITE) { /* 上位机写寄存器,注意协议栈内部是字节序交换的,直接赋值即可 */ for (USHORT i = 0; i < usNRegs; i++) { usRegHoldingBuf[usRegIndex + i] = (uint16_t)(pucRegBuffer[2 * i] << 8) | pucRegBuffer[2 * i + 1]; } } else { /* 上位机读寄存器,把寄存器值拆成高低字节填到缓冲区 */ for (USHORT i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = (uint8_t)(usRegHoldingBuf[usRegIndex + i] >> 8); pucRegBuffer[2 * i + 1] = (uint8_t)(usRegHoldingBuf[usRegIndex + i] & 0xFF); } } return MB_ENOERR; }

这里有一个 Modbus 协议本身的字节序问题需要说明。Modbus RTU 在传输 16 位寄存器值时采用大端序,即先传高字节再传低字节。而很多 STM32 工程里的业务数据是直接用一个 uint16_t 变量存储的,在内存里是小端序。如果不做拆分和拼装处理,读出来的数会高低字节颠倒。上面代码里读回路的拆字节逻辑就是对这个问题的处理,写回路的拼接逻辑同理。

4.2 寄存器地址规划:给模拟量、开关量和参数下发做分区

寄存器地址的规划直接决定未来上位机组态软件的配置工作量。我习惯把保持寄存器和输入寄存器的地址空间做分区。例如保持寄存器 1 到 20 用于系统参数下发,21 到 40 用于运行参数修改,41 到 64 保留备用;输入寄存器 1 到 8 用于模拟量输入,9 到 16 用于设备状态反馈。这样划分后,上位机工程师只需要一张寄存器映射表就能完成整个页面的组态。

// 寄存器规划示例,推荐放在头文件中做宏定义 #define REG_SYS_ADDR_START 0x0001 // 系统参数起始地址 #define REG_SYS_PARA_BAUD 0x0001 // 波特率设置 #define REG_SYS_PARA_ADDR 0x0002 // 从机地址设置 #define REG_RUN_STATUS 0x0029 // 运行状态寄存器,输入寄存器地址偏移

有一点容易忽略:Modbus 的寄存器地址在协议报文里是从 0 开始编号的,但上位机组态软件里地址标签通常从 1 开始显示。两者相差 1。例如协议报文中地址为 0x0000 的寄存器,在上位机软件里显示为保持寄存器地址 1。处理映射时以上位机显示为准统一折算,能避免很多软件联调阶段的困惑。

4.3 寄存器回调里的两个隐藏细节:字节序和越界保护

字节序问题在前面代码里已经处理过,但还有一处隐藏细节容易被忽略。当上位机用功能码 06(写单个寄存器)写入时,FreeModbus 调用的是同一个回调函数,只是 usNRegs 为 1。当上位机用功能码 16(写多个寄存器)时,usNRegs 可能大于 1,此时要特别注意读写缓冲区指针的跨度。部分移植代码只处理了 usNRegs 为 1 的情况,多个寄存器连续写入时会出现数据错位。

另一个细节是越界保护。回调函数必须检查 usAddress 加上 usNRegs 是否超过存储区上限。不检查的话,上位机故意发送一个非法寄存器地址范围,协议栈会正常回复应答,但内存里存储区之外的数据会被覆盖。完整性测试阶段用过模糊测试工具往下位机发随机地址请求,靠的就是这个越界保护兜底。

5. 避坑与排查:HAL 库移植 FreeModbus 的 5 个血泪记录

5.1 串口只收到第一帧,后续数据全部丢失

现象:FreeModbus 初始化后在主循环里持续调用 eMBPoll,上位机第一次发送请求帧能收到响应,第二次及以后的请求全部无响应。

原因:HAL_UART_Receive_IT 在完成一字节接收后自动关闭接收中断,如果不在 HAL_UART_RxCpltCallback 里重新启动接收,串口就只工作一次。部分开发者是在主循环里重新调用 HAL_UART_Receive_IT,但主循环的调度周期不稳定,高波特率下会丢字节。

解决:在 HAL_UART_RxCpltCallback 里每收完一字节立即调用 HAL_UART_Receive_IT 开启下一次接收。同时确认初始化阶段只在 vMBPortSerialEnable 里启动了第一轮接收,不要在 main 函数里重复启动导致回调重入。

5.2 高波特率下 CRC 校验频繁失败

现象:波特率不超过 19200 时通信稳定,改成 115200 后上位机频繁报 CRC 错误,偶发超时。

原因:FreeModbus 的 3.5 字符时间在 115200 波特率下约 0.4ms,如果定时器时基是 1ms 的 SysTick,超时判定误差达到 2.5 倍。协议栈可能在一帧数据还没收全时触发了帧结束判定,导致缓存里的数据不完整,CRC 校验自然失败。

解决:改用独立定时器提供 100us 或更短的时基。检查定时器中断优先级,确保定时器中断优先级高于串口中断。这样即使串口正在处理字节接收,定时器超时也能及时抢入,保证帧边界判定在正确时间点执行。

5.3 从机地址设为 0 后设备不响应

现象:初始化时把从机地址设为 0,上位机向地址 0 发送请求,设备不回任何数据。

原因:Modbus 协议规定地址 0 是广播地址。广播帧不要求从机应答,FreeModbus 协议栈内部对地址 0 的请求只执行寄存器写入操作,不触发发送应答帧的逻辑。

解决:把从机地址设置为 1 到 247 之间的实际地址,通常默认 1 即可。如果确实需要设备响应广播帧,需要修改 FreeModbus 协议栈内部的事件处理逻辑,这种场景一般只在多机同步校时这类特殊应用中出现。

5.4 上位机读寄存器返回非法功能码

现象:上位机发送功能码 04(读输入寄存器),设备响应错误码 01(非法功能码)。

原因:mbconfig.h 中的 MB_FUNC_READ_INPUT_REG 宏被设置为 0,即编译时没有把读输入寄存器的功能代码编译进去。FreeModbus 对未启用的功能码统一返回非法功能码错误。

解决:打开 mbconfig.h 里对应的功能码配置宏,重新编译下载。排查时建议把全部功能码配置宏都打开,通过实际收发功能码来确认协议栈行为,再按需关闭多余功能码。

5.5 寄存器地址对不上:上位机显示地址与代码里的偏移差异

现象:上位机组态软件里配置保持寄存器地址 30001,设备返回的数据总是错位一格,读到的值像是一只调到邻居地址的寄存器。

原因:不同上位机软件对保持寄存器地址的起始定义不一致。有的从 0 开始显示,有的从 1 开始,还有的按 PLC 习惯将保持寄存器起始地址设定为 40001。FreeModbus 协议栈内部使用协议报文地址,两者之间会有一个偏移量没有对齐。

解决:联调第一件事就是确认上位机的地址基准,然后把寄存器回调函数里的地址折算统一处理。我一般会在 modbus_reg.c 开头加一个注释明确的地址偏移量宏,方便联调时按上位机软件的需求调整一行代码。

6. 从手动验证到工程化收尾:用串口助手验证 RTU 帧和常见进阶思路

移植完成后,先用串口调试工具手动发裸 RTU 帧做验证。这是最直接、也最能暴露底层问题的方式,因为不依赖任何上位机组态软件,收到什么响应字节一目了然。以从机地址 1、波特率 9600、无校验为例,验证读保持寄存器的功能码 03,请求帧是:01 03 00 00 00 02 CRC_H CRC_L。CRC 的算法可以通过在线计算工具或用 Python 脚本算出来,不同工具的计算结果必须一致再往设备上发。

# 用 Python 快速计算一帧 Modbus RTU 的 CRC16 data = bytes.fromhex("01 03 00 00 00 02") crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 print(f"{crc & 0xFF:02X} {crc >> 8:02X}")

收到设备响应后先确认响应帧里功能码高位置 1 的位没有被置位,即帧内第二个字节与请求帧功能码一致。如果响应帧功能码带 BIT7 置 1,说明协议栈进入了异常响应状态,对照 Modbus 异常码表排查具体原因。设备回复的数据内容要和寄存器回调函数里存储的值逐字节比对,确认字节序和数值都符合预期,再进入使用上位机组态软件联调的阶段。

验证通过后,建议把 eMBPoll 的调用放到一个固定的时间片里。用定时器产生 1ms 时基,在主循环里标记时间片到达标志,eMBPoll 只在标志置位时执行一次。这样做的目的是保证协议栈的轮询周期稳定,避免主循环里的其他耗时任务影响帧间隔判定。另一个工程化细节是看门狗的喂狗时机要放在 eMBPoll 之后,而不是放在主循环开头。如果协议栈处理异常帧占用了过多时间,看门狗在后置位置会及时复位设备,避免故障设备长时间占线。

最后说一个我栽过的坑。第一次做这个移植时,我为了图省事,直接用 SysTick 做了定时器超时的时基,9600 波特率下测试全部通过,就交付给现场联调了。到现场换成 115200 波特率后,通信成功率跌到六成以下,排查了三天才发现是时基精度不够。从那以后移植任何 Modbus 从机协议栈,我都会先确认定时器时基和波特率匹配,用裸帧测试通过后再谈联调。这个习惯帮我省了很多现场返工的麻烦,希望也能帮到你。

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

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

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

立即咨询