微信公众号:二进制人生
专注于嵌入式linux开发。问题或建议,请发邮件至[email protected]。
更新:2020/04/26。
图:二进制人生公众号
本文研究的是arm架构的函数帧栈,阅读者需要有arm汇编基础,不过本文涉及的汇编指令不是很多。
理论上来说,ARM的15个通用寄存器是通用的,但实际上并非如此,特别是在过程调用的过程中。
以下4个寄存器有特殊用途:
R11:frame pointer,FP寄存器
R12:IP寄存器,用于暂存SP
R13:stack pointer,SP寄存器
R14:link register,LR寄存器
R15:PC寄存器
我们知道每个进程都有自己的栈,实际上每个函数也有自己的栈(尽管这些栈在空间上是连续的,都是在进程的栈上)。而在ARM上,函数的栈帧是由SP寄存器和FP寄存器来界定的。
我们写一个小程序来观察下函数的帧栈。
1 2 3 4 5 6 7 8 9 10 11 12 13 | int fun(int a,int b) { int c = 1; int d = 2; return 0; } int main(int argc,char **argv) { int a = 0; int b = 1; fun(a,b); } |
画出帧栈变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | 高地址 ----- | fp -- main函数也是被人调用的,所以也要保存调用者的fp。 main_fp ----- | lr -- main里调用了fun,会修改了lr,所以lr也要入栈,如果main里没有调用fun,不会有lr入栈这一步 ----- | 局部变量a main ----- 的栈 | 局部变量b ----- | argc ----- | argv main_sp ----- | main_fp -- 保存main的fp fun_fp ----- | ----- |局部变量c fun的 ----- 栈 |局部变量d ----- | 形参a ----- | 形参b fun_sp ----- |.... 低地址 ------ |
1 |
fun调用返回前将sp更新为fun_fp,再pop fp,这两步操作同时还原了main的fp,sp。
局部变量存放于栈中,
调用函数fun,传递了两个形参,本质上是在函数fun的栈里开辟多两个空间。将main函数的栈里的两个局部变量传递给中间桥梁---寄存器r0和r1,(如果有第三个参数的话,那就是传给r3,依次类推)
调用fun时会把r0和r1赋值给fun栈相应的位置,完成传参,这就是为啥传递变量无法修改变量值的原因。
编译:
1 | arm-himix200-linux-gcc main.c |
反汇编:
1 | arm-himix200-linux-objdump a.out -d |
反汇编结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | 000103dc <fun>: 103dc: e52db004 push {fp} ; (str fp, [sp, #-4]!) 103e0: e28db000 add fp, sp, #0 ;更新fun函数栈起始地址 103e4: e24dd014 sub sp, sp, #20 ;为fun函数开辟栈 103e8: e50b0010 str r0, [fp, #-16] 103ec: e50b1014 str r1, [fp, #-20] ; 0xffffffec 103f0: e3a03001 mov r3, #1 ;局部变量1 103f4: e50b3008 str r3, [fp, #-8] 103f8: e3a03002 mov r3, #2 103fc: e50b300c str r3, [fp, #-12] ;局部变量2 10400: e3a03000 mov r3, #0 10404: e1a00003 mov r0, r3 10408: e28bd000 add sp, fp, #0 1040c: e49db004 pop {fp} ; (ldr fp, [sp], #4) 10410: e12fff1e bx lr 00010414 <main>: 10414: e92d4800 push {fp, lr} ;保存调用者的fp和lr,因为main后面调用了bl指令,会改变lr,所以需要将lr入栈 10418: e28db004 add fp, sp, #4 ;更新main函数栈起始地址 1041c: e24dd008 sub sp, sp, #8 ;为main函数开辟栈 10420: e3a03000 mov r3, #0 10424: e50b3008 str r3, [fp, #-8] ;局部变量1 10428: e3a03001 mov r3, #1 1042c: e50b300c str r3, [fp, #-12] ;局部变量2 10430: e51b100c ldr r1, [fp, #-12] ;形参1 10434: e51b0008 ldr r0, [fp, #-8] ;形参2 10438: ebffffe7 bl 103dc <fun> ;函数调用 1043c: e3a03000 mov r3, #0 10440: e1a00003 mov r0, r3 ;main函数返回值 10444: e24bd004 sub sp, fp, #4 ;还原调用者的sp 10448: e8bd8800 pop {fp, pc} ;还原调用者的fp,将lr赋值给pc,相当于b lr, |
如果fun里调用了其他函数,那么在一开始除了会将调用者的fp入栈之外,还会将链接寄存器lr入栈。链接寄存器在调用函数时保存了返回地址(即跳转指令的下一条指令的地址)。
平常的函数调用就是通过bl跳转指令来实现,有人可能见过另外一条跳转指令b。它们两者的区别是bl是带返回的跳转指令,即它跳转之前会自动把返回地址保存在链接寄存器,而b指令没有这以一功能,那就一去不复返了。
在函数调用结束会执行bx lr指令,完成返回。
在程序执行过程中(通常是发生了某种意外情况而需要进行调试),通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(母函数的SP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序,这就是所谓的栈回溯。
所以,假如有面试官问你什么是栈回溯,可以这样子回答他。
通过上面的简短程序,我们知道了arm汇编的一些规定:
1、形参通过r0、r1、…寄存器传递
2、函数返回值放在r0
3、函数调用通常使用bl指令,函数返回时调用bx lr
4、在被调用的函数里,会将调用者的fp入栈,退出时再将fp还原。如果函数内部会再次调用其他函数,lr寄存器也要入栈。
5、函数调用至少产生7条指令(即调用一个空的无返回值函数):
1 2 3 4 5 6 7 8 | bl 103dc <fun1> 000103dc <fun1>: push {fp} ; (str fp, [sp, #-4]!) add fp, sp, #0 nop ; (mov r0, r0) add sp, fp, #0 pop {fp} ; (ldr fp, [sp], #4) bx lr |
如果是非空函数,还需要为函数开辟栈:
sub sp, sp, #xxx ;为函数开辟栈xxx * 4字节的栈
退出时还原栈:
add sp, fp, #0
fp寄存器的是必要的吗?单靠sp寄存器来维持入栈和出栈,似乎也是可以的。
所以arm的gcc提供了选项-fomit-frame-pointer,用于在编译时忽略产生fp操作,这样编译出来的程序反汇编之后会少了操作fp的指令,可以优化程序执行速度以减少程序空间。
但这样的缺陷是出现段错误时无法进行栈回溯,所以效率与调试二者不可得兼。
我们在网上看到分析arm帧栈的文章,都引用了这幅图:
上面这个图是标准的过程调用下函数的帧栈。我们需要知道一个选项-mapcs-frame,它对所有函数都生成一个遵从ARM程序调用标准的堆栈帧,即使在正确执行代码无需严格这么做时。缺省情况下是“-mno-apcs-frame”。
-mapcs与“-mapcs-frame”相同。
所以只有指定了-mapcs-frame才会在函数调用时将pc、lr、sp、fp一股脑入栈,实际上如果函数里没有修改这些寄存器,是没有必要全部入栈的。现在的编译器出于效率考虑,都会选择性入栈。
编译时加了-fomit-frame-pointer选项后,反汇编结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | 00010410 <fun>: 10410: e52de004 push {lr} ; (str lr, [sp, #-4]!) 10414: e24dd014 sub sp, sp, #20 10418: e58d0004 str r0, [sp, #4] 1041c: e58d1000 str r1, [sp] 10420: e3a03001 mov r3, #1 10424: e58d300c str r3, [sp, #12] 10428: e3a03002 mov r3, #2 1042c: e58d3008 str r3, [sp, #8] 10430: e3a00002 mov r0, #2 10434: ebffff9f bl 102b8 <malloc@plt> 10438: e3a03000 mov r3, #0 1043c: e1a00003 mov r0, r3 10440: e28dd014 add sp, sp, #20 10444: e49df004 pop {pc} ; (ldr pc, [sp], #4) 00010448 <main>: 10448: e52de004 push {lr} ; (str lr, [sp, #-4]!) 1044c: e24dd00c sub sp, sp, #12 10450: e3a03000 mov r3, #0 10454: e58d3000 str r3, [sp] 10458: e3a03001 mov r3, #1 1045c: e58d3004 str r3, [sp, #4] 10460: e1a0300d mov r3, sp 10464: e59d1004 ldr r1, [sp, #4] 10468: e1a00003 mov r0, r3 1046c: ebffffe7 bl 10410 <fun> 10470: e3a03000 mov r3, #0 10474: e1a00003 mov r0, r3 10478: e28dd00c add sp, sp, #12 1047c: e49df004 pop {pc} ; (ldr pc, [sp], #4) |
结果自行分析。
有了本文的基础,我们后面会介绍栈回溯的原理。