1. 项目概述:从轮询到中断,串口数据接收的效率革命
在嵌入式开发,尤其是基于STM32这类MCU的项目里,串口通信几乎是“标配”。无论是打印调试信息、与上位机交互,还是连接GPS、蓝牙模块,串口都扮演着关键角色。但很多新手,甚至一些有经验的开发者,在处理串口接收时,往往停留在最简单的“轮询”方式——在主循环里不停地检查串口接收寄存器有没有新数据。这种方式在数据量小、实时性要求不高的场合勉强能用,一旦数据流变得不规律或数据包较长,就会严重占用CPU资源,导致系统响应迟钝,甚至丢失数据。
今天要聊的“STM32F103串口中断接收和空闲中断接收”,就是解决这个痛点的核心方案。这不仅仅是两个中断功能的简单使用,而是一套提升串口通信可靠性、实时性和系统效率的完整方法论。简单来说,串口接收中断(USART_IT_RXNE)负责在每一个字节数据到达时及时响应并读取,确保数据不丢失;而串口空闲中断(USART_IT_IDLE)则像一个聪明的“包尾检测器”,当一帧数据发送完毕,总线空闲一段时间后,它会自动触发,告诉我们“这一包数据收齐了,可以处理了”。
这种组合拳,完美解决了如何高效、准确地接收不定长数据包的问题。你不用再费心去计算数据包长度,或者设置超时定时器,硬件已经为你提供了最优雅的解决方案。无论是接收GPS模块的NMEA语句,还是解析自定义的通信协议帧,这套机制都能让你的代码更加健壮和高效。接下来,我们就深入STM32F103的USART内部,拆解这两种中断的工作原理、配置步骤,以及如何将它们结合起来,打造一个稳定可靠的串口数据接收引擎。
2. 核心机制解析:RXNE与IDLE中断如何协同工作
要玩转这两种中断,首先得理解STM32的USART外设是如何“感知”数据的。这不仅仅是配置几个寄存器那么简单,你需要明白数据从物理引脚到你的应用程序缓冲区,中间经历了什么,以及中断在哪个环节介入。
2.1 串口接收中断(USART_IT_RXNE):数据的“门铃”
想象一下USART外设内部有一个小的接收缓冲区,通常是一个字节的寄存器(RDR)。当串口接收引脚(RX)上一个字节的数据被完整地移入这个寄存器后,硬件会自动将状态寄存器(SR)中的RXNE(Read data register not empty)标志位置1。这个标志位的意思就是“接收数据寄存器非空”,有数据可以读了。
如果我们使能了RXNE中断(USART_IT_RXNE),那么这个硬件标志就会触发一个中断请求。CPU会暂停当前任务,跳转到我们预先写好的中断服务函数(ISR)中。在这个函数里,我们的核心任务就一件事:立刻把RDR寄存器里的这个字节读出来。通常我们会把它存到一个自己定义的数组(环形缓冲区)里。
关键点:读取RDR寄存器的操作(通常通过
USART_ReceiveData()函数或直接访问USARTx->DR)会自动清除RXNE标志。如果你在中断里忘了读,或者读的方式不对,这个标志会一直存在,导致中断不断被触发,形成“中断风暴”,系统就卡死在这里了。
2.2 串口空闲中断(USART_IT_IDLE):帧结束的“信号枪”
空闲中断的机制比RXNE要稍微“间接”一些。它监测的不是具体的数据,而是RX引脚上的电平状态。当一帧数据开始传输时,起始位会把总线拉低,然后传输数据位和停止位。在数据传输期间,总线上是有电平变化的。
当最后一个字节的停止位结束后,如果发送方不再发送新的数据,RX引脚就会恢复到空闲状态(通常是高电平)。如果这个高电平状态持续超过一个完整字符传输的时间(即1个起始位+8个数据位+1个停止位,共10位的时间),USART硬件就会认为“总线空闲了”,随之将状态寄存器中的IDLE标志位置1。
同样,如果我们使能了IDLE中断(USART_IT_IDLE),此时就会触发中断。注意:IDLE标志不会被硬件自动清除,也不会通过读RDR寄存器清除。这是一个非常容易踩坑的地方。必须在中断服务函数中,通过先读SR寄存器,再读DR寄存器的序列来清除它。具体操作是USART_GetITStatus(USARTx, USART_IT_IDLE)检测到中断后,紧接着调用USART_ReceiveData(USARTx)(这个读DR的操作可能无实际数据,但为了清除标志必须执行)。
2.3 协同工作流程:一个完整的接收场景
假设我们通过串口接收一个不定长的指令:“AT+COMMAND\r\n”。
- 第一个字节 ‘A’ 到达:硬件接收完毕,置位RXNE,触发RXNE中断。中断服务函数读取‘A’,存入缓冲区
buf[0]。 - 后续字节 ‘T’, ‘+’, ‘C’… 陆续到达:每个字节都会触发一次RXNE中断,依次存入缓冲区
buf[1],buf[2],buf[3]… - 最后一个字节 ‘\n’ 到达并处理:‘\n’被存入缓冲区。
- 总线空闲:发送方发完‘\n’后停止发送。RX线保持高电平超过一个字符时间。
- 触发IDLE中断:硬件置位IDLE标志,触发空闲中断。
- 处理帧数据:在IDLE中断服务函数中,我们清除IDLE标志,然后根据缓冲区中已存入的字节数(可以通过一个计数变量得知),知道收到了一帧完整的数据“
AT+COMMAND\r\n”。此时,我们可以设置一个标志位(如rx_frame_complete = 1),通知主循环或其他任务:“有一包新数据准备好了,快来处理”。
这样,主程序完全不用操心数据什么时候来、来了多少。它只需要在rx_frame_complete被置位时,去处理缓冲区里的完整数据包即可。CPU的利用率得到了极大提升。
3. 基于标准外设库的完整实现与配置
理论清楚了,我们动手实现它。这里以STM32F103C8T6的USART1为例,使用STM32标准外设库(StdPeriph_Lib)。虽然ST现在主推HAL/LL库,但标准库的代码更贴近寄存器,对于理解原理非常有帮助。
3.1 硬件与引脚初始化
首先,确保你的硬件连接正确:USART1的TX(PA9)接USB转串口模块的RX,RX(PA10)接USB转串口模块的TX,共地。
初始化分为两部分:GPIO和USART。
// 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // 2. 配置GPIO GPIO_InitTypeDef GPIO_InitStructure; // PA9 作为复用推挽输出 (TX) 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); // PA10 作为浮空输入 (RX) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置USART参数 USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; // 使能收发 USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &USART_InitStructure);3.2 中断配置与使能
这是核心步骤,需要配置NVIC(嵌套向量中断控制器)和使能USART的特定中断。
// 4. 配置NVIC(设置中断优先级和使能) NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置优先级分组为2 // 配置USART1全局中断通道 NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级1 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; // 子优先级1 NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 5. 使能USART的接收中断和空闲中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 使能接收中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 使能空闲中断 // 6. 最后,使能USART USART_Cmd(USART1, ENABLE);注意中断优先级分组:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)表示有2位用于抢占优先级,2位用于子优先级。抢占优先级高的可以打断低的,相同抢占优先级时,子优先级高的先响应。串口中断一般不需要设置成最高优先级,但要避免被其他长时间中断阻塞导致数据丢失。
3.3 中断服务函数与缓冲区管理
中断服务函数是灵魂所在。我们需要在这里安全、高效地处理数据。
// 定义接收缓冲区和相关变量 #define RX_BUF_SIZE 256 uint8_t rx_buffer[RX_BUF_SIZE]; // 环形缓冲区或线性缓冲区 volatile uint16_t rx_index = 0; // 当前存储位置,必须加volatile volatile uint8_t rx_flag = 0; // 帧接收完成标志,必须加volatile void USART1_IRQHandler(void) { uint8_t temp; // 1. 处理接收中断 (RXNE) if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { // 读取一个字节,这个操作会清除RXNE标志 temp = USART_ReceiveData(USART1); // 将数据存入缓冲区(这里使用简单的线性缓冲区,生产环境建议用环形缓冲区) if(rx_index < RX_BUF_SIZE) { rx_buffer[rx_index++] = temp; } else { // 缓冲区溢出处理,可以重置索引或丢弃数据 rx_index = 0; // 也可以设置一个错误标志 } // 注意:不要在此处进行复杂处理或调用耗时函数! } // 2. 处理空闲中断 (IDLE) if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { // 重要!清除IDLE标志位的序列:先读SR,再读DR temp = USART1->SR; // 读SR寄存器 temp = USART1->DR; // 读DR寄存器,这个读操作是清除IDLE标志的关键 (void)temp; // 防止编译器警告 // 一帧数据接收完毕,设置标志位 rx_flag = 1; // 此时,rx_index 就是这一帧数据的长度 // rx_buffer[0] 到 rx_buffer[rx_index-1] 是有效数据 // 注意:不要在中断里长时间处理数据!通常只是置位标志。 } }3.4 主循环中的数据处理
中断服务函数只负责“收”和“通知”,真正的数据处理应该放在主循环或低优先级任务中。
int main(void) { // ... 系统初始化,USART初始化(包含上述中断配置) while(1) { // 检查帧接收完成标志 if(rx_flag) { rx_flag = 0; // 清除标志 // 处理接收到的数据帧 // rx_buffer 中存放了从 0 到 (rx_index-1) 的数据 process_rx_data(rx_buffer, rx_index); // 处理完后,重置索引,准备接收下一帧 rx_index = 0; } // 其他任务... // 例如:按键扫描、LED闪烁、传感器读取等 } } void process_rx_data(uint8_t *data, uint16_t len) { // 这里是你的应用层协议解析函数 // 例如:判断是否是有效指令,解析GPS数据等 // 可以将数据通过串口回传,用于调试 for(int i=0; i<len; i++) { USART_SendData(USART1, data[i]); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等待发送完成 } // 或者加个换行便于观察 USART_SendData(USART1, '\r'); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, '\n'); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); }4. 进阶优化与环形缓冲区应用
上面的例子使用了一个简单的线性缓冲区和索引rx_index。这在单次数据包长度可控且处理速度很快的场景下没问题。但在实际复杂应用中,存在两个问题:1) 如果数据处理函数process_rx_data比较耗时,在新一帧数据到来时可能还没处理完,导致缓冲区被覆盖。2) 无法应对数据流非常快,或者数据包背靠背发送的情况。
解决方案是使用环形缓冲区(Ring Buffer)。环形缓冲区是一个逻辑上的“环”,有读指针和写指针。写指针由RXNE中断移动,存数据;读指针由主循环处理函数移动,取数据。两者互不干扰,只要不同时操作同一位置,就能安全地在中断和主循环之间共享数据。
4.1 环形缓冲区的实现
#define RING_BUF_SIZE 512 // 缓冲区大小,根据实际需要调整,最好是2的幂次方,便于取模运算优化 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; // 写指针(中断写入),指向下一个可写位置 volatile uint16_t tail; // 读指针(主循环读取),指向下一个可读位置 } ring_buffer_t; ring_buffer_t uart_rx_buf; // 初始化缓冲区 void ring_buf_init(ring_buffer_t *rb) { rb->head = 0; rb->tail = 0; } // 中断中调用:向缓冲区写入一个字节 static inline uint8_t ring_buf_put(ring_buffer_t *rb, uint8_t data) { uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE; // 判断缓冲区是否已满(写指针即将追上读指针) if(next_head == rb->tail) { return 0; // 缓冲区满,写入失败 } rb->buffer[rb->head] = data; rb->head = next_head; return 1; // 写入成功 } // 主循环中调用:从缓冲区读出一个字节 static inline uint8_t ring_buf_get(ring_buffer_t *rb, uint8_t *data) { // 判断缓冲区是否为空(读指针等于写指针) if(rb->head == rb->tail) { return 0; // 缓冲区空,读取失败 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_BUF_SIZE; return 1; // 读取成功 } // 获取缓冲区中有效数据长度 static inline uint16_t ring_buf_len(ring_buffer_t *rb) { return (rb->head - rb->tail) % RING_BUF_SIZE; }4.2 使用环形缓冲区的中断服务函数
修改后的中断服务函数,不再直接操作线性数组,而是调用ring_buf_put。
void USART1_IRQHandler(void) { uint8_t temp; if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { temp = USART_ReceiveData(USART1); // 存入环形缓冲区,如果满了,可以根据需求丢弃或设置错误标志 if(!ring_buf_put(&uart_rx_buf, temp)) { // 缓冲区溢出处理 // 例如:可以丢弃这个字节,或者丢弃最早的数据(移动tail) // uart_rx_buf.tail = (uart_rx_buf.tail + 1) % RING_BUF_SIZE; // ring_buf_put(&uart_rx_buf, temp); // 再试一次 } } if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { temp = USART1->SR; temp = USART1->DR; (void)temp; // 空闲中断发生时,设置标志。数据已经在环形缓冲区里了。 rx_flag = 1; // 注意:此时我们不知道数据在环形缓冲区里的具体起止位置。 // 需要结合协议来处理,例如在数据中查找帧头帧尾。 } }4.3 主循环中的数据处理策略
使用环形缓冲区后,主循环中的处理逻辑也需要改变。因为数据是流式的,我们需要在缓冲区中解析出完整的帧。
// 假设我们的协议帧格式为:帧头(0xAA 0x55) + 数据长度(1字节) + 数据 + 校验和 void process_ring_buffer_data(void) { static uint8_t state = 0; // 状态机状态 static uint8_t data_len = 0; static uint8_t data_cnt = 0; static uint8_t checksum = 0; static uint8_t packet[256]; uint8_t byte; while(ring_buf_get(&uart_rx_buf, &byte)) // 只要缓冲区有数据就处理 { switch(state) { case 0: // 寻找第一个帧头 if(byte == 0xAA) state = 1; break; case 1: // 寻找第二个帧头 if(byte == 0x55) state = 2; else state = 0; // 匹配失败,复位 break; case 2: // 获取数据长度 data_len = byte; if(data_len > sizeof(packet) - 3) // 长度异常保护 { state = 0; break; } data_cnt = 0; checksum = 0xAA ^ 0x55 ^ byte; // 初始化校验和 state = 3; break; case 3: // 接收数据体 packet[data_cnt++] = byte; checksum ^= byte; if(data_cnt >= data_len) { state = 4; } break; case 4: // 接收并验证校验和 if(byte == checksum) { // 校验成功,得到一个完整的数据包 packet[0..data_len-1] // 调用真正的处理函数 handle_valid_packet(packet, data_len); } // 无论校验是否成功,都回到初始状态,准备接收下一帧 state = 0; break; default: state = 0; break; } } } int main(void) { // ... 初始化 ring_buf_init(&uart_rx_buf); while(1) { if(rx_flag) // 空闲中断触发了,说明可能有一帧数据发完了 { rx_flag = 0; // 启动对环形缓冲区的解析 process_ring_buffer_data(); } // 也可以不依赖rx_flag,在主循环里持续解析环形缓冲区 // process_ring_buffer_data(); // 其他任务... } }这种“中断+环形缓冲区+状态机解析”的组合,是工业级串口通信程序的标配。它解耦了数据接收和数据处理,抗数据冲击能力强,稳定性极高。
5. 常见问题、调试技巧与避坑指南
在实际项目中,配置和使用这两种中断时,会遇到各种各样的问题。下面是我在多年项目中总结的一些典型坑点和解决技巧。
5.1 中断服务函数中的常见错误
问题1:中断服务函数执行时间过长。这是最忌讳的。中断应该快进快出。如果你在USART1_IRQHandler里进行复杂计算、调用printf、或者等待某个标志,很可能会错过后续的中断,导致数据丢失,或者影响其他中断的响应。
解决:中断里只做最必要的事——存取数据、设置标志。所有耗时的处理(如协议解析、数据转发)都放到主循环或低优先级任务中,通过标志位来触发。
问题2:忘记清除中断标志。对于RXNE中断,读USARTx->DR会自动清除。但对于IDLE中断,必须手动清除。我见过太多人只写了if(USART_GetITStatus(USARTx, USART_IT_IDLE) != RESET),却没有后续的清标志操作,结果IDLE中断只触发一次,系统就卡死了,因为标志一直存在。
解决:牢记IDLE中断的清标志“标准操作”:
volatile uint32_t tmp = USARTx->SR; tmp = USARTx->DR; (void)tmp;。用库函数的话,就是先USART_GetITStatus检测,再USART_ReceiveData读一下(虽然可能没数据)。
问题3:全局变量未加volatile。在中断里修改的、在主循环里判断的标志位(如rx_flag,rx_index),必须用volatile修饰。否则编译器可能会进行优化,比如把rx_flag的值缓存到寄存器里,导致主循环永远看不到中断里修改的新值。
解决:所有在中断和主程序间共享的变量,一律加上
volatile关键字。
5.2 数据丢失与错乱排查
问题4:高速率下丢数据。在115200甚至更高波特率下,如果系统还有其他高优先级中断(如定时器中断)长时间执行,或者主循环关中断时间太长,就可能因为来不及响应串口中断而丢数据。
解决:
- 优化中断优先级:适当提高串口中断的抢占优先级(但不要最高,避免影响系统关键任务)。
- 检查关中断时间:排查代码中
__disable_irq()或类似关总中断的操作,确保其持续时间极短。- 使用DMA:如果数据量真的很大,考虑使用“串口空闲中断+DMA”的模式,让硬件直接把数据搬到内存,完全解放CPU。这是更高级的玩法。
问题5:收到的数据偶尔多一个或少一个字节。这很可能和清除IDLE标志的时机有关。IDLE中断是在总线空闲一个字符时间后触发的。如果你在IDLE中断服务函数里进行了一些处理(比如解析数据),而这段时间内,发送方又紧接着发送了下一帧数据的第一个字节,那么这个字节触发的RXNE中断,可能会在IDLE中断服务函数结束前就被响应。这会导致第一帧的末尾和第二帧的开头在逻辑上混在一起。
解决:在IDLE中断服务函数的一开始,就暂时关闭RXNE中断(
USART_ITConfig(USART1, USART_IT_RXNE, DISABLE)),等处理完标志、准备好接收新帧后,再重新开启RXNE中断。或者,确保你的IDLE中断处理极其迅速。
5.3 硬件与配置相关陷阱
问题6:使能了中断,但就是不触发。请按以下清单检查:
- USART时钟和GPIO时钟是否已开启?(
RCC_APB2PeriphClockCmd) - NVIC配置是否正确?中断通道(如
USART1_IRQn)和优先级设置了吗?NVIC_Init调用了吗? - USART本身是否使能?(
USART_Cmd(USART1, ENABLE))必须在配置完中断后再使能USART,顺序很重要。 - 中断函数名是否正确?
USART1_IRQHandler不能拼错。 - 检查硬件连接,RX/TX是否接反?共地了吗?
问题7:使用CubeMX/HAL库时的问题。如果你用的是HAL库,流程类似但函数不同。关键点:
- 使能中断:
__HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE);和__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); - 中断服务函数:在
stm32f1xx_it.c中找到USART1_IRQHandler,调用HAL_UART_IRQHandler(&huart1);。 - 回调函数:HAL库采用回调机制。你需要重写(
__weak)函数HAL_UART_RxCpltCallback(RXNE)和HAL_UART_ErrorCallback(IDLE中断在HAL中作为错误处理之一,通过huart->ErrorCode判断是否为HAL_UART_ERROR_IDLE)。 - HAL库的坑:HAL的IDLE处理有时不够直观,且默认可能未开启IDLE中断。需要手动开启并处理好回调。
5.4 调试技巧
- 利用发送功能调试接收:在RXNE中断里,收到什么字节就立刻通过串口发送(回环)出去。用串口助手看,如果发送“123”,收到“123”,说明RXNE中断基本正常。如果发现数据不对,可能是波特率等配置错误。
- 翻转GPIO引脚:在中断入口和出口用
GPIO_WriteBit翻转一个LED或空闲的IO引脚,用示波器或逻辑分析仪观察中断的触发频率和持续时间。这是最直观的调试硬件中断的方法。 - 打印调试信息:在IDLE中断里,通过串口打印出当前接收到的数据长度。但要注意,打印本身是耗时操作,可能会影响下一次接收,所以仅作临时调试用。
- 模拟发送测试:不要总依赖上位机。可以写一个简单的定时器中断,每隔一段时间模拟发送一包数据,来测试你的接收程序是否稳定。
6. 项目实战:构建一个简单的AT指令解析器
让我们把上面的所有知识整合起来,做一个有实际意义的小项目:一个通过串口接收并解析简单AT指令的系统。指令格式为:AT+<CMD>[=<PARAM>]\r\n。例如AT+LED=ON\r\n或AT+QUERY\r\n。
6.1 系统设计
- 硬件:STM32F103C8T6最小系统板,USART1连接PC串口助手,PA0连接一个LED。
- 功能:接收不定长的AT指令,解析出命令和参数,并执行相应操作(控制LED,或回复状态)。
- 技术选型:采用“RXNE中断+IDLE中断+线性缓冲区+状态机解析”的方案。考虑到指令不会很长,暂不使用环形缓冲区。
6.2 代码实现框架
// at_parser.h #ifndef __AT_PARSER_H #define __AT_PARSER_H void at_parser_init(void); void at_parser_process(void); // 在主循环中调用 #endif // at_parser.c #include "stm32f10x.h" #include <string.h> #include <stdio.h> #define AT_BUF_SIZE 128 static uint8_t at_rx_buf[AT_BUF_SIZE]; static volatile uint16_t at_rx_index = 0; static volatile uint8_t at_rx_flag = 0; // 声明USART1初始化函数(在uart.c中实现) extern void uart1_init(void); void at_parser_init(void) { uart1_init(); // 初始化串口1,包含GPIO、USART、NVIC和中断使能配置 at_rx_index = 0; at_rx_flag = 0; printf("AT Parser Ready.\r\n"); // 假设printf已重定向到串口 } // USART1中断服务函数 (在 stm32f10x_it.c 中,或集中放在uart.c) void USART1_IRQHandler(void) { uint8_t temp; if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { temp = USART_ReceiveData(USART1); if(at_rx_index < AT_BUF_SIZE) { at_rx_buf[at_rx_index++] = temp; } else { // 缓冲区溢出,丢弃最旧的数据(类似FIFO) memmove(at_rx_buf, &at_rx_buf[1], AT_BUF_SIZE - 1); at_rx_index = AT_BUF_SIZE - 1; at_rx_buf[at_rx_index++] = temp; } } if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { volatile uint32_t tmp = USART1->SR; tmp = USART1->DR; (void)tmp; at_rx_flag = 1; // 设置帧接收完成标志 } } // 解析并执行AT指令 static void execute_at_command(uint8_t *cmd, uint16_t len) { // 确保是以\r\n结尾 if(len < 2 || cmd[len-2] != '\r' || cmd[len-1] != '\n') { printf("ERROR: Invalid frame ending\r\n"); return; } // 去掉结尾的\r\n len -= 2; cmd[len] = '\0'; // 方便使用字符串函数 // 检查是否以"AT"开头 if(len < 2 || cmd[0] != 'A' || cmd[1] != 'T') { printf("ERROR: Not an AT command\r\n"); return; } // 解析命令 if(strncmp((char*)cmd, "AT+LED=ON", 9) == 0) { GPIO_SetBits(GPIOA, GPIO_Pin_0); // 假设LED在PA0,高电平点亮 printf("OK: LED ON\r\n"); } else if(strncmp((char*)cmd, "AT+LED=OFF", 10) == 0) { GPIO_ResetBits(GPIOA, GPIO_Pin_0); printf("OK: LED OFF\r\n"); } else if(strncmp((char*)cmd, "AT+QUERY", 8) == 0) { printf("OK: System is running.\r\n"); } else if(strncmp((char*)cmd, "AT+HELP", 7) == 0) { printf("OK: Supported commands:\r\n"); printf(" AT+LED=ON/OFF\r\n"); printf(" AT+QUERY\r\n"); printf(" AT+HELP\r\n"); } else { printf("ERROR: Unknown command\r\n"); } } // 主循环中调用的处理函数 void at_parser_process(void) { if(at_rx_flag) { at_rx_flag = 0; // 清除标志 // 复制一份数据到临时缓冲区进行处理,避免处理过程中被中断修改 uint8_t temp_buf[AT_BUF_SIZE]; uint16_t temp_len = at_rx_index; memcpy(temp_buf, at_rx_buf, temp_len); // 执行解析 execute_at_command(temp_buf, temp_len); // 重置接收索引,准备接收下一帧 at_rx_index = 0; } } // main.c #include "at_parser.h" int main(void) { // 系统时钟、GPIO等初始化... GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 初始化LED引脚 at_parser_init(); // 初始化AT解析器(包含串口) while(1) { at_parser_process(); // 处理接收到的AT指令 // 这里可以添加其他任务,如按键扫描等 // 由于串口接收完全由中断处理,这里不会阻塞 } }6.3 测试与验证
将代码编译下载到STM32,打开串口助手(如XCOM,Putty),设置好波特率115200。
- 发送
AT+HELP\r\n,会收到支持的指令列表。 - 发送
AT+LED=ON\r\n,会收到“OK: LED ON”,并且板子上的LED应该点亮。 - 发送
AT+LED=OFF\r\n,LED熄灭。 - 发送
AT+QUERY\r\n,收到状态回复。 - 发送错误指令,如
AT+TEST\r\n,会收到“ERROR: Unknown command”。 - 尝试快速连续发送多条指令,观察系统是否都能正确响应,没有数据丢失或粘连。
这个项目虽然小,但涵盖了串口中断接收、空闲中断、数据帧处理、简单协议解析等多个核心知识点。你可以在此基础上扩展,增加更多的AT指令,或者将其改造成一个更复杂的通信协议解析引擎。