资讯动态

STM32L152RE串口通信实战:从时钟配置到DMA收发全解析

发布时间:2026/10/13 1:19:36 来源:尧图企业网站定制
1. 为什么串口通信是嵌入式开发的“第一课”拿到一块STM32L152RE的开发板点亮LED之后紧接着要做的事情十有八九就是调通串口。原因很简单串口是嵌入式系统与外界对话最基础、最可靠、成本最低的通道。你后面要接的传感器模块、无线通信模组、上位机调试工具几乎都绕不开UART这个外设。可以说串口不通后面的一切都是空中楼阁。STM32L152RE属于ST的L1系列基于Cortex-M3内核主打低功耗场景。它的USART外设功能相当完整支持异步模式、同步模式、单线半双工、多处理器通信、IrDA、LIN等。但实际项目里我们用得最多的还是最基础的异步全双工模式——两根线TX和RX配上合适的波特率就能收发数据了。这篇文章面向的是刚接触STM32L1系列、或者从F1系列转过来发现寄存器有些差异的开发者。我会从时钟配置、GPIO复用、USART初始化、中断处理、收发缓冲设计这几个维度把串口收发这件事彻底讲透。不是那种“复制CubeMX生成代码就完事”的教程而是把每个关键寄存器背后的逻辑、每个参数选择的理由都摊开来讲。你跟着走一遍以后遇到串口丢数据、乱码、中断不触发这些问题自己能定位。2. 硬件连接与时钟树的前置认知2.1 硬件连线别小看这两根线STM32L152RE的USART引脚分布在不同的GPIO端口上具体用哪组要看你的板子设计。以常见的USART1为例TX是PA9RX是PA10USART2的TX是PA2RX是PA3USART3的TX是PB10RX是PB11。这些引脚有些还带重映射功能但L1系列的重映射配置和F1略有不同后面会细说。连线的时候有个基本原则TX接对方的RXRX接对方的TXGND必须共地。我见过不少新手在这里翻车——TX接TX然后纳闷为什么收不到数据。另外如果你用的是USB转TTL模块注意模块的电平是3.3V还是5V。STM32L152RE的IO电平是3.3V虽然部分引脚标称容忍5V但为了安全建议统一用3.3V电平的转换模块。注意如果通信距离超过几十厘米或者波特率高于115200建议在TX/RX线上串联33Ω到100Ω的电阻抑制信号反射。这个技巧在工业现场特别管用能解决很多莫名其妙的偶发丢包。2.2 时钟使能外设不工作先查这里STM32的任何外设要工作第一步都是使能对应的时钟。L1系列的时钟使能寄存器是RCC_APB2ENR挂APB2的外设和RCC_APB1ENR挂APB1的外设。USART1挂在APB2上USART2和USART3挂在APB1上。GPIO则全部挂在AHB上通过RCC_AHBENR控制。这里有个容易踩的坑L1系列的GPIO时钟使能位和F1系列不一样。F1是RCC_APB2ENR的bit2到bit7分别对应GPIOA到GPIOE而L1系列是RCC_AHBENR的bit0到bit6对应GPIOA到GPIOG。如果你从F1的代码直接搬过来只改了芯片型号GPIO时钟没使能对引脚就不会有输出。// 使能GPIOA和USART1的时钟L1系列 RCC-AHBENR | RCC_AHBENR_GPIOAEN; // GPIOA挂AHB RCC-APB2ENR | RCC_APB2ENR_USART1EN; // USART1挂APB2我个人的习惯是在写初始化代码之前先把参考手册的时钟树章节翻出来用笔把要用到的外设挂在哪个总线上标清楚。这个动作花不了五分钟但能省掉后面半小时的调试时间。2.3 波特率计算不是随便填个数就行波特率的计算公式在参考手册里有明确给出Tx/Rx Baud fCK / (8 × (2 - OVER8) × USARTDIV)其中fCK是给USART外设的时钟频率OVER8是过采样模式0表示16倍过采样1表示8倍过采样USARTDIV是一个存放在USART_BRR寄存器里的定点数低4位是小数部分高位是整数部分。假设你的系统时钟是32MHzUSART1挂在APB2上APB2预分频系数为1那么fCK就是32MHz。要得到115200的波特率用16倍过采样USARTDIV 32000000 / (16 × 115200) 17.3611整数部分是17小数部分是0.3611。把小数部分乘以16得到5.7776四舍五入为6。所以BRR寄存器应该写入0x116整数17左移4位是0x110加上小数6等于0x116。实际误差是多少用17加上6/16等于17.375反算波特率是32000000/(16×17.375)115107.9误差约0.08%完全在容忍范围内一般要求误差小于2%。提示如果你用HAL库HAL_UART_Init会自动帮你算BRR但前提是你在UART_HandleTypeDef里填的BaudRate正确并且HAL_RCC_GetPCLK2Freq()返回的时钟频率和实际一致。我遇到过有人改了时钟树但没更新SystemCoreClock导致波特率算错、通信乱码的情况。3. GPIO复用配置与USART初始化实战3.1 GPIO模式设置复用推挽与浮空输入USART1的TX引脚PA9需要配置为复用推挽输出模式RX引脚PA10配置为浮空输入或上拉输入模式。L1系列的GPIO配置寄存器和F1不同它用的是MODER、OTYPER、OSPEEDR、PUPDR这一套和F4类似而不是F1的CRL/CRH。具体配置步骤设置MODER寄存器把PA9和PA10都设为复用模式MODER0b10。设置OTYPER寄存器PA9设为推挽输出bit90PA10无所谓因为它是输入。设置OSPEEDR寄存器根据波特率选择速度。115200及以下用中速0b01就够了高速通信可以选快速0b10。设置PUPDR寄存器PA10建议设为上拉0b01防止悬空时收到噪声。设置AFR寄存器把PA9和PA10的复用功能选到USART1。L1系列的AFR寄存器是AFR[0]和AFR[1]每个引脚占4位。PA9和PA10都在AFR[1]里AF9和AF10都设为7USART1的AF编号是7。// PA9 - USART1_TX GPIOA-MODER ~(3 (9*2)); GPIOA-MODER | (2 (9*2)); // 复用模式 GPIOA-OTYPER ~(1 9); // 推挽 GPIOA-OSPEEDR | (1 (9*2)); // 中速 GPIOA-AFR[1] | (7 ((9-8)*4)); // AF7 // PA10 - USART1_RX GPIOA-MODER ~(3 (10*2)); GPIOA-MODER | (2 (10*2)); // 复用模式 GPIOA-PUPDR ~(3 (10*2)); GPIOA-PUPDR | (1 (10*2)); // 上拉 GPIOA-AFR[1] | (7 ((10-8)*4)); // AF7这里有个细节AFR寄存器的索引方式。AFR[0]管的是pin0到pin7AFR[1]管的是pin8到pin15。所以PA9对应AFR[1]的第4到第7位PA10对应第8到第11位。写代码的时候如果搞错了引脚就不会输出正确的复用信号。3.2 USART寄存器初始化逐位拆解GPIO配好之后接下来配置USART本身。关键寄存器有CR1、CR2、CR3、BRR。CR1寄存器里我们需要设置UEbit13USART使能位最后再置1。Mbit12字长0表示8位数据位1表示9位。PCEbit10校验使能0表示无校验。TEbit3发送使能。REbit2接收使能。RXNEIEbit5接收缓冲区非空中断使能用中断收数据时置1。TCIEbit6发送完成中断使能一般用DMA或轮询发送时不需要。CR2寄存器主要设置停止位。默认是1位停止位STOP0b00。如果需要2位停止位设为0b10。CR3寄存器控制硬件流控CTSE/RTSE一般不用保持默认0。BRR寄存器就是前面算出来的波特率值。USART1-CR1 0; // 先清零 USART1-CR2 0; USART1-CR3 0; USART1-BRR 0x116; // 32MHz下115200波特率 USART1-CR1 | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE; USART1-CR1 | USART_CR1_UE; // 最后使能注意UE位一定要最后置1。如果在配置其他位之前就使能了USART可能会因为BRR还没写入而产生一个错误的起始位导致对方收到乱码。这个顺序问题在参考手册里没有特别强调但实际调试中确实会遇到。3.3 中断优先级配置如果用中断接收还需要配置NVIC。L1系列的中断优先级分组和F1一样通过SCB-AIRCR的PRIGROUP位来设置。一般项目里用分组22位抢占优先级2位响应优先级就够了。USART1的中断号是USART1_IRQn在NVIC_ISER寄存器里对应的位是bit37。设置优先级的时候抢占优先级和响应优先级都要填。NVIC_SetPriority(USART1_IRQn, 1 2); // 抢占1响应0 NVIC_EnableIRQ(USART1_IRQn);如果你用的是HAL库HAL_UART_Receive_IT会自动处理NVIC使能但优先级还是得自己设。我一般把串口中断的抢占优先级设得比SysTick低避免在串口中断里耽误系统时基。4. 收发数据的三种实现方式与代码实操4.1 轮询发送最简单但最占CPU轮询发送就是不断查TXE标志位空了就往DR寄存器写数据。代码大概长这样void UART_SendByte(uint8_t data) { while (!(USART1-SR USART_SR_TXE)); // 等发送缓冲区空 USART1-DR data; } void UART_SendString(char *str) { while (*str) { UART_SendByte(*str); } while (!(USART1-SR USART_SR_TC)); // 等最后一个字节发完 }这种方式适合发送少量数据比如调试信息。但如果你的系统里有其他实时任务轮询发送会阻塞CPU。假设波特率115200发100个字节需要约8.7ms这8.7ms里CPU什么都干不了。对于低功耗应用来说这简直是灾难。4.2 中断接收环形缓冲区的经典设计中断接收是实际项目里最常用的方式。每收到一个字节硬件置RXNE标志触发中断在中断服务函数里把数据读出来存到缓冲区。但这里有个问题如果中断里只是简单地把数据存到一个数组里主循环处理不及时数组就会溢出。所以需要一个环形缓冲区也叫循环队列。环形缓冲区的核心是两个指针写指针由中断更新和读指针由主循环更新。缓冲区大小一般是2的幂次比如64、128、256这样可以用位与运算代替取模提高效率。#define RX_BUF_SIZE 128 // 必须是2的幂 typedef struct { uint8_t buffer[RX_BUF_SIZE]; volatile uint16_t head; // 写指针中断里更新 volatile uint16_t tail; // 读指针主循环更新 } RingBuffer; RingBuffer rxBuf; // 中断服务函数 void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) { uint8_t data USART1-DR; // 读DR会自动清RXNE uint16_t next (rxBuf.head 1) (RX_BUF_SIZE - 1); if (next ! rxBuf.tail) { // 缓冲区没满 rxBuf.buffer[rxBuf.head] data; rxBuf.head next; } // 如果满了丢弃这个字节或者做其他处理 } } // 主循环里读取 uint8_t UART_ReadByte(uint8_t *data) { if (rxBuf.head rxBuf.tail) { return 0; // 缓冲区空 } *data rxBuf.buffer[rxBuf.tail]; rxBuf.tail (rxBuf.tail 1) (RX_BUF_SIZE - 1); return 1; }提示head和tail必须用volatile修饰否则编译器优化时可能把它们缓存到寄存器里导致主循环看不到中断里的更新。这个坑我踩过现象是中断明明进了但主循环读不到数据。环形缓冲区的大小怎么定取决于你的数据帧长度和主循环的处理周期。假设你的协议一帧最多50字节主循环最坏情况下10ms处理一次波特率115200下10ms能收115个字节那缓冲区至少得128。留点余量用256更稳妥。4.3 DMA收发解放CPU的终极方案如果你的数据量比较大或者CPU要处理其他高优先级任务DMA是更好的选择。STM32L152RE的USART1_TX支持DMA1的通道4USART1_RX支持DMA1的通道5。DMA接收的配置思路是设置DMA源地址为USART1-DR目标地址为内存缓冲区传输方向为外设到内存循环模式开启。这样每收到一个字节DMA自动搬运完全不占CPU。你只需要定期检查DMA的剩余传输数量NDTR寄存器就能知道收了多少数据。// DMA1通道5配置USART1_RX DMA1_Channel5-CPAR (uint32_t)USART1-DR; DMA1_Channel5-CMAR (uint32_t)dmaRxBuffer; DMA1_Channel5-CNDTR DMA_RX_SIZE; DMA1_Channel5-CCR DMA_CCR_MINC | DMA_CCR_CIRC | DMA_CCR_EN; // USART1的CR3里使能DMA接收 USART1-CR3 | USART_CR3_DMAR;DMA接收有个经典问题如何知道一帧数据收完了常用的方法有两种。一是用空闲线检测IDLE当总线空闲一个字节时间后触发中断在中断里计算收了多少字节。二是靠协议里的帧头帧尾主循环自己解析。空闲线检测的配置USART1-CR1 | USART_CR1_IDLEIE; // 使能空闲中断 void USART1_IRQHandler(void) { if (USART1-SR USART_SR_IDLE) { USART1-DR; // 读DR清IDLE标志 uint16_t received DMA_RX_SIZE - DMA1_Channel5-CNDTR; // 处理received个字节的数据 // 重新设置DMA接收 DMA1_Channel5-CCR ~DMA_CCR_EN; DMA1_Channel5-CNDTR DMA_RX_SIZE; DMA1_Channel5-CCR | DMA_CCR_EN; } }注意清IDLE标志必须先读SR再读DR顺序反了清不掉。这个细节在参考手册里有写但很容易被忽略。我见过有人卡在这里半天以为中断没触发其实是标志没清导致反复进中断。4.4 三种方式怎么选方式CPU占用适用场景缺点轮询高调试输出、极简系统阻塞CPU实时性差中断环形缓冲低大多数中小数据量场景高波特率下中断频繁DMA极低大数据量、高波特率配置复杂需要空闲检测配合我个人的经验是调试阶段用轮询发日志正式产品里接收一律用中断环形缓冲发送如果数据量大就上DMA。对于L1这种低功耗芯片能用DMA就用DMA让CPU多睡觉才是正道。5. 常见问题排查与避坑指南5.1 乱码问题九成是波特率或时钟不对串口乱码是最常见的问题没有之一。排查顺序应该是确认双方波特率一致。你设115200对方也得是115200。别笑我见过有人一边115200一边9600调了一下午。确认系统时钟频率。用HAL_RCC_GetPCLK2Freq()或者直接读RCC_CFGR寄存器看看APB2的时钟是不是你以为的值。L1系列默认用HSI 16MHz如果你没配PLL那fCK就是16MHz而不是32MHzBRR算出来就是错的。确认BRR值。用前面给的公式自己算一遍和代码里写的对比。检查GPIO复用配置。AFR寄存器有没有设对MODER有没有设成复用模式如果以上都没问题用示波器或者逻辑分析仪抓一下TX线的波形量一下一个位的宽度。115200波特率下一个位是8.68微秒。如果量出来差很多那肯定是时钟问题。5.2 收不到数据先查RX引脚和中断收不到数据的情况按这个顺序排查RX引脚有没有信号用示波器看PA10上有没有波形。没有的话检查对方TX是否正常线有没有接反。RXNE标志有没有置位在中断里打个断点或者用调试器看USART1-SR的bit5。如果没置位说明硬件没收到数据。中断有没有使能CR1的RXNEIE位、NVIC的USART1_IRQn使能位都要检查。中断服务函数名字对不对启动文件里定义的中断向量名是USART1_IRQHandler你写成USART1_IRQHandler(void)没问题但写成USART1_Handler就不会被调用。提示L1系列的启动文件里中断向量名和F1系列基本一致但有些型号的USART3/4/5中断名可能不同。用CubeMX生成代码的话直接看stm32l1xx_it.c里的函数名最保险。5.3 数据丢失缓冲区溢出和中断优先级数据丢失通常有两个原因。一是环形缓冲区太小主循环处理不过来。解决办法是加大缓冲区或者提高主循环的处理频率。二是中断优先级太低被其他高优先级中断打断了。我遇到过一个案例系统里有个定时器中断优先级比串口高定时器中断里做了一些耗时操作导致串口中断被延迟RXNE标志还没来得及读下一个字节就来了硬件置ORE溢出错误标志数据就丢了。解决办法有两个一是把串口中断的抢占优先级设高一点二是缩短其他中断的执行时间。如果实在没法调优先级那就上DMADMA搬运不依赖中断响应速度。5.4 常见问题速查表现象可能原因排查方法乱码波特率不匹配核对双方BRR和时钟乱码系统时钟配置错误读RCC_CFGR确认PLL收不到RX引脚无信号示波器量波形收不到中断未使能查CR1和NVIC收不到中断函数名错误对照启动文件丢数据缓冲区溢出加大缓冲区或提高处理频率丢数据中断被抢占调整NVIC优先级丢数据ORE溢出读SR和DR清标志发送卡死TXE一直为0检查USART使能位UE发送卡死引脚被占用查GPIO配置5.5 低功耗场景的特殊处理STM32L152RE主打低功耗串口在低功耗模式下有一些特殊行为。比如在Stop模式下USART的时钟被关闭无法接收数据。如果需要在低功耗下唤醒可以用USART的唤醒功能通过CR3的WUS位配置但这就不是标准串口通信了。实际项目里如果既要低功耗又要串口通信通常的做法是平时让MCU进入低功耗模式串口引脚配置为中断唤醒源。当RX线上有起始位下降沿时触发外部中断唤醒MCU然后再初始化USART接收数据。这个方案稍微复杂一点但能把待机功耗降到微安级别。注意L1系列的GPIO在低功耗模式下的状态需要仔细配置。如果RX引脚设成浮空输入悬空时可能因为噪声误触发唤醒。建议设成上拉输入或者外部加一个上拉电阻。6. 从能用到好用几个提升稳定性的实战技巧6.1 发送加超时防止死等轮询发送的时候while (!(USART1-SR USART_SR_TXE));这行代码如果USART没使能或者引脚配置错误就会死循环。加一个超时计数器uint32_t timeout 100000; while (!(USART1-SR USART_SR_TXE)) { if (--timeout 0) { return 0; // 超时返回错误 } } USART1-DR data; return 1;这个习惯能救命。特别是在调试阶段一个死循环能让你怀疑人生。6.2 接收加帧超时处理不定长数据很多协议没有固定的帧长度靠字节间超时来判断一帧结束。实现方法是在接收中断里重置一个定时器定时器超时了就认为一帧收完了。void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) { uint8_t data USART1-DR; ringBufWrite(rxBuf, data); frameTimer FRAME_TIMEOUT; // 重置帧超时计数 } } // 在SysTick中断里递减 void SysTick_Handler(void) { if (frameTimer 0) { frameTimer--; if (frameTimer 0) { frameComplete 1; // 标记一帧结束 } } }帧超时的时间一般设3到5个字节的传输时间。115200波特率下一个字节约86微秒3个字节约260微秒。取个整设300微秒比较合适。6.3 打印调试信息的分级控制用串口打日志是调试利器但正式发布时不能把所有日志都打出来。建议做一个日志分级#define LOG_LEVEL_ERROR 0 #define LOG_LEVEL_WARN 1 #define LOG_LEVEL_INFO 2 #define LOG_LEVEL_DEBUG 3 #define CURRENT_LOG_LEVEL LOG_LEVEL_INFO #define LOG_ERROR(fmt, ...) do { if (CURRENT_LOG_LEVEL LOG_LEVEL_ERROR) printf([E] fmt, ##__VA_ARGS__); } while(0) #define LOG_INFO(fmt, ...) do { if (CURRENT_LOG_LEVEL LOG_LEVEL_INFO) printf([I] fmt, ##__VA_ARGS__); } while(0)发布时把CURRENT_LOG_LEVEL改成LOG_LEVEL_ERROR所有调试信息自动消失不用一行行删代码。6.4 用DMA发送时注意数据生命周期DMA发送是异步的你调用发送函数后DMA在后台搬运数据。如果你发送的是一个局部变量的数据函数返回后局部变量被销毁DMA搬过去的就是垃圾数据。// 错误示范 void SendData(void) { uint8_t buf[10] {1,2,3,4,5,6,7,8,9,10}; UART_SendDMA(buf, 10); // buf是局部变量函数返回后失效 } // 正确做法 uint8_t txBuf[10]; // 全局或静态 void SendData(void) { memcpy(txBuf, data, 10); UART_SendDMA(txBuf, 10); }或者用发送完成中断在中断里再修改缓冲区。这个坑很隐蔽因为调试的时候可能碰巧数据还没被覆盖看起来正常但跑久了就出问题。6.5 电平匹配和隔离如果串口要接外部设备特别是工业设备建议加光耦隔离或者至少加TVS管。我见过因为外部设备漏电把MCU的RX引脚打坏的案例。一个几毛钱的TVS管能省掉换芯片的麻烦。另外如果对方是RS232电平正负12V必须加电平转换芯片不能直接接。RS485则要用收发器芯片半双工通信还需要控制DE/RE引脚。7. 个人实操体会调串口这件事说难不难说简单也不简单。我刚开始玩STM32的时候觉得串口不就是配个波特率、写个DR寄存器的事吗结果第一次调就遇到了乱码查了半天发现是系统时钟没配对HSI默认16MHz我按72MHz算的BRR差了四倍多。后来做低功耗项目又遇到了Stop模式下串口收不到数据的问题。翻手册才发现L1系列的USART在Stop模式下时钟停了得用唤醒机制。那段时间把参考手册的USART章节翻了几十遍每个寄存器的每个位都仔细看过才算真正把串口吃透了。我的建议是不要满足于“能收发就行”。多想想为什么这么配每个参数背后的逻辑是什么。比如BRR的计算你自己手算一遍比用CubeMX自动生成强得多。再比如环形缓冲区的设计自己用指针实现一遍比直接调HAL库的HAL_UART_Receive_IT理解得深。最后分享一个调试小技巧如果串口通信不稳定先降波特率。9600不行就48004800不行就2400。低波特率下如果稳定高波特率下出问题那多半是信号完整性问题——线太长、干扰太大、或者GPIO速度配置不对。这时候再针对性解决比盲目改代码有效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑