恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
STM32F103串口通信实战:标准库UART配置与调试全解析
首页
资讯中心
/
STM32F103串口通信实战:标准库UART配置与调试全解析
STM32F103串口通信实战:标准库UART配置与调试全解析
发布时间:2026/9/16 1:16:54
简介面向嵌入式初学者与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因为PA9TX和PA10RX引脚直接引出到最小系统板的排针上。许多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说明PA9USART1_TXGPIO_Mode_AF_PP推挽复用输出PA10USART1_RXGPIO_Mode_IN_FLOATING浮空输入PB10USART3_TXGPIO_Mode_AF_PP推挽复用输出PB11USART3_RXGPIO_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和PA10GPIO_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.5Mbps50MHz完全满足。如果你后期在同一引脚复用其他功能比如定时器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挂在APB2APB2时钟也是72MHz计算得到BRR0x271程序可以打印或查看寄存器验证。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所以字符串发送函数最好在末尾补上等待TCvoid 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组。我们有下面这个对照表配置项USART1USART3总线APB2APB1TX/RX引脚PA9/PA10PB10/PB11RCC时钟组RCC_APB2Periph_USART1RCC_APB1Periph_USART3引脚时钟RCC_APB2Periph_GPIOARCC_APB2Periph_GPIOBTX GPIO模式AF_PPAF_PPRX GPIO模式IN_FLOATINGIN_FLOATINGDMA通道(发送)DMA1_Channel4DMA1_Channel2中断IRQnUSART1_IRQnUSART3_IRQn这里最常被忽视的是DMA通道。USART1_TX使用DMA1_Channel4USART3_TX使用DMA1_Channel2。如果按串口1的通道配置去驱动串口3DMA不会触发请求发送中断永远不进来。注意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置1BOOT1置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直接接GNDBOOT0用跳线帽这样大多数下载问题都能排除。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-DRDMA_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不可随意更改想换通道只能换串口。本文还有配套的精品资源点击获取