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 寄存器版
void uart1_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使能 */
}
/* 发送一个字节 */
void uart_send_byte(uint8_t data)
{
while (!(USART1->SR & (1 << 7))); /* 等待发送数据寄存器空(TXE) */
USART1->DR = data;
}
/* 接收一个字节 */
uint8_t uart_recv_byte(void)
{
while (!(USART1->SR & (1 << 5))); /* 等待读数据寄存器非空(RXNE) */
return USART1->DR;
}
3.2 标准库版
void uart1_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);
}
/* 发送一个字节 */
void uart_send_byte(uint8_t data)
{
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 发送字符串
void uart_send_string(UART_HandleTypeDef *huart, const char *str)
{
while (*str) {
HAL_UART_Transmit(huart, (uint8_t *)str, 1, 100);
str++;
}
}
/* 使用 */
uart_send_string(&huart1, "Hello Embedded!\r\n");
4.2 发送数字
void uart_send_number(UART_HandleTypeDef *huart, int num)
{
char buf[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);
int count = 10;
printf("计数: %d\r\n", count);
比手动调uart_send_string方便100倍。
5.2 重定向实现(HAL库版)
只需重写fputc函数:
#include <stdio.h> /* 必须包含 */
extern UART_HandleTypeDef huart1; /* 你的USART1句柄 */
/* 重定向fputc到USART1 */
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
return ch;
}
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>
int fputc(int ch, FILE *f)
{
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
USART_SendData(USART1, (uint8_t)ch);
return ch;
}
5.5 printf的坑:浮点数不显示
STM32用MicroLIB时,printf默认不支持%f浮点输出,printf("%.1f", 3.14)会打印出%f或空。
原因:MicroLIB为了省体积,去掉了浮点格式化功能。这不是FPU(硬件浮点单元)的问题,是C库实现的问题。
解决方法1:Keil不勾选MicroLIB,改用标准库
Options for Target → Target标签页 → 取消勾选 "Use MicroLIB"
但标准库需要额外处理半主机模式(加以下代码):
#pragma import(__use_no_semihosting) /* 禁用半主机模式 */
struct __FILE { int handle; };
FILE __stdout;
void _sys_exit(int x) { x = x; } /* 半主机模式需要的空实现 */
解决方法2:用sprintf转换再发(最简单,推荐新手)
char buf[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_t rx_buf[1];
/* 在main函数里启动中断接收 */
HAL_UART_Receive_IT(&huart1, rx_buf, 1);
第3步:重写接收完成回调函数
/* HAL库会自动调用这个函数(名字不能改) */
void HAL_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在后面追(读出),到末尾了绕回开头继续。
#define BUF_SIZE 256
uint8_t ring_buf[BUF_SIZE];
volatile uint16_t buf_head = 0; /* 写位置(中断里写入) */
volatile uint16_t buf_tail = 0; /* 读位置(主程序读出) */
void HAL_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_t ring_buf_read(uint8_t *data)
{
if (buf_head == buf_tail) return -1; /* 空 */
*data = ring_buf[buf_tail];
buf_tail = (buf_tail + 1) % BUF_SIZE;
return 0;
}
环形缓冲区是嵌入式面试常考点,原理:head追tail,用取模实现循环。ESP8266、GPS模块这种"不定长数据流"接收都用它。
七、常见踩坑
坑1:串口助手收到乱码
现象:串口助手显示一堆乱码或问号。
排查顺序:
- 波特率对不对:代码115200,助手也必须115200
- GND接没接:TX/RX接了但GND没共地,必乱码
- TX/RX接反:STM32的TX要接TTL模块的RX
- 时钟配错:系统时钟72MHz配成了8MHz,波特率计算就错了
最常见的坑:系统时钟没配,默认8MHz跑,但波特率按72MHz算,差9倍,必乱码。
坑2:printf无输出
现象:调了printf但串口助手什么都收不到。
排查:
- 勾选MicroLIB了吗?(Keil最容易漏的)
- fputc函数写了吗?只include了stdio.h不够
- USART句柄对不对?
&huart1别写成&huart2 - 先用
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就丢字节。
原因:
- 内部RC振荡器精度不够(±3%),高波特率误差超2%
- 中断处理太慢,来不及处理上一个字节
解决:
- 换外部晶振(8MHz或16MHz)
- 高波特率改用DMA接收,不依赖中断
八、完整示例:串口收发回显
把上面学的串起来,做一个"收到什么就回发什么"的回显程序:
#include "main.h"
#include <stdio.h>
UART_HandleTypeDef huart1;
uint8_t rx_buf[1];
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100);
return ch;
}
void HAL_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);
}
}
int main(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输出》
如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。作者:嵌入式阿蔡
更多推荐
所有评论(0)