引言
玩过stm32的小伙伴,应该知道,在使用的keil工程里面有一个start.s的启动文件(网上关于这个启动文件的分析很多,本文不是讲解启动文件的文字,不打算具体讲解这个文件的内容)。start.s文件是芯片复位、启动要运行的一个代码文件(其实在之前还会运行其它的东西,在后面会介绍下),在复位中断里面会执行SystemInit初始化系统时钟,最后调用__main(keil C库的API),__main调用我们写的main函数,进入用户写的代码。同时在讲解的过程中,对函数调用的规则进行深入分析,C和汇编的调用规则进行理论和实践的讲解。
本文将对上述过程进行分析,从芯片刚上电的时刻开始,芯片进行了哪些工作,最终启动完成,运行main,该部分的代码全部有博主手写,并未使用任何C库中的代码,希望读者对芯片的启动过程有一个更详细的了解,而非仅仅知道start.s文件中的那些内容。
博主用的芯片是stm32F407(下面简称F4芯片、芯片等),启动地址(内部flash地址)在0x0800 0000,RAM在0x2000 0000位置,flash大小512KB,RAM 192KB(分为128KB+64KB,下文中为了省事,直接将这两个RAM放到一起了,不影响结论,且结论和RAM大小无关)。
注:下文中的flash大小和ram大小可能会出现错误,这是因为博主写的时候,用了两个芯片做实验,flash、ram的起始地址和大小是不影响本文的结论。
一、芯片启动过程
1.1 内存映射
图1 部分内存映射
在F4芯片中的部分内存映射如图1所示。在芯片刚上电的时候,PC指针实际的值是0x0000_0000,并非很多人理解的0x0800_0000(boot0为低电平),其执行的是Boot ROM中的代码,这部分代码是芯片厂商烧录进去,用户无法更改。在这部分代码中,芯片厂商可以在里面做一些定制性的操作。例如同样的芯片,配置不同(关掉某些功能),成为两个芯片,其也会去检测boot0、boot1引脚,在stm32中,若是boot0为0(低电平)、boot1任意电平,pc会赋值为0x0800_0000,执行内部的flash(若是boot1、boot1为1,执行SRAM中的程序)。在Boot ROM中,芯片厂商会进行一些定制的操作,具体与本文内容无关。在F4芯片中,boot为0(低电平)、boot1任意电平时,会跳转到0x0800_0000处,开始执行用户写的代码。
在之前的文章中(keil下载程序具体过程),博主说明了一个keil编译生成的axf文件,是如何从在keil中点击download按钮之后下载到内部的flash中。感兴趣的看看博主的文章,在flash中的储存的是bin文件,我们先看下bin文件有什么。
1.2 bin文件
bin文件是由axf文件转化过来的,axf文件ELF格式的二进制文件,里面除了包含bin文件的内容,还包含了调试信息、地址信息等。keil编译之后,产生的链接结果,博主设置了在编译之后生成bin文件。
图2 keil编译结果
我们看下bin文件的大小。
图3 bin文件大小
bin文件大小2780字节。由图2可知:
Code=2436 RO-data=340 RW-data=4 ZI-data=276。
一个bin文件包含Code、RO、RW、ZI段的信息,keil工程中,代码部分放在Code中,只读数据放到RO中(如常量字符串、const修饰的变量等等),可读数据放到RW中(如已经初始化的全局变量),ZI段放的是未初始化的全局变量等定义了的变量,但未初始化,这样可以减少bin文件的大小。
Code + RO + RW = 2436 + 340 + 4 = 2780 = bin文件大小,没有ZI段,ZI段只需要记录起始地址和长度,将该部分内存全部清零即可,故可知,bin的文件内容实际就是Code、RO、RW三部分。
至于Code、RO、RW具体应该如何存放,放到哪个地址,需要看分散加载文件如何写。
关于映像文件的介绍先到这里,只是简单的说了下映像文件,后面会出一篇文章专门介绍映像文件的组成。
1.3 分散加载文件
分散加载文件(sct后缀的文件)是一个文本文件,通过编写一个分散加载文件来指定ARM连接器在生成映像文件时如何分配RO/RW/ZI等数据的存放地址,HTQ_MCU芯片的分散加载文件如下所示。
LR_IROM1 0x08000000 0x00100000 { ; load region size_region
ER_IROM1 0x08000000 0x00100000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
.ANY (+XO)
}
RW_IRAM1 0x20000000 0x00020000 { ; RW data
.ANY (+RW +ZI)
}
}
分散加载文件的区域分两类:
加载区:该映像文件开始运行前存放的区域,即当系统启动或加载时应用程序存放的区域(本文的Flash部分)。
执行区:映像文件运行时的区域,即系统启动后,应用程序进行执行和数据访问的存储器区域,系统在实时运行是可以有一个或多个执行块(本文也是指Flash部分)。
上述分散链接文件定义了一个区域ER_IROM1(起始地址为0x08000000,大小为0x00100000,为内部的Flash映射地址),在ER_IROM1中存放bin,也在这个上面执行。有的时候,存放的bin位置和执行地址,二者可能不一致,需要修改分散链接文件。定义了区域RW_IRAM1,起始地址为0x20000000,大小0x00020000 ,为SRAM映射地址。
在分散链接文件中,只有RO、RW属性,并没有Code属性。其实,在分散链接文件看来,Code本身也是一种RO数据,因此,
Code=2436 RO-data=340 RW-data=4 ZI-data=276。
Code和RO在bin中放到一起,叫做RO段,RW放到RW段 。
由上述可知,我们的bin存放在0x08000000处,该处存放的是RO段(Code部分和RO部分),紧接着存放的是RW段,这一点,我们在后面可以得到验证。
1.4 链接符号
在keil中,我们可以使用类似于下面这样的链接符号,在代码中获得分散链接文件中定义的区域相关信息,还可以更具体的获得RO段、RW段、ZI段的信息。在本文中,我们使用如下链接符号获得bin中的信息。
extern int Image$$ER_IROM1$$Base; //RO段的起始地址
extern int Image$$ER_IROM1$$Limit; //RO段的结束地址
extern int Image$$RW_IRAM1$$RW$$Base; //RW段的起始地址,加载地址
extern int Image$$RW_IRAM1$$RW$$Limit; //RW段的结束地址,加载地址
extern int Load$$RW_IRAM1$$RW$$Base; //RW段的起始地址,执行地址
extern int Load$$RW_IRAM1$$RW$$Limit; //RW段的结束地址,执行地址
extern int Image$$RW_IRAM1$$ZI$$Base; //ZI段的起始地址,执行地址
extern int Image$$RW_IRAM1$$ZI$$Limit; //ZI段的结束地址,执行地址
RO段的数据,并不需要改动,因此存放在0x0800 0000处不动。RW段数据需要从加载地址拷贝到执行地址处运行,ZI段都是零,因此,只需要知道执行地址即可(bin中也没有ZI段,只有ZI段的起始地址和结束地址信息)。
1.5 start.s文件简述
在stm32f407中,使用的是startup_stm32f40_41xxx.s作为启动文件。下面的代码做了简化。
Stack_Size EQU 0x00001000
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
Heap_Size EQU 0x00000200
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
PRESERVE8
THUMB
; Vector Table Mapped to Address 0 at Reset
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__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
......
__Vectors_End
__Vectors_Size EQU __Vectors_End - __Vectors
AREA |.text|, CODE, READONLY
; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
最开始,设置了栈、堆的大小,之后是中断向量表,这部分代码存放在bin文件的最开头,bin的前两个字就是stack和pc的值,对应的就是__initial_sp、Reset_Handler。我们的代码就进入到了Reset_Handler即复位中断,在这里面,调用SystemInit初始化系统的时钟,调用__main,完成一些环境的初始化(后面具体展开),最后进入main(应用程序的入口)函数,即我们写的代码,控制权来到了程序员手中。
打开汇编代码的时候,我们可以看到如下的代码。
LDR R0, =__main,之后会跳转到这里
这里会多出来__scatterload、__rt_entry函数,这些都是C库里面的函数,进行一些初始化的工作。
截取了一些代码放这里。有兴趣的自己看下里面的汇编代码具体这么做的,这里总结下里面做的内容。
__scatterload:将RORW段从加载域复制到运行域,并完成ZI段初始化。
__rt_entry:完成库函数的初始化工作,如果采用分散装载技术,还必须实现__user_initial_stackheap函数,重新定义堆栈和堆空间,最后自动跳转向main()函数。
在__rt_entry函数里面,使用__rt_lib_init完成库的初始化工作,调用__rt_stackheap_init()设置stack和heap,最后进入main函数。main函数的返回值由__rt_exit函数处理。
__main是C/C++应用程序的入口点,main是用户应用程序入口点。简单的总结下__main函数做了哪些工作:
1、将非固定(nonroot)的执行代码域(region)从加载地址空间搬运到运行地址空间
2、将ZI域清零
3、跳转到__rt_entry()
__rt_entry函数做的工作:
1、调用__rt_stackheap_init()简历数据栈和数据堆
2、调用__rt_lib_init()初始化应用程序用到的C运行时库
3、调用main函数,main是用户程序的入口点,在main函数中,可以调用C运行时库的相应函数
4、调用exit()函数,退出应用程序
既然想用全部手写代码实现启动代码的过程,我们在上面的start.s的基础上,将__main函数改成我们自己的代码就可以完成。__main函数主要完成3个功能。__rt_entry中的初始化C库的部分,由于我们未用到C库,可以去掉,只保留初始化堆栈和进入main的功能。__scatterload部分我们手动实现它。
二、实现__main函数
在实现__main的时候,博主将其功能分为如下几类:
1、初始化RW:将RW段由加载域搬运到运行域。由于RO段是不动的,因此,RO段搬运的代码未实现。
2、初始化ZI:ZI段都是0,因此,只是将某一块内容清空即可。
3、跳转main:考虑汇编的难读性,博主实现的代码均是使用C完成,因此,在start.s中直接跳转到main.s了。
画个简图表示上面实现的关系。
2.1 加载域和运行域的关系
添加一张ARM给的图
图片来源于《Introductiontothe Armv8-M Architecture and its ProgrammersModel》
在1.3中已经说明了加载域和运行域是什么含义,下面用图表示下二者中的具体含义。
左边是flash部分,从0x0800 0000地址开始,bin文件里面内容摆放的位置,从低地址开始,分别是RO、RW、ZI段的信息,code存放的位置也是RO段,RO段不需要动。RW段的内容,需要从flash搬运到sram中,在flash和sram中存放的起始地址、结束地址如下:
flash:Image$$RW_IRAM1$$RW$$Base ----- Image$$RW_IRAM1$$RW$$Limit sram: Load$$RW_IRAM1$$RW$$Base ----- Load$$RW_IRAM1$$RW$$Limit
上面的链接符合在1.4节有说明。
2.2 RW段拷贝
既然知道了RW段的存放位置和目的地址,因此,只需要写一个简单的函数将里面的内容全部copy到sram即可。函数如下:
void rw_section_init(uint32_t src_addr, uint32_t dst_addr, uint32_t length)
{
uint32_t * psrc, *pdst, *pend;
psrc = (uint32_t*)src_addr;
pdst = (uint32_t*)dst_addr;
pend = (uint32_t*)(src_addr + length);
while(psrc < pend){
*pdst++ = *psrc++;
}
}
函数很简单,src_addr表示起始地址,为flash中bin文件的RW段起始地址,dst_addr表示目的地址,为sram中的RW段起始地址,length表示copy字节长度。具体的值为2.1小节的flash起始地址和sram的起始地址。
2.3 ZI段清零
ZI段的内容在flash中只保留了起始地址,长度等信息,并无实际内容,在sram直接清空即可。这里说一下,在实际代码中,当一个变量小于8 Byte时,哪怕未赋值,实际存放的也是RW段,而非ZI段。读者可以自行试试。
ZI段初始化代码很简单。
void zi_section_init(uint32_t src_addr, uint32_t length)
{
uint8_t * psrc, *pend;
psrc = (uint8_t*)src_addr;
pend = (uint8_t*)(src_addr + length - 16);
while(psrc < pend){
*psrc++ = 0;
}
}
src_addr的值为Image**RW_IRAM1**ZI**Base,length为(Image**RW_IRAM1**ZI**Limit - Image**RW_IRAM1**ZI**Base)。注:*表示的是$,CSDN上两个$显示的有问题。
这里解释下为什么这行代码里面有一个 “ - 16 ”的操作:
pend = (uint8_t*)(src_addr + length - 16)
我们都知道ZI段里面的值都是0,包含所有未初始化的变量、数组、堆、栈等等。在博主写的代码里,堆的内存大小设置为0(为了简化代码),栈的大小设置为200 Byte(大小无所谓),ZI段一共276 Byte。用一个图来表示ZI段的内容。
在sram中的ZI段大概类似于上图所示。在我们的代码里面,应该将从ZI**Base开始到ZI**Limit中的所有数据都清空,设置为0。但博主在写的时候,用的纯C,使用C就必须设置好sp指针,sp指针设置的位置为ZI**Limit。在执行LDR R0, =main(在start.s中)时,sp指针会向下移动4B,在main和zi_section_init中又定义了一些局部变量,因此又要去掉一些空间。因此必须给留出一些空间。接下来的分析将和编译器有关,因此,若是对该部分不是很熟悉的小伙伴,看看即可。
三、ATPCS
3.1 ATPCS
在解释之前,先补充点额外的知识,与ARM链接器相关,ATPCS相关知识。ATPCS是ARM程序和Thumb程序中子程序调用的基本规则(博主发的文章里面有讲解ATPCS)。在ATPCS中有如下两条规则:
1、子程序间通过R0--R3传递参数,被调用的子程序在返回前无需恢复寄存器R0--R3的内容。
2、在子程序中,使用R4--R11保存局部变量,如果在子程序中使用到了寄存器R4--R11中的某些寄存器,子程序进入前需要保存这些寄存器的值,在返回时需要恢复。未用到,不处理。在Thumb程序中,通常只能使用寄存器R4--R7来保存局部变量。
ATPCS部分,博主会抽时间写一篇文章专门讲解ATPCS规则(纯理论的文章,估计很多人不太喜欢看^_^)本小节的内容可以看作ATPCS规则的实战部分讲解了。
keil编译出来的各个段大小,RO段=Code+RO-data,RW段=RW-data,ZI段=ZI-data=276=0x114Byte。
Program Size: Code=2508 RO-data=340 RW-data=4 ZI-data=276
3.2 函数调用详细分析
在start.s中还未进main函数时,芯片的现场环境如下:
从这里可以看出一些信息:
1)博主在进入main之前设置RO--R11分别为0--11,为了后面调试追踪寄存器入栈。
2)栈顶指针为0x118,而我们设置的ZI段为0x114,这是因为sp指针指向栈最后一个单元的后一个单元,即如图所示:
故sp的值为0x118 = 0x114 + 4。接下来进入main函数。
先上main的代码(无用的代码已删去)
int main()
{
uint32_t image_rw_start_addr = (uint32_t)&Image$$RW_IRAM1$$RW$$Base;
uint32_t image_rw_length = (uint32_t)&Image$$RW_IRAM1$$RW$$Limit - (uint32_t)&Image$$RW_IRAM1$$RW$$Base;
uint32_t load_rw_start_addr = (uint32_t)&Load$$RW_IRAM1$$RW$$Base;
uint32_t image_zi_start_addr = (uint32_t)&Image$$RW_IRAM1$$ZI$$Base;
uint32_t image_zi_length = (uint32_t)&Image$$RW_IRAM1$$ZI$$Limit - (uint32_t)&Image$$RW_IRAM1$$ZI$$Base;
rw_section_init(load_rw_start_addr, image_rw_start_addr, image_rw_length);
zi_section_init(image_zi_start_addr, image_zi_length);
}
首先,结合zi_section_init函数,先计算下main和zi_section_init函数一共有多少局部变量:
main:5个,分别是image_rw_start_addr、image_rw_length、load_rw_start_addr、image_zi_start_addr、image_zi_length
zi_section_init:2个,分别是psrc、pend
刚进入main的时候
栈指针为0x118。
进入rw_section_init函数之前,寄存器的值和监视的变量。
栈大小为0x110,PC为0x10000A1A。
再看看各个变量的值以及寄存器里面的值
具体的就不分析了,可以自己对照上面红色方框里面的值,说下结论:Watch 1里面监视变量在R8、R9、R10、R4、R6中储存,其余未用到的寄存器都是保存的开始设置的值,如R5保存的值为5,R7为7。
进入rw_section_init函数之后
进入函数之后,栈值为0x110。R0--R2保存的是rw_section_init,和load_rw_start_addr, image_rw_start_addr, image_rw_length值相等(肯定的)。这一步是传递参数,使用LR保存PC寄存器的值,但LR的值为0x0x10000A25,不是0x10000A1A。
重新来看下,在进入函数之前的rw_section_init汇编代码,可以看到,红框部分有四条汇编代码,故LR的值应该为0x0x10000A20+4,至于为0x0x10000A25而非A24,这是因为在ARMv7之前,指令集分为ARM和Thumb,而进入到Cortext-M3之后,使用的是Thumb2,不再区分ARM和Thumb状态,但依然保留某些时候会将PC的最低为置1(具体什么时候博主也忘了,在《Cortex-M3权威指南》中博主曾经看到过一眼,具体在哪里就不太清楚了)。
刚进入rw_section_init函数,sp还未变动,只是R0--R2变化。
再运行一步,看看栈是如何变化的。
栈减少了0x10 B,说明入栈了4个字(word)。 看看栈的变化,再四个红色框框里面,0x2000 00FC地址是不不变化的,但后面四个字就变成了0x2000 0004、0x0000 0005、
0x0000 0114、0x1000 0A25。 0x1000 0A25是PC的值,最先入栈(栈是倒着增长),依次入栈 0x0000 0114、 0x0000 0005、0x2000 0004。这三个是寄存器R6、R5、R4的值(如此看来寄存器应该是高寄存器先入栈),按照ATPCS规则,R4--R11是可以保存局部变量的,而rw_section_init函数刚好三个局部变量,看看汇编代码。
确实跟分析的一样。接下来就是将R0保存到R3,R0、R4、R5分别保存psrc, pdst, pend的值(准确的说,在C中的局部变量psrc, pdst, pend实际就是R0、R4、R5)。
看看具体的汇编代码。
对照着C的循环代码。
可以看到,R6实际上是赋值数据的中间变量作用。最后弹出提前保存的R4--R6、LR
直接将lr的值弹出到pc中,完成pc的赋值。
函数调用的具体过程、栈的变化、寄存器和变量的分配使用等在这里进行了较深入的介绍。这里虽然没有直接解释为什么“- 16”,但进行了间接的解释,相信不难理解,进入到zi_section_init函数时,要对栈的内容进行保护(“- 16”的操作)。具体的不分析了,这个函数比较简单,简单的看下即可。
这里栈为0x20000108,最开始栈为0x0x20000118,相差0x10即16B,符合前面的操作。
四、题外话
关于__main函数的分析,暂时就到这里了。里面有很多内容可以深挖下去,以后若是继续进行研究再来补充。博主在写代码时,执行zi_section_init函数之前的内容完全没问题,就是执行zi_section_init函数时,程序老是跑飞,到HardFault_Handler里面。一开始百思不得其解,但我将pend的值设置的小一些(上面“-16”的操作部分),发现程序可以正常运行完成了。16这个值最开始是博主试验出来的(^_^),后面根据ATPCS规则,想通了这部分的代码为啥是16了(当然,不排除想错了的可能.....)。
总结一下芯片的启动过程:
1、读取Boot ROM(芯片厂商的代码)
2、执行start.s(设置堆栈大小、中断向量表、初始化系统时钟、初始化堆栈、初始化C库,进入main)
3、执行用户的main,控制权回到程序员手中。
这里面牵扯到很多的技术,博主知道的就有:CPU启动、编译器、ARM汇编、C库函数相关,硬件体系等等,每一部分都有很多知识点,有很多规范。博主也只是刚刚进入计算机的世界,以后还要继续学习,争取早日能在计算机的世界中做更深入的工作。
PS:写在最后,这篇文章从开始到结束跨度时间有点长,里面的内容应该有不少纰漏之处,博主做实验的时候,芯片用了两款,还不是一家的产品,但文章中对芯片的分析是没啥问题的,结论也没啥问题,只有一些关于地址、储存器大小相关部分的数据有些问题,写到最后已经没法改动了,还请见谅。
代码中包含不少调试信息,博主就不删了。链接:https://gitee.com/zichuanning520/htq_library/tree/master/%E5%8F%82%E8%80%83%E4%BB%A3%E7%A0%81/