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,它的电路连接有几个关键点:
- RO和DI引脚:分别连接到MCU的RX和TX引脚,负责TTL电平与差分信号之间的转换。
- A和B总线:这是差分信号线,必须使用双绞线。所有设备的A接A,B接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-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)的逻辑如下:
- 收到字节(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 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 资源管理与鲁棒性提升
- 环形缓冲区防溢出:在串口接收中断中,每次存入数据前检查
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等)或者其他IDE(Keil, 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通信。