STM32F103 MODBUS-RTU从机实现:03/06功能码与串口空闲中断优化
2026/6/7 14:19: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的TX(PA9)和RX(PA10)引脚并不直接连接到485总线,而是需要经过一个“电平转换和方向控制”的桥梁——RS-485收发器芯片。我选用的是常见的SP3485,它的电路连接有几个关键点:

  1. RO和DI引脚:分别连接到MCU的RX和TX引脚,负责TTL电平与差分信号之间的转换。
  2. A和B总线:这是差分信号线,必须使用双绞线。所有设备的A接A,B接B。总线两端需要各接一个120欧姆的终端电阻,用以消除信号反射,尤其是在通信速率较高或距离较长时。这个电阻很多时候容易被忽略,导致通信不稳定,时好时坏。
  3. 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-247,0为广播地址(我的程序未处理广播),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位(1+8+2)来计算一个完整的字符帧。那么: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)的逻辑如下:

  1. 收到字节(USART_IT_RXNE):将数据存入Rx_Buffer[rx_index]rx_index加1。如果rx_index超过缓冲区大小,则回绕到0(实现环形缓冲)。同时,重置定时器。只要数据在持续到来,定时器就不断被重置,不会超时。
  2. 检测到总线空闲(USART_IT_IDLE):读取SR寄存器(USART_GetITStatus)会清除空闲中断标志。当这个中断发生时,意味着自上一个字节后,总线已经空闲了超过一个字符的时间。此时,我们检查rx_index,如果大于0,说明收到过数据,那么立刻置位rx_frame_ready_flag,表示一帧数据可能接收完成了。为什么是“可能”?因为MODBUS要求3.5字符时间,而空闲中断在1字符时间后就触发了。所以这里更准确的做法是,在空闲中断触发时,启动一个短定时器(比如2ms后),再去检查是否真的没有新数据,以此模拟3.5字符时间。但在实际应用中,如果波特率不是特别高(比如115200以上),且总线干扰小,空闲中断后直接置位标志也能稳定工作,代码更简洁。我采用的是简化版。
  3. 定时器超时中断:如果定时器计数值达到预设值(如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 i=0; i<reg_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 04

3.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 资源管理与鲁棒性提升

  1. 环形缓冲区防溢出:在串口接收中断中,每次存入数据前检查rx_index,如果达到RX_BUFF_SIZE - 1(留一个位置给结束判断?其实环形缓冲不需要),则将其重置为0,并可以设置一个“缓冲区溢出”错误标志。这能防止异常数据流(如干扰、错误的主站请求)冲垮你的缓冲区。
  2. 状态机设计:对于更复杂的应用,可以考虑引入状态机来管理MODBUS从机的状态(如IDLE、RECEIVING、PROCESSING、SENDING)。这样逻辑更清晰,也更容易处理异常情况,比如在发送响应时又收到新请求该怎么处理。
  3. 超时重发与异常响应:作为从机,我们只响应。但主站需要我们的响应。如果我们的程序因为某些原因(如处理时间过长)没有及时回复,主站会超时。为了友好,我们的处理函数应尽量高效。对于无法处理的请求(如非法地址、非法功能码、非法数据),一定要按照MODBUS协议规范回复异常响应(功能码+0x80,后跟异常码),而不是静默丢弃。这能帮助主站快速定位问题。
  4. 寄存器映射抽象:将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等)或者其他IDE(Keil, STM32CubeIDE),只需调整以下几部分:

  1. 硬件抽象层(HAL)替换:工程中使用的是标准外设库(SPL)。如果你使用HAL库或LL库,需要替换相应的初始化函数和中断处理函数。例如,HAL库中使能空闲中断是__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE)
  2. 引脚与时钟配置:根据你的硬件连接,修改USART和GPIO的初始化代码,确认引脚和时钟使能正确。
  3. 中断服务函数名:不同型号MCU的中断向量表可能不同,确保中断服务函数的名称与启动文件中的向量名一致。例如,在CubeIDE中,USART1中断函数可能是void USART1_IRQHandler(void)
  4. 系统时钟:确保你的系统时钟配置正确,特别是用于超时判断的定时器,其时钟源和分频设置要准确,才能得到正确的超时时间。
  5. 编译器差异:IAR、Keil、GCC的内联汇编、位操作语法可能略有不同,但我们的C代码是标准的,通常无需改动。

移植步骤建议:

  • 第一步:在你的新工程中,先实现一个最简单的串口收发功能(查询方式即可),确保硬件通路正常。
  • 第二步:将我的工程中的modbus.cmodbus.h文件(包含CRC表、协议解析函数)复制到你的项目。
  • 第三步:参照我的串口初始化代码,在你的平台上使能接收中断空闲中断
  • 第四步:将我的串口中断服务函数逻辑移植到你的中断函数中。
  • 第五步:在主循环中调用MODBUS_Process()函数。
  • 第六步:定义你的从机地址MY_SLAVE_ADDR和寄存器数组Holding_Registers[],并实现具体的功能码处理回调(如读取ADC、控制GPIO等)。

最后,关于停止位设置为2的问题,这在MODBUS-RTU中很常见,主要是为了在复杂的电磁环境下提供更可靠的帧间隔识别。虽然很多设备用1位停止位也能工作,但遵循协议规范(通常是2位)是保证与不同厂商设备兼容性的好习惯。在初始化串口时务必注意这一点。

整个程序代码量不大,但涵盖了从硬件驱动到应用层协议解析的完整链条。自己实现一遍后,你对MODBUS-RTU的理解会非常深刻,以后再遇到任何通信问题,排查起来都会得心应手。希望这份详细的梳理和踩坑经验,能帮你更快地在自己的STM32项目上实现稳定可靠的MODBUS通信。

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

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

立即咨询