STM32的启动文件通常是一个是系统上电复位后第一个执行的汇编文件(如 startup_stm32fxxx.s),主要做了以下工作:
- 初始化堆栈指针 SP = _initial_sp。
- 初始化程序计数器指针 PC = Reset_Handler。
- 栈和堆的初始化:定义栈和堆的大小及起始地址。
- 初始化中断向量表:定义中断服务例程(ISR)的入口地址。
- 复位处理程序:在芯片复位后执行,调用 __main 函数、SystemInit 函数(可选)。
5.1 在SystemInit 函数中配置外部 SRAM 作为数据存储器(可选)。
5.2 在SystemInit 函数中配置重映射的中断向量表地址(可选)。
5.3 在 _main 函数中初始化用户堆栈。
5.4 在 _main 函数中初始化 .data 和 .bss 段(将初始化的全局变量从Flash复制到RAM,并清零未初始化的全局变量)。
1. 栈空间的开辟
- EQU 给数字常量
0x00000400
取一个符号名Stack_Size
。- AREA 汇编一个新的代码段或者数据段,段名为
STACK
,不初始化[NOINIT]
,可读可写[READWRITE]
,8 字节对齐[ALIGN=3表示 2^3 对齐]
。- SPACE分配内存指令,分配大小为
Stack_Size[1KB]
字节连续的存储单元给栈空间,并初始化为 0。- __initial_sp 紧挨着
SPACE
放置,表示栈的结束地址,栈是从高往低生长,所以结束地址就是栈顶地址。
33~39行:
在存储器映射区 Block1[片内SRAM区]
开辟 1KB 字节的连续空间给栈空间[可读可写]
,并将符号名__initial_sp
指向栈顶地址。
注意:
栈主要用于存放局部变量
,函数形参
等,属于编译器自动分配和释放的内存
,栈的大小不能超过内部 SRAM 的大小。如果工程的程序量比较大,定义的局部变量比较多,那么就需要在启动代码中修改栈的大小,即修改Stack_Size
的值。如果程序出现了莫名其妙的错误,并进入了 HardFault 的时候,你就要考虑下是不是栈空间不够大,溢出了的问题。CM3/4一致,开辟空间为:存储器映射 Block1 的片内SRAM区。
2. 堆空间的开辟
- EQU 给数字常量
0x00000200
取一个符号名Heap_Size
。- AREA 汇编一个新的代码段或者数据段,段名为
HEAP
,不初始化[NOINIT]
,可读可写[READWRITE]
,8 字节对齐[ALIGN=3表示 2^3 对齐]
。- SPACE分配内存指令,分配大小为
Heap_Size[512字节]
字节连续的存储单元给堆空间,并初始化为 0。。- __heap_base 表示堆的起始地址。
- __heap_limit 表示堆的结束地址。
- PRESERVE8 指示编译器按照 8 字节对齐。
- THUMB 指示编译器之后的指令为 THUMB 指令。
40~55行:
在存储器映射区 Block1[片内SRAM区]
开辟 512 字节的连续空间给堆空间[可读可写]
,并将符号名__heap_bas
指向堆的起始地址,符号名__heap_limi
指向堆的结束地址,最后指示编译器按照 8 字节对齐,编译器之后的指令为 THUMB 指令。
注意:
堆主要用于动态内存的分配
,像 malloc()、calloc()和 realloc()等函数申请的内存就在堆上面。堆中的内存一般由程序员分配和释放,若程序员不释放,程序结束时可能由操作系统回收。
CM3/4一致,开辟空间为:存储器映射 Block1 的片内SRAM区。
由于一般都会使用独立的内存管理实现方式(mymalloc,myfree 等),并不需要使用 C 库的 malloc 和 free 等函数,也就用不到堆空间,因此我们可以设置 Heap_Size 的大小为 0,以节省内存空间。
3. 中断向量表定义
- AREA 汇编一个新的数据段
[DATA]
,段名为RESET
,只读[READONLY]
。- EXPORT 表示声明一个标号
[__Vectors]
具有全局属性,可被外部的文件使用。- EXPORT 表示声明一个标号
[__Vectors_End]
具有全局属性,可被外部的文件使用。- EXPORT 表示声明一个标号
[__Vectors_Size]
具有全局属性- DCD 分配一个或者多个以字为单位的内存,以四字节对齐,并要求初始化这些内存。
- __Vectors 为向量表起始地址。
- __Vectors_End 为向量表结束地址。
- EQU 给数字常量
[__Vectors_End - __Vectors]
取一个符号名__Vectors_Size [向量表大小]
。
56~59行:
汇编一个段名为RESET
的数据段[DATA]
,此数据段只读[READONLY]
。58~61行:
声明标号[__Vectors、__Vectors_End、__Vectors_Size]
具有全局属性,可被外部的文件使用。62~163行:
使用 DCD 分配一个或者多个以字为单位的内存,以四字节对齐,并要求初始化这些内存[中断向量表]
。164~165行:
将 符号名__Vectors_End 指向向量表结束地址。166行:
将 符号名__Vectors_Size 指向堆的大小【使用 EQU 计算出的向量表大小】
当内核响应了一个发生的异常后,对应的异常服务例程(ESR)就会执行。为了决定 ESR的入口地址, 内核使用了向量表查表机制。向量表其实是一个 WORD(32 位整数)数组,每个下标对应一种异常,该下标元素的值则是该 ESR 的入口地址。向量表在地址空间中的位置是可以设置的,通过 NVIC 中的一个重定位寄存器来指出向量表的地址。在复位后,该寄存器的值为 0。因此,在地址 0 (即 FLASH 地址 0) 处必须包含一张向量表,用于初始时的异常分配。
举个例子,如果发生了异常 SVCall,则 NVIC 会计算出偏移移量是 11x4=0x2C,然后从那里取出服务例程的入口地址并跳入。要注意的是这里有个另类:地址 0x0000 0000 并不是什么入口地址,而是给出了复位后 MSP 的初值。中断向量表被放置在代码段的最前面。例如:当我们的程序在 FLASH 运行时,那么向量表的起始地址是:0x0800 0000。地址 0x0800 0000 存放的是栈顶地址。DCD:以四字节对齐分配内存,也就是下个地址是 0x0800 0004,存放的是 Reset_Handler 中断函数入口地址。
从代码上看,向量表中存放的都是中断服务函数的函数名,所以 C 语言中的函数名对芯片来说实际上就是一个地址。
注意: 向量表格中灰色部分是系统内核异常,CM3和CM4系统内核异常是一样的。表格中位置 0 ~ 81 是外部中断,CM3/4内核的芯片最大支持 240 个外部中断,具体使用多少个由芯片厂家设计决定。如这个表格中的 CM4 只使用了81个,CM3 芯片只使用了 60 个。这里说的外部中断是相对内核而言。
4. 复位程序
- AREA 汇编一个新的代码段
[CODE]
,段名为.text
,只读[READONLY]
。- PROC、ENDP 这一对伪指令把程序段分为若干个过程,使程序的结构加清晰。
- EXPORT 声明复位中断向量
Reset_Handler
为全局属性,这样外部文件就可以调用此复位中断服务。- WEAK 表示弱定义,如果外部文件优先定义了该标号则首先引用外部定义的标号,如果外部文件没有声明也不会出错。
- IMPORT 表示该标号来自外部文件。
- LDR 是内核指令,表示用于从内存加载数据到寄存器。
- ORR 是内核指令,用于在两个操作数上进行逻辑或运算,并把结果放置到目的寄存器
[R1]
中。- STR 是内核指令,用于将寄存器
[R1]
中的数据存储到内存中的指定地址[R0]
。- BLX 是内核指令,用于跳转到指定的地址,并在跳转前将当前PC寄存器的值保存到链接寄存器LR(即R14)。
- BX 是内核指令,表示跳转到由寄存器/标号给出的地址,不用返回。
167~169行:
汇编一个段名为.text
的代码段[CODE]
,此代码段只读[READONLY]
。170~171行:
复位子程序开始。172行:
表示复位子程序可以由用户在其他文件重新实现,这里并不是唯一的。173行:
表示 SystemInit 这个函数来自外部的文件。174行:
表示 __main 这个函数来自外部的文件。175~178行:
0xE000ED88 就是协处理器控制寄存器(CPACR)的地址,该寄存器的第 20~23 位用来控制是否支持浮点运算,这里我们全设置为 1[CP10/CP11完全访问协处理器(开启硬件FPU)]
,以支持硬件浮点运算。关于 CPACR 寄存器的详细描述,见《STM32F3 与 F4 内核编程手册.pdf》179~180行:
表示切换到SystemInit 地址,SystemInit 是一个标准的库函数,在 system_stm32f4xx.c 文件中定义,主要作用是配置系统时钟、还有就是初始化 FSMC/FMC总线上外挂的 SRAM(可选),配置外部 SRAM 作为数据存储器(可选)。181~182行:
表示切换到__main 地址,__main 是一个标准的 C 库函数,主要作用是初始化用户堆栈和变量等,最终调用 main 函数去到 C 的世界。这就是为什么我们写的程序都有一个 main 函数的原因,如果不调用__main,那么程序最终就不会调用我们 C 文件里面的main,也就无法正常运行。183行:
表示子程序结束。
注意:CM3没有FPU所以
175~178行
屏蔽,寄存器版本中173行、179~180行
屏蔽。
在寄存器版启动文件中屏蔽SystemInit函数的原因是为了避免不必要的初始化操作,从而简化启动过程和提高效率。
SystemInit函数是STM32官方库中的一个重要函数,它用于重置时钟控制寄存器(RCC)的配置到默认状态。这个函数通常在启动文件中被调用,以确保时钟系统处于一个已知的状态。然而,在寄存器版的开发中,开发者直接操作寄存器进行配置,因此不需要通过SystemInit函数来进行初始化。
具体来说,寄存器版开发直接通过编程来设置寄存器的值,这样可以更灵活地控制硬件的行为。如果使用SystemInit函数,它会重置所有的时钟配置,这可能会干扰开发者手动设置的寄存器值,导致不必要的麻烦和效率降低。因此,在寄存器版的启动文件中屏蔽SystemInit函数,可以避免这种不必要的初始化操作,使得启动过程更加简洁和高效。
4.1 SystemInit函数
在系统启动之后,程序会先执行 SystemInit 函数,进行系统一些初始化配置:
- 外部存储器配置(初始化 FSMC 总线上外挂的 SRAM)
- 中断向量表地址配置
注意:
DATA_IN_ExtSRAM 和 USER_VECT_TAB_ADDRESS 这两个宏实际并没有定义,实际上 SystemInit 并没有起作用。
保留这个接口,是为了避免去修改启动文件。另外,还可以把一些重要的初始化放到 SystemInit 这里,在 main 函数运行前就把重要的一些初始化配置好,这个一般用不到,而是直接在main函数中处理。
4.2 __main函数
_main 代码是编译器(如Keil、IAR、GCC)自动生成的,当编译器发现定义了 main 函数,就会自动创建_main,因此无法找到_main 代码。_main 主要负责初始化 C 运行时环境。
__main 函数的作用:
- 初始化 .data 段:将Flash中的已初始化的全局变量复制到RAM中。
- 清零 .bss 段:将未初始化的全局变量所在的内存区域清零。
- 设置堆和栈:根据启动文件中的定义,初始化堆和栈的起始地址和大小。
- 调用用户的 main 函数:在完成上述初始化工作后,跳转到用户的 main 函数。
程序经过汇编启动代码,执行到__main()后,可以看出有两个大的函数:
__scatterload():
负责把 RW/RO 输出段从装载域地址复制到运行域地址,并完成了 ZI 运行域的初始化工作。即完成 .data 段和 .bss 段的初始化。__rt_entry():
负责初始化堆栈,完成库函数的初始化,最后自动跳转向 main()函数。
__main 函数的实现:
当程序运行到__main 函数,先跳转到
__scatterload 函数
运行,执行完__scatterload 函数后,R10 和 R11 会被赋值;
r10=0x08001630,r11=0x08001650接着执行
__scatterload_null 函数
;
2.1 第 1、2 行比较 r10、r11 是否相等,如果不等则跳转到 0x0800014E。明显两个值不等,所以程序跳转到 0x0800014E,
2.2 第 4 行是把 0x08000147 赋值给 lr,即是保存_scatterload_null 的入口地址;
2.3 第 5 行是把 r10 对应地址存放的 4 个字复制到 r0-r3 中,执行后得到:
r0=0x08001650, 表示的是加载域起始地址。
r1=0x20000000,表示运行域地址。
r2=0x0000001C,表示要复制的 RW Data 大小,也可以在 map 文件查找得知。
r3=0x0800016C,表示_scatterload_copy 函数
的起始地址。
r10=0x08001640,表示运行域地址。步骤2最后一行跳转到
_scatterload_copy 函数
_scatterload_copy 复制好 RW Data 后,最后跳转回到__scatterload_null。回到__scatterload_null 函数后同样是先判断 r10 和 r11 是否相等,明显也是不等的,代码继续运行,最后跳转到 r3 寄存器存的地址。此时是循环回来再执行完__scatterload_null 函数后,即将进入了__scatterload_zeroinit 函数,先来看一下 r0 到 r3 的值变化。
r0=0x0800166C, 表示的是加载域结束地址。
r1=0x2000001C,表示ZI 段的起始地址。
r2=0x0000076C,表示ZI 段大小,即 ZI Data 大小,也可以在 map 文件查找得知。
r3=0x08000189,表示__scatterload_zeroinit 函数
的起始地址步骤3最后跳转到
__scatterload_zeroinit 函数
__scatterload_zeroinit 代码其实就是对 ZI 段清零的过程,从 ZI 段的起始地址 0x2000001C 开始,大小为 0x0000076C,进行清零操作。步骤4执行完跳转回__scatterload 函数,再接着跳转到
__rt_entry 函数
__rt_entry 函数先调用
__user_setup_stackheap 函数
来建立堆栈
第一条指令是保存函数的返回地址;
第二条指令是跳转到__user_libspace 进行一些微库的初始化工作;
后面的几条语句是建立一个临时栈;
然后程序跳转到__user_inital_stackheap 进行用户栈的初始化,初始化完后 [ r0=0x20000188,r1=0x20000788,r2=0x20000388, r3=0x20000388 ]
执行完__rt_entry 代码,用户栈顶地址为设置成 0x20000788,完成了堆栈的初始化。最后运行到__rt _entry_main,去到 C 的世界
总结:
1. 初始化 .data 段
data 段存储已初始化的全局变量和静态变量。这些变量的初始值存储在Flash中,__main 函数会将这些值复制到RAM中。复制步骤如下:
步骤1:获取 .data 段在Flash中的起始地址和大小。
步骤2:将数据从Flash复制到RAM中的目标地址。2. 初始化 .bss 段
.bss 段存储未初始化的全局变量和静态变量。__main 函数会将 .bss 段对应的RAM区域清零。清零步骤如下:
步骤1:获取 .bss 段的起始地址和大小。
步骤2:将对应内存区域清零。3. 设置堆和栈
栈(Stack)用于存储局部变量和函数调用信息。栈的大小在启动文件中定义,通常位于RAM的顶部。
堆(Heap)用于动态内存分配(如 malloc 和 free)。堆的大小也在启动文件中定义。
__main 函数根据启动文件中的定义,初始化堆和栈的起始地址和大小。4. 调用用户的 main 函数
在完成上述初始化工作后,__main 函数会跳转到用户的 main 函数,开始执行用户代码。
5. 中断服务程序
- B 表示跳转到一个标号。
- PROC、ENDP 这一对伪指令把程序段分为若干个过程,使程序的结构加清晰。
- EXPORT 声明符号名为全局属性,可被外部文件使用。
- WEAK 表示弱定义,如果外部文件优先定义了该标号则首先引用外部定义的标号,如果外部文件没有声明也不会出错。
185~228行:
系统内核异常中断子程序,当中断到来时,如果外部有定义则调用外部定义的,否则调用此处[进行无限循环]
。229~397行:
外部中断子程序,当中断到来时,如果外部有定义则调用外部定义的,否则调用此处[进行无限循环]
。
注意:
如果我们开启了某个中断,但是忘记写对应的中断服务程序函数又或者把中断服务函数名写错,那么中断发生时,程序就会跳转到启动文件预先写好的弱定义的中断服务程序中,并且在 B 指令作用下跳转到一个‘.’中,无限循环。
这里的系统异常中断是内核的,外部中断是外设的。
6. 用户堆栈初始化
- ALIGN 表示对指令或者数据的存放地址进行对齐,一般需要跟一个立即数,缺省表示 4字节对齐。
- IF, ELSE, ENDIF 是汇编的条件分支语句。
- EXPORT 声明符号名为全局属性,可被外部文件使用。
- IMPORT 表示该标号来自外部文件。
- LR是内核指令, 用于保存函数调用的返回地址。
- BX 是内核指令,表示跳转到由寄存器/标号给出的地址,不用返回。
- LDR 是内核指令,表示用于从内存加载数据到寄存器。
- END 表示程序结束。
404行
判断是否定义了__MICROLIB[勾选了 Use MicroLIB 就代表定义了__MICROLIB 这个宏]
。
如果定义__MICROLIB,声明__initial_sp [栈顶地址]
、__heap_base [堆起始地址]
和__heap_limit [堆结束地址]
这三个标号具有全局属性,可被外部的文件使用。
如果没有定义__MICROLIB,使用默认的 C 库运行。那么堆栈的初始化由 C 库函数__main 来完成。
注意:
__user_initial_stackheap
函数的主要作用是为编译器提供初始化C库函数所需的堆栈信息。这个标号用于设置用户程序的堆栈,包括堆和栈的初始化和配置。堆栈是程序运行时用于存储局部变量、函数调用参数、返回地址等信息的内存区域。具体来说,堆(Heap)用于动态内存分配,如通过malloc申请内存;栈(Stack)则用于存储函数的局部变量和返回地址等。
MicroLIB 是 MDK 自带的微库,是缺省 C 库的备选库,MicroLIB 进行了高度优化使得其代码变得很小,功能比缺省 C 库少。MicroLIB 是没有源码的,只有库。
__use_two_region_memory
函数是ARM Cortex-M系列处理器的一个编译器(如MDK)指令,用于指定处理器的内存管理单元(MMU)使用两个不同的地址空间,分别用于存储代码和数据。这种技术被称为“双区域内存”,它可以提高系统的可靠性和安全性。在使用双区域内存时,MMU将RAM分为两个部分:代码区和数据区。代码区用于存放代码和只读数据,数据区用于存放可读可写的数据。由于代码区是只读的,因此当程序出现错误时,它不会影响代码的运行。同时,数据区也可以设置为只读或只写区域,以保护程序的安全性。双区域内存还可以提高系统的性能。由于代码和数据被存储在不同的地址空间中,处理器可以同时从代码区和数据区读取数据,从而提高了内存访问的效率。