三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

STM32串口通信详解:从寄存器到printf重定向

STM32串口通信详解:从寄存器到printf重定向

串口是嵌入式开发里用得最多的通信方式——没有之一。
调试靠它打印log、和上位机通信靠它、连WiFi模块(ESP8266)也靠它。
这篇从UART协议原理讲到printf重定向,帮你彻底搞定串口通信。


一、为什么串口这么重要?

你做嵌入式开发,90%的调试都依赖串口。

程序跑没跑通?串口打印一下。
传感器读对没?串口打印一下。
哪里出了Bug?串口打印一下。

没有串口的嵌入式开发,就像闭着眼睛开车。

串口(UART)是最简单、最稳定、最通用的通信方式:

  • 只需要3根线:TX、RX、GND
  • 不需要时钟线(靠约定波特率)
  • 几乎所有MCU都有串口外设
  • 电脑用USB转TTL就能连

二、UART协议原理

2.1 UART是什么

UART(Universal Asynchronous Receiver/Transmitter),通用异步收发器。

关键词是异步——没有时钟线,收发双方靠约定相同的波特率来对齐数据。

2.2 通信连接

STM32 USB转TTL模块 电脑 ┌──┐ ┌──┐ ┌──┐ │ │ TX ──────► RX │ │ │ │ │ │ │ USB │ │ │ │ RX ◄────── TX │◄─────────►│ │ │ │ │ │ │ │ │ │ GND ────── GND│ │ │ └──┘ └──┘ └──┘

注意:TX接RX,RX接TX,交叉连接。GND必须共地。

2.3 数据帧格式

一个UART数据帧的组成:

空闲 起始位 数据位(8位) 校验 停止位 ‾‾‾‾‾ ‾ D0 D1...D7 P ‾‾ ┌──┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌──┐ ‾‾‾‾‾‾‘ └‘ └‘ └─┘ └─┘ ... └─┘ └─┘ └ └‾‾‾‾ 起始 D0 D1 D7 P 停止 (低) (高)
组成部分说明
空闲状态总线保持高电平
起始位1位低电平,标志数据开始
数据位5-9位,通常用8位
校验位奇校验/偶校验/无校验,通常用无
停止位1或2位高电平,通常用1位

最常用配置:8位数据位 + 无校验 + 1位停止位,简称"8N1"。

2.4 波特率

因为UART是异步通信,收发双方必须用相同波特率,否则数据会乱码。

常用波特率:9600、115200(最常用)、460800、921600。

波特率误差不能超过±2%,否则采样错位,数据全乱。这也是为什么高波特率建议用外部晶振而不是内部RC振荡器。


三、STM32串口配置(三种方式)

USART1(PA9-TX, PA10-RX),115200波特率,8N1为例。

3.1 寄存器版

voiduart1_init(void){/* 1. 开时钟:USART1和GPIOA都在APB2 */RCC->APB2ENR|=(1<<2);/* GPIOA时钟 */RCC->APB2ENR|=(1<<14);/* USART1时钟 *//* 2. 配置PA9(TX)为复用推挽输出,PA10(RX)为浮空输入 */GPIOA->CRH&=~(0xFF<<4);/* 清零PA9、PA10配置 */GPIOA->CRH|=(0x0B<<4);/* PA9: 复用推挽输出,50MHz */GPIOA->CRH|=(0x04<<8);/* PA10: 浮空输入 *//* 3. 配置波特率115200,系统时钟72MHz *//* BRR = 72MHz / 115200 = 625 = 0x271 */USART1->BRR=625;/* 4. 使能发送和接收 */USART1->CR1|=(1<<3);/* TE: 发送使能 */USART1->CR1|=(1<<2);/* RE: 接收使能 */USART1->CR1|=(1<<13);/* UE: USART使能 */}/* 发送一个字节 */voiduart_send_byte(uint8_tdata){while(!(USART1->SR&(1<<7)));/* 等待发送数据寄存器空(TXE) */USART1->DR=data;}/* 接收一个字节 */uint8_tuart_recv_byte(void){while(!(USART1->SR&(1<<5)));/* 等待读数据寄存器非空(RXNE) */returnUSART1->DR;}

3.2 标准库版

voiduart1_init(void){GPIO_InitTypeDef GPIO_InitStruct;USART_InitTypeDef USART_InitStruct;/* 1. 开时钟 */RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1|RCC_APB2Periph_GPIOA,ENABLE);/* 2. 配置引脚 */GPIO_InitStruct.GPIO_Pin=GPIO_Pin_9;/* PA9 TX */GPIO_InitStruct.GPIO_Mode=GPIO_Mode_AF_PP;/* 复用推挽输出 */GPIO_InitStruct.GPIO_Speed=GPIO_Speed_50MHz;GPIO_Init(GPIOA,&GPIO_InitStruct);GPIO_InitStruct.GPIO_Pin=GPIO_Pin_10;/* PA10 RX */GPIO_InitStruct.GPIO_Mode=GPIO_Mode_IN_FLOATING;/* 浮空输入 */GPIO_Init(GPIOA,&GPIO_InitStruct);/* 3. 配置USART */USART_InitStruct.USART_BaudRate=115200;USART_InitStruct.USART_WordLength=USART_WordLength_8b;USART_InitStruct.USART_StopBits=USART_StopBits_1;USART_InitStruct.USART_Parity=USART_Parity_No;USART_InitStruct.USART_HardwareFlowControl=USART_HardwareFlowControl_None;USART_InitStruct.USART_Mode=USART_Mode_Tx|USART_Mode_Rx;USART_Init(USART1,&USART_InitStruct);/* 4. 使能 */USART_Cmd(USART1,ENABLE);}/* 发送一个字节 */voiduart_send_byte(uint8_tdata){USART_SendData(USART1,data);while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET);}

3.3 HAL库版(CubeMX生成)

CubeMX配置USART1后,自动生成MX_USART1_UART_Init(),你只需调用:

/* 发送一个字节 */HAL_UART_Transmit(&huart1,&data,1,100);/* 发送字符串 */HAL_UART_Transmit(&huart1,(uint8_t*)"Hello\r\n",7,100);/* 接收一个字节 */HAL_UART_Receive(&huart1,&data,1,100);

三种方式对比和点灯那篇类似:寄存器底层、标准库过渡、HAL库主流。日常开发用HAL库,理解原理看寄存器。


四、发送字符串和数字

光会发一个字节不够,实际开发要发字符串、发数字。

4.1 发送字符串

voiduart_send_string(UART_HandleTypeDef*huart,constchar*str){while(*str){HAL_UART_Transmit(huart,(uint8_t*)str,1,100);str++;}}/* 使用 */uart_send_string(&huart1,"Hello Embedded!\r\n");

4.2 发送数字

voiduart_send_number(UART_HandleTypeDef*huart,intnum){charbuf[16];sprintf(buf,"%d\r\n",num);uart_send_string(huart,buf);}/* 使用 */uart_send_number(&huart1,12345);

这种方式能用,但每次都要手动调函数,不够方便。有没有更优雅的方式?有——printf重定向


五、printf重定向(本文重点)

5.1 为什么要重定向printf?

在电脑上写C程序,printf会输出到屏幕。但STM32没有屏幕,printf默认不知道往哪输出。

重定向就是告诉printf:“你不用往屏幕输出了,往串口输出吧。”

重定向后,你就可以这样写:

printf("温度: %.1f°C, 湿度: %.1f%%\r\n",temp,humi);intcount=10;printf("计数: %d\r\n",count);

比手动调uart_send_string方便100倍。

5.2 重定向实现(HAL库版)

只需重写fputc函数:

#include<stdio.h>/* 必须包含 */externUART_HandleTypeDef huart1;/* 你的USART1句柄 *//* 重定向fputc到USART1 */intfputc(intch,FILE*f){HAL_UART_Transmit(&huart1,(uint8_t*)&ch,1,100);returnch;}

5.3 Keil中必须勾选微库

在Keil里使用printf,必须勾选Use MicroLIB,否则会卡死或链接报错:

Keil设置步骤: Options for Target → Target标签页 → 勾选 "Use MicroLIB"

什么是半主机模式?半主机模式是ARM的一种调试机制,标准C库的printf会把输出通过调试器(J-Link/ST-Link)转发到电脑的调试窗口,而不是通过串口。但脱离调试器后,程序会卡在等待调试器响应的BKPT指令上,导致死机。

MicroLIB是Keil提供的精简版C库,去掉了半主机模式依赖,printf直接走我们重定向的fputc函数(通过串口输出),体积也小很多。所以勾选MicroLIB就解决了半主机模式卡死的问题。

5.4 标准库版重定向

如果你用标准库(SPL),重定向代码略有不同:

#include<stdio.h>intfputc(intch,FILE*f){while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)==RESET);USART_SendData(USART1,(uint8_t)ch);returnch;}

5.5 printf的坑:浮点数不显示

STM32用MicroLIB时,printf默认不支持%f浮点输出printf("%.1f", 3.14)会打印出%f或空。

原因:MicroLIB为了省体积,去掉了浮点格式化功能。这不是FPU(硬件浮点单元)的问题,是C库实现的问题。

解决方法1:Keil不勾选MicroLIB,改用标准库

Options for Target → Target标签页 → 取消勾选 "Use MicroLIB"

但标准库需要额外处理半主机模式(加以下代码):

#pragmaimport(__use_no_semihosting)/* 禁用半主机模式 */struct__FILE{inthandle;};FILE __stdout;void_sys_exit(intx){x=x;}/* 半主机模式需要的空实现 */

解决方法2:用sprintf转换再发(最简单,推荐新手)

charbuf[32];sprintf(buf,"温度: %.2f°C\r\n",temp);/* sprintf在MicroLIB下支持%f */uart_send_string(&huart1,buf);

注意:标准库的sprintf也支持%f,MicroLIB的sprintf也支持%f,只有MicroLIB的printf不支持。所以用sprintf转成字符串再用printf发字符串是最省心的方案。


六、中断接收(实用进阶)

上面都是发送,实际项目里接收用中断更高效(不用死等)。

6.1 为什么用中断?

【轮询方式】主循环里一直查"有没有数据?" → 浪费CPU 【中断方式】来数据了MCU自动跳到中断函数处理 → CPU该干嘛干嘛

6.2 HAL库中断接收实现

第1步:CubeMX里开启USART1全局中断

NVIC Settings → USART1 global interrupt → Enable

第2步:启动中断接收

uint8_trx_buf[1];/* 在main函数里启动中断接收 */HAL_UART_Receive_IT(&huart1,rx_buf,1);

第3步:重写接收完成回调函数

/* HAL库会自动调用这个函数(名字不能改) */voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart->Instance==USART1){/* 收到一个字节,存入缓冲区 */printf("收到: 0x%02X\r\n",rx_buf[0]);/* 重新启动下一次接收(必须!) */HAL_UART_Receive_IT(&huart1,rx_buf,1);}}

注意:HAL库的中断接收是"一次性的",收到1个字节就停。必须在回调函数里重新调用HAL_UART_Receive_IT,否则只能收一次。

6.3 环形缓冲区(进阶)

实际项目里收到的数据不止1字节(比如ESP8266的AT响应可能50字节),需要用环形缓冲区缓存数据。

为什么不用普通数组?因为串口数据是持续流入的,普通数组写到末尾就满了。环形缓冲区通过"写指针head和读指针tail在环上追逐"的方式,让旧数据被自动覆盖,实现无限循环接收。就像一个圆形跑道,head在前面跑(写入),tail在后面追(读出),到末尾了绕回开头继续。

#defineBUF_SIZE256uint8_tring_buf[BUF_SIZE];volatileuint16_tbuf_head=0;/* 写位置(中断里写入) */volatileuint16_tbuf_tail=0;/* 读位置(主程序读出) */voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart->Instance==USART1){ring_buf[buf_head]=rx_buf[0];/* 存入环形缓冲 */buf_head=(buf_head+1)%BUF_SIZE;HAL_UART_Receive_IT(&huart1,rx_buf,1);/* 重新启动接收 */}}/* 从环形缓冲区读一个字节 */int8_tring_buf_read(uint8_t*data){if(buf_head==buf_tail)return-1;/* 空 */*data=ring_buf[buf_tail];buf_tail=(buf_tail+1)%BUF_SIZE;return0;}

环形缓冲区是嵌入式面试常考点,原理:head追tail,用取模实现循环。ESP8266、GPS模块这种"不定长数据流"接收都用它。


七、常见踩坑

坑1:串口助手收到乱码

现象:串口助手显示一堆乱码或问号。

排查顺序

  1. 波特率对不对:代码115200,助手也必须115200
  2. GND接没接:TX/RX接了但GND没共地,必乱码
  3. TX/RX接反:STM32的TX要接TTL模块的RX
  4. 时钟配错:系统时钟72MHz配成了8MHz,波特率计算就错了

最常见的坑:系统时钟没配,默认8MHz跑,但波特率按72MHz算,差9倍,必乱码。


坑2:printf无输出

现象:调了printf但串口助手什么都收不到。

排查

  1. 勾选MicroLIB了吗?(Keil最容易漏的)
  2. fputc函数写了吗?只include了stdio.h不够
  3. USART句柄对不对&huart1别写成&huart2
  4. 先用HAL_UART_Transmit测试:如果这个能发但printf不能,说明是重定向问题

坑3:接收中断只触发一次

现象:第一次收数据正常,后面就没反应了。

原因:HAL库的HAL_UART_Receive_IT是"一次性"的,回调函数里忘了重新启动。

解决:在HAL_UART_RxCpltCallback里必须再次调用:

HAL_UART_Receive_IT(&huart1,rx_buf,1);/* 重新启动! */

坑4:高波特率(921600)出错

现象:115200正常,换921600就丢字节。

原因

  1. 内部RC振荡器精度不够(±3%),高波特率误差超2%
  2. 中断处理太慢,来不及处理上一个字节

解决

  1. 换外部晶振(8MHz或16MHz)
  2. 高波特率改用DMA接收,不依赖中断

八、完整示例:串口收发回显

把上面学的串起来,做一个"收到什么就回发什么"的回显程序:

#include"main.h"#include<stdio.h>UART_HandleTypeDef huart1;uint8_trx_buf[1];intfputc(intch,FILE*f){HAL_UART_Transmit(&huart1,(uint8_t*)&ch,1,100);returnch;}voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart->Instance==USART1){/* 回显收到的字节 */HAL_UART_Transmit(&huart1,rx_buf,1,100);/* 重新启动接收 */HAL_UART_Receive_IT(&huart1,rx_buf,1);}}intmain(void){HAL_Init();SystemClock_Config();MX_GPIO_Init();MX_USART1_UART_Init();printf("=== STM32 UART Echo Demo ===\r\n");printf("输入任意字符,将原样返回\r\n");HAL_UART_Receive_IT(&huart1,rx_buf,1);/* 启动中断接收 */while(1){/* 主循环空闲,可以做其他事 */HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);HAL_Delay(500);}}

这个例程包含了:串口初始化 + printf重定向 + 中断接收 + 回显。理解透这个,串口通信就入门了。


九、总结

要点内容
UART核心异步通信,约定波特率,3线(TX/RX/GND)
数据帧起始位 + 8数据位 + 停止位(8N1最常用)
三种开发方式寄存器(底层)/ 标准库(过渡)/ HAL库(主流)
printf重定向重写fputc + 勾选MicroLIB
中断接收HAL_UART_Receive_IT + 回调里重新启动
常见坑波特率、GND、MicroLIB、中断重启

一句话总结:串口是嵌入式调试的命脉,printf重定向 + 中断接收这两招学会,调试效率翻10倍。


下一篇预告:《STM32定时器详解:从延时到PWM输出》

如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。

作者:嵌入式阿蔡

← 返回列表