基于C程序启动代码的深入分析
一、映像文件基本组成
映像文件加载时域包括RO和RW段,运行时域则包括RO、RW和ZI三个段。其中RO和RW段的内容在加载时和运行时是一样的,只是存储空间可能不同,而ZI段则是运行时由初始化函数创建的。
RO段:Read-Only段,包括源程序中的CODE段,只读数据段(包括变量的初始化值——可以是任意变量,全局/局部、静态/动态变量的初值;还包括数据常量——这个常量也可以是全局的或局部的。也就是说,编译器既要为变量分配存储空间——变量是可读写的,并不放在RO段,又要为变量的初值分配存储空间,两者是两回事)。
RW段:可读写段,主要指RW-DATA,也可能有RW-CODE。RW-DATA是指已经初始化的全局变量。
ZI段:Zero-Initialized段,主要包括未初始化的全局变量,编译器用0值对其进行初始化。该段中的数据由于是变量,因而也是可读写的,但在映像文件加载时,并不为ZI段分配存储空间,虽然在ADS编译器的Memory map文件中认为Total RW Size = (RW Data + ZI Data)。
二、代码,数据和变量在映像文件中的位置
上面简单总结了映像文件各段的组成。从程序的组成看,可以分为变量、数据和代码,其中变量又分为全局/局部的或静态/动态的,它们的存储空间又是如何分配的呢?
代码:一般是只读的,由编译器分配存储空间并放到映像文件的RO段。
数据:这里所指的数据都是常量(若可变则为变量),也包括指针常量,那么也属于只读的数据,也由编译器分配存储空间放到映像文件的RO段。
变量:主要根据生存期来分,因为生存期是按在内存中的生存时间来定义的,而作用域与存储空间分配无关。
1.全局变量和静态变量:包括静态局部变量和全局/静态指针变量在内,由编译器分配存储空间,已初始化的放到RW段,否则放到ZI段;
2.动态变量:主要是指局部变量,包括局部指针变量,函数参数,返回值等在内,占用栈空间。
三、启动过程中的堆栈初始化释疑
堆与栈:对于ARM,堆是向上生长的,栈是向下生长的。
局部变量占用栈(stack)空间(但其初始化值为数据,占用RO空间);
程序中动态申请的如malloc()和new函数申请的内存空间占用堆(heap)空间。
————×以下讨论不使用semihosting机制×————
因此,在转入C应用程序前,必须要为C程序准备堆栈空间。根据具体的目标平台的存储器资源,要对堆栈的初始化函数__user_initial_stackheap( )进行移植,主要是正确设置堆(heap)和栈(stack)的地址。它可以使用C或ARM汇编语言来编写,并至少返回堆基址(保存在R0中),栈基址(保存在R1)可选。因而一个简单的汇编语言编写的__user_initial_stackheap( )函数如下:
EXPORT __user_initial_stackheap
__user_initial_stackheap
LDRR0, =0x20000 ;heap base
LDRR1, =0x40000 ;stack base, optional
MOV PC, R14
注意,如果在工程中没有自定义这个函数,那么缺省情况下,编译器/链接器会把|Image$$ZI$$Limit|作为堆(heap)的基址(即把heap和stack区放置在ZI区域的上方,这也被认为是标准的实现[7])。但是,如果使用scatter文件实现分散加载机制,链接器并不生成符号|Image$$ZI$$Limit|,这时就必须自己重新实现__user_initial_stackheap( )函数并且设置好堆基址和栈顶,否则链接时会报错。
堆栈区还分为单区模型和双区模型,在双区模型中,还必须设置堆栈限制[4,6,7]。
关于重定义__user_initial_stackheap( )函数时几点要注意的地方:一是不要使用超过96字节的stack,二是不要影响到R12(IP,用作进程间调用的暂存寄存器),三是按规则返回参数值(R0:heap base;R1:stack base;R2:heap limit;R3:stack limit),四是让堆区保持8字节对齐[6]。
在启动代码中,还要对各个处理器模式的栈指针进行初始化。这个问题很容易与上面谈到的__user_initial_stackheap()函数的作用相混淆。可从以下几点来加以说明:
(1)在嵌入式应用中,启动代码分为两个部分:一是系统的初始化,包括中断向量表的建立、时钟、存储系统初始化、关键I/O口初始化、各处理器模式下的栈指针初始化等;二是应用程序初始化(或说C库函数初始化),包括RW段的搬移和ZI段的清零、C应用程序堆栈区的建立(__user_initial_stackheap()函数初始化堆栈指针)等。
从这个意义上说,两者并没有直接关系。
(2)但两者并不是没有联系的。以单区模型的堆栈区为例,由于栈是向下生长的,堆是向上生长的,系统模式的栈指针(与用户模式相同,共用一个R13寄存器来描述)实际上定义了用户模式下单区模型堆栈区的上限,而__user_initial_stackheap()函数中指定的heap基址则成为该堆栈区的下限。
因此,如果之前已经对系统模式(用户模式)的栈指针进行了初始化,则在重定义__user_initial_stackheap()函数时,就不需要重新定义stack base了。
四、启动代码的内容和初始化顺序探讨
前面已经指出,启动代码包括系统初始化以及应用程序运行环境的初始化两个部分,完成初始化后,就可以呼叫用户主程序了。参考资料[1]、[3]和[5]等都对两个部分的内容以及过程列出了非常清晰但又简单明了的步骤,这对于初学者来说稍微有点抽象。
如果不需要使用MMU进行地址重映射,那么,结合网上可以搜集的示例boot代码以及分析文档,加上自己动手移植和调试,也是比较容易理解的。如果是使用处理器自带的Remap控制寄存器来进行地址重映射,网上也有相关的代码,例如网友twentyone的boot代码【4510 bootloader的实现与分析(附源代码)】就非常清楚,另外,在《ARM学习报告》系列文章中也对其有详细的分析。
对于在启动过程中要使用MMU进行地址重映射的系统初始化顺序,在《使用AXD调试MMU地址映射程序手记(二)》一文中给出了一个参考步骤,并做了一定的说明。通过进一步参考权威资料,这里,对系统初始化顺序作了小的改进与修正如下:
①禁止所有中断→②初始化时钟→③初始化存储器→④初始化各模式下的栈指针→⑤初始化GPIO→⑥拷贝映像文件到SDRAM→⑦建立地址重映射表→⑧使能MMU→⑨应用程序初始化(RW&ZI区)→⑩使能异常中断→⑾呼叫主程序(dummyOS)。
主要对使能异常中断和应用程序初始化的顺序做了调整,即先进行应用程序的初始化,再使能异常中断。
......