4. GPIO驱动开发:寄存器映射、输出模式配置与LED点灯实战

好,咱们进入第四章。前面几章我们把ARMv7的架构、内存映射和启动流程捋了一遍,算是把芯片的“骨架”搭好了。这一章,我们来给它注入第一缕“灵魂”——让一个LED灯亮起来。

你可能会觉得,点个灯而已,有什么好讲的?我当年刚入行时也这么想。直到我在一个项目中,因为GPIO初始化顺序搞错,导致整个电机驱动板冒烟……嗯,从那以后,我再也不敢小看任何一个GPIO引脚。说白了,GPIO是MCU和外界交互的“手脚”,手脚都控制不好,还谈什么RTOS任务调度?

4.1 GPIO寄存器映射:找到灯的“门牌号”

ARM Cortex-A7的GPIO外设,和STM32这类Cortex-M系列不太一样。它没有那种“一键配置”的HAL库,你得直接和寄存器打交道。我个人习惯,拿到一块新板子,第一件事就是翻开芯片参考手册,找到GPIO章节的寄存器地址表。

以我们课程用的i.MX6ULL为例,它的GPIO外设挂载在AXI总线上,基地址是0x0209C000。每个GPIO组(比如GPIO1、GPIO2)都有一组固定的寄存器偏移量。我整理了一个常用寄存器表:

寄存器名称 偏移地址 宽度 功能描述
DR 0x000 32位 数据寄存器,读写引脚电平
GDIR 0x004 32位 方向寄存器,0=输入,1=输出
PSR 0x008 32位 引脚状态寄存器,只读
ICR1 0x00C 32位 中断配置寄存器1(低16位引脚)
ICR2 0x010 32位 中断配置寄存器2(高16位引脚)
IMR 0x014 32位 中断掩码寄存器
ISR 0x018 32位 中断状态寄存器
EDGE_SEL 0x01C 32位 边沿选择寄存器

你想想看,这么多寄存器,我们点灯需要哪几个?其实就两个核心的:GDIR(设置方向)和DR(写电平)。其他的中断相关寄存器,后面讲按键中断时再用。

关键点:GPIO的基地址 + 偏移量 = 寄存器的绝对地址。在C语言里,我们通过#define宏定义或者结构体指针来访问。我建议用结构体方式,代码可读性更高。

来看一下寄存器映射的代码实现:

/* GPIO寄存器结构体定义 */
typedef struct {
    volatile uint32_t DR;        /* 数据寄存器,偏移0x000 */
    volatile uint32_t GDIR;      /* 方向寄存器,偏移0x004 */
    volatile uint32_t PSR;       /* 状态寄存器,偏移0x008 */
    volatile uint32_t ICR1;      /* 中断配置1,偏移0x00C */
    volatile uint32_t ICR2;      /* 中断配置2,偏移0x010 */
    volatile uint32_t IMR;       /* 中断掩码,偏移0x014 */
    volatile uint32_t ISR;       /* 中断状态,偏移0x018 */
    volatile uint32_t EDGE_SEL;  /* 边沿选择,偏移0x01C */
} GPIO_TypeDef;

/* GPIO基地址映射 */
#define GPIO1_BASE      (0x0209C000)
#define GPIO2_BASE      (0x020A0000)
#define GPIO3_BASE      (0x020A4000)
#define GPIO4_BASE      (0x020A8000)
#define GPIO5_BASE      (0x020AC000)

/* 将基地址强制转换为结构体指针 */
#define GPIO1           ((GPIO_TypeDef *)GPIO1_BASE)
#define GPIO2           ((GPIO_TypeDef *)GPIO2_BASE)
#define GPIO3           ((GPIO_TypeDef *)GPIO3_BASE)
#define GPIO4           ((GPIO_TypeDef *)GPIO4_BASE)
#define GPIO5           ((GPIO_TypeDef *)GPIO5_BASE)

小技巧:关键字volatile一定不能丢。它告诉编译器,这个变量的值可能被硬件修改,不要做优化。我曾经见过有人忘了加volatile,结果点灯死活不亮,debug了一整天……

4.2 输出模式配置:让引脚“说话”

寄存器映射好了,接下来就是配置。配置GPIO为输出模式,分两步走:

  1. 复用功能选择——通过IOMUX控制器,把物理引脚连接到GPIO模块。
  2. 方向设置——在GDIR寄存器中,把对应位写1。

很多初学者只记得设GDIR,忘了配IOMUX,结果灯就是不亮。我当年也犯过这个错,查了半天手册才发现,原来引脚默认是作为UART功能的。

以i.MX6ULL的GPIO1_IO03为例(对应开发板上的LED0),它的IOMUX配置寄存器地址是0x020E0068。我们需要把这个寄存器的低3位设为0b101(ALT5模式),才能让这个引脚工作在GPIO功能下。

/* IOMUX配置:将GPIO1_IO03设为GPIO功能 */
#define IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03   (0x020E0068)

void GPIO_Init(void)
{
    uint32_t *mux_reg;
    
    /* 第一步:配置IOMUX复用功能 */
    mux_reg = (uint32_t *)IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03;
    *mux_reg = 0x5;  /* ALT5,对应GPIO功能 */
    
    /* 第二步:设置GPIO方向为输出 */
    GPIO1->GDIR |= (1 << 3);  /* GPIO1_IO03设为输出 */
    
    /* 第三步:初始输出高电平(LED熄灭) */
    GPIO1->DR |= (1 << 3);
}

注意:IOMUX的配置必须在GPIO方向设置之前完成。顺序反了,可能会导致引脚输出一个短暂的错误电平。我在一个电机控制项目里就吃过这个亏,上电瞬间电机抖了一下,差点把机械结构打坏。

4.3 LED点灯实战:从寄存器到亮灭

好了,理论讲完了,咱们直接上实战。我们的目标是:让开发板上的LED0以1Hz的频率闪烁。

先看一下硬件连接:

LED编号 GPIO引脚 有效电平 限流电阻
LED0 GPIO1_IO03 低电平点亮 470Ω
LED1 GPIO1_IO02 低电平点亮 470Ω
LED2 GPIO5_IO01 低电平点亮 470Ω

注意看,这里是低电平点亮。也就是说,DR寄存器对应位写0,灯亮;写1,灯灭。这和很多开发板是反的,写代码时一定要确认好原理图。

完整的点灯代码:

/* led.h */
#ifndef __LED_H
#define __LED_H

#include "imx6ul.h"

/* LED引脚宏定义 */
#define LED0_PIN        (3)     /* GPIO1_IO03 */
#define LED1_PIN        (2)     /* GPIO1_IO02 */
#define LED2_PIN        (1)     /* GPIO5_IO01 */

/* LED控制宏 */
#define LED0_ON()       (GPIO1->DR &= ~(1 << LED0_PIN))
#define LED0_OFF()      (GPIO1->DR |= (1 << LED0_PIN))
#define LED0_TOGGLE()   (GPIO1->DR ^= (1 << LED0_PIN))

/* 函数声明 */
void LED_Init(void);
void LED_On(int led_id);
void LED_Off(int led_id);
void LED_Toggle(int led_id);

#endif

/* led.c */
#include "led.h"
#include "delay.h"

void LED_Init(void)
{
    volatile uint32_t *mux_reg;
    
    /* 配置GPIO1_IO03的IOMUX */
    mux_reg = (uint32_t *)IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03;
    *mux_reg = 0x5;
    
    /* 配置GPIO1_IO02的IOMUX */
    mux_reg = (uint32_t *)IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO02;
    *mux_reg = 0x5;
    
    /* 配置GPIO5_IO01的IOMUX */
    mux_reg = (uint32_t *)IOMUXC_SW_MUX_CTL_PAD_GPIO5_IO01;
    *mux_reg = 0x5;
    
    /* 设置GPIO方向为输出 */
    GPIO1->GDIR |= (1 << LED0_PIN) | (1 << LED1_PIN);
    GPIO5->GDIR |= (1 << LED2_PIN);
    
    /* 初始状态:所有LED熄灭 */
    GPIO1->DR |= (1 << LED0_PIN) | (1 << LED1_PIN);
    GPIO5->DR |= (1 << LED2_PIN);
}

void LED_On(int led_id)
{
    switch(led_id) {
        case 0:
            GPIO1->DR &= ~(1 << LED0_PIN);
            break;
        case 1:
            GPIO1->DR &= ~(1 << LED1_PIN);
            break;
        case 2:
            GPIO5->DR &= ~(1 << LED2_PIN);
            break;
        default:
            break;
    }
}

void LED_Off(int led_id)
{
    switch(led_id) {
        case 0:
            GPIO1->DR |= (1 << LED0_PIN);
            break;
        case 1:
            GPIO1->DR |= (1 << LED1_PIN);
            break;
        case 2:
            GPIO5->DR |= (1 << LED2_PIN);
            break;
        default:
            break;
    }
}

void LED_Toggle(int led_id)
{
    switch(led_id) {
        case 0:
            GPIO1->DR ^= (1 << LED0_PIN);
            break;
        case 1:
            GPIO1->DR ^= (1 << LED1_PIN);
            break;
        case 2:
            GPIO5->DR ^= (1 << LED2_PIN);
            break;
        default:
            break;
    }
}

/* main.c */
#include "led.h"
#include "delay.h"

int main(void)
{
    /* 初始化LED */
    LED_Init();
    
    while(1) {
        LED0_ON();      /* 点亮LED0 */
        delay_ms(500);  /* 延时500ms */
        LED0_OFF();     /* 熄灭LED0 */
        delay_ms(500);  /* 延时500ms */
    }
    
    return 0;
}

这段代码跑起来,LED0就会以1Hz的频率闪烁。你可能会问,delay_ms()是怎么实现的?简单来说,就是让CPU空转,消耗时钟周期。后面讲到RTOS时,我们会用任务调度来代替这种“傻等”方式。

4.4 核心逻辑图解:GPIO点灯全流程

为了让你对整个流程有个直观的认识,我画了一张图。从芯片上电到LED闪烁,每一步都离不开。

GPIO点灯完整流程 芯片上电复位 CCM时钟使能GPIO模块 IOMUX设置复用功能 GDIR设为输出模式 DR寄存器写0/1 LED点亮/熄灭 关键寄存器 • CCM: 时钟门控寄存器 • IOMUX: 引脚复用选择器 • GDIR/DR: GPIO核心寄存器 常见错误 • 忘记使能时钟 → 寄存器写不进 • IOMUX配错 → 引脚功能不对

从这张图可以看得很清楚:时钟 → IOMUX → GDIR → DR,这个顺序一步都不能乱。我见过有人把GDIR和DR的顺序搞反,先写DR再设方向,结果引脚输出了一瞬间的错误电平。

4.5 避坑指南与经验总结

做嵌入式开发,尤其是裸机编程,细节决定成败。下面这几个坑,是我这些年一个一个踩出来的:

  • 时钟使能不能忘——i.MX6ULL的GPIO模块默认是关闭时钟的。你不开时钟,写寄存器等于白写。对应的寄存器是CCM_CCGRx,需要把对应位写为0b11(使能所有模式)。
  • volatile关键字——前面提过,再强调一次。编译器优化会“吃掉”你对寄存器的写操作,尤其是循环里反复读写同一个地址时。
  • 位操作别用错——设置位用|=,清除位用&=~。很多人写GPIO1->DR &= (1 << 3),结果把其他引脚的电平也清掉了。
  • 确认硬件极性——LED是低电平点亮还是高电平点亮?一定要看原理图。我有个同事,照着网上的代码抄,结果灯死活不亮,最后发现板子的LED是共阳极接法。

重要提醒:在调试GPIO时,建议先用万用表量一下引脚电压。如果配置正确,引脚应该能测到0V和3.3V的跳变。不要只盯着代码看,硬件信号才是最终证据。