一、前言
该文章不适合C语言初学者,适合懂一点汇编、单片机或者ARM开发技术的人,如果你依然对教科书上的栈区懵懵懂懂,看了无数SP指针的讲解却不能解其惑,那么这篇文章会帮你弄明白。
芯片以市面上开发板常用的2440为例子,该文章将详细讲解C语言函数从被调用开始到局部变量数据存储(也就是栈区)的整个过程,因此必须反汇编成汇编代码,其中涉及 ARM SP / FP/ IP / LR / PC寄存器。
写这篇文章的目的是为了填补我几年前学习时候的怨念,当时在百度搜索了“ARM fp寄存器的作用”得到的都是一些讲解模糊的答案后,我气愤地翻墙谷歌,终于在某国外技术论坛获得了翔实的讲解,现在工作中又遇到了类似的问题,想起当年往事,因此希望能填补国内网上资料的欠缺。
如需转载请注明出处,谢谢。
二、源代码讲解
是的,我将用一段源代码来讲解,细到每一行代码,这也是国外论坛那位技术大牛给我讲解的方式(再次感谢那位不知名的大牛,这件事情对我的触动非常大,我被国外浓厚的学习氛围给震惊了)。
运行环境为裸机,无操作系统,源码分为两部分,汇编引导部分,C语言部分。汇编引导是为了看到从汇编跳转到C语言的时候做了什么事情。
1.汇编引导部分 start.s
.text
.global _start
_start:
ldr sp, 4096 #设置栈底为4096(十进制,下面的讲解地址都用十进制)
bl main #跳转到C语言main 函数
上述汇编代码做了三件事情,第一件事是标注代码段text,剩下两件事在注释中都讲了。
这里如果不懂为什么要设置sp寄存器的话,我可以简单讲解一下。
SP寄存器全称Stack Pointer,意思就是栈指针,严格来说叫栈顶指针,永远指向栈的顶部。
这里顺带插一句栈的概念,栈被广泛运用于C语言函数的调用过程,由于先入后出的特性,使得它很容易保存C函数跳转前的寄存器状态,也就是保护现场。
ARM的局部数据寄存器只有4个,R0-R4,因此它没有那么多只手去存储每一个函数的局部变量,尤其是深层调用的情景(就是一个函数调用了另一个函数,另一个函数再调用了一个函数),那怎么办呢?这就到了栈的使用场景,栈是先入后出的,每一个函数除了代码段,实际上都有一个自己的栈区,用于存放局部变量,在深层调用中永远是最后一个被调用的函数先出栈,然后按照堆栈的数据一层一层出栈,就能顺利返回最初的函数,这有点像递归是不?事实上栈也能被用于递归,不过这就是题外话了。
于是,SP指针可以这样理解,在函数调用中,SP指针永远指向栈顶,也就是最后一个被调用函数的栈区。
这个讲解有点模糊是不?没关系,到了实际的代码你就会有深刻的感受了。
再提一点,ARM的栈是自减栈,栈是向下生长的,也就是栈底处于高地址处,栈顶处于低地址处,所以栈区一般都放在内存的顶端,我这里偷懒,直接使用了2440 4K的片内内存,所以栈底就是片内内存的顶端地址,4096。
内存布局大概是上图这样,图丑,请忍耐一下。
2.C语言部分 test.c
int main()
{
char a,b;
char c;
a=5,b=10;
c=a+b;
return 0;
}
一个比hello world还简单的程序,这个程序和那个老外给我的例子类似,但他说从另一个角度,它的难度和hello world是一样的,因为编译器巴拉巴拉巴啦(省略我听不懂的1W个字节)。
编译后(包括引导部分一起编译),反汇编展开,如下
00000000 <_start>:
0: e3a0da01 mov sp, #4096 ; 0x1000
4: ebffffff bl 8 <main>
00000008 <main>:
8: e1a0c00d mov ip, sp
c: e92dd800 stmdb sp!, {fp, ip, lr, pc}
10: e24cb004 sub fp, ip, #4 ; 0x4
14: e24dd004 sub sp, sp, #4 ; 0x4
18: e3a03005 mov r3, #5 ; 0x5
1c: e54b300d strb r3, [fp, #-13]
20: e3a0300a mov r3, #10 ; 0xa
24: e54b300e strb r3, [fp, #-14]
28: e55b200d ldrb r2, [fp, #-13]
2c: e55b300e ldrb r3, [fp, #-14]
30: e0823003 add r3, r2, r3
34: e54b300f strb r3, [fp, #-15]
38: e3a03000 mov r3, #0 ; 0x0
3c: e1a00003 mov r0, r3
40: e89da808 ldmia sp, {r3, fp, sp, pc}
再讲解代码之前,先补充一点汇编的知识,主要是两个指令stmdb和ldmia,这两个指令网上讲解得不算太好,我必须重新讲解一下,其它的指令上网可以找到,不再赘述。
stm/ldm 【多重内存写/读操作】
写操作的格式为:
Stm{写入方式} Rn{!} {^},{Rx list}
Rx list指的是要写入内存的寄存器
该指令的意思是把Rn作为基址寄存器,把Rx list寄存器中的内容按照写入方式依次写入Rn指向的内存中。
写入方式常见的有如下几种(注意寄存器的方向):
ia 先传后增 # Rx list中的数值按照寄存器R0-R15递增的方式(即使Rx list乱序也依然按照寄存器从小到大的方式来)写入Rn所指向的内存中,第一个数据写入前基址寄存器Rn不自加,第一个数据传输完成后基址寄存器才自加4,之后每次写入Rn都会自加
ib 先增后传 #和之前的IA类似,只不过在第一个数据写入前先让Rn加4,再进行写入操作
da 先传后减 # Rx list中的数值按照寄存器R15-R0递减的方式(即使Rx list乱序也依然按照寄存器从大到小的方式来写入Rn所指向的内存中,第一个数据写入前基址寄存器Rn不自减,第一个数据传输完成后基址寄存器才自减4,之后每次写入Rn都会自减
db 先减后传 #和之前的IA类似,只不过在第一个数据写入前先让Rn减4,再进行写入操作
传递顺序是在芯片手册里详细说明的的,绝非我臆想瞎猜。
The registers are transferred in the order lowest to highest, so R15 (if in the list) will always be
transferred last. The lowest register also gets transferred to/from the lowest memory address.
Rn表示基址寄存器。
Rn后的{!} {^}为可选项,
{!}的意思是该传输指令完全传递完成后,把最后的写入地址的首地址或者写入地址的首地址的下一个字长数据的首地址存入基址寄存器(由IA/DA/IB/DB方式决定)。
比如基址寄存器是SP,我采用先传后增的方式(IA)最后一个数据的写入地址是SP+8,那么执行完指令后,SP=SP+8+4=SP+12,如果采用先增后传,就是SP+8
{^}用于ldm指令
如 stmdb sp!,{r1,r2,r3,ip,fp}
表示数据按照fp-ip-r3-r2-r1的顺序依次写入sp指向的内存中(注意sp是依次递减的)
读操作的格式为
ldm{读取方式} Rn{!} {^},{Rx list}
该指令的意思把基址寄存器Rn所指向的内存地址中的数据根据读取方式依次拷贝给Rx list中的寄存器。
读取方式常见的有如下几种(注意寄存器的方向):
ia 先传后增 # Rn所指向的内存中的数据依次写入按照寄存器R0-R15递增的方式(即使Rx list乱序也依然按照寄存器从小到大的方式来)排列的寄存器中,第一个数据载入寄存器前基址寄存器Rn不自加,第一个数据传输完成后基址寄存器才自加4,之后每次读取Rn都会自加
ib 先增后传 #和之前的IA类似,只不过在第一个数据写入前先让Rn加4,再进行读取操作
da 先传后减 # Rn所指向的内存中的数据依次写入按照寄存器R15-R0递减的方式(即使Rx list乱序也依然按照寄存器从大到小的方式来)的寄存器中,第一个数据写入前基址寄存器Rn不自减,第一个数据传输完成后基址寄存器才自减4,之后每次写入Rn都会自减
db 先减后传 #和之前的IA类似,只不过在第一个数据读入前先让Rn减4,再进行读取操作
如 ldmia sp!,{r1,r2,r3,ip,fp}
表示sp中的内存数据按照r1-r2-r3-ip-fp的顺序依次存入这些寄存器中。
以上两个stmdb和ldmia可以很容易地组成内存栈,因此大量运用于子程序的调用。
下面截几个图,来看看当使用感叹号后,IA\IB\DA\DB这些后缀对基址寄存器Rn的影响,下面是R1,R5,R7从基址0x1000入栈的示意图(非常重要)
下面开始讲解数据流,先摆一张图,再根据这张图讲解每一行代码
2440是32位CPU,也就说它取数据是4字节4字节取的,所以你可以看到PC都是+4+4,栈内存地址也是-4-4-4这样排列的。
为什么最后一个内存地址是4092,而不是4096呢,因为内存是从0地址开始执行的,CPU一次取4字节,4092不仅包含4092,还包含4093,4094,4095,4096四个字节的内容,所以0-4092实际上包含到了4096,如果最后一个地址是4096,那么就会读到4100了。
第一行指令mov ip,sp,把 sp地址赋给ip,ip是2440的R12寄存器,当子程序被调用时,sp会存放在ip寄存器中,表示父函数的栈顶指针(当没有东西入栈时,栈底等于栈顶)这个操作是由编译器自动翻译的。
第二行stmdb指令把fp,ip,lr,pc寄存器的内容入栈,也就是按照内存排列所示,根据db的写入方式,所有内容写入完成后SP=4080,示意图如下,o-fp表示old-fp,o-ip表示old-ip,依次类推,这些就是保护现场的操作,以便于main函数执行完以后返回。pc就不讲了,fp寄存器全称是frame pointer,存的是子函数的栈底地址,这个之后你会看到,lr寄存器是linker pointer,我们在汇编引导代码使用了bl指令,b指令后面的l就表示跳转时把当前的pc地址的下一条指令暂存进lr寄存器(2440
有预读取指令的操作,在执行指令时会预读取下两个指令,但芯片手册没有说明LR的值是多少,因此可能是8也可能是16),等函数返回时,就会继续执行之后的代码。
第三条指令sub fp, ip, #4 把ip-4赋给fp,也就是4092,这就是main函数的栈底了,也是上一个函数的栈顶(没有上一个函数,所以是4092),明白fp寄存器的作用了吗,是父函数与子函数的分界线。
第四条指令sub sp, sp, #4,把sp指向sp-4,也就是4076,这个操作对应test.c里的char,a,b,c。没错,这也是编译器干的,是在给这三个变量分配内存(编译器:……)。
第五条指令mov r3, #5 ,给R3赋值5
第六条指令strb r3, [fp, #-13],strb是字节存储操作,把R3的值存入fp-13对应的内存地址中,fp-13是多少呢?4079,也就是4076那块内存的最高字节,也就是说编译器即使是字节读写也是遵循栈操作原则的哦,这也是编译器干的(编译器:我有句MMP你想听吗?)
上面两条指令,共同组成了a=5这个C语言操作。
第七条第八条指令类似,共同实现了b=10这个操作,并存放在了4078内存里。
第九条指令ldrb r2, [fp, #-13],是字节读取指令,把fp-13内存里值存入R2,也就是a的值,第十条指令同理,R3存入b的值,这个fp-13又是怎么来的呢,还是编译器算的(编译器:你全家死了)
第十一条指令,add r3, r2, r3 ,加法操作,a+b,结果存入R3
第十二条指令,strb r3, [fp, #-15],结果入栈,存入fp-15,也就是4097。
第十三条指令,mov r3, #0 ,把0存入r3
第十四条指令,mov r0,r3 ,让r0=r3,以上两条语句组成return 0,咦,为什么要第十三条指令呢?你灵光一闪,莫非……我发现了编译器可优化的地方?千万别乱来,因为默认r0存放的是返回值,而且return也并非完全是标准的32位类型的,return struct_100W_bytes_kill 正在阴险地看着你
最后一条指令,ldmia sp, {r3, fp, sp, pc},没有感叹号,注意,如果出栈所存放的Rx list中包含和基址寄存器相同的寄存器时,比如这里面的sp,是绝对不能用感叹号的,这个问题想一下你就能明白了,这里不解释了。这条指令它先把局部变量的值送给r3,虽然没什么卵用,但也得做啊,这是释放内存的过程,然后新的fp(简称new-fp)等于o-fp,new-sp=o-ip,new-pc=o-lr,这样就出栈成功了,但你一看,不对呀,入栈时候的PC呢?这货没卵用,直接被new-sp=o-ip给跳过了。
以上,就是出入栈全过程,你还可以试试更加复杂的函数跳转以及复杂结构体的内存分配,这里已经给你提供了引子,剩下的就待你自己发现了。
加油!