简介:面向嵌入式初学者与STM32开发者,这套实验工程以STM32F103C8T6为核心,基于标准外设库演示UART串口通信的完整实现流程。工程通过串口输入1、2、3任意数字,分别输出不同内容,可直观观察字符接收、分支判断与回显发送的处理过程,适合从固件库角度理解串口外设的初始化及中断数据收发方式。资源包共175个文件、约5.78MB,主体为C源码、头文件、汇编启动文件及Keil工程配置,同时带有Hex烧录文件、Map映射表、列表文件和调试日志等,既能直接编译烧录,也方便对照中间产物排查问题。整套资料结构清晰,作者MQ0522将项目文件与编译输出一并整理,读者可以边改边验证,逐步掌握串口初始化、串口中断、字符解析和发送响应的编码技巧,为后续实现更复杂的通信协议或调试功能打好基础。目前该实验已有2645人学习下载,适合入门阶段的STM32开发者按需取用。
1. 串口通信实验,先把UART搞明白
STM32F103的UART串口实验往往不是第一个程序,但却是第一个能让你“看见”数据的程序。C8T6最小系统板上电,PA9/PA10接USB转串口,发送1、2、3得到不同反馈,这个实验把GPIO复用、时钟、中断和波特率都串了起来。对新手来说,它比点灯更有实际意义;对老手来说,标准库的寄存器操作和中断优先级设计也能看出工程素养。下面的内容从硬件最小系统到代码实现,再到串口1与串口3的差异,给出可以直接编译的工程和调试时真正会遇到的坑。
2. 标准库工程下的USART外设与引脚配置
2.1 UART协议与C8T6的引脚映射
UART协议全称通用异步收发器,一帧数据由起始位、数据位、可选校验位和停止位组成。STM32F103C8T6内部集成3个USART,其中USART1挂在APB2总线,USART2和USART3挂在APB1总线。实际实验多用USART1,因为PA9(TX)和PA10(RX)引脚直接引出到最小系统板的排针上。许多stm32f103最小系统板同时把PA9/PA10连到ST-Link的虚拟串口,这意味着不需要额外USB转TTL模块,直接用ST-Link的串口通道就能看到打印信息。
注意:USART1的TX引脚必须配置为推挽复用输出,RX引脚配置为浮空输入或上拉输入。不要直接使用GPIO_Mode_Out_PP,否则发送时引脚波形不正确。
2.1.1 引脚复用模式与标准库结构体
标准库中GPIO_InitTypeDef结构体的GPIO_Mode决定了引脚工作模式。对于串口功能,我们使用以下配置表:
| 引脚 | 复用功能 | GPIO_Mode | 说明 |
|---|---|---|---|
| PA9 | USART1_TX | GPIO_Mode_AF_PP | 推挽复用输出 |
| PA10 | USART1_RX | GPIO_Mode_IN_FLOATING | 浮空输入 |
| PB10 | USART3_TX | GPIO_Mode_AF_PP | 推挽复用输出 |
| PB11 | USART3_RX | GPIO_Mode_IN_FLOATING | 浮空输入 |
PA9作为TX,推挽输出能够提供足够的驱动能力。PA10作为RX,浮空输入意味着引脚电平完全由外部决定,如果外部没有拉高,空闲状态下可能检测到噪声。很多工程师习惯在硬件上加10k上拉电阻,或者用GPIO_Mode_IPU设置为内部上拉。但标准库默认示例通常使用浮空输入,方便配合不同电平的外设。
2.2 时钟树开启顺序与stm32标准库新建工程的时钟配置
新建标准库工程时,会复制一份stm32f10x_rcc.c 和 stm32f10x_usart.c 进工程。但很多人没有真正理解RCC是做什么的。STM32外设使用前必须打开对应总线上的时钟,否则寄存器写入无效。
对于USART1,它位于APB2总线,同时需要操作GPIOA,所以配置代码如下:
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);这句话同时打开GPIOA和USART1的时钟。逻辑说明:RCC_APB2PeriphClockCmd函数将指定的外设时钟使能位置1,参数是APB2总线上的外设位掩码。有时我们看到别人只写RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE),那么USART1寄存器不可用,调试时先卡死在USART_Init。
如果改用USART3,时钟配置变为:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);USART3在APB1上,GPIOB在APB2上,必须分两条指令开启。经常有人在APB1上找不到GPIOB,因为GPIOB永远挂在APB2。这是stm32f103串口1和串口3使用差异中最容易踩的一个点。
2.2.1 GPIO参数细节
配置PA9和PA10:
GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure);代码说明:GPIO_Speed在STM32标准库中只对输出模式有意义,RX配置浮空输入时该字段不生效,但为了结构体完整,通常先填充了一次。将TX和RX的初始化放在同一个结构体变量中连续执行,可以减少临时变量占用。参数说明:GPIO_Speed_50MHz表示输出引脚的最大翻转速率,USART1的最高波特率是4.5Mbps,50MHz完全满足。如果你后期在同一引脚复用其他功能,比如定时器PWM,需要重新考虑速度。
2.3 USART_Init参数与波特率计算
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_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE);USART_BaudRate是期望波特率,库函数会根据外设时钟自动计算USART_BRR寄存器值。比如在72MHz系统时钟下,USART1挂在APB2,APB2时钟也是72MHz,计算得到BRR=0x271,程序可以打印或查看寄存器验证。
8位数据无校验和1位停止位是UART通信协议最常用的配置。如果使用9位数据位(如用于多机通信的地址/数据标志),需要把USART_WordLength改为9b,并配置USART_Parity为有校验。此时数据帧中会包含校验位,实际有效数据仍然是8位。实验里的1、2、3是ASCII字符,用8位无校验即可。
HardwareFlowControl设置为None表示不启用硬件流控。如果启用,需要额外占用CTS/RTS引脚,并且发送端必须等待对端拉高信号。C8T6最小系统板上一般不引出这些引脚,使用None最稳妥。
注意:USART_Cmd(USART1, ENABLE)必须在所有配置完成后才执行。很多人把使能放在USART_Init之前,导致USART_Init内部操作寄存器时外设还处于关闭状态,部分设置不会立即生效。标准库这样设计是因为某些外设的位域需要在外设禁用状态下修改。
3. 串口接收中断与命令分发的实战写法
3.1 接收寄存器与标志位行为
USART接收数据时,硬件将收到的字节放入USART_DR寄存器,同时置位RXNE标志。程序读取DR数据后,该标志自动清零。标准库中USART_ReceiveData(USART1)返回的是DR寄存器的低8位,该函数内会读取DR,因此可以清除RXNE。
实验要求根据接收到的字符输出不同内容。最直接的方式是在中断服务函数中判断:
void USART1_IRQHandler(void) { uint8_t ch; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { ch = USART_ReceiveData(USART1); if (ch == '1') { UART_SendString(USART1, "You pressed 1\r\n"); } else if (ch == '2') { UART_SendString(USART1, "You pressed 2\r\n"); } else if (ch == '3') { UART_SendString(USART1, "You pressed 3\r\n"); } } }逻辑说明:进入中断后必须先判断中断标志是否有RXNE,防止其它USART中断(比如TC)触发后误进入该分支。读取USART_ReceiveData会清除标志,所以不需要手动调用清除函数。UART_SendString是自定义的字符串发送函数,在下一节给出实现。
使用单字符判断比strcmp更高效。如果接收的是多个字符组成的字符串,比如从ModbusPoll软件写入的传感器寄存器地址,就需要结合stm32f103标准库中的strcmp函数,但要确保缓冲区以'\0'结尾,否则会越界。字符串比较在中断中使用时要格外小心,类似strcmp(buffer, "1")这种写法在嵌入式里并不推荐,因为strlen和循环指令会拉长中断执行时间。
3.2 发送函数与TXE、TC标志
标准库中发送一个字节通常这样写:
| 标志位 | 触发条件 | 操作 |
|---|---|---|
| RXNE | 收到一个字节且数据寄存器未读 | 读取USART_DR自动清零 |
| TXE | 数据寄存器空,可以写入新字节 | 写入USART_DR自动清零 |
| TC | 移位寄存器完成最后一位发送 | 读USART_SR再写USART_DR清除 |
TXE和TC是串口发送中最重要的两个标志。TXE表示数据从软件写入到硬件缓存,但硬件还没有完全发出。TC表示移位寄存器已经发送完最后一位。在串口关闭或MCU进入低功耗模式前,必须等待TC,否则最后一个字节会被截断。
发送一个字节的函数:
void USART_SendChar(USART_TypeDef* USARTx, uint8_t ch) { while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) == RESET); USART_SendData(USARTx, ch); }这个函数等待TXE置位后写入数据。如果连续发送大量数据,最后一次发送后需要等待TC,所以字符串发送函数最好在末尾补上等待TC:
void UART_SendString(USART_TypeDef* USARTx, char* str) { while (*str) { USART_SendChar(USARTx, (uint8_t)*str++); } while (USART_GetFlagStatus(USARTx, USART_FLAG_TC) == RESET); }参数说明:USARTx可以是USART1或USART3,函数内部通过指针访问不同外设寄存器,标准库将外设基地址封装成结构体指针,所以通用性很好。str是C字符串,必须用'\0'结尾,否则循环不会停止。
3.3 NVIC中断优先级配置与常见误区
要使能USART1接收中断,需要配置NVIC和USART的中断源:
NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);NVIC_PriorityGroupConfig在整个工程中只需调用一次,通常放在main函数开头。如果多个外设使用中断,需要规划抢占优先级和子优先级。USART1的中断优先级如果比定时器高,那么定时器回调可能被串口打断,导致采样时间抖动。
有初学者在USART1_IRQHandler中调用delay函数,其中等待SysTick中断,如果SysTick优先级比USART低,会造成死锁。建议中断服务函数中不要做延时,而是把接收到的字符存入循环缓冲区,主循环再解析。
3.3.1 简易环形缓冲区设计
为了避免实验只接收一个字符,可以加入环形缓冲区:
#define RX_BUF_SIZE 16 volatile uint8_t rx_buf[RX_BUF_SIZE]; volatile uint8_t rx_head = 0, rx_tail = 0;中断中写入:
uint8_t ch = USART_ReceiveData(USART1); uint8_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_buf[rx_head] = ch; rx_head = next; }主循环中读取并判断:
while (rx_tail != rx_head) { uint8_t cmd = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; // 根据cmd分发 }这个模式在很多协议栈中都能看到,比如ModbusPoll上位机软件发送的Modbus帧,也是逐字节进入缓冲区后解析。实验中的1、2、3是单字节命令,用该缓冲区可以避免中断中做耗时打印。
4. 串口1与串口3的使用差异及启动文件选择
4.1 外设时钟总线和引脚复用差异
串口1和串口3在标准库开发中的差异不只在引脚号上。USART1挂在APB2,最高支持72MHz时钟;USART3挂在APB1,最高36MHz。SystemInit后APB1的时钟等于系统时钟除以2,也就是36MHz。虽然两者都能生成115200的波特率,但USART_BRR的量化精度不同。在48MHz系统时钟这种非整数倍情况下,串口3的波特率误差可能比串口1更大。
从代码上看,串口3的RCC使能要放在APB1组,GPIOB的RCC使能还是放在APB2组。我们有下面这个对照表:
| 配置项 | USART1 | USART3 |
|---|---|---|
| 总线 | APB2 | APB1 |
| TX/RX引脚 | PA9/PA10 | PB10/PB11 |
| RCC时钟组 | RCC_APB2Periph_USART1 | RCC_APB1Periph_USART3 |
| 引脚时钟 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB |
| TX GPIO模式 | AF_PP | AF_PP |
| RX GPIO模式 | IN_FLOATING | IN_FLOATING |
| DMA通道(发送) | DMA1_Channel4 | DMA1_Channel2 |
| 中断IRQn | USART1_IRQn | USART3_IRQn |
这里最常被忽视的是DMA通道。USART1_TX使用DMA1_Channel4,USART3_TX使用DMA1_Channel2。如果按串口1的通道配置去驱动串口3,DMA不会触发请求,发送中断永远不进来。
注意:USART1和USART3的RX引脚输入模式都是浮空。如果外部RS485芯片控制方向,TX和RX可能复用同一对引脚,需要额外用GPIO控制DE/RE引脚。stm32f103基于cubemx hal库的485收发程序很多,但标准库下同样需要把DE/RE引脚当作普通GPIO操作,在发送前拉高,发送后拉低。
4.1.1 串口3初始化样例
下面是标准库下配置串口3的最小代码段:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, &GPIO_InitStructure);这段代码与串口1的差异一目了然。GPIOB的引脚编号和RCC寄存器不同,但USART_Init结构体完全相同。关键是USART_Init函数会根据传入的USART3选择内部对应的总线时钟频率,而该频率来自RCC_GetClocksFreq,因此必须先使能USART3的时钟,否则读取到0会导致波特率计算异常。
4.2 外部晶振频率对串口波特率的影响
stm32f103标准库默认HSE_VALUE是8MHz。SystemInit会根据HSE_VALUE和RCC_CFGR的PLL倍频系数把系统时钟设为72MHz。如果你的最小系统板上焊的是12MHz晶振,而没有修改stm32f10x.h中的HSE_VALUE和PLL设置,那么APB2时钟变成108MHz甚至超出规格,串口输出乱码。
在调试Uart实验中,如果PC端收到的全是乱码,先检查这一点:
#define HSE_VALUE ((uint32_t)8000000)将宏改为实际晶振频率,再确认RCC_CFGR中的PLLMULL为9倍,得到72MHz。使用8MHz晶振时,HSE_VALUE不变,但PLL倍频系数应为9;使用12MHz晶振时,倍频系数要调整为6,凑成72MHz。
注意:某些串口下载器(如FT232R、CP2104)驱动异常也会导致乱码,但这类问题通常表现为完全无数据或一打开串口就报错,不会出现规律性乱码。如果字符大部分正确,偶尔错一两个,说明波特率偏移超出容忍范围。
4.3 Boot0和Boot1对串口下载与调试的影响
实验过程中经常遇到下载失败。用UART1下载程序时需要把BOOT0置1,BOOT1置0,让芯片进入系统存储器启动。此时内置bootloader接管USART1。下载完成后把BOOT0跳回0,按复位键运行用户程序。
如果使用ST-LINK/J-Link下载,BOOT0不需要置1,但前提是程序没有关闭SWD引脚。有些例程为了省电,把PA13/PA14复用为普通GPIO,会导致下次无法用DAP连接。解决办法是:按住复位键不放,连接调试器,配置keil在连接前复位,然后擦除整个Flash。这个操作里BOOT0和BOOT1的跳线也要确认处在用户Flash模式。
提示:stm32f103 dap下载失败 boot1这个热搜词的场景,多半是跳线帽插错或者BOOT1悬空导致电平不确定。最简单的处理方式是BOOT1直接接GND,BOOT0用跳线帽,这样大多数下载问题都能排除。
5. 进阶:printf重定向与DMA串口发送
5.1 通过fputc重定向printf到UART
标准库开发中打印调试信息最方便的方法就是重定向fputc。在MDK工程中添加:
#include <stdio.h> int fputc(int ch, FILE *f) { USART_SendChar(USART1, (uint8_t)ch); return ch; }同时在工程设置中勾选Use MicroLIB。如果不勾选,printf内部的文件系统接口会走半主机模式,在嵌入式环境里会卡死。这里有一个技巧:如果发送字符串长度在几十字节内,只要等待TXE标志,不需要等TC。这样fputc返回很快,但最后几个字节仍在移位寄存器中。如果你接下来要操作串口1的DMA或直接修改USART外设,需要先调用:
while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);实际上,在标准库中USART_SendChar内部等待TXE,这已经能保证数据寄存器空,但不能保证发送移位完成。所以临界操作前等待TC是防止丢最后一个字符的标准做法。
5.2 DMA单次发送模式
高频打印或者通过串口发送波形数据时,轮询发送会占用大量CPU时间。stm32f103标准库可以使用DMA1通道4为USART1发送提供搬运。
DMA_InitTypeDef DMA_InitStructure; static uint8_t tx_buf[128]; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)tx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 128; 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_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel4, &DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); DMA_Cmd(DMA1_Channel4, ENABLE);逻辑说明:DMA_PeripheralBaseAddr指向USART1->DR,DMA_DIR_PeripheralDST表示数据由内存搬到外设。MemoryInc使能后,DMA每传输一个字节自动将内存地址加1。DMA_Mode_Normal表示传输完指定数量字节后停止,不再自动重装。
使用时每次要发送新数据,先关闭DMA,更新DMA_MemoryBaseAddr和DMA_BufferSize,重新使能。比如:
void UART_DMA_Send(uint8_t* data, uint16_t len) { DMA_Cmd(DMA1_Channel4, DISABLE); DMA1_Channel4->CMAR = (uint32_t)data; DMA1_Channel4->CNDTR = len; DMA_Cmd(DMA1_Channel4, ENABLE); }注意不要在DMA还在传输时修改内存数组,否则数据会被部分覆盖。
5.3 检查发送完成的两种方式
DMA传输可以在中断里等待DMA1_Channel4_IRQn触发,也可以在主循环中轮询DMA_GetFlagStatus(DMA1_FLAG_TC4)。为了实验简单,我们可以在UART_DMA_Send后轮询:
while (DMA_GetFlagStatus(DMA1_FLAG_TC4) == RESET); DMA_ClearFlag(DMA1_FLAG_TC4);TC4标志表示DMA通道4传输完成,但并不代表USART移位寄存器已经发送完成。如果接下来要关闭USART或进入低功耗,还需要等待USART_FLAG_TC。这两个标志对应不同阶段,很多人混淆。
验证完整效果时,用逻辑分析仪观察PA9引脚,在115200波特率下每个bit约8.68us,发送字符'1'即0x31,起始位低电平,然后LSB起始数据01000110,停止位高电平。如果你看到波形只对了一半,检查DMA_MemoryInc是否设置为Enable,如果忘记设置,每个字节都从同一个内存地址读取,波形会单调重复。
提示:stm32f103多路捕获实验和ADC采样实验中常需要同时采集多路模拟量,此时串口DMA发送就可以把CPU从逐字节搬移中解放出来。标准库下USART1_DMA通道固定为DMA1_Channel4,不可随意更改,想换通道只能换串口。
本文还有配套的精品资源,点击获取