ARM 栈和函数调用
阅读本文前,可以先阅读下述文档,对函数栈、栈帧等的概念会有所了解,会对本文章的理解大有益处
X86_64 栈和函数调用
1、调试环境
Ubuntu:
liangjie@liangjie-virtual-machine:~/Desktop$ cat /proc/version
Linux version 6.5.0-35-generic (buildd@lcy02-amd64-079)
(x86_64-linux-gnu-gcc-12 (Ubuntu 12.3.0-1ubuntu1~22.04) 12.3.0,
交叉编译器使用:gcc-linaro-7.4.1-2019.02-x86_64_arm-linux-gnueabi
2、调试原码
/* proc.c */
void func1()
{
}int func2(int a, long b, char *c)
{*c = a * b;func1();return a * b;
}int main()
{char value;int rc = func2(1, 2, &value);
}
3、反汇编结果
3.1 ARM 基础
ARM 是精简指令集(RISC:Reduced Instruction set Computing)处理器,拥有更简单的指令集(少于100个)和更多的通用寄存器。与 X86 不同,ARM 指令只操作寄存器,且只能使用 Load/Stroe(取/存) 命令来读取和写入内存。也就是说,如果增加某个地址处的 32 位数据的值,你起码需要三个指令(取,加,存):首先将该地址处的数据加载到寄存器(取),然后增加寄存器里的值(加),最后再将寄存器里的值存储到原来的地址处(存)。
ARM 在任何时候都可以看到 16 个通用寄存器,具体取决于当前的处理器模式。它们是 R0-R12、SP、LR、PC (R15)
- R0-R12:可用于常见操作期间存储临时值、指针(内存位置)等等。例如 R0,在算术运算期间可以称为累加器,或用于存储调用的函数时返回的结果。R7 在进行系统调用时非常有用,因为它存储了系统号,R11(FP 栈底)可帮助我们跟踪作为帧指针的堆栈上的边界。此外,ARM上的函数调用约定函数的前四个参数存储在寄存器 r0-r3 中(关于栈底,不同的编译器实现可能不同,有些编译器将 R7 作为栈底,而有些则是 R11 作为栈底)
- SP(或 R13)是堆栈指针。C 和 C++ 编译器始终使用 SP 作为堆栈指针。不鼓励将 SP 用作通用寄存器。在 Thumb 中,SP 被严格定义为堆栈指针。汇编程序参考中的说明页描述了何时可以使用 SP 和 PC
- 在用户模式下,LR(或R14)用作链接寄存器,用于在调用子程序时存储返回地址。如果返回地址存储在堆栈中,它也可以用作通用寄存器。在异常处理模式中,LR 保存异常的返回地址,或者如果在异常内执行子例程调用,则保存子例程返回地址。如果返回地址存储在堆栈中,LR 可以用作通用寄存器
- CPSR:状态寄存器:在它下面你可以看到工作状态标志,用户模式,中断标志,溢出标志,进位标志,零标志位,符号标志。这些标志代表了CPSR寄存器中特定的位,并根据CPSR的值进行设置,如果标志位有效则会进行加粗。N、Z、C 和 V 位与 x86 上的 EFLAG 寄存器中的 SF、ZF、CF 和 OF 位相同
- SPSR:程序保存状态寄存器(saved program status register)SPSR用于保存CPSR的状态,以便异常返回后恢复异常发生时的工作状态。当特定的异常中断发生时,这个寄存器用于存放当前程序状态寄存器的内容。在异常中断退出时,可以用 SPSR 来恢复 CPSR
3.2 源码讲解
00010398 <func1>:10398: b480 push {r7}1039a: af00 add r7, sp, #01039c: bf00 nop1039e: 46bd mov sp, r7103a0: bc80 pop {r7}103a2: 4770 bx lr000103a4 <func2>:103a4: b580 push {r7, lr}103a6: b084 sub sp, #16103a8: af00 add r7, sp, #0103aa: 60f8 str r0, [r7, #12]103ac: 60b9 str r1, [r7, #8]103ae: 607a str r2, [r7, #4]103b0: 68fb ldr r3, [r7, #12]103b2: b2da uxtb r2, r3103b4: 68bb ldr r3, [r7, #8]103b6: b2db uxtb r3, r3103b8: fb12 f303 smulbb r3, r2, r3103bc: b2da uxtb r2, r3103be: 687b ldr r3, [r7, #4]103c0: 701a strb r2, [r3, #0]103c2: f7ff ffe9 bl 10398 <func1>103c6: 68fb ldr r3, [r7, #12]103c8: 68ba ldr r2, [r7, #8]103ca: fb02 f303 mul.w r3, r2, r3103ce: 4618 mov r0, r3103d0: 3710 adds r7, #16103d2: 46bd mov sp, r7103d4: bd80 pop {r7, pc}000103d6 <main>:103d6: b580 push {r7, lr}103d8: b082 sub sp, #8103da: af00 add r7, sp, #0103dc: 1cfb adds r3, r7, #3103de: 461a mov r2, r3103e0: 2102 movs r1, #2103e2: 2001 movs r0, #1103e4: f7ff ffde bl 103a4 <func2>103e8: 6078 str r0, [r7, #4]103ea: 2300 movs r3, #0103ec: 4618 mov r0, r3103ee: 3708 adds r7, #8103f0: 46bd mov sp, r7103f2: bd80 pop {r7, pc}
r7 对应于 x86 下的 bp 寄存器,相对与 sp,r7 就是栈底,在进入一个新栈帧之后先把原来的 r7 压栈,然后 r7 保存当前 bp。Linux 下,r7 大部分情况用来保存系统调用号(syscall number)
3.2.1 func1
func1 函数比较简单,由 func1 先讲起。 func1 是叶子函数,主要关注其函数调用关系流程。
ARM 中的 bl 指令为相对跳转指令,在跳转之前,会先将当前指令的下一条指令地址保存到 lr 寄存器中,然后才跳转到标号执行。
所以当进入一个函数时,如果该函数可能会修改 lr 寄存器,则 lr 需要入栈保存;如果该函数是叶子函数,则不需要保存 lr 寄存器值
103c2: f7ff ffe9 bl 10398 <func1>
再看函数 func1,
00010398 <func1>:10398: b480 push {r7} # r7 入栈保存值1039a: af00 add r7, sp, #0 # r7 = sp + 01039c: bf00 nop1039e: 46bd mov sp, r7 # sp = r7103a0: bc80 pop {r7} # r7 出栈103a2: 4770 bx lr # 返回
push {r7}
,将 r7 压栈的,保存原来的栈底 r7add r7, sp, #0
,原来的栈底(r7 指向的位置)成为了新的栈顶(sp 指向的位置)
mov sp, r7
,恢复原来的栈顶pop {r7}
,恢复原来的栈底
3.2.2 func2
我们再看看 func2,func2 则主要关注函数传参以及局部变量的存储。
000103a4 <func2>:103a4: b580 push {r7, lr} # r7、lr 入栈保存值103a6: b084 sub sp, #16 # sp = sp - 16103a8: af00 add r7, sp, #0 # r7 = sp + 0103aa: 60f8 str r0, [r7, #12] # (r7 + 12) = r0103ac: 60b9 str r1, [r7, #8] # (r7 + 8) = r1103ae: 607a str r2, [r7, #4] # (r7 + 4) = r2103b0: 68fb ldr r3, [r7, #12] # r3 = (r7 + 12)103b2: b2da uxtb r2, r3 # r2 = r3 低 8 位103b4: 68bb ldr r3, [r7, #8] # r3 = (r7 + 8)103b6: b2db uxtb r3, r3 # r3 = r3 低 8 位103b8: fb12 f303 smulbb r3, r2, r3 # r3 = r2 * r3103bc: b2da uxtb r2, r3 # r2 = r3 低 8 位103be: 687b ldr r3, [r7, #4] # r3 = (r7 + 4)103c0: 701a strb r2, [r3, #0] # (r3 + 0) = r2 (8位)103c2: f7ff ffe9 bl 10398 <func1>103c6: 68fb ldr r3, [r7, #12] # r3 = (r7 + 12)103c8: 68ba ldr r2, [r7, #8] # r2 = (r7 + 8)103ca: fb02 f303 mul.w r3, r2, r3 # r3 = r2 * r3 (16位乘法)103ce: 4618 mov r0, r3 # r0 = r3 (返回值)103d0: 3710 adds r7, #16 # r7 = (r7 + 16) 103d2: 46bd mov sp, r7 # sp = r7103d4: bd80 pop {r7, pc} # r7 、lr 出栈,lr 出栈赋值给 pc
push {r7, lr}
,保存 lr 和 r7sub sp, #16
,开辟 16 字节空间,即 func2 的参数大小 (sp = sp - 16)add r7, sp, #0
,更新 r7 栈底(r7 = sp + 0) —— 这里,栈底 r7 其实和 sp 已经重合了str r0, [r7, #12]
,参数入栈 —— 其实从这里也能看到,r7 栈底的功能,就是访问栈中的参数变量
到这里,函数栈情况如下图:
TG : func2 参数为 12 字节,但是实际开辟大小为 16 字节,这与不同编译器对栈对齐、调用约定等原因有关
其中,恢复 func1 栈顶 sp
adds r7, #16
mov sp, r7
其中,恢复 func1 栈底 r7、pc(r7 = r7,pc = lr)
pop {r7, pc}
恢复后栈帧如下:
总的函数调用关系如下图:
4、关于编译器版本
较老版本的 gcc 编译器,编译出的栈帧逻辑和新版本的 gcc 编译器可能不同,例如:
使用 arm-linux-gcc-4.3.2 编译器,编译出效果如下:
00008350 <func2>:8350: e92d4800 push {fp, lr}8354: e28db004 add fp, sp, #4 ; 0x48358: e24dd010 sub sp, sp, #16 ; 0x10835c: e50b0008 str r0, [fp, #-8]8360: e50b100c str r1, [fp, #-12]8364: e50b2010 str r2, [fp, #-16]8368: e51b2008 ldr r2, [fp, #-8]836c: e51b300c ldr r3, [fp, #-12]8370: e0030392 mul r3, r2, r38374: e20330ff and r3, r3, #255 ; 0xff8378: e51b2010 ldr r2, [fp, #-16]837c: e5c23000 strb r3, [r2]8380: ebffffed bl 833c <func1>8384: e51b2008 ldr r2, [fp, #-8]8388: e51b300c ldr r3, [fp, #-12]838c: e0030392 mul r3, r2, r38390: e1a00003 mov r0, r38394: e24bd004 sub sp, fp, #4 ; 0x48398: e8bd8800 pop {fp, pc}
- 针对栈底寄存器,不再使用 r7,而是使用 fp 替代
- 栈底的功能,不再是保存新栈帧的栈顶,而是调用者栈帧的栈底 fp 的值,这样会方便栈回溯。使用 gcc-7.3 默认选项编译,GNU 说可以使用 unwind 方法回溯,这里暂时不会介绍 unwind 方法
其实不管编译器版本怎么变化,关于栈帧的逻辑最终都是大同小异,只要看懂了一个,其它的都能无师自通。