目录
前言
在刚接触接触单片机时,都是不管三七二十一的去调用各种函数去驱动外设(尤其是STM32,其提供了固件库以及HAL库),以此来学习单片机的功能,特别是在第一次时,通过调用GPIO相关函数来点亮LED灯。后来慢慢知道了,是单片机内核(CPU)通过配置各种外设的寄存器来驱动它的。那么,这篇文章就来揭秘这个神秘的过程,到底是如何配置寄存器以达到控制外设的(以STM32为例)。
一、处理器控制外设原理
CPU本身是不能直接控制硬件的,硬件一般是由其对应的控制器来控制,处理器中将各个硬件控制器的寄存器映射到CPU地址空间中的一段范围,这样CPU就可以通过读写寄存器来间接控制硬件。
地址映射:在一个处理器中,一般会将ROM、RAM、寄存器等存储设备分别映射到寻址空间的不同地址段。地址映射、寻址空间等内容在下面文章中有具体提到:
简单的说,就是单片机通过地址总线,给出一定的地址,用来分配给各种存储设备,使每个存储设备都有属于自己的地址(只有存储设备有了地址,CPU才能找得到它,并访问它,寄存器也属于是存储设备),这一过程叫地址映射;而这个地址的总量就是寻址空间(由地址总线的宽度决定),它的大小决定了可以连接多少存储设备。比如,STM32地址总线宽度为32位,可以映射个地址,即为4G的大小,所以STM32的寻址空间为4G。
如下图,STM324G的地址空间分配为:
可以看到,单片机上的外设就放在地址空间的0x4000 0000~0x5FFF FFFF中,之后我们通过操作这片空间上各种外设对应的寄存器,便可以实现控制外设的效果。
补充:C语言在ARM中的数据类型有
二、如何控制(以GPIO为例)
在这里就以STM32的GPIO这个外设为例进行讲解,设定GPIO的端口为B,引脚为5,即PB5。
首先,查询GPIOB的地址。
通过手册可以看到GPIOB的起始地址为0x40010C00,并且在为其分配的地址空间上又连接着GPIO相关的寄存器,如下:
如想控制PB5输出高电平,只需要将GPIOB地址空间里的ODR寄存器的第5位置一便可。算出GPIOB-ODR寄存器的地址,即0x40010C00+0xC=0x4001 0C0C(起始地址加上偏移量)。
知道了寄存器地址了,接下来该如何操作地址呢?我们都知道,0x4001 0C0C只是一个数字,我们需要先将其转变为地址。
( unsigned int *)0x40010C0C;
这样我们便把这个数据强制转化成32位无符号的指针,在C语言中,指针即代表了地址。然后对该指针赋值,使其第五位为1。
*( unsigned int *)0x40010C0C |=1<<5;
在指针前加上*号便可以对指针所指的内容进行操作,即寄存器GPIOB_ODR;然后将其第五位写1,需要注意的是,这里需要先将他们进行按位或操作,否则会改变该寄存器里其他位的值,从而会破坏原来的控制。这样,我们便可以直接操作地址控制PB5引脚输出高电平。如下LED闪烁程序:
为了方便阅读,我们可以将该地址宏定义,如下:
#define ODR *( unsigned int *)0x40010C0C
ODR |= 1<<5;
但在实际操作中,这样还是不够简便,代码的可移植性很低,并且GPIOB的地址空间中还有其他寄存器。每个端口都有七个寄存器:CRL、CRH、IDR、ODR、BSRR、BRR、LCKR,这些寄存器之间的地址是连续的。那么如何将这些寄存器全部定义在一起呢?
这时你可能想到了C语言中的数组,因为它的每个单元之间的地址空间就是连续的,但是数组不行,因为数组的每个单元的空间都是相同的,不能改变,而有些外设的各个寄存器之间的空间大小是不一样的。所以这个时候最好的选择就是结构体。结构体相关内容可以查阅下面内容:
C语言——结构体(Struct)详解+运用举例-CSDN博客
结构体内的各个成员之间的空间是连续的,而且可以放不同类型的数据。如下定义:
#define __IO volatile
#define uint32_t unsigned int
typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
上面定义中出现了一个关键字volatile,不清楚的可以查阅C语言——难点关键字(extern、static、struct、enum、union、volatile)-CSDN博客。
通过以上结构体的定义,便把每个端口的各个寄存器全部打包起来,只需访问这个结构体便可以进行端口的控制。但是,还差一步,就是把地址和结构体关联起来:GPIOB的起始地址为0x40010C0C,我们只需要把地址0x40010C0C变成上面结构体类型的地址即可,为了方便阅读,同时将其进行宏定义。如下:
#define GPIOB ( GPIO_TypeDef *)0x40010C0C
这就是官方库中给出的定义,通过此定义,方便了寄存器的操作。
但在开发中,往往不用寄存器开发,通常用库函数以及HAL库就行,而这些库函数就是通过封装寄存器实现的。如GPIO_Write(GPIO_TypeDef* GPIOx, uint16_t PortVal)这个函数:
我们只需要调用函数就行,这样不仅方便阅读,而且不用过多去了解寄存器的地址,减小了开发难度。如下,就是正常操作时的程序:
三、举例——串口(USART1)
首先,地址映射。
#define PERIPH_BASE ((uint32_t)0x40000000) //外设基地址
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)//APB2总线的基地址
#define USART1_BASE (APB2PERIPH_BASE+0x3800)//串口1的起始地址
然后,定义结构体。
/**
* @brief Universal Synchronous Asynchronous Receiver Transmitter
*/
typedef struct
{
__IO uint16_t SR;
uint16_t RESERVED0;
__IO uint16_t DR;
uint16_t RESERVED1;
__IO uint16_t BRR;
uint16_t RESERVED2;
__IO uint16_t CR1;
uint16_t RESERVED3;
__IO uint16_t CR2;
uint16_t RESERVED4;
__IO uint16_t CR3;
uint16_t RESERVED5;
__IO uint16_t GTPR;
uint16_t RESERVED6;
} USART_TypeDef;
最后,将地址转换成该结构体指针。
#define USART1 ((USART_TypeDef *)USART1_BASE)//强制转换成串口1结构体类型的地址。
在使用USART1发送一个字节的数据时,可以使用下面的语句:
USART1->DR=mydata;
进一步处理将外设操作封装成函数的形式:
/**
* @brief Transmits single data through the USARTx peripheral.
* @param USARTx: Select the USART or the UART peripheral.
* This parameter can be one of the following values:
* USART1, USART2, USART3, UART4 or UART5.
* @param Data: the data to transmit.
* @retval None
*/
void USART_SendData(USART_TypeDef* USARTx, uint16_t Data)
{
/* Check the parameters */
assert_param(IS_USART_ALL_PERIPH(USARTx));
assert_param(IS_USART_DATA(Data));
/* Transmit Data */
USARTx->DR = (Data & (uint16_t)0x01FF);
}
所以,STM32固件库函数就是基于这种思路设计的。应该注意的是,在定义USART_TypeDef结构时,使用了__IO变量类型,该类型实质上volatile的宏定义(该宏定义包含在core_m3.h文件中)。外设访问定义指针时,需要使用volatile关键字。volatile用于防止相关变量被优化。
完结
有误之处望指正