arm架构函数帧栈分析【转】

转自:https://www.codenong.com/cs105961527/

微信公众号:二进制人生
专注于嵌入式linux开发。问题或建议,请发邮件至hjhvictory@163.com。
更新: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)

结果自行分析。

 

 

有了本文的基础,我们后面会介绍栈回溯的原理。

posted @ 2022-02-12 02:00  Sky&Zhang  阅读(932)  评论(0编辑  收藏  举报