一直在用ARM的Cortex-M系列做产品开发,也陆陆续续学习了ARM的启动流程、汇编启动文件,但是总感觉没有连贯的把全部知识串起来,不知道某些汇编语句为什么要这么写,没法按照自己的情况进行修改。今天从连接器及连接脚本入手,梳理一下完整流程。
一、基本概念
嵌入式系统开发完成最终的映像文件是需要写入到嵌入式设备的ROM/FLASH中断的。
常见的映像文件格式包括bin和hex文件。AXF/ELF文件也是一种映像文件,是在bin文件中加入了一些文件头和调试信息。
一个映像文件中可以包含多个域(region),每个域在加载时域( load region)和运行时域(execution region)可以有不同的地址。每个域可以最多3个输出段(output section),每个输出段是由具有相同属性的若干输入段(input section)组成,各输入段包含了目标文件中的代码和数据。
输入段中包含4类内容:代码、已经初始化的数据、未经过初始化的存储区域、内容初始化为0的存储区域。每个输入段有相应的属性,可以为只读(RO)、可读写(RW)、初始化为0的(ZI)。ARM连接器根据各输入段的属性将这些输入段分组,再组成不同的输出段以及域。
一个输出段中包含了一系列的具有相同的RO、RW和ZI属性的输入段。输出段的属性与其中包含的输入段的属性相同。
一个域中包含1~3个输出段,其中各输出段的属性各不相同。各输出段的排列属性是由其属性决定的。RO属性的输出段排在最前面,其次是RW属性的输出段,最后是ZI属性的输出端。一个域通常映射到一个物理存储器上,如ROM和RAM等。
连接器根据各输入段的属性来组织这些输入段,具有相同属性的输入段被放到域中一段连续的空间中,组成一个输出段。在输出段中,各输入段的起始地址与输出段的起始地址和该输出段中各输入段的排列顺序有关。
连接器需要知道如下信息,以决定如何生成相关的映像文件:
- 分组信息:决定如何将各输入段组织成响应的输出段和域
- 定位信息:决定各域在存储空间中的起始地址
分散加载是指示ARM连接器在生产映像文件时如何分配RO、RW、ZI等数据的存放地址。如果不分散加载,ARM连接器会按照默认的方法来生成映像。分散加载文件就是用于具有简单语法规则,描述以上相关地址信息的一个配置文件,告诉连接器相关的地址映射关系,也就是我们常说的连接脚本,
MDK使用armlink作为连接器,将编译得到的ELF格式的目标文件及相关的库进行连接,生产可执行的映像文件。
MDK对应的文件后缀名为.sct。
IAR对应的文件后缀名为.icf。
GCC对应的文件后缀名为.ld。
更多详细信息可查阅MDK安装目录下“ARM\Hlp\DUI0377G_02_mdk_armlink_user_guide.pdf”文件
二、STM32的scatter文件
使用MDK编译STM32,很多时候会忽视分散加载文件的存在,只需要我们选择了对应的芯片型号,MDK已经帮我们自动生成了.sct文件,并自动加入到连接路径中。可在“Options for Target”下的“Linker”选项页中查看。
打开该文件,可以看到如下配置信息:
上面的region和sction信息是从哪里来呢?
打开“Options for Target”下的“Target”选项页,就一目了然了。
MDK5版本开始采用了Software Packs(DFP)管理芯片和组件,只需要下载STM32对应的DFP包,就包括了对应的芯片信息。在mdk安装目录对应“ARM\PACK\Keil\STM32F1xx_DFP\2.3.0”目录“Keil.STM32F1xx_DFP.pdsc”文件中,就有我选择芯片的对应信息。
因为我们在“Linker”选项页中选择了“Use Memory Layout from Target Dialog”
如果取消了该选项,就可以选择自己编写的scatter文件了。
三、scatter文件解读
; *************************************************************
; *** Scatter-Loading Description File generated by uVision ***
; *************************************************************
LR_IROM1 0x08000000 0x00040000 { ; load region size_region
第一个加载时域,起始地址0x08000000,大小0x00040000
ER_IROM1 0x08000000 0x00040000 { ; load address = execution address
第一个运行时域,起始地址0x08000000,大小0x00040000;第一个执行域必须和加载域地址重合,因为ARM的复位地址就是加载域的起始地。
*.o (RESET, +First)
将编译后生成的文件.o中找到含有“RESET”段的内容放到“ER_IROM1"这块存储区域内;用First指定该输入段处于该执行域的开头。
*(InRoot$$Sections)
下面会重点介绍
.ANY (+RO)
所有编译生成的RO属性的代码全部存放在运行时域ER_IROM1指定的地址范围内,存放方式:顺序存放
}
RW_IRAM1 0x20000000 0x00010000 { ; RW data
第二个运行时域
.ANY (+RW +ZI)
说把剩下的RW,ZI类型的数据放到这里
}
}
关于ER_IROM1运行时域中的*(InRoot$$Sections),查阅了MDK的帮助文档,得到的答案如下:
Root regions
A root region is an execution region with an execution address that is the same as its load address. A scatter file must have at least one root region.
One restriction placed on scatter-loading is that the code and data responsible for creating execution regions cannot be copied to another location. As a result, the following sections must be included in a root region:
- __main.o and __scatter*.o containing the code that copies code and data
- __dc*.o that performs decompression
- Region$$Table section containing the addresses of the code and data to be copied or decompressed.
Because these sections are defined as read-only, they are grouped by the * (+RO) wildcard syntax. As a result, if * (+RO) is specified in a non-root region, these sections must be explicitly declared in a root region using InRoot$$Sections.
根区域是一个执行区域,其执行地址与其加载地址相同。 分散加载文件必须至少有一个根区域。
对分散加载的一个限制是,不能将负责创建执行区域的代码和数据复制到另一个位置。 因此,以下部分必须包含在根区域中:
- __main. o和__scatter *.o包含复制代码及数据的程序
- __dc *. O,执行解压缩
- Region$$Table部分包含要复制或解压的代码和数据的地址。
因为这些部分被定义为只读的,所以它们按照* (+RO)通配符语法分组。 因此,如果* (+RO)是在非根区域中指定的,那么这些section必须使用InRoot$$ sections在根区域中显式声明。
根据上面介绍,我们的应用程序使用的是main()作为代码主入口,但汇编启动文件最后跳转到__main()而不是main。__main属于runtime库的一部分,负责以下的工作:
- 将非零(只读和读写)运行区域从其载入地址复制到运行地址。
2、清零ZI 区域。
3、完成其他的一系列库函数、堆栈等应用环境初始化。
4、跳转到应用程序入口main函数。
四、STM32汇编启动文件
启动文件由汇编编写,是系统上电复位后第一个执行的程序。主要做了以下工作:
1、初始化堆栈指针SP=_initial_sp
2、初始化PC指针=Reset_Handler
3、初始化中断向量表
4、配置系统时钟
5、调用C库函数_main初始化用户堆栈,从而最终调用main函数去到C的世界
启动文件使用的ARM汇编指令汇总:
指令名称 | 作用 |
EQU | 给数字常量取一个符号名,相当于C语言中的define |
AREA | 汇编一个新的代码段或者数据段 |
SPACE | 分配内存空间 |
PRESERVE8 | 当前文件堆栈需按照8字节对齐 |
EXPORT | 声明一个标号具有全局属性,可被外部的文件使用 |
DCD | 以字为单位分配内存,要求4字节对齐,并要求初始化这些内存 |
PROC | 定义子程序,与ENDP成对使用,表示子程序结束 |
WEAK | 弱定义,如果外部文件声明了一个标号,则优先使用外部文件定义的标号,如果外部文件没有定义也不出错。要注意的是:这个不是ARM的指令,是编译器的,这里放在一起只是为了方便。 |
IMPORT | 声明标号来自外部文件,跟C语言中的EXTERN关键字类似 |
B | 跳转到一个标号 |
ALIGN | 编译器对指令或者数据的存放地址进行对齐,一般需要跟一个立即数,缺省表示4字节对齐。要注意的是:这个不是ARM的指令,是编译器的,这里放在一起只是为了方便。 |
END | 到达文件的末尾,文件结束 |
IF,ELSE,ENDIF | 汇编条件分支语句,跟C语言的if else类似 |
重点讲一下接下来会经常用到的AREA。
AREA是ARM汇编的伪指令,用于定义一个代码段或数据段。段是独立的、指定的、不可见的代码或数据块,它们由连接程序处理。
语法格式:
AREA sectionname{,attr}{,attr}…
其中:sectionname为所定义的段的名称;attr为该段的属性,具有的属性如下表:
伪操作 | 功能 |
CODE | 定义代码段 |
DATA | 定义数据段 |
READONLY | 指定本段为只读,代码段的默认属性 |
READWRITE | 指定本段为可读可写,数据段的默认属性 |
ALIGN | 指定段的对齐方式为2^expession。expression的取值为0~31 |
COMMON | 指定一个通用段。该段不包含任何用户代码和数据 |
NOINIT | 指定此数据段仅仅保留了内存单元,而没有将各初始值写入内存单元,或者将各个内存单元值初始化为0 |
1、Stack栈
; Amount of memory (in bytes) allocated for Stack
; Tailor this value to your application needs
; <h> Stack Configuration
; <o> Stack Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>
Stack_Size EQU 0x00004000
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
开辟栈的大小为0x00004000(1KB) ,名字为STACK,NOINIT即不初始化,可读可写,8(2^3)字节对齐。
栈的作用是用于局部变量,函数调用,函数形参等的开销,栈的大小不能超过内部SRAM的大小。如果编写的程序比较大,定义的局部变量很多,那么就需要修改栈的大小。如果某一天,你写的程序出现了莫名奇怪的错误,并进入了hardfault的时候,这时你就要考虑下是不是栈不够大,溢出了。
EQU:宏定义的伪指令,相当于等于,类似与C中的define。
AREA:告诉汇编器汇编一个新的代码段或者数据段。STACK表示段名,这个可以任意命名;NOINIT:表示不初始化;READWRITE表示可读可写,ALIGN=3,表示按照2^3对齐,即8字节对齐。
SPACE:用于分配一定大小的内存空间,单位为字节。这里指定大小等于Stack_Size。
标号__initial_sp紧挨着SPACE语句放置,表示栈的结束地址,即栈顶地址,栈是由高向低生长的。
2、Heap堆
; <h> Heap Configuration
; <o> Heap Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>
Heap_Size EQU 0x00000200
AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit
开辟堆的大小为0x00000200(512字节),名字为HEAP,NOINIT即不初始化,可读可写,8(2^3)字节对齐。__heap_base表示对的起始地址,__heap_limit表示堆的结束地址。堆是由低向高生长的,跟栈的生长方向相反。
堆主要用来动态内存的分配,像malloc()函数申请的内存就在堆上面,一般在没有操作系统的环境下较少使用。
3、向量表
IMPORT xPortPendSVHandler
IMPORT xPortSysTickHandler
IMPORT vPortSVCHandler
当前工程使用到了FreeRTOS,修改了3个中断函数,使用了IMPORT声明。
PRESERVE8
THUMB
PRESERVE8:指定当前文件的堆栈按照8字节对齐。
THUMB:表示后面指令兼容THUMB指令。THUBM是ARM以前的指令集,16bit,现在Cortex-M系列的都使用THUMB-2指令集,THUMB-2是32位的,兼容16位和32位的指令,是THUMB的超级。
; Vector Table Mapped to Address 0 at Reset
AREA RESET, DATA, READONLY
定义一个数据段,名字为RESET,可读。上一章节中讲到了“将编译后生成的文件.o中找到含有“RESET”段的内容放到“ER_IROM1"这块存储区域内”,“RESET”段就在这个汇编启动文件中定义。
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
声明__Vectors、__Vectors_End和__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
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD vPortSVCHandler ; SVCall Handler
DCD DebugMon_Handler ; Debug Monitor Handler
DCD 0 ; Reserved
DCD xPortPendSVHandler ; PendSV Handler
DCD xPortSysTickHandler ; SysTick Handler
; External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD PVD_IRQHandler ; PVD through EXTI Line detect
。。。中间代码省略
DCD OTG_FS_IRQHandler ; USB OTG FS
__Vectors_End
__Vectors_Size EQU __Vectors_End - __Vectors
__Vectors为向量表起始地址,__Vectors_End 为向量表结束地址,两个相减即可算出向量表大小。
向量表从FLASH的0地址开始放置,以4个字节为一个单位,地址0存放的是栈顶地址,0x04存放的是复位程序的地址,以此类推。从代码上看,向量表中存放的都是中断服务函数的函数名,可我们知道C语言中的函数名就是一个地址。
DCD:分配一个或者多个以字为单位的内存,以四字节对齐,并要求初始化这些内存。在向量表中,DCD分配了一堆内存。
在启动文件中把设置栈顶及首条指令地址到了最前面的地址空间,但这并没有指定绝对地址,各种内容的绝对地址是由链接器根据分散加载文件(*.sct)分配的。(详见第3部分)
分散加载文件把加载区和执行区的首地址都设置为0x08000000,正好是内部FLASH的首地址,因此汇编文件中定义的栈顶及首条指令地址会被存储到0x08000000和0x08000004的地址空间。
AREA |.text|, CODE, READONLY
定义一个名称为.text的代码段,可读。
; 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函数初始化系统时钟,然后调用C库函数_mian,最终调用main函数去到C的世界。
WEAK:表示弱定义,如果外部文件优先定义了该标号则首先引用该标号,如果外部文件没有声明也不会出错。这里表示复位子程序可以由用户在其他文件重新实现,这里并不是唯一的。
IMPORT:表示该标号来自外部文件,跟C语言中的EXTERN关键字类似。这里表示SystemInit和__main这两个函数均来自外部的文件。
SystemInit()在STM32驱动库中提供。
__main是一个标准的C库函数,主要作用是初始化用户堆栈,最终调用main函数去到C的世界。这就是为什么我们写的程序都有一个main函数的原因。如果我们在这里不调用__main,那么程序最终就不会调用我们C文件里面的main,如果我们想直接跳转到自己定义的主函数,可以修改主函数的名称,然后在这里面IMPORT你写的主函数名称即可。
4、中断服务程序
; Dummy Exception Handlers (infinite loops which can be modified)
NMI_Handler PROC
EXPORT NMI_Handler [WEAK]
B .
ENDP
。。。中间代码省略
SysTick_Handler PROC
EXPORT SysTick_Handler [WEAK]
B .
ENDP
Default_Handler PROC
EXPORT WWDG_IRQHandler [WEAK]
。。。中间代码省略
EXPORT OTG_FS_IRQHandler [WEAK]
WWDG_IRQHandler
。。。中间代码省略
OTG_FS_IRQHandler
B .
ENDP
在启动文件里面已经帮我们写好所有中断的中断服务函数,跟我们平时写的中断服务函数不一样的就是这些函数都是空的,真正的中断复服务程序需要我们在外部的C文件里面重新实现,这里只是提前占了一个位置而已。
如果我们在使用某个外设的时候,开启了某个中断,但是又忘记编写配套的中断服务程序或者函数名写错,那当中断来临的时,程序就会跳转到启动文件预先写好的空的中断服务程序中,并且在这个空函数中无线循环,即程序就死在这里。
B:跳转到一个标号。这里跳转到一个'.',即表示无限循环。
部分汇编指令:
指令名称 | 作用 |
LDR | 从存储器中加载字到一个寄存器中 |
BL | 跳转到由寄存器/标号给出的地址,并把跳转前的下条指令地址保存到LR |
BLX | 跳转到由寄存器给出的地址,并根据寄存器的LSE确定处理器的状态,还要把跳转前的下条指令地址保存到LR |
BX | 跳转到由寄存器/标号给出的地址,不用返回 |
ALIGN
ALIGN:对指令或者数据存放的地址进行对齐,后面会跟一个立即数。缺省表示4字节对齐。
5、用户堆栈初始化
;*******************************************************************************
; User Stack and Heap initialization
;*******************************************************************************
IF :DEF:__MICROLIB
EXPORT __initial_sp
EXPORT __heap_base
EXPORT __heap_limit
ELSE
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
判断是否定义了__MICROLIB ,如果定义了则赋予标号__initial_sp(栈顶地址)、__heap_base(堆起始地址)、__heap_limit(堆结束地址)全局属性,可供外部文件调用。如果没有定义(实际的情况就是我们没定义__MICROLIB)则使用默认的C库,然后初始化用户堆栈大小,这部分有C库函数__main来完成,当初始化完堆栈之后,就调用main函数去到C的世界。
ALIGN
ENDIF
END
IF,ELSE,ENDIF:汇编的条件分支语句,跟C语言的if ,else类似
END:文件结束
五、系统启动流程
在离开复位状态后, Cortex-M3 做的第一件事就是读取下列两个 32 位整数的值:
1、从地址 0x0000,0000 处取出 MSP 的初始值。
2、从地址 0x0000,0004 处取出 PC 的初始值——这个值是复位向量, LSB 必须是 1。 然后从这个值所对应的地址处取指。
复位序列
请注意,这与传统的 ARM 架构不同——其实也和绝大多数的其它单片机不同。传统的 ARM 架构总是从 0 地址开始执行第一条指令。它们的 0 地址处总是一条跳转指令。在 Cortex-M3 中,在 0 地址处提供 MSP 的初始值,然后紧跟着就是向量表。向量表中的数值是 32 位的地址,而不是跳转指令。向量表的第一个条目指向复位后应执行的第一条指令,就是我们刚刚分析的Reset_Handler这个函数。
虽然内核是固定访问0x00000000和0x00000004地址的,但实际上这两个地址可以被重映射到其它地址空间。芯片引出的BOOT0及BOOT1引脚的电平情况,这两个地址可以被映射到内部FLASH、内部SRAM以及系统存储器。
采用内部FLASH启动方式,当芯片上电后采样到BOOT0引脚为低电平时, 0x00000000和0x00000004地址被映射到内部FLASH的首地址0x08000000和0x08000004。因此,内核离开复位状态后,读取内部FLASH的0x08000000地址空间存储的内容,赋值给栈指针MSP,作为栈顶地址,再读取内部FLASH的0x08000004地址空间存储的内容,赋值给程序指针PC,作为将要执行的第一条指令所在的地址。具备这两个条件后,内核就可以开始从PC指向的地址中读取指令执行了。
初始化MSP和PC的一个范例
因为 Cortex-M3 使用的是向下生长的满栈,所以 MSP 的初始值必须是堆栈内存的末地址加 1。举例来说,如果我们的堆栈区域在 0x20007C00-0x20007FFF 之间,那么 MSP 的初始值就必须是 0x20008000。
向量表跟随在 MSP 的初始值之后——也就是第 2 个表目。要注意因为 Cortex-M3是在 Thumb 态下执行,所以向量表中的每个数值都必须把 LSB 置 1(也就是奇数)。正是因为这个原因,图 143中使用0x101 来表达地址 0x100。当 0x100 处的指令得到执行后,就正式开始了程序的执行(即去到C的世界)。在此之前初始化 MSP 是必需的,因为可能第 1 条指令还没来得及执行,就发生了 NMI 或是其它 fault。 MSP 初始化好后就已经为它们的服务例程准备好了堆栈。
现在,程序就进入了我们熟悉的C世界,现在我们也应该明白main并不是系统执行的第一个程序了。
六、参考资料:
1、《ARM体系结构与编程》
2、《ARM Cortex-M3与Cortex-M4权威指南》
3、野火零死角玩转STM32-F429系列《第14章 启动文件详解》
https://www.cnblogs.com/firege/p/5748722.html