Chapter 02 · ARM 嵌入式 C 开发

GPIO 与寄存器

深入理解 GPIO 四种工作模式的硬件原理,掌握寄存器操作与 HAL 库双路径,实现按键消抖与呼吸灯。

课程进度20%

关键概念

GPIO(通用输入输出)
General Purpose Input/Output,芯片上最基础的数字信号接口。每个 GPIO 引脚都可以通过寄存器配置为输入或输出,控制外部器件(LED、继电器)或读取外部信号(按键、传感器)。STM32F4 最多有 140 个 GPIO 引脚。
推挽输出(Push-Pull)
引脚内部有两个 MOSFET:上管导通时输出高电平(接 VDD),下管导通时输出低电平(接 GND)。主动驱动,电流驱动能力强(STM32 单引脚最大 25mA),适合直接驱动 LED。这是最常用的输出模式。
开漏输出(Open-Drain)
只有下管(N-MOS),没有上拉到 VDD 的能力。输出 0 时下管导通拉低;输出 1 时下管断开,引脚呈高阻态,需要外部上拉电阻拉高。I2C 总线必须使用开漏,因为多个设备挂在同一条线上,任一设备可拉低(线与逻辑)。
上拉/下拉电阻
引脚空悬时电平不确定(浮空),噪声可能触发误动作。内部上拉电阻(约 40kΩ)连接引脚与 VDD,默认高电平;内部下拉电阻连接引脚与 GND,默认低电平。按键输入通常使用上拉(按键按下接 GND,读到低电平)。
复用功能(Alternate Function)
GPIO 引脚除了普通 I/O,还可以连接到片上外设信号——例如 PA9 可复用为 USART1_TX、TIM1_CH2 等。STM32F4 每个引脚最多有 16 种复用功能(AF0-AF15),通过 MODER 设为 AF 模式,再通过 AFR 寄存器选择具体功能。
ODR / IDR / BSRR 寄存器
ODR(Output Data Register):输出数据寄存器,bit N = 1 则 PIN_N 输出高。IDR(Input Data Register):只读,读取当前引脚电平。BSRR(Bit Set/Reset Register):原子操作——高16位写1复位(清零),低16位写1置位,一次操作完成,无需关中断保护。HAL 库的 WritePin 底层就是写 BSRR。
按键消抖(Debounce)
机械按键按下/松开时,弹片会抖动 5-20ms,产生多次高低跳变。软件消抖:检测到按键变化后延时 10-20ms,再确认状态。硬件消抖:RC 滤波电路或施密特触发器。不消抖会导致单次按键被识别为多次。
GPIO 速度等级
控制引脚输出驱动电路的压摆率(Slew Rate),即电平跳变的陡峭程度。速度越高,边沿越陡,但 EMI(电磁干扰)越大。STM32 有四档:Low(2MHz)、Medium(25MHz)、High(50MHz)、Very High(100MHz)。LED 等低速场景选 Low,SPI/SDIO 高速通信需选 High/Very High。

GPIO 硬件结构图

每个 GPIO 引脚背后都是一个相对复杂的电路,理解这个结构才能明白各模式的区别:

STM32 GPIO 内部电路(以推挽输出为例) VDD(3.3V) │ ┌────┴────┐ │ P-MOS │ ← 上管(推) └────┬────┘ ODR Reg ──────►│ 输出控制逻辑 ┌────┴────┐ │ N-MOS │ ← 下管(拉) └────┬────┘ │ GND ┌────────────── 引脚 PIN ──────────────┐ │ │ │ │ 内部上拉电阻(40kΩ)│ 内部下拉电阻 │ │ ┌─────────┐ │ ┌─────────┐ │ │ │ VDD ─┤R├─ PIN │ │ PIN ─┤R├─ GND│ │ └─────────┘ │ └─────────┘ │ │ │ │ │ ┌─────────▼─────────┐ │ │ │ 输入施密特触发器 │ │ │ │ (整形,去除毛刺) │ │ │ └─────────┬─────────┘ │ └──────────────────┼──────────────────┘ │ IDR Reg (只读) 模式对照: 推挽输出 : P-MOS 和 N-MOS 都工作,主动驱动高低 开漏输出 : 仅 N-MOS 工作,高电平需外部上拉 浮空输入 : 两个 MOS 都断开,仅读取 IDR 上拉输入 : 内部上拉电阻使能,悬空时读到高电平 模拟模式 : 施密特触发器和上下拉全部断开,连接 ADC

GPIO 四种工作模式详解

模式MODEROTYPERPUPDR典型应用
浮空输入0000外部已有上下拉的信号输入
上拉输入0001按键(按下接GND),I/O检测
下拉输入0010悬空时需要默认低电平的场景
推挽输出01000驱动LED、控制继电器
开漏输出011I2C总线、多设备线与逻辑
复用推挽100UART TX、SPI MOSI/CLK
复用开漏101I2C SDA/SCL
模拟模式11ADC输入、DAC输出

GPIOA 寄存器总览(基地址 0x40020000)

偏移寄存器读写功能
+0x00MODERRW每引脚2bit:输入/输出/复用/模拟
+0x04OTYPERRW每引脚1bit:推挽/开漏
+0x08OSPEEDRRW每引脚2bit:速度等级
+0x0CPUPDRRW每引脚2bit:无/上拉/下拉
+0x10IDRRO低16bit:当前引脚输入电平
+0x14ODRRW低16bit:输出数据(读写均可)
+0x18BSRRWO高16bit清零,低16bit置位(原子)
+0x24AFR[0]RWPIN0-7的复用功能选择(AF0-AF15)
+0x28AFR[1]RWPIN8-15的复用功能选择

BSRR 的重要性:原子操作

为什么用 BSRR 而不是直接读-改-写 ODR?考虑这种情况:

/* 危险:读-改-写 ODR 不是原子操作 */
/* 步骤1:读 ODR */
/* 步骤2(中断打入!改变了其他引脚)*/
/* 步骤3:写 ODR,中断的修改被覆盖!*/
GPIOD->ODR |= (1 << 12);  /* ← 存在竞态条件 */

/* 安全:BSRR 是单次写操作,无需关中断 */
/* 低16位:对应位写1 → 置高 */
GPIOD->BSRR = (1 << 12);   /* 置 PD12 高,其他引脚不受影响 */

/* 高16位:对应位写1 → 置低 */
GPIOD->BSRR = (1 << (12 + 16));  /* 清 PD12,拉低 */

/* HAL 库内部就是这样实现的:*/
/* HAL_GPIO_WritePin(GPIOD, GPIO_PIN_12, GPIO_PIN_SET)   → BSRR = (1<<12)       */
/* HAL_GPIO_WritePin(GPIOD, GPIO_PIN_12, GPIO_PIN_RESET) → BSRR = (1<<12)<<16   */

GPIO 完整初始化(寄存器版)

#include "stm32f4xx.h"

/**
 * @brief 初始化 LED(PD12-PD15)和按键(PA0,用户按钮)
 */
void GPIO_Init_All(void) {

  /* ===== 开启时钟 ===== */
  RCC->AHB1ENR |= (1 << 0);  /* GPIOA 时钟 */
  RCC->AHB1ENR |= (1 << 3);  /* GPIOD 时钟 */

  /* ===== 配置 PD12-PD15 为推挽输出(4个LED)===== */
  /* MODER: PD12→bit25:24=01, PD13→bit27:26=01 ... */
  GPIOD->MODER &= ~(0xFF << 24);  /* 清零 pin12-15 的4×2 bits */
  GPIOD->MODER |=  (0x55 << 24);  /* 0101_0101 = 全部为输出模式 */
  GPIOD->OTYPER &= ~(0xF << 12);  /* 推挽输出 */
  GPIOD->OSPEEDR &= ~(0xFF << 24); /* 低速 */
  GPIOD->PUPDR &= ~(0xFF << 24);  /* 无上下拉 */
  GPIOD->ODR  &= ~(0xF << 12);   /* 初始熄灭 */

  /* ===== 配置 PA0 为上拉输入(用户按钮) ===== */
  GPIOA->MODER  &= ~(0x3 << 0);  /* 输入模式 (00) */
  GPIOA->PUPDR  &= ~(0x3 << 0);  /* 清零 */
  GPIOA->PUPDR  |=  (0x1 << 0);  /* 上拉 (01) */
}

/**
 * @brief 读取按键(PA0),高有效(按下为高)
 *        STM32F407-Discovery 的 User Button 按下时 PA0 = 高
 */
uint8_t Button_Read(void) {
  return (GPIOA->IDR & (1 << 0)) ? 1 : 0;
}

按键消抖实现

#include "stm32f4xx.h"
#include "stm32f4xx_hal.h"  /* 使用 HAL_GetTick() 和 HAL_Delay() */

#define BTN_PIN      GPIO_PIN_0
#define BTN_PORT     GPIOA
#define DEBOUNCE_MS  20      /* 消抖时间窗口 20ms */

/**
 * @brief 软件消抖:检测按键是否被有效按下(高有效)
 *        本函数是阻塞式,仅适用于简单场景
 *        复杂场景应使用状态机方式(见下)
 */
uint8_t Button_Debounce_Blocking(void) {
  if (HAL_GPIO_ReadPin(BTN_PORT, BTN_PIN) == GPIO_PIN_SET) {
    HAL_Delay(DEBOUNCE_MS);     /* 等待抖动平息 */
    if (HAL_GPIO_ReadPin(BTN_PORT, BTN_PIN) == GPIO_PIN_SET) {
      while (HAL_GPIO_ReadPin(BTN_PORT, BTN_PIN) == GPIO_PIN_SET);  /* 等待松手 */
      HAL_Delay(DEBOUNCE_MS);   /* 松手消抖 */
      return 1;                  /* 确认一次有效按下 */
    }
  }
  return 0;
}

/* ──────────────────────────────────────────
   状态机消抖(非阻塞,推荐在主循环中调用)
   ────────────────────────────────────────── */
typedef enum {
  BTN_IDLE,       /* 等待按下 */
  BTN_PRESSING,   /* 检测到下降沿,等待消抖 */
  BTN_PRESSED,    /* 确认按下 */
  BTN_RELEASING   /* 检测到上升沿,等待消抖 */
} BtnState_t;

static BtnState_t btn_state = BTN_IDLE;
static uint32_t   btn_tick  = 0;

/**
 * @brief 非阻塞按键状态机,在主循环每次调用
 * @return 1=检测到一次完整按下并松手,0=否
 */
uint8_t Button_StateMachine(void) {
  uint8_t pin = (HAL_GPIO_ReadPin(BTN_PORT, BTN_PIN) == GPIO_PIN_SET);
  uint32_t now = HAL_GetTick();

  switch (btn_state) {
    case BTN_IDLE:
      if (pin) { btn_state = BTN_PRESSING; btn_tick = now; }
      break;
    case BTN_PRESSING:
      if (!pin) { btn_state = BTN_IDLE; }               /* 抖动,忽略 */
      else if (now - btn_tick >= DEBOUNCE_MS) { btn_state = BTN_PRESSED; }
      break;
    case BTN_PRESSED:
      if (!pin) { btn_state = BTN_RELEASING; btn_tick = now; }
      break;
    case BTN_RELEASING:
      if (pin) { btn_state = BTN_PRESSED; }              /* 抖动,忽略 */
      else if (now - btn_tick >= DEBOUNCE_MS) {
        btn_state = BTN_IDLE;
        return 1;  /* 一次完整的按下+松手 */
      }
      break;
  }
  return 0;
}

LED 呼吸灯(软件 PWM)

硬件 PWM 由定时器生成(第3章讲),这里用延时模拟 PWM 占空比变化,实现呼吸灯效果:

/**
 * @brief 软件 PWM 呼吸灯
 *        通过改变高低电平时间比(占空比),让 LED 亮度渐变
 *        注意:软件 PWM 占用 CPU,频率低,有闪烁感;实际应用用定时器 PWM
 */

#define LED_PIN   GPIO_PIN_12
#define LED_PORT  GPIOD
#define PWM_PERIOD_US  1000   /* PWM 周期 1000μs → 1kHz */

/* 简单微秒延时(基于 SysTick,非精确,仅演示)*/
void delay_us(uint32_t us) {
  uint32_t start = DWT_GetCycles();   /* 需要先使能 DWT */
  uint32_t cycles = us * (SystemCoreClock / 1000000);
  while (DWT_GetCycles() - start < cycles);
}

void LED_Breathe_Loop(void) {
  int duty;   /* 占空比 0~100 */

  for (int dir = 0; dir < 2; dir++) {   /* dir=0 渐亮,dir=1 渐暗 */
    for (duty = (dir ? 100 : 0);
         dir ? duty >= 0 : duty <= 100;
         dir ? duty-- : duty++) {
      /* 连续输出 20 个 PWM 周期让人眼感受到亮度 */
      for (int i = 0; i < 20; i++) {
        uint32_t on_time  = (PWM_PERIOD_US * duty) / 100;
        uint32_t off_time = PWM_PERIOD_US - on_time;
        if (on_time > 0) {
          HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET);
          delay_us(on_time);
        }
        if (off_time > 0) {
          HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET);
          delay_us(off_time);
        }
      }
    }
  }
}
HAL_GPIO 函数速查

HAL_GPIO_Init(GPIOx, &Init) — 初始化引脚
HAL_GPIO_WritePin(GPIOx, Pin, State) — 写引脚(底层用 BSRR)
HAL_GPIO_ReadPin(GPIOx, Pin) — 读引脚(读 IDR)
HAL_GPIO_TogglePin(GPIOx, Pin) — 翻转引脚(读-改-写 ODR,注意非原子)
HAL_GPIO_DeInit(GPIOx, Pin) — 复位引脚到默认状态(浮空输入)

电气安全:引脚保护

STM32 引脚绝对最大电压为 VDD+0.3V(约3.6V)。若外部电路有5V信号直接连接,必须使用电平转换电路(3.3V↔5V)或串联限流电阻(至少1kΩ)。直接连接5V信号到 GPIO 引脚可能永久损坏芯片。I/O 引脚最大持续电流:单引脚25mA,所有 I/O 总电流不超过 120mA。