4、串口打印调试:UART基础配置与printf重定向
串口打印调试,说白了就是给MCU装个「小喇叭」。代码跑到哪了、变量值对不对、哪个函数崩了——全靠它喊出来。我个人做项目时,第一件事就是把串口调通,不然就像闭着眼睛写代码,心里没底。
这一章我们重点讲四个东西:UART的基础配置、printf重定向、环形缓冲区设计,还有多串口同时使用的技巧。嗯,都是实战里天天用的。
4.1 UART基础配置:GPIO、波特率、中断
先看最基础的。STM32的UART外设,配置起来其实就那么几步。我习惯用HAL库,但底层原理你得懂。
4.1.1 GPIO引脚配置
UART的TX和RX,必须配成复用功能。别小看这一步,我见过有人把TX配成推挽输出、RX配成浮空输入,结果死活收不到数据。
// 以STM32F103为例,USART1:PA9=TX, PA10=RX
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_USART1_CLK_ENABLE();
// TX引脚:复用推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// RX引脚:复用开漏输入(实际是浮空输入)
GPIO_InitStruct.Pin = GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
4.1.2 波特率设置
波特率就是串口通信的「语速」。两边必须一致,否则收到的全是乱码。常用的有9600、115200、921600。我个人项目里,调试用115200,量产产品用9600(抗干扰好)。
| 波特率 | 适用场景 | 注意事项 |
|---|---|---|
| 9600 | 长距离、强干扰环境 | 速度慢,但稳定 |
| 115200 | 常规调试、短距离通信 | 大部分场景首选 |
| 921600 | 高速数据输出(如日志流) | 线长超过30cm可能丢数据 |
// 波特率配置(HAL库自动计算)
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_1;
huart1.Init.Parity = UART_PARITY_NONE;
huart1.Init.Mode = UART_MODE_TX_RX;
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart1.Init.OverSampling = UART_OVERSAMPLING_16;
HAL_UART_Init(&huart1);
4.1.3 中断接收配置
轮询接收太浪费CPU了。实际项目里,我们几乎都用中断。配置起来很简单:
// 使能USART1中断
HAL_NVIC_SetPriority(USART1_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);
// 启动中断接收(每次接收1个字节)
uint8_t rx_byte;
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
中断回调函数里处理收到的数据:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 把收到的字节存入环形缓冲区
ringbuf_put(&uart1_rx_buf, rx_byte);
// 重新启动接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
4.2 printf重定向实现
printf是C标准库的函数,默认输出到stdout。在嵌入式里,我们需要把它「重定向」到串口。说白了,就是让printf往串口发数据。
4.2.1 两种重定向方式
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| HAL库重定向 | 重写fputc函数,调用HAL_UART_Transmit | 简单、兼容性好 | 效率略低 |
| 寄存器级重定向 | 直接操作USART->DR寄存器 | 速度快、无依赖 | 代码可移植性差 |
我个人推荐用HAL库方式,代码清晰,移植到其他STM32型号也方便。
4.2.2 代码实现(HAL库方式)
// 在usart.c或main.c中添加
#include <stdio.h>
#ifdef __GNUC__
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#endif
PUTCHAR_PROTOTYPE
{
// 等待发送完成
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = (uint8_t)ch;
return ch;
}
然后就可以愉快地用了:
printf("系统启动成功!\r\n");
printf("当前温度: %.2f°C\r\n", temperature);
4.2.3 多串口printf切换
有时候我们需要在多个串口之间切换输出。比如调试信息走USART1,传感器数据走USART2。我的做法是定义一个全局变量:
UART_HandleTypeDef *printf_uart = &huart1;
void set_printf_uart(UART_HandleTypeDef *huart)
{
printf_uart = huart;
}
// 重定向fputc
PUTCHAR_PROTOTYPE
{
while (!(printf_uart->Instance->SR & USART_SR_TXE));
printf_uart->Instance->DR = (uint8_t)ch;
return ch;
}
// 使用
set_printf_uart(&huart2);
printf("这条信息走USART2\r\n");
4.3 环形缓冲区设计
为什么要用环形缓冲区?你想想看,中断里直接处理数据,万一处理时间太长,下一个字节就丢了。环形缓冲区就像一个「蓄水池」,中断只管往里存,主循环慢慢取。
4.3.1 数据结构
#define RINGBUF_SIZE 256
typedef struct {
uint8_t buffer[RINGBUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ringbuf_t;
4.3.2 核心操作
// 初始化
void ringbuf_init(ringbuf_t *rb)
{
rb->head = 0;
rb->tail = 0;
}
// 写入一个字节
int ringbuf_put(ringbuf_t *rb, uint8_t data)
{
uint16_t next = (rb->head + 1) % RINGBUF_SIZE;
if (next == rb->tail) {
return -1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 0;
}
// 读取一个字节
int ringbuf_get(ringbuf_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return -1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % RINGBUF_SIZE;
return 0;
}
// 查询已用空间
uint16_t ringbuf_used(ringbuf_t *rb)
{
return (rb->head - rb->tail + RINGBUF_SIZE) % RINGBUF_SIZE;
}
4.3.3 使用示例
ringbuf_t uart1_rx_buf;
// 中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
ringbuf_put(&uart1_rx_buf, rx_byte);
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}
}
// 主循环处理
while (1) {
uint8_t ch;
while (ringbuf_get(&uart1_rx_buf, &ch) == 0) {
process_char(ch); // 处理收到的字符
}
// 其他任务...
}
4.4 多串口同时使用
实际项目中,一个串口往往不够用。蓝牙模块、GPS、调试口、传感器……我做过一个项目,同时用了5个串口。嗯,这时候就要注意资源分配了。
4.4.1 硬件资源规划
| 串口 | 用途 | 波特率 | 中断优先级 |
|---|---|---|---|
| USART1 | 调试打印 | 115200 | 最低(3) |
| USART2 | 蓝牙通信 | 9600 | 中等(2) |
| USART3 | GPS数据接收 | 38400 | 最高(0) |
4.4.2 代码组织
每个串口独立配置,但共用一套环形缓冲区逻辑:
// 定义多个缓冲区
ringbuf_t uart1_rx_buf;
ringbuf_t uart2_rx_buf;
ringbuf_t uart3_rx_buf;
// 中断回调中区分串口
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
ringbuf_put(&uart1_rx_buf, rx_byte1);
HAL_UART_Receive_IT(&huart1, &rx_byte1, 1);
}
else if (huart->Instance == USART2) {
ringbuf_put(&uart2_rx_buf, rx_byte2);
HAL_UART_Receive_IT(&huart2, &rx_byte2, 1);
}
else if (huart->Instance == USART3) {
ringbuf_put(&uart3_rx_buf, rx_byte3);
HAL_UART_Receive_IT(&huart3, &rx_byte3, 1);
}
}
4.5 本章知识结构
下面这张图把串口调试的核心流程串起来了。从硬件配置到数据流动,一目了然。
这张图把整个串口调试的脉络理清楚了。从硬件配置到底层数据流动,再到上层打印输出,每一层都环环相扣。你写代码的时候,脑子里要有这张图——中断负责存数据,主循环负责处理,printf负责喊出来。