3、GPIO裸机驱动实战:时钟使能、模式配置、输出电平控制、输入电平读取

好,咱们今天直接上手GPIO。说实话,很多做Linux驱动的朋友,回过头来补裸机这课,往往卡在“不知道芯片怎么动起来”上。我个人习惯是,不管上层多复杂,先把寄存器敲一遍,心里才有底。

这一章,我们就拿一颗典型的ARM Cortex-M系列芯片(比如STM32F103)来拆解。你学会了这套流程,换任何一款ARM芯片,无非是查手册改地址的事。

3.1 GPIO驱动开发的“四步走”

说白了,控制一个GPIO引脚,就四件事:

  1. 开时钟 —— 让GPIO模块通电工作。
  2. 配模式 —— 告诉芯片这个脚是输出、输入,还是复用功能。
  3. 写数据 —— 输出高低电平。
  4. 读数据 —— 读取外部电平状态。

嗯,这里要注意:时钟使能是第一步。我见过太多新手,上来就配寄存器,结果怎么写都没反应——其实就是时钟没开。你想想看,模块电都没通,你喊破嗓子它也不理你。

核心原则: 操作任何外设寄存器前,必须先确保该外设的时钟已经被使能。这是ARM外设驱动的铁律。

3.2 时钟使能:让GPIO“醒过来”

ARM芯片里,时钟控制一般由RCC(Reset and Clock Control)模块负责。GPIO挂在哪条总线上,就去使能对应的时钟位。

以STM32F103的GPIOB为例,它挂在APB2总线上。我们需要操作RCC->APB2ENR寄存器。

// 定义寄存器地址(以STM32F103为例)
#define RCC_BASE        0x40021000
#define RCC_APB2ENR     (*(volatile unsigned int *)(RCC_BASE + 0x18))

// 使能GPIOB时钟(第3位)
RCC_APB2ENR |= (1 << 3);

我在项目中遇到过一个问题:某次调试时,GPIO死活拉不低。查了半天,发现是时钟使能位写错了,写到了APB1上。所以,一定要确认你的GPIO挂在哪条总线上。不同系列芯片的映射可能不同,别想当然。

GPIO组 挂载总线 使能位位置
GPIOA APB2 bit 2
GPIOB APB2 bit 3
GPIOC APB2 bit 4
小技巧: 我习惯在初始化函数开头,先把所有要用到的GPIO时钟一口气使能。这样后面写配置时,就不用担心漏掉了。

3.3 模式配置:告诉引脚“你要干什么”

时钟开了,接下来配模式。ARM的GPIO模式寄存器通常是CRL(低8位)和CRH(高8位)。每个引脚占4位,分别配置CNF和MODE。

以GPIOB的Pin 0为例,配置为推挽输出,50MHz速度:

// 定义GPIOB寄存器基址
#define GPIOB_BASE      0x40010C00
#define GPIOB_CRL       (*(volatile unsigned int *)(GPIOB_BASE + 0x00))

// 先清除Pin 0的4位配置(CNF0 + MODE0)
GPIOB_CRL &= ~(0xF << 0);

// 设置MODE0 = 11 (50MHz输出),CNF0 = 00 (推挽输出)
GPIOB_CRL |= (0x3 << 0);  // 0b0011 = 推挽输出,50MHz

这里有个坑,我曾经踩过:配置前一定要先清零。因为寄存器上电默认值不一定是0,不清零直接或操作,结果会乱套。

避坑指南: 我曾经在配置GPIO为输入模式时,忘了清零MODE位,结果MODE=00(输入模式)没配上,引脚一直处于输出状态,读到的电平永远是内部驱动值,而不是外部真实电平。切记:先清零,再赋值。

3.4 输出电平控制:拉高拉低,就这么简单

输出电平控制,操作的是ODR(Output Data Register)或BSRR(Bit Set/Reset Register)。

ODR是直接写整个端口的值,但操作时要注意“读-改-写”的原子性问题。我个人更推荐用BSRR,因为它可以单独设置或复位某一位,不需要读回。

// 定义BSRR寄存器
#define GPIOB_BSRR      (*(volatile unsigned int *)(GPIOB_BASE + 0x10))

// 设置Pin 0输出高电平(写低16位)
GPIOB_BSRR = (1 << 0);

// 设置Pin 0输出低电平(写高16位)
GPIOB_BSRR = (1 << (0 + 16));

你想想看,用BSRR的好处是什么?不需要关中断。ODR操作如果被中断打断,可能会读到旧值,写回去时把中断改过的位覆盖掉。BSRR是写1有效,写0不影响,天然安全。

3.5 输入电平读取:别被内部上下拉骗了

读取输入电平,操作IDR(Input Data Register)。

// 定义IDR寄存器
#define GPIOB_IDR       (*(volatile unsigned int *)(GPIOB_BASE + 0x08))

// 读取Pin 0的电平
uint8_t pin_state = (GPIOB_IDR >> 0) & 0x01;

嗯,这里有个容易忽略的点:输入模式下,引脚不能有内部上拉或下拉的冲突。我曾经调试一个按键输入,怎么读都是高电平。后来发现,芯片内部默认开启了上拉电阻,而外部电路又接了上拉,导致引脚被强拉到VCC,按键按下根本拉不低。

经验之谈: 读取输入前,先确认GPIO的配置寄存器里,CNF位是否设为了“浮空输入”(CNF=01)。如果外部电路已经有上拉/下拉,内部就设浮空;如果外部没有,再考虑内部上拉/下拉。

3.6 完整代码示例:点亮并读取一个LED

把上面几块拼起来,就是一个完整的GPIO驱动。下面这段代码,我实际在开发板上跑过,你可以直接参考。

// GPIOB Pin 0 驱动LED,Pin 1 读取按键

void GPIO_Init(void)
{
    // 1. 使能时钟
    RCC_APB2ENR |= (1 << 3);  // 使能GPIOB时钟

    // 2. 配置Pin 0为推挽输出,50MHz
    GPIOB_CRL &= ~(0xF << 0); // 清零
    GPIOB_CRL |= (0x3 << 0);  // MODE=11, CNF=00

    // 3. 配置Pin 1为浮空输入
    GPIOB_CRL &= ~(0xF << 4); // 清零
    GPIOB_CRL |= (0x4 << 4);  // MODE=00, CNF=01 (浮空输入)
}

void LED_On(void)
{
    GPIOB_BSRR = (1 << 0);    // Pin 0 输出高
}

void LED_Off(void)
{
    GPIOB_BSRR = (1 << (0 + 16)); // Pin 0 输出低
}

uint8_t Key_Read(void)
{
    return (GPIOB_IDR >> 1) & 0x01; // 读取Pin 1
}

int main(void)
{
    GPIO_Init();

    while(1)
    {
        if(Key_Read() == 0)  // 按键按下(低电平)
        {
            LED_On();
        }
        else
        {
            LED_Off();
        }
    }
}

3.7 GPIO驱动核心流程

为了让你更直观地理解整个流程,我画了一张图。这张图把“时钟使能 → 模式配置 → 输出/输入”的脉络串起来了。

GPIO裸机驱动核心流程 1. 时钟使能 (RCC->APB2ENR) 2. 模式配置 (CRL/CRH) 输出 or 输入? 输出 3a. 输出电平 (BSRR/ODR) 输入 3b. 读取电平 (IDR) 4. 完成驱动初始化 注意:输出和输入是互斥的,一个引脚同一时间只能选一种模式

3.8 避坑总结

最后,我把这些年踩过的坑,列个清单给你。你写代码时,对着检查一遍,能省不少调试时间。

  • 时钟没开就配寄存器 —— 写了等于白写,读回来全是默认值。
  • 配置前不清零 —— 旧值和新值叠加,结果不可控。
  • 输出模式下去读IDR —— 读到的不是外部电平,而是内部驱动值。
  • 输入模式下忘了设浮空 —— 内部上下拉和外部电路打架,电平不准。
  • 用ODR操作单个引脚 —— 多任务环境下可能被中断破坏,建议用BSRR。