【ARM 汇编基础速成7】ARM汇编之栈与函数
作者:互联网
原文地址:
https://www.jianshu.com/p/f3f771f8f65b
原文链接 https://azeria-labs.com/functions-and-the-stack-part-7/
在这部分我们将研究一篇独特的内存区域叫做栈,讲解栈的目的以及相关操作。除此之外,我们还会研究ARM架构中函数的调用约定。
栈
一般来说,栈是一片在程序/进程中的内存区域。这部分内存是在进程创建的时候被创建的。我们利用栈来存储一些临时数据比如说函数的局部变量,环境变量等。在之前的文章中,我们讲了操作栈的相关指令PUSH和POP。
在我们开始之前,还是了解一下栈的相关知识以及其实现方式吧。首先谈谈栈的增长,即当我们把32位的数据放到栈上时候它的变化。栈可以向上增长(当栈的实现是负向增长时),或者向下增长(当栈的实现是正向增长时)。具体的关于下一个32位的数据被放到哪里是由栈指针来决定的,更精确的说是由SP寄存器决定。不过这里面所指向的位置,可能是当前(也就是上一次)存储的数据,也可能是下一次存储时的位置。如果SP当前指向上一次存放的数据在栈中的位置(满栈实现),SP将会递减(降序栈)或者递增(升序栈),然后再对指向的内容进行操作。而如果SP指向的是下一次要操作的数据的空闲位置(空栈实现),数据会先被存放,而后SP会被递减(降序栈)或递增(升序栈)。
image
不同的栈实现,可以用不同情形下的多次存取指令来表示(这里很绕...):
栈类型 | 压栈(存储) | 弹栈(加载) |
---|---|---|
满栈降序(FD,Full descending) | STMFD(等价于STMDB,操作之前递减) | LDMFD(等价于LDM,操作之后递加) |
满栈增序(FA,Full ascending) | STMFA(等价于STMIB,操作之前递加) | LDMFA(等价于LDMDA,操作之后递减) |
空栈降序(ED,Empty descending) | STMED(等价于STMDA,操作之后递减) | LDMED(等价于LDMIB,操作之前递加) |
空栈增序(EA,Empty ascending) | STMEA(等价于STM,操作之后递加) | LDMEA(等价于LDMDB,操作之前递减) |
我们的例子中,使用的是满栈降序的栈实现。让我们看一个栈相关的例子。
/* azeria@labs:~$ as stack.s -o stack.o && gcc stack.o -o stack && gdb stack */
.global main
main:
mov r0, #2 /* 设置R0 */
push {r0} /* 将R0存在栈上 */
mov r0, #3 /* 修改R0 */
pop {r0} /* 恢复R0为初始值 */
bx lr /* 程序结束 */
在一开始,栈指针指向地址0xbefff6f8,代表着上一次入栈数据的位置。可以看到当前位置存储了一些值。
gef> x/1x $sp
0xbefff6f8: 0xb6fc7000
在执行完第一条指令MOV后,栈没有改变。在只执行完下一条PUSH指令后,首先SP的值会被减4字节。之后存储在R0中的值会被存放到SP指向的位置中。现在我们在看看SP指向的位置以及其中的值。
gef> x/x $sp
0xbefff6f4: 0x00000002
之后的指令将R0的值修改为3。然后我们执行POP指令将SP中的值存放到R0中,并且将SP的值加4,指向当前栈顶存放数据的位置。z最终R0的值是2。
gef> info registers r0
r0 0x2 2
(下面的动图展示了低地址在顶部的栈的变化情况)
image
栈被用来存储局部变量,之前的寄存器状态。为了统一管理,函数使用了栈帧这个概念,栈帧是在栈内用于存储函数相关数据的特定区域。栈帧在函数开始时被创建。栈帧指针(FP)指向栈帧的底部元素,栈帧指针确定后,会在栈上申请栈帧所属的缓冲区。栈帧(从它的底部算起)一般包含着返回地址(之前说的LR),上一层函数的栈帧指针,以及任何需要被保存的寄存器,函数参数(当函数需要4个以上参数时),局部变量等。虽然栈帧包含着很多数据,但是这其中不少类型我们之前已经了解过了。最后,栈帧在函数结束时被销毁。
下图是关于栈帧的在栈中的位置的抽象描述(默认栈,满栈降序):
image
来一个例子来更具体的了解下栈帧吧:
/* azeria@labs:~$ gcc func.c -o func && gdb func */
int main()
{
int res = 0;
int a = 1;
int b = 2;
res = max(a, b);
return res;
}
int max(int a,int b)
{
do_nothing();
if(a<b)
{
return b;
}
else
{
return a;
}
}
int do_nothing()
{
return 0;
}
在下面的截图中我们可以看到GDB中栈帧的相关信息:
image
可以看到上面的图片中我们即将离开函数max(最下面的反汇编中可以看到)。在此时,FP(R11)寄存器指向的0xbefff254就是当前栈帧的底部。这个地址对应的栈上(绿色地址区域)位置存储着0x00010418这个返回地址(LR)。再往上看4字节是0xbefff26c。可以看到这个值是上层函数的栈帧指针。在0xbefff24c和0xbefff248的0x1和0x2是函数max执行时产生的局部变量。所以栈帧包含着我们之前说过的LR,FP以及两个局部变量。
函数
在开始学习ARM下的函数前,我们需要先明白一个函数的结构:
- 序言准备(Prologue)
- 函数体
- 结束收尾(Epilogue)
序言的目的是为了保存之前程序的执行状态(通过存储LR以及R11到栈上)以及设定栈以及局部函数变量。这些的步骤的实现可能根据编译器的不同有差异。通常来说是用PUSH/ADD/SUB这些指令。举个例子:
push {r11, lr} /* 保存R11与LR */
add r11, sp, #4 /* 设置栈帧底部,PUSH两个寄存器,SP加4后指向栈帧底部元素 */
sub sp, sp, #16 /* 在栈上申请相应空间 */
函数体部分就是函数本身要完成的任务了。这部分包括了函数自身的指令,或者跳转到其它函数等。下面这个是函数体的例子。
mov r0, #1 /* 设置局部变量(a=1),同时也是为函数max准备参数a */
mov r1, #2 /* 设置局部变量(b=2),同时也是为函数max准备参数b */
bl max /* 分支跳转调用函数max */
上面的代码也展示了调用函数前需要如何准备局部变量,以为函数调用设定参数。一般情况下,前四个参数通过R0-R3来传递,而多出来的参数则需要通过栈来传递了。函数调用结束后,返回值存放在R0寄存器中。所以不管max函数如何运作,我们都可以通过R0来得知返回值。而且当返回值位64位值时,使用的是R0与R1寄存器一同存储64位的值。
函数的最后一部分即结束收尾,这一部分主要是用来恢复程序寄存器以及回到函数调用发生之前的状态。我们需要先恢复SP栈指针,这个可以通过之前保存的栈帧指针寄存器外加一些加减操作做到(保证回到FP,LR的出栈位置)。而当我们重新调整了栈指针后,我们就可以通过出栈操作恢复之前保存的寄存器的值。基于函数类型的不同,POP指令有可能是结束收尾的最后一条指令。然而,在恢复后我们可能还需要通过BX指令离开函数。一个收尾的样例代码是这样的。
sub sp, r11, #4 /* 收尾操作开始,调整栈指针,有两个寄存器要POP,所以从栈帧底部元素再减4 */
pop {r11, pc} /* 收尾操作结束。恢复之前函数的栈帧指针,以及通过之前保存的LR来恢复PC。 */
总结一下:
- 序言设定函数环境
- 函数体实现函数逻辑功能,将结果存到R0
- 收尾恢复程序状态,回到调用发生的地方。
关于函数,有一个关键点我们要知道,函数的类型分为叶函数以及非叶函数。叶函数是指函数中没有分支跳转到其他函数指令的函数。非叶函数指包含有跳转到其他函数的分支跳转指令的函数。这两种函数的实现都很类似,当然也有一些小不同。这里我们举个例子来分析一下:
/* azeria@labs:~$ as func.s -o func.o && gcc func.o -o func && gdb func */
.global main
main:
push {r11, lr} /* Start of the prologue. Saving Frame Pointer and LR onto the stack */
add r11, sp, #4 /* Setting up the bottom of the stack frame */
sub sp, sp, #16 /* End of the prologue. Allocating some buffer on the stack */
mov r0, #1 /* setting up local variables (a=1). This also serves as setting up the first parameter for the max function */
mov r1, #2 /* setting up local variables (b=2). This also serves as setting up the second parameter for the max function */
bl max /* Calling/branching to function max */
sub sp, r11, #4 /* Start of the epilogue. Readjusting the Stack Pointer */
pop {r11, pc} /* End of the epilogue. Restoring Frame pointer from the stack, jumping to previously saved LR via direct load into PC */
max:
push {r11} /* Start of the prologue. Saving Frame Pointer onto the stack */
add r11, sp, #0 /* 设置栈帧底部,PUSH一个寄存器,SP加0后指向栈帧底部元素 */
sub sp, sp, #12 /* End of the prologue. Allocating some buffer on the stack */
cmp r0, r1 /* Implementation of if(a<b) */
movlt r0, r1 /* if r0 was lower than r1, store r1 into r0 */
add sp, r11, #0 /* 收尾操作开始,调整栈指针,有一个寄存器要POP,所以从栈帧底部元素再减0 */
pop {r11} /* restoring frame pointer */
bx lr /* End of the epilogue. Jumping back to main via LR register */
上面的函数main以及max函数,一个是非叶函数另一个是叶函数。就像之前说的非叶函数中有分支跳转到其他函数的逻辑,函数max中没有在函数体逻辑中包含有这类代码,所以是叶函数。
除此之外还有一点不同是两类函数序言与收尾的实现是有差异的。来看看下面这段代码,是关于叶函数与非叶函数的序言部分的差异的:
/* A prologue of a non-leaf function */
push {r11, lr} /* Start of the prologue. Saving Frame Pointer and LR onto the stack */
add r11, sp, #4 /* Setting up the bottom of the stack frame */
sub sp, sp, #16 /* End of the prologue. Allocating some buffer on the stack */
/* A prologue of a leaf function */
push {r11} /* Start of the prologue. Saving Frame Pointer onto the stack */
add r11, sp, #0 /* Setting up the bottom of the stack frame */
sub sp, sp, #12 /* End of the prologue. Allocating some buffer on the stack */
一个主要的差异是,非叶函数需要在栈上保存更多的寄存器,这是由于非叶函数的本质决定的,因为在执行时LR寄存器会被修改,所以需要保存LR寄存器以便之后恢复。当然如果有必要也可以在序言期保存更多的寄存器。
下面这段代码可以看到,叶函数与非叶函数在收尾时的差异主要是在于,叶函数的结尾直接通过LR中的值跳转回去就好,而非叶函数需要先通过POP恢复LR寄存器,再进行分支跳转。
/* An epilogue of a leaf function */
add sp, r11, #0 /* Start of the epilogue. Readjusting the Stack Pointer */
pop {r11} /* restoring frame pointer */
bx lr /* End of the epilogue. Jumping back to main via LR register */
/* An epilogue of a non-leaf function */
sub sp, r11, #4 /* Start of the epilogue. Readjusting the Stack Pointer */
pop {r11, pc} /* End of the epilogue. Restoring Frame pointer from the stack, jumping to previously saved LR via direct load into PC */
最后,我们要再次强调一下在函数中BL和BX指令的使用。在我们的示例中,通过使用BL指令跳转到叶函数中。在汇编代码中我们使用了标签,在编译过程中,标签被转换为对应的内存地址。在跳转到对应位置之前,BL会将下一条指令的地址存储到LR寄存器中这样我们就能在函数max完成的时候返回了。
BX指令在被用在我们离开一个叶函数时,使用LR作为寄存器参数。刚刚说了LR存放着函数调用返回后下一条指令的地址。由于叶函数不会在执行时修改LR寄存器,所以就可以通过LR寄存器跳转返回到main函数了。同样BX指令还会帮助我们切换ARM/Thumb模式。同样这也通过LR寄存器的最低比特位来完成,0代表ARM模式,1代表Thumb模式。
最后,这张动图阐述了非叶函数调用叶函数时候的内部寄存器的工作状态。
作者:Arnow117
链接:https://www.jianshu.com/p/f3f771f8f65b
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
下面自己来总结一下几个寄存器:
PC: 指向当前执行指令的地址
SP 指向当前栈的栈顶位置。程序运行会开辟一个栈空间。然后在每一个函数,会在栈内开辟栈帧
P11/FP: 指向当前栈帧的底部。在arm中是向上申请空间,所以申请到空间的地址编号是越来越小的。也就是说栈帧底部是当前栈帧中最大的地址(在函数创建的时候,就会对应生成一个栈帧,用来存放函数中的所有数据)
LR:保存返回地址。就是本函数执行完之后要跳转的位置。
接下来看几个函数执行的真实例子
########################################################################################################################################################################################################
例1:
这是我们的一个子函数mfun1.。main函数掉员工子函数。main函数跟这里无关,就不贴出来了。但是main函数中设置了自己的SP和R11.
这里我们为每一条指令标了号。如图:1-17 。这里一共是17条指令:
下面来介绍下每条指令的意思:
SP 是main函数的数栈的栈顶。R11是main函数函数栈的栈底。这里在本函数调用前就确定了。
1、保存main函数栈底。这里R11是main函数的栈底。需要保存到SP - 4 的位置(FF9AE268-4 = FF9AE246).同时注意后面感叹哈号。需要更改SP寄存器为 FF9AE246
2、设置当前的函数的栈底R11位置。这里就直接是SP + 0 。也就是本函数的栈底为 FF9AE246
3、这里做个减法其实是在开辟栈帧的空间。这个空间就是函数特有的自己的空间。0xC就是十进制12.就是开辟了12字节的空间。3*4 = 12 这里有3个参数。后面可以看到a,b,c三个参数都占了4个字节。这时候SP 指向的是FF9AE258 这个位置。
如此;函数的初始化完成。指令4开始是函数的逻辑。这里不予介绍。此时栈的状态。如下:
大致的可以看到:
FF9AE258(fun1 函数 SP指向的位置)
......(这一块是func1函数的栈)
FF9AE264(fun1 函数 R11指向的位置)
FF9AE268(main 函数 SP指向的位置)
......(这一块是main函数的栈)
FF9AE27C(main 函数 R11指向的位置)
程序持续运行运行完成 指令14 mov R0,R3.
这里其实就已经完成函数的所有逻辑了。R0中保存着函数的返回值。
此时我们再来看看栈的状态。
main函数栈的部分没有改动。当前的函数中保存的main函数R11也还在。
接下来15、16 指令就是完成栈的回收工作了。
15、执行完之后。SP 和 R11相等了。这里都等于 FF9AE264。
也就是说
FF9AE258(fun1 函数 SP指向的位置)
......(这一块是func1函数的栈)
FF9AE264(fun1 函数 R11指向的位置)
这一块空间被释放了。
16、这里是设置R11为FF9AE260 。一个新为位置。最后是函数BX返回。
########################################################################################################################################################################################################
例2
这个跟前面相比多了参数,以及函数的内部还有调用另外函数
来看看我们的执行流程吧。
同样的是main函数调用 fun2。所以在调用fun2前。mian函数的SP和R11已经确定了。
同样是
FF9AE268(main 函数 SP 指向的位置)
......(这一块是main函数的栈)
FF9AE27C(main 函数 R11指向的位置)
此时的栈
接下来执行
指令1 STMFD SP!, {R4,R11,LR}
就是将main函数的
LR ---> F773E4F0
R11 ---> FF9AE27C
R4 ---> FF9AE2BC
依次都保存到栈中。
同时注意感叹号。这里的SP 函数栈顶变成 FF9AE25C
这里相比之前多了保存LR是因为这里的fun2中调用了其他的函数。保存LR是后面返回的时候需要用到。LR这里的F773E4F0 其实是指向main中的下一条指令。也就是fun2 之后返回需要执行的第一条指令的位置。
执行指令 2
ADD R11, SP, #8
这里是设置函数栈底R11.此时的 SP 函数栈顶变成 FF9AE25C。因为前面入栈了 3*4 = 12 字节的数据。
这里用一个加法 就是要让SP指针往下游走。8 是游走8个字节。如何确定常熟8?看前一条指令。STMFD 这个指令入栈了3个寄存器的值。每个寄存器里面有4字节数据。所以是入栈了12字节的值。
main指令的栈顶指向的 FF9AE268
然后入栈了3个 寄存器的值。我们的函数栈底需要设置在 FF9AE268 - 4 = FF9AE264的位置上。
于是后可以这么算 (入栈寄存器的数量 -1) *4 就是栈底的偏移位置。
这里就是(3-1)* 4 = 8
执行完成后。毫无疑问 R11 就是新函数栈底。设置在 FF9AE264 位置上.
指令3 SUB SP, SP, #0xC
同样的设置了栈底之后。需要蛇者栈。也就是开辟栈的空间。#0xC 也就是十进制12.字节。所以SP 此时指向FF9AE250
于是fun2的初始化完成了。此时大致可以看到
FF9AE250(fun2 函数 SP指向的位置)
......(这一块是func2函数的栈)
FF9AE264(fun2 函数 R11指向的位置)
FF9AE268(main 函数 SP指向的位置) =
......(这一块是main函数的栈)
FF9AE27C(main 函数 R11指向的位置)
接下去就是函数的逻辑内容。这里不详细分析。
要注意的是。因为函数中调用了其他函数。所以LR 是会更更改的。
执行到指令 12 MOV R0, R3 这里得到函数fun2的返回值。意味着函数fun2的逻辑正式运行完成。接下来就是fun2的函数空间释放问题。
指令13 SUB SP, R11, #8
这条指令是跟 指令2 ADD R11, SP, #8 相对应的。
R11 是fun2 的栈底指针。当前是FF9AE264 .这里就是设置SP 为 FF9AE264 - 8 = FF9AE25C位置上。移到这里的以为也很明显。就是反着走初始化的流程。将SP指向上层main函数的sp栈顶指针+ 12字节为位置。
指令14 LDMFD SP!, {R4,R11,PC}
这个指令很明显是接着指令13执行的。上面说到SP指针的移动。特意补上了8个字节。就因为要执行指令14。要出栈3个寄存器的值。刚好就是12个字节。
于是执行这条指令。sp的值增加12字节。最后指向main 函数的SP指针。也就是 FF9AE268 的位置。
至此。func2 函数执行完成,栈帧数据也成功释放出去。
########################################################################################################################################################################################################
例3
这个函数相对于上面两个例子来说,不仅在内部调用其他函数。而且还有超过4个的参数。在arm中,少于4个参数的函数会默认使用寄存器R0,R1,R2,R3 来传递参数。但是如果函数的参数多于4个,就需要使用栈帧来传递函数了。这样在函数的执行前不仅需要将上层调用函数的栈帧底部R11,LR等寄存器压入栈帧中,还需要将函数参数也压入栈帧中。不过这里有个需要注意的地方。参数是在main中进行传递的。因此,是压入main函数的栈帧中。
在main中,如果我们这样调用fun3:
fun3(1,2,3,4,5,6);
那么对应的fun3的arm代码应该是这样的:
这里通过R0-R3 依次传递前面4个参数。后面的两个参数就要依靠栈来传递。比如这里运行到指令9时。栈的状态如下:
这里main 函数的栈底R11指向的是 FFDB164C ,栈顶SP指向的是的是FFDB1638。R11根SP之间的内容就是main用到的空间。
这里注意下FFDB1638 保存了一个数值5,FFDB163C保存了数值6。这其实就是我们调用fun3需要的第5,第6个常数。这里已经压入main 的栈中了。 另外可以看到,R0-R3也依次存放着前面4个参数的值。
我们再来看看fun3 的arm 代码:
执行指令1 STMFD SP!, {R11,LR}
同样将main 的LR,R11压入栈中。这里的R11 是 FDB164C, LR指向的是main中fun3执行返回后的第一条指令。也就是F70FC514的位置。注意入栈之后的SP是指向现在的 FFDB1630位置。
执行指令2 ADD R11, SP, #4
因为指令1入栈了2个寄存器。所以是压入了2*4 = 8个字节。这里R11是要指向当前fun3函数栈底的。也就是FFDB1634 的位置。所以这里要ADD 4。
指令3 SUB SP, SP, #0x18
这里的0x18也就是十进制的24,也就是说还要开辟24个字节的空间。 4*6 = 24,所以SP的位置在 FFDB1630 基础上还要往上走24. 。视图中往上蹦6层
至此。我们新函数的函数栈就搭建起来了。
FFDB1638 ~ FFDB164C 之间是main函数的空间。
FFDB1638 同时也是栈顶SP指向的位置。 FFDB164C 是栈底R11指向的位置
栈顶的 FFDB1638 ,FFDB163C位置上依旧保存着main函数需要传递到fun3中的参数5,和参数6.
FFDB1634 ~ FFDB1618 之间是fun3函数的空间。
该空间由两次操作创建的。第一次是 STMFD 的入栈。入栈了2个寄存器,8字节大小。第二次是SUB 指令修改SP指针位置创建,开辟了24字节大小。所以一共就是32(4*8,视图中一共8层)字节。
至此,初始化工作完成,接下来执行函数逻辑。这里不关注中间逻辑直接跳到
k= 4
j= 8
指令28 LDR R3, [R11,#k]
指令30 LDR R3, [R11,#j]
关注这两条指令是因为这两条指令是从main 函数栈中取出第5,第6个参数。这里的R11 是指向fun3函数的 栈底。要访问到main函数的的内容。需要R11往下走。这里偏移4,偏移8 刚好就是第5,6个参数的位置。
指令33 SUB SP, R11, #4
这里是将fun3函数的栈底R11往上走4个字节。这是与前面的 ADD R11, SP, #4 相对应的。执行完成之后。sp会指向到FFDB1630位置。刚好也就是将 SUB SP, SP, #0x18 这个指令 申请的24 字节的空间释放出去了。
最后是指令 LDMFD SP!, {R11,PC}
这个指令将 FFDB1630 和 FFDB1634 这8个字节的的空间释放出去。至此。fun3 的函数的所有工作执行完成,然后返回到main函数。返回的位置就是最先设置的LR寄存器,也就是F7482514 的地方。
lin___ 发布了24 篇原创文章 · 获赞 10 · 访问量 16万+ 私信 关注
标签:汇编,SP,函数,R11,之栈,指令,LR,main,ARM 来源: https://blog.csdn.net/lin___/article/details/103816918