基于arm的C++反汇编 函数的工作原理
栈帧的形成和关闭 各种调用方式的考擦 使用 fp或sp寻址 函数的参数 与返回值 arm指令中立即数存放位置 gdbserver 调试环境
栈帧的形成和关闭
栈在内存中是一块特殊的存储空同, 它的存储原则是“先进后出”, 即最先被存储的数据最后被释放, 汇编过程通常使用 push 指令与 POP指令对栈空间执行数据压入和数据弹出操作。
栈结构在内存中占用一段连续的存储空间, 通过sp与 fp这两个栈指针寄存器(在x86上是esp,ebp)来保存当前栈的起始地址与结束地址(又称为 栈顶与 栈底)。 在 栈结构中, 每4字的 栈空间保存一个数据, 像这样的 栈顶到 栈底之间的存储空间被称为 栈帧.
在arm中没有x86那样丰富的栈操作指令,也没有专门的栈指针寄存器, 按照PCS规定用
$r13
寄存器来作为sp寄存器,还有一个可选的$r11
作为fp寄存器。
栈帧是如何形成的呢? 当栈顶指针 sp小于栈底指针 fp时, 就形成了栈帧。 通常, 栈帧中可以寻址的数据有局部变量、函数返回地址等(关于栈帧中函数返回地址的寻址可见我以前博文mips体系堆栈回溯分析与实现)。
不同的两次函数调用, 所形成的栈帧也不相同 。 当由一个函数进入到另一个函数中时, 就会针対调用的函数开辟出其所需的栈空间, 形成此函数的栈。 当这个函数结束调用时, 需要清除掉它所使用的栈空同, 关闭栈帧,我们把这一过程称为栈平衡。
为什么要进行栈平衡呢?这就像借钱一样,”有惜有还.再借不难’。如果某一函数在开辞了新的栈空间后没有进行恢复, 或者过度恢复, 那么将会造成栈空间的上溢或下溢, 极有可能给程序带来致命性的错误
现在的高级语言中,没有让程序猿操作栈的机会,都是由编译器自动进行栈帧的开辟和释放操作,一般除了缓冲区溢出,或者强制指针操作不会导致栈的异常。
还以上文的代码为例子:
#include using namespace std; int main() { // 将变量 nConst 修饰为const const int nConst = 5; // 定义int 类型的指针,保存nConst 地址 int *pConst = (int*)&nConst; // 修改指针pConst 并指向地址中的数据 *pConst = 6; // 将修饰为const 的变量nConst 赋值给nVar int nVar = nConst; cout << nVar << endl; }
反汇编代码如下:
000091fc : 91fc: e92d4800 push {fp, lr} 9200: e28db004 add fp, sp, #4 9204: e24dd010 sub sp, sp, #16 9208: e3a03005 mov r3, #5 920c: e50b3010 str r3, [fp, #-16] ;nConst = 5; 9210: e24b3010 sub r3, fp, #16 9214: e50b3008 str r3, [fp, #-8] ;pConst = &nConst 9218: e51b3008 ldr r3, [fp, #-8] ;r3 = pConst 921c: e3a02006 mov r2, #6 ;r2 = 6 9220: e5832000 str r2, [r3] ;*pConst = r2 = 6; 9224: e3a03005 mov r3, #5 9228: e50b300c str r3, [fp, #-12] ;nVar = r3 = 5 922c: e59f0024 ldr r0, [pc, #36] ; 9258 9230: e51b100c ldr r1, [fp, #-12] ;r1 = nVar 9234: eb000971 bl b800 <_ZNSolsEi> 9238: e1a03000 mov r3, r0 923c: e1a00003 mov r0, r3 9240: e59f1014 ldr r1, [pc, #20] ; 925c 9244: eb000469 bl a3f0 <_ZNSolsEPFRSoS_E> 9248: e3a03000 mov r3, #0 924c: e1a00003 mov r0, r3 9250: e24bd004 sub sp, fp, #4 9254: e8bd8800 pop {fp, pc}
在上述代码中,进入函数后,先保存原来的fp,然后调整fp的位置到sp+4,接下来通过“sub sp, 16”这句指令打开了 0x10 字节大小的栈空间, 这是留给局部变量使用的。并且注意到,设置完毕fp和sp指针之后,在函数返回之前不再修改这两个寄存器的值,而是通过它们来寻址局部变量。
由于在进入函数前打开了一定大小的栈空间, 在函数调用结束后需要将这些栈空间释放,因此需要还原环境sub sp, fp, #4
与 pop ,以降低栈顶这样的指令。
另外再提一点 gcc 还有一个关于stack frame的优化选项:
-fomit-frame-pointer
关于这个选项说明如下
Don't keep the frame pointer in a register for functions that don't need one. This avoids the instructions to save, set up and restore frame pointers; it also makes an extra register available in many functions. It also makes debugging impossible on some machines. On some machines, such as the VAX, this flag has no effect, because the standard calling sequence automatically handles the frame pointer and nothing is saved by pretending it doesn't exist. The machine-description macro "FRAME_POINTER_REQUIRED" controls whether a target machine supports this flag.
大意是说在不需要的函数里面不保存 frame指针,这样在很多函数里面多了一个寄存器可用,但是同样也使调试机制在某些机器上无法使用。
加入 -fomit-frame-pointer
选项之后生成的栈帧开辟和恢复指令就没有了fp寄存器操作了:
00008704 : 8704: e52de004 push {lr} ; (str lr, [sp, #-4]!) 8708: e24dd014 sub sp, sp, #20 .......... 8754: e28dd014 add sp, sp, #20 8758: e49df004 pop {pc} ; (ldr pc, [sp], #4)
arm支持4种栈操作方式,分别是 满减栈,满增栈,空减栈,空增栈
。实际使用中还是用和x86指令集相同的栈类型,具体详见 ARM的栈指令
各种调用方式的考擦
在x86下有各种调用方式出名的主要有 _cdecl
_stdcall
_fastcall
,主要规定了在函数调用和返回的时候参数如何传递,栈如何使用,函数返回的时候栈由谁来清理。
关于这个可见我以前转帖的博客: cdecl、stdcall、fastcall函数调用约定区别
在arm下 函数参数都是通过寄存器,当参数多了之后就用栈传递,关于这一点网上有人已经写的很好了,这里直接引用。理解APCS– ARM过程调用标准
使用 fp或sp寻址
在前面的内容中, 我们接触到很多高级语言中的变量访问 。 将高级语言转换成汇编代码后, 就变成了对 fp或 sp的加减法操作(寄存器相对寻址方式)来获取变量在内存中的数据,比如以下代码
9208: e3a03005 mov r3, #5 920c: e50b3010 str r3, [fp, #-16] ;nConst = 5; 9224: e3a03005 mov r3, #5 9228: e50b300c str r3, [fp, #-12] ;nVar = r3 = 5
由此可见, 局部变量是通过栈空间来保存的. 根据这两个变量以fp 寻址方式可以看出,在内存中,局部变量是以连续排列的方式存储在栈内的。
由于局部变量使用栈空间进行存储, 因此进入函数后的第一件事就是开辟函数中局部变量所需的栈空间。这时函数中的局部変量就有了各自的内存空间 。在函数结尾处执行释放栈空间的操作,因此局部变量是有生命周期的, 它的生命周期在进入函数体的时候开始, 在函数执行结束的时候结束 。
加入-fomit-frame-pointer
参数,使用了 sp 寻址后, 不必在每次进入函数后都调整栈底 fp, 这样既減少了fp的使用, 又省去了维护 fp的相关指令 因此可以有效提升程序的执行效率。
函数的参数 ,与返回值
在x86上因为寄存器比较少,而且栈指令功能强大,函数通过栈传递参数,但是在arm上因为寄存器比较多,函数参数直接通过寄存器传递,当寄存器不够的时候采用栈传递。
看一个例子:
#include using namespace std; int Add(int var1,int var2) { return var1 + var2; } int main() { int nVar1 = 0x123; int nVar2 = 0x456; int sum; sum = Add(nVar1,nVar2); cout << sum << endl; }
其对应的反汇编代码如下所示:
000091fc <_Z3Addii>: 91fc: e52db004 push {fp} ; (str fp, [sp, #-4]!) 9200: e28db000 add fp, sp, #0 9204: e24dd00c sub sp, sp, #12 9208: e50b0008 str r0, [fp, #-8] 920c: e50b100c str r1, [fp, #-12] 9210: e51b2008 ldr r2, [fp, #-8] 9214: e51b300c ldr r3, [fp, #-12] 9218: e0823003 add r3, r2, r3 921c: e1a00003 mov r0, r3 9220: e24bd000 sub sp, fp, #0 9224: e49db004 pop {fp} ; (ldr fp, [sp], #4) 9228: e12fff1e bx lr 0000922c : 922c: e92d4800 push {fp, lr} 9230: e28db004 add fp, sp, #4 9234: e24dd010 sub sp, sp, #16 9238: e59f3044 ldr r3, [pc, #68] ; 9284 923c: e50b3008 str r3, [fp, #-8] 9240: e59f3040 ldr r3, [pc, #64] ; 9288 9244: e50b300c str r3, [fp, #-12] 9248: e51b0008 ldr r0, [fp, #-8] 924c: e51b100c ldr r1, [fp, #-12] 9250: ebffffe9 bl 91fc <_Z3Addii> 9254: e50b0010 str r0, [fp, #-16] 9258: e59f002c ldr r0, [pc, #44] ; 928c 925c: e51b1010 ldr r1, [fp, #-16] 9260: eb000973 bl b834 <_ZNSolsEi> 9264: e1a03000 mov r3, r0 9268: e1a00003 mov r0, r3 926c: e59f101c ldr r1, [pc, #28] ; 9290 9270: eb00046b bl a424 <_ZNSolsEPFRSoS_E> 9274: e3a03000 mov r3, #0 9278: e1a00003 mov r0, r3 927c: e24bd004 sub sp, fp, #4 9280: e8bd8800 pop {fp, pc} 9284: 00000123 andeq r0, r0, r3, lsr #2 9288: 00000456 andeq r0, r0, r6, asr r4 928c: 000f7334 andeq r7, pc, r4, lsr r3 ; 9290: 0000af04 andeq sl, r0, r4, lsl #30
上述代码中 [fp, #-8] 对应变量 nVar1 ,因为用gdbserver调试执行到 0x923c的时候r3寄存器为 0x123
[fp, #-12] 对应就是 nVar2 了,然后当执行到指令0x9250 的时候 r0 对应nVar1 ,r1对应nVar2 。所以可以看出arm是通过使用寄存器传递参数的。
当从Add函数出来之后指令 str r0, [fp, #-16]
因此可以看出函数返回值是通过r0传递,在x86上这个是eax传递。
静态分析下Add函数,分析下指令,刚进去这个函数的时候开辟栈帧
add fp, sp, #0 sub sp, sp, #12
然后分别把参数r0,r1存储到开辟的栈中,如果此时有对局部变量的写操作,最终结果还是反馈到Add的栈帧里面,对main函数的栈帧没影响,因此可以得出结论 “形参是实参的副本,对形参修改不形象实参”
继续用 gdbserver 动态调试跟踪到指令 0x9250 ,这是一条bl指令,调用子函数 bl 91fc <_Z3Addii>
发现单步执行之后栈指针sp的值没改变,lr寄存器里面却保存了函数的返回值:
这个lr寄存器在子函数里面会被妥善安置,并且在子函数返回的时候很有用:
1)如果这个子函数是叶子函数,那么lr就不压栈返回时候直接 bx lr
所谓叶子函数就是 这个函数不再调用其它子函数。
2)如果这个函数不是叶子函数,那么就要压栈,并且出栈的时候直接用pc寄存器,这样就实现子函数返回,详见上面的main函数最后的指令。
这点跟x86也不一样,x86上用call
ret
指令来实现函数调用返回。喎? f/ware/vc/"="" target="_blank" class="keylink">vcD4NCjxoMSBpZD0="arm指令中立即数存放位置">arm指令中立即数存放位置
x86属于复杂指令集每条指令长度不固定,arm属于精简指令集每条指令限制了四字节,
x86如果要操作一个四字节立即数这个立即数可以编码到指令里面,这样一条指令就大于或者等于5个字节。但是arm就没有这个能力,函数里面用到的立即数都被放到函数末尾紧挨返回指令的地方,比如上面的main函数后面的0x123,0x456。这样做好出就是立即数存放位置距离当前PC指针不远,可以用pc指针加上一个偏移量来寻址。
gdbserver 调试环境
有时候单靠静态分析无法知道一些arm指令的细节,这时候就需要单步动态调试了,gdbserver就是一个很重要的工具,gdbserver 调试依赖于网络,因此需要按照本系列教程第一篇搭建好环境,弄好网络。
然后官网下载 源码 Index of /gnu/gdb
这里我们只需要gdbserver ,进入目录 /gdb/gdbserver
配置
./configure --build=i686-pc-linux-gnu --host=arm-linux --target=arm-linux
关于build host target 含义可见博文: 交叉编译: –host –build –target到底什么意思?
修改Makefile LDFLAGS= -static
,实际上arm工具链里面已经有了 gdbserver了,但是动态链接的,在busybox上无法顺利运行,为了省事这里简单的编译一个静态链接的gdbserver,编译完成后放到busybox即可。
后续步骤可见博文 嵌入式arm linux环境中gdb+gdbserver调试
喎?>