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为输出模式,分两步走:
- 复用功能选择——通过IOMUX控制器,把物理引脚连接到GPIO模块。
- 方向设置——在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闪烁,每一步都离不开。
从这张图可以看得很清楚:时钟 → 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的跳变。不要只盯着代码看,硬件信号才是最终证据。