本文摘自:
https://blog.csdn.net/xiashiwendao/article/details/122291583
概述
今天我们的开启了STM32开发的第一站:点亮LED,今天的内容包含了很多基础的知识,也有一些劝退的意味,不过,如果你能够扛得住这波攻势的,我觉得你高嵌入式方面真的是“风骨清奇,可造之材”。
程序总览
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
#define __IO volatile
#define PERIPH_BASE ((uint32_t)0x40000000)
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
#define GPIOC_BASE (APB2PERIPH_BASE + 0x1000)
#define GPIOC (GPIOC_BASE)
#define GPIOC_CRH (GPIOC+0x04)
#define GPIOC_ODR (GPIOC+0x0C)
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
#define RCC (RCC_BASE)
#define RCC_APB2ENR (RCC+0x18)
#define RCC_CR (RCC+0x00)
#define RCC_CFGR (RCC+0x04)
#define FLASH_R_BASE (AHBPERIPH_BASE + 0x2000)
#define FLASH (FLASH_R_BASE)
#define FLASH_ACR (FLASH+0x00)
void RCC_init(uint16_t PLL)
{
uint32_t temp=0;
*((uint32_t *)RCC_CR) |= 0x00010000;
while(!( *((uint32_t *)RCC_CR) >>17));
*((uint32_t *)RCC_CFGR) = 0X00000400;
PLL -= 2;
*((uint32_t *)RCC_CFGR) |= PLL<<18;
*((uint32_t *)RCC_CFGR) |= 1<<16;
*((uint32_t *)FLASH_ACR)|=0x2;
*((uint32_t *)RCC_CR) |= 0x01000000;
while(!(*((uint32_t *)RCC_CR) >> 25));
*((uint32_t *)RCC_CFGR) |= 0x00000002;
while(temp != 0x02)
{
temp = *((uint32_t *)RCC_CFGR) >> 2;
temp &= 0x03;
}
}
void delay(unsigned int time)
{
unsigned int i=0;
while(time--)
{
i=10000000;
while(i--) ;
}
}
int main(void)
{
*((uint32_t *)RCC_APB2ENR) |= 0x00000010;
*((uint32_t *)GPIOC_CRH) |= 0x00300000;
*((uint32_t *)GPIOC_ODR) |= 0x00002000;
while(1)
{
*((uint32_t *)GPIOC_ODR) &= ~(1<<13);
*((uint32_t *)GPIOC_ODR) |= 0x00000000;
delay(3);
*((unsigned int *)GPIOC_ODR) &= ~(1<<13);
*((unsigned int *)GPIOC_ODR) |= 0x00002000;
delay(1);
}
}
分析代码套路
不要慌,看到一堆大写字符,符号,我们梳理一下程序结构,总体来讲一般分为三个部分,以后即使我们碰到再复杂的文件,比如同文件引用其实也不过是这样的三个部分:
- 类型定义;类型的定义决定了变量的长度;
- 定义宏,即定义常量,常量不好理解,通过宏来定义,给予一个有意义的宏定义,程序可理解性会更强;除此之外,如果多个地方使用同一个变量,通过使用宏定义,可以实现只修改一个地方(宏定义)即可实现所有的引用地方做修改;
- main函数,程序运行要走的函数;
// 1.类型定义
typedef unsigned short int uint16_t;
... ...
// 2. 定义宏
#define __IO volatile
#define PERIPH_BASE ((uint32_t)0x40000000)
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
... ...
// 3. 主函数
int main(void)
{
... ...
delay(3);
... ...
}
// 4.调用函数
void delay(unsigned int time)
{
... ...
}
任何程序基本都是这三部分的延伸和拓展。
下面我们来看主函数,在研究主函数的时候,上面我们定义的宏自然就明白了;
使能APB2总线
下面是第一行代码,这一行代码的含义是向目标内存地址寄存器地址中通过与计算0x00000010;至于RCC_APB2ENR是做什么的我们放在后面来讲解,首先搞清楚它的计算脉络;
*((uint32_t *)RCC_APB2ENR) |= 0x00000010;
地址计算
首先搞清楚RCC_APB2ENR是什么东西,首先追溯一下它的define计算过程
#define PERIPH_BASE ((uint32_t)0x40000000)
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
... ...
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
#define RCC (RCC_BASE)
#define RCC_APB2ENR (RCC+0x18)
PERIPH_BASE是总线基地址,什么是基地址?就是某个组件的起始地址,因为分配各组件的地址是一个范围,所以有起始地址和结束地址,基地址就是指起始地址,但是并不是所有组件的起始地址都叫做基地址,只有那些组件下面还要挂在其他组件,即其他组件的地址是基于它计算出来的地址才叫做基地址;
0x40000000从哪里来的呢?翻看芯片手册“3.3 Memory map”章节里面,这里会罗列各个STM32组件的内存地址映射,注意,是内存地址映射,并不是真正的内存地址,为了访问这些组件需要构造专门为这些组件分配一定的虚拟内存地址空间,这些虚拟的内存地址并不会真正的和物理内存做映射,而是和指定的寄存器做映射;
表格排列的地址顺序从高地址到低地址,所以需要拉倒最下面看到基地址,拖拽到表格的最下面就可以看到BUS的起始地址,即总线的基地址是0x4000 000;
总线上面其他组件的地址都是这个基地址偏移(offset);所谓的偏移就是指基于某个值再加上指定值;比如我们说基地址是100,某个组件偏移量是60,那么组件的(起始)地址160;
就是我们来看一下下一个APB2的总线地址,是基于总线基址偏移0x10000;
define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
翻看Memory Map可以看到APB2总线的基址是0x4001 0000;所以我们的宏定义APB2PERIPH_BASE是PERIPH_BASE偏移0x10000就是从这个表格里面来的;
后面我们继续看,AHBPERIPH_BASE,即AHB总线上面基地址,注意AHB总线上面横跨了几个地址范围0x5x,0x4002x,0x4001x;而我们所要获取的RCC的是0x4002地址段的,所以在计算基地址的时候,就不再是直接看最后一个,因为最后一个是0x4001x地址段;
我们是通过总线基址+0x20000来进行指定的,所以具体的组件的基址的计算也是灵活的,是需要看你要组件所在的地址段来进行偏移计算的;然后是RCC_BASE,也是根据RCC的起始地址0x4002 10000,所以基于AHBPERIPH_BASE(0x4002 0000)基础上再偏移0x10000,此时得到了RCC的基址:0x4002 10000;
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
#define RCC (RCC_BASE)
最后一个计算,是RCC的使能位,所谓的使能就是时钟生效,因为在嵌入式系统里面为了省电,很多组件默认是不工作的,不过这个不工作不是不上电,而是不开启时钟,只有开启时钟的组件才会工作,没有时钟就处于休眠状态;使能就是开启时钟,让组件处于工作状态:
#define RCC_APB2ENR (RCC+0x18)
0x18从哪里来的呢?就是从芯片手册的章节定义来的,这里的Address就是RCC基地址偏移0x18:
关于位计算
然后我们来看一下位运算“|“:
*((uint32_t *)RCC_APB2ENR) |= 0x00000010;
在C语言里面有七种运算:
// 1.赋值运算,即将具体的值赋给一个变量:
int a = 5;
// 2.算术运算,即+-*÷四则运算:
int c = b + 5;
// 3.逻辑运算,逻辑运算的结果是true/ false,运算包括与运算(“&&”),或运算("||"),取反运算(“!"):
if(a && b){
... ...
}
int d = c || b;
// 4.关系运算符,包括>,<.<=,>=,!=,==,关系运算的结果也是true/ false
if(a == b){
... ...
}
// 5.三目运算
int e = a < b ? d : e;
// 6. 位运算,这个重点,也是我们做地址运算普遍采用的运算方式,
// 包括与运算&, 或运算!,异或^,左移<<,右移>>;
// 7.单目运算,++,--,~(取反操作)
for(int i = 0; i<MAX; i++){
... ...
}
那么这里为什么采用或运算呢?首先搞清楚什么是位运算,位运算和其他运算最大区别在于其他的运算都是以数据类型为单位进行设计规则,而位运算不再是关注数据类型作为一个整体,而是基于每一个bit来设计运算规则;
然再搞清楚什么是或运算,x|y,x和y只要有一个为0(false)就是0,x和y只有都是1(true)才是1(true);
使能PC端口
代码中要做的事情是使能APB2总线,首先是查找手册,定位到RCC_APB2ENR;
在芯片手册的register小节中,将会非常详细罗列出RCC_APB2ENR这个引脚所对应寄存器的每一位的含义;我们可以把每一个引脚理解为寄存器,一个32bit的寄存器,引脚可以抽象为输入/输出接口,用于“存放”输入/输出的数据;
这里我们目标是确保设置PC为使能状态即1,因为采用的或运算,与值为0的位置维持原来的值,而与值为1的位(IOPCEN,第4位)设置为1,于是从32位到0位(注意表示是大端表示方式,从高位到低位),依次是:0000 0000 0000 0000 0000 0000 0000 0000 0001 0000,转化为16进制就是0x00000010;
关于地址类型
最后,为什么前面会有一个呢?在C语言里面变量大体有两种分类,一种是值,一种是(内存)地址,比如整型2009,可以是代表值2009,也可以代表要访问某个内存地址,怎么代表要访问是这个地址呢?就是在前面添加一个“”;
其实你想没想过当你定义个变量的时候,所谓的初始化,其实就是为这个变量和一个内存地址做了绑定,这种绑定是记录在变量定义表的,找到了地址之后,对这个地址进行赋值;
所以,当你看到下面的代码:
int a = 5;
其实本质是下面的形式,其中a经过初始化,将变量a和地址0x40000230(这个地址是举例)进行绑定:
*0x40000230 = 6;
配置PC13
继续看配置GPIOC_CRH的代码:
*((uint32_t *)GPIOC_CRH) |= 0x00300000;
什么是GPIOC_CRH?上手册,在第9章介绍GPIO和AFIO的GPIO寄存器章节里面可以看到GPIOx_CRH,全称是Config Register High,即高位配置寄存器,有高位就有地位,看来配置项很多,一个32bit是不够的,所以有高位和低位两个寄存器来记录配置项;
继续手册下面给了GPIOx_CRH的32bit每个bit的含义,GPIOx中的x是指A,B,C,我们在STM32的板子上面都可以看到PAx,PBx,PCx的字样(x的取值范围就是1~16),P代表Port即端口(端子),ABC是分类;CRH描述的是PA/PB/PC的第9引脚到16引脚;我们这里是要设置PC13的相关配置;
其中CNF位配置的是该引脚是作用方向,是输入还是输出;MODE位则是配置输出的最大时钟频率(如果是输出的话基本可以忽略MODE了);注意,每个CNF占两个bit位,每个MODE也是占两个bit位;其中rw代表这个bit位是可以通过软件来进行读写,如果碰到了有的位是“r”,则一般是状态位,有硬件层面设置,软件层面只能够读取:
配置PC13高低电平
按照这个思路,我们再来看一下GPIOC_ODR,代码如下:
*((uint32_t *)GPIOC_ODR) |= 0x00002000;
上手册:
可以看到,ODR全称是output data register,输出数据寄存器,寄存器数据分布如下,可以看到其中16~31位是保留位,保留位一般标记都是0:
其中通过与运算为ODR13位赋值是1(其他bit保持不变),这一步操作是一个写操作,即设置PC13为高电平。
硬件原理图
代码的含义我们清楚了,都是根据芯片手册来的配置的,那么为什么要做这些配置呢?这个就还是需要看一下LED的原理图,如下图所示,表示了两个LED的电路图,其中我们重点关注LED2(PWR是Power的缩写,是指电源LED):
可以看到LED2两端一个接着的是VCC,即3.3v,另外一端接的是PC13端口;当PC13是高电平的时候LED2两端没有电压差,所以LED2没有通电,所以处于关灯状态;如果PC13是低电平状态,LED2两端有电压差,所以处于通电状态,此时LED2将会亮灯;
所以可以通过设置PC13的低电平和高电平才让LED2电量和关闭;设置PC13的高低电平就是设置GPIOC寄存器的ODR的值,1即为高电平,灯灭,0为低电平,灯亮。
上面是LED2的原理图,我们知道了通过PC13来控制LED2的亮灭;但是PC13并不是直接就可以控制的,在嵌入式开发领域通常为了节能,只有需要某个端口的时候,才需要上电,上时钟,只有有时钟输出才能够工作;否则端口的时钟关闭,你设置任何值对于对于端口来说都是无效的;
所以如果我们想要让对于PC13的控制生效,就需要使能PC的端口使能;要设置使能首先就要明白所有的片内外设都是挂在总线上面,我们需要通过打开总线时钟来实现对于端口的使能,那么GPIOC挂在哪个总线下面呢?上手册,打开3.1节:
其中,系统架构图如下,我们看到GPIOC在APB2的总线下面,到此我们知道了刚才在代码中为什么要使能APB2总线了以及配置GPIOC的CRH来实现使能PC13。
呼吸灯实现
代码最后一部分就是实现了while循环,通过亮-灭-亮-灭-...从而实现呼吸灯的效果:
while(1)
{
*((uint32_t *)GPIOC_ODR) &= ~(1<<13);
*((uint32_t *)GPIOC_ODR) |= 0x00000000;
delay(3);
*((unsigned int *)GPIOC_ODR) &= ~(1<<13);
*((unsigned int *)GPIOC_ODR) |= 0x00002000;
delay(1);
}
亮灯
首先我们杠一下while里面的第一行,这里面运算比较复杂:
*((uint32_t *)GPIOC_ODR) &= ~(1<<13);
首先我们来拆解一下1左移13位,左移操作属于我们上面提到的位运算,位运算特点就是和整体数值无关,只是针对每个bit位来尽心运算;1左移13位,就是14位,添加2两位凑成16位(凑成2的n次方值)即:0010 0000 0000 0000,外面的“~”是代表取反,取反是一个单目运算,即针对操作数本身的操作,取反之后的值:1101 1111 1111 1111;
取反之后的值和GPIOC_ODR的原始值进行与运算(&),与运算的规则就是只有两个位操作数都是1(true)结果才是1(true),否则结果就是0(false);
和还记得GPIOx_ODR的寄存器定义吗?
所以,GPIOC_ODR和1101 1111 1111 1111做与运算,目的就是如果之前是1的还是1,之前如果是0的还是0;但是对于ODR13而言,无论之前值是什么,此番设置之后就是0了。这里有一点注意,3116为保留位,真正参与位运算的是150位,所以真正与运算的值是0000 0000 0000 0000 1101 1111 1111 1111;
然后GPIOC_ODR再和0x00000000做或运算,操作数和0x00000000或运算实现了原来是1的保持,原来是0的保持0;这一步其实意义并不大,可以是处于对称的目的做的这一步操作;ODR经过了上述的与运算和或运算之后,将ODR设置为0(清零),从而让LED产生电压差,实现了亮灯效果;
灭灯
类似,第6行和第7行代码如下:
*((unsigned int *)GPIOC_ODR) &= ~(1<<13);
*((unsigned int *)GPIOC_ODR) |= 0x00002000;
第一行代码含义和上面介绍的完全一致,目的是用于维持其他位不变只是针对第13位ODR13,设置其为0,即“清零”操作;
然后第二行GPIOC_ODR和0x00002000做与运算,前面4个零代表32~16位保留位,可以忽略,重点关注2000,转换为16进制是0010 0000 0000 0000,即实现设置ODR13的位的值为1;设置ODR13为1的效果,让LED两端没有了电压差(PC13为1即高电平,高电平即3.3V),于是有了灭灯的效果;
呼吸灯小节
所以设置ODR的状态一般都是两个步骤:
第一步是ODR13清零;
第二步是ODR13设置为目标值;不过在设置目标值为0的场景下,这一步似乎没有什么价值;不过处于对称的目的,还是会设置一下;于是有了*((unsigned int *)GPIOC_ODR) |= 0x00000000;
基于上述的计算,你会发现,或运算一般用于设定指定位的值(而不影响其他位);与运算用于“清零”(保持指定位不变);
延时函数delay
我们还需要注意在亮灭之间还有一个delay的函数:
void delay(unsigned int time)
{
unsigned int i=0;
while(time--)
{
i=10000000;
while(i--) ;
}
}
delay这个函数就是一个while循环,达到指定次数之后就退出,从而实现了延时的效果,类似c/ Java里面的Sleep函数的效果;那么为什么能够实现这个效果呢?这个是因为任何芯片都有一个时钟的概念,比如我们说STM32的APB2总线是48MHz,其实讲述的就是APB32每秒钟会经历481024次时钟;所以在单位时间内的时钟/周期次数,就是频率(也称之为时钟频率)的概念;
再回到上面的例子中,如果我们设置了时钟是8MHz,那么就意味着每秒钟将会循环81024次,那么如果循环次数是8*1024次,就可以认为是1秒钟;这一个就是为什么while循环指定次数可以作为定时器来用(后面我们专门有一个源码解读片内外设定时器);
debug时钟设置
那么我们的时钟是多少呢?如果你是调试模式,这个时钟是通过目标选项(Options For Target)来进行配置,开心就好:
如果是直接烧录到STM32的板子里面,默认使用的是HSI(High Speed Internal,内部高速时钟),即8MHz;所以为了Debug的效果和烧录之后的效果保持一致,最好是设置为一致的时钟频率。