ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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.h、mbconfig.h、mbproto.hmodbus/src协议栈实现比如mb.c、mbfuncholding.c、mbfuncinput.c、mbrtu.cmodbus/port这是移植层包含port.h、portserial.c、porttimer.c是唯一需要改动的目录demo官方例程有各种平台ARM7、AVR、PC等的参考以STM32F103标准库工程为例我们要做的事情就是重写portserial.c和porttimer.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负责切换收发使能prvvUARTTxReadyISR和prvvUARTRxISR是中断回调入口这两个函数是协议栈和底层外设的桥梁。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标准库工程移植FreeModbus3.1 准备工程和源文件我用的开发环境是Keil MDK5芯片是STM32F103C8T6标准外设库版本是v3.5。先建好一个基础的串口工程然后把FreeModbus的源码按下面方式加入工程新建freemodbus分组添加modbus/src下的所有.c文件用到哪个功能码就编哪个但建议全加上把modbus/include加入头文件搜索路径把modbus/port下的port.h、portserial.c、porttimer.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.cFreeModbus要求的串口底层接口就这么几个初始化、接收字节、发送字节、使能收发。我用了USART2PA2TX、PA3RX接了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; }有没有发现一个微妙的地方官方接口的参数ucDataBits和eParity我完全没用到。这是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的心跳我用的是TIM472MHz主频下把它配置为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入口加一个打印把usAddress和usNRegs打印出来对比上位机请求帧基本一眼就能看出偏移量错误还是长度字段错误。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()的调用周期太慢或者有别的中断频繁抢占这时候做实时性优化就有据可依了。本文还有配套的精品资源点击获取
返回列表