C语言栈区的讲解(基于ARM)以及ARM sp,fp寄存器的作用

一、前言

      该文章不适合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类似,只不过在第一个数据写入前先让Rn4,再进行写入操作

da   先传后减 # Rx list中的数值按照寄存器R15-R0递减的方式(即使Rx list乱序也依然按照寄存器从大到小的方式来写入Rn所指向的内存中,第一个数据写入前基址寄存器Rn不自减,第一个数据传输完成后基址寄存器才自减4,之后每次写入Rn都会自减

db   先减后传 #和之前的IA类似,只不过在第一个数据写入前先让Rn4,再进行写入操作

 传递顺序是在芯片手册里详细说明的的,绝非我臆想瞎猜。

  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类似,只不过在第一个数据写入前先让Rn4,再进行读取操作

da   先传后减 # Rn所指向的内存中的数据依次写入按照寄存器R15-R0递减的方式(即使Rx list乱序也依然按照寄存器从大到小的方式来)的寄存器中,第一个数据写入前基址寄存器Rn不自减,第一个数据传输完成后基址寄存器才自减4,之后每次写入Rn都会自减

db   先减后传 #和之前的IA类似,只不过在第一个数据读入前先让Rn4,再进行读取操作

 

ldmia sp!,{r1,r2,r3,ip,fp}

表示sp中的内存数据按照r1-r2-r3-ip-fp的顺序依次存入这些寄存器中。


以上两个stmdbldmia可以很容易地组成内存栈,因此大量运用于子程序的调用。

下面截几个图,来看看当使用感叹号后,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给跳过了。

以上,就是出入栈全过程,你还可以试试更加复杂的函数跳转以及复杂结构体的内存分配,这里已经给你提供了引子,剩下的就待你自己发现了。

加油!




<think>好的,我现在要分析ARM架构下C语言函数调用过程中帧的变化情况。首先,我需要回忆一下ARM架构的基本知识,特别是关于函数调用和帧的部分。ARM处理器使用寄存器来传递参数,这和x86架构有所不同。根据引用[1]和[2],ARM的函数调用过程涉及堆操作、寄存器的保存与恢复,以及特定的调用约定,比如ATPCS(ARM-Thumb Procedure Call Standard)。 首先,函数调用时,调用者(caller)需要将参数传递给被调用者(callee)。在x86的cdecl约定中,参数是从右向左压,但在ARM中,通常使用寄存器R0-R3来传递前四个参数,如果参数超过四个,剩余的参数会通过传递。这点需要注意,因为的使用方式可能不同。 接下来是返回地址的处理。在ARM中,当使用BL(Branch with Link)指令调用函数时,返回地址(即下一条指令的地址)会被保存在LR(Link Register,R14)中。然而,如果函数内部还会调用其他函数,就需要将LR的值保存到中,否则原来的返回地址会被覆盖,导致无法正确返回。因此,被调用函数通常会在入口处将LR压,同时在退出前从中恢复LR到PC(Program Counter),或者通过其他方式确保正确返回。 然后是帧的构建。每个函数在调用时都会创建一个帧,用于保存局部变量、临时数据以及需要保存的寄存器帧通常包括以下几个步骤:将旧的帧指针(FP,通常是R11)压,然后将当前的指针(SP)赋值给FP,接着调整SP以分配足够的空间供局部变量使用。在函数结束时,需要恢复原来的SPFP,并将LR弹出到PC,完成返回。 举个例子,假设函数A调用函数B。函数A在调用前,会将参数存入R0-R3(如果参数超过四个,后面的参数压),然后执行BL指令跳转到B的地址,同时LR保存返回地址。进入函数B后,B首先将LR和可能的其他需要保存的寄存器(如R4-R10等)压入中,然后调整SP分配空间。函数B执行完毕后,恢复之前保存的寄存器,并将LR的值弹出到PC,返回到A继续执行。 但需要注意,不同的编译器和优化选项可能会影响帧的具体结构。比如,如果函数B是叶子函数(不调用其他函数),可能不需要保存LR,直接通过BX LR返回。此外,ARM架构有多个变种(如ARM模式和Thumb模式),处理方式可能略有不同,需要具体分析。 现在,我需要验证这些步骤是否正确。根据引用[2]提到的ATPCS,确实规定了寄存器的使用规则,比如R0-R3作为参数和结果寄存器,R4-R11用于保存局部变量,需要由被调用者保存。的对齐方式也可能影响帧的大小和布局。 另外,帧分析通常涉及反汇编或查看编译器生成的汇编代码。例如,一个简单的C函数: void func(int a, int b) { int c = a + b; } 编译后的汇编可能包括保存FP和LR,调整SP,执行操作,然后恢复现场。通过分析具体的汇编指令,可以更清晰地看到的变化过程。 可能的疑问点:当传递结构体等复杂数据类型时,参数是如何传递的?根据ATPCS,较大的结构体可能会通过传递,而较小的可能通过寄存器。此外,返回值的处理方式,比如返回结构体时,可能通过R0返回指针,或者在中分配空间。 总结来说,分析帧变化需要关注参数传递方式、寄存器保存与恢复、指针的调整以及返回地址的处理。结合具体的汇编代码和调用约定,可以详细追踪每一步的变化情况。</think>### ARM架构下C语言函数调用帧变化分析 在ARM架构中,函数调用过程中的帧变化遵循ATPCS(ARM-Thumb Procedure Call Standard),具体步骤如下: #### 1. **参数传递** - 前4个参数通过寄存器$R0$-$R3$传递,若参数超过4个,剩余参数通过传递,按**从右向左**顺序压[^2]。 - 示例:调用函数`void func(a, b, c, d, e)`时,`a`-$R0$,`b`-$R1$,`c`-$R2$,`d`-$R3$,`e`通过传递。 #### 2. **调用指令与返回地址保存** - 使用`BL func`指令跳转至函数`func`,此时返回地址(下一条指令地址)存入$LR$(R14)。 - 若`func`内部调用其他函数,需保存$LR$到中,防止覆盖。 #### 3. **帧构建** - **入口操作**(函数`func`开始): ```assembly PUSH {R11, LR} ; 保存调用者的帧指针和返回地址 MOV R11, SP ; 设置当前帧指针R11 = SP SUB SP, SP, #N ; 分配N字节空间(局部变量/临时存储) ``` - **帧结构**: ``` | 高地址 | | 旧R11 | ← R11指向此处 | 旧LR | | 局部变量| | 额外参数| | ... | ← SP指向此处 | 低地址 | ``` #### 4. **寄存器保存** - 若函数使用$R4$-$R11$,需在入口通过`PUSH {R4-R7}`保存,并在退出前通过`POP {R4-R7}`恢复[^1]。 #### 5. **函数返回** - **出口操作**(函数`func`结束): ```assembly MOV SP, R11 ; 恢复指针SP POP {R11, LR} ; 恢复调用者的帧指针和返回地址 BX LR ; 跳转回调用者 ``` - 若未修改$LR$,可直接通过`BX LR`返回。 #### 6. **帧示例分析 假设函数`main`调用`add`: ```c int add(int a, int b) { return a + b; } int main() { int result = add(1, 2); return 0; } ``` 对应的汇编片段: ```assembly main: MOV R0, #1 ; a=1通过R0传递 MOV R1, #2 ; b=2通过R1传递 BL add ; 调用add,LR保存返回地址 ... add: PUSH {R11, LR} ; 保存帧指针和返回地址 MOV R11, SP ADD R0, R0, R1 ; 计算a+b MOV SP, R11 POP {R11, LR} ; 恢复帧 BX LR ; 返回 ``` #### 7. **帧变化图示** 调用`add`时的变化: ``` 调用前顶 → [main的局部变量...] 调用后顶 → [旧R11][旧LR][add的空间...] ``` ---
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值