2. STM32基础外设驱动实战(GPIO与UART)

这一章,我们聊聊嵌入式开发最基础、也最绕不开的两个外设——GPIO和UART。别觉得基础就掉以轻心,我见过太多项目栽在看似简单的配置上。尤其是后面要做AI推理,串口打印调试信息、GPIO控制指示灯状态,几乎是每时每刻都在用。

2.1 GPIO推挽与开漏输出模式详解

GPIO的输出模式,说白了就两种:推挽(Push-Pull)和开漏(Open-Drain)。很多初学者搞不清区别,直接默认用推挽,结果在I²C总线上死活通信不上——嗯,我当年也踩过这个坑。

2.1.1 推挽输出

推挽模式下,内部有两个MOS管:一个负责拉高(P-MOS),一个负责拉低(N-MOS)。输出高电平时,P-MOS导通,引脚直接连到VDD;输出低电平时,N-MOS导通,引脚连到GND。说白了,就是能主动输出高低电平,驱动能力强。

我的习惯:控制LED、继电器这类负载,我首选推挽输出。驱动电流一般能到20mA左右,直接点亮一个普通LED完全没问题。

2.1.2 开漏输出

开漏模式就很有意思了。它只有N-MOS负责拉低,高电平是靠外部上拉电阻实现的。输出低电平时,N-MOS导通;输出高电平时,N-MOS截止,引脚处于高阻态,电平由外部上拉电阻决定。

为什么要有这种模式?我举个例子:I²C总线。多个设备挂在同一条线上,如果都用推挽,一个设备输出高、另一个输出低,那就直接短路了。开漏模式配合“线与”逻辑,谁拉低谁说了算,不会打架。

特性 推挽输出 开漏输出
输出高电平 内部直接拉高到VDD 需外部上拉电阻
输出低电平 内部直接拉低到GND 内部直接拉低到GND
驱动能力 强,可主动输出 弱,依赖上拉电阻
多设备共用 不适用,会短路 适用,支持线与
典型应用 LED、蜂鸣器、普通信号 I²C、电平转换
我曾经踩过的坑:用开漏输出驱动LED,忘了加上拉电阻。结果LED死活不亮,用示波器一看,引脚电平一直在1.2V左右飘忽不定。加上4.7kΩ上拉电阻到3.3V,立马正常。

2.1.3 代码示例:GPIO配置

以STM32CubeMX生成的HAL库代码为例,配置一个推挽输出和一个开漏输出:

// 推挽输出:PA0控制LED
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;  // 推挽
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 开漏输出:PB1用于I²C模拟
GPIO_InitStruct.Pin = GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;  // 开漏
GPIO_InitStruct.Pull = GPIO_PULLUP;          // 内部上拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

2.2 UART中断与DMA收发

UART是调试的命脉。我个人的习惯是:项目启动第一件事,先把串口调通。没有串口打印,调试AI模型推理结果就像蒙着眼睛走路。

2.2.1 中断方式接收

轮询方式太浪费CPU,尤其是在跑AI推理时,CPU每一纳秒都宝贵。中断方式接收,每收到一个字节就触发一次中断,在中断服务函数里把数据取走。

// 开启中断接收(每次接收1个字节)
HAL_UART_Receive_IT(&huart1, (uint8_t *)&rx_byte, 1);

// 中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        // 把收到的字节放入环形缓冲区
        ring_buf_put(&uart_rx_buf, rx_byte);
        // 重新开启下一次接收
        HAL_UART_Receive_IT(&huart1, (uint8_t *)&rx_byte, 1);
    }
}
关键点:中断回调里一定要重新调用HAL_UART_Receive_IT,否则只收一次就停了。我见过有人忘了这步,调试了一下午。

2.2.2 DMA方式收发

当数据量大的时候,中断方式频繁进出中断,开销也不小。DMA方式就舒服多了——CPU只需要配置好DMA,剩下的搬运工作DMA自己完成,传输完成才触发一次中断。

DMA特别适合批量数据传输,比如从传感器读取一帧图像数据、或者通过串口发送AI推理结果。我建议:发送用DMA,接收用中断+环形缓冲区。为什么?因为接收的数据长度不确定,DMA不好处理不定长数据。

方式 CPU占用 实时性 适合场景
轮询 高(一直等) 差 调试、简单测试
中断 中(每字节中断一次) 好 不定长数据、交互式
DMA 低(传输完成才中断) 好 批量数据、高速传输

2.3 环形缓冲区实现

环形缓冲区(Ring Buffer),说白了就是一个固定大小的数组,配合两个指针(读指针和写指针)循环使用。为什么需要它?因为中断接收和主循环处理是异步的——中断收得快,主循环处理得慢,如果没有缓冲区,数据就丢了。

2.3.1 数据结构

#define RING_BUF_SIZE 256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buf_t;

head指向下一个要写入的位置,tail指向下一个要读取的位置。当head追上tail时,缓冲区满;当tail追上head时,缓冲区空。

2.3.2 核心操作

// 放入一个字节
int ring_buf_put(ring_buf_t *rb, uint8_t data)
{
    uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE;
    if (next_head == rb->tail) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 取出一个字节
int ring_buf_get(ring_buf_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}
注意:head和tail要加volatile修饰。因为中断和主循环都会修改它们,不加volatile的话,编译器优化可能导致读取到错误的值。我曾经因为这个bug,排查了整整两天。

2.4 串口控制台调试技巧

串口控制台不只是打印"Hello World"。我总结了几个实战中特别好用的技巧:

2.4.1 彩色日志输出

在终端里用ANSI转义码区分不同级别的日志,一眼就能看出问题在哪。

#define LOG_INFO(fmt, ...)  \
    printf("\033[32m[INFO] " fmt "\033[0m\r\n", ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)  \
    printf("\033[33m[WARN] " fmt "\033[0m\r\n", ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
    printf("\033[31m[ERROR] " fmt "\033[0m\r\n", ##__VA_ARGS__)

2.4.2 命令解析器

实现一个简单的命令行,可以在运行时修改参数、查看变量。比如输入"adc read"就读取ADC值并打印,输入"ai run"就触发一次推理。这样调试AI模型参数时,不用每次改代码重新烧录。

2.4.3 性能打点

用GPIO翻转配合示波器,比任何软件计时都准。在关键代码段前后翻转一个GPIO,用示波器看高电平宽度,就是这段代码的执行时间。

#define PERF_PIN_ON()  HAL_GPIO_WritePin(PERF_GPIO_Port, PERF_Pin, GPIO_PIN_SET)
#define PERF_PIN_OFF() HAL_GPIO_WritePin(PERF_GPIO_Port, PERF_Pin, GPIO_PIN_RESET)

// 测量AI推理时间
PERF_PIN_ON();
ai_model_run();
PERF_PIN_OFF();
我的经验:串口波特率建议用115200或更高。9600太慢,打印一屏数据要等半天。另外,printf默认是阻塞的,如果数据量大,建议用DMA发送,或者自己实现一个非阻塞的打印函数。

2.5 本章知识体系

下面这张图总结了本章的核心知识点和它们之间的关系:

第2章 知识体系:GPIO与UART驱动实战 GPIO 输出模式 推挽输出 开漏输出 LED、蜂鸣器 I²C、电平转换 UART 收发方式 轮询 中断 DMA CPU占用:高 → 中 → 低 推荐:中断接收 + DMA发送 环形缓冲区(Ring Buffer) head(写指针)→ tail(读指针)→ 解决中断与主循环的异步问题 串口控制台调试技巧 彩色日志 | 命令解析器 | GPIO性能打点

从图上可以看得很清楚:GPIO和UART是基础,环形缓冲区是连接中断接收和主循环处理的桥梁,而串口控制台调试技巧则是我们日常开发中离不开的利器。把这套东西玩熟了,后面的AI推理性能调优才能有的放矢。