我们之前讲过,单片机有根据boot的不同,有三种启动方式:
boot0 | boot1 | 启动模式 |
---|---|---|
0 | X | flash启动 |
1 | 0 | 系统存储器 |
1 | 1 | 内置SRAM |
单片机上电复位后,运行main函数。以STMF103ZE芯片、flash启动为例。
可以看到,ROM也就是flash起始地址是0x08000000,大小是512k:RAM起始地址是0x20000000,大小是64k。
下面以串口实验为例,看一下程序的反汇编代码。分析一下系统的启动流程。
程序被下载到flash中,首地址就是0x08000000,而且内核规定了,第一条指令必须是初始化栈指针的,第二条指令必须是初始化PC指针,PC = Rest_Handle,并且跳转过去执行,然后在Rest_Handler中调用main函数,这样程序就会执行我们自己写的函数了。注意这个函数是我们自己编写的,不是官方的,这里为了让大家更加清晰的看到程序的执行过程,简化了这个函数。
这里用mymain代替main函数,也是要简化系统启动的流程。 查看MDK的文档,会发现有这么一句说明:It is automatically created by the linker when it sees a definition of main()。就是说,当发现定义了main函数,那么就会自动创建__main,这个函数是编译器自动创建的,会在里面去进行一些初始化设置。
这样,我们就可以正常运行一个简单的程序,下面以串口实验为例:
可以看到,现象正常,说明我们自己的启动文件是可以运行的。
启动文件像上面那样设置是不是就没问题了?我们做个实验,还是以串口为例。
char Temp = 'O';
int mymain()
{
usart_init();
putchar('1');
putchar('1');
putchar(Temp);
putchar('1');
putchar('1');
while(1);
}
定义一个全局变量,并赋值。然后通过串口打印输出。
我们可以看到,输出的结果明显不对,Temp应该是输出字母O,但是结果明显不是。下面大红框是数据的16进制表示,31、32是1、2的ASCII码值,中间应该是O的,应该是4F。而且每一次复位之后,输出的也不一样。说明全局变量Temp的值不是确定的,每次上电后都发生变化。
下面定义一个const修饰的全局变量,可以看到,Temp2对应的16进制是41,也就是A,说明Temp2的值是确定的,Temp1的值是不确定的。
可以看到,Temp1地址是0x2000 0000,Temp2地址是0x0800 0144,这两个地址是不是有些眼熟。没错,正是对应芯片的RAM和ROM。
也就是说,虽然都是全局变量,但是他们在程序运行时,保存的位置是不一样的。
对于全局变量Temp1,他是可读可写的,所以在运行时放在RAM中。Temp2是只读的,所以放在ROM(也就是Flash)中。根据程序数据存放位置的不同,内存会被分为不同的段。
1、可读可写数据段(RW-data):存放初始值不为0的全局变量、静态变量。烧录在ROM上,使用之前需要从ROM上复制到内存。
2、只读数据段(RO-data):存放不可以被修改的变量,如const修饰的变量或者字符串,烧录在ROM上,不需要复制到内存。
3、BSS或ZI段:初始值为0和未初始化的全局变量或静态变量,放在RAM中。
4、堆:一块空闲空间,存放进程运行中被动态分配的内存段,使用malloc函数来管理它。
5、栈:存放局部变量和函数调用时的参数
上面是存放数据的段,还有存放代码的段。
代码段(Code):就是目标文件的所有程序代码,不会被修改,烧录在Flash中。
烧录时:
Code+RO+RW全在flash上,RI段全是0没必要烧录,只需要在程序运行前全部清零即可。
运行时:
RW-data会搬运到RAM中,链接器会把RW的地址映射到RAM中的一片区域内,访问RW-data实际上访问的是RAM中的地址。
接下来我们分析一下,为什么上面的全局变量Temp1的值是不确定的。
因为链接器只负责映射地址,不负责数据的拷贝,所以对于RAM中的0x20000000这个地方,是没有数据的,或者说这里面的数据是随机的,因为没有对这快内存进行赋值。我们的程序上电之后,执行Rest_Handler程序,设置栈指针,然后运行main函数,使用串口打印Temp,这些流程中,并没有对0x20000000这块内存进行赋值。这就是为什么定义Temp1时候,即使赋值了,但是仍然打印不出来正确的数据初始化赋的值保存在了ROM中,而程序运行时访问的时RAM中的地址,RAM中的空间并没有被初始化。对于Temp2,CPU访问的时候访问的是ROM区域,而Temp2正是保存在这里,所以CPU可以直访问。
由上可知,程序在烧录和运行时分布是不同的。这些程序中所有初值要保存只可能存在ROM中,但是在使用的时候,访问的却是RAM。所以这中间肯定有这样一种机制 : 在上电以后把ROM中存储的这些变量初值来重新初始化到对应的RAM地址,以便后续程序指令访问。
在程序烧录时,所有数据都加载都ROM中,在ROM中为代码和数据分配内存空间,加载地址就是程序保存在ROM中的地址。
链接地址:程序在RAM中执行时的地址。执行这条指令时,PC值应该等于这个地址,也就是说,PC等于这个地址时,这条指令应该保存在这个地址内。链接地址由链接脚本文件指出,链接的时候确定。
重定位就是把目标重新放在一个新的地址上,PC访问的时候是去新地址上访问。简单地说就是用链接地址来代替加载地址成为程序被访问时的地址。保存在ROM上的全局变量的值,在使用前要复制到RAM,这就是数据段重定位。若想把代码移动到其他位置,就是代码重定位。
假若程序不位于链接地址时程序会出现什么问题?
去访问某些全局变量时就会出错,因为访问这些全局变量时用的是它的链接地址,我去链接地址访问你,你就必须位于链接地址上。
现在我们应该知道了为什么上面的Temp1打印出来乱码,是因为Temp1的链接地址上的数据没有被赋值。
下面我们看一下官方提供的启动文件中的Rest_Handler函数中做了些什么:
SystemInit
中主要负责初始化STM的时钟系统。
__main
中的__scatterload
函数负责设置内存,而rt_entry
函数负责设置运行时的环境。__scatterload
中负责把RW(非零)输出段从装载域地址复制到运行域地址(执行代码和数据复制、解压缩),并完成ZI段运行域数据的0初始化工作。然后跳到__rt_entry
设置堆栈和堆、初始化库函数和静态数据。然后,__rt_entry
跳转到应用程序的入口main()
。主应用程序结束执行后,__rt_entry
将库关闭,然后把控制权交换给调试器。main()
函数的存在强制链接器链接到__main
和__rt_entry
中的代码。如果没有标记为main()的函数,则没有链接到初始化序列,因而部分标准C库功能得不到支持。
到现在我们知道了程序的启动流程应该是:复位->设置堆栈->执行Reset_Handler->SystemInit
(可以没有)->__main(重定位RW-data数据段、清楚ZI段)->进入真正的main函数。
可以看到,我们自己的启动函数缺少了SystemInit
和__main
两部分,对于SystemInit
是配置时钟的,如果不初始化,芯片默认使用HSI-8MHz的时钟。所以不是必须的;但是数据段的重定位是必须的,所以我们要加上这部分。
是否需要重定位,根据数据的加载地址和链接地址确定。若加载地址和链接地址相等,则不需要重定位,比如保存在ROM中的RO-data。
否则就需要重定位,比如RW-data。BSS(ZI)段:不需要重定位,在可执行文件中没有他,只需要在把他对应的空间清零即可。
对于重定位,还需要知道两个概念:位置无关码、位置有关码。
该段代码无论放在内存的哪个地址,都能正确运行。因为他使用程序当前运行的PC值进行相对跳转,PC=PC+offset,没有使用绝对地址,都是相对地址,无论代码在哪,总能达到指令的正常目的,也就是说这段代码扔在任何位置都可以运行。类似于文件的相对路径,可以把程序放在任何文件夹下面,编辑器均可以根据工程文件路径找到其他每一个文件。
例如:
bl main
它的地址与代码处于的位置相关,是绝对地址。如PC=0x08000000。他不依赖当前PC值,而是直接把要跳转的地址赋值给PC。这样赋给PC的地址里面,必须有正确的指令才能正常运行,否则会出现各种错误。类似绝对路径,一旦文件夹发生变动,基本上就是定位不到具体的文件了。
例如:
ldr pc, =main
重定位的实质就是数据拷贝,把加载地址中的数据搬运到链接地址中去。
对于移动数据,我们要知道三个重要因素:、
1、源:数据来源,也就是加载地址中的数据。
2、目的:数据最终要保存的地址,也就是链接地址。
3、长度:要搬运的数据的长度。
在keil中,用散列文件描述以上三个方面。
在编译过程中有多个.o文件,而最后生成的只是一个可执行文件,那么这些文件要怎么以什么方式生成一个文件呢----链接,在Keil-MDK下就是使用散列文件来指导链接的。
如上图所示,进行设置,然后重新编译,就会在Objects文件夹中得到一个.sct文件,他就是散列文件。
打开文件,可以看到下图中的代码。
下面我们分析一下这个散列文件中的代码。
首先看一下keil中散列文件的基础知识。
根据上图操作,打开文档找到第八章Scatter File Syntax。
根据上图可知,一个散点文件包含一个或多个加载域, 一个加载域可以包含一个或多个执行域,每一个执行域包含一个或多个Input section。 加载域中描述二进制文件中一共有哪些东西。
加载域语法
执行域分布:
LR_IROM1 0x08000000 0x00080000 {;加载域地址从0x08000000开始,大小是0x00080000
ER_IROM1 0x08000000 0x00080000 { ; 执行域ER_IROM1的链接地址是0x08000000,与加载地址相同,大小也相同,所以他不需要重定位
*.o (RESET, +First) ;所有的.o文件里的RESET段抽取出来放在最开始的位置,一般只有启动文件中有RESET段
*(InRoot$$Sections) ;所有的文件包括库,keil添加的可执行文件,看不到源码,可以去掉
.ANY (+RO) ;等同于*,优先级比*低,这里表示所有的只读数据段-RO
.ANY (+XO) ;这里表示所有的只可执行段
}
RW_IRAM1 0x20000000 0x00010000 { ; 执行域RW_IRAM1的链接地址是0x20000000,大小是0x00010000,他需要重定位
.ANY (+RW +ZI) ;所有的文件的可读可写数据段和ZI段,但是ZI段并不会存在bin可执行文件里。
}
}
上面两个执行域包含了整个工程的所有信息,两个执行域,一个把内容存放在ROM,一个把内存放在了RAM。由上面也看到了,RO-data保存在了ROM上,RW-data和ZI段放在了RAM。
实际上,对于在STM32F103这类资源紧缺的单片机芯片中:
1、代码段保存在Flash上,直接在Flash上运行(当然也可以重定位到内存里)。
2、数据段暂时先保存在Flash上,然后在使用前被复制到内存里(只读数据段不复制)。
根据上面的内容可以分析出执行域的源和目的。
源 | 目的 | |
---|---|---|
ER_IROM1 | 0x0800 0000 | 0x0800 0000 |
RW_IRAM1 | 紧随ER_IROM1之后 | 0x2000 0000 |
上面的内容是根据已知的文件生成的,但是对于任意的、未知的执行域来说,如何查找呢?
还是看上面的keil手册6.3节。
上面知道了三要素:
1、目的:Image$$region_name$$Base
2、长度:Image$$region_name$$Length
3、源:Load$$region_name$$Base
知道数据传输三要素之后,我们就可以自己编写重定位代码了。注意:重定位是在调用main函数之前。
先写一个数据拷贝函数memory_copy
:
void memory_copy(void * dest,void * src,unsigned int len)
{
unsigned char * pcDest;
unsigned char * pcSrc;
while(len--)
{
*pcDest = *pcSrc;
pcSrc++;
pcDest++;
}
}
ldr r0, = |Image$$RW_IRAM1$$Base|
ldr r1, = |Load$$RW_IRAM1$$Base|
ldr r2, = |Image$$RW_IRAM1$$Length|
bl memory_copy
重定位之后,再次执行之前的实验代码:
可以看到,全局变量Temp1已经可以正常打印出来了。
打开反汇编文件,查看Temp1和Temp2的地址。可以看到,Temp1的地址是0x2000 0000,Temp2位于RO-data段,地址是0x0800 0164。
ZI段不被烧录到ROM中,也不会放入bin文件中,否则也太浪费空间了。在使用ZI段里的变量之前,把ZI段所占据的内存清零就可以了。
查看散列文件可知,ZI段在可执行域RW_IRAM1
中。
根据手册可以看到ZI段的基地址和长度。
知道了ZI段的基地址和长度,我们就可以对这块空间进行清除了。
ZI段保存的是初始值为0或没有初始值的全局变量和静态变量。
int Temp1[16] = {0};
int Temp2[16];
int mymain()
{
static int Temp3[16] = {0};
static int Temp4[16];
usart_init();
put_s_hex("Temp1 is: ",Temp1[0]);
put_s_hex("Temp2 is: ",Temp2[0]);
put_s_hex("Temp3 is: ",Temp3[0]);
put_s_hex("Temp4 is: ",Temp4[0]);
while(1);
}
输出结果:
Temp1 is: 0x6BF87F8B
Temp2 is: 0x126577A9
Temp3 is: 0xC82AA8DC
Temp4 is: 0xE5EC5EF7
可以看到,上面四个变量的值是随机的,打开keil生成的.map文件(Listings目录下),可以看到,他们都是位于BSS段。
由于没有进行RAM中BSS段的清零,所以他们初值是随机的。
注意:若本属于BSS段的数据比较少,编译器会进行优化,把数据放在.data段。
int Temp1 = 0;
int Temp2;
int mymain()
{
static int Temp3 = 0;
static int Temp4;
usart_init();
put_s_hex("Temp1 is: ",Temp1);
put_s_hex("Temp2 is: ",Temp2);
put_s_hex("Temp3 is: ",Temp3);
put_s_hex("Temp4 is: ",Temp4);
while(1);
}
ZI段清零:
还是在启动文件中添加ZI段清零的代码:
IMPORT |Image$$RW_IRAM1$$ZI$$Base| ;ZI段基地址
IMPORT |Image$$RW_IRAM1$$ZI$$Length| ;ZI段长度
IMPORT memory_set
ldr r0, = |Image$$RW_IRAM1$$ZI$$Base|
mov r1, 0
ldr r2, = |Image$$RW_IRAM1$$ZI$$Length|
bl memory_set
输出结果:
Temp1 is: 0x00000000
Temp2 is: 0x00000000
Temp3 is: 0x00000000
Temp4 is: 0x00000000
可以看到,ZI段已经被清零了。
一般情况下,代码都是下载到Flash中,但是有时候为了提高执行速度,也会把代码拷贝到RAM中运行。
首先设置Keil,使用自定义的散列文件。
然后修改Objects
文件夹下的.sct文件。
LR_IROM1 0x08000000 0x00080000 { ; load region size_region
ER_IROM1 0x20000000 { ; load address = execution address
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
RW_IRAM1 + 0 { ; RW data,紧跟着ER_IROM1后面
.ANY (+RW +ZI)
}
}
这样,代码的链接地址就处于RAM中了。
在汇编中,使用不同的指令可以在Flash或RAM中跳转函数。
LDR PC,=main ;使用链接地址,运行RAM中代码
BL main ;运行Flash中代码
所以运行RAM中的代码,要使用第一种跳转方法,同时必须进行代码重定位,否则会跟RW-data重定位出现的问题一样,运行函数的之前,函数的链接地址中没有相应的指令,程序就会崩溃。
代码段的链接地址(基地址)、长度,使用下面的符号获得:
代码段的加载地址,使用下面的符号获得:
代码重定位的汇编代码与之前的RW-data重定位类似,
LDR R0, = |Image$$ER_IROM1$$Base|
LDR R1, = |Load$$ER_IROM1$$Base|
LDR R2, = |Image$$ER_IROM1$$Length|
BL memory_copy
LDR R0, =mymain
BLX R0
注意:注意:要把启动文件的第二个指令的地址改一下,否则程序上电后第二步执行Reset_Handler
时,是去RAM中执行,但此时RAM中并没有相应的Reset_Handler
代码,所以要先执行ROM中的Reset_Handler
,就是地址0x08000008。
__Vectors DCD 0
;DCD Reset_Handler ;直接跳转到RAM中执行,但是此时RAM中并没有相应的指令,所以程序崩溃
DCD 0x08000009 ;跳转到0x08000008执行,执行Flash中的Reset_Handler
重定位之前的代码是使用位置无关码写的,关于位置无关码和位置有关码的区别之前介绍过,这里不再讲解。
使用位置无关码,无论程序在哪里,都可以被CPU正确访问到。
1、只使用相对跳转指令:B、BL
2、不能用绝对跳转指令:
LDR R0, =main
BLX R0
3、不访问全局变量、静态变量、字符串、数组
4、重定位完后,使用绝对跳转指令跳转到XXX函数的链接地址去
BL main ; bl相对跳转,程序仍在Flash上运行
LDR R0, =main ; 绝对跳转,跳到链接地址去,就是跳去内存里执行
BLX R0
在汇编中,重定位需要的变量用|Image$$region_name$$Base|这样的格式表示。
在C语言中,对于一个变量,可以用extern
关键字声明为外部变量,然后直接引用即可。
对于变量,引用的时候要加上&:
extern int Image$$ER_IROM1$$Base;
extern int Load$$ER_IROM1$$Base;
extern int Image$$ER_IROM1$$Length;
memcpy(&Image$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);
对于数组,数组名就相当于地址,所以不用加&:
extern char Image$$ER_IROM1$$Base[];
extern char Load$$ER_IROM1$$Base[];
extern int Image$$ER_IROM1$$Length;
memcpy(Image$$ER_IROM1$$Base, Image$$ER_IROM1$$Length, &Load$$ER_IROM1$$Base);
现在把启动文件中的重定位代码删除,用C语言编写一个重定位函数relocate_c
,然后在启动文件中调用。
extern int Image$$ER_IROM1$$Base;
extern int Image$$ER_IROM1$$Length;
extern int Load$$ER_IROM1$$Base;
extern int Image$$RW_IRAM1$$Base;
extern int Image$$RW_IRAM1$$Length;
extern int Load$$RW_IRAM1$$Base;
extern int Image$$RW_IRAM1$$ZI$$Base;
extern int Image$$RW_IRAM1$$ZI$$Length;
void relocate_c(void)
{
/*代码段重定位*/
memcpy(&Image$$ER_IROM1$$Base, &Load$$ER_IROM1$$Base, &Image$$ER_IROM1$$Length);
/*RW-data重定位*/
memcpy(&Image$$RW_IRAM1$$Base, &Load$$RW_IRAM1$$Base, &Image$$RW_IRAM1$$Length);
/*清除ZI段*/
memset(&Image$$RW_IRAM1$$ZI$$Base, 0, &Image$$RW_IRAM1$$ZI$$Length);
}
1、把程序从flash加载到RAM需要bootloader,(其实程序也可以直接下载到RAM中运行,只不过重启程序就没有了。
2、单片机RAM较小程序太多无法加载全部程序。
3、单片机执行分三个步骤:取指令,分析指令,执行指令。取指令任务是根据PC值从地址总线上读出指令。虽然从RAM取指令速度远大于ROM,但单片机自身运行速度不高,所以程序放在哪里没有太大影响。
linux程序比较大,很少用norflash储存程序,而是用nandFlash或sd储存,这类储存不符合CPU 的指令译码执行要求。
运行linux系统的cpu,其运行频率非常高,远大于ROM读写速度,从ROM取指会严重影响速度。故系统会把程序拷贝到RAM执行。
所有运行Linux系统时程序必须加载到RAM
(7条消息) STM32裸机开发(6) — Keil-MDK下散列文件的分析_Willliam_william的博客-CSDN博客
(7条消息) STM32启动详细流程之__main_非常规自我实现的博客-CSDN博客
(7条消息) STM32代码烧写到哪里去了?是ROM?还是RAM?还是flash?它们都是啥?代码具体占了多少空间?超没超芯片的范围?KEI里如何设置芯片flash、RAM可用大小呢?_越过山丘呀的博客-CSDN博客
欢迎阅读《MDK的编译过程及文件类型全解》文档-by 秉火 — FLASH 1.0 文档 (flash-rtd.readthedocs.io)
(7条消息) 【IoT】STM32 启动代码 __main 与用户主程序 main() 的区别_产品人卫朋的博客-CSDN博客
(7条消息) 什么是重定位?为什么需要重定位?_cherisegege的博客-CSDN博客_重定位是什么意思
stm32中存在rom中的全局变量初始值是怎么copy到RAM区? (amobbs.com 阿莫电子论坛)
(7条消息) MDK __main()代码执行分析_TS_up的博客-CSDN博客___main