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的引脚,通常是PA9(TX)和PA10(RX)。新手最容易犯的第一个错误就是直接把这俩引脚用杜邦线连到USB转TTL模块上,然后发现电脑端一片寂静或者全是乱码。别急,我们先从硬件原理上把几个关键点搞清楚。
2.1 电平标准:TTL vs RS232 vs RS485
这是第一个容易混淆的概念。STM32芯片引脚输出的信号是TTL电平:高电平接近3.3V(VDD),低电平接近0V。我们常用的USB转串口模块(比如CH340、CP2102、FT232)核心也是TTL电平。所以,STM32的TX应接模块的RX,RX接模块的TX,并且共地(GND),这是最基础的连接。共地至关重要,它确保了双方有相同的电压参考点,否则信号无法正确识别。
而老式电脑上的9针串口(COM口)使用的是RS232电平,它是负逻辑,电压范围在±3V到±15V之间。因此,直接连接STM32和电脑COM口会烧毁芯片!必须通过MAX232这类电平转换芯片。RS485则是另一种差分信号标准,抗干扰能力强,用于长距离通信,需要用到MAX485等收发器。对于STM32F103ZET6,我们绝大部分场景是TTL电平直接对接各种模块,务必先确认对方模块的电平标准。
2.2 启动配置:Boot0与Boot1引脚的影响
这是一个经典的“坑”。STM32F103ZET6的USART1(PA9/PA10)有一个特殊功能:系统启动时,如果Boot0=1, Boot1=0,芯片会进入串口ISP编程模式。此时,PA9和PA10被复用为下载接口,你的用户程序是无法正常使用这两个引脚进行通信的。很多人在调试时,Boot跳线帽没插回正常模式(Boot0=0),导致程序运行了,但串口死活没数据。所以,检查硬件的第一步,永远是确认Boot引脚状态。
2.3 外设引脚映射与重映射
STM32的引脚功能非常灵活。以USART1为例,它的默认引脚确实是PA9和PA10。但STM32支持“重映射”功能。例如,USART1可以完全重映射到PB6(TX)和PB7(RX)。当你发现PA9、PA10被其他功能(比如定时器、CAN)占用时,重映射就派上用场了。在标准外设库(SPL)中,你需要开启AFIO时钟,并调用GPIO_PinRemapConfig函数。在HAL库中,则在CubeMX里直接勾选“Remap”选项即可。记住,重映射不是随便映射,必须查阅芯片数据手册的“Alternate function mapping”表格,那里定义了所有合法的映射关系。
3. 时钟树配置:串口稳定工作的基石
STM32的任何一个外设要工作,都必须有时钟驱动。串口通信的稳定性,尤其是波特率的精确性,几乎完全取决于时钟树的配置。很多“乱码”问题,根源就在这里。
3.1 系统时钟源(SYSCLK)的选择与分频
STM32F103ZET6的时钟源可以选择内部高速RC振荡器(HSI,8MHz)或外部高速晶振(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”中,开启HSE(Crystal/Ceramic Resonator)。然后在“Clock Configuration”标签页,将HSE选为PLL源,并配置PLL倍频,使系统时钟达到72MHz,APB2 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函数本身比较耗时,在实时性要求高的中断里避免使用。