用树莓派分析函数调用栈

理解本篇文章需要具备一些GDB、汇编、寄存器的基础知识。可以在阅读的过程中碰到不理解的地方再针对性的学习。

寄存器

分析函数调用栈涉及到的几个特殊用途的寄存器如下:

ARM X86 用途
r11(fp) rbp(ebp) 栈帧指针
r13(sp) rsp(esp) 栈顶指针
r14(lr) N/A 返回地址
r15(pc) rip 指令指针(程序计数器)

函数调用栈

如下图(《程序员的自我修养》图10-4)所示:

图中,栈帧指针(ebp)指向的内存空间中保存的是上一个栈的栈帧指针(old ebp)。这是X86的情形,在树莓派中分析函数调用栈时发现,ARM的栈帧指针(fp)指向的是函数返回地址。

这只是不同架构CPU的底层实现的不同,并没有优劣之分。

入栈过程

一个函数的调用过程可以分为如下几步:

  • 首先压栈的是参数,且从右向左依次压栈;
  • 接着压入返回地址;
  • 接着被调函数执行“标准开头”(x86):
1push rbp
2mov rbp rsp

“标准开头”执行过程如下:

  • 首先rbp入栈;
  • rbp入栈后,rsp自动加8(64位),rsp此时指向存放rbp的栈帧地址;
  • 接着令%rbp=%rsp,这就使得rbp指向存放着上一个栈的rbp的内存地址。

而ARM(32位)的“标准开头”长这样:

1push {fp, lr}
2add fp, sp, #4
  • 返回地址(lr)入栈
  • 栈帧指针(fp)入栈
  • 接着令%fp=%sp+4,也就是使fp(栈帧指针)指向存放返回地址的内存

不论栈帧指针指向的是上一个栈帧指针,还是返回地址,都能通过函数的栈帧指针偏移找到调用函数的地址,因此根据栈帧指针的链式关系,可以回溯出整个函数的调用关系链。这对于一些复杂问题的定位是非常有帮助的。

GCC的编译选项--fomit-frame-pointer可以使程序不使用栈帧指针,而使用栈指针顶定位函数的局部变量、参数、返回地址等。这么做的好处是可以多出一个寄存器(栈帧指针)供使用,程序运行速度更快,但是就没发很方便的使用GDB进行调试了。

出栈过程

出栈与入栈动作刚好相反。

x86的“标准结尾”如下:

1leaveq
2retq

实际上leaveq内部分为两条指令:

1movq %rbp, %rsp
2popq %rbp

所以,出栈过程可以分解为如下三步:

  • 第一步是通过将rbp地址赋给rsp,即此时rsp指向的内存存放的是上一个栈的rbp。
  • 第二步弹出栈顶的数据到rbp中,即rbp指向上一个栈的栈底,出栈动作导致rsp自增,于是rsp此时指向的内存中存放函数返回地址;
  • 第三步通过retq指令将栈顶地址pop到rip,即rip此时指向函数退出后的下一条指令,rsp则指向上一个栈的栈顶。

这三步做完后,rsp、rbp、rip就恢复到调用函数以前的现场。

ARM的行为和x86一致,它的“标准结尾”长这样:

1sub sp, fp, #4
2pop {fp, pc}

基于树莓派3分析函数调用栈

我在树莓派3中运行了如下所示的C语言代码,并用GDB进行了调试:

树莓派3使用的是32位、arm架构CPU,因此下面的调试过程涉及到的寄存器以及地址信息和64位x86 CPU不同

 1#include <stdio.h>
 2
 3void test2(int i)
 4{
 5    int ii;
 6    ii = i;
 7}
 8
 9char test(char c)
10{
11    int i;
12    printf("%c",c);
13    test2(i);
14    return c;
15}
16
17int main()
18{
19    char c = 'a';
20    char ret;
21    ret = test(c);
22    return 0;
23}

分析函数调用(入栈)过程

使用GDB进行调试,将断点打在main函数调用test之前,并使用disassemble查看反汇编结果:

 1(gdb) b *0x000104bc
 2Breakpoint 2 at 0x104bc: file main.c, line 21.
 3(gdb) disassemble /m main
 4Dump of assembler code for function main:
 518 {
 6   0x000104a0 <+0>: push {r11, lr}
 7   0x000104a4 <+4>: add r11, sp, #4
 8   0x000104a8 <+8>: sub sp, sp, #8
 9
1019 char c = 'a';
11   0x000104ac <+12>: mov r3, #97 ; 0x61
12   0x000104b0 <+16>: strb r3, [r11, #-5]
13
1420 char ret;
1521 ret = test(c);
16   0x000104b4 <+20>: ldrb r3, [r11, #-5]
17   0x000104b8 <+24>: mov r0, r3
18=> 0x000104bc <+28>: bl 0x10468 <test>
19   0x000104c0 <+32>: mov r3, r0
20   0x000104c4 <+36>: strb r3, [r11, #-6]
21
2222 return 0;
23   0x000104c8 <+40>: mov r3, #0
24
2523 }
26   0x000104cc <+44>: mov r0, r3
27   0x000104d0 <+48>: sub sp, r11, #4
28   0x000104d4 <+52>: pop {r11, pc}
29
30End of assembler dump.

查看此时栈帧指针和栈顶指针的值:

1(gdb) i r r11 sp
2r11            0x7efffaec 2130705132
3sp             0x7efffae0 0x7efffae0
4(gdb) x /xw 0x7efffaec
50x7efffaec: 0x76e8f678
6(gdb) info symbol 0x76e8f678
7__libc_start_main + 276 in section .text of /lib/arm-linux-gnueabihf/libc.so.6

可以看到,栈帧指针指向的返回地址是__libc_start_main + 276,即main函数是由__libc_start_main调用的

由前面分析得知,栈帧指针-4地址处存放的是上一个函数的栈帧指针,于是我们继续向上追溯__libc_start_main的调用者地址,可以发现其值为0:

1(gdb) x /xw 0x7efffaec-4
20x7efffae8: 0x00000000

因此可以认为__libc_start_main是所有进程真正的起点。

接着执行调用test函数的命令,使用si单步运行,并查看汇编指令:

 1(gdb) si
 2test (c=0 '\000') at main.c:10
 310 {
 4(gdb) disassemble
 5Dump of assembler code for function test:
 6=> 0x00010468 <+0>: push {r11, lr}
 7   0x0001046c <+4>: add r11, sp, #4
 8   0x00010470 <+8>: sub sp, sp, #16
 9   0x00010474 <+12>: mov r3, r0
10   0x00010478 <+16>: strb r3, [r11, #-13]
11   0x0001047c <+20>: ldrb r3, [r11, #-13]
12   0x00010480 <+24>: mov r0, r3
13   0x00010484 <+28>: bl 0x10300 <putchar@plt>
14   0x00010488 <+32>: ldr r0, [r11, #-8]
15   0x0001048c <+36>: bl 0x10440 <test2>
16   0x00010490 <+40>: ldrb r3, [r11, #-13]
17   0x00010494 <+44>: mov r0, r3
18   0x00010498 <+48>: sub sp, r11, #4
19   0x0001049c <+52>: pop {r11, pc}
20End of assembler dump.
21(gdb) i r $lr
22lr             0x104c0 66752
23(gdb) info symbol $lr
24main + 32 in section .text of /root/main

可以看到此时lr寄存器中保存的指令即调用test后的下一条指令。继续向下执行:

1(gdb) ni
20x0001046c 10 {
3(gdb) i r r11 sp
4r11            0x7efffaec 2130705132
5sp             0x7efffad8 0x7efffad8

观察到将r11和lr入栈后,sp减少了8字节,不难猜测,高4字节存放了lr的值(返回地址),低4字节存放了sp的值(上一个栈的栈帧指针):

1(gdb) x /xw 0x7efffad8
20x7efffad8: 0x7efffaec
3(gdb) x /xw 0x7efffadc
40x7efffadc: 0x000104c0
5(gdb) i r $lr $r11
6lr             0x104c0 66752
7r11            0x7efffaec 2130705132

继续执行:

1(gdb) ni
20x00010470 10 {
3(gdb) i r $r11
4r11            0x7efffadc 2130705116

此时r11指向的是函数返回地址,而不是像x86一样指向上一个栈帧指针,和前面所说的一致。

分析函数返回(出栈)过程

test函数的汇编指令如下所示:

 1(gdb) disassemble /m test
 2Dump of assembler code for function test:
 310 {
 4   0x00010468 <+0>:	push	{r11, lr}
 5   0x0001046c <+4>:	add	r11, sp, #4
 6   0x00010470 <+8>:	sub	sp, sp, #16
 7   0x00010474 <+12>:	mov	r3, r0
 8   0x00010478 <+16>:	strb	r3, [r11, #-13]
 9
1011		int i;
1112		printf("%c",c);
12   0x0001047c <+20>:	ldrb	r3, [r11, #-13]
13   0x00010480 <+24>:	mov	r0, r3
14   0x00010484 <+28>:	bl	0x10300 <putchar@plt>
15
1613		test2(i);
17   0x00010488 <+32>:	ldr	r0, [r11, #-8]
18   0x0001048c <+36>:	bl	0x10440 <test2>
19
2014		return c;
21   0x00010490 <+40>:	ldrb	r3, [r11, #-13]
22
2315	}
24   0x00010494 <+44>:	mov	r0, r3
25=> 0x00010498 <+48>:	sub	sp, r11, #4
26   0x0001049c <+52>:	pop	{r11, pc}
27
28End of assembler dump.

函数运行完毕进入出栈流程的执行过程分为如下几步:

  • 首先通过 sub sp, r11, #4 将栈顶指针指向上一个栈帧指针
  • 接着通过 pop {r11, pc} 将上一个栈帧指针赋值给r11,并将返回地址赋值给pc
  • 两次pop后,栈顶指针自动往栈底方向退两次

最终,栈顶指针(sp)、栈帧指针(r11)和指令指针(pc)都还原成了main函数调用test前的样子,用GDB查看寄存器内容证实了这一点:

 1(gdb) disassemble 
 2Dump of assembler code for function main:
 3   0x000104a0 <+0>:	push	{r11, lr}
 4   0x000104a4 <+4>:	add	r11, sp, #4
 5   0x000104a8 <+8>:	sub	sp, sp, #8
 6   0x000104ac <+12>:	mov	r3, #97	; 0x61
 7   0x000104b0 <+16>:	strb	r3, [r11, #-5]
 8   0x000104b4 <+20>:	ldrb	r3, [r11, #-5]
 9   0x000104b8 <+24>:	mov	r0, r3
10   0x000104bc <+28>:	bl	0x10468 <test>
11=> 0x000104c0 <+32>:	mov	r3, r0
12   0x000104c4 <+36>:	strb	r3, [r11, #-6]
13   0x000104c8 <+40>:	mov	r3, #0
14   0x000104cc <+44>:	mov	r0, r3
15   0x000104d0 <+48>:	sub	sp, r11, #4
16   0x000104d4 <+52>:	pop	{r11, pc}
17End of assembler dump.
18(gdb) i r r11 sp pc
19r11            0x7efffaec	2130705132
20sp             0x7efffae0	0x7efffae0
21pc             0x104c0	0x104c0 <main+32>