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。说白了,就是能主动输出高低电平,驱动能力强。
2.1.2 开漏输出
开漏模式就很有意思了。它只有N-MOS负责拉低,高电平是靠外部上拉电阻实现的。输出低电平时,N-MOS导通;输出高电平时,N-MOS截止,引脚处于高阻态,电平由外部上拉电阻决定。
为什么要有这种模式?我举个例子:I²C总线。多个设备挂在同一条线上,如果都用推挽,一个设备输出高、另一个输出低,那就直接短路了。开漏模式配合“线与”逻辑,谁拉低谁说了算,不会打架。
| 特性 | 推挽输出 | 开漏输出 |
|---|---|---|
| 输出高电平 | 内部直接拉高到VDD | 需外部上拉电阻 |
| 输出低电平 | 内部直接拉低到GND | 内部直接拉低到GND |
| 驱动能力 | 强,可主动输出 | 弱,依赖上拉电阻 |
| 多设备共用 | 不适用,会短路 | 适用,支持线与 |
| 典型应用 | LED、蜂鸣器、普通信号 | I²C、电平转换 |
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);
}
}
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;
}
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();
2.5 本章知识体系
下面这张图总结了本章的核心知识点和它们之间的关系:
从图上可以看得很清楚:GPIO和UART是基础,环形缓冲区是连接中断接收和主循环处理的桥梁,而串口控制台调试技巧则是我们日常开发中离不开的利器。把这套东西玩熟了,后面的AI推理性能调优才能有的放矢。