第3章 GPIO驱动开发:点亮第一颗LED

好,咱们正式开始动手写驱动了。

点亮LED,在嵌入式圈子里就是"Hello World"一样的存在。你别看它简单,这里面的门道其实不少。我记得刚入行那会儿,觉得点个灯有啥难的?结果第一次上板子,灯死活不亮,查了半天发现是GPIO复用配置没搞对。嗯,从那以后我再也不敢小看这个"Hello World"了。

3.1 硬件原理:LED为什么会亮?

先搞清楚一件事:LED本质上就是个二极管。正向导通,反向截止。

在嵌入式系统里,我们通常用GPIO引脚来控制LED。有两种接法:

  • 高电平点亮:GPIO输出高电平(通常是3.3V或1.8V),LED正极接GPIO,负极通过限流电阻接地。
  • 低电平点亮:GPIO输出低电平(0V),LED正极接电源,负极通过限流电阻接GPIO。

我个人习惯用高电平点亮。为什么?因为大多数MCU上电后GPIO默认是输入或高阻态,用高电平点亮更安全,不会一上电就亮。

关键参数:

  • LED正向压降:红色约1.8V,绿色约2.2V,蓝色约3.0V
  • 工作电流:通常5mA~20mA
  • 限流电阻计算:R = (Vcc - Vf) / I

举个例子:Vcc=3.3V,红色LED Vf=1.8V,目标电流10mA。那么R = (3.3 - 1.8) / 0.01 = 150Ω。实际取个220Ω也行,亮度差别不大。

3.2 寄存器操作:GPIO的底层控制

说白了,GPIO驱动就是操作寄存器。MCU把GPIO的控制接口映射成一组寄存器,你往里面写值,引脚就输出对应电平。

以STM32F103为例,GPIO相关的寄存器主要有:

寄存器 功能 位宽
GPIOx_CRL 低8位引脚配置(Pin0~7) 32位
GPIOx_CRH 高8位引脚配置(Pin8~15) 32位
GPIOx_ODR 输出数据寄存器 32位
GPIOx_BSRR 位设置/清除寄存器 32位
GPIOx_BRR 位清除寄存器 32位

每个引脚需要4位配置。其中CNF[1:0]配置模式,MODE[1:0]配置速度。你想想看,32位寄存器刚好管8个引脚。

我的小技巧:操作ODR寄存器时,千万别用"读-改-写"的方式去操作单个位。用BSRR寄存器,原子操作,不会被打断。我在项目中遇到过因为中断里改了ODR导致主循环输出异常的情况,后来全换成BSRR就没事了。

3.3 点亮LED:从零开始写代码

好,咱们直接上代码。目标:点亮PA0引脚上的LED。

第一步:使能GPIOA时钟。

// RCC寄存器基地址
#define RCC_BASE        0x40021000
#define RCC_APB2ENR     *(volatile uint32_t *)(RCC_BASE + 0x18)

// 使能GPIOA时钟(bit2)
RCC_APB2ENR |= (1 << 2);

第二步:配置PA0为推挽输出,50MHz速度。

// GPIOA寄存器基地址
#define GPIOA_BASE      0x40010800
#define GPIOA_CRL       *(volatile uint32_t *)(GPIOA_BASE + 0x00)

// 先清除PA0的4位配置
GPIOA_CRL &= ~(0xF << 0);
// 配置为推挽输出,50MHz:MODE=11, CNF=00
GPIOA_CRL |= (0x3 << 0);

第三步:输出高电平,点亮LED。

#define GPIOA_BSRR      *(volatile uint32_t *)(GPIOA_BASE + 0x10)

// 设置PA0为高电平(BSRR低16位是set)
GPIOA_BSRR = (1 << 0);

完整代码整合一下:

void delay(volatile uint32_t count) {
    while(count--);
}

int main(void) {
    // 1. 使能时钟
    RCC_APB2ENR |= (1 << 2);
    
    // 2. 配置GPIO
    GPIOA_CRL &= ~(0xF << 0);
    GPIOA_CRL |= (0x3 << 0);
    
    while(1) {
        // 3. 点亮
        GPIOA_BSRR = (1 << 0);
        delay(1000000);
        
        // 4. 熄灭(BSRR高16位是reset)
        GPIOA_BSRR = (1 << 16);
        delay(1000000);
    }
}

注意:delay函数里的计数值要根据实际主频调整。我曾经在调试时忘了改这个值,结果LED闪得跟频闪仪似的,眼睛都快瞎了。建议先用示波器或者逻辑分析仪确认一下实际频率。

3.4 封装驱动接口:让代码更优雅

裸写寄存器虽然直接,但可读性太差。我建议你封装成函数,以后复用起来方便。

// gpio_driver.h
#ifndef __GPIO_DRIVER_H
#define __GPIO_DRIVER_H

#include <stdint.h>

typedef enum {
    GPIO_MODE_INPUT = 0,
    GPIO_MODE_OUTPUT_PP,    // 推挽输出
    GPIO_MODE_OUTPUT_OD,    // 开漏输出
    GPIO_MODE_AF_PP,        // 复用推挽
    GPIO_MODE_AF_OD         // 复用开漏
} GPIO_Mode_t;

typedef enum {
    GPIO_SPEED_10MHZ = 1,
    GPIO_SPEED_2MHZ  = 2,
    GPIO_SPEED_50MHZ = 3
} GPIO_Speed_t;

void GPIO_Init(uint32_t gpio_base, uint8_t pin, 
               GPIO_Mode_t mode, GPIO_Speed_t speed);
void GPIO_SetPin(uint32_t gpio_base, uint8_t pin);
void GPIO_ResetPin(uint32_t gpio_base, uint8_t pin);
void GPIO_TogglePin(uint32_t gpio_base, uint8_t pin);

#endif

实现文件里,把刚才的寄存器操作封装进去:

// gpio_driver.c
#include "gpio_driver.h"

void GPIO_Init(uint32_t gpio_base, uint8_t pin, 
               GPIO_Mode_t mode, GPIO_Speed_t speed) {
    volatile uint32_t *cr;
    uint8_t offset;
    
    // 判断是CRL还是CRH
    if(pin < 8) {
        cr = (volatile uint32_t *)(gpio_base + 0x00);
        offset = pin * 4;
    } else {
        cr = (volatile uint32_t *)(gpio_base + 0x04);
        offset = (pin - 8) * 4;
    }
    
    // 清除原配置
    *cr &= ~(0xF << offset);
    // 写入新模式和速度
    *cr |= ((mode << 2) | speed) << offset;
}

void GPIO_SetPin(uint32_t gpio_base, uint8_t pin) {
    volatile uint32_t *bsrr = (volatile uint32_t *)(gpio_base + 0x10);
    *bsrr = (1 << pin);
}

void GPIO_ResetPin(uint32_t gpio_base, uint8_t pin) {
    volatile uint32_t *bsrr = (volatile uint32_t *)(gpio_base + 0x10);
    *bsrr = (1 << (pin + 16));
}

void GPIO_TogglePin(uint32_t gpio_base, uint8_t pin) {
    volatile uint32_t *odr = (volatile uint32_t *)(gpio_base + 0x0C);
    *odr ^= (1 << pin);
}

你看,封装之后主函数就清爽多了:

int main(void) {
    RCC_APB2ENR |= (1 << 2);  // 使能时钟
    
    GPIO_Init(GPIOA_BASE, 0, GPIO_MODE_OUTPUT_PP, GPIO_SPEED_50MHZ);
    
    while(1) {
        GPIO_SetPin(GPIOA_BASE, 0);
        delay(1000000);
        GPIO_ResetPin(GPIOA_BASE, 0);
        delay(1000000);
    }
}

经验之谈:TogglePin函数里我用了异或操作,这在调试时特别有用。你可以在中断服务函数里加一个GPIO_TogglePin,用示波器看引脚波形,就能精确测量中断执行时间。这招我用了好多年,屡试不爽。

3.5 避坑指南:那些年我踩过的GPIO坑

最后,分享几个实战中容易翻车的地方:

  1. 时钟没使能:写寄存器没反应,读回来全是0。这是最常犯的错误,没有之一。
  2. 复用功能冲突:有些引脚默认被JTAG/SWD占用。比如PA15、PB3、PB4。我曾在PA15上接LED,结果怎么配置都不亮,后来才发现JTAG占着呢。
  3. 开漏输出忘加上拉:开漏输出本身不会输出高电平,必须外部上拉。如果你看到LED亮度不对,八成是这个问题。
  4. BSRR和ODR混用:BSRR是原子操作,ODR不是。在多任务或中断环境下,务必用BSRR。

我曾经...在一个量产项目里,因为GPIO初始化顺序不对,导致两个引脚在启动瞬间同时输出高电平,直接把一个MOSFET给烧了。从那以后,我每次写GPIO驱动都会先确认:上电默认状态是什么?初始化过程中会不会产生毛刺?

嗯,点亮LED看似简单,但它是你理解嵌入式系统底层运作的起点。把这一步走扎实了,后面的UART、I2C、SPI驱动,其实都是类似的套路——操作寄存器,封装接口,注意时序。

下一章,咱们聊聊按键输入和中断。到时候你会发现,输入比输出要复杂得多。