首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

通用IO(GPIO)端口驱动LED灯

我们在一个极精简的工程上分析了系统刚上电后是如何开始工作的, 然后介绍了MCU得以正常运行的时钟系统。 在本文中,我们主要了解访问片上外设寄存器的方法,以通用IO(GPIO, General Perpose I/O)为例,通过GPIO端口驱动LED灯。

这里我们参照官方库访问片上外设寄存器。准备工作可以分为三个部分:(1)定义描述片上外设寄存器的结构体; (2)定义片上外设起始地址宏; (3)根据起始地址和结构体定义指向片上外设的指针。在介绍访问片上外设的方法之前,针对本文的例子, 我们先介绍STM32上的引脚。

1. I/O引脚的功能简介

查看芯片手册和探索者原理图, 我们会发现除了一些与电源相关的引脚外,几乎所有的都是I/O引脚。通过这些引脚的高低电平,可以实现MCU与其余芯片或者设备之间的通信, 进一步的实现我们期望的各种应用。

这些I/O引脚可以用作不同的功能,例如PA2脚可以用作TIM2的通道,也可以用作USART2的发送引脚。 但是同一时间每个引脚只能用作一种功能。每个I/O端口都可以通过GPIO功能直接控制输出高低电平,或者读取引脚上的输入电平,甚至是读取引脚上的电压值。 GPIO是每个引脚都拥有的基本功能,引脚的其它功能被称为复用功能(Alternate Function, AF)。

每个引脚可以有8种工作状态:

其中模拟输入通常用于ADC(模拟/数字转换),推挽复用功能和开漏复用功能就是用于引脚的复用功能,其余的工作模式均用于GPIO。 这里涉及到的上拉、下拉、浮空、推挽、开漏均是指I/O引脚的工作状态与功能无关,具体需要根据实际电路的连接方式决定。 相关名词以及可能产生的作用我们会在下面I/O引脚结构中进行解释。

2. 芯片内部I/O引脚结构

图1是从Reference Manual中直接截下来的图。I/O接口是一个MCU必须具备的基本外设,它由钳位二极管(Protection diode)、 可控制的上下拉电阻(Pull up/Pull down)、输入驱动器(Input driver)、输出驱动器(Output driver)、输入数据寄存器(Input data register)、 输出数据寄存器(Output data register)、置/复位寄存器(Bit set/reset register)几个部分组成。

图1 片上I/O引脚结构

钳位二极管存在的目的是防止外部接入的引脚电压过高或者过低对芯片造成了损害。 如果外部引脚的电压大于VDD_FT与二极管压降的和,那么上面的二极管就会导通。 这样输入电压就限定在了略高于VDD_FT的值上。同样的道理,如果外部引脚的电压过低小于VSS减去二极管压降的值时, 下面的二极管就会导通,输入的电压就限定在略低于VSS的值上。这就好像输入电压被钳制在了VSS到VDD_FT之间,因此称之为钳位二极管。

所谓的上拉电阻指的是引脚通过一个电阻与VDD连接,这个电阻就被称为上拉电阻。 接有上拉电阻的引脚在没有任何输入或者输出信号的情况下,因为有上拉电阻和VDD的存在,引脚上电平就好像被电阻拉在了高位上,表现为接近VDD的高电平。 而当是低电平信号时,电阻的存在保持了低电平引脚与VDD之间的一段压降,使得引脚电压与地接近。
下拉电阻则指的是引脚通过一个电阻与地(或者是VSS)连接,这个电阻就是下拉电阻。 其原理与上拉电阻类似,所不同之处在于,当没有任何信号时,引脚会被默认拉低,表现为低电平。
网上搜索上拉电阻和下拉电阻的作用时写了一堆,写的太专业都没怎么看懂。根据其工作原理,个人理解的它们的作用就只有两个:

至于网上说的各种各样奇奇怪怪的功能,都应该可以归结到以上两点。

输入驱动器主要就是一个施密特触发器,它的作用是对输入信号进行处理,把变化缓慢或者畸形的信号调整成较为理想的矩形脉冲信号。 在施密特触发器之前我们可以得到引脚的输入电压,是模拟(Analog)输入的信号源。经过触发器调整之后的信号就是数字上的逻辑信号, 它将用作复用功能的输入数据源,或者是GPIO的输入数据源。
GPIO的输入功能有三种状态:浮空输入、上拉输入、下拉输入。浮空输入是指引脚既不接上拉电阻也不接下拉电阻,引脚的电平完全由输入信号决定。 在没有信号输入时,引脚的电平既不是高电平也不是低电平,处于一种高阻态其输入或者输出电阻很大。可以将其理解为断路,就好像引脚被悬空了一样, 对下一级的电路完全没有影响。顾名思义,上拉输入和下拉输入是指输入引脚接了上拉或者下拉电阻。 那么在没有信号输入的情况下,引脚默认是高电平或者低电平。

输出驱动器由一个输出控制器和一个推挽放大器组成。输出控制器决定了输出引脚的数据源是GPIO还是复用功能, 也控制了推挽放大器是工作在推挽状态还是开漏状态。对于数据源是GPIO还是复用功能很好理解,这里主要解释一下推挽状态和开漏状态。
推挽放大器有两只MOS管,分别工作在信号的正半周和负半周,两只MOS管不会同时导通,否则阻抗很小会产生大电流损坏芯片。 在信号的正半周,上面的P-MOS导通输出引脚的电平被拉高至VDD;负半周下面的N-MOS导通输出引脚拉低至VSS。 工作在推挽状态下的推挽放大器既可以向负载灌电流也可以从负载中拉电流。就负载而言就好像一个在推电流一个在拉电流,因而被称为推挽。
而开漏状态下上面的P-MOS管始终不会导通,在信号的正半周N-MOS管关断,输出引脚不接地;而在负半周N-MOS管导通,输出引脚接地。 可见这种状态下引脚的输出实际上是接地不接地,如果想得到高低电平,需要用一个上拉电阻控制不接地的情况下默认输出高电平。

3. IO端口的寄存器

IO端口寄存器与Cortex-M4的寄存器不一样, 后者是ARM处理器内核的寄存器,可以通过汇编语句直接访问,在内存中没有映射。前者则是MCU的片上外设的寄存器, 它们在内存中对应一段地址,通过访问这段地址空间下的内存就可以实现对其的读写访问,进而达到控制片上外设的目的, 它们是不能够通过汇编语句访问。

Cortex-M4的内存系统中, 从0x4000_0000到0x5FFF_FFFF的一段0.5GB的空间被分配给了系统外设,其中GPIO的寄存器地址起始于0x4002_0000, 图2是从Datasheet上外设内存分配表格中截下的一段,可见STM32F407一共有9个GPIO端口,分别标记为GPIOA到GPIOI。 它们被分配到了一段连续的地址空间中,每个GPIO占有0x3FF长度的空间。此外还可以看到GPIO是挂在AHB1总线上的, 它们的工作时钟由AHB1总线时钟驱动。

图2 片上外设内存映射举例

每个GPIO端口都有16个引脚, 它们可以分别通过4个配置寄存器(GPIOx_MODER, GPIOx_OTYPER, GPIOx_SPEEDR和GPIOx_PUPDR)设置工作在不同的状态下, 有两个数据寄存器GPIOx_IDR和GPIOx_ODR用于读取引脚电平或者控制引脚输出电平,一个置/复位寄存器用于按位控制输出电平, 一个配置锁定寄存器(GPIOx_LCKR)和两个功能选择寄存器(GPIOx_AFRH, GPIOx_AFRL)。

具体的寄存器功能和位定义这里就不再赘述了,在Reference Manual中有明确的描述。这里主要讲述如何访问这些寄存器。 通过查询Datasheet我们知道GPIOA的起始地址是0x40020000,所以我们定义如下的宏:

        #define GPIOA_BASE 0x40020000
        #define GPIOA ((gpio_regs_t *)GPIOA_BASE)
我们先定义了GPIOA_BASE它描述了GPIOA的起始地址,然后通过强制类型转换定义了GPIOA, 把0x40020000描述成为一个指向gpio_regs_t类型的指针。这就是在文章一开始提到的访问一个片上外设需要准备的三个东西: 片上外设结构体(gpio_regs_t)起始地址宏定义(GPIOA_BASE)、和片上外设指针(GPIOA)。 完成这三个定义之后,我们就可以通过类似如下的语句访问片上外设的寄存器。
        GPIOA->MODER
        GPIOA->OTYPER
关于这三个要素是如何组合起来访问外设寄存器的原理,可以参见上一篇文章。 GPIOA->MODER对应的就是配置端口工作模式的寄存器,这点是由gpio_regs_t完全按照Reference Manual描述的偏移地址组织各个成员变量来保证的,如下:
        typedef struct gpio_regs {
            volatile union gpio_moder MODER;        /* 端口模式寄存器, offset: 0x00 */
            volatile union gpio_otyper OTYPER;      /* 端口输出类型寄存器, offset: 0x04 */
            volatile union gpio_ospeedr OSPEEDR;    /* 端口输出速度寄存器, offset: 0x08 */
            volatile union gpio_pupdr PUPDR;        /* 端口上拉下拉电阻寄存器, offset: 0x0C */
            volatile union gpio_idr IDR;            /* 端口输入数据寄存器, offset: 0x10 */
            volatile union gpio_odr ODR;            /* 端口输出数据寄存器, offset: 0x14 */
            volatile union gpio_bsrr BSRR;          /* 端口Set/Reset寄存器, offset: 0x18 */
            volatile union gpio_lckr LCKR;          /* 端口配置锁定寄存器, offset: 0x1C */
            volatile union gpio_afr AFR;            /* 端口复选功能寄存器, offset: 0x20-0x24 */
        } gpio_regs_t;
这里的volatile是一个关键字告诉编译器不要对后面的变量进行优化。每个成员变量都被描述成为了一个联合体, 这里以MODER为例简单介绍一下。MODER的偏移地址是0x00,所以被定义成了结构体的第一个变量,它是一个32位的寄存器, 所以定义的联合体如下:
        union gpio_moder {
            struct gpio_moder_bits bits;
            uint32 all;
        };
在C语言中联合体表示其中的成员共享一段内存,通过成员名称可以对这段内存有不同的解释。 例如在这里,我们可以以32位的无符号数来访问MODER寄存器:
        GPIOA->MODER->all
也可以通过结构体struct gpio_moder_bits来访问寄存器:
        GPIOA->MODER->bits
本质上它们都是访问0x40020000下的32位内存,只是解释的方式不一样。结构体struct gpio_moder_bits的定义如下:
        struct gpio_moder_bits {
            uint32 pin0 : 2;
            uint32 pin1 : 2;
            uint32 pin2 : 2;
            uint32 pin3 : 2;
            uint32 pin4 : 2;
            uint32 pin5 : 2;
            uint32 pin6 : 2;
            uint32 pin7 : 2;
            uint32 pin8 : 2;
            uint32 pin9 : 2;
            uint32 pin10 : 2;
            uint32 pin11 : 2;
            uint32 pin12 : 2;
            uint32 pin13 : 2;
            uint32 pin14 : 2;
            uint32 pin15 : 2;
        };
在这个结构体中,我们把MODER按位拆分成了16个字段,每个字段占两位分别对应pin0到pin15。 这与手册上描述的MODER位定义完全一致,其余的寄存器都是这样实现的。
那么,我们就可以通过如下的代码直接访问MODER的每个位了。
        GPIOA->MODER.bits.pin0

4. 点亮LED灯

LED灯就是一个发光二极管,导通时发光。 根据探索者原理图, 我们知道LED灯的控制引脚接到了GPIOF的第9、10脚,而且是输出低电平时点亮LED。

因此我们可以通过函数init_led对这三个引脚进行初始化。
        void init_led() {
            RCC->AHB1ENR.bits.gpiof = 1;

            GPIOF->MODER.bits.pin9 = GPIO_Mode_Out;
            GPIOF->MODER.bits.pin10 = GPIO_Mode_Out;

            GPIOF->OTYPER.bits.pin9 = GPIO_OType_PP;
            GPIOF->OTYPER.bits.pin10 = GPIO_OType_PP;

            GPIOF->PUPDR.bits.pin9 = GPIO_Pull_Up;
            GPIOF->PUPDR.bits.pin10 = GPIO_Pull_Up;

            GPIOF->OSPEEDR.bits.pin9 = GPIO_OSpeed_Very_High;
            GPIOF->OSPEEDR.bits.pin10 = GPIO_OSpeed_Very_High;
        }
然后我们就可以控制点亮或者熄灭LED灯,下面的代码可以点亮LED_0。
        GPIOF->ODR.bits.pin9 = 0;

5. 总结

在本部分中我们先介绍了I/O引脚的结构和功能:

然后,介绍了如何直接访问GPIO的各个寄存器和位定义。为此我们定义了一系列的结构体和联合体。 关于联合体的使用,有人会说这不安全,但我觉得好用就行。

最后,我们直接给出了一个函数来初始化控制LED灯需要的I/O引脚,并给了一个点亮LED_0的例子。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1