news 2026/9/9 11:21:47

STM32标准库移植FreeModbus RTU协议栈完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32标准库移植FreeModbus RTU协议栈完整指南

简介:Modbus 作为工业自动化领域应用最广泛的通信协议之一,FreeModbus v1.6 压缩包则是面向嵌入式开发者的开源实现,目标群体是需要在 PLC、SCADA、仪器仪表及各类自动化设备间实现串口或以太网通信的软硬件工程师。该版本同时支持 RTU、ASCII 与 TCP 三种传输模式,覆盖常见工业组网需求,代码分层与抽象接口设计清晰,可直接裁剪后集成到单片机或 Linux 平台中。压缩包共 1103 个文件,整体约 4.78MB,核心代码由 492 个 C 头文件和 418 个 C 源文件构成,再辅以链接脚本、工程配置、编译批处理、许可证文本、更新日志、HTML 文档与示例工程,能够帮助用户在阅读源码和运行示例时快速建立协议栈的整体认知。通过研究移植接口、端口封装、功能码处理和不同模式下收发差异,可以掌握 Modbus 主从机初始化、请求响应封装、异常判断等关键流程,为工业数据采集与控制项目提供可直接落地的参照。目前已有 1191 人学习下载,适合具有一定嵌入式或网络基础、希望深入学习 Modbus 通信的开发者参考。该压缩包对商业用途也相对友好,BSD 许可证允许在保留版权信息的前提下灵活使用。 最近在调一块STM32F103的设备,需要和上位机走Modbus RTU协议。本来想自己撸一个,后来翻出老朋友FreeModbus v1.6,直接移植过来用,省下不少时间。这东西在圈子里流传了好多年,网上资料多但都讲得零散,今天就把我这次基于标准库 v3.5 + RS232 的移植过程完整记录下来,包括DMA接收怎么处理、定时器怎么选、状态机怎么跑,踩过的坑也一并列出,给后面要做的朋友一个参考。

1. 整体设计与方案选型思路

1.1 为什么选FreeModbus而不是自己写协议栈

Modbus RTU的报文格式很简单,无非就是地址码、功能码、数据和CRC校验,但真正做产品时,坑都在细节里:比如帧间隔3.5个字符时间怎么精准判断、异常响应怎么组织、多个功能码怎么扩展、广播地址怎么处理。自己从头写,开发和测试周期至少一到两周,而且很容易在某些边界条件下翻车。

FreeModbus是一个开源的Modbus协议栈,协议处理部分跟硬件完全解耦,官方已经帮你处理好了状态机、帧解析、异常码生成这些脏活。我们只需要做两件事:提供串口收发字节的底层接口,以及提供定时器来产生1个tick的时基。这对嵌入式项目来说,接入成本非常低,v1.6版本也很稳定,网上资料多,出了问题容易搜到答案。

1.2 v1.6源码结构初步认识

解压freemodbus-v1.6.zip之后,核心代码在modbus目录下:

  • modbus/include:协议栈对外头文件,比如mb.hmbconfig.hmbproto.h
  • modbus/src:协议栈实现,比如mb.cmbfuncholding.cmbfuncinput.cmbrtu.c
  • modbus/port:这是移植层,包含port.hportserial.cporttimer.c,是唯一需要改动的目录
  • demo:官方例程,有各种平台(ARM7、AVR、PC等)的参考

以STM32F103标准库工程为例,我们要做的事情就是重写portserial.cporttimer.c,把官方对"底层串口"和"底层定时器"的抽象接口用STM32的外设填上。

提示:FreeModbus v1.6虽然是老代码,但结构非常清晰。移植成功的关键,是搞懂它内部跑的那套状态机,而不是把代码抄一遍就完事。

2. 核心细节解析:看懂协议栈的工作方式

2.1 状态机是灵魂

FreeModbus RTU模式的接收是事件驱动的,核心状态机在mbrtu.c中。我从实际调试的角度把这个状态机的流转过程捋了一遍:

  • STATE_RX_IDLE:等待接收第一个字节。串口收到数据后,如果地址匹配或是广播地址0x00,则进入STATE_RX_RCV,否则直接丢弃。
  • STATE_RX_RCV:持续接收后续字节,直到超时(3.5个字符时间)或超过接收缓冲区上限MB_RTU_RX_SIZE
  • STATE_RX_ERROR:一旦发现帧过长或CRC校验错误,进入错误状态并复位接收逻辑。

与之配套的发送状态机,负责发送响应帧。整个过程中,vMBPortSerialEnable负责切换收发使能,prvvUARTTxReadyISRprvvUARTRxISR是中断回调入口,这两个函数是协议栈和底层外设的桥梁。

2.2 定时器时基与3.5字符时间

Modbus RTU规定,帧与帧之间的间隔要大于3.5个字符时间,帧内字节间隔要小于1.5个字符时间。FreeModbus用定时器周期性中断来模拟这个超时判断,所以定时器中断周期必须设置成不超过1.5个字符时间,我们通常取1个字符时间内的一个整数分频值。

计算方式(8位数据位、无校验、1位停止位时,一个字符是11位):

t_char = 11 / 波特率

波特率9600时:t_char = 11 / 9600 ≈ 1.146ms波特率115200时:t_char = 11 / 115200 ≈ 95.5us

定时器中断周期可以取t_char的整数倍,但不能超过1.5倍。最保险的做法是让定时器Tick等于一个字符时间,这样在STATE_RX_RCV中连续两个tick没收到新数据,就认为帧结束了。

实操心得:波特率越高,定时器要求越苛刻。57600以上建议直接把定时器中断周期设为50us,这样整个判断更平滑。

3. 实操过程:STM32F103标准库工程移植FreeModbus

3.1 准备工程和源文件

我用的开发环境是Keil MDK5,芯片是STM32F103C8T6,标准外设库版本是v3.5。先建好一个基础的串口工程,然后把FreeModbus的源码按下面方式加入工程:

  • 新建freemodbus分组,添加modbus/src下的所有.c文件(用到哪个功能码就编哪个,但建议全加上)
  • modbus/include加入头文件搜索路径
  • modbus/port下的port.hportserial.cporttimer.c拷贝到工程的port目录,准备修改

这里有一个关键配置,打开mbconfig.h

#define MB_ASCII_ENABLED 0 // 只做RTU #define MB_RTU_ENABLED 1 #define MB_FUNC_HANDLING_INPUTS 1 // 03功能码保留寄存器 #define MB_FUNC_HANDLING_HOLDING 1 // 03/06/16功能码保持寄存器 #define MB_FUNC_READ_COIL 1 // 01功能码 #define MB_FUNC_WRITE_COIL 1 // 05功能码 #define MB_FUNC_READ_INPUT 1 // 04功能码 #define MB_FUNC_WRITE_REG 1 #define MB_FUNC_WRITE_MULTIPLE_REG 1 #define MB_FUNC_READ_DISCRETE 1

这样协议栈支持常用功能码,使用寄存器读写功能很顺手。

3.2 重写portserial.c

FreeModbus要求的串口底层接口就这么几个:初始化、接收字节、发送字节、使能收发。我用了USART2,PA2(TX)、PA3(RX),接了MAX3232转RS232电平。

初始化接口:

BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { USART_InitTypeDef USART_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = ulBaudRate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_Cmd(USART2, ENABLE); return TRUE; }

有没有发现一个微妙的地方:官方接口的参数ucDataBitseParity我完全没用到。这是FreeModbus的老设计,对RTU模式来说默认就是8位数据位。如果要做7位数据位或偶校验的Modbus,你得自己处理这个参数,大多数人直接忽略也没问题。

串口中断里要调用协议栈的回调函数:

void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t ucByte = USART_ReceiveData(USART2); prvvUARTRxISR(ucByte); } if (USART_GetITStatus(USART2, USART_IT_TXE) != RESET) { USART_ITConfig(USART2, USART_IT_TXE, DISABLE); prvvUARTTxReadyISR(); } }

这里特别要注意:发送完成要用TXE(发送数据寄存器空)而不是TC(发送完成)中断。原因在于,如果使用TC中断,发送最后一个字节后,可能等很久才触发,而FreeModbus要求发送完成事件越快越好,否则会拖慢主循环的轮询节奏。

3.3 用DMA接收为什么会有坑,以及我的最终选择

很多项目为了提高CPU利用率,想把串口接收改成DMA+空闲中断。但我最后用的是传统RXNE中断接收。原因有两个:

第一,FreeModbus的接收是以字节为单位的,每收一个字节就调用一次prvvUARTRxISR,它内部会在每次调用时更新时间戳(也就是定时器的计数)。如果用DMA成批接收,空闲中断才把数据一次性交给协议栈,你会丢失"帧内字节间隔"的粒度,导致状态机在超时判断上出问题。

第二,DMA接收要开环形缓冲区,还得自己维护写指针、读指针,代码复杂度明显上升。项目上如果对CPU负荷不是特别敏感,直接RXNE中断足够。

这话反过来也不是说不能用DMA:如果你用DMA把数据搬进自己的环形数组,仍然按字节粒度(在DMA传输完成中断里逐个字节喂给state machine),这也能用。但此时DMA只是"搬运工",判断逻辑还是得走prvvUARTRxISR,收益不大,所以我不建议为了DMA而DMA。

实操心得:宁可RXNE中断里多花几个周期,也要保证帧边界判断是准确的。Modbus协议最看重的就是时序,而不是CPU占用率。

3.4 重写porttimer.c

定时器是FreeModbus的"心跳",我用的是TIM4,72MHz主频下把它配置为50us中断一次,代码如下:

BOOL xMBPortTimersInit(UCHAR ucMasterTimer) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); TIM_TimeBaseStructure.TIM_Period = 50 - 1; // 50us TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 1MHz计数 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); NVIC_InitStructure.NVIC_IRQChannel = TIM4_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_ITConfig(TIM4, TIM_IT_Update, ENABLE); TIM_Cmd(TIM4, DISABLE); return TRUE; }

注意TIM_Cmd(TIM4, DISABLE),定时器初始化后不立即启动。FreeModbus在需要的时候会调用vMBPortTimersEnable()开启定时器,在超时处理完成后调用vMBPortTimersDisable()关闭定时器,这样的设计可以降低功耗,同时避免中断频繁打扰CPU。

中断服务函数和使能控制:

void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); prvvTIMERExpiredISR(); } } void vMBPortTimersEnable(void) { TIM_SetCounter(TIM4, 0); TIM_Cmd(TIM4, ENABLE); } void vMBPortTimersDisable(void) { TIM_Cmd(TIM4, DISABLE); }

3.5 注册回调函数并启动协议栈

main.c中初始化协议栈:

int main(void) { USART2_Config(); TIM4_Config(); eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { eMBPoll(); } }

eMBInit的第二个参数是设备地址,我设的是0x01;第三个参数是串口编号(FreeModbus不真正区分,直接传0);波特率和校验方式需要和上位机一致。eMBPoll()要放在主循环里不断调用,调用周期最好小于1ms,否则会影响响应速度。

有一点容易被忽略:eMBInit返回的是eMBErrorCode,一定要检查是否为MB_ENOERR,如果串口或定时器初始化失败,协议栈会返回错误码,忽略的话后面收不到任何响应,排查起来会绕远路。

3.6 实现寄存器读写回调

协议栈注册好之后,需要自己实现保持寄存器的读写回调函数:

eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT usRegIndex = usAddress - 1; // Modbus地址从1开始,数组下标从0开始 if (usAddress < 1 || (usAddress + usNRegs - 1) > REG_HOLDING_NREGS) { return MB_ENOREG; } if (eMode == MB_REG_WRITE) { for (int i = 0; i < usNRegs; i++) { usHoldingRegs[usRegIndex + i] = (USHORT)(pucRegBuffer[2 * i] << 8) | pucRegBuffer[2 * i + 1]; } } else // MB_REG_READ { for (int i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = usHoldingRegs[usRegIndex + i] >> 8; pucRegBuffer[2 * i + 1] = usHoldingRegs[usRegIndex + i] & 0xFF; } } return MB_ENOERR; }

注意Modbus协议中寄存器地址是从1开始的(协议数据单元中的地址是从0开始的,但偏移量取决于具体设备)。我在回调里做usAddress - 1的偏移,让自己定义的数组从0开始,这样业务代码写起来直观。

4. 常见问题与排查技巧实录

4.1 串口助手下发指令完全没有响应

这类问题的排查路径基本是固定的。第一,用示波器或逻辑分析仪看TX引脚有没有波形,排除接线和USB转串口模块的问题。第二,检查eMBInit的返回值和eMBEnable是否调用成功。第三,确认中断服务函数里有正确调用prvvUARTRxISR,这是新手最容易漏掉的一步。

我遇到过一次比较隐蔽的问题:在main中先初始化了USART,再调用eMBInit,但eMBInit内部会调用xMBPortSerialInit,把我的配置覆盖掉了。所以如果你需要自定义串口参数,请放在xMBPortSerialInit内部做,而不是先初始化再等协议栈覆盖。

4.2 能收到请求,但响应乱码或CRC错误

这个现象大多是波特率误差引起的。FreeModbus的定时器和串口共用同一个时钟源时,晶振误差会造成定时器Ticks偏移,导致帧超时误判。排查时先用逻辑分析仪对比实际波特率,再看定时器周期。如果用的是内部RC振荡器,建议先锁相到PLL,确保SYSCLK准确,不要裸跑内部8MHz。

另外,检查CRC校验函数的字长。STM32标准库自带硬件CRC外设,但FreeModbus用的是软件查表法,两者互不兼容。如果你在移植时把协议栈的CRC函数替换成了硬件CRC,一定要确保按Modbus规定的多项式0xA001进行位序反转。

4.3 定时器频繁触发,但状态机仍判断超时

这时先看vMBPortTimersEnable是不是每次都清零了计数器。FreeModbus在STATE_RX_RCV状态下每收到一个字节都会重新装载定时器,如果不清零计数器,就会用上一次的剩余时间来计算超时,导致帧提前结束。

同时要确认prvvTIMERExpiredISR调用频率不会太高。我调试时发现,如果定时器中断周期设置为比串口波特率周期还短(比如115200波特率下设置了20us),会导致同一个帧内触发了多次超时事件,这时候需要把中断周期调整到50us或接近1个字符时间。

4.4 异常响应码 02 或 03 不断返回

异常码02(非法数据地址)通常是因为回调函数里地址判断写错,寄存器编号超出了范围。异常码03(非法数据值)则多半是写入的值超出了业务范围,但协议栈本身并不会校验业务逻辑,它只负责把值写进你的数组,所以这通常是应用层回调里人为返回的错误。

一个实用的调试办法是,在eMBRegHoldingCB入口加一个打印,把usAddressusNRegs打印出来,对比上位机请求帧,基本一眼就能看出偏移量错误还是长度字段错误。

5. 串口调试的必备工具和验证技巧

移植完成后,怎么验证移植是否成功?我的建议是分三步:

第一步,用PC串口助手手动发一帧标准请求,看是否能收到正确响应。比如发送01 03 00 00 00 02 C4 0B,如果设备地址是1,读取保持寄存器地址0开始的两个寄存器,正确响应应该是01 03 04+ 4个字节数据 + CRC。

第二步,用Modbus Poll这类专门的调试工具做连续读写,重点测试03、06、16功能码,看有无偶发丢帧。

第三步,接一个真实的PLC或组态软件进行长时间稳定性测试。这一步最能发现帧超时和收发切换的时序问题。

我从实际项目中体会到,FreeModbus最影响成功率的往往不是协议栈本身,而是移植层的时序配合。串口和定时器只要节奏一致,协议栈几乎不需要改动。特别是波特率9600和115200这两个常用档位,建议都测一遍,因为不同的波特率下定时器的余量差别很大,有些问题只在高速档位下才暴露。

最后再分享一个小技巧:在prvvUARTTxReadyISR里加一个IO翻转,用示波器观察从收到请求到发出响应的延时,正常应该在几百微秒到一两毫秒之间。如果这个时间过长,说明主循环里eMBPoll()的调用周期太慢,或者有别的中断频繁抢占,这时候做实时性优化就有据可依了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 11:21:38

IDEA+Tomcat控制台中文乱码根治:字符编码统一方案

用 IDEA 开发 Java Web 项目&#xff0c;启动 Tomcat 时控制台输出一堆中文乱码&#xff0c;这几乎是每个刚接触 Spring MVC 或者 SSM 框架的同学都撞过的坑。乱码看起来是小事&#xff0c;但真正排查起来涉及的环节一点都不少&#xff1a;源文件编码、JVM 启动参数、IDEA 控制…

作者头像 李华
网站建设 2026/9/9 11:21:31

ruflo实战:用Rust构建嵌入式实时日志告警流处理管线

ruflo 这个名字念起来有点拗口&#xff0c;但拆开看就很直白了&#xff1a;ru 是 Rust&#xff0c;flo 是 flow。我最初是在一个内部监控服务里需要处理实时日志流&#xff0c;过滤异常、聚合计数、触发告警&#xff0c;结果翻了半天生态&#xff0c;要么直接上 Flink 这种重型…

作者头像 李华
网站建设 2026/9/9 11:20:47

C#中if/else的正确写法与重构思路

很多人觉得 if/else 是编程入门第一课的内容&#xff0c;简单到没什么好聊的。但我在做代码评审、带新人、以及面试候选人的过程中&#xff0c;几乎每周都能看到把简单条件分支写成一团浆糊的程序&#xff1a;三层嵌套起步、条件表达式写成天书、能用 if 走天下绝不换姿势。C# …

作者头像 李华
网站建设 2026/9/9 11:20:35

树莓派Pico调试工具横评:mpremote、Putty与MobaXterm怎么选?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 11:20:23

技术博客创作复盘:从灵感到发布的全流程方法论与数据驱动迭代

不知不觉&#xff0c;又到了“我的创作纪念日”。说实话&#xff0c;以前我对这种日子没什么感觉&#xff0c;觉得它不过是一个时间节点&#xff0c;像生日一样&#xff0c;过完就完了。但今年不一样&#xff0c;我翻了一下后台的累计数据&#xff0c;突然想认真聊聊“创作”这…

作者头像 李华
网站建设 2026/9/9 11:20:16

风险IP定位实战:从日志分析到威胁情报与自动化封禁

上个月我处理一起异常流量的时候&#xff0c;客户把一堆日志导出给我&#xff0c;让我看看到底是谁在打他的接口。日志长什么样&#xff1f;几千条恶意请求&#xff0c;几十个IP&#xff0c;密密麻麻的4xx、5xx&#xff0c;还有几个触发了WAF规则。我知道很多人这时候的操作是打…

作者头像 李华