融汇贯通系列之--栈(一)

栈这个东西联结了C语言,函数调用,汇编指令,操作系统,虚拟内存。总而言之就是非常的重要。

我们首先来看栈的作用,为什么需要栈,不用栈可以吗?

栈一般包括如下几方面的内容:

  • 函数的返回地址和参数
  • 临时变量:包括函数的局部变量以及编译器生成的其它临时变量
  • 保存的上下文,包括在函数调用前后需要保持不变的寄存器
上面提到了临时变量,结合我们以前的疑问,即CPU只有少数几个通用寄存器,那么它们不够用的时候怎么办。编译器就会把一些临时变量安排到栈上。

但是其实,我们在使用C语言的时候,并没有直接的去操作栈,甚至压根对这件事情不感知。比如我们调用函数,那么我们直接传参就行,在函数里面使用局部变量,我们也只用知道它的生存周期就行。其实,这是编译器,操作系统,CPU一起替我们分担了这里面的繁复的工作。

我们之所以用栈,往根上刨,主要还是因为CPU的设计,CPU的设计中就提供了这种机制,可以把一段内存当做栈来使用。

例如8086的CPU, 提供入栈和出栈指令,PUSH和POP.

push ax 是将ax的内容入栈,pop ax是把栈顶取出来给ax.

随之而来的一个问题是,我们怎么知道push ax到底把ax的内容放到哪个地址去了呢?同理,pop ax 是从哪个地址取数据呢?

显然,需要有额外的寄存器来记录这些信息。在8086 CPU中,栈顶的段地址存放在SS中,偏移地址放在SP中。任意时刻,SS + SP的结果指向的就是栈顶的地址。(SS指向栈底,SP是先减小,然后再push,显然这里是个满减栈)

这里额外补充一些内容:什么是 【满/空】 | 【增/减】栈

满就是指,SP指针指向的是栈顶;空是指,SP指针指向栈顶下一个要存放数据的地址

增就是,push的时候,SP要先增加,然后再存放数据;减就是,push的时候,SP先减少再存放数据

 在i386中,则是esp寄存器指向栈的顶部,ebp指向函数活动记录的一个固定位置,又被称为帧指针(Frame Pointer)这里为什么特别指出函数活动记录,这是因为,在涉及到函数的地方,CPU指令的安排有一些特殊,但是呢,函数又是程序中再普通不过的基础单元了,你甚至可以理解为,程序就是从main函数开始不断地在各个函数之间跳转。

那么只要涉及到跳转,必然涉及到以下几件事情:

1.往哪里跳,跳完之后,怎么知道回到现在的位置继续执行。只有保证了这一点才能保证函数跳来跳去不会出错。这是个非常重要的基础功能,CPU从设计层面就帮我想好了这件事情了。这里似乎也体现了软硬件协同设计的思想。

2. 函数的参数怎么从这个函数传递过去。参数个数,参数类型,这些东西,该怎么告知要被调用的函数呢?(这里需要依靠的是调用惯例)

我们看下i386中是如何回答上述问题的:

1、首先,把所有或者一部分参数压入栈中,如果有参数没有入栈,那么使用某些特定的寄存器传递【编译器knows how to do】

2、把当前指令的下一条指令的地址压入栈中,作为函数的返回地址

3、跳转到要调用的函数中执行

其中2,3步由call指令一起执行【看吧,CPU的设计中也考虑到了这个问题】

4、i386中,函数体的“标准”开头是这样的

push ebp; 把ebp压入栈中,称为old ebp;
mov ebp, esp: ebp = esp(这时ebp指向栈顶,此时栈顶就是old ebp)
sub esp, xxx; 在栈上分配xxx字节的临时空间
push xxx: 如有必要,把xxx寄存器压入栈中【编译器knows how to do】

把ebp压入栈中,是为了在函数返回的时候便于恢复以前的ebp值,
压入某些寄存器在于编译器可能要求某些寄存器在函数调用前后保持不变。

5、在函数返回时,需要做一些标准结尾工作: pop xxx; 如有必要,恢复保存的xxx寄存器 mov esp, ebp; 恢复ebp,esp = ebp, 同时回收局部变量空间 pop ebp; 从栈中恢复保存的ebp的值 ret; 从栈中取得返回地址,并跳转到该位置

 这里再着重说一下帧指针fp, 它是用来记录一次函数调用的基地址,fp在一个函数中不会变化,sp则是会不断变化,因此fp可以用来定位函数活动记录中的各个数据。这么说还是不太好理解,其实每个函数调用都有一个自己的栈帧,fp就是这个栈帧的底。我们要结合栈,和CPU的sp寄存器,fp寄存器一起来理解这件事情。比如我们现在在一个函数中正在执行,这个函数必定有一个自己的栈帧,这个栈帧的栈顶现在是由sp寄存器存储,然后这个函数内部遇到了一个函数调用,因此,在这个函数的栈顶的基础上,我们开始压栈函数参数,然后是函数的返回值,压完之后,把当前的ebp也就是记录着当前帧底的寄存器的内容也给它压栈,因为待会到了新的函数中,fp就会指向新的位置,然后令ebp= esp。也就是新的一帧的ebp就是老的那一帧的esp栈顶.这样当新的函数执行完,准备返回的时候,我们在把原先的ebp和esp恢复,这样整个上下文就又恢复到之前的状态了。

这里我们不妨再思考一下,中断是不是也是类似的操作呢,我们也是需要保存好现场,然后,跳转去处理中断,处理完中断后,还有返回,并把上下文恢复成
中断前的状态

 接下来我们要看一下调用惯例的含义:

我们可以这样理解,函数的调用方和被调用方之间是通过栈来完成数据传输的,那必然涉及到两方需要按照同样的规则去操作栈内的数据。

这种两方都遵守的约定就叫做调用惯例。一个调用惯例一般会规定如下几个方面的内容。

  • 函数参数的传递顺序和方式【例如从右往左,是否由寄存器传递参数,以提升性能】
  • 栈的维护方式【入栈的函数参数是由函数本身完成出栈还是由调用方出栈】
  • 名字修饰策略

不同的调用惯例有不同的名字修饰策略,在C语言中,默认是cdecl,其约定如下:

参数按照从右往左的顺序入栈,参数由函数调用方出栈,名字修饰是直接在名称前加一个下划线

下面插播一则短报

融汇贯通系列之--栈(一)

 

现在的我不由得又联想到了一件事情,那就是stm32 的startup.s的代码,这个是单片机的启动代码

这里面一个重要的事情就是初始化后面程序会用到的栈,这个弄好了,C语言的代码才能正常运行。

因为C的函数调用需要栈。那为啥汇编不需要呢?其实汇编里面全是自己定义参数怎么传递,函数调用怎么处理,所以相当于是自己干了一部分编译器的活。这也正是C语言是高级语言的好处,帮助我们屏蔽了底层的大量细节。

说道这里,我们不妨在下一章回顾一下,stm32的启动代码,一起看一下C语言运行之前我们需要做的事情。

 

上一篇:滴水逆向-数据类型-C代码是怎么变成汇编


下一篇:《C++反汇编与逆向分析技术揭秘》--钱林松,赵海旭 著