做系统分析的话你肯定遇到过一些crash, oops等棘手问题,一般大家都会用 gdb, objdump 或者 addr2line等工具分析 pc 位置来定位出错的地方。但是这些分析工具背后的本质原理就不见得理解深刻了,而且有的时候面对一系列 backtrace 或者 stack 日志处于懵逼的状态。
今天和大家一起看下面对 crash 日志的时候,如何利用 stack 来分析其变化的来龙去脉。
Arm64 user模式通用寄存器:
有34个寄存器,包括31个通用寄存器、SP、PC、CPSR。
FP(x29) :保存栈帧地址(栈底指针)
LR(x30) :通常称X30为程序链接寄存器,保存子程序结束后需要执行的下一条指令
SP:保存栈指针
PC:程序计数器,俗称PC指针,总是指向即将要执行的下一条指令
CPSR:状态寄存器
64位,8字节为一个字word,32位,4字节位一个字word。
Arm64过程调用标准(AAPCS64) :定义了如何通过寄存器传递函数参数和返回值。
规则:
实验平台:
给大家介绍一个很好用的在线编译平台,可以自由选择编译器和flag,并支持反汇编。链接:https://godbolt.org/
在这里,我选择:
假如现在你已经掌握了 ARM64 指令的用法,即便没有掌握也没关系,“书到用时回头翻”。这里以一段简单的 c 语言为例:
int m=0;
int funa(int a, int b)
{
int ret = 0 ;
ret = a+b;
return ret;
}
int funb(int c, int d)
{
int ret = c+d ;
ret = funa(c, ret);
return ret;
}
int main(void)
{
int i=1,j=2, r;
m=6;
r = funb(i,j);
return r;
}
编译一下,然后反汇编。不使用在线平台的可以用:
$ aarch64-linux-gnu-gcc main.c -O0 -o main.out
$ aarch64-linux-gnu-objdump -j .text -ld -C -S main.out
使用在线平台编译直接点击即可,上述代码反汇编如下:
m:
.zero 4
funa:
sub sp, sp, #32
str w0, [sp, 12]
str w1, [sp, 8]
str wzr, [sp, 28]
ldr w1, [sp, 12]
ldr w0, [sp, 8]
add w0, w1, w0
str w0, [sp, 28]
ldr w0, [sp, 28]
add sp, sp, 32
ret
funb:
stp x29, x30, [sp, -48]!
mov x29, sp
str w0, [sp, 28]
str w1, [sp, 24]
ldr w1, [sp, 28]
ldr w0, [sp, 24]
add w0, w1, w0
str w0, [sp, 44]
ldr w1, [sp, 44]
ldr w0, [sp, 28]
bl funa
str w0, [sp, 44]
ldr w0, [sp, 44]
ldp x29, x30, [sp], 48
ret
main:
stp x29, x30, [sp, -32]!
mov x29, sp
mov w0, 1
str w0, [sp, 28]
mov w0, 2
str w0, [sp, 24]
adrp x0, m
add x0, x0, :lo12:m
mov w1, 6
str w1, [x0]
ldr w1, [sp, 24]
ldr w0, [sp, 28]
bl funb
str w0, [sp, 20]
ldr w0, [sp, 20]
ldp x29, x30, [sp], 32
ret
如何能让读者接受吸收的更快,我一直觉得按照学习效率来讲的话顺序应该是视频,图文,文字。反正我是比较喜欢视频类的教学。这里给大家画下栈变化的过程是什么样子的。这里的图是结合上面的代码来画的,希望有助于读者的理解。
2.全局变量m赋值
程序加载到内存后,全局变量放在data段,已经有初始化的值。
1.main函数自己开辟栈空间,同时保存caller的FP和LR
PC跳转到main函数运行时。为当前函数开辟栈空间。由于main函数不是叶子函数,会修改x29(FP)和(LR)寄存器的值,需要将这两个寄存器的值保存到当前栈,以便返回时恢复。
710: a9be7bfd stp x29, x30, [sp, #-32]!
2.更新栈帧寄存器FP
SP寄存器是当前函数栈指针,指向栈顶。
FP寄存器是当前函数栈帧指针,指向栈底。
对当前函数来说,FP=SP。FP指向当前函数的栈帧基地址。每个函数都要执行该动作。
714: 910003fd mov x29, sp
*3.mian函数的局部变量依次入栈保存
将当前函数局部变量,依次从栈底往栈顶顺序(高地址—>低地址)压栈保存。
mov w0, 1
str w0, [sp, 28]
mov w0, 2
str w0, [sp, 24]
4.函数内部更新全局变量的值
adrp x0, m //获取变量m的页基地址,相对当前pc地址
add x0, x0, :lo12:m //获取变量m的页偏移地址,加上页地址,为相对当前pc的地址
mov w1, 6
str w1, [x0]
将局部变量出栈(从栈顶开始,从低地址到高地址),通过w0和w1传递给函数funb.
第二个参数:w1 = j
第一个参数:w0 = i
ldr w1, [sp, 24]
ldr w0, [sp, 28]
bl funb
8.将子函数返回赋值给局部变量
子函数执行完成后返回,将返回结果保存在寄存器w0。
由于子函数返回结果赋值给局部变量r了,因此将寄存器w0的值保存在栈,即给局部变量r赋值。
str w0, [sp, 20] //为局部变量r在栈上分配空间,并赋值
main函数将局部变量的值放在寄存器w0,在ret时返回给caller。
str w0, [sp, 20]
ldr w0, [sp, 20]
10.main函数释放栈,并从栈上恢复FP和LR寄存器,并返回
当前函数自己分配栈,返回前自己做栈平衡。在释放栈前,将保存在栈上的caller的FP和LR恢复。
ldp x29, x30, [sp], 32
ret
我们分析下,在从main函数跳转到funb函数时,栈的变化情况。从上面4.1可知,当时main给funb传递两个参数w0和w1,并需要funcb返回一个值。
跳转时栈情况:
1.函数funb为自己分配栈空间,并保存LR和FP到栈顶
funb函数为自己分配栈空间,在其caller的底部,栈向下生长。
由于函数不是叶子函数,因此还要调用子函数,会修改FP和LR寄存器的值,因此需要将其caller(main函数)的执行现场FP和LR寄存器保存到栈顶。
funb:
stp x29, x30, [sp, -48]!
FP指向自己的栈帧,FP=SP。
mov x29, sp
从caller传过来的参数保存在寄存器w0和w1, 接下来要用这两个寄存器。因此,先将传参保存到栈上。
str w0, [sp, 28] // 保存传参1
str w1, [sp, 24] // 保存传参2
4.funb函数中将运算结果赋值给局部变量
为局部变量ret分配空间,存放计算结果。
ret作为funb函数的第一个局部变量,放在栈底的位置。
ldr w1, [sp, 28]
ldr w0, [sp, 24]
add w0, w1, w0
str w0, [sp, 44]
5.funb函数为callee准备传参,并跳转到callee
ldr w1, [sp, 44]
ldr w0, [sp, 28]
bl funa
6.funb函数为处理callee返回结果
子函数funa执行结束后,将结果返回到寄存器w0。
funb函数将返回结果w0保存到局部变量rer。
str w0, [sp, 44]
7.funb函数准备返回结果
函数在返回前,将返回结果放在w0寄存器。
ldr w0, [sp, 44]
8.funb函数在返回前,恢复caller的FP和LR,并做栈平衡
ldp x29, x30, [sp], 48
ret
由4.2可知,从funb函数跳转到funa时,栈的情况如下:
由于函数是叶子函数,没有callee。就不会修改FP和LR寄存器的值了,因此无需保存这两个寄存器了。
sub sp, sp, #32
2.funa函数不需要更新FP栈帧寄存器,FP指向栈底
由于它是叶子函数,用SP已经可以表示栈帧了。FP在这里表示栈底,指向caller的栈帧。
3.funa函数保存形参到栈
str w0, [sp, 12]
str w1, [sp, 8]
4.funa函数为局部变量分配空间
为局部变量ret 分配空间,并将运算结果赋值给ret。
str wzr, [sp, 28]
ldr w1, [sp, 12]
ldr w0, [sp, 8]
add w0, w1, w0
str w0, [sp, 28]
5.funa函数准备返回值到w0,并处理栈平衡
先将返回值放在wo寄存器,再释放自己的栈。
ldr w0, [sp, 28]
add sp, sp, 32
ret
一个典型的函数(有caller和callee)的栈帧结构如下:
从图可知:
每个函数(假设同时具有caller和callee)的栈结构格式为:
这就是一个完整的栈帧。
每个函数(假设同时具有caller和callee)进入后典型的栈操作:
每个函数(假设同时具有caller和callee)返回前典型的栈操作:
**重要:**这里有两个很重要寄存器,FP和LR。根据这两个寄存器就可以反推出所有函数的调用栈。
核心点在于:FP寄存器,又叫栈帧寄存器。
关键规则:
示例:
已知如下图所示30多个寄存器的值,求解函数调用关系以及调用栈?
假设函数调用关系为:main() ——> fun1() ——>fun2()
根据当前栈顶保存的FP值,就知道上一级栈帧基地址。即:上一级FP(栈帧基地址)=*(FP)。
1)当前现场寄存器信息:FP=SP=0xFFFF0000B903B20。得知,当前函数fun2的栈帧基地址为0xFFFF0000B903B50。
2) 当前栈帧基地址,即为当前函数的栈顶,存放的是其caller(fun1)的FP(栈帧基地址)。读取栈顶(即地址0xFFFF0000B903B50),返回值为caller(fun1)的栈帧基地址,0xFFFF0000B903BD0.
3.同样,读取fun1的栈顶0xFFFF0000B903BD0,得到其calller(main函数)的栈帧基地址,为0xFFFF0000B90360。
4.以此类推。
关键点:FP寄存器和LR。
从4.1可知,知道FP寄存器就能得到每个函数的栈帧基地址。而知道每个函数的栈帧基地址的条件下,可通过当前函数栈帧保存的LR获得当前函数的Entry地址和函数名。
**依据:**可通过当前函数栈帧所保存的LR间接获取到calller调用callee时的PC,从而根据符号表得到具体的函数名。在calller(调用者)调用callee(被调函数)时,LR指向被调函数callee的返回的下一条指令,通过LR指向的地址-4字节偏移就得到了跳转时的指令,包括被调用函数callee的入口地址。再通过符号表即可得到此入口函数对应的函数名。
caller 跳转时的pc= *LR - 4 = * (FP +8 ) -4
**示例:**假设函数调用关系为:main() ——> fun1() ——>fun2()
$ rd 0xFFFF0000B903B20
0xFFFF0000B903B20: 0xFFFF0000B903B50
2.得到栈帧上保存的LR,0xFFFF0000B903B50 + 8。得到func2返回地址。
$ rd 0xFFFF0000B903B58
0xFFFF0000B903B58: 0xFFFF00000E590A0
4.func2返回地址-4 (0xFFFF00000E5909C)就是calller跳转到callee时的pc. 配合符号表, 对该地址进行反汇编,就得到该地址处的跳转指令。跳转指令中就有callee fun2函数的函数名。
$ dis 0xFFFF00000E5909C
0xFFFF00000E5909C <fun1 + 24>: bl 0xFFFF0000B903BCC <fun2>
反汇编该地址,返回两列内容
第一列:“0xFFFF00000E5909C
第二列:“bl 0xFFFF0000B903BCC ”。这一列放的是该PC地址处的指令,calller跳转到callee。这里给出了跳转指令,callee的函数名(func1)和callee的入口地址(0xFFFF0000B903BCC)。