摘要:从STM32新建工程、编译下载程序出发,让新手由浅入深,尽享STM32标准库开发的乐趣。
自从CubeMX等图像配置软件的出现,同学们往往点几下鼠标就解决了单片机的配置问题。对于追求开发速度的业务场景下,使用快速配置软件是合理的,高效的,但对于学生的学习场景下,更为重要的是知其然并知其所以然。
以下是学习(包括但不限于)嵌入式的三个重要内容,
1、学会如何参考官方的手册和官方的代码来独立写自己的程序。
2、积累常用代码段,知道哪里的问题需要哪些代码处理。
3、跟随大佬步伐,一步一个脚印。
首先:我们都知道编程时一般查的是《参考手册》,而进行芯片选型或需要芯片数据时,查阅的是《数据手册》。此外市面上所有关于STM32的书籍都是立足于前二者(+Cortex内核手册)进行编著。
其次:要分清什么是内核外设与内核之外的外设,为了便于区分,按照网上的一种说法,将“内核之外的外设”以“处理器外设”代替。再者:如今很少使用标准库了,都是HAL库,但作为高校目前教学方式,我们将以STM32f10xxx为例对标准库开发进行概览。
一、STM32 系统结构
STM32f10xxx系统结构
内核IP
从结构框图上看,Cortex-M3内部有若干个总线接口,以使CM3能同时取址和访内(访问内存),它们是:指令存储区总线(两条)、系统总线、私有外设总线。有两条代码存储区总线负责对代码存储区(即 FLASH 外设)的访问,分别是 I-Code 总线和 D-Code 总线。
I-Code用于取指,D-Code用于查表等操作,它们按最佳执行速度进行优化。
系统总线(System)用于访问内存和外设,覆盖的区域包括SRAM,片上外设,片外RAM,片外扩展设备,以及系统级存储区的部分空间。
私有外设总线负责一部分私有外设的访问,主要就是访问调试组件。它们也在系统级存储区。
还有一个DMA总线,从字面上看,DMA是data memory access的意思,是一种连接内核和外设的桥梁,它可以访问外设、内存,传输不受CPU的控制,并且是双向通信。简而言之,这个家伙就是一个速度很快的且不受老大控制的数据搬运工。
处理器外设(内核之外的外设)
从结构框图上看,STM32的外设有串口、定时器、IO口、FSMC、SDIO、SPI、I2C等,这些外设按照速度的不同,分别挂载到AHB、APB2、APB1这三条总线上。
二、寄存器什么是寄存器?寄存器是内置于各个IP外设中,是一种用于配置外设功能的存储器,并且有想对应的地址。一切库的封装始于映射。
是不是“又臭又长”,如果进行寄存器开发,就需要怼地址以及对寄存器进行字节赋值,不仅效率低而且容易出错。
来,开个玩笑。
你也许听说过“国际C语言乱码大赛(IOCCC)”下面这个例子就是网上广为流传的 一个经典作品:
#include 《stdio.h》
main(t,_,a)char *a;{return!0《t?t《3?main(-79,-13,a+main(-87,1-_,
main(-86,0,a+1)+a)):1,t《_?main(t+1,_,a):3,main(-94,-27+t,a)&&t==2?_《13?
main(2,_+1,“%s %d %d
”):9:16:t《0?t《-72?main(_,t,
“@n‘+,#’/*{}w+/w#cdnr/+,{}r/*de}+,/*{*+,/w{%+,/w#q#n+,/#{l+,/n{n+,/+#n+,/#
;#q#n+,/+k#;*+,/‘r :’d*‘3,}{w+K w’K:‘+}e#’;dq#‘l
q#’+d‘K#!/+k#;q#’r}eKK#}w‘r}eKK{nl]’/#;#q#n‘){)#}w’){){nl]‘/+#n’;d}rw‘ i;#
){nl]!/n{n#’; r{#w‘r nc{nl]’/#{l,+‘K {rw’ iK{;[{nl]‘/w#q#n’wk nw‘
iwk{KK{nl]!/w{%’l##w#‘ i; :{nl]’/*{q#‘ld;r’}{nlwb!/*de}‘c
;;{nl’-{}rw]‘/+,}##’*}#nc,‘,#nw]’/+kd‘+e}+;#’rdq#w! nr‘/ ’) }+}{rl#‘{n’ ‘)#
}’+}##(!!/”)
:t《-50?_==*a?putchar(31[a]):main(-65,_,a+1):main((*a==‘/’)+t,_,a+1)
:0《t?main(2,2,“%s”):*a==‘/’||main(0,main(-61,*a,
“!ek;dc i@bK‘(q)-[w]*%n+r3#l,{}:
uwloca-O;m.vpbks,fxntdCeghiry”),a+1);}
库的存在就是为了解决这类问题,将代码语义化。语义化思想不仅仅是嵌入式有的,前端代码也在追求语义特性。
三、万物始于点灯
(1)内核库文件分析
cor_cm3.h
这个头文件实现了:
1、内核结构体寄存器定义。
2、内核寄存器内存映射。
3、内存寄存器位定义。跟处理器相关的头文件stm32f10x.h实现的功能一样,一个是针对内核的寄存器,一个是针对内核之外,即处理器的寄存器。
misc.h
内核应用函数库头文件,对应stm32f10x_xxx.h。
misc.c
内核应用函数库文件,对应stm32f10x_xxx.c。在CM3这个内核里面还有一些功能组件,如NVIC、SCB、ITM、MPU、CoreDebug,CM3带有非常丰富的功能组件,但是芯片厂商在设计MCU的时候有一些并不是非要不可的,是可裁剪的,比如MPU、ITM等在STM32里面就没有。
其中NVIC在每一个CM3内核的单片机中都会有,但都会被裁剪,只能是CM3 NVIC的一个子集。在NVIC里面还有一个SysTick,是一个系统定时器,可以提供时基,一般为操作系统定时器所用。misc.h和mics.c这两个文件提供了操作这些组件的函数,并可以在CM3内核单片机直接移植。
(2)处理器外设库文件分析
startup_stm32f10x_hd.s
这个是由汇编编写的启动文件,是STM32上电启动的第一个程序,启动文件主要实现了
初始化堆栈指针 SP;
设置 PC 指针=Reset_Handler ;
设置向量表的地址,并 初始化向量表,向量表里面放的是 STM32 所有中断函数的入口地址
调用库函数 SystemInit,把系统时钟配置成 72M,SystemInit 在库文件 stytem_stm32f10x.c 中定义;
跳转到标号_main,最终去到 C 的世界。
system_stm32f10x.c
这个文件的作用是里面实现了各种常用的系统时钟设置函数,有72M,56M,48, 36,24,8M,我们使用的是是把系统时钟设置成72M。
Stm32f10x.h
这个头文件非常重要,这个头文件实现了:
1、处理器外设寄存器的结构体定义。
2、处理器外设的内存映射。
3、处理器外设寄存器的位定义。
关于 1 和 2 我们在用寄存器点亮 LED 的时候有讲解。
其中 3:处理器外设寄存器的位定义,这个非常重要,具体是什么意思?
我们知道一个寄存器有很多个位,每个位写 1 或者写 0 的功能都是不一样的,处理器外设寄存器的位定义就是把外设的每个寄存器的每一个位写 1 的 16 进制数定义成一个宏,宏名即用该位的名称表示,如果我们操作寄存器要开启某一个功能的话,就不用自己亲自去算这个值是多少,可以直接到这个头文件里面找。
我们以片上外设 ADC 为例,假设我们要启动 ADC 开始转换,根据手册我们知道是要控制 ADC_CR2 寄存器的位 0:ADON,即往位 0 写 1,即:
ADC-》CR2=0x00000001;
这是一般的操作方法。现在这个头文件里面有关于 ADON 位的位定义:
#define ADC_CR2_ADON ((uint32_t)0x00000001)
有了这个位定义,我们刚刚的代码就变成了:
ADC-》CR2=ADC_CR2_ADON
stm32f10x_xxx.h
外设 xxx 应用函数库头文件,这里面主要定义了实现外设某一功能的结构体,比如通用定时器有很多功能,有定时功能,有输出比较功能,有输入捕捉功能,而通用定时器有非常多的寄存器要实现某一个功能。
比如定时功能,我们根本不知道具体要操作哪些寄存器,这个头文件就为我们打包好了要实现某一个功能的寄存器,是以机构体的形式定义的,比如通用定时器要实现一个定时的功能,我们只需要初始化 TIM_TimeBaseInitTypeDef 这个结构体里面的成员即可,里面的成员就是定时所需要操作的寄存器。
有了这个头文件,我们就知道要实现某个功能需要操作哪些寄存器,然后再回手册中精度这些寄存器的说明即可。
stm32f10x_xxx.c
stm32f10x_xxx.c:外设 xxx 应用函数库,这里面写好了操作 xxx 外设的所有常用的函数,我们使用库编程的时候,使用的最多的就是这里的函数。
(3)SystemInit
工程中新建main.c 。
在此文件中编写main函数后直接编译会报错:
❝
Undefined symbol SystemInit (referred from startup_stm32f10x_hd.o)。
❞错误提示说SystemInit没有定义。从分析启动文件startup_stm32f10x_hd.s时我们知道,
;Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
;IMPORT SystemInit
;LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
汇编中;分号是注释的意思
第五行第六行代码Reset_Handler调用了SystemInit该函数用来初始化系统时钟,而该函数是在库文件system_stm32f10x.c中实现的。我们重新写一个这样的函数也可以,把功能完整实现一遍,但是为了简单起见,我们在main文件里面定义一个SystemInit空函数,为的是骗过编译器,把这个错误去掉。
关于配置系统时钟之后会出文章RCC时钟树详细介绍,主要配置时钟控制寄存器(RCC_CR)和时钟配置寄存器(RCC_CFGR)这两个寄存器,但最好是直接使用CubeMX直接生成,因为它的配置过程有些冗长。
如果我们用的是库,那么有个库函数SystemInit,会帮我们把系统时钟设置成72M。
现在我们没有使用库,那现在时钟是多少?答案是8M,当外部HSE没有开启或者出现故障的时候,系统时钟由内部低速时钟LSI提供,现在我们是没有开启HSE,所以系统默认的时钟是LSI=8M。
(4)库封装层级
达到第四层级便是我们所熟知的固件库或HAL库的效果。当然库的编写还需要考虑许多问题,不止于这些内容。我们需要的是了解库封装的大概过程。
将库封装等级分为四级来介绍是为了有层次感,就像打怪升级一样,进行认知理解的升级。
我们都知道,操作GPIO输出分三大步:
时钟控制:
STM32 外设很多,为了降低功耗,每个外设都对应着一个时钟,在系统复位的时候这些时钟都是被关闭的,如果想要外设工作,必须把相应的时钟打开。
STM32 的所有外设的时钟由一个专门的外设来管理,叫RCC(reset and clockcontrol),RCC 在STM32 参考手册的第六章。
STM32 的外设因为速率的不同,分别挂载到三条总系上:AHB、APB2、APB1,AHB为高速总线,APB2 次之,APB1 再次之。所以的IO 口都挂载到APB2 总线上,属于高速外设。
模式配置:
这个由端口配置寄存器来控制。端口配置寄存器分为高低两个,每4bit 控制一个IO 口,所以端口配置低寄存器:CRL 控制这IO 口的低8 位,端口配置高寄存器:CRH控制这IO 口的高8bit。
在4 位一组的控制位中,CNFy[1:0] 用来控制端口的输入输出,MODEy[1:0]用来控制输出模式的速率,又称驱动电路的响应速度,注意此处速率与程序无关,GPIO引脚速度、翻转速度、输出速度区别输入有4种模式,输出有4种模式,我们在控制LED 的时候选择通用推挽输出。
输出速率有三种模式:2M、10M、50M,这里我们选择2M。
电平控制:
STM32的IO口比较复杂,如果要输出1和0,则要通过控制:端口输出数据寄存器ODR来实现,ODR 是:Output data register的简写,在STM32里面,其寄存器的命名名称都是英文的简写,很容易记住。
从手册上我们知道ODR是一个32位的寄存器,低16位有效,高16位保留。低16位对应着IO0~IO16,只要往相应的位置写入0或者1就可以输出低或者高电平。
第一层级:基地址宏定义
时钟控制:
在STM32中,每个外设都有一个起始地址,叫做外设基地址,外设的寄存器就以这个基地址为标准按照顺序排列,且每个寄存器32位,(后面作为结构体里面的成员正好内存对齐)。
查表看到时钟由APB2外设时钟使能寄存器(RCC_APB2ENR)来控制,其中PB端口的时钟由该寄存器的位3写1使能。我们可以通过基地址+偏移量0x18,算出RCC_APB2ENR的地址为:0x40021018。那么使能PB口的时钟代码则如下所示:
#define RCC_APB2ENR *(volatile unsigned long *)0x40021018
// 开启端口B 时钟
RCC_APB2ENR |= 1《《3;
模式配置:
同RCC_APB2ENR一样,GPIOB的起始地址是:0X4001 0C00,我们也可以算出GPIO_CRL的地址为:0x40010C00。那么设置PB0为通用推挽输出,输出速率为2M的代码则如下所示:
同上,从手册中我们看到ODR寄存器的地址偏移是:0CH,可以算出GPIOB_ODR寄存器的地址是:0X4001 0C00 + 0X0C = 0X4001 0C0C。现在我们就可以定义GPIOB_ODR这个寄存器了,代码如下:
#define GPIOB_ODR *(volatile unsigned long *)0x40010C0C//PB0 输出低电平
GPIOB_ODR = 0《《0;
第一层级:基地址宏定义完成用STM32控制一个LED的完整代码:
#define RCC_APB2ENR *(volatile unsigned long *)0x40021018#define GPIOB_CRL *(volatile unsigned long *)0x40010C00#define GPIOB_ODR *(volatile unsigned long *)0x40010C0Cint main(void)
{
// 开启端口B 的时钟
RCC_APB2ENR |= 1《《3;
// 配置PB0 为通用推挽输出模式,速率为2M
GPIOB_CRL = (2《《0) | (0《《2);
// PB0 输出低电平,点亮LED
GPIOB_ODR = 0《《0;
}
void SystemInit(void)
{
}
第二层级:基地址宏定义+结构体封装
外设寄存器结构体封装
上面我们在操作寄存器的时候,操作的是寄存器的绝对地址,如果每个寄存器都这样操作,那将非常麻烦。我们考虑到外设寄存器的地址都是基于外设基地址的偏移地址,都是在外设基地址上逐个连续递增的,每个寄存器占32个或者16个字节,这种方式跟结构体里面的成员类似。
所以我们可以定义一种外设结构体,结构体的地址等于外设的基地址,结构体的成员等于寄存器,成员的排列顺序跟寄存器的顺序一样。这样我们操作寄存器的时候就不用每次都找到绝对地址,只要知道外设的基地址就可以操作外设的全部寄存器,即操作结构体的成员即可。
下面我们先定义一个GPIO寄存器结构体,结构体里面的成员是GPIO的寄存器,成员的顺序按照寄存器的偏移地址从低到高排列,成员类型跟寄存器类型一样。
typedef struct
{
volatile uint32_t CRL;
volatile uint32_t CRH;
volatile uint32_t IDR;
volatile uint32_t ODR;
volatile uint32_t BSRR;
volatile uint32_t BRR;
volatile uint32_t LCKR;
} GPIO_TypeDef;
在《STM32 中文参考手册》8.2 寄存器描述章节,我们可以找到结构体里面的7个寄存器描述。在点亮LED的时候我们只用了CRL和ODR这两个寄存器,至于其他寄存器的功能大家可以自行看手册了解。
在GPIO结构体里面我们用了两个数据类型,一个是uint32_t,表示无符号的32位整型,因为GPIO的寄存器都是32位的。这个类型声明在标准头文件stdint.h 里面使用typedef对unsigned int重命名,我们在程序上只要包含这个头文件即可。
另外一个是volatile作用就是告诉编译器这里的变量会变化不因优化而省略此指令,必须每次都直接读写其值,这样就能确保每次读或者写寄存器都真正执行到位。
STM32F1系列的GPIO端口分A~G,即GPIOA、GPIOB。。。。。。GPIOG。每个端口都含有GPIO_TypeDef结构体里面的寄存器,我们可以根据手册各个端口的基地址把GPIO的各个端口定义成一个GPIO_TypeDef类型指针,然后我们就可以根据端口名(实际上现在是结构体指针了)来操作各个端口的寄存器,代码实现如下:
#define GPIOA ((GPIO_TypeDef *) 0X4001 0800)#define GPIOB ((GPIO_TypeDef *) 0X4001 0C00)#define GPIOC ((GPIO_TypeDef *) 0X4001 1000)#define GPIOD ((GPIO_TypeDef *) 0X4001 1400)#define GPIOE ((GPIO_TypeDef *) 0X4001 1800)#define GPIOF ((GPIO_TypeDef *) 0X4001 1C00)#define GPIOG ((GPIO_TypeDef *) 0X4001 2000)
外设内存映射
讲到基地址的时候我们再引人一个知识点:Cortex-M3存储器系统,这个知识点在《Cortex-M3权威指南》第5章里面讲到。CM3的地址空间是4GB
我们这里要讲的是片上外设,就是我们所说的寄存器的根据地,其大小总共有512MB,512MB是其极限空间,并不是每个单片机都用得完,实际上各个MCU厂商都只是用了一部分而已。STM32F1系列用到了:0x4000 0000 ~0x5003 FFFF。现在我们说的STM32的寄存器就是位于这个区域
APB1、APB2、AHB 总线基地址
现在我们说的STM32的寄存器就是位于这个区域,这里面ST设计了三条总线:AHB、APB2和APB1,其中AHB和APB2是高速总线,APB1是低速总线。不同的外设根据速度不同分别挂载到这三条总线上。
从下往上依次是:APB1、APB2、AHB,每个总线对应的地址分别是:APB1:0x40000000,APB2:0x4001 0000,AHB:0x4001 8000。
这三条总线的基地址我们是从《STM32 中文参考手册》2.3小节—存储器映像得到的:APB1的基地址是TIM2定时器的起始地址,APB2的基地址是AFIO的起始地址,AHB的基地址是SDIO的起始地址。其中APB1地址又叫做外设基地址,是所有外设的基地址,叫做PERIPH_BASE。
现在我们把这三条总线地址用宏定义出来,以后我们在定义其他外设基地址的时候,只需要在这三条总线的基址上加上偏移地址即可,代码如下:
#define PERIPH_BASE ((uint32_t)0x40000000)#define APB1PERIPH_BASE PERIPH_BASE#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
GPIO 端口基地址
因为GPIO挂载到APB2总线上,那么现在我们就可以根据APB2的基址算出各个GPIO端口的基地址,用宏定义实现代码如下:
#define GPIOA_BASE (APB2PERIPH_BASE + 0x0800)#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)#define GPIOC_BASE (APB2PERIPH_BASE + 0x1000)#define GPIOD_BASE (APB2PERIPH_BASE + 0x1400)#define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)#define GPIOF_BASE (APB2PERIPH_BASE + 0x1C00)#define GPIOG_BASE (APB2PERIPH_BASE + 0x2000)
第二层级:基地址宏定义+结构体封装完成用STM32控制一个LED的完整代码:
#include 《stdint.h》#define __IO volatiletypedef 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;
typedef struct
{
__IO uint32_t CR;
__IO uint32_t CFGR;
__IO uint32_t CIR;
__IO uint32_t APB2RSTR;
__IO uint32_t APB1RSTR;
__IO uint32_t AHBENR;
__IO uint32_t APB2ENR;
__IO uint32_t APB1ENR;
__IO uint32_t BDCR;
__IO uint32_t CSR;
} RCC_TypeDef;
#define PERIPH_BASE ((uint32_t)0x40000000)
#define APB1PERIPH_BASE PERIPH_BASE#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
#define GPIOA_BASE (APB2PERIPH_BASE + 0x0800)#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)#define GPIOC_BASE (APB2PERIPH_BASE + 0x1000)
#define GPIOD_BASE (APB2PERIPH_BASE + 0x1400)#define GPIOE_BASE (APB2PERIPH_BASE + 0x1800)#define GPIOF_BASE (APB2PERIPH_BASE + 0x1C00)
#define GPIOG_BASE (APB2PERIPH_BASE + 0x2000)#define RCC_BASE (AHBPERIPH_BASE + 0x1000)#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)