STM32F103 MODBUS-RTU从机实现:03/06功能码与串口空闲中断优化 1. 项目概述与核心思路在工业控制、楼宇自动化或者一些分布式数据采集系统中我们经常会遇到一个主设备需要轮询多个从设备数据的场景。MODBUS-RTU协议因其简单、可靠、开源成为了这类应用中最常见的通信标准之一。最近在一个基于STM32F103C8T6的温湿度采集节点项目里我需要实现MODBUS-RTU从机功能让主站通常是一台工控机或PLC能够读取传感器数据。网上能找到的库要么过于庞大要么适配起来很麻烦所以我决定自己动手从零开始撸一个精简、高效的MODBUS-RTU从机程序。这个程序的核心目标非常明确在资源有限的STM32F1系列MCU上稳定可靠地响应主站的MODBUS-RTU查询命令。它不追求实现全部功能码只聚焦于最常用的**03读保持寄存器和06写单个寄存器**功能这已经能覆盖90%的采集与控制需求。整个程序的编写思路完全遵循了MODBUS-RTU标准文档的精髓并针对嵌入式MCU的特点做了大量优化比如利用串口空闲中断来精准判断一帧数据的结束而不是傻傻地依赖定时器超时这大大提升了响应速度和总线利用率。选择STM32F103是因为它性价比极高自带USART外设通过一个SP3485之类的芯片就能轻松转为RS-485信号。整个工程在IAR EWARM 4.42环境下开发、测试代码结构清晰可以直接移植到你的项目中。接下来我会详细拆解从硬件连接到软件实现的每一个环节包括那些数据手册里不会写的“坑”和调试技巧。2. 硬件设计与通信基础在动手写代码之前我们必须先把硬件平台和通信的物理层、数据链路层搞清楚。MODBUS-RTU跑在RS-485总线上这是一种半双工、差分传输的通信方式抗干扰能力远强于RS-232非常适合工业环境。2.1 RS-485硬件电路设计要点我的硬件核心是一块STM32F103C8T6最小系统板通过USART1与外部通信。USART1的TXPA9和RXPA10引脚并不直接连接到485总线而是需要经过一个“电平转换和方向控制”的桥梁——RS-485收发器芯片。我选用的是常见的SP3485它的电路连接有几个关键点RO和DI引脚分别连接到MCU的RX和TX引脚负责TTL电平与差分信号之间的转换。A和B总线这是差分信号线必须使用双绞线。所有设备的A接AB接B。总线两端需要各接一个120欧姆的终端电阻用以消除信号反射尤其是在通信速率较高或距离较长时。这个电阻很多时候容易被忽略导致通信不稳定时好时坏。RE和DE引脚方向控制这是半双工通信的核心。它们通常短接由一个GPIO引脚我用了PA8控制。当这个引脚为高电平时收发器处于发送模式DI上的数据被驱动到A/B总线上当为低电平时收发器处于接收模式总线上的差分信号被转换为电平从RO输出。注意方向控制GPIO的切换时机至关重要。必须在MCU的USART发送数据之前将总线切换到发送模式并在确认最后一个字节的发送完成后尽快切换回接收模式。切换晚了会丢失发送数据的开头切换早了会干扰总线上可能存在的其他设备发送。最稳妥的方法是使用USART的TC发送完成中断而不是TXE发送寄存器空中断。2.2 MODBUS-RTU帧格式解析MODBUS协议栈位于OSI模型的第7层应用层而RTU是其一种传输模式。一帧完整的MODBUS-RTU数据看起来是这样的[从机地址][功能码][数据段][CRC校验低字节][CRC校验高字节]从机地址1字节范围1-2470为广播地址我的程序未处理广播248-255保留。每个从机必须有唯一地址。功能码1字节告诉从机要做什么。03是读寄存器06是写单个寄存器。数据段N字节根据功能码不同而不同。对于03功能码主站发送的数据段包含[起始寄存器地址高8位][低8位][寄存器数量高8位][低8位]从机回复的数据段包含[字节数][寄存器值高8位][低8位]...。CRC16校验2字节对整个帧从地址到数据段结束进行循环冗余校验计算的结果。这是MODBUS-RTU帧的“指纹”用于确保数据在传输过程中没有出错。校验算法是标准的MODBUS CRC-16初始值为0xFFFF多项式为0xA001。2.3 帧间隔3.5个字符时间这是MODBUS-RTU的一个灵魂设定。协议规定帧与帧之间必须以至少3.5个字符时间的静默间隔作为分隔。对于我们的程序而言这意味着判断一帧数据接收完成的标志不是收到某个特定结束符而是总线空闲时间超过了3.5个字符时间。如何计算3.5个字符时间它取决于你的波特率。一个字符时间包括1个起始位、8个数据位、1或2个停止位我们常用1个。通常我们按11位182来计算一个完整的字符帧。那么3.5字符时间 3.5 * 11 / 波特率例如在9600波特率下3.5 * 11 / 9600 ≈ 4ms。在115200波特率下这个时间约为334μs。我们的程序必须能够检测到这个微小的空闲间隔STM32的串口空闲中断Idle Interrupt正是为此而生。3. 软件架构与核心模块实现理解了硬件和协议我们就可以开始设计软件了。整个程序采用“中断驱动主循环处理”的经典架构确保实时响应又不阻塞主程序。3.1 串口与定时器配置首先初始化USART和用于超时保护的定时器。// USART1 初始化示例 (9600, 8N2) void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // 配置TX(PA9)为复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置RX(PA10)为浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 配置USART参数 USART_InitStructure.USART_BaudRate 9600; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_2; // MODBUS-RTU常用2位停止位 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(USART1, USART_InitStructure); // 使能接收中断和空闲中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 关键使能空闲中断 // 配置USART1中断通道 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }同时我配置了一个基本定时器如TIM6用于辅助超时判断。虽然空闲中断是主力但定时器可以作为“双保险”防止在某些极端情况下如总线持续有干扰信号无法触发空闲中断程序一直等待。定时器周期可以设置为略大于3.5字符时间比如5ms。3.2 数据接收与帧判断机制这是整个程序最精巧的部分。我设计了一个环形缓冲区Rx_Buffer来接收串口数据并用几个关键状态变量来管理接收过程。#define RX_BUFF_SIZE 64 uint8_t Rx_Buffer[RX_BUFF_SIZE]; volatile uint16_t rx_index 0; // 当前接收位置 volatile uint8_t rx_frame_ready_flag 0; // 帧接收完成标志 volatile uint8_t rx_busy_flag 0; // 正在处理上一帧防止新帧覆盖串口中断服务函数USART1_IRQHandler的逻辑如下收到字节USART_IT_RXNE将数据存入Rx_Buffer[rx_index]rx_index加1。如果rx_index超过缓冲区大小则回绕到0实现环形缓冲。同时重置定时器。只要数据在持续到来定时器就不断被重置不会超时。检测到总线空闲USART_IT_IDLE读取SR寄存器USART_GetITStatus会清除空闲中断标志。当这个中断发生时意味着自上一个字节后总线已经空闲了超过一个字符的时间。此时我们检查rx_index如果大于0说明收到过数据那么立刻置位rx_frame_ready_flag表示一帧数据可能接收完成了。为什么是“可能”因为MODBUS要求3.5字符时间而空闲中断在1字符时间后就触发了。所以这里更准确的做法是在空闲中断触发时启动一个短定时器比如2ms后再去检查是否真的没有新数据以此模拟3.5字符时间。但在实际应用中如果波特率不是特别高比如115200以上且总线干扰小空闲中断后直接置位标志也能稳定工作代码更简洁。我采用的是简化版。定时器超时中断如果定时器计数值达到预设值如5ms说明在这么久的时间内都没有收到新字节可以确信一帧数据已经接收完毕。此时置位rx_frame_ready_flag并关闭定时器。实操心得在调试初期我过于依赖空闲中断结果在115200波特率下通信不稳定。后来发现高波特率下字符时间极短MCU处理中断的微小延迟都可能影响判断。加入定时器作为后备判断机制后通信的鲁棒性大大提升。另外在中断服务函数里代码一定要精简只做最必要的操作存数据、设标志复杂的解析交给主循环。3.3 MODBUS协议解析与响应主循环不断检查rx_frame_ready_flag。一旦发现其为1就进行协议解析。void MODBUS_Process(void) { if(rx_frame_ready_flag) { uint8_t addr Rx_Buffer[0]; uint8_t func Rx_Buffer[1]; uint16_t crc_received (Rx_Buffer[rx_index-1] 8) | Rx_Buffer[rx_index-2]; // 注意小端序 // 1. 检查地址 if(addr ! MY_SLAVE_ADDR addr ! 0) { // 忽略广播地址0 Clear_Rx_Buffer(); return; } // 2. CRC校验 uint16_t crc_calculated CRC16_Calc(Rx_Buffer, rx_index - 2); // 计算除CRC外的所有字节 if(crc_calculated ! crc_received) { Clear_Rx_Buffer(); // CRC错误静默丢弃 return; } // 3. 根据功能码处理 switch(func) { case 0x03: // Read Holding Registers Handle_Read_Holding_Registers(); break; case 0x06: // Write Single Register Handle_Write_Single_Register(); break; // 可以扩展其他功能码如 0x04(读输入寄存器), 0x10(写多个寄存器)等 default: // 非法功能码可以构造异常响应功能码0x80, 异常码01 Send_Exception_Response(func, 0x01); break; } Clear_Rx_Buffer(); // 处理完成清空缓冲区准备下一次接收 } }03功能码处理示例假设主站要读取从地址0x01的设备的保持寄存器起始地址为0x0000数量为2。 主站发送01 03 00 00 00 02 C4 0B从机需要回复01 03 04 00 12 00 34 XX YY(其中00 12和00 34是两个寄存器的值XX YY是CRC)。void Handle_Read_Holding_Registers(void) { uint16_t start_addr (Rx_Buffer[2] 8) | Rx_Buffer[3]; uint16_t reg_num (Rx_Buffer[4] 8) | Rx_Buffer[5]; uint8_t response[256]; uint8_t resp_index 0; // 检查地址和数量是否合法 if((start_addr reg_num) TOTAL_HOLDING_REG_NUM) { Send_Exception_Response(0x03, 0x02); // 非法数据地址 return; } if(reg_num 125) { // MODBUS RTU一次最多读125个寄存器 Send_Exception_Response(0x03, 0x03); // 非法数据值 return; } response[resp_index] MY_SLAVE_ADDR; response[resp_index] 0x03; response[resp_index] reg_num * 2; // 字节数 寄存器数 * 2 for(int i0; ireg_num; i) { uint16_t reg_value Holding_Registers[start_addr i]; response[resp_index] (reg_value 8) 0xFF; // 高字节在前 response[resp_index] reg_value 0xFF; // 低字节在后 } uint16_t crc CRC16_Calc(response, resp_index); response[resp_index] crc 0xFF; response[resp_index] (crc 8) 0xFF; // 切换485为发送模式发送数据再切换回接收模式 RS485_TX_ENABLE(); USART_Send_Data(USART1, response, resp_index); // 等待发送完成最好用TC中断判断 while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); RS485_RX_ENABLE(); }06功能码处理示例主站要写地址0x01的设备的保持寄存器0x0002值为0x55AA。 主站发送01 06 00 02 55 AA 98 04从机成功则原样回发01 06 00 02 55 AA 98 043.4 CRC16校验算法实现CRC校验是MODBUS通信的“守门员”必须准确无误。下面是一个经过优化的查表法实现速度快占用资源少。static const uint16_t crc16_table[256] { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, // ... 此处省略中间252个值实际代码需补全完整的256项查表数据 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 }; uint16_t CRC16_Calc(uint8_t *pdata, uint16_t len) { uint8_t tmp; uint16_t crc 0xFFFF; // MODBUS CRC初始值 while(len--) { tmp *pdata ^ (uint8_t)crc; crc 8; crc ^ crc16_table[tmp]; pdata; } return crc; }注意事项CRC校验有两个关键点。第一是字节顺序MODBUS协议规定CRC低字节在前高字节在后。所以在发送时要先送CRC的低字节再送高字节在接收校验时也要按这个顺序组合。第二是计算范围CRC计算是从“从机地址”字节开始到数据区最后一个字节结束不包括帧末尾自带的两个CRC字节。4. 程序优化与调试心得一个能跑的程序和一个稳定可靠的工业级程序之间隔着无数个调试的夜晚。下面分享几个让程序更健壮的优化点和调试技巧。4.1 资源管理与鲁棒性提升环形缓冲区防溢出在串口接收中断中每次存入数据前检查rx_index如果达到RX_BUFF_SIZE - 1留一个位置给结束判断其实环形缓冲不需要则将其重置为0并可以设置一个“缓冲区溢出”错误标志。这能防止异常数据流如干扰、错误的主站请求冲垮你的缓冲区。状态机设计对于更复杂的应用可以考虑引入状态机来管理MODBUS从机的状态如IDLE、RECEIVING、PROCESSING、SENDING。这样逻辑更清晰也更容易处理异常情况比如在发送响应时又收到新请求该怎么处理。超时重发与异常响应作为从机我们只响应。但主站需要我们的响应。如果我们的程序因为某些原因如处理时间过长没有及时回复主站会超时。为了友好我们的处理函数应尽量高效。对于无法处理的请求如非法地址、非法功能码、非法数据一定要按照MODBUS协议规范回复异常响应功能码0x80后跟异常码而不是静默丢弃。这能帮助主站快速定位问题。寄存器映射抽象将Holding_Registers[]数组与实际物理数据ADC读数、GPIO状态、系统参数的同步操作抽象成函数。例如可以设置一个后台任务定期更新数组中的传感器值或者在写寄存器函数中不仅更新数组还触发相应的动作如改变PWM占空比。这样业务逻辑更清晰。4.2 调试技巧与常见问题排查调试通信协议一个好用的工具抵得上千行代码。我强烈推荐以下组合硬件工具USB转RS-485适配器、逻辑分析仪哪怕是最便宜的Saleae兼容款。软件工具MODBUS调试助手如Modbus Poll/Slave QModMaster等、串口助手带十六进制显示和发送。常见问题排查清单现象可能原因排查步骤完全无通信1. 硬件连接错误A/B线接反2. 收发器方向控制逻辑反了3. MCU串口未正确初始化4. 终端电阻未接或阻值不对1. 用万用表测A-B间电压发送数据时应有变化。2. 用逻辑分析仪抓取方向控制引脚和TX引脚波形确认时序。3. 先用串口调试助手测试MCU的TX是否能自发自收短接RX和TX。4. 在总线两端测量电阻应为60欧姆左右两个120欧并联。能收到数据但CRC总错1. 波特率、数据位、停止位、校验位不匹配2. CRC计算算法错误3. 接收到的数据字节序错乱1. 确认主从设备串口参数完全一致特别是停止位MODBUS常用2位。2. 用已知数据包测试CRC函数与在线CRC计算器比对。3. 用逻辑分析仪抓取总线波形看是否因干扰导致数据位错误。通信时好时坏高波特率下更差1. 未接终端电阻或位置不对2. 总线布线过长、未用双绞线、靠近干扰源3. 程序帧间隔判断不准确高波特率下3.5字符时间极短1. 确保只在总线物理最远端的两台设备上接120Ω电阻。2. 检查布线远离电机、变频器等。3. 优化帧判断逻辑结合空闲中断和精准定时器。从机不响应特定功能码1. 程序未实现该功能码2. 寄存器地址映射错误3. 数据长度不符合协议要求1. 检查switch(func)语句是否包含了该功能码分支。2. 确认主站请求的地址是否在从机定义的寄存器地址范围内。3. 例如写多个寄存器0x10要求字节数等参数必须匹配。响应速度慢主站易超时1. 从机处理函数中有耗时操作如延时、复杂计算2. 中断被长时间关闭3. 发送完成后切换回接收模式太慢1. 将耗时操作移出中断和协议处理函数放到主循环或低优先级任务。2. 检查程序中有无长时间关中断的代码。3. 确保在USART_TC发送完成中断中切换485方向这是最快最准的。一个关键的调试方法回环测试。在程序开发初期可以先将485收发器的DI和RO短接或者将MCU的TX和RX短接让设备“自言自语”。用调试助手模拟主站发送请求同时监听串口接收。这样能隔离硬件问题纯软件调试协议栈的逻辑、CRC计算是否正确。5. 工程移植与适配指南我提供的工程基于STM32F103C8T6和IAR 4.42但核心代码具有很强的可移植性。如果你用的是其他型号STM32如F0, F4, G0等或者其他IDEKeil, STM32CubeIDE只需调整以下几部分硬件抽象层HAL替换工程中使用的是标准外设库SPL。如果你使用HAL库或LL库需要替换相应的初始化函数和中断处理函数。例如HAL库中使能空闲中断是__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)。引脚与时钟配置根据你的硬件连接修改USART和GPIO的初始化代码确认引脚和时钟使能正确。中断服务函数名不同型号MCU的中断向量表可能不同确保中断服务函数的名称与启动文件中的向量名一致。例如在CubeIDE中USART1中断函数可能是void USART1_IRQHandler(void)。系统时钟确保你的系统时钟配置正确特别是用于超时判断的定时器其时钟源和分频设置要准确才能得到正确的超时时间。编译器差异IAR、Keil、GCC的内联汇编、位操作语法可能略有不同但我们的C代码是标准的通常无需改动。移植步骤建议第一步在你的新工程中先实现一个最简单的串口收发功能查询方式即可确保硬件通路正常。第二步将我的工程中的modbus.c和modbus.h文件包含CRC表、协议解析函数复制到你的项目。第三步参照我的串口初始化代码在你的平台上使能接收中断和空闲中断。第四步将我的串口中断服务函数逻辑移植到你的中断函数中。第五步在主循环中调用MODBUS_Process()函数。第六步定义你的从机地址MY_SLAVE_ADDR和寄存器数组Holding_Registers[]并实现具体的功能码处理回调如读取ADC、控制GPIO等。最后关于停止位设置为2的问题这在MODBUS-RTU中很常见主要是为了在复杂的电磁环境下提供更可靠的帧间隔识别。虽然很多设备用1位停止位也能工作但遵循协议规范通常是2位是保证与不同厂商设备兼容性的好习惯。在初始化串口时务必注意这一点。整个程序代码量不大但涵盖了从硬件驱动到应用层协议解析的完整链条。自己实现一遍后你对MODBUS-RTU的理解会非常深刻以后再遇到任何通信问题排查起来都会得心应手。希望这份详细的梳理和踩坑经验能帮你更快地在自己的STM32项目上实现稳定可靠的MODBUS通信。