首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

STM32的启动过程

上一篇文章中,我们介绍了如何创建一个工程、添加源代码, 并进行编译链接和调试。本文着重分析STM32的启动过程。主要回答两个问题:

  1. STM32是如何启动的,为什么能够执行到main函数;
  2. 如何保证编译后的代码能够烧录到正确的地址中。

1. MCU工作的基本原理

在回答那两个问题之前,我们先介绍一下CPU的工作原理。下图1是一个MCU的结构框图。

从图中我们可以看出一个MCU一般由CPU(中央处理单元)、地址空间、片上外设三个部分组成。CPU是处理器的核心,它控制着整个系统的运作。 地址空间则是一个抽象的概念,它是CPU访问外设和内存的接口。如果一定要对应一个物理设备的话,我想应该是芯片内部的地址总线和数据总线。 片上外设以及芯片内部的各种存储设备都会被映射到地址空间中,使得我们在编写程序的时候只需要对地址空间中的某一段进行读写就可以操控外设了。 片上外设实际上是一个集合,指的是MCU芯片内部为实现某一特殊功能而专门设计的模块,比如说串口、GPIO等等,它们都会映射到地址空间中。

一个芯片上有很多引脚,它们有片上外设控制,是连接芯片内部与用户设备之间的桥梁。 每个引脚的功能基本都是确定的,不过为了节省资源同一个引脚往往对应几个不同的功能。所以具体一个引脚是用作串口还是输出PWM信号,除了由其物理属性决定之外, 还需要在程序中通过软件进行配置。通过正确的连接和配置引脚的功能,合理的操控片上外设,我们就可以控制LED,驱动SD卡,进而实现期望的功能。 关于引脚的分配和使用,我们会在通用IO端口驱动LED灯一文中予以介绍。

图1 MCU结构框图

作为一个计算机系统的核心,CPU的实际工作就是取指令和计算。大体上它可以看做是三个部分组成的:寄存器组、算术逻辑单元(ALU)、指令队列。 在Cortex-M4的寄存器中有一个特殊的寄存器PC(Program Counter,程序计数器), 用于控制程序的执行。在每个时钟周期中CPU都会根据PC中的值,从地址空间中取一条指令放到指令队列中, 同时从指令队列中取出一条指令进行解析和运算(实际上ARM采用的是一种流水线的指令处理方式,与这里所讲的内容还是有很大差异的,但大体思想差不多)。 并把上次的运算结果写到寄存器中。

CPU运算所用的指令和数据都来自地址空间。在Cortex-M4的内存系统中, CPU可以访问4G的地址空间,根据所映射的物理对象不同大体上被划分成了6块。我们烧写到芯片内部的程序一般都在其中的Code段中, 在STM32中这个段对应的是一块FLASH。上电的时候,基本上只有这块FLASH中的内容是确定的,其它地址空间以及CPU内部的寄存器中的值都是随机的。 当然为了防止上电的时候外设产生意外,片上外设(Peripheral)段中的值在上电的时候也会有初始值。

所以,从处理器的角度来看,启动过程实际上是给各个寄存器赋初值的过程,更具体的是给PC寄存器赋初值的过程。从MCU和系统的角度来看, 启动过程是初始化处理器和外设的过程。

2. STM32的启动过程

为了研究STM32的启动过程,我们建立一个极精简的工程(代码下载), 如下如图2所示,这个工程项目中只有三个文件,除去启动文件"startup_stm32f40_41xxx.S"外, 一共只有两个空函数,分别放在了"main.c"和"system_stm32f4xx.c"中。 启动文件是从ST的官方库中直接拷贝过来的,为了配合启动文件,并探究如何执行到main函数, 特意用C语言实现了main和SystemInit两个函数。本文将围绕这个精简的项目回答文章开始提出的两个问题。

图2 一个极精简的工程

上电后系统进行复位,等到时钟稳定后才可以正常工作,这个过程通常需要几个毫秒。 图3中描述了处理器的复位过程,Cortex-M内核会先从地址0x0000处读取栈地址,并写到CPU内部的SP寄存器中。 再从地址0x0004读取Reset Vector到PC寄存器中,进而跳转到Reset Vector所指的地址上开始执行程序。

图3 Cortex-M复位流程

栈空间是处理器实现函数调用和中断服务的工具。函数调用和中断服务有一个共同的特点就是,它们都需要先把当前正在处理的内容暂时保存下来,转而执行要调用的函数, 或者中断服务函数,等待新的函数执行完毕返回后,在从原来保存的内容恢复回来继续执行原来的函数。而函数的调用是支持嵌套的,也就是说一个函数中可以调用子函数, 在子函数中又可以调用其它子函数。那么从函数的调用和返回的顺序上来看,最后调用的函数一定先返回。栈这种数据结构的特点就是其中的数据是后进先出的, 与函数调用和返回的顺序是一致的。因而,人们就专门在内存空间中划分出来一块用作栈空间,并从CPU中拿出一个宝贵的寄存器用于指示栈顶, 该寄存器被记为SP(Stack Pointer)。

所以,前面所说的从CPU的角度看启动过程就是PC寄存器初始化的过程还不完善。虽然对PC寄存器进行初始化后,CPU就可以正常的取指令并进行运算了, 但这时所能完成的功能十分有限,并不能支持对我们很重要的函数和中断。因此,从CPU角度看启动过程是对PC和SP两个寄存器的初始化过程。

Cortex-M4中规定0x0000起始的地址存放的是系统向量表(vector table)。在STM32中0x0000本身并不对应什么物理设备, 通过配置引脚BOOT[1:0]我们可以控制0x0000映射到地址空间中的其它地址中,也就实现了不同的启动方式。一共有三种可选的启动方式如表1所示, 从主闪存或者系统存储器启动时,硬件上会把0x0000 0000映射到0x0800 0000或者0x1FFF F000上,这样我们从地址0x0000 0000访问的空间实际上就是主闪存或者系统存储器的空间。 从SRAM启动时,只能在0x2000 0000开始的地址访问SRAM。一般我都是从主闪存启动的,也就是说系统的向量表应当烧写在0x0800 0000的地址上。至于如何从SRAM启动还没有研究过。

表 1 STM32的启动模式

启动模式选择引脚 启动模式 偏移地址
BOOT1 BOOT0
X 0 主闪存(Main Flash Memory) 0x0800 0000
0 1 系统存储器(system memory) 0x1FFF F000
1 1 内置SRAM(Embedded SRAM) 0x2000 0000

3. 确定代码的烧写地址

在STM32中,一般都会有一个片上的Flash和SRAM。Flash用于烧录我们编译后生成的目标代码,SRAM则用于栈空间和保存全局变量, 它们分别对应图1中地址空间的Code段和SRAM段。此外STM32中的Flash一般都映射在0x0800 0000的地址上的, 因此为了保证向量表写在0x0800 0000的位置上,我们必须保证生成的目标代码中一开始就是向量表的内容。

我们的源文件不止一个,向量表怎么就写在了0x0800 0000的地址上呢?我们打开“Options for Target 'Project Name'”窗口, 如图3所示,在Linker选项卡下,可以看到链接器的配置。其中"R/O Base"一栏中标注了只读代码的起始位置0x0800 0000,"R/W Base"一栏标注了可读写的地址空间。 我们只需要保证向量表链接在只读代码段的起始位置就可以了。这一点由链接器的控制参数(Linker control string)和分布文件(Scatter File)实现。 这两项都是由μVision自动生成的,刚刚提到的只读代码段地址和可读写的地址实际上只是为生成分布文件提供了一些数据而已。 我们也可以根据自己的需要重新编辑分布文件,比如实现应用程序自编程时,我们就会对其做些简单的修改。

图3 项目工程的连接设置

Linker control string描述了链接器的工作参数,我们的项目的具体配置信息如下。

        --cpu Cortex-M4.fp *.o 
        --strict --scatter ".\Objects\Particle.sct"
        --summary_stderr --info summarysizes --map --xref --callgraph --symbols 
        --info sizes --info totals --info unused --info veneers 
        --list ".\Listings\Particle.map" 
        -o .\Objects\Particle.axf

这里把关于Scater File的选项用红色标注出来了,按照其描述的路径就可以找到Particle所用的分布文件。 Scater File则是用来告知编译器各段代码在最后生成的可执行文件中的位置。下面是由μVision生成的分布文件内容:

        LR_IROM1 0x08000000 0x00100000  {    ; load region size_region
            ER_IROM1 0x08000000 0x00100000  {  ; load address = execution address
                *.o (RESET, +First)
                *(InRoot$$Sections)
                .ANY (+RO)
            }
            RW_IRAM1 0x20000000 0x00020000  {  ; RW data
                .ANY (+RW +ZI)
            }
        }

LR_IROM1描述了可以烧录代码的起始地址(0x0800_000)和大小(0x0010_0000),最后生成的代码分为ER_IROM1和RW_IRAM1两个部分。 这里的起始地址正好就是向量表所要在的位置。

参考数据手册(Data Sheet)的第四章内存映射内容我们知道从0x0800_0000开始长度为0x0010_0000的这段地址空间是一段片上的FLASH, 系统掉电后它仍保有原来的数据,一般用来保存系统的代码。 所以这里的ER_IROM1就是用来告知链接器把生成的各个*.o工程文件以及只读的代码放置在从0x0800_0000起始的地址空间中。 红色标注的语句告知链接器把RESET代码段放置到起始位置。后面分析启动文件时就会发现RESET代码段一开始就定义了系统的向量表。

任何可读可写的代码段都被放置到了RW_IRAM1中,RW_IRAM1起始于0x2000_0000对应于一段片上的RAM区域, 这段地址空间中的数据掉电后是不能保留的,通常用作数据段保存系统工作过程中的数据, 一般被抽象为栈空间和堆空间(关于栈和堆以后会有专题介绍)。

3. 启动文件"startup_stm32f40_41xxx.S"

启动文件中定义了系统的向量表、栈空间和堆空间。系统复位后首先就执行该文件中的代码。

在启动文件中,首先定义了400字节的栈空间和200字节的堆空间。 由于这两段空间是可读可写的(READWRITE),根据链接器和分布文件的配置,这两段代码将被放置到RW_IRAM1声明的地址空间下,即0x20000000。

接着启动文件定义了系统的向量表。 这段代码被定义为只读的(READONLY),并且被标记为RESET,因此将被映射到ER_IROM1声明的地址空间下的起始位置,而__Vectors标识了向量表的起始位置。 其中的第一个数据就是系统的栈顶向量__initial_sp, 紧接着就是复位处理程序向量Reset_Handler,这正是系统复位时首先读取的两个数据。

复位处理程序很简单只有几行,它首先执行函数SystemInit,然后执行函数__main。 这两个函数就是那两个空函数,SystemInit和main(main函数在编译过程中会被加两个下划线)。

此时我们可以看到,系统在跳转到Reset_Handler所指的代码后,先执行了函数SystemInit。 在官方的库中,这个函数做了一些系统时钟初始化的工作。 然后就开始执行我们的main函数,实际上我们完全可以把SystemInit这个函数删掉,把初始化工作移到main函数中完成。

4. 总结




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1