1. 从零开始为什么STM32的串口通信是嵌入式开发的“必修课”如果你刚开始接触STM32或者从51单片机转过来第一个让你感到既熟悉又陌生的外设大概率就是串口。说它熟悉是因为几乎所有单片机都有这个功能概念上无非是“发送”和“接收”数据说它陌生是因为在STM32的Cortex-M3内核和复杂的库函数面前如何稳定、高效地使用串口里面全是细节。今天我就以最经典的STM32F103ZET6这块“大牛”芯片为例结合USART把串口通信从原理到代码再到实际调试中的那些“坑”给你彻底捋清楚。STM32F103ZET6属于STM32F1系列的“增强型”产品拥有144个引脚资源非常丰富。它有多达5个USART通用同步异步收发器和3个UART通用异步收发器。我们常说的“串口”通常指的是异步通信模式下的USART/UART。为什么串口如此重要因为它是最基础、最直接的调试和通信手段。在项目初期没有屏幕、没有网络模块的时候通过串口打印日志、发送指令是验证程序逻辑、排查硬件问题的“生命线”。即便后期功能复杂了串口也常用来连接GPS、蓝牙、LoRa等模块是嵌入式系统与外界对话的核心通道之一。很多人觉得串口配置很简单调用库函数初始化一下就能用了。但实际项目中你会遇到数据收不全、乱码、通信中断、占用CPU过高等等问题。这背后的原因涉及到时钟树配置、波特率计算、中断与DMA的权衡、硬件流控制等一整套知识体系。这篇文章我会带你超越简单的“点灯式”教程深入到实际工程应用的层面分享我在多个量产项目中积累的USART使用经验和避坑指南。2. 硬件连接与原理不止是TX和RX两根线拿到一块STM32F103ZET6的开发板找到标有USART1的引脚通常是PA9TX和PA10RX。新手最容易犯的第一个错误就是直接把这俩引脚用杜邦线连到USB转TTL模块上然后发现电脑端一片寂静或者全是乱码。别急我们先从硬件原理上把几个关键点搞清楚。2.1 电平标准TTL vs RS232 vs RS485这是第一个容易混淆的概念。STM32芯片引脚输出的信号是TTL电平高电平接近3.3VVDD低电平接近0V。我们常用的USB转串口模块比如CH340、CP2102、FT232核心也是TTL电平。所以STM32的TX应接模块的RXRX接模块的TX并且共地GND这是最基础的连接。共地至关重要它确保了双方有相同的电压参考点否则信号无法正确识别。而老式电脑上的9针串口COM口使用的是RS232电平它是负逻辑电压范围在±3V到±15V之间。因此直接连接STM32和电脑COM口会烧毁芯片必须通过MAX232这类电平转换芯片。RS485则是另一种差分信号标准抗干扰能力强用于长距离通信需要用到MAX485等收发器。对于STM32F103ZET6我们绝大部分场景是TTL电平直接对接各种模块务必先确认对方模块的电平标准。2.2 启动配置Boot0与Boot1引脚的影响这是一个经典的“坑”。STM32F103ZET6的USART1PA9/PA10有一个特殊功能系统启动时如果Boot01 Boot10芯片会进入串口ISP编程模式。此时PA9和PA10被复用为下载接口你的用户程序是无法正常使用这两个引脚进行通信的。很多人在调试时Boot跳线帽没插回正常模式Boot00导致程序运行了但串口死活没数据。所以检查硬件的第一步永远是确认Boot引脚状态。2.3 外设引脚映射与重映射STM32的引脚功能非常灵活。以USART1为例它的默认引脚确实是PA9和PA10。但STM32支持“重映射”功能。例如USART1可以完全重映射到PB6TX和PB7RX。当你发现PA9、PA10被其他功能比如定时器、CAN占用时重映射就派上用场了。在标准外设库SPL中你需要开启AFIO时钟并调用GPIO_PinRemapConfig函数。在HAL库中则在CubeMX里直接勾选“Remap”选项即可。记住重映射不是随便映射必须查阅芯片数据手册的“Alternate function mapping”表格那里定义了所有合法的映射关系。3. 时钟树配置串口稳定工作的基石STM32的任何一个外设要工作都必须有时钟驱动。串口通信的稳定性尤其是波特率的精确性几乎完全取决于时钟树的配置。很多“乱码”问题根源就在这里。3.1 系统时钟源SYSCLK的选择与分频STM32F103ZET6的时钟源可以选择内部高速RC振荡器HSI8MHz或外部高速晶振HSE通常8MHz。对于串口通信特别是需要较高波特率如115200以上或与其他精密时钟设备通信时强烈建议使用外部晶振HSE。HSI的精度大约只有1%在较高波特率下累积误差可能导致通信失败。而外部晶振精度通常在0.5%以内稳定性好得多。假设我们使用8MHz的HSE并通过PLL倍频到72MHz作为系统时钟SYSCLK。那么供给外设的时钟APB2和APB1就是基于这个72MHz分频而来。USART1挂在高速APB2总线上USART2/3挂在低速APB1总线上。这里就引出了第二个关键点波特率计算与APB总线频率。3.2 波特率生成器原理与计算USART有一个波特率寄存器USART_BRR它是一个16位的寄存器但实际上由两部分组成高12位的整数部分DIV_Mantissa和低4位的小数部分DIV_Fraction。波特率的计算公式为Tx/Rx波特率 f_PCLKx / (16 * USARTDIV)其中f_PCLKx是USART所在APB总线的时钟频率USARTDIV就是我们要写入BRR寄存器的值一个浮点数。例如我们想要在APB2时钟为72MHz时配置USART1的波特率为115200。 计算USARTDIV 72000000 / (16 * 115200) 39.0625。 那么整数部分DIV_Mantissa 39小数部分DIV_Fraction 0.0625 * 16 1因为小数部分寄存器是4位代表0/16, 1/16, ... 15/16。 所以BRR寄存器的值应为(39 4) | 1 0x0271。如果你算出来的波特率有微小误差可以通过在线波特率计算器核对。但更重要的是你要清楚你当前APB总线的实际频率是多少。如果你在系统初始化代码里修改了时钟配置比如为了低功耗降低了系统频率但串口初始化代码中的波特率计算还是基于默认频率那必然会导致通信失败。我的经验是在串口初始化函数里最好用宏或变量来引用当前实际的PCLK频率而不是写死一个数字。4. 固件库选择与初始化HAL库 vs 标准库STM32的开发主要有两种库早期的标准外设库SPL和现在的HAL/LL库。对于新手我建议从HAL库配合CubeMX开始它能图形化配置时钟和引脚自动生成初始化代码避免很多低级错误。但对于追求极致效率和可控性的老手标准库或直接寄存器操作仍是首选。4.1 使用CubeMX与HAL库进行配置打开CubeMX选择STM32F103ZE芯片。首先在“Pinout Configuration”的“System Core”-“RCC”中开启HSECrystal/Ceramic Resonator。然后在“Clock Configuration”标签页将HSE选为PLL源并配置PLL倍频使系统时钟达到72MHzAPB2 Prescaler设为不分频72MHz。接着在“Connectivity”中选择USART1。模式选择“Asynchronous”异步。基础参数设置波特率115200字长8位停止位1位无校验无硬件流控制。CubeMX会自动帮你配置好PA9和PA10为复用推挽输出和浮空输入模式。生成代码后你会得到MX_USART1_UART_Init()函数。HAL库的初始化已经完成。发送数据可以使用HAL_UART_Transmit(huart1, (uint8_t*)data, sizeof(data), HAL_MAX_DELAY)接收则可以使用轮询、中断或DMA模式。HAL库的好处是抽象程度高代码跨系列兼容性好但缺点是代码体积大执行效率相对较低中断处理函数HAL_UART_IRQHandler比较臃肿。4.2 标准外设库SPL手动初始化代码剖析如果你使用标准库初始化过程需要手动编写但每一步都清晰可见有助于理解底层。以下是核心步骤的代码和注释void USART1_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置GPIO // PA9 - USART1_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 - USART1_RX 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置USART参数 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); // 4. 配置NVIC如果使用中断 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); // 5. 使能USART接收中断可选 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 接收寄存器非空中断 // 6. 使能USART USART_Cmd(USART1, ENABLE); }这段代码比HAL库生成的更精简你可以清楚地看到时钟、GPIO模式、串口参数、中断优先级每一个环节的配置。对于资源紧张的F103标准库往往能产生更小的代码体积。5. 三种数据收发模式详解轮询、中断与DMA配置好硬件和初始化接下来就是如何收发数据。根据数据量、实时性要求和CPU占用率你需要在这三种模式中做出选择。5.1 轮询模式简单但低效轮询就是“死等”。发送时不断查询状态寄存器USART_SR的TXE发送数据寄存器空或TC发送完成标志接收时不断查询RXNE接收数据寄存器非空标志。// 轮询发送一个字节 void USART1_SendByte(uint8_t data) { USART_SendData(USART1, data); while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); // 等待发送完成 } // 轮询接收一个字节阻塞 uint8_t USART1_ReceiveByte(void) { while(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) RESET); // 死等直到收到数据 return USART_ReceiveData(USART1); }优点代码简单逻辑清晰适合在初始化阶段发送少量固定数据。缺点CPU利用率100%在等待严重浪费资源。在接收时如果数据没来程序就卡死了整个系统无法响应其他事件。在实际项目中除了极简单的场景应避免在主循环中使用轮询接收。5.2 中断模式平衡性能与复杂度中断模式是STM32串口通信最常用的方式。当发送寄存器空或接收到新数据时硬件会产生中断CPU暂停当前任务去处理串口数据处理完再返回。发送中断通常用于提高连续发送的效率。你可以先把数据放入一个缓冲区FIFO然后在TXE中断服务函数中从缓冲区取出下一个字节发送直到缓冲区清空。这样可以实现“后台”发送CPU在数据搬移间隙可以处理其他任务。接收中断这是核心。一旦收到一个字节立即进入中断服务函数ISR将数据从USART_DR寄存器读走存入自定义的环形缓冲区Ring Buffer。主程序只需要定期或不定期地从环形缓冲区中读取并处理数据即可。#define RX_BUF_SIZE 256 uint8_t USART1_RxBuffer[RX_BUF_SIZE]; volatile uint16_t USART1_RxHead 0; // 写入指针在中断中修改 volatile uint16_t USART1_RxTail 0; // 读取指针在主循环中修改 // USART1中断服务函数 void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { // 读取接收到的数据 uint8_t data USART_ReceiveData(USART1); // 存入环形缓冲区 uint16_t next_head (USART1_RxHead 1) % RX_BUF_SIZE; if(next_head ! USART1_RxTail) // 缓冲区未满 { USART1_RxBuffer[USART1_RxHead] data; USART1_RxHead next_head; } else { // 缓冲区溢出处理可以丢弃数据或设置错误标志 } // 清除中断标志读DR寄存器会自动清除RXNE但为了安全可显式清除 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } // 还可以处理其他中断如发送中断、错误中断等 } // 主循环中从缓冲区读取数据 void ProcessRxData(void) { while(USART1_RxTail ! USART1_RxHead) { uint8_t data USART1_RxBuffer[USART1_RxTail]; USART1_RxTail (USART1_RxTail 1) % RX_BUF_SIZE; // 处理data例如解析协议 } }优点CPU利用率低实时性好能够及时响应数据。缺点中断频繁。在高波特率如921600下每接收一个字节就进一次中断中断开销可能成为系统瓶颈。此外中断服务函数必须尽可能短小精悍不能做复杂运算或调用可能阻塞的函数。5.3 DMA模式大数据量传输的终极武器DMA直接存储器访问是解决高带宽、低CPU占用需求的终极方案。它允许外设如USART直接与内存交换数据无需CPU介入。发送DMA将待发送的数据数组地址和长度配置给DMA启动后DMA会自动将数据从内存搬运到USART的发送数据寄存器搬完后产生DMA传输完成中断通知CPU。接收DMA更常用。配置DMA从USART数据寄存器自动搬运到内存中的接收缓冲区。你可以设置DMA为循环模式Circular Mode这样缓冲区就像一个“滑窗”新数据会自动覆盖旧数据永不停止。你只需要在需要时去检查DMA当前搬运的位置CNDTR寄存器就能知道收到了多少新数据。// 使用标准库配置USART1的DMA接收循环模式 void USART1_DMA_Rx_Config(void) { DMA_InitTypeDef DMA_InitStructure; // 开启DMA时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA1通道5用于USART1_RX DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART1-DR); // 外设地址 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)USART1_RxBuffer; // 内存地址 DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 方向外设-内存 DMA_InitStructure.DMA_BufferSize RX_BUF_SIZE; // 缓冲区大小 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不递增 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 循环模式 DMA_InitStructure.DMA_Priority DMA_Priority_VeryHigh; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); // 使能USART1的DMA接收请求 USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); // 使能DMA通道 DMA_Cmd(DMA1_Channel5, ENABLE); } // 在主循环中如何获取DMA接收到的数据长度 uint16_t Get_USART1_DMA_Rx_DataCount(void) { // DMA当前剩余传输次数CNDTR从初始值递减 // 已接收数据长度 缓冲区大小 - 当前剩余次数 uint16_t remain_cnt DMA_GetCurrDataCounter(DMA1_Channel5); return RX_BUF_SIZE - remain_cnt; }优点CPU零开销处理数据搬运特别适合高速、连续的数据流如GPS模块持续输出、高速数据采集。在循环模式下无需担心缓冲区溢出旧数据被新数据覆盖。缺点配置相对复杂。对于不定长数据包判断一帧数据接收完成比较麻烦通常需要结合串口空闲中断IDLE来使用。6. 高级应用与实战避坑指南掌握了基础模式在实际项目中还会遇到更复杂的需求和问题。下面分享几个关键的高级技巧和常见“坑点”。6.1 串口空闲中断IDLE实现不定长数据帧接收这是STM32串口的一个非常实用的功能。当串口总线在一帧数据结束后保持高电平空闲状态超过一个字节的时间就会产生IDLE中断。利用这个中断可以完美地配合DMA来接收不定长数据包。操作步骤以USART1为例配置并使能DMA接收循环模式。开启串口空闲中断USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);在中断服务函数中判断是否是IDLE中断。如果是IDLE中断则计算本次接收到的数据长度缓冲区大小 - DMA_CNDTR然后处理这一帧数据。关键一步必须读一次USART_SR寄存器清除IDLE标志位再读一次USART_DR寄存器手册要求才能清除IDLE中断标志。volatile uint32_t temp USART1-SR; temp USART1-DR; (void)temp;这样只要一帧数据结束例如Modbus RTU协议中3.5个字符的静默时间程序就能立刻知道并处理效率极高。6.2 硬件流控制RTS/CTS的使用场景当通信双方速度不匹配或者处理数据需要时间时就需要流控制来防止数据丢失。硬件流控制利用额外的两根线RTS请求发送和CTS清除发送。RTS输出本设备准备好接收数据时拉低RTS信号告诉对方“可以发送”。CTS输入本设备在发送前会检查CTS引脚。如果对方拉低CTS表示对方准备好接收本设备才发送否则等待。在STM32上启用硬件流控制需要在初始化时配置USART_HardwareFlowControl USART_HardwareFlowControl_RTS_CTS并连接对应的引脚如USART1是PA12(CTS)和PA11(RTS)。注意很多USB转串口模块并不引出RTS/CTS引脚如果你在代码中开启了硬件流控制但硬件没接会导致通信一直处于“被流控”状态而无法收发数据。这是一个常见的排查点。6.3 低功耗模式下的串口唤醒在电池供电设备中STM32常进入低功耗的停止Stop模式。此时大部分时钟关闭如何通过串口来唤醒CPU呢USART支持在停止模式下通过RX引脚上的起始位Start Bit来唤醒MCU。配置要点进入停止模式前必须将USART配置为“静默模式”Mute mode或“低功耗模式”具体名称因库而异并开启“接收器唤醒”功能。在HAL库中对应HAL_UARTEx_EnableStopMode()函数。配置RX引脚为外部中断模式并设置唤醒事件。当RX引脚检测到起始位下降沿时会先唤醒USART的时钟USART接收完这个字节可能会是唤醒字符后再产生一个唤醒事件将整个系统从停止模式唤醒。这个过程对时序和配置要求严格需要仔细查阅参考手册的“低功耗模式与唤醒”章节并充分测试。6.4 多串口协同与优先级管理STM32F103ZET6有多个串口在复杂系统中可能同时工作。这时中断优先级NVIC的管理就很重要。原则是高实时性、高数据率的中断设置更高的抢占优先级。例如USART1用于调试打印偶尔发送USART2用于连接高速传感器持续接收。那么USART2的接收中断应该比USART1的中断拥有更高的抢占优先级确保传感器数据不被调试信息打断而丢失。同时在各自的中断服务函数中处理速度要快避免长时间占用中断导致其他中断被延迟响应。7. 调试技巧与常见问题排查即使理论都懂了实际调试时还是会遇到各种问题。这里有一个我常用的排查清单无任何数据输出检查硬件连接TX/RX是否交叉GND是否共地USB转串口模块的驱动是否安装好电脑端串口助手参数波特率、数据位、停止位、校验位是否与代码一致检查Boot引脚确认Boot0已接地0处于正常启动模式。检查时钟配置用示波器测量芯片OSC_IN/OUT引脚看外部晶振是否起振。检查代码中系统时钟和APB总线时钟是否配置正确。检查初始化顺序确保先开启GPIO和USART时钟再配置GPIO最后配置和使能USART。有些库要求严格顺序。输出乱码99%是波特率不匹配用示波器测量TX引脚波形计算实际波特率。一个字节8N1格式总共10位1起始8数据1停止。测量一个完整位的时间T波特率1/T。与代码设定值对比。检查时钟源精度如果使用HSI尝试换用HSE外部晶振。检查数据格式代码配置是8N1但串口助手设置成了8E1偶校验或其它格式。只能发送不能接收或接收数据不全检查中断配置NVIC是否使能中断服务函数名是否正确不能拼错在中断函数里是否清除了对应的中断标志RXNE或IDLE检查缓冲区溢出在中断接收模式中是否因为处理太慢导致环形缓冲区被写满新数据被丢弃可以增加缓冲区大小或在溢出时加入错误计数。检查DMA配置如果是DMA模式DMA通道、内存地址、传输方向、循环模式是否配置正确DMA是否已使能通信一段时间后死机或出错检查中断服务函数是否进行了耗时的操作是否调用了不可重入的函数中断嵌套是否导致栈溢出检查DMA与CPU访问冲突DMA正在向缓冲区写数据的同时主程序是否也在读如果是需要做好临界区保护暂时关闭中断。检查硬件长距离通信是否受到干扰考虑增加RS485差分传输或使用屏蔽线。调试串口一个逻辑分析仪或带协议分析功能的示波器是神器可以直观地看到总线上的每一个字节、每一个位以及精确的时序能快速定位是软件配置问题还是硬件信号完整性问题。最后关于代码风格我建议将串口操作封装成一个独立的模块如uart.c和uart.h提供初始化、发送字符串、发送格式化数据类似printf、注册接收回调函数等接口。这样主程序逻辑清晰也便于代码复用和移植。对于printf重定向可以通过重写_write或fputc函数将输出指向指定的USART这样就能方便地使用printf进行调试打印了但要注意printf函数本身比较耗时在实时性要求高的中断里避免使用。