漫谈STM32单片机启动过程

前言

笔者假期刚开学没几天,现在处于一个比较闲的状态。

假期简单学习了一下ARM汇编相关内容,目的是写一个简单的RTOS,但是这个项目其实只有保存上下文的部分使用到了ARM汇编,于是打算写写博客来沉淀一下假期学到的东西。

单片机的启动过程一直是笔者十分感兴趣的话题,故借着这个机会简单分析一下单片机的启动过程。由于笔者知识浅薄,分析的过程必定有所纰漏,还望有心人斧正。

启动文件分析

要分析整个启动过程必然离不开对于启动文件的分析,此处笔者以ST官方提供的STM32F401CCU6单片机的Keil环境下的启动文件为例进行简单的分析:

startup_stm32f401xc.s

堆栈空间的分配

笔者此处依照从上到下的原则对文件进行分析,首先来看第一段:

Stack_Size      EQU     0x400

                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

第一行对栈大小进行了定义,以便限制使用使用栈的大小以及其他形式的内存使用。

第三行使用AREA伪指令指定了栈区空间,该部分空间可读可写,不进行初始化并且地址边界3字节对齐。

第四行使用SPACE伪指令填充了栈区空间(占坑),填充的空间大小是刚刚设置的Stack_Size.

第五行使用__initial_sp标签标识了栈顶以便后续向量表使用。

下面一段与上面一段以几乎相同的方式分配了堆的空间:

Heap_Size      EQU     0x200

                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

                PRESERVE8
                THUMB

同时使用__heap_base__heap_limit两个标签标识了堆空间的边界备用。

此外,第八行使用PRESERVE8伪指令指明了堆栈需要保留8字节对齐;第九行使用THUMB伪指令指明了文件所使用的指令集为THUMB指令集

向量表

完成了堆栈的分配后,文件中对于向量表的空间进行了分配:

                AREA    RESET, DATA, READONLY
                EXPORT  __Vectors
                EXPORT  __Vectors_End
                EXPORT  __Vectors_Size
                EXPORT __initial_sp

__Vectors       DCD     __initial_sp               ; Top of Stack
                DCD     Reset_Handler              ; Reset Handler
                DCD     NMI_Handler                ; NMI Handler
                DCD     HardFault_Handler          ; Hard Fault Handler
                DCD     MemManage_Handler          ; MPU Fault Handler
                DCD     BusFault_Handler           ; Bus Fault Handler
                DCD     UsageFault_Handler         ; Usage Fault Handler
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     0                          ; Reserved
                DCD     SVC_Handler                ; SVCall Handler
                DCD     DebugMon_Handler           ; Debug Monitor Handler
                DCD     0                          ; Reserved
                DCD     PendSV_Handler             ; PendSV Handler
                DCD     SysTick_Handler            ; SysTick Handler

                ; External Interrupts
                DCD     xxxx_IRQHandler                   ; A lot of handlers

__Vectors_End

在向量表头尾使用标签来标识向量表边界应该不需要赘述了,我们来看看空间分配向量表的分布

空间的分配是使用伪指令DCD来实现的,其作用是分配一个四字节对齐的内存字空间并将后续表达式作为该字的复位初始值。

此处还应该提到的一点内容是:

ARM汇编的函数名和C语言的函数名全都表示函数首地址(简直是天作之合

向量表的分布是依照ARMv7-M架构的参考手册的B1.5.3章节进行的,此处笔者不再展开详细说明。简单来说就是表内偏移为0的字作为主堆栈指针的重置值,其后的各个字依照偏移的字数分别为对应标号的异常,如下图:

image-20220906205739885.png

显然表中的前16个向量分别与主堆栈指针和上图中的15个异常一致,而后续的向量则是单片机生产厂商(此处指ST)指定的相对于内核的外部中断

紧接着是对向量表的空间大小进行计算备用:

__Vectors_Size  EQU  __Vectors_End - __Vectors

内核异常的实现

完成向量表的操作之后,文件中接着对ARM内核异常进行了实现。

不过说是实现似乎并不够贴切,笔者认为更加贴切的理解方式应该是“Make linker happy”:丢一个弱化的无限循环在这里,一方面避免程序跑飞方便调试,另一方面在使用者没有自行实现对应函数的需求的时候让链接器能够找到对应的函数实现。

NMI_Handler     PROC
                EXPORT  NMI_Handler                [WEAK]
                B       .
                ENDP

类似上面这样的函数有一堆,这里笔者就不一一粘贴出来了。

众所周知,Keil环境下链接器会默认复位向量为入口地址,所以接下来我们来特别看看Reset_Handler是如何实现的:

Reset_Handler    PROC
                 EXPORT  Reset_Handler             [WEAK]
        IMPORT  SystemInit
        IMPORT  __main

                 LDR     R0, =SystemInit
                 BLX     R0
                 LDR     R0, =__main
                 BX      R0
                 ENDP

整个流程很简单,从内存分别加载SystemInit__main函数的地址到R0寄存器,接着分别跳转到对应位置处执行。至于这两个函数究竟有什么作用,我们后续再分解。

笔者注:此处为了避免奇怪的误解,笔者还是强调一下:main和__main不是同一个函数,并不存在什么编译器在编译的过程中把main函数的名称改为__main的荒唐操作。

除了上面提到的异常向量之外,还实现了一个Default_Handler,在其中定义了所有与外部中断同名的标签并导出为弱类型,进而实现了将所有的外部中断都定位到了Default_Handler中,而其中做的事情也就是做了一个死循环:

Default_Handler PROC
                ; Export a lot of handlers
                EXPORT  xxx_IRQHandler                   [WEAK] 

xxx_IRQHandler ; A lot of lable for Handlers

                B       .

                ENDP

用户堆栈的初始化

实现了内核异常以后,文件最终对用户的堆栈进行初始化。

整个初始化的过程分为两种情况:使用MicroLib不使用MicroLib

  • 使用MicroLib时:

    EXPORT  __initial_sp
    EXPORT  __heap_base
    EXPORT  __heap_limit

    __initial_sp__heap_base__heap_limit分别导出到外部文件使用。

  • 不使用MicroLib时:

                 IMPORT  __use_two_region_memory
                   EXPORT  __user_initial_stackheap
    
    __user_initial_stackheap
    
                   LDR     R0, = Heap_Mem
                   LDR     R1, = (Stack_Mem + Stack_Size)
                   LDR     R2, = (Heap_Mem +  Heap_Size)
                   LDR     R3, = Stack_Mem
                   BX      LR

    导入__use_two_region_memory函数,导出__user_initial_stackheap函数。而在__user_initial_stackheap函数中做的事也很简单:将传入的四个参数而分别赋值为对应的四个值供外部使用。

笔者马后炮:

其实在ARMC库用户指南中有对__user_initial_stackheap函数的说明,其真正作用是向下兼容更老版本的C库,所以在新版本中没什么作用:

image-20220908170803535.png

至此,启动文件我们就分析完了,接下来我们来看看整个启动过程。

启动过程分析

一切的起源是复位程序入口,如果我们是自行使用编译器编译然后使用链接器进行链接的话我们可以自行指定复位程序的入口地址。不过在Keil的环境中,绝大部分的应用都是从复位向量(即Reset_Handler)开始的。因此,我们就从这里开始顺藤摸瓜。

又是那段熟悉的代码:

Reset_Handler    PROC
                 EXPORT  Reset_Handler             [WEAK]
        IMPORT  SystemInit
        IMPORT  __main

                 LDR     R0, =SystemInit
                 BLX     R0
                 LDR     R0, =__main
                 BX      R0
                 ENDP

之前遗留的SystemInit__main两个函数的功能让我们来逐个康康:

/**
 * @brief  Setup the microcontroller system
 *         Initialize the FPU setting, vector table location and External memory
 *         configuration.
 * @param  None
 * @retval None
 */
void SystemInit(void)
{
/* FPU settings ------------------------------------------------------------*/
#if (__FPU_PRESENT == 1) && (__FPU_USED == 1)
    SCB->CPACR |= ((3UL << 10 * 2) | (3UL << 11 * 2)); /* set CP10 and CP11 Full Access */
#endif

#if defined(DATA_IN_ExtSRAM) || defined(DATA_IN_ExtSDRAM)
    SystemInit_ExtMemCtl();
#endif /* DATA_IN_ExtSRAM || DATA_IN_ExtSDRAM */

    /* Configure the Vector Table location -------------------------------------*/
#if defined(USER_VECT_TAB_ADDRESS)
    SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM */
#endif /* USER_VECT_TAB_ADDRESS */
}

显然,SystemInit的作用是初始化系统(废话)。

第一部分条件编译功能是启用浮点运算支持。

第二部分如果使用外部SRAM作为内存空间,就会调用SystemInit_ExtMemCtl来配置外部的SRAM所使用到的GPIO,同时接下来的堆栈分配都会在外部SRAM 上进行。

第三部分如果用户定义了自己的向量表,就会将用户自己的向量表地址写入对应寄存器。

总的来说,该函数就是对浮点功能、内存使用以及向量表相关的内容进行了初始化。

接下来我们康康__main函数,由于该函数似乎是编译器内部链接库中的内容,所以笔者只好在反汇编文件中一窥究竟了。在使用MicroLib的情况下,该函数的反汇编是这样的:

    __main
    _main_stk
        0x08000194:    f8dfd00c    ....    LDR      sp,__lit__00000000 ; [0x80001a4] = 0x20000678
    .ARM.Collect$$$$00000004
    _main_scatterload
        0x08000198:    f000f890    ....    BL       __scatterload ; 0x80002bc
    .ARM.Collect$$$$00000008
    .ARM.Collect$$$$0000000A
    .ARM.Collect$$$$0000000B
    __main_after_scatterload
    _main_clock
    _main_cpp_init
    _main_init
        0x0800019c:    4800        .H      LDR      r0,[pc,#0] ; [0x80001a0] = 0x8001259
        0x0800019e:    4700        .G      BX       r0

显然,此处我们的工作量大翻倍,因为其中调用了__scatterload函数(被迫加班

v2-949533724348dae5570f07caafc2f753_720w.jpg

__scatterload
__scatterload_rt2
__scatterload_rt2_thumb_only
    0x0800019c:    a00a        ..      ADR      r0,{pc}+0x2c ; 0x80001c8
    0x0800019e:    e8900c00    ....    LDM      r0,{r10,r11}
    0x080001a2:    4482        .D      ADD      r10,r10,r0
    0x080001a4:    4483        .D      ADD      r11,r11,r0
    0x080001a6:    f1aa0701    ....    SUB      r7,r10,#1

__scatterload函数调试起来比较困难,需要对照着反汇编一点一点地看,但是其功能概括起来还是相对来说简单的:

首先内部调用__scatterload_copy函数,依照Region$$Table段内的地址将需要的数据从FLASH拷贝到内存中:

    __scatterload_copy
        0x080013c8:    e002        ..      B        0x80013d0 ; __scatterload_copy + 8
        0x080013ca:    c808        ..      LDM      r0!,{r3}
        0x080013cc:    1f12        ..      SUBS     r2,r2,#4
        0x080013ce:    c108        ..      STM      r1!,{r3}
        0x080013d0:    2a00        .*      CMP      r2,#0
        0x080013d2:    d1fa        ..      BNE      0x80013ca ; __scatterload_copy + 2
        0x080013d4:    4770        pG      BX       lr

接着调用__scatterload_zeroinit函数对ZI段进行初始化:

    __scatterload_zeroinit
        0x080013d8:    2000        .       MOVS     r0,#0
        0x080013da:    e001        ..      B        0x80013e0 ; __scatterload_zeroinit + 8
        0x080013dc:    c101        ..      STM      r1!,{r0}
        0x080013de:    1f12        ..      SUBS     r2,r2,#4
        0x080013e0:    2a00        .*      CMP      r2,#0
        0x080013e2:    d1fb        ..      BNE      0x80013dc ; __scatterload_zeroinit + 4
        0x080013e4:    4770        pG      BX       lr
        0x080013e6:    0000        ..      MOVS     r0,r0

最后调用__main_after_scatterload函数跳转到main函数:

    __main_after_scatterload
    _main_clock
    _main_cpp_init
    _main_init
        0x0800019c:    4800        .H      LDR      r0,[pc,#0] ; [0x80001a0] = 0x8001259
        0x0800019e:    4700        .G      BX       r0

此处值得注意的一点是:由于跳转到main函数使用的是BX指令,并没有保存返回地址到LR寄存器,故使用微库时主函数不能返回这件事就很容易理解了。

其实原本应该写到这里就结束的,但是出于笔者的强迫症,既然已经分析了使用微库的情况,自然也要分析一下不使用微库的情况。(毕竟微库究竟和正常C库有什么区别也是笔者感兴趣的内容之一

关闭微库的开关,可以看到此时编译出的__main函数非常短小精悍:

    __main
        0x08000194:    f000f802    ....    BL       __scatterload ; 0x800019c
        0x08000198:    f000f84e    ..N.    BL       __rt_entry ; 0x8000238

但是笔者嗅到了加班的味道,所以今天就先溜了。

v2-0d9b54bb3f8881844e2c64898eb8c483_720w.jpg

时间来到了第二天,笔者开始上班了。

__scatterload函数内容其实和使用微库的时候是一样的:

    __scatterload
    __scatterload_rt2
    __scatterload_rt2_thumb_only
        0x0800019c:    a00a        ..      ADR      r0,{pc}+0x2c ; 0x80001c8
        0x0800019e:    e8900c00    ....    LDM      r0,{r10,r11}
        0x080001a2:    4482        .D      ADD      r10,r10,r0
        0x080001a4:    4483        .D      ADD      r11,r11,r0
        0x080001a6:    f1aa0701    ....    SUB      r7,r10,#1

整体上的行为也是大致相同的:

首先调用__scatterload_copy函数来拷贝数据;

接着调用__scatterload_zeroinit函数初始化ZI段;

不过不同的是,此处由于使用了ARM的C库,所以后续调用了__rt_entry函数来进入主函数,而不是在__scatterload处直接进入主函数。

至于__rt_entry函数,笔者本打算调试进去看看做了什么的,但是突然灵光一闪,想到了有这么一个文档:

Arm C and C++ Libraries and Floating-Point Support User Guide

在其中详细的介绍了__rt_entry的作用:

image-20220908162121552.png

即:设置堆栈、初始化C库、调用main,以及在主函数返回后做一些收尾工作,如:停止C库和退出程序。

至此,整个启动过程就全部分析完毕了。

下班!

上一篇
下一篇