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);
小技巧: 如果你用CubeMX生成代码,GPIO配置会自动搞定。但我建议你手动看一遍生成的代码,知道它配了哪些寄存器。出问题时能快速定位。

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);
避坑: 波特率误差不能超过±2%。如果系统时钟不是整数倍分频,误差会累积。我曾经用72MHz主频配2M波特率,误差直接超了5%,收的数据全是错的。后来老老实实换回115200。

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);
注意: 使用printf会链接标准库的完整实现,代码体积会增加5-10KB。如果Flash紧张,可以考虑用轻量级的printf实现(如xprintf、tinyprintf)。

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;
}
经验之谈: 缓冲区大小选256还是1024?看你的数据量和处理速度。如果主循环每1ms处理一次,串口波特率115200(约11.5KB/s),256字节够用。但如果你要接收大量数据,建议至少1024。

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);
    }
}
注意: 中断优先级要合理分配。高波特率、数据量大的串口优先级设高一些。我曾经把调试串口优先级设得最高,结果GPS数据频繁丢失——调试信息太多,把CPU时间吃光了。

4.5 本章知识结构

下面这张图把串口调试的核心流程串起来了。从硬件配置到数据流动,一目了然。

串口打印调试知识结构图 串口打印调试知识结构 UART基础配置 GPIO引脚配置 波特率设置 中断接收配置 printf重定向实现 HAL库重定向(fputc) 寄存器级重定向 环形缓冲区设计 写入(中断中) 读取(主循环) 状态查询 多串口同时使用(资源规划+代码组织) 核心:中断存 + 主循环取

这张图把整个串口调试的脉络理清楚了。从硬件配置到底层数据流动,再到上层打印输出,每一层都环环相扣。你写代码的时候,脑子里要有这张图——中断负责存数据,主循环负责处理,printf负责喊出来。