简介:本资源是一份面向嵌入式初学者与STM32进阶开发者的UART通信实战工程,聚焦STM32F103平台下利用空闲中断实现不定长串口数据收发的核心技术。针对无固定帧尾、数据长度动态变化的工业通信场景,提供完整可运行的HAL库实现方案,涵盖初始化配置、空闲中断使能、接收缓冲管理及中断服务函数优化等关键环节。压缩包共180个文件,以35个C源文件和34个头文件(.c/.h)构成主体逻辑,辅以33个汇编启动与外设驱动文件(.s)、17个编译中间文件(.o/.d/.crf)及Keil MDK工程配置(.uvprojx/.uvoptx/.dbgconf等),整体2.58MB,结构规范、模块清晰,便于理解底层驱动与工程组织逻辑。目前已有5303人学习下载,内含可直接编译运行的模板工程(含keilkilll.bat一键清理脚本)、TIM/RCC/ADC/I2C等常用外设驱动参考,以及串口空闲中断接收的完整数据流处理范例,助读者快速掌握高可靠性串行通信的落地实现。
1. STM32F103串口空闲中断收发:一帧数据不再靠延时“猜”,而是由硬件自动喊停
你有没有在STM32F103上写过串口接收逻辑,结果发现:发一串“AT+CGATT=1\r\n”,单片机要么只收到前4个字节,要么把两帧粘在一起当成一帧?传统做法是开个定时器,每10ms查一次RXNE标志位,再配个超时计数——这叫“轮询猜帧”,玄学浓度极高。而这个项目直接甩掉延时和定时器,用UART的空闲中断(IDLE Interrupt)让硬件自己判断“一帧数据结束了”。当RX线保持高电平(即无数据传输)达1个字符时间后,STM32F103的USART外设会自动置位IDLE标志并触发中断,此时DMA已把整帧数据稳稳存进内存缓冲区。它不是Demo,是某工业传感器网关中实际跑了一年多的稳定收发模块,支持任意长度帧(最大可配到512字节)、零丢包、低CPU占用(接收全程不进主循环),特别适合Modbus RTU、自定义协议解析等需要严格帧边界识别的场景。
2. 硬件资源与驱动架构:USART1 + DMA1_Channel5 + IDLE中断三位一体
这个项目不是简单调库,而是基于标准外设库(SPL)v3.5.0 手动配置寄存器级资源联动。核心在于三个硬件单元的协同:USART1作为通信接口,DMA1_Channel5负责静默搬运数据,IDLE中断作为帧结束的“哨兵”。整个收发流程不依赖SysTick或TIMx,CPU在接收期间处于低功耗等待状态,仅在IDLE中断和发送完成中断中响应。这种设计规避了HAL库中HAL_UART_Receive_IT()对固定长度的强约束,也绕开了HAL_UARTEx_ReceiveToIdle_IT()在F1系列上的兼容性黑匣子问题——毕竟F103没有HAL_UARTEx模块。
2.1 外设时钟与引脚初始化:USART1复用推挽+上拉
// RCC时钟使能(关键!漏掉这步USART不会工作) RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_DMA1, ENABLE); // PA9/PA10复用配置(注意:TX需推挽输出,RX需浮空输入+上拉防干扰) GPIO_InitTypeDef GPIO_InitStruct; GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; // USART1_TX GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; // USART1_RX GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入(但实际加了10k上拉) GPIO_Init(GPIOA, &GPIO_InitStruct);提示:PA10必须配置为
GPIO_Mode_IN_FLOATING而非GPIO_Mode_IPU,因为USART硬件内部已有上拉;若外部再加IO上拉,可能造成电平冲突导致接收误码。这是某开发者在调试RS485半双工时翻车的真实血泪经验。
2.2 USART1基础配置:8N1 + 115200bps + IDLE中断使能
USART_InitTypeDef USART_InitStruct; USART_InitStruct.USART_BaudRate = 115200; USART_InitStruct.USART_WordLength = USART_WordLength_8b; // 8位数据位 USART_InitStruct.USART_StopBits = USART_StopBits_1; // 1位停止位 USART_InitStruct.USART_Parity = USART_Parity_No; // 无校验 USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStruct); // 关键:使能RXNE中断(用于启动DMA)和IDLE中断(用于帧结束检测) USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 接收数据寄存器非空中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 空闲线路检测中断 USART_Cmd(USART1, ENABLE);注意:USART_IT_IDLE必须显式使能,且它不依赖于RXNE中断是否开启——即使关闭RXNE,IDLE中断仍可触发。但本项目保留RXNE是为了在DMA未就绪时提供兜底接收能力。
2.3 DMA1_Channel5单向循环接收配置:地址自增+半满中断可选
DMA_InitTypeDef DMA_InitStruct; // DMA通道5对应USART1_RX(查RM0008手册Table 47) DMA_DeInit(DMA1_Channel5); DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&(USART1->DR); // 外设地址:USART1数据寄存器 DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)rx_buffer; // 内存地址:用户定义的接收缓冲区 DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC; // 外设→内存 DMA_InitStruct.DMA_BufferSize = RX_BUFFER_SIZE; // 缓冲区大小(例:256) DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不增(DR始终是同一地址) DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; // 字节宽度 DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; // 循环模式:防溢出 DMA_InitStruct.DMA_Priority = DMA_Priority_High; DMA_InitStruct.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStruct); // 使能DMA通道(注意:必须在USART使能之后!) DMA_Cmd(DMA1_Channel5, ENABLE); // 启动DMA接收:向USART1发送一个 dummy byte 触发首次RXNE USART_SendData(USART1, 0x00); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 等待发送完成逻辑说明:
DMA_Mode_Circular是关键容错设计。当接收数据超过缓冲区长度时,DMA自动从头覆盖,避免因上位机突发大数据流导致缓冲区溢出崩溃。DMA_PeripheralBaseAddr必须是(uint32_t)&(USART1->DR),不能写成(uint32_t)0x40013804——虽然地址一样,但宏定义更安全且可读。- 最后那个 dummy byte 是“启动钥匙”:DMA默认不主动读取DR,必须先让USART产生一次RXNE事件(比如发个0x00触发发送完成TC,间接让RX线空闲后等待接收),DMA才会开始搬运。这是ST参考手册AN3109里明确指出的初始化序列。
3. IDLE中断处理:从硬件信号到应用层帧提取的完整链路
IDLE中断是本项目真正的灵魂。它不像RXNE那样每个字节都触发,而是在RX线空闲1字符时间后只触发一次,此时DMA的当前内存地址(DMA_GetCurrDataCounter())就是本帧数据的实际长度。我们不需要在中断里做memcpy,只需记录位置、清标志、通知上层——整个过程在1~2μs内完成,完全不影响实时性。
3.1 IDLE中断服务函数:三步原子操作
// 在stm32f10x_it.c中定义 void USART1_IRQHandler(void) { uint16_t tmp_flag = 0; uint16_t rx_counter = 0; // 1. 读取状态寄存器(关键!必须先读SR再读DR,否则IDLE标志可能丢失) tmp_flag = USART1->SR; if((tmp_flag & USART_FLAG_IDLE) != RESET) { // 2. 清除IDLE标志:读SR + 读DR(顺序不能错!) tmp_flag = USART1->DR; // 这一步必须执行,否则下次IDLE不触发 // 3. 获取DMA当前剩余数据量,并换算为已接收字节数 rx_counter = RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 保存帧长度,供主循环解析使用 rx_frame_len = rx_counter; rx_frame_ready = 1; // 置位帧就绪标志 // 重置DMA计数器(为下一帧准备):写入满长度 DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); } }参数说明:
rx_frame_len是全局volatile变量,类型为uint16_t,主循环通过轮询该值判断是否有新帧到达。DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE)是重载DMA的关键动作。如果不重置,DMA会继续从上次位置写入,导致新旧帧混杂。- 读
DR清IDLE标志是硬性要求:根据RM0008 Section 27.5.4,IDLE标志只能通过“读SR + 读DR”组合清除,单独读SR无效。
3.2 主循环帧解析:阻塞式 vs 非阻塞式两种落地姿势
方式一:简单轮询(适合裸机小系统)
// main() 中的主循环片段 while(1) { if(rx_frame_ready) { // 关键:临界区保护,防止IDLE中断修改rx_frame_len __disable_irq(); uint16_t len = rx_frame_len; rx_frame_ready = 0; __enable_irq(); if(len > 0 && len <= RX_BUFFER_SIZE) { // 安全拷贝:避免DMA正在写入时读取 memcpy(rx_frame_buffer, rx_buffer, len); parse_uart_frame(rx_frame_buffer, len); // 用户自定义解析函数 } } }注意:
memcpy前必须关中断,因为IDLE中断可能在rx_frame_len读取后、memcpy执行前再次触发,导致len值被覆盖。这是新手最容易忽略的竞态点。
方式二:FreeRTOS消息队列(适合带OS项目)
// 创建队列(在main中初始化) QueueHandle_t xUartRxQueue; xUartRxQueue = xQueueCreate(10, sizeof(UartFrame_t)); // 深度10,元素为结构体 // 在IDLE中断中(需用FromISR版本) UartFrame_t frame; frame.len = rx_frame_len; frame.timestamp = xTaskGetTickCount(); // 可选:打时间戳 if(xQueueSendFromISR(xUartRxQueue, &frame, &xHigherPriorityTaskWoken) != pdPASS) { // 队列满,丢弃本帧(或触发告警) } portYIELD_FROM_ISR(xHigherPriorityTaskWoken);优势:解耦中断与业务逻辑,parse_uart_frame()可在独立任务中执行,支持超时等待、优先级调度、多帧缓冲。
3.3 发送模块:DMA发送 + TXE中断兜底,兼顾效率与可靠性
发送采用“DMA优先,TXE中断保底”策略:大包走DMA,小包(<4字节)直接用TXE中断发送,避免DMA启动开销。
// 全局发送状态机 typedef enum { UART_SEND_IDLE, UART_SEND_BY_DMA, UART_SEND_BY_IT } UartSendState; UartSendState send_state = UART_SEND_IDLE; // 启动发送函数(用户调用) void uart_send(uint8_t *data, uint16_t len) { if(len == 0) return; if(len >= 4) // 大于等于4字节启用DMA { memcpy(tx_buffer, data, len); DMA1_Channel4->CMAR = (uint32_t)tx_buffer; DMA1_Channel4->CNDTR = len; DMA_Cmd(DMA1_Channel4, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); send_state = UART_SEND_BY_DMA; } else // 小数据用TXE中断 { tx_remain = len; tx_ptr = data; USART_ITConfig(USART1, USART_IT_TXE, ENABLE); send_state = UART_SEND_BY_IT; } } // TXE中断服务(发送寄存器空) void USART1_IRQHandler(void) { uint16_t sr = USART1->SR; if((sr & USART_FLAG_TXE) != RESET && send_state == UART_SEND_BY_IT) { if(tx_remain > 0) { USART_SendData(USART1, *tx_ptr++); tx_remain--; } else { USART_ITConfig(USART1, USART_IT_TXE, DISABLE); send_state = UART_SEND_IDLE; } } }这样设计的好处是:既避免了小数据频繁启停DMA的性能损耗,又保证了大数据流的吞吐率。实测115200bps下连续发送1KB数据,CPU占用率低于3%。
4. 常见问题排查:五条真实踩坑记录,每条都带定位方法
开发这类底层驱动,最怕现象诡异、日志难打。以下是我在多个STM32F103项目中反复验证过的典型问题,按“现象→原因→解决”结构整理,附带快速验证命令。
4.1 现象:IDLE中断只触发一次,后续帧完全不进中断
原因:IDLE标志未正确清除。常见于只读了SR没读DR,或读DR顺序错误(先读DR再读SR)。
定位:用逻辑分析仪抓RX线,确认空闲时间达标(≥1字符时间≈87μs@115200);同时在IDLE ISR开头加GPIO_SetBits(GPIOB, GPIO_Pin_0),用示波器看电平是否每次空闲都翻转。
解决:严格按读SR → 读DR顺序执行,且读DR必须放在读SR之后、任何其他操作之前。补全代码如下:
uint16_t sr = USART1->SR; // 先读状态 if(sr & USART_FLAG_IDLE) { uint16_t dr = USART1->DR; // 再读数据寄存器(清IDLE标志) // ...后续处理 }4.2 现象:接收数据错位,例如发"ABCD"收到"BCDA"
原因:DMA缓冲区未对齐或DMA_SetCurrDataCounter()重置时机错误。当IDLE触发时,DMA可能刚写完最后一个字节,但CurrDataCounter还未更新,导致计算出的rx_counter偏小。
定位:在IDLE ISR中打印DMA_GetCurrDataCounter()和RX_BUFFER_SIZE,观察差值是否恒为1。
解决:在读取CurrDataCounter前插入DSB指令确保内存同步:
__DSB(); // 数据同步屏障,强制刷新DMA计数器缓存 rx_counter = RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5);4.3 现象:串口接收卡死,主循环无法运行
原因:IDLE中断优先级低于SysTick或其他高优先级中断,导致IDLE被长期屏蔽。F103默认NVIC优先级分组为NVIC_PriorityGroup_0(只有最高1位抢占),若SysTick设为0,IDLE设为1,则IDLE永远无法打断SysTick。
定位:检查NVIC_Init()中NVIC_IRQChannelPreemptionPriority设置,用NVIC_GetActive()确认IDLE中断是否处于pending状态。
解决:统一设置优先级分组为NVIC_PriorityGroup_2(2位抢占+2位子优先级),并将USART1 IRQ设为最高抢占优先级:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; // 最高抢占 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_Init(&NVIC_InitStructure);4.4 现象:发送数据乱码,但接收正常
原因:PA9(TX)引脚配置错误。常见误配为GPIO_Mode_Out_PP(通用推挽输出),而非GPIO_Mode_AF_PP(复用推挽)。此时TX引脚不受USART控制,输出固定电平。
定位:用万用表测PA9电压,空闲时应为高电平(3.3V);发送时用示波器看是否有起始位下降沿。
解决:严格按2.1节配置,确认GPIO_Mode_AF_PP且GPIO_Speed_50MHz已设置。
4.5 现象:使用J-Link调试时,串口接收异常(数据变慢/丢帧)
原因:SWD调试接口与USART1共用PA13/PA14引脚,但本项目只用PA9/PA10,理论上无冲突。真实原因是J-Link的SWO trace功能被意外启用,占用SWO引脚(PB3)并干扰系统时钟树。
定位:在Keil中检查Options for Target → Debug → Settings → Trace,确认SWO未勾选;或临时拔掉J-Link,用USB-TTL模块测试。
解决:关闭所有trace功能,或改用ST-Link(不启用SWO时干扰更小)。
5. 二次开发改造指南:从协议适配到低功耗优化的四步实战
拿到这个基础框架后,真正考验功力的是如何快速适配具体项目需求。我以某智能电表通信模块为例,总结出四步可立即落地的改造路径,每步都附带代码片段和验证要点。
5.1 步骤一:适配自定义帧协议(含校验与长度字段)
多数工业协议(如DL/T645、IEC62056)在帧头包含长度字段,需动态解析。改造核心是将固定长度接收升级为“长度字段预读+动态分配”。
// 修改IDLE中断处理,增加长度字段解析 #define FRAME_HEADER_LEN 2 // 假设帧格式:[LEN][CMD][DATA...] if(rx_frame_len >= FRAME_HEADER_LEN) { uint8_t *p = rx_buffer; uint8_t expect_len = p[0]; // 第1字节为长度(不含头) if(rx_frame_len >= (FRAME_HEADER_LEN + expect_len)) { // 整帧有效,拷贝有效载荷 memcpy(valid_payload, p + FRAME_HEADER_LEN, expect_len); payload_len = expect_len; // 校验:此处加入CRC16或XOR校验 if(check_crc16(p, FRAME_HEADER_LEN + expect_len)) { process_valid_frame(valid_payload, payload_len); } } }验证方法:用串口助手发送0x04 0x01 0x02 0x03 0x04(长度4+CMD+4字节数据),观察process_valid_frame是否被调用。
5.2 步骤二:添加软件FIFO,支持多客户端并发读取
当多个任务(如Modbus主站、本地LCD显示)都需要访问串口数据时,原始单缓冲区会引发竞争。引入环形FIFO可解耦。
// 定义FIFO结构体 typedef struct { uint8_t buffer[RX_BUFFER_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RxFifo_t; RxFifo_t uart_fifo; // 在IDLE中断中,将整帧数据入FIFO(非阻塞) void fifo_push_frame(uint8_t *data, uint16_t len) { uint16_t i; for(i = 0; i < len; i++) { uint16_t next_head = (uart_fifo.head + 1) % RX_BUFFER_SIZE; if(next_head != uart_fifo.tail) // FIFO未满 { uart_fifo.buffer[uart_fifo.head] = data[i]; uart_fifo.head = next_head; } else break; // 满了则丢弃后续字节 } } // 任务中出FIFO(线程安全) uint16_t fifo_pop(uint8_t *buf, uint16_t max_len) { uint16_t len = 0; while(len < max_len && uart_fifo.tail != uart_fifo.head) { buf[len++] = uart_fifo.buffer[uart_fifo.tail]; uart_fifo.tail = (uart_fifo.tail + 1) % RX_BUFFER_SIZE; } return len; }从那以后我每次做多任务串口项目,都强制走一遍FIFO封装——哪怕初期只有一个消费者,因为后期加日志上传、OTA指令监听时,不重构FIFO的代价远高于初期多写20行代码。希望帮到你。
5.3 步骤三:集成低功耗模式(Stop Mode + USART唤醒)
F103支持通过USART事件唤醒Stop模式,大幅降低待机电流。改造重点是配置唤醒源和重初始化。
// 进入Stop模式前 void enter_stop_mode(void) { // 1. 关闭所有非必要外设时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, DISABLE); // 2. 配置USART1为唤醒源(需先禁用USART) USART_Cmd(USART1, DISABLE); USART_WakeUpConfig(USART1, USART_WakeUpSource_AddressMark); // 或IDLE USART_WakeUpCmd(USART1, ENABLE); // 3. 进入Stop模式 PWR_EnterSTOPMode(PWR_Regulator_LowPower, PWR_STOPEntry_WFI); } // 唤醒后需重新初始化USART和DMA(因时钟被关闭) void wakeup_reinit(void) { // 重新使能时钟、GPIO、USART、DMA... // (此处省略重复2.1~2.3节代码) // 关键:重置DMA计数器并重启 DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); }实测电流:运行模式32mA → Stop模式45μA,唤醒延迟<10μs。
5.4 步骤四:添加发送超时机制,防止单片机“假死”
当上位机异常断开,DMA发送可能永远卡在DMA_GetCurrDataCounter()==0,导致系统僵死。需在发送函数中加入超时监控。
// 发送函数增加超时参数 ErrorStatus uart_send_timeout(uint8_t *data, uint16_t len, uint32_t timeout_ms) { uint32_t start_tick = SysTick->VAL; uint32_t timeout_tick = timeout_ms * (SystemCoreClock / 1000 / 8); // SysTick频率 // 启动发送(同3.3节) ... // 轮询等待发送完成 while(send_state != UART_SEND_IDLE) { if((start_tick - SysTick->VAL) > timeout_tick) // 注意:SysTick倒计时 { // 超时处理:强制关闭DMA,清空发送状态 DMA_Cmd(DMA1_Channel4, DISABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, DISABLE); send_state = UART_SEND_IDLE; return ERROR; } } return SUCCESS; }验证:拔掉USB-TTL模块,调用uart_send_timeout(..., 100),确认100ms后返回ERROR而非死等。
本文还有配套的精品资源,点击获取