为方便更好的学习STM32单片机,本篇博客主要总结STM32的入门基础知识,重点在于理解寄存器以及存储器映射和寄存器映射,深刻体会STM32是如何组织和管理庞大的寄存器,从而提高开发效率的,为后面的基于标准库的开发做好铺垫,务必认真学习,深刻理解。
目录
一、C 语言基础知识复习
1.0 stdint.h
1.1 位操作
1.2 #define宏定义
1.3 ifdef条件编译
1.4 extern外部声明
1.5 typedef类型重命名(重点理解 )
1.6 结构体(重点理解)
1.6.1 概念
1.6.2 结构体的定义
1.6.3 结构体成员变量的访问
1.6.4 结构体传参
1.6.5 结构体在STM32单片机程序开发中如何发挥作用?(深刻理解)
1.7 指针(重点理解 )
1.8 嵌入式单片机C代码规范与风格
二、 寄存器基础知识(重点理解 )
2.1 什么是寄存器及作用?
2.2 寄存器的分类
2.3 寄存器应用举例
三、 STM32F407 系统架构(重点理解 )
3.1 Cortex M4内核&芯片
3.2 STM32系统架构
四、存储器映射(重点理解 )
4.1 STM32单片机的寻址范围
4.2 存储器映射
4.3 存储器区域功能划分
4.3.1 存储器 Block0 内部区域功能划分
4.3.2 存储器 Block1 内部区域功能划分
4.3.3 存储器 Block2 内部区域功能划分
五、寄存器映射(重点理解 )
5.1 什么是寄存器映射,简单举例
5.2 寄存器地址的计算(必须会)
5.2.1 总线基地址
5.2.2 外设基地址
5.2.3 外设寄存器的地址
5.3 寄存器描述解读
5.4 C 语言对寄存器的封装
5.5 总结
5.6 举例
一、C 语言基础知识复习
本节主要复习一下 C 语言基础知识,对于 C 语言比较熟练的读者,可以跳过此节, 对于基础比较薄弱的读者,建议好好学习一下本节内容。 由于 C 语言博大精深,不可能我们一小节就全讲明白了,所以本节我们只是复习 STM32 开发时常用的几个 C 语言知识点,以便大家的更好的学习并编写 STM32 代码。
1.0 stdint.h
stdint.h是从C语言的标准C99中引进的一个标准C库的文件,里面主要是基本的数据类型进行重命名,要在Keil5中应用命名后的数据类型名,必须要在keil5中进行配置,这个头文件的在安装包的路径为:C:\Keil-v5\ARM\ARMCC\include。
1.1 位操作
C 语言位操作相信学过 C 语言的人都不陌生了,简而言之,就是对基本类型变量可以在位级别进行操作。这节的内容很多朋友都应该很熟练了,我这里也就点到为止,不深入探讨。着重讲解位操作在单片机开发中的一些实用技巧。C 语言支持如下 6 种位操作:
应用场景1:在不改变其他位的值的状况下,对某几个位进行设值。这个场景在单片机开发中经常使用,方法就是:先对需要设置的位用&操作符进行清零操作, 然后用|操作符设值。
比如我要改变 GPIOA 的 CRL 寄存器 bit6(第 6 位)的值为 1;//1.可以先对寄存器的值进行&清零操作:
GPIOA->CRL &= 0XFFFFFFBF; //将第 bit6 清 0 (1011 1111)//2.然后再与需要设置的值进行|或运算:
GPIOA->CRL |= 0X00000040; //设置 bit6 的值为 1,不改变其他位的值 (0100 0000)
应用场景2:移位操作提高代码的可读性,移位操作在单片机开发中非常重要
下面是 delay_init 函数的一行代码:
SysTick->CTRL |= 1 << 1;
上面这行代码的操作就是将 CTRL 寄存器的第 1 位(从 0 开始算起)设置为 1,为什么要通过左移而不是直接设置一个固定的值呢?其实这是为了提高代码的可读性以及可重用性。 如果写成: SysTick->CTRL |= 0X0002; 这个虽然也能实现同样的效果,但是可读性稍差,而且修改也比较麻烦。
应用场景3:~按位取反操作使用技巧,常用于清除某一个/某几个位。
SR 寄存器的每一位都代表一个状态,某个时刻我们希望去设置某一位的值为 0,同时 其他位都保留为 1,简单的作法是直接给寄存器设置一个值: TIMx->SR=0xFFF7; 这样的作法设置第 3 位为 0,但是这样的作法同样不好看,并且可读性很差。看看库函数 代码中怎样使用的:
TIMx->SR = (uint16_t)~TIM_FLAG; 而 TIM_FLAG 是通过宏定义定义的值:
#define TIM_FLAG_Update ((uint16_t)0x0001)
#define TIM_FLAG_CC1 ((uint16_t)0x0002)
看这个应该很容易明白,可以直接从宏定义中看出 TIM_FLAG_Update 就是设置的第 0 位了, 可读性非常强。
应用场景4:^按位异或操作使用技巧,该功能非常适合用于控制某个位翻转,常见的应用场景就是控制 LED 闪烁
GPIOB->ODR ^= 1 << 5;//1的补码(十六进制显示):0000 0000 0000 0001
//左移5位 0000 0000 0010 0000
//二者进行异或 0000 0000 0010 0001
执行一次该代码,就会使 PB5 的输出状态翻转一次,如果我们的 LED 接在 PB5 上,就可 以看到 LED 闪烁了。
1.2 #define宏定义
define 是 C 语言中的预处理命令,它用于宏定义,可以提高源代码的可读性,为编程提供方 便。常见的格式: #define 标识符 字符串 “标识符”为所定义的宏名。“字符串”可以是常数、表达式、格式串等。
例如: #define HSE_VALUE 8000000U
#define PLL_M 8 //定义标识符 PLL_M 的值为 8。
定义标识符 HSE_VALUE 的值为 8000000,数字后的 U 表示 unsigned 的意思。 至于 define 宏定义的其他一些知识,比如宏定义带参数这里我们就不多讲解。
1.3 ifdef条件编译
单片机程序开发过程中,经常会遇到一种情况,当满足某条件时对一组语句进行编译,而 当条件不满足时则编译另一组语句。常见指令如下所示:
条件编译命令最常见的形式为:
#ifdef 标识符程序段 1
#else 程序段 2
#endif
它的作用是:当标识符已经被定义过(一般是用#define 命令定义),则对程序段 1 进行编译, 否则编译程序段 2。 其中#else 部分也可以没有 ,即:
#ifdef 程序段 1
#endif
条件编译在 MDK 里面是用得很多,在自己编写的头文件中经常会看到这样的语句,它的作用是防止头文件重复引用。
#ifndef _XXX_H_
#define _XXX_H_//代码#endif
条件编译也是 C 语言的基础知识, 这里也就点到为止吧。
1.4 extern外部声明
C 语言中 extern 可以置于变量或者函数前,以表示变量或者函数的定义在别的文件中,提示编 译器遇到此变量和函数时在其他模块中寻找其定义。这里面要注意,对于 extern 申明变量可以多次,但定义只有一次。在我们的代码中你会看到看到这样的语句:
extern uint16_t g_usart_rx_sta; //声明变量
extern void delay_us(uint32_t nus); //声明函数
这个语句是申明 g_usart_rx_sta 变量在其他文件中已经定义了,在这里要使用到。所以,你 肯定可以找到在某个地方有变量定义的语句: uint16_t g_usart_rx_sta; extern 的使用比较简单,但是也会经常用到,需要掌握。
1.5 typedef类型重命名(重点理解 )
typedef 用于为现有类型创建一个新的名字,或称为类型别名,用来简化变量的定义。typedef 在 MDK 用得最多的就是定义结构体的类型别名和枚举类型了。
最基本的用法如下:
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
struct _GPIO
{__IO uint32_t MODER;__IO uint32_t OTYPER;__IO uint32_t CRL;__IO uint32_t CRH;…
};
定义了一个结构体 GPIO,这样我们定义结构体变量的方式为:
struct _GPIO gpiox; //定义结构体变量 gpiox
但是这样很繁琐,MDK 中有很多这样的结构体变量需要定义。这里我们可以为结体定义一个别名 GPIO_TypeDef,这样我们就可以在其他地方通过别名 GPIO_TypeDef 来定义结构体变 量了,方法如下:
typedef struct _GPIO
{__IO uint32_t MODER;__IO uint32_t OTYPER;__IO uint32_t CRL;__IO uint32_t CRH;…
} GPIO_TypeDef;上述引入typedef对结构体重命名,相当于下面这行代码
typedef struct _GPIO GPIO_TypeDef
Typedef 为结构体定义一个别名 GPIO_TypeDef,这样我们可以通过 GPIO_TypeDef 来定义 结构体变量: 这里的 GPIO_TypeDef 就跟 struct _GPIO 是等同的作用了,但是 GPIO_TypeDef 使用起来方便很多。
GPIO_TypeDef gpiox;
1.6 结构体(重点理解)
经常很多用户提到,他们对结构体使用不是很熟悉,但是 MDK 中太多地方使用结构体以及 结构体指针,这让他们一下子摸不着头脑,学习 STM32 的积极性大大降低,其实结构体并不是那么复杂,这里我们稍微提一下结构体的一些知识,还有一些知识我们会在下面的“寄存器映射”中讲到一些,结构体的理解与运用对于STM32是非常重要的!!!
1.6.1 概念
由若干基本数据类型集合组成的一种自定义数据类型,也叫聚合类型
1.6.2 结构体的定义
struct 结构体名
{
成员列表;
} 变量名列表(可选);
//C语言中的简单应用,如下定义学生类型的结构体
struct student
{char *name; //姓名int num; //学号int age; //年龄 char group; //所在学习小组 float score; //成绩
}stu1, stu2;struct student stu3,stu4; //定义结构体变量,赋初值
stu3.name = "张三";
stu3.num = 1;
stu3.age = 18;
stu3.group = 'A';
stu3.score = 80.9;
1.6.3 结构体成员变量的访问
struct U_TYPE
{int BaudRate int WordLength;
}usart1, usart2;struct U_TYPE *usart3; // 定义结构体指针变量 usart3
方式1:通过结构体成员变量 . 访问。
方式2:通过结构体指针的指向符->访问。(STM32常用的方式)
usart1.BaudRate; //结构体变量访问
usart3->BaudRate; //结构体指针变量访问
1.6.4 结构体传参
要想在函数内部修改外部结构体的成员变量的值,必须要传结构体的地址,也就是函数设计的参数为结构体指针,函数调用时要传入结构体的地址!!!
1.6.5 结构体在STM32单片机程序开发中如何发挥作用?(深刻理解)
在我们单片机程序开发过程中,经常会遇到要初始化一个外设比如串口,它的初始化状态 是由几个属性来决定的,比如串口号,波特率,极性,以及模式。对于这种情况,在我们没有学习结构体的时候,我们一般的方法是,设计一个初始化函数,然后把需要设置的属性,通过参数传递给函数内部进而修改。
void usart_init(uint8_t usartx, uiut32_t BaudRate, uint32_t Parity,uint32_t Mode);
这种方式是有效的同时在一定场合是可取的。但是存在一个问题:试想,如果有一天,我们希望往这个函数里面再传入一个/几个参数,那么势必我们需要修改这个函数的定义,重新加入新的入口参数, 随着开发不断的增多,那么是不是我们就要不断的修改函数的定义呢?这是不是给我们开发带 来很多的麻烦?那又怎样解决这种情况呢?
我们使用结构体参数,就可以在不改变入口参数的情况下,只需要改变结构体的成员变量, 就可以达到改变入口参数的目的。
即把一个外设的所有寄存器(结构体成员变量)封装成一个结构体类型,然后设计一个函数,传入结构体的地址,便可以在函数内部修改结构体成员变量的值,并且不会随着寄存器的增多,修改函数,只需要往结构体类型的声明中加入新的结构体成员变量!!!
结构体就是将多个变量组合为一个有机的整体,上面的函数,usartx,BaudRate,Parity, Mode 等这些参数,他们对于串口而言,是一个有机整体,都是来设置串口参数的,所以我们可以将 他们通过定义一个结构体来组合在一个有机整体。MDK 中是这样定义的:
typedef struct
{uint32_t BaudRate;uint32_t WordLength;uint32_t StopBits;uint32_t Parity;uint32_t Mode;uint32_t HwFlowCtl;uint32_t OverSampling;
} UART_InitTypeDef;
这样,我们在初始化串口的时候入口参数就可以是 USART_InitTypeDef 类型的指针变量了,于是我们可以改为:
void usart_init(UART_InitTypeDef *huart);
这样,任何时候,我们只需要修改结构体成员变量,往结构体中间加入新的成员变量,而 不需要修改函数定义就可以达到修改入口参数同样的目的了。这样的好处是不用修改任何函数 定义就可以达到增加变量的目的。 理解了结构体在这个例子中间的作用吗?在以后的开发过程中,如果你的变量定义过多, 如果某几个变量是用来描述某一个对象,你可以考虑将这些变量定义在结构体中,这样也许可 以提高你的代码的可读性。
使用结构体组合参数,可以提高代码的可读性,不会觉得变量定义混乱。当然结构体的作用就远远不止这个了,同时,MDK 中用结构体来定义外设也不仅仅只是这个作用,这里我们只是举一个例子,通过最常用的场景,让大家理解结构体的一个作用而已。后面一节我们还会讲 解结构体的一些其他知识。
1.7 指针(重点理解 )
指针是一个值指向地址的变量(或常量),其本质是指向一个地址,从而可以访问一片内 存区域。在编写 STM32 代码的时候,或多或少都要用到指针,它可以使不同代码共享同一片内存数据,也可以用作复杂的链接性的数据结构的构建,比如链表,链式二叉树等,而且,有些 地方必须使用指针才能实现,比如内存管理等。
指针就是内存的地址;
指针变量就是保存了指针/地址的变量;
类型名 * 指针变量名
char * p_str = “This is a test!”;*p_str; //取p_str 变量的值
&p_str; //取p_str变量的地址
指针是 C 语言的精髓,在后续的代码中我们将会大量用到 各种指针,大家务必好好学习和了解指针的使用。
1.8 嵌入式单片机C代码规范与风格
二、 寄存器基础知识(重点理解 )
2.1 什么是寄存器及作用?
寄存器(Register)是单片机内部一种特殊的内存,它可以实现对单片机各个功能的控制, 简单的来说可以把寄存器当成一些控制开关,控制包括内核及外设的各种状态。简单来说:寄存器就是单片机内部的控制机构。所以无论是 51 单片机还是 STM32,都需要用寄存器来实现各种控制,以完成不同的功能。 由于寄存器资源非常宝贵,一般都是一个位或者几个位控制一个功能,对于 STM32 来说, 其寄存器是 32 位的,占用四个字节,相当于一个4字节的内存单元;
记住一句话:从底层讲,我们是通过对寄存器进行配置(向寄存器写入数据或者读取寄存器的数据),从而实现对外部电路的控制,进而实现不同的功能。向寄存器写入1,相当于单片机向外界电路输出高电平3.3V,读取寄存器的1,相当于外界电路向单片机发送一个高电平3.3V,通过这样的一种方式来通过寄存器控制外界电路,实现不同的功能。
2.2 寄存器的分类
一个 32 位的寄存器,可能会有 32 个控制功能,相当于 32 个开关,由于 STM32 的复杂性,它内部有几百个寄存器,所以整体来说 STM32 的寄存器还是比较复杂的。 不过,我们不要被其吓到了,实际上 STM32 是由于内部有很多外设,所以导致寄存器很多,实际上我们把它分好类,每个外设也就那么几个或者几十个寄存器,学起来就不难了。从大方向来区分,STM32 寄存器分为两类,如表所示:
其中,内核寄存器,我们一般只需要关心中断控制寄存器和 SysTick 寄存器即可,其他三 大类,我们一般很少直接接触。而外设寄存器,则是学到哪个外设,就了解哪个外设相关寄存 器即可,所以整体来说,我们需要关心的寄存器并不是很多,而且很多都是有共性的,比如 STM32F407ZGT6 有 14 个定时器,我们只需要学习了其中一个的相关寄存器,其他 12 个基本都是一样。
2.3 寄存器应用举例
我们知道寄存器的本质是一个特殊的内存,对于 STM32 来说,以 GPIOB 的 ODR 寄存器为例,其寄存器地址为:0X40010C0C,所以我们对其赋值可以写成:
(*(unsigned int *))(0X40010C0C) = 0XFFFF;或者更简单的写为如下形式:
(*(uint32_t *))(0X40010C0C) = 0XFFFF;
如何理解上述代码?
step1. 将寄存器的地址(32位)本身是一个常数,通过强制类型转换,转换为一个32位的指针,它就代表这4个字节内存单元的首地址
step2. 通过指针解引用访问这4个字节内存单元,(指针解引用访问内存空间大小取决于指针的类型,此时指针的类型为:unsigned int,占用4个字节(32位),因此它可以访问4个字节的内存单元(32位),也就是访问这块内存单元 ),举例:int a=1; int* p=&a; *p=2; 通过这几行代码就可以把4个字节单元里面的内容改为2;
step3. 通过等号赋值,因为GPIO只有低16位有效,这句代码执行完毕,就是将GPIO的低16位全部置为1。
这样我们就完成了对 GPIOB->ODR 寄存器的赋值,全部 0XFFFF,表示 GPIOB 所有 IO 口 (16 个 IO 口)都输出高电平。对于我们来说,0X40010C0C 就是一个寄存器的特殊地址,至于 它是怎么来的,我们后续再给大家介绍。 虽然上面的代码实现了我们需要的功能,但是从实用的角度来说,这么写肯定是不好的, 可读性极差,可维护性也很差,所以一般我们使用结构体来访问,比如改写成这样: GPIOB->ODR = 0XFFFF; 这样可读性就比之前的代码好多了,可维护性也相对好一点。至于 GPIOB 结构体怎么来 的,我也会在后续给大家介绍。
三、 STM32F407 系统架构(重点理解 )
STM32F407 是 ST 公司基于 ARM 授权 Cortex M4 内核而设计的一款芯片,而 Cortex M 内 核使用的是 ARM v7-M 架构,是为了替代老旧的单片机而量身定做的一个内核,具有低成本、 低功耗、实时性好、中断响应快、处理效率高等特点。
3.1 Cortex M4内核&芯片
我们看到的 STM32 芯片已经是已经封装好的成品,主要由内核和片上外设组成。若与电脑类比,内核与外设就如同电脑上的 CPU 与主板、内存、显卡、硬盘的关系。 STM32F407 采用的是 Cortex-M4 内核,内核即 CPU,由 ARM 公司设计。ARM 公司并不生产芯片,而是出售其芯片技术授权。芯片生产厂商(SOC)如 ST、TI、Freescale,负 责在内核之外设计部件并生产整个芯片,这些内核之外的部件被称为核外外设或片上外设。 如 GPIO、USART(串口)、I2C、SPI 等都叫做片上外设。不同厂家设计出的单片机会 有不同的配置,包括存储器容量、类型、外设等都各具特色,因此才会有市面上各种不同应用的ARM芯片。Cortex M4内核和芯片的关系如图所示。
可以看到,ARM公司提供CM4内核和调试系统,其他的东西(外设(IIC、SPI、UART、 TIM等)、存储器(SRAM、FLASH等)、I/O等)由芯片制造商设计开发。这里ST公司就是 STM32F407芯片的制造商。
3.2 STM32系统架构
STM32F407ZGT6 系统架构如图所示:
芯片内核和外设之间通过各种总线连接,其中主控总线有 8 条,被控总线有 7 条,具 体见上图。主控总线通过一个总线矩阵来连接被控总线,总线矩阵用于主控总线之间的访问仲裁管理,仲裁采用循环调度算法。总线之间交叉的时候如果有个圆圈则表示可以通信,没 有圆圈则表示不可以通信。比如 S0:I 总线只有跟 M0、M2 和 M6 这三根被控总线交叉的时候 才有圆圈,就表示 S0 只能跟这三根被控总线通信。从功能上来理解,I 总线是指令总线,用来取指,指令指的是编译好的程序指令。我们知道 STM32 有三种启动方式,从 FLASH 启动(包含系统存储器),从内部 SRAM 启动,从外部 RAM 启动,这三种存储器刚好对应的就是 M0、 M2 和 M6 这三条总线。
对于系统架构的知识,在刚开始学习 STM32 的时候只需要一个大概的了解,大致知道是个 什么情况即可。对于寻址之类的知识,这里就不做深入的讲解,中文参考手册都有很详细的讲 解。
四、存储器映射(重点理解 )
4.1 STM32单片机的寻址范围
想要解答这个问题,我们需要清楚以下两点:
1,32位的单片机可以有32根地址线(每根地址线有两种状态:导通或不导通)
2,单片机内存地址访问的存储单元是按字节编址的,即一个字节分配一个地址编号
STM32寻址大小: 2^32 =4G (字节), STM32寻址范围: 0x0000 0000 ~ 0xFFFF
我们电脑x86也是32位,所以其大小和我们内存条访问的空间是一样的,只是划分的区域有所不同。
4.2 存储器映射
STM32是一个32位单片机,他可以很方便的访问4GB以内的存储空间(2^32 = 4GB),因此 Cortex M4内核将系统总线中的所有结构,包括:FLASH、SRAM、外设及相关寄存器等全部组织在同一个4GB的线性地址空间内,我们可以通过C语言来访问这些地址空间,从而操作相关外设(读/写)。连接被控总线的是 FLASH,RAM 和片上外设,这些功能部件共同排列在一个 4GB 的地址空间内。我们在编程的时候,操作的也正是这些功能部件。数据字节以小端格式(小端模式)存放在存储器中,数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。 存储器指可以存储数据的设备,本身没有地址信息,对存储器分配地址的过程称为存储器映射。这个分配 一般由芯片厂商做好了,ST将所有的存储器及外设资源都映射在一个4GB的地址空间上(8个块),从而可以通过访问对应的地址,访问具体的外设。其映射关系如图所示:
4.3 存储器区域功能划分
ST将4GB空间分成8个块,每个块512MB,如上图所示,从图中我们可以看出有很多保留区 域(Reserved),这是因为一般的芯片制造厂家是不可能把4GB空间用完的,同时,为了方便后 续型号升级,会将一些空间预留(Reserved)。8个存储块的功能如表所示:
在这 8 个 Block 里面,有 3 个块非常重要,也是我们最关心的三个块。Boock0 用来设计成内部 FLASH,Block1 用来设计成内部 RAM,Block2 用来设计成片上的外设,下面我 们简单的介绍下这三个 Block 里面的具体区域的功能划分。
4.3.1 存储器 Block0 内部区域功能划分
第一个块是Block 0,用于存储代码,即FLASH 空间 Block0 主要用于设计片内的 FLASH, F407 系列片内部 FLASH 最大是 1MB,我们使 用的 STM32F407ZGT6 的 FLASH 就是 1MB。要在芯片内部集成更大的 FLASH 或者 SRAM 都意味着芯片成本的增加,往往片内集成的 FLASH 都不会太大,ST 能在追求性价 比的同时做到 1MB 以上,实乃良心之举。,其功能划分如表所示:
可以看到,我们用户FLASH大小是1024KB(1MB),这是对于我们使用的STM32F407ZGT6 来说,如果是其他型号,可能FLASH会更小,当然,如果ST喜欢,也是可以随时推出更大容量 的STM32单片机的,因为这里保留了一大块地址空间。还有,STM32的出厂固化BootLoader占 用差不多31KB FLASH空间。
4.3.2 存储器 Block1 内部区域功能划分
第二个块是Block 1,用于存储数据,即SRAM空间,其功能划分如表所示:
这个块仅使用了 128KB大小(仅大容量 STM32F407型号才有这么多 SRAM,比如 STM32F407ZGT6等),用于SRAM访问,同时也有大量保留地址用于扩展。
4.3.3 存储器 Block2 内部区域功能划分
第三个块是Block 2,用于外设访问,STM32内部大部分的外设都是放在这个块里面的,该 存储块里面包括了AHB、APB1和APB2三个总线相关的外设。Block2 用于设计片内的外设,根据外设的总线速度不同,Block 被分成了 APB 和 AHB 两部分,其中 APB 又被分为 APB1 和 APB2,AHB 分为 AHB1 和 AHB2。 还有一个 AHB3 包含了 Block3/4/5,AHB3 包含的 3 个 Block 用于扩展外部存储器,如 SRAM,NORFLASH 和 NANDFLASH 等。其功能划分如表所示:
同样可以看到,各个总线之间,都有预留地址空间,方便后续扩展。关于STM32各个外设 具体挂在哪个总线上面,大家可以参考前面的STM32F407系统结构图和STM32F407存储器映射 图进行查找对应。
五、寄存器映射(重点理解 )
5.1 什么是寄存器映射,简单举例
我们知道,存储器本身没有地址,给存储器分配地址的过程叫存储器映射,那什么叫寄存器映射?寄存器到底是什么?
在存储器 Block2 这块区域,设计的是片上外设,它们以四个字节为一个单元,共 32bit,每一个单元对应不同的功能,当我们控制这些单元时就可以驱动外设工作。我们可以找到每个单元的起始地址,然后通过 C 语言指针的操作方式来访问这些单元,如果每次都是通过这种地址的方式来访问,不仅不好记忆还容易出错,这时我们可以根据每个单元功能的不同,以功能为名给这个内存单元取一个别名,这个别名就是我们经常说的寄存器, 这个给已经分配好地址的有特定功能的内存单元取别名的过程就叫寄存器映射。简单来说就是:给内存单元起别名!
比如,我们找到 GPIOF 端口的输出数据寄存器 ODR 的地址是 0x4002 1414(至于这个 地址如何找到可以先跳过,后面我们会有详细的讲解),ODR 寄存器是 32bit,低 16bit 有效,对应着 16 个外部 IO,写 0/1 对应的的 IO 则输出低/高电平。现在我们通过 C 语言指针 的操作方式,让 GPIOF 的 16 个 IO 都输出高电平,具体见代码如下。
代码通过绝对地址访问内存单元
*(unsigned int*)(0x4002 1414) = 0xFFFF; // GPIOF 端口全部输出 高电平
刚刚我们说了,通过绝对地址访问内存单元不好记忆且容易出错,我们可以通过寄存 器的方式来操作,具体见代码如下。
通过寄存器别名方式访问内存单元GPIOF 端口全部输出 高电平
#define GPIOF_BASE 0x40021400
#define GPIOF_ODR (unsigned int*)(GPIOF_BASE+0x14)
*GPIOF_ODR = 0xFF;
为了方便操作,我们干脆把指针操作“*”也定义到寄存器别名里面,具体见代码如下。
通过寄存器别名访问内存单元
GPIOF 端口全部输出 高电平
#define GPIOF_BASE 0x40021400
#define GPIOF_ODR *(unsigned int*)(GPIOF_BASE+0x14)
GPIOF_ODR = 0xFF;
通过这种方式就定义了一个 GPIOB_ODR 的宏,来替代数值操作,很明显,GPIOB_ODR 的 可读性和可维护性,比直接使用数值操作来的直观和方便。这个宏定义过程就可以称之为寄存器的映射。 当然,为了简单,我们只举了一个简单实例,实际上大量寄存器的映射,使用结构体是最方便的方式,stm32f407xx.h 里面使用结构体方式对 STM32F407 的寄存器做了详细映射,等下 我们再介绍。当然STM32内部的寄存器有非常多,一般都有特定的作用,涉及到寄存器描述,大家可以参考《STM32F4xx 参考手册_V4(中文版).pdf》相关章节的寄存器描述部分,有详细的描述。
5.2 寄存器地址的计算(必须会)
片上外设区分为四条总线,根据外设速度的不同,不同总线挂载着不同的外设,APB 挂载低速外设,AHB 挂载高速外设。相应总线的最低地址我们称为该总线的基地址,总线 基地址也是挂载在该总线上的首个外设的地址。其中 APB1 总线的地址最低,片上外设从这里开始,也叫外设基地址。
STM32F407大部分外设寄存器地址都是在存储块2上面的。具体某个寄存器地址,由三个参数决定:1、总线基地址(BUS_BASE_ADDR);2,外设基于总线基地址的偏移量(PERIPH_OFFSET);3,寄存器相对外设基地址的偏移量(REG_OFFSET)。可以表示为: 寄存器地址 = 总线基地址 + 外设基于总线基地址的偏移量 + 寄存器相对外设基地址的偏移量
5.2.1 总线基地址
表格中的“相对外设基地址偏移”即该总线地址与“片上外设”基地址 0x4000 0000 的差值。
5.2.2 外设基地址
总线上挂载着各种外设,这些外设也有自己的地址范围,特定外设的首个地址称为 “XX 外设基地址”,外设基于总线基地址的偏移量(PERIPH_OFFSET),这个不同外设偏移量不一样,我们可以在STM32F407存储器映射图里面找到具体的偏移量,以GPIO为例,其偏移量如表所示:
上表的偏移量,就是外设基于AHB1总线基地址的偏移量(PERIPH_OFFSET)。
5.2.3 外设寄存器的地址
知道了外设基地址,再在参考手册里面找到具体某个寄存器相对外设基地址的偏移量就可以知道该寄存器的实际地址了,GPIO 有很多个寄存器,每一个都有特定的功能。每个寄存器为 32bit,占四个字节,因此,每个寄存器的地址编号相差4,因为地址编号是按照字节编号的, 在该外设的基地址上按照顺序排列,寄存器的位置都以相对该外设基地址的偏移地址来描述。这里我们以GPIOB的相关寄存器为例,来说明 GPIO 都有哪些寄存器,具体见表格 6-7。如表所示:
上表的偏移量,就是寄存器基于外设基地址的偏移量(REG_OFFSET)。 因此,我们根据前面的公式,很容易可以计算出GPIOB_ODR的地址: GPIOB_ODR地址 = AHB1总线基地址 + GPIOB外设偏移量 + 寄存器偏移量 所以得到:GPIOB_ODR地址 = 0X4002 0000 + 0X400 + 0X14 = 0X4002 0414 关于寄存器地址计算我们就讲到这里,通过本节的学习,其他寄存器的地址大家都应该可以熟练掌握并计算出来。
5.3 寄存器描述解读
有关外设的寄存器说明可参考《STM32F4xx 参考手册》中具体章节的寄存器描述部分, 在编程的时候我们需要反复的查阅外设的寄存器说明。这里我们以“GPIO 端口置位/复位寄存器”为例,教大家如何理解寄存器的说明,具体见图。
①名称 寄存器说明中首先列出了该寄存器中的名称,“(GPIOx_BSRR)(x=A…I)”这段的意思 是该寄存器名为“GPIOx_BSRR”其中的“x”可以为 A-I,也就是说这个寄存器说明适用 于 GPIOA、GPIOB 至 GPIOI,这些 GPIO 端口都有这样的一个寄存器。
②偏移地址 偏移地址是指本寄存器相对于这个外设的基地址的偏移。本寄存器的偏移地址是 0x18, 从参考手册中我们可以查到 GPIOA 外设的基地址为 0x4002 0000 ,我们就可以算出 GPIOA 的这个 GPIOA_BSRR 寄存器的地址为:0x4002 0000+0x18 ;同理,由于 GPIOB 的 外设基地址为 0x4002 0400,可算出 GPIOB_BSRR 寄存器的地址为:0x4002 0400+0x18 。 其他 GPIO 端口以此类推即可。
③寄存器位表 紧接着的是本寄存器的位表,表中列出它的 0-31 位的名称及权限。表上方的数字为位编号,中间为位名称,最下方为读写权限,其中 w 表示只写,r 表示只读,rw 表示可读写。 本寄存器中的位权限都是 w,所以只能写,如果读本寄存器,是无法保证读取到它真正内容的。而有的寄存器位只读,一般是用于表示 STM32 外设的某种工作状态的,由 STM32 硬件自动更改,程序通过读取那些寄存器位来判断外设的工作状态。
④位功能说明 位功能是寄存器说明中最重要的部分,它详细介绍了寄存器每一个位的功能。 例如本寄存器中有两种寄存器位,分别为 BRy 及 BSy,其中的 y 数值可以是 0-15,这里的 0-15 表示端口的引脚号,如 BR0、BS0 用于控制 GPIOx 的第 0 个引脚,若 x 表示 GPIOA,那就是控制 GPIOA 的第 0 引脚,而 BR1、BS1 就是控制 GPIOA 第 1 个引脚。 其中 BRy 引脚的说明是“0:不会对相应的 ODRx 位执行任何操作;1:对相应 ODRx 位进行复位”。这里的“复位”是将该位设置为 0 的意思,而“置位”表示将该位设置为 1;说明中的 ODRx 是另一个寄存器的寄存器位,我们只需要知道 ODRx 位为 1 的时候, 对应的引脚 x 输出高电平,为 0 的时候对应的引脚输出低电平即可(感兴趣的读者可以查询该寄存器 GPIOx_ODR 的说明了解)。所以,如果对 BR0 写入“1”的话,那么 GPIOx 的第 0 个引脚就会输出“低电平”,但是对 BR0 写入“0”的话,却不会影响 ODR0 位,所以引 脚电平不会改变。要想该引脚输出“高电平”,就需要对“BS0”位写入“1”,寄存器位 BSy 与 BRy 是相反的操作。
5.4 C 语言对寄存器的封装
以上所有的关于存储器映射的内容,最终都是为大家更好地理解如何用 C 语言控制读 写外设寄存器做准备,此处是本章的重点内容。STM32F407所有寄存器映射都在stm32f407xx.h里面完成,包括各种基地址定义、结构体定义、外设寄存器映射、寄存器位定义(占了绝大部分)等,整个文件有1W多行,非常庞大。我们没有必要对该文件进行全面分析,因为很多内容都是相似的,我们只需要知道寄存器是如何被映射的,就可以了,至于寄存器位定义这些内容,知道是怎么回事就可以了。 我们还是以GPIO为例进行说明,看看stm32f407xx.h是如何对GPIO的寄存器进行映射的,通过对GPIO寄存器映射,了解stm32f407xx.h的映射规则。stm32f407xx.h文件主要包含五个部分内容,如表所示:
寄存器映射主要涉及到表5.3.4.4中加粗的两个组成部分:外设寄存器结构体类型定义和寄存器映射,总结起来,包括3个步骤:
step1. 外设寄存器结构体类型定义
step2. 外设基地址定义
step3. 寄存器映射(通过将外设基地址强制转换为外设结构体类型指针即可)
以GPIO为例,其寄存器结构体类型定义如下:
这段代码用 typedef 关键字声明了名为 GPIO_TypeDef 的结构体类型,结构体内有 9个 成员变量,变量名正好对应寄存器的名字,并且每个寄存器都是32位的,占用4个字节,也就是每个寄存器的地址编号相差4,这与寄存器相对于外设基地址的偏移量相同,因此我们只要知道外设基地址,通过结构体指针访问结构体成员,便是访问4个字节(因为结构体成员是寄存器,它是对一个占用4个字节内存单元起的别名),恰好对应该寄存器的内存单元,便可以对该寄存器进行读写操作。这样的地址偏移与 STM32 GPIO 外设定义的寄存器地址偏移一一对应,只要给结构体 设置好首地址,就能把结构体内成员的地址确定下来,然后就能以结构体的形式访问寄存器了。
GPIO外设基地址定义如下:
GPIO外设寄存器映射定义如下:
5.5 总结
以上三部分代码,就完成了STM32F407内部GPIOA~GPIOI的寄存器映射,其原理其实是比 较简单的,包括两个核心知识点:1,结构体地址自增;2,地址强制转换;
结构体地址自增:我们第一步就定义了GPIO_TypeDef结构体类型,其成员包括:MODER、 OTYPER、OSPEEDR、PUPDR、IDR、ODR、BSRR、LCKR和AFRH以及AFRL,每个成员是 uint32_t类型,也就是4个字节,这样假设:MODER地址是0的话,OTYPER就是0X04,OSPEEDR 就是0X08,PUPDR就是0X0C,以此类推,这样的地址偏移与 STM32 GPIO 外设定义的寄存器地址偏移一一对应,只要给结构体设置好首地址,就能把结构体内成员的地址确定下来,然后就能以结构体的形式访问寄存器了。
地址强制转换:以GPIOB为例,GPIOB外设的基地址为:GPIOB_BASE(0X4002 0400), 我 们 使 用 GPIO_TypeDef 将 该 地 址 强 制 转 换 为 GPIO 结 构 体 类 型 指 针 : GPIOB , 这 样 GPIOB->MODER的地址就是:GPIOB_BASE(0X4002 0400),GPIOB->OTYPER的地址就是: GPIOB_BASE + 0X04(0X4002 0404),GPIOB->OSPEEDR的地址就是:GPIOB_BASE + 0X08 (0X4002 0408),以此类推。
这样我们就使用结构体方式完成了对GPIO寄存器的映射,其他外设的寄存器映射也都是这 个方法,这里就不一一介绍了。关于stm32f407xx.h的寄存器映射,我们就介绍到这里。
5.6 举例
在这里,我们举个简单的例子,使用结构体指针(外设对应的指针)访问结构体成员(寄存器 )。
/*ST公司已经帮我们做好,放在头文件,使用 GPIO_TypeDef 把地址强制转换成指针*/
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)
#define GPIOC ((GPIO_TypeDef *) GPIOC_BASE)
#define GPIOD ((GPIO_TypeDef *) GPIOD_BASE)
#define GPIOE ((GPIO_TypeDef *) GPIOE_BASE)
#define GPIOF ((GPIO_TypeDef *) GPIOF_BASE)
#define GPIOG ((GPIO_TypeDef *) GPIOG_BASE)
#define GPIOH ((GPIO_TypeDef *) GPIOH_BASE)/*使用定义好的宏直接访问*//*访问 GPIOF 端口的寄存器*/GPIOF->BSRRL = 0xFFFF; //通过指针访问并修改 GPIOF_BSRRL 寄存器GPIOF->MODER = 0xFFFFFFF; //修改 GPIOF_MODER 寄存器GPIOF->OTYPER =0xFFFFFFF; //修改 GPIOF_OTYPER 寄存器uint32_t temp;temp = GPIOF->IDR; //读取 GPIOF_IDR 寄存器的值到变量 temp 中/*访问 GPIOA 端口的寄存器*/GPIOA->BSRRL = 0xFFFF; //通过指针访问并修改 GPIOA_BSRRL 寄存器GPIOA->MODER = 0xFFFFFFF; //修改 GPIOA_MODER 寄存器GPIOA->OTYPER =0xFFFFFFF; //修改 GPIOA_OTYPER 寄存器uint32_t temp;temp = GPIOA->IDR; //读取 GPIOA_IDR 寄存器的值到变量 temp 中
以上便是STM32 基础知识入门的全部内容,涵盖了方方面面,从0开始入门,介绍ST公司是如何管理庞大的寄存器的,简单来讲就是将每个外设抽离出来,封装成一个结构体类型,结构体的成员就是该外设对应的寄存器,从而通过结构体指针访问或者修改寄存器的值。可谓是网上最全教程,后面会逐步介绍每个外设的固件库使用方法,如有兴趣,感谢点赞、关注、收藏,若有不正地方,还请各位大佬多多指教!