2. STM32硬件基础与最小系统
嵌入式开发里,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,就能控制引脚。
为什么位带操作这么快?因为它是一条原子操作。你不需要“读-改-写”,直接写就完了。这在多线程或中断嵌套的环境下特别有用。
注意:位带操作只适用于 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 库,极端时序用位带操作。