第5章深入了解项目模板 5.1启动文件的作用 在项目中配置RunTime Environment时,必须选中CMSIS下的CORE组件,以及Device下的Startup组件。Startup组件与CORE组件之间存在依存关系,两者必须同时选中。选中了Startup组件后,Keil软件会根据项目所选择单片机芯片的类型,添加相应的启动文件。 本书所用的STM32F103VET6单片机为高密度单片机,项目中添加的启动文件为startup_stm32f10x_hd.s。文件名中的hd是high density的首字母缩写,高密度指片上Flash存储器容量为256~512KB的大容量单片机,根据存储器容量,单片机分为小容量、中容量和大容量3个系列。 启动文件用汇编语言编写,文件扩展名为.s。启动文件实现了以下3个基本功能: (1) 定义了栈区(stack)和堆区(heap); (2) 定义了中断向量表; (3) 定义了中断处理函数。 5.1.1定义栈和堆 启动文件中定义了栈和堆的大小。栈区也常常被称作“堆栈”,不要将堆栈与堆混淆。 堆栈(stack)按“先进后出”原则进行操作。将数据存入堆栈称为“压栈”。从堆栈中将数据弹出称为“弹栈”。CPU中有一个专门的寄存器,堆栈指针寄存器SP,指示堆栈栈顶位置,压栈、弹栈操作始终在SP所指的堆栈栈顶位置进行。对堆栈的操作犹如一个单端口的“弹夹”,我们将数据“子弹”连续压入堆栈“弹夹”中,最先压入的数据“子弹”在最下方。射击时最上方的数据“子弹”最先弹出“弹夹”。 堆栈是软件运行的基础,函数的调用与返回、中断的响应与返回都需要利用堆栈。调用函数时将返回地址压入堆栈,然后跳转到函数体去执行。函数结束返回时从堆栈中弹出返回地址,回到函数调用的位置继续向下执行。此外,函数内部的局部变量也在堆栈中分配存储空间,所以函数嵌套调用或中断嵌套时,嵌套层数越多,对堆栈的消耗越大。如果堆栈“溢出”(overflow),那么程序也就“飞死”了。 堆(heap)用于动态内存分配。在程序运行过程中调用malloc()函数动态分配内存时,就是从堆中分配存储空间。动态分配的内存的生存周期由开发人员决定,使用完毕时必须调用free()函数主动释放,否则就会出现内存泄漏现象。 在单片机程序中,栈和堆都是在片上SRAM存储器中分配存储空间,编译时按启动文件中定义的大小划分堆和栈的存储空间。STM32F103VET6单片机内部集成有64KB的SRAM,栈和堆都是从这64KB的SRAM中分配的。启动文件中定义栈和堆的相关代码截图见图5.1。 图5.1启动文件中关于栈和堆的定义 汇编语言中用分号“; ”添加注释信息。Keil软件用不同的颜色标识各类信息,汇编语言的关键字用蓝色,立即数为红色,而自己定义的符号为黑色,注释信息为灰色。虽然启动文件中所有关键字都是全大写,但是汇编语言是不区分字母大小写的编程语言。 启动文件中首先用伪指令EQU定义了Stack_Size,指定堆栈大小。然后用汇编语言伪指令AREA定义了STACK段。伪指令SPACE用于保留指定字节数的存储空间,堆栈大小设定为Stack_Size,即0x00000400。关键字NOINIT说明不初始化这个段的存储单元。汇编语句ALIGN=3说明地址按23,即8字节对齐。定义了堆栈之后,接着用类似的方式定义了堆。 用SPACE伪指令定义了堆栈区大小后,立即定义了符号__initial_sp,这个符号代表了堆栈区尾部的地址。上电复位时用__initial_sp初始化堆栈指针寄存器SP,复位后寄存器SP指向堆栈区的末尾。压栈时堆栈指针SP减小,数据存入堆栈,而弹栈时取出数据,SP增加。随着压栈弹栈操作,SP寄存器始终指向堆栈栈顶。 通过仿真器调试程序时,进入Debug环境后,打开Registers窗口,可以查看内核寄存器,观察SP寄存器的变化情况,了解对堆栈存储空间的使用情况,如图5.2所示。 图5.2堆栈指针寄存器SP 5.1.2定义中断向量表 中断向量指中断处理函数的入口地址。系统中每个中断源有一个对应的中断处理函数,这个中断处理函数的入口地址就是对应的中断向量,所有的中断向量合在一起形成了中断向量表。对于单片机来说,整个中断系统是预先定义好的。 启动文件中用伪指令DCD定义了中断向量表,其中包含内核异常(Exception)处理函数以及片上硬件中断(Interrupt)处理函数的名称,如图5.3所示。DCD说明定义的数据项占4字节,后面的函数名就代表了函数入口地址。由于整个地址空间为4GB,地址位宽为32位,所以需要4字节存放函数入口地址。 图5.3中断向量表 定义了中断向量表后,接着定义所有的异常以及中断处理函数。汇编语言中将函数称为“子程序”。伪指令PROC和ENDP分别定义子程序的开始和结束。除了复位处理子程序Reset_Handler以外,其他所有的中断处理子程序都没有实现任何具体功能,只是一个“死循环”,发生异常时就会陷入对应的异常处理子程序中。相关代码截图见图5.4。 图5.4异常处理子程序 从图5.4中可以看到,NMI_Handler子程序只有一条可执行的指令,汇编指令“B.”的功能是跳转到自身。一旦发生NMI中断请求,系统自动响应中断,调用NMI处理函数时就会一直执行这条“B.”指令,陷入死循环(infinite loop)。 所有异常和中断处理函数的函数名都用伪指令EXPORT输出,并声明为[WEAK]。用[WEAK]说明的符号可以在其他源文件中重新定义,而不会产生错误。如果没有在其他源文件中实现,则以启动文件中的定义作为默认的函数实现。 总的来说,启动文件中定义了中断向量表,规定了每个中断处理函数的函数名,并给出了一个不完成任何功能,仅仅是死循环的函数定义。这个默认的函数定义是可以被改变的,开发人员可以自行在其他源文件中重新实现中断处理函数,但是应注意函数名称必须与中断向量表中的函数名称一致。 5.1.3定义复位中断子程序 复位时会触发复位中断,内核响应复位中断请求,查中断向量表,跳转到复位中断处理子程序,即Reset_Handler去执行。启动文件中复位中断子程序Reset_Handler的相关定义见图5.5。 图5.5复位处理子程序 Reset_Handler子程序中首先用伪指令EXPORT和IMPORT完成了符号的输出和输入。输入的符号__main和SystemInit实际上都是函数名,__main是系统定义的C语言的主函数,而SystemInit是在Startup组件的库函数文件system_stm32f10x.c中定义的系统初始化函数,该函数完成系统时钟SYSCLK,以及AHB、APB1、APB2总线时钟的初始化。 复位子程序的可执行代码只有4条,前两条指令调用了SystemInit()函数,完成系统时钟初始化。后两条指令跳转到__main()函数去执行。系统提供的__main()函数完成库函数的初始化,并且初始化应用程序的执行环境,最后跳转到main()函数,就进入了用户所编写的程序。初始化步骤如图5.6所示,https: //www.keil.com/support/man/docs/armclang_intro/armclang_intro_asa1505906246660.htm讲解了进入用户编写的main()函数之前系统完成的初始化工作。 图5.6系统初始化 设置仿真配置时,如果在Options for Target 'BlinkyLED'窗口的Debug标签页中,取消选中Run to main()复选框,那么进入Debug环境时,会停留在Reset_Handler子程序中。如果选中了Run to main()复选框,那么进入Debug环境时,会直接运行到main()函数才停止,此时系统初始化工作已经完成,进入到用户编写的代码了,如图5.7所示。 图5.7项目中的仿真器配置 5.2单片机的时钟初始化 单片机内核的工作速度受系统时钟频率控制。CortexM3内核时钟频率最高可达72MHz,STMicroelectronics公司生产的STM32F10x系列单片机都采用了CortexM3内核,但是STM32F101系列单片机的主频限定为36MHz,STM32F102系列单片机的主频最高只有48MHz,只有STM32F103系列单片机的工作速度才真正达到最高频率72MHz。 STM32F10x系列单片机的系统时钟SYSCLK有3个来源: 内部高速晶振(HighSpeed Internal Oscillator,HSI)、外部高速晶振(HighSpeed External Oscillator,HSE)和锁相环时钟PLLCLK。 内部高速晶振的频率为8MHz,但内部晶振的精度较低,无法满足对时间精度要求高的硬件模块的需求,实际应用中很少使用它。单片机开发板上大多配置有高精度的外部晶振,但是STM32F10x单片机限定外部晶振频率只能为4~16MHz,远远低于72MHz,不适宜直接将外部高速晶振作为系统时钟。 单片机内部集成有锁相环PLL模块。锁相环PLL模块能够将输入的时钟信号倍频,输出更高频率的高品质时钟信号,因此可以将高速外部晶振作为锁相环PLL的输入时钟信号,经过倍频后,使锁相环的输出时钟信号PLLCLK频率达到72MHz。将72MHz频率的锁相环时钟PLLCLK设置为系统时钟SYSCLK。 目前比较常见的一种时钟配置方案是采用8MHz的外部晶振,通过单片机内部的锁相环PLL,实现9倍倍频,产生72MHz的锁相环时钟PLLCLK,选择PLLCLK作为系统时钟SYSCLK。单片机参考手册中的“复位与时钟控制RCC”相关章节详细说明了如何配置时钟,给出了时钟树示意图,如图5.8所示。 图5.8时钟树示意图 图5.8中的OSC_IN和OSC_OUT为单片机芯片的两个引脚,用于连接外部高速晶振。外部晶振为8MHz,通过配置PLLXTPRE和PLLSRC多路开关,可以选择直接将外部时钟HSE作为锁相环的输入时钟; 然后将锁相环的倍频系数PLLMUL设置为9,使锁相环PLL实现9倍倍频,产生72MHz的PLLCLK; 最后通过配置SW多路开关,选择PLLCLK作为系统时钟SYSCLK。图5.8中的多路开关PLLXTPRE、PLLSRC、SW,以及倍频系数PLLMUL都是RCC模块中时钟控制寄存器CR和时钟配置寄存器CFGR中的控制位。初始化系统时钟时需要正确配置这些寄存器,才能完成系统时钟的初始化。 系统时钟SYSCLK经过AHB预分频后产生HCLK时钟信号。HCLK频率最高为72MHz,HCLK时钟提供给AHB总线、内核、存储器以及DMA。AHB预分频后的时钟信号再分别经过APB1预分频和APB2预分频,得到PCLK1和PCLK2时钟信号。PCLK1时钟信号即为APB1总线时钟,APB1为低速总线,它的时钟频率最大只能为36MHz。PCLK2时钟信号即为APB2总线时钟,APB2总线时钟频率最高可以与AHB总线一致。 若系统时钟SYSCLK设置为72MHz,将AHB预分频系数设置为1,那么分频后HCLK时钟频率与SYSCLK一致,都为72MHz。将APB1预分频系数设置为2,而APB2预分频系数设置为1,PCLK1频率为36MHz,而PCLK2频率为72MHz,使APB1和APB2总线都达到各自的最高频率。 复位时CortexM3内核默认直接将8MHz的内部高速时钟HSI作为系统时钟,时钟频率远远低于系统的最大工作频率,因此复位后的首要工作就是完成时钟初始化,使单片机工作在最高频率下。 系统提供的启动文件中实现了复位中断处理函数Reset_Handler。复位时硬件触发复位中断请求,内核响应中断请求,自动转去执行Reset_Handler中断处理函数,完成时钟初始化以及系统初始化工作后,跳转到用户编写的main()函数。 复位中断处理函数中首先调用SystemInit()函数完成时钟初始化工作,该函数改写RCC模块的相关寄存器,完成锁相环配置,使锁相环时钟频率达到72MHz,并选择锁相环时钟PLLCLK作为系统时钟SYSCLK,然后设置AHB、APB1和APB2的分频系数,完成3个总线的时钟初始化。 运行Keil开发软件,打开本书配套资料中的项目BlinkyLED。在Options for Target窗口的C/C++标签页中预定义了STM32F10X_HD符号,说明单片机类型为大容量。根据项目所用单片机的类型,stm32f10x.h头文件中在条件编译语句里定义了宏HSE_VALUE,声明外部晶振频率为8MHz。然后在system_stm32f10x.c文件中在条件编译语句里定义了宏SYSCLK_FREQ_72MHz,说明系统时钟频率为72MHz,相关代码见图5.9。SystemInit()函数调用SetSysClock()函数,根据这些宏定义,完成时钟初始化。 图5.9时钟初始化的相关宏定义 SystemInit()函数定义如下。为适应不同单片机类型,函数中用条件编译语句针对单片机类型,编译不同的代码段。本书所用单片机为STM32F10X_HD类型,为节省篇幅,将函数中针对其他类型单片机的代码用省略号代替。 void SystemInit (void) { RCC->CR |= (uint32_t)0x00000001; //按位相或,将CR寄存器d0位置1,使能内部高速 //时钟HSI #ifndef STM32F10X_CL RCC->CFGR &= (uint32_t)0xF8FF0000; //按位相与,将指定位清0,设置CFGR寄存器 #else … #endif /* STM32F10X_CL */ RCC->CR &= (uint32_t)0xFEF6FFFF; //将CR寄存器中的HSEON, CSSON和PLLON 位清0 RCC->CR &= (uint32_t)0xFFFBFFFF; //将CR寄存器中的HSEBYP位清0 //将CFGR寄存器中的PLLSRC, PLLXTPRE, PLLMUL和 USBPRE位清0 RCC->CFGR &= (uint32_t)0xFF80FFFF; #ifdef STM32F10X_CL … #elif defined (STM32F10X_LD_VL)||defined (STM32F10X_MD_VL)||(defined STM32F10X_HD_VL) … #else RCC->CIR = 0x009F0000; //清除悬挂的中断标志位,即将中断标志位清0 #endif /* STM32F10X_CL */ #if defined (STM32F10X_HD) || (defined STM32F10X_XL) || (defined STM32F10X_HD_VL) #ifdef DATA_IN_ExtSRAM SystemInit_ExtMemCtl(); //如果扩展了外部SRAM,才调用该函数进行初始化 #endif /* DATA_IN_ExtSRAM */ #endif SetSysClock(); //设置系统时钟频率、HCLK、PCLK2和PCLK1预分频系数,配置Flash接口参数 #ifdef VECT_TAB_SRAM SCB->VTOR = SRAM_BASE | VECT_TAB_OFFSET; /*将向量表重定位到内部SRAM中*/ #else SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; /*将向量表重定位到内部FLASH中*/ #endif } SystemInit()函数配置RCC中的相关寄存器,初始化Flash接口和锁相环PLL,设置内核时钟、系统时钟SYSCLK,以及HCLK、PCLK1和PCLK2时钟。这个函数只在复位中断处理函数Reset_Handler中调用一次,完成时钟初始化,此后单片机内部总线时钟就已经设定好了。 单片机内部总线时钟初始化完毕后,挂接在总线上的片上外设才能正常工作。出于降低功耗的目的,复位后在默认情况下,只有Flash接口和SRAM的时钟处于使能状态,其他片上外设时钟均为禁止状态。若不使能片上外设的时钟,则片上外设不工作,也就不产生功耗。 RCC模块为AHB、APB1和APB2总线各提供了一个时钟使能寄存器,即AHBENR、APB1ENR和APB2ENR寄存器。寄存器中的每个二进制位控制总线上一个片上外设的时钟,将对应位置1使能时钟,清0则禁止时钟。 开发具体的嵌入式系统时,只会用到单片机内部的部分硬件资源。这时应根据项目需求,使能所用到的片上外设的时钟。需要使用某个片上外设时,首先应该调用RCC模块的库函数,使能该外设的时钟; 然后才能调用硬件模块的库函数,完成片上外设的初始化。 例5.1: 参考例程BlinkyLED的分析与调试。 硬件连接: PC6引脚通过1kΩ电阻接LED小灯的阳极,小灯阴极接GND。 单片机最小板上将PC6和PC7引脚分别通过1kΩ电阻接到2个贴片发光二极管的阳极,只要将J5插针的跳帽接上,发光二极管D1、D2的阴极就连接了GND,如图5.10所示。 图5.10硬件连接示意图 软件分析: 通过GPIOC模块的PC6引脚控制一个LED小灯,实现亮灭闪烁的效果。 项目中除了Keil软件添加的CMSIS库文件外,只编写了main.c源文件,main()函数中首先完成了GPIOC模块中的IO引脚PC6的初始化,然后在while(1)循环体中控制PC6引脚输出高、低电平,控制LED小灯亮灭,实现小灯闪烁效果,相关代码如下。 BlinkyLED项目main.c源文件。 #include "stm32f10x.h" void delay(__IO uint32_t time) {for(;time>0;time--);} int main(void) {GPIO_InitTypeDef GPIOinit; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); //RCC函数, 使能 GPIOC 时钟 //调用GPIO_Init()函数, 初始化 PC6 引脚, 模式为Out_PP GPIOinit.GPIO_Pin = GPIO_Pin_6; GPIOinit.GPIO_Mode = GPIO_Mode_Out_PP; GPIOinit.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOC, &GPIOinit); while(1) {GPIO_WriteBit(GPIOC, GPIO_Pin_6, Bit_SET); //输出高电平, 灯亮 delay(0x200000); GPIO_WriteBit(GPIOC, GPIO_Pin_6, Bit_RESET); //输出低电平, 灯灭 delay(0x200000); } } 复位时触发复位中断,CPU响应复位中断,自动执行Reset_Handler中断处理子程序,完成系统时钟初始化后,进入用户编写的main()函数。 main()函数中首先应该完成各个硬件模块的初始化工作,然后进入while(1)死循环,在循环体中编写代码实现具体功能。初始化硬件模块时必须先使能片上外设的时钟,然后才能初始化片上外设的工作模式。片上外设初始化完毕之后,才能调用硬件模块的接口函数,控制片上外设的工作。 调试程序时,可以在合适的指令旁设置断点。设置好断点后,按F5键连续运行程序,遇到断点暂停执行程序,这时就可以在Watch窗口中观察变量或片上外设寄存器的值。 BlinkyLED项目只用到一个片上外设,即GPIOC模块,GPIO模块都在APB2总线上,所以main()函数中首先调用RCC模块的库函数RCC_APB2PeriphClockCmd()使能GPIOC模块的时钟; 然后再调用GPIO模块的库函数GPIO_Init()初始化GPIOC模块PC6引脚的工作模式,完成引脚初始化。 在Debug调试环境下可以看到,调用GPIO_Init()函数初始化PC6引脚后,GPIOC模块的CRL寄存器从复位后默认值0x44444444改变成0x42444444,说明PC6引脚的工作模式已经修改成功,如图5.11所示。如果没有先开启GPIOC模块时钟,那么由于模块不工作,GPIO_Init()函数调用不起作用,所以初始化后CRL寄存器依然为0x44444444。 图5.11GPIOC模块CRL寄存器情况 初始化PC6引脚的工作模式和速度后,在while(1)循环体中,调用GPIO模块的库函数GPIO_WriteBit(),先将PC6引脚置位,控制LED小灯亮,延时一段时间后,再次调用GPIO_WriteBit()函数将PC6引脚复位,控制LED小灯灭。 由于单片机执行指令速度非常快,将PC6引脚置位或复位后,必须调用延时函数,使小灯亮、灭状态维持一段时间,才能看到小灯亮、灭闪烁的效果。如果不调用延时函数或者延时时间过短,那么单步调试程序时能够观察到小灯亮、灭状态的变换,但是连续运行时就会看到小灯一直点亮,而看不到小灯熄灭的状态了。 5.3stm32f10x.h头文件的作用 CMSIS为片上硬件模块提供了相应的库函数,开发者只需要调用这些库函数,就能完成硬件模块的初始化,控制硬件模块的工作。在CMSIS基础上进行开发,能够大大降低开发难度,缩短项目开发的周期。嵌入式系统的开发人员有必要了解CMSIS固件库的基本架构,熟悉CMSIS库函数。 打开任意一个库函数的C语言源文件,会发现它们全都包含stm32f10x.h头文件。简单地说,想要在CMSIS固件库基础上完成嵌入式应用开发,就必须包含这个头文件。作为库函数的使用者,必须熟悉stm32f10x.h头文件,了解该文件的作用。 stm32f10x.h头文件中主要包含以下信息。 (1) 定义枚举数据类型IRQn,声明了所有中断源的中断类型号。 CortexM3内核中的NVIC模块管理单片机中所有的中断源。每个中断源有一个唯一的编号,称为“中断类型号”。NVIC模块最多可以管理240个中断源,包括内核产生的异常(Exception)和片上外设产生的中断(Interrupt)。内核异常的类型号为负数,片上外设中断的类型号从0开始,内核异常的优先级高于片上外设中断。 基于CortexM3内核,生产商设计具体的单片机型号时,根据单片机集成的片上硬件资源,规定了片上外设中断源的个数以及对应的类型号。所以虽然NVIC最多可以管理240个中断源,但具体某个型号单片机的中断源个数可能远远低于240。例如,STM32F10x系列单片机最多只有60个片上外设中断源,中断类型号为0~59。单片机参考手册的“中断和异常向量”节中给出了中断向量表。 stm32f10x.h头文件中定义了IRQn枚举数据类型,包含了所有内核异常以及片上外设中断的类型号。由于不同类型单片机集成的片上资源不同,定义IRQn枚举数据类型时,使用了条件编译指令,根据项目中声明的单片机类型,编译不同的代码片段。例如,STM32F10X_HD类型的大容量单片机片上资源丰富,IRQn中定义的中断类型号包含了全部60个片上外设中断源,而STM32F10X_LD类型的小容量单片机片上资源较少,条件编译语句中定义的中断类型号就比较少。定义枚举数据类型IRQn的部分代码如图5.12所示。 图5.12stm32f10x.h头文件中的IRQn枚举数据类型定义 (2) 包含系统头文件。 打开CMSIS的库函数文件,常常会看到一些ANSI C标准中没有定义的数据类型,例如uint8_t或uint32_t等。这些数据类型是重命名的ANSI C基本数据类型,例如,uint8_t实际上就是unsigned char类型,而uint32_t实际就是unsigned int类型。 系统头文件stdint.h中用typedef关键字对无符号和有符号整型数据类型做了一系列的重命名,相关定义代码片段见图5.13。要使用这些重命名后的数据类型,就必须包含相应的头文件。 图5.13stdint.h中重定义整型的部分代码 在嵌入式系统开发中,常常需要定义整型变量来访问片上外设的寄存器,这些寄存器可能是16位的,可能是32位的,所定义的整型变量位宽必须与要访问的寄存器一致。ANSI C标准中的char、short、int等数据类型,从名称上看不出所定义整型变量的位宽,而重命名后的数据类型,名称中就明确说明了整型的位数,以及有符号或无符号的特性,例如,int8_t为有符号8位整型,而uint16_t是无符号16位整型。 除了stdint.h头文件以外,作为嵌入式系统的开发人员,有时需要访问内核的相关寄存器,这时就需要包含core_cm3.h头文件。core_cm3.h是为内核定义的头文件,其中包含内核中硬件模块的结构体数据类型定义,如NVIC模块和SCB模块等。要访问内核硬件模块,就需要包含core_cm3.h头文件。 如果开发人员要自己了解所有系统头文件的作用,并在代码中一一包含这些头文件,会是一件非常头疼的事情。stm32f10x.h头文件中包含了stdint.h、core_cm3.h以及其他的系统头文件,开发人员只需要包含stm32f10x.h这一个头文件,就可以顺利在代码中调用固件库接口函数了。 (3) 包含标准外设驱动的头文件。 在CMSIS固件库基础上完成项目开发时,新建项目后,需要在Options for Target 'BlinkyLED'窗口的C/C++标签页中预定义两个符号,STM32F10X_HD和USE_STDPERIPH_DRIVER,如图5.14所示。符号STM32F10X_HD说明单片机的类型,而符号USE_STDPERIPH_DRIVER说明项目开发使用标准外设驱动。这两个符号的定义会改变stm32f10x.h头文件中条件编译指令的编译结果。 图5.14C/C++标签页 如果定义了符号USE_STDPERIPH_DRIVER,那么stm32f10x.h头文件中的条件编译指令满足条件,编译时会包含stm32f10x_conf.h头文件。stm32f10x_conf.h头文件被称作“头文件的头文件”,其中包含了项目选中的CMSIS组件的相关头文件,这样开发人员就可以直接调用库函数控制片上外设了,如图5.15所示。 在Manage RunTime Environment窗口中选中项目所需的所有组件后,Keil 5开发软件自动生成RTE_Components.h头文件,根据配置情况自动生成对应的宏定义指令。stm32f10x_conf.h头文件中用条件编译语句包含CMSIS每个组件的头文件,如果定义了对应的宏,则包含了该组件的头文件。所以只要在Manage RunTime Environment窗口中选中了对应的组件,那么编译后stm32f10x_conf.h头文件就会包含该组件的头文件。而stm32f10x.h头文件中包含了stm32f10x_conf.h头文件,因此,对于开发人员来说,只要包含stm32f10x.h头文件,就能调用CMSIS固件库的函数了。 图5.15相关头文件 (4) 为访问片上外设寄存器定义结构体数据类型和指针。 单片机内部集成有多种硬件模块,如模数转换模块ADC、通用目的输入/输出模块GPIO、定时器模块TIM。某些硬件模块可能有多个,例如,STM32F103VET6单片机中集成了GPIOA~GPIOE共5个GPIO模块。每个硬件模块内有多个寄存器,开发人员需要读写这些寄存器,才能了解硬件模块的工作状态,控制硬件模块的工作模式。 单片机集成的片上硬件模块个数以及所有片上外设的地址都是固定的,参考手册“存储器映像”中列出了所有片上外设寄存器组的起始地址。并且参考手册中为每一种硬件模块单独编写了一章,详细介绍硬件模块的功能,模块中每个寄存器的作用,并在最后一节中列表给出模块中寄存器的相对偏移地址。 所有的片上外设都挂接在AHB、APB1或APB2总线上,总线的基地址以及各个片上外设的起始地址都是确定的。相应地,stm32f10x.h头文件中定义了一系列的宏,首先定义了各个存储区的基地址,接着定义了3总线的基地址,最后定义了总线上片上外设的基地址,如图5.16所示。 图5.16片上外设基地址的相关宏定义 stm32f10x.h头文件中为每种硬件模块定义了一个结构体数据类型,结构体中的每个数据成员对应模块中的一个寄存器,而结构体数据成员定义的先后顺序就反映了模块内寄存器的相对偏移地址。 定义了结构体数据类型,通过宏定义指定了片上外设基地址之后,stm32f10x.h头文件为每个片上外设定义了一个宏,通过强制类型转换,将片上外设的基地址转换成相应的结构体指针。通过结构体指针就能够访问片上外设的寄存器了。 例如GPIO模块,参考手册“GPIO和AFIO寄存器地址映像”节给出了GPIO模块寄存器的相对地址。而单片机芯片中包含多个GPIO模块,STM32F103系列单片机中最多包含GPIOA~GPIOG共7个GPIO模块,参考手册“存储器映像”节中给出了所有片上外设的地址空间。 stm32f10x.h头文件中首先为GPIO模块定义了结构体数据类型GPIO_TypeDef,然后定义宏,将每个GPIO模块地址强制转换成GPIO_TypeDef类型的指针,如图5.17所示。 图5.17如何访问片上的GPIO模块 stm32f10x.h头文件中为所有的片上外设都定义了结构体数据类型,定义宏指明模块寄存器基地址,并且通过宏定义,将模块基地址强制转换成对应的结构体指针,因此只 图5.18访问TIM5寄存器 需要包含stm32f10x.h头文件,就可以通过结构体指针访问所有片上外设寄存器了。 例5.2: 编程操作基本定时器TIM5的寄存器。 源程序中需要先包含stm32f10x.h头文件,编译后,编写代码时Keil 5开发软件会自动提示结构体成员,从中选择要访问的寄存器,对其进行读写操作就可以了,如图5.18所示。 5.4项目中的文件管理 5.4.1CMSIS固件库文件 安装Keil 5开发软件后,需要根据所选用的单片机型号安装相应的Pack包。本书选用STMicroelectronics公司的STM32F103系列单片机,所以需要安装STM32F1xx_DFP的Pack包。安装了Pack包后,Keil 5开发软件中就包含了CMSIS固件库,无须用户自己单独下载或复制CMSIS固件库文件到项目中。 建议安装Keil 5软件时不要修改安装路径,默认的安装路径为c:\Keil_v5。软件安装后会新建一系列文件夹,其中c:\Keil_v5\ARM\PACK文件夹下包含所有安装的Pack包文件,Pack文件夹下包含ARM和Keil子文件夹,其中ARM子文件夹下是ARM公司提供的与内核相关的CMSIS固件库,如图5.19所示,例如,core_cm3.h头文件就存放在这个子文件夹下。 图5.19ARM的CMSIS固件库所在路径 c:\Keil_v5\ARM\PACK\Keil路径下存放各生产商提供的单片机系列的Pack包,c:\Keil_v5\ARM\PACK\Keil\STM32F1xx_DFP\1.0.5\Device\StdPeriph_Driver文件夹下包含标准外设固件库文件,文件路径中的1.0.5是所安装Pack包的版本号。标准外设固件库的C语言源文件都存放在src子文件夹下,而对应的头文件都存放在inc子文件夹下,如图5.20所示。 图5.20Keil 5中所安装的Pack包文件 5.4.2项目中的系统文件 运行Keil 5开发软件,选择Project菜单下的New uVision Project菜单项,按照向导的指引新建项目后,Keil 5软件会在指定的项目文件夹下新建一些子文件夹,并将部分固件库文件复制到项目文件夹下。新建项目后,Keil 5软件在项目文件夹下新建了RTE、Objects、Listings子文件夹,以及扩展名为.uvoprojx的项目文件和扩展名为.uvoptx的项目选项配置文件。项目文件的文件名与项目同名,因此项目名称中不要有空格、中文或特殊符号,项目名称可参照C语言中标识符的命名规则。图5.21显示了BLinkyLED项目文件夹的内容,其中USERS和myHardware子文件夹是开发人员新建的,其他3个子文件夹是Keil 5新建的。 图5.21BLinkyLED项目文件夹内容 (1) RTE子文件夹 RTE子文件夹下存放RunTime Environment相关的文件。根据项目CMSIS组件的配置情况,Keil 5软件自动生成的RTE_Components.h头文件以及其他相关文件都在RTE子文件夹下,启动文件也复制放在这个文件夹下。只有少量CMSIS固件库文件被复制到项目文件夹下,项目使用的CMSIS标准外设驱动固件库文件没有复制到项目文件夹下,这些固件库文件在Keil 5软件的安装路径下。 (2) Objects子文件夹 一个项目中包含有多个汇编语言或C语言源文件,单击Build工具按钮后,首先逐一编译项目中的每一个源文件,编译后输出同名的.d和.o文件,其中.o为目标文件。编译成功后,再连接项目中所有的.o目标文件,输出一个扩展名为.axf的可执行文件,编译连接时的提示信息如图5.22所示。 图5.22编译项目时的提示信息 默认情况下,编译和连接时输出的文件都存放在Objects子文件夹,并且输出的可执行文件与项目同名。在项目配置的Output标签页中可以修改输出文件的存放位置,以及可执行文件名称,如图5.23所示。 图5.23Output标签页 图5.23中“Create Executable:.\Objects\BlinkyLED”中的“.\”说明是相对路径,指代当前目录,即项目文件夹,BlinkyLED为文件名称。在右上方的输入框中可修改可执行文件的名字,单击上方的Select Folder for Objects按钮可重新选择输出文件的存放位置。如果在对话框中选中了Create HEX File复选框,那么输出axf文件后,转换工具会将axf文件转换成HEX格式的文件,此时输出文件夹下会产生两个文件名相同,扩展名分别为.axf和.hex的可执行文件。 除了目标文件和可执行文件外,编译源文件时还会产生扩展名为.crf的交叉引用文件,这个文件中包含了源代码中的宏定义、变量和函数的定义及声明。有了这个文件,编写程序时才能够通过Go to definitions of快捷菜单跳转到函数或变量的定义。 项目中包含多个源文件,每个源文件都需要经过编译,编译后产生对应的依赖文件.d、目标文件.o以及交叉引用文件.crf。所有源文件都编译成功后,才能连接产生可执行文件.axf。默认情况下,上述所有输出文件都存放在项目文件夹下的Objects子文件夹中,如图5.24所示。 图5.24BlinkyLED项目的输出文件 (3) Listings子文件夹 Listings文件夹下存放由编译器和连接器输出的扩展名为.lst的列表文件以及扩展名为.map的镜像文件,这些对于研究编译过程非常有帮助。在项目配置的Listing标签页中可以修改列表文件的存放位置,以及.map文件包含的内容,如图5.25所示。 图5.25Listing标签页 5.4.3项目中的用户文件 新建项目后,开发人员需要向项目中添加C语言源文件,如主程序main.c源文件等,在这些源文件中编写代码,实现项目设计的功能。 (1) 用户文件的命名及保存 首先,从原则上说,项目中所有用户新建的源文件以及头文件必须存放在项目文件夹下。这样当需要备份项目,或需要将项目复制到其他PC上继续开发时,只需要复制项目文件夹就可以了。 其次,源程序文件的名称应该在一定程度上说明程序的功能,例如,main.c是主程序源文件,而串口操作的源文件可以命名为myUart.c,实现复杂算法的源文件可以命名为myMath.c。 除了main.c主程序源文件以外,其他源文件中大多会编程实现一些接口函数,为了方便主程序或其他文件调用这些接口函数,通常会为每个源文件新建一个对应的头文件,头文件的文件名应该与源程序文件名相同。例如,为控制按键和小灯而编写的C语言源文件可以命名为KeyLED.c,而对应的头文件就应该命名为KeyLED.h。 在头文件中做函数声明、宏定义、全局变量的外部声明等,这样其他源程序只要包含头文件,就能够调用对应的接口函数了。 最后,如果项目功能较为复杂,为操作片上外设编写了相应的接口函数,新建了多个C语言源文件,就可以考虑在项目文件夹下新建子文件夹,分别存放这些源文件与相应的头文件。 (2) 项目中用户文件的组织 在Keil 5中打开项目后,在Project窗口中可以看到当前项目文件的组织情况,用户新建的源文件应该添加到项目中。在Project窗口的项目名称上右击,或在分组上右击,从弹出的快捷菜单中选择相应的菜单项,就能够为项目新建分组,或将源文件添加到相应分组中,如图5.26所示。 图5.26项目的文件管理 如果项目中有头文件存放在子文件夹下,那么需要在项目配置的C/C++标签页中将子文件夹路径添加到Include Paths中。 BlinkyLED项目在项目文件夹下新建了USERS子文件夹存放main.c源文件,新建了myHardware子文件夹存放GPIOBitBand.h头文件,main.c中包含了GPIOBitBand.h头文件,必须在C/C++标签页中添加myHardware子文件夹的路径,如图5.27所示。 图5.27C/C++标签页 如果项目文件夹下新建了多个子文件夹,或者一个子文件夹下又新建了子文件夹,那么必须在项目配置的C/C++标签页中逐一添加所有的子文件夹作为搜索路径。单击图5.27右方的“…”按钮,会弹出对话框,在已有搜索路径下方的空白处双击,就能够添加新的搜索路径了。 项目中的源文件需要包含某个头文件时,可以直接用指令“#include"头文件"”,指令中头文件名称用双引号括起来,双引号意味着编译器会先在项目的当前目录中查找,如果找不到,就会去系统配置的库环境变量以及用户配置的路径(即用户在C/C++标签页中添加的路径)中搜索。如果在所有这些路径中都没有找到头文件,编译器就报错,提示找不到头文件。例如,如果删除BlinkyLED项目中配置的包含路径,编译main.c源文件时编译器找不到所包含的GPIOBitBand.h头文件,此时就会产生编译错误,错误提示如图5.28所示。 图5.28编译错误: 找不到头文件