4. GPIO调试接口设计:串口打印与状态追踪

调试嵌入式系统,说白了就是跟硬件“对话”。你写了个GPIO翻转,怎么知道它真的翻了?拿示波器戳?太麻烦。点个LED?引脚不够用怎么办。

我个人习惯,第一件事就是把串口打印搭起来。有了它,你就能随时看变量、看状态、看程序跑到哪了。这章我们就干三件事:搞懂串口打印的原理、把printf重定向到USART、再封装一个专门打印GPIO状态的函数。

4.1 串口打印调试的原理

串口打印,本质就是让MCU通过TX引脚,按约定的波特率把数据发出去。PC这边用串口工具收下来,显示在屏幕上。

STM32的USART外设,内部有个发送数据寄存器(TDR)。你把一个字节丢进去,硬件就会自动加上起始位、停止位,按波特率时钟一位一位往外送。送完了,硬件置位TXE标志,告诉你“可以发下一个了”。

核心流程:

  • 应用程序调用 printf("val = %d", val);
  • printf内部逐字符调用 fputc()
  • fputc() 把字符写入 USART->DR
  • USART硬件自动串行发送
  • PC端串口工具接收并显示

这里有个关键点:printf默认输出到“标准输出”,在裸机环境下标准输出不存在。所以我们要做的,就是把fputc这个底层函数,重定向到USART的发送寄存器。

我的经验: 刚开始做重定向时,我忘了加 __io_putchar 的弱定义覆盖,结果printf死活没输出。查了半天才发现是链接器没找到我的fputc。嗯,这个坑踩过一次就不会忘了。

4.2 重定向printf到USART

重定向printf,在GCC和ARMCC(Keil)下做法略有不同。我以GCC为例,因为我现在主要用STM32CubeIDE。

你需要做这几步:

  1. 初始化USART(时钟、GPIO、波特率、模式)
  2. 实现 int __io_putchar(int ch) 函数
  3. 在函数内轮询等待TXE标志,然后写入DR
  4. 链接时使用 -u _printf_float 支持浮点(可选)

下面是一个完整的实现:

/* usart_debug.c */
#include "usart_debug.h"
#include <stdio.h>

static UART_HandleTypeDef huart1;

void Debug_USART_Init(void)
{
    __HAL_RCC_USART1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    GPIO_InitTypeDef gpio = {0};
    /* TX: PA9, AF7 */
    gpio.Pin = GPIO_PIN_9;
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Pull = GPIO_PULLUP;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    gpio.Alternate = GPIO_AF7_USART1;
    HAL_GPIO_Init(GPIOA, &gpio);

    /* RX: PA10, AF7 (可选,用于接收) */
    gpio.Pin = GPIO_PIN_10;
    HAL_GPIO_Init(GPIOA, &gpio);

    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;
    HAL_UART_Init(&huart1);
}

/* 重定向printf到USART1 */
int __io_putchar(int ch)
{
    /* 轮询等待发送完成 */
    while (!(USART1->ISR & USART_ISR_TXE)) {}
    USART1->TDR = (ch & 0xFF);
    return ch;
}

注意: 上面的代码用了寄存器直接操作(USART1->ISR),而不是HAL库的HAL_UART_Transmit。为什么?因为printf调用频率很高,HAL库函数调用开销大,而且HAL_UART_Transmit内部有超时机制,在调试阶段反而可能引入不必要的延迟。直接操作寄存器,干净利落。

如果你用Keil(ARMCC),做法稍微不同:

/* Keil下重定向printf */
int fputc(int ch, FILE *f)
{
    while (!(USART1->SR & USART_SR_TXE)) {}
    USART1->DR = (ch & 0xFF);
    return ch;
}

两种编译器,本质一样:把字符塞进发送寄存器,等硬件发完再塞下一个。

4.3 实现GPIO_Debug_PrintState函数

有了printf,我们就可以封装一个专门打印GPIO状态的函数。这个函数要做什么?

  • 传入GPIO端口和引脚号
  • 读取当前引脚的电平状态
  • 打印端口名、引脚号、电平值
  • 最好还能打印引脚的模式(输入/输出)

我直接上代码:

/* gpio_debug.c */
#include "gpio_debug.h"
#include <stdio.h>

/* 获取GPIO端口名称字符串 */
static const char* GPIO_GetPortName(GPIO_TypeDef *port)
{
    if (port == GPIOA) return "GPIOA";
    if (port == GPIOB) return "GPIOB";
    if (port == GPIOC) return "GPIOC";
    if (port == GPIOD) return "GPIOD";
    /* 根据需要继续添加 */
    return "UNKNOWN";
}

/* 获取引脚模式字符串 */
static const char* GPIO_GetModeStr(GPIO_TypeDef *port, uint16_t pin)
{
    uint32_t mode_reg = port->MODER;
    int pin_index = __builtin_ctz(pin);  /* 获取引脚编号 */
    uint32_t mode = (mode_reg >> (pin_index * 2)) & 0x03;

    switch (mode) {
        case 0: return "输入";
        case 1: return "输出";
        case 2: return "复用";
        case 3: return "模拟";
        default: return "未知";
    }
}

/* 打印GPIO状态 */
void GPIO_Debug_PrintState(GPIO_TypeDef *port, uint16_t pin)
{
    const char *port_name = GPIO_GetPortName(port);
    const char *mode_str = GPIO_GetModeStr(port, pin);
    uint8_t level = HAL_GPIO_ReadPin(port, pin);

    printf("[GPIO_DEBUG] %s.Pin%d | 模式: %s | 电平: %s\r\n",
           port_name,
           __builtin_ctz(pin),
           mode_str,
           level ? "HIGH" : "LOW");
}

使用示例:

int main(void)
{
    HAL_Init();
    Debug_USART_Init();

    /* 初始化LED引脚 */
    GPIO_InitTypeDef gpio = {0};
    __HAL_RCC_GPIOB_CLK_ENABLE();
    gpio.Pin = GPIO_PIN_0;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOB, &gpio);

    while (1)
    {
        HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0);
        GPIO_Debug_PrintState(GPIOB, GPIO_PIN_0);
        HAL_Delay(500);
    }
}

运行后,串口会输出类似这样的信息:

[GPIO_DEBUG] GPIOB.Pin0 | 模式: 输出 | 电平: HIGH
[GPIO_DEBUG] GPIOB.Pin0 | 模式: 输出 | 电平: LOW
[GPIO_DEBUG] GPIOB.Pin0 | 模式: 输出 | 电平: HIGH

你看,每500ms翻转一次,打印一次。调试时一眼就能看出引脚状态对不对。

避坑指南: 我曾经在打印函数里直接调用了HAL_Delay,结果在中断里调用这个函数时,系统直接卡死。后来我加了一个参数 uint8_t use_delay,让调用者决定是否要延时。记住:调试函数也要考虑调用上下文。

4.4 知识结构总览

下面这张图,把本章的核心逻辑串起来了:

GPIO调试接口设计知识结构 GPIO调试接口设计知识结构 调试需求:实时查看GPIO状态 串口打印原理 printf重定向 状态打印函数 USART硬件发送流程 TDR → 移位寄存器 → TX引脚 TXE标志控制发送节奏 fputc/__io_putchar 轮询TXE → 写DR GCC/ARMCC差异处理 GPIO_Debug_PrintState 读IDR → 解析模式 → 格式化输出 端口名/引脚号/电平/模式 串口输出:[GPIO_DEBUG] GPIOB.Pin0 | 模式: 输出 | 电平: HIGH 核心思想:用最小的代码开销,实现最大化的调试可见性

4.5 扩展:批量打印与过滤

实际项目中,GPIO可能几十个。一个一个打印太慢。我习惯加一个批量打印函数:

/* 批量打印一组GPIO状态 */
void GPIO_Debug_PrintGroup(GPIO_TypeDef *port, uint16_t pin_mask)
{
    uint16_t pin = 1;
    for (int i = 0; i < 16; i++)
    {
        if (pin_mask & pin)
        {
            GPIO_Debug_PrintState(port, pin);
        }
        pin <<= 1;
    }
}

调用时,传一个掩码就行:

GPIO_Debug_PrintGroup(GPIOB, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2);

这样一次就能看到三个引脚的状态。调试效率高很多。

性能提醒: 批量打印时,如果引脚数多,串口输出会占用不少时间。我建议在正式发布代码前,用宏把调试打印关掉:

#ifdef DEBUG_ENABLE
    #define GPIO_DEBUG_PRINT(port, pin) GPIO_Debug_PrintState(port, pin)
#else
    #define GPIO_DEBUG_PRINT(port, pin)
#endif

这样发布时,调试代码零开销。

4.6 本章小结

这一章我们做了三件事:

  • 理解了串口打印的硬件原理——说白了就是往TDR里写数据,硬件自动发出去
  • 实现了printf重定向——把fputc指向USART的发送寄存器
  • 封装了GPIO_Debug_PrintState——一个函数就能看到引脚的全部状态