笔记前言说明
为了简化说明先不去考虑变长栈帧的管理,也就说先不考虑金丝雀(哨兵值),还有寄存器%rbp作为x86-64代码中帧指针的等等的内容(或者叫基指针,base pointer)结合寄存器的英文register,从名字上好像一下就能看出%rbp这个寄存器的由来历史,没错在早些年的x86代码中,几乎每个函数调用都使用了帧指针。
废话先不多说了,就用最简单最简单的形式开始正题。笔记中的汇编约定用的是GNU的ABI,它和微软的ABI(就是MASM用的那套)有很大区别。
虽然在给定的一个时刻只有一个过程是活动的,但是我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值,这是程序运行最最基本的保证。根据惯例所以要使用“被调用者保存寄存器”。这样当过程P调用过程Q的时候,过程Q必须保证这些寄存器的值不变,也就是说当Q返回到P的时候与Q被调用前这些值是一样的。Q要做到这样的保证,要么根本就不去改变它们(也就是说不去使用这些寄存器),要么就是把这些寄存器的值压入栈中,改变这些寄存器的值(使用这些寄存器),然后在返回前从栈中pop出旧值。这就是栈帧结构中所谓的“保存的寄存器部分”。
假设{…O调用过程P,P又要调用Q,Q又调用了R…},把关注点放在P和Q上,P如果要使用某个“被调用者保存寄存器”,那么P就要把O调用P前使用的这个“被调用者保存寄存器”压入自己栈帧中,然后P再返回O之前把其pop回原来值,P调用Q后,如果Q也需要使用某一个“被调用者保存寄存器”那么同理,Q也要像P那样做,先保存进栈,然后返回P之前进行pop,以此类推。理解了这个就能理解递归过程寄存器中局部存储空间是如何使用的,然后又会更好的理解递归过程。
根据GNU ABI的约定通用整数寄存器可以分为三类:第一类是被调用者保存寄存器,就是%rbx,%rbp和%r12~%r15类似于微软ABI中的nonvolatile寄存器但是成员有细微区别,然后第二类是调用者保存寄存器的比如什么%rax,%rdi,%rsi,%rdx,%rcx,%r 8 9 10 11类似微软ABI中的volatile寄存器,gcc汇编通常使用他们去进行传递参数,返回值等事情,最后还有一个特殊的是%rsp,这个是栈指针。
一、先来个简单例子
C源代码
假设{过程O调用过程P,P又要调用Q}
O和Q的具体是什么不用去管
P部分为:
long P(long x, long y)
{
long u = Q(y);
long v = Q(x);
return u + v;
}
P调用了两次Q并且第一次调用使用的是y作为参数,第二次调用才使用x作为参数。第一次调用,要保存x的值为了后面用,类似第二次调用,要保存Q(y)的值。下面看下P的汇编代码,根据汇编代码来分析。
#long P(long x , long y)
#x 在 %rdi,y在%rsi,这些参数传递工作是R为P做好的
P:
pushq %rbp #保存%rbp,因为后面要使用这个寄存器,所以进栈保存原值
pushq %rbx #保存%rbx,这个被调用者保存寄存器也要使用,同理也进栈保存
subq $8, %rsp #校准下栈帧
movq %rdi,%rbp #保存x值,使用%rbp这个寄存器
movq %rsi,%rdi #移动y到1参数寄存器,因为第一次调用使用的参数是y,而y现在在2参数寄存器
call Q #Q(y)
movq %rax,%rbx #保存第一次调用的结果,这里就使用了%rbx这个寄存器了
movq %rbp,%rdi #把x移会1参数寄存器,准备第二次作为参数调用Q
call Q #Q(x)
addq %rbp,%rax #进行u+v
addq $8, %rsp #把栈帧最后那部分清了
popq %rbx #恢复之前的%rbx 因为P准备返回到R了
popq %rbp #恢复之前的%rbp,这里就体现了先进后出,先push的%rbp那就后pop%rbp
ret #返回到R
这个小例子里面有如何使用寄存器的局部存储,还体现了栈的先进后出。下面分析下递归过程,就能更好理解了。
二、递归过程实现的例子1
用递归的例子去加深理解“被调用者保存寄存器”是个不错的办法,因为递归过程每次调用自己返回之后,都需要用到之前值,而每次被调用自己之中又会进行下一次调用自己。因为这个特点,所以要使用被调用者保存寄存器。
首先抓住递归过程几个重要的因素:
1.最后一次迭代的返回值
2.什么时候结束递归,总不能无限死循环吧。这个就是递归头。
3.参数是如何迭代的。
4.递归表达式,也就是递归体。
抓住这几个要点来分析递归会很清晰。
递归在效率上和性能上大部分时候都不是最佳的,用来理解计算机系统尤其是寄存器和运行时栈倒是挺好的。
先看下C代码部分
第一个例子结合C代码和汇编代码来分析阶乘n!
每个过程调用,在栈中都有自己的私有空间,因此多个未完成调用的局部变量不会相互影响,当过程被调用的时候分配自己的局部存储,当返回的时候释放存储。递归是自己调用自己,其实跟普通的调用没有区别可以看做就是A调用B,B中调用C,C中调用D。。。。。只不过A B C D。。。都是一样的代码,唯一不同的是参数成规律性的在变化(迭代)。这种实现方法甚至适用于相互递归调用,比如过程P调用Q,Q中调用P。
long factorial(long x)
{
long result;
if (x <= 1)
result = 1;
else
result = x * factorial(x-1);
return result;
}
这个函数是计算n!最后一次迭代的返回值是1,递归头是n<=1,参数的迭代方式是X(n-1)=X(n) - 1,递归体就是result = x * factorial(x-1),先假设x的值只发生一次自己调用自己,然后去扩展问题可能就会简单很多
看下汇编代码部分,分析下这个计算阶乘的递归在寄存器和栈中是如何实现的:
factorial:
push %rbx #保存%rbx 因为每次调用自己之后都需要之前的x值,所以需要使用一下被调用者保存寄存器
movq %rdi, %rbx #保存X(n)到这个被调用者保存寄存器
movl $1, %eax #**这就是结束递归那次调用返回的值**
#**其实就是对应if (x <= 1) result = 1**;
cmpq $1, %rdi #这就是递归头if (x <= 1)如果x<=1了递归结束
jle .L2 #如果小于等于了,就goto done了
leaq -1(%rdi),%rdi #这是参数如何去迭代即X(n-1)=X(n) - 1
call factorial #开始自己调用自己了factorial(x-1)
imulq %rbx, %rax #这块就是递归体result = x * factorial(x-1)
.L2:
popq %rbx #恢复这个寄存器
ret #return
整个递归过程就是在最后一次递归(达到递归头的条件)之前,factorial的代码都是进行了一半然后就调用自己了,如果把代码扩展了就是一层一层在发生call factorial,直到最后一次递归结束之后,开始一层一层的返回,一层一层的pop,一层一层的计算**imulq %rbx, %rax 即result = x * factorial(x-1)**直到最后,将阶乘的结果返回。
三、下面这个例子通过抓住递归的几个关键点,参照汇编代码能够逆向C源代码的逻辑
汇编代码如下
play:
push %rbx
movq %rdi, %rbx
movl $0, %eax #这行能分析出最后一次递归的时候是返回值0
testq %rdi,%rdi #通过第五行和第六行分析
je .L2 #能知道递归头是if(x == 0)
shrq $2, %rdi #因为shrp这个指令是64位逻辑右移,能知道x类型为unsigned long
call play
addq %rbx,%rax #这里就是递归体
.L2:
popq %rbx
ret
首先能看出play这个函数只有一个参数,假设是x
从第4行movl $0, %eax这里就能分析出来 最后一次递归的时候是返回值0。
从第5,6,7行能分析出参数x的类型是unsigned long,递归头是if(x == 0),并且参数的迭代方式是X(n-1)=(X(n) >>2),shrq能知道这是逻辑右移。
从第9行能分析出递归体result = x+play(x>>2)
综上得出C源代码的逻辑就是:
long play(unsigned long x)
{
long result;
if(x == 0)
result = 0;
else
result = x + play(x >> 2);
return result;
}
递归代码其实与其他函数的结构一模一样。栈和寄存器保存规则足以让递归函数正确执行。递归代码会让代码看起来优雅一点,但是性能其实不咋地,它会大量的往栈中分配空间,每一次递归都会分配一次,至少有寄存器保存部分,返回地址,更有甚的还会一些参数构造部分。次数太多了就会栈溢出。但是通过研究一些简单的递归过程确实能帮助理解寄存器存储和栈帧,还是挺有意思的。
最后把C源代码改的更好看些
long play(unsigned long x)
{
if(x == 0)
return 0;
unsigned long nx = x >> 2;
long rec = play(nx);
return x + rec;
}
总结
在学习的过程中,个人觉得还是有些东西没有必要去死记,比如栈帧的结构,寄存器名字,什么参数3寄存器就一定是放参数3的?那样没有什么意义,因为那东西并不是死的,不同的ABI的约定是不同的,比如MASM用的微软ABI和gcc用的那个GNU ABI区别最为明显,约定只是个惯例的东西它虽然不同但是原理是一样的。理解了寄存器的局部存储和栈底层逻辑,有很多东西自然而然就理解了,比如用%rax往栈里面存金丝雀值,还有有时候会看到频繁进行将%rbp进栈,%rsp写入%rbp,然后开始调用函数,%rbp再进栈再将%rsp写入%rbp…,每一层返回到上一层之前都将原来的%rbp,pop回去,这就是笔记开头说的x86-64代码使用寄存器%rbp作为帧指针(基指针),其实都是为了实现调用者为被调者传递参数,实现变长栈帧的管理,检测栈缓冲区越界等等等,稍后时间会整理下关于变长栈帧的和栈溢出内容。总之实现的方法有很多,惯例也不是不变的,但是核心的底层逻辑不变。