前言
笔者假期刚开学没几天,现在处于一个比较闲的状态。
假期简单学习了一下ARM汇编相关内容,目的是写一个简单的RTOS,但是这个项目其实只有保存上下文的部分使用到了ARM汇编,于是打算写写博客来沉淀一下假期学到的东西。
单片机的启动过程一直是笔者十分感兴趣的话题,故借着这个机会简单分析一下单片机的启动过程。由于笔者知识浅薄,分析的过程必定有所纰漏,还望有心人斧正。
启动文件分析
要分析整个启动过程必然离不开对于启动文件的分析,此处笔者以ST官方提供的STM32F401CCU6单片机的Keil环境下的启动文件为例进行简单的分析:
堆栈空间的分配
笔者此处依照从上到下的原则对文件进行分析,首先来看第一段:
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的字作为主堆栈指针的重置值,其后的各个字依照偏移的字数分别为对应标号的异常,如下图:
显然表中的前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库,所以在新版本中没什么作用:
至此,启动文件我们就分析完了,接下来我们来看看整个启动过程。
启动过程分析
一切的起源是复位程序入口,如果我们是自行使用编译器编译然后使用链接器进行链接的话我们可以自行指定复位程序的入口地址。不过在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
函数(被迫加班
__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
但是笔者嗅到了加班的味道,所以今天就先溜了。
时间来到了第二天,笔者开始上班了。
__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
的作用:
即:设置堆栈、初始化C库、调用main,以及在主函数返回后做一些收尾工作,如:停止C库和退出程序。
至此,整个启动过程就全部分析完毕了。
下班!