单片机如何从上电复位执行到 main 函数?
来源:程序是如何从复位的序列执行到 main 函数的?
作者:麦克泰技术
从事嵌入式开发的伙伴可能会思考过一个问题,我们一般都是使用芯片厂商提供的驱动库和初始化文件,直接从 main 函数开始写程序,那么系统上电之后,程序怎么引导进 main 函数执行的呢?还有,系统上电之后 RAM 的数据是随机的,那么定义的全局变量的初始值又是怎么实现的呢?
下面我将带着这两个问题,以 Cortex-M 架构为例,采用 IAR EWARM 作为编译工具链,从系统上电之后执行的第一条代码开始,梳理系统的启动过程,了解编译器在此期间所做的工作。其他的工具链,如 Keil 和 GCC 在系统初始化过程所做的工作也是相似的,但具体的实现有所差异。
1、启动文件
芯片厂商提供的启动文件,一般是采用汇编语言编写,少数用 C 语言。在启动文件中一般至少存在下面两个部分内容:
1、向量表
2、默认的中断和异常处理程序
向量表实际上是一个数组,放置在存储器的零地址,每个元素存储的是各个中断或异常处理程序的入口地址。以 STM32F107 芯片基于 IAR 工具的启动文件为例:
文件的开头定义了一个名为__vector_table 的全局符号,“DATA”的作用是在代码段中定义一个数据区,用作向量表。数据区的内容是使用 DCD 指令定义的 32 位宽度常量,除了第一个 sfe(CSTACK)比较特殊以为,其他的常量都是异常和中断服务程序的地址(在编译时函数名会被替换成函数的入口地址)。sfe(CSTACK)是 IAR 汇编器段操作,用于获取段(section)的结束地址,在这里意欲何为呢?
实际上这是获取堆栈基地址的操作。IAR 在链接器脚本(*.icf)文件中定义堆栈,实际是定义了一个名为“CSTACK”的空闲块(block),如下图的脚本命令所示。所谓的块就是保留一段连续的地址空间,用来作为堆栈或者堆。当然,块也可以是用内容的,例如可以用来管理段,但不在今天的讨论范围。
我们知道 Cortex-M 架构的堆栈模型是满减栈,堆栈从高地址向低地址增长,因此堆栈的基地址是 CSTACK 的结束地址。
向量表的第一个元素是栈基址这是由 Cortex-M 架构定义的。系统上电后硬件自动从向量表中获取,并设置主堆栈指针 MSP,而不是像其他 ARM 架构,堆栈指针需要通过软件来设置。
向量表中第二个元素是复位异常(Reset_Handler)的入口地址。系统上电后,硬件自动从__vector_table + 4 的位置读取,并从读取到的地址开始执行。系统上电后 CPU 执行的第一条是 Reset_Handler 函数的第一条语句。
上面的 THUMB 命令表示接下来的代码采用 THUMB 模式(Cortex-M 只支持 Thumb-2 指令集);SECTION 用于定义一个段,段名为“.ResetHandler”,段的类型是代码(CODE);REODER 指示用给定的名称开启一个新的段;ROOT 指示链接器,当段内的符号没有被引用,链接器也不可以丢弃这个段。
PUBWEAK 是弱定义,如果用户在其他位置编写了中断处理函数,在连接时实际链接用户所编写的,启动文件中用汇编写的服务函数会忽略。之所以要在启动文件中以弱定义的方式编写全部的异常和中断服务函数,是为了防止用户在没有编写服务函数的情况下开启并触发了中断,导致系统的不确定。
2、系统初始化过程
在 EWARM 的工程 Options > Debugger > Setup 中将“Run to”勾选取消,这样在进入调试之后就会停第一条要执行的代码的位置:
进入调试之后会停在启动文件 Reset_Handler 函数第一条汇编指令位置:
此时,通过寄存器观察窗口查看 SP 的值为 0x20009820。通过链接时生成的 map 文件,查看 CSTACK 的地址范围,0x20009820 正好是 CSTACK 的结束地址。有了 MSP,C 代码就能运行了。
ystemInit 函数是芯片厂商根据 ARM 的 CMSIS 标准提供的一个系统基础配置函数,配置基础的时钟系统和向量表重定位等。这里的 LDR 是伪指令,它将 SystemInit 函数的地址加载到寄存器 R0,实际上是通过 PC 偏移寻址来获取 SystemInit 的地址。
从上面的图可以发现一个问题,在反汇编窗口可以观察到 SystemInit 的地址是 0x20000150,但加载到 R0 寄存器后却是 0x20000151。这是因为在使用跳转指令更新 PC 时,需要置 PC 的 LSB 为 1,以表示 THUMB 模式,由于 Cortex-M 不支持 ARM 模式,因此 LSB 总是 1。
执行完芯片厂商提供的 SystemInit 函数之后,跳转到__iar_program_start,这是 IAR 编译器提供的初始化代码的入口。
__iar_program_start 首先会执行两个函数:__iar_init_core 和__iar_init_vfp,可以完成一些 CPU 和 FPU 相关的初始化操作,在某些 ARM 架构打包好的运行时库会有这两个函数,用户也可以重写这两个函数来自己实现一些相关的操作。
之后,跳转到__cmain 函数执行。在__cmain 中调用了一个__low_level_init 函数,该函数专门用于提供给用户编写一个初阶的初始化操作,它在全局变量初始化之前执行,例如可用在__low_level_init 中初始化 SDRAM,这样就可以将全局变量定义到 SDRAM 中使用。
__low_level_init 可以在任意的 C 文件中编写,注意它的返回值,如果返回 0,后续就会跳过变量初始化操作,正常一般都是返回 1。
3、全局变量的初始化
此后进入到__iar_data_init3 函数,在这里会完成所有具有初始值的全局/静态变量的赋值,以及零初始化全局/静态变量的清零操作,分别调用__iar_copy_init3 和__iar_zero_init3,将保存在 ROM 区由链接器生成的变量初始值复制到变量的地址。注意,新的 EWARM 版本默认变量初始化操作可能会采用压缩算法,实际变量初始化调用的函数可能有区别。
在全局变量未初始化之前,通过 watch 窗口可以看到,变量的值都是随机数。
在__iar_data_init3 执行完成后,全部变量的初值赋值已经完成。
在__cmain 函数的最后,跳转到用户的 main 函数,最终开始用户的代码执行。
了解了编译器所提供的初始化过程和处理器架构,我们可以根据自己的需求定制系统的初始化。
例如,在进入__iar_program_start 之前,就可以执行必要的硬件初始化操作,可以用汇编写,也可以用 C 写。还可以手动控制变量的初始化操作,自己实现变量的初始化。甚至,完全不采用 IAR 编译器提供的初始化操作,自己从复位序列引导至 main 函数那也是可以的。
版权声明: 本文为 InfoQ 作者【不脱发的程序猿】的原创文章。
原文链接:【http://xie.infoq.cn/article/f78623979d91344e042027a09】。文章转载请联系作者。
评论