GPIO 输出驱动:从点亮一颗 LED 开始

嵌入式开发里,GPIO 输出是最基础、最常用的功能。说白了,就是让芯片的某个引脚输出高电平或低电平。我刚开始学 STM32 时,第一个实验就是点亮板子上的 LED。嗯,今天我们就来聊聊 GPIO 输出的几种实现方式,以及背后的原理。

HAL_GPIO_WritePin 与 LL_GPIO_SetOutputPin 对比

STM32 提供了两套库函数:HAL 库和 LL 库。它们都能控制 GPIO 输出,但设计思路完全不同。

对比项 HAL_GPIO_WritePin LL_GPIO_SetOutputPin
所属库 HAL 库 LL 库
函数原型 void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState) void LL_GPIO_SetOutputPin(GPIO_TypeDef* GPIOx, uint32_t PinMask)
参数特点 第三个参数传电平状态:GPIO_PIN_SET 或 GPIO_PIN_RESET 只传引脚掩码,默认置高;置低需调用 LL_GPIO_ResetOutputPin
执行效率 较慢,有参数校验和状态判断 极快,直接操作寄存器
代码体积 较大,包含错误处理 极小,几乎就是一条汇编指令
适用场景 原型验证、对速度不敏感的控制 时序敏感、高频翻转、中断服务函数

我的经验:项目初期用 HAL 库快速验证功能,后期优化性能时再替换成 LL 库。我曾经在一个电机驱动项目里,因为 HAL_GPIO_WritePin 在中断里耗时太长,导致 PWM 波形失真。换成 LL 库后,问题立刻解决。

来看一个实际代码对比。假设我们要让 PA5 引脚输出高电平:

// HAL 库方式
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);

// LL 库方式
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_5);

你想想看,HAL 库函数内部会检查参数是否合法、当前状态是否已经匹配。而 LL 库直接往寄存器里写值。我建议在定时器中断或高速通信协议里,优先用 LL 库。

LED 流水灯实战

流水灯是 GPIO 输出的经典练习。我们用一个数组保存引脚编号,循环依次点亮。这里我用 LL 库实现,因为代码更简洁,执行效率也更高。

#include "stm32f1xx_hal.h"
#include "stm32f1xx_ll_gpio.h"

// 定义 LED 引脚数组
#define LED_PORT GPIOB
const uint32_t led_pins[] = {
    LL_GPIO_PIN_0,  // PB0
    LL_GPIO_PIN_1,  // PB1
    LL_GPIO_PIN_2,  // PB2
    LL_GPIO_PIN_3   // PB3
};
const uint8_t led_count = 4;

void LED_Init(void) {
    // 使能 GPIOB 时钟
    __HAL_RCC_GPIOB_CLK_ENABLE();

    // 配置为推挽输出,速度 50MHz
    LL_GPIO_SetPinMode(LED_PORT, LL_GPIO_PIN_0|LL_GPIO_PIN_1|LL_GPIO_PIN_2|LL_GPIO_PIN_3, LL_GPIO_MODE_OUTPUT);
    LL_GPIO_SetPinSpeed(LED_PORT, LL_GPIO_PIN_0|LL_GPIO_PIN_1|LL_GPIO_PIN_2|LL_GPIO_PIN_3, LL_GPIO_SPEED_FREQ_HIGH);
    LL_GPIO_SetPinOutputType(LED_PORT, LL_GPIO_PIN_0|LL_GPIO_PIN_1|LL_GPIO_PIN_2|LL_GPIO_PIN_3, LL_GPIO_OUTPUT_PUSHPULL);

    // 初始全部熄灭
    LL_GPIO_ResetOutputPin(LED_PORT, LL_GPIO_PIN_0|LL_GPIO_PIN_1|LL_GPIO_PIN_2|LL_GPIO_PIN_3);
}

void LED_Flow(void) {
    static uint8_t index = 0;

    // 熄灭所有 LED
    LL_GPIO_ResetOutputPin(LED_PORT, LL_GPIO_PIN_0|LL_GPIO_PIN_1|LL_GPIO_PIN_2|LL_GPIO_PIN_3);

    // 点亮当前 LED
    LL_GPIO_SetOutputPin(LED_PORT, led_pins[index]);

    // 更新索引
    index++;
    if (index >= led_count) {
        index = 0;
    }
}

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

    while (1) {
        LED_Flow();
        HAL_Delay(200);  // 延时 200ms
    }
}

小技巧:流水灯的延时可以用定时器中断实现,避免阻塞主循环。我习惯把 LED_Flow() 放在定时器回调里,这样主循环可以处理其他任务。

位带操作原理

位带操作是 Cortex-M3/M4 内核的一个特性。它允许你像操作普通变量一样,直接读写某个 GPIO 引脚的电平。为什么需要这个?因为传统的“读-改-写”操作在多任务环境下可能出问题。

举个例子,你想让 PA5 输出高电平。用寄存器操作是:

GPIOA->BSRR = GPIO_PIN_5;  // 置位
GPIOA->BRR  = GPIO_PIN_5;  // 复位

这已经很快了。但位带操作更快——它把每个比特映射到一个 32 位的地址空间。你直接往那个地址写 1 或 0,就能控制引脚。

位带操作原理图 外设寄存器(如 GPIOA->ODR) 位带别名区(32MB 空间) 1 bit 映射到 32 bit 位带别名地址计算公式 别名地址 = 位带基址 + (字节偏移 × 32) + (位号 × 4) 例如:PA5 的 ODR 位 → 0x42000000 + (0x0C × 32) + (5 × 4) = 0x42000184 写 1 → 引脚置高 写 0 → 引脚置低

为什么位带操作这么快?因为它是一条原子操作。你不需要“读-改-写”,直接写就完了。这在多线程或中断嵌套的环境下特别有用。

注意:位带操作只适用于 Cortex-M3/M4 内核的 STM32 型号(如 F1/F4 系列)。Cortex-M0/M0+(如 F0/G0 系列)不支持位带。我曾经在 F0 项目里用了位带代码,编译没问题,但运行结果完全不对。查了半天才发现是内核不支持。

位带操作的代码实现也很简单。以 PA5 为例:

// 定义位带地址
#define BITBAND_PERIPH_BASE    0x40000000
#define BITBAND_PERIPH_ALIAS   0x42000000

// 计算 PA5 的 ODR 位地址
#define PA5_ODR_ADDR           (GPIOA_BASE + 0x0C)  // ODR 寄存器偏移 0x0C
#define PA5_BITBAND_ADDR       (BITBAND_PERIPH_ALIAS + ((uint32_t)&PA5_ODR_ADDR - BITBAND_PERIPH_BASE) * 32 + 5 * 4)

// 定义读写宏
#define PA5_OUT()              (*(volatile uint32_t *)PA5_BITBAND_ADDR)

// 使用
PA5_OUT() = 1;  // PA5 输出高电平
PA5_OUT() = 0;  // PA5 输出低电平

你想想看,这比调用任何库函数都快。但代价是代码可读性差,移植性也不好。我建议只在性能瓶颈处使用位带操作,比如高速 SPI 的片选信号、PWM 的同步信号等。

总结一下:HAL 库适合快速开发,LL 库适合性能敏感场景,位带操作是终极优化手段。实际项目中,我通常混合使用:初始化用 HAL 库,高频翻转用 LL 库,极端时序用位带操作。