心中有栈


博客书写不易,您的点赞收藏是我前进的动力,千万别忘记点赞、 收**藏 ^ _ ^ !

栈基本术语

栈 (Stack )
在汉语中类似地称为栈或堆叠,是一种线性存储结构。是一种限定只能在栈顶执行操作(存储、查找、插入、删除)的数据结构,具有LIFO(后进先出)特性。栈在内存结构中和数据结构所说是两个不同的含义和领域。

栈顶与栈底
允许元素插入与删除的一端称为栈顶,另一端称为栈底。

进栈
向栈插入元素的操作,叫做进栈,也称压栈、入栈

出栈
栈顶元素的删除操作,也叫做出栈。

栈的数据结构

用自定义栈来简单描述栈的数据结构,其数据可以用顺序存储和链式存储两种。
1)顺序存储结构即用数组实现栈

/**
 * @author :罗发新
 * time  :2020/6/27 0027  14:58
 * email :[email protected]
 * desc  :
 */
class ArrayStack {

    private int[] stack;

    /**
     * 默认分配空间
     */
    private final int DEFAULT_SIZE = 10;

    /**
     * 当前元素的数量
     */
    private int currentCount;

    /**
     * 默认大小构造
     */
    public ArrayStack() {
        stack = new int[DEFAULT_SIZE];
        currentCount = 0;
    }

    /**
     * 指定大小构造
     *
     * @param size 创建的数组大小
     */
    public ArrayStack(int size) {
        stack = new int[size];
    }

    /**
     * 返回当前栈元素数量
     *
     * @return 当前数组Count
     */
    public int getSize() {
        return currentCount;
    }

    /**
     * @return 获取栈顶元素
     */
    public int getTop() {
        return stack[getSize()];
    }

    /**
     * 入栈
     *
     * @param element 元素
     * @return 返回是否
     */
    public boolean push(int element) {
        if (currentCount == stack.length) {
            return false;
        } else {
            stack[getSize()] = element;
            currentCount++;
            return true;
        }
    }

    /**
     * 出栈
     *
     * @return 退出
     */
    public int pop() {
        int result;
        if (currentCount == 0) {
            throw new RuntimeException();
        } else {
            result = stack[getSize() - 1];
            currentCount--;
            return result;
        }
    }
}

2)链式存储结构即用链表实现栈(单链表)

class ListNode {
    /**
     * 节点数据值
     */
    int value;
    /**
     * 指向下一节点的next指针
     */
    ListNode next;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    public ListNode getNext() {
        return next;
    }

    public void setNext(ListNode next) {
        this.next = next;
    }

    public ListNode(int value, ListNode next) {
        this.value = value;
        this.next = next;
    }
}


class LinkedStack {
    /**
     * 指向栈顶元素的top指针
     */
    private ListNode top;

    /**
     * @return 获取栈顶元素
     */
    public int getTop() {
        return top.value;
    }

    /**
     * 入栈
     *
     * @param value 插入的值
     */
    public void push(int value) {
        ListNode listNode = new ListNode(value, null);
        if (top == null) {
            top = listNode;
        } else {
            listNode.next = top;
            top = listNode;
        }
    }

    /**
     * 出栈
     *
     * @return 出栈的数据
     */
    public int pop() {
        int result;
        if (top == null) {
            throw new RuntimeException();
        } else {
            result = top.value;
            top = top.next;
            return result;
        }
    }
}

内存中的栈

栈存在原因

在内存中,栈是一种线性数据结构。函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据等数据有序的组织在一起形成一个栈帧,一个栈中有多个栈帧。

在编程中变量常有局部变量和全局变量之分。局部变量只在调用它所在的函数时才会生效,一旦函数返回就失效了,所以很多局部变量的生存周期远远低于整个程序的运行周期。

如果为每个局部变量分配不同的空间,让它们像全局变量一样先用确定的地址定位,那么会存在一些问题:

  1. 函数调用完毕局部变量失效,这分配的地址就没有用了,这将导致内存空间的利用率大大降低。

  2. 发生了递归调用,会存在某个函数尚未返回。通过这种多次调用,相同名称的局部变量会有不同的值,这些值必须同时保存在内存之中,而且又不能互相影响。所以它们必须要储存在不同的地址,这样当一次递归完成后会有大量空间被浪费。

  3. 对于函数形参,与局部变量也非常相似,它们都不能通过像全局变量一样用固定的地址加以定位。

解决如上问题的方案就上将局部变量、形参等数据存储在一种特殊的结构中,那就是。这种存储函数形参和局部变量的栈,也称之为运行栈。

内存中的堆栈

在JVM中,栈拥有其中一部分的内存空间,它以一种特殊的方式进行内存的访问。在此说的堆和栈不是数据结构中的堆和栈,是堆区和栈区中的堆栈。

  • 程序的堆栈是由处理器直接支持的。堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同)

  • 在32位系统中,堆栈每个数据单元的大小为4字节。小于等于4字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4个字节的。大于4字节的数据在堆栈中占4字节整数倍的空间。

  • ESP寄存器总是指向堆栈的栈顶,执行PUSH命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP命令时,首先把ESP指向的数据拷贝到内存地址/寄存器中,然后ESP加4。

  • EBP寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置。函数的参数地址比EBP的值高,而函数的局部变量地址比EBP的值低,因此参数或局部变量总是通过EBP加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为EBP+8。

栈分配

以C语言程序中来谈论栈
1)每次我们开机的时候,系统都会初始化好栈指针(SP)。初始方法也很简单,在boot_load代码里我们可以看到:ldr sp, =4096
这样的语句就是让SP指针指向这样的地址。
2)注意栈指针SP指向的地址是内存中的地址,而不是cpu片内地址。内存资源相对cpu资源来说充裕多了,所以SP可以有很大的增长空间,这也是C语言可以写复杂程序的前提。

函数调用方向

我们知道栈在不同的系统中的增长方向是不一样的,Windows中是向下增长,但是栈的结构决定了它一定是先进后出的模型。
所以和我们函数调用的过程是类似的,最先调用的函数总是最后返回,而最后调用的函数则是最先返回。
栈的出栈方式决定函数的返回过程,栈的增长空间支持函数嵌套的复杂程度。

运行栈

运行栈中的数据分为一个一个栈帧,每一个栈帧对应一次函数调用。所以栈帧中包含这次函数调用中的形参值,局部变量值,一些控制信息,临时数据(例如复杂表达式计算的中间值,某些函数的返回值)。

运行栈的原理:

  1. 每次发生函数调用时,都会有一个栈帧被压入运行栈中,而函数调用返回后,相应的栈帧会被弹出。

  2. 一个函数在执行的过程中,能够直接随机访问它所对应的栈帧中的数据,即处在运行栈最顶端的栈帧的数据(执行中的函数的栈帧,总是处在运行栈的最顶端)。

  3. 当一个函数调用其他函数时,要为它调用的函数设置实参,具体方式是在调用前把实参压入栈中,运行栈中的这一部分空间是主调函数和被调函数都可以直接访问的,也就是参数的形式结合就是通过这一公共空间来完成的。

  4. 虽然每一个函数在被调用时的形参和局部变量的地址都是不确定的,但是它们的地址相对于栈顶的地址却是确定的,这样就可以通过栈顶的地址,来间接定位函数形参和局部变量。

栈帧(Stack Frame)

函数调用经常是嵌套的,在同时刻,栈中会有多个函数的信息()。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。

栈帧特性

栈帧有如下特点:

  1. 一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了,在函数退出时,整个函数帧将被销毁。所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的

  2. 栈帧存放着函数参数,局部变量和恢复前一栈帧所需要的数据等。

  3. 栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈,当函数返回时逻辑栈帧被从堆栈中弹出。

  4. 编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。

  5. 编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。

  6. 使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

  7. 栈帧的边界由EBP和ESP界定,通过EBP分析确定分配在函数栈帧上的局部变量空间准确值

  8. 栈帧是运行时概念,若程序不运行,就不存在栈和栈帧。

  9. 栈帧的创建和清理都是由函数调用者Caller和被调用的函数Callee完成的。

栈帧的构建、传值和销毁

栈帧的构建
以如下代码为例:

public static void main(String[] args) {

        int result = add(3, 4);
        int b=result*10;

    }

    private static int add(int a, int b) {
        int c = A*2;
        int d = b*3;
        return add2(c,d) + c + d;
    }

    private static int add2(int c, int d) {
        return d + c ;
    }
   
  • 在调用add()函数之前,这时main以及之前的函数对应的栈帧已经存在栈中了。
    在这里插入图片描述

  • 当add函数被调用时,首先将a=3,b=4压入堆栈。一般来说,参数是由右往左入栈的,所以入栈顺序是4、3。
    在这里插入图片描述

  • 然后将函数add的指令地址 0x00*****压入栈中
    在这里插入图片描述

  • 当add()方法的地址被压入栈后,此时需要将EBP寄存器中的值压入栈中。此时EBP中的值还是main函数的,用它可以访问main函数的局部变量和参数,压入EBP值是为了后面退出add()时取出回复。再将EBP的值存入栈后,此时会重新给EBP赋新的值,其新值指向前一个EBP值内存地址。
    在这里插入图片描述

  • 接下来add()函数为局部变量分配地址,它不是一个个入栈而是通过一定规则ESP=ESP-0x00F3(该值仅举例,需要根据一定规则确定),这样分配一块地址空间存放局部变量,其局部变量地址可能不连续,有空隙。

  • 然后将通用寄存器中的值入栈,那么一个完整的栈帧就建立起来了。
    在这里插入图片描述
    +同理将add2()同样加入栈中,得到最终完整的栈如图。
    在这里插入图片描述
    栈帧的传值
    栈帧中如何传递返回值,函数调用者和被调用函数都有关于返回值存放的“约定”,如下:

  1. 首先,如果返回值等于4字节,函数将把返回值赋予EAX寄存器,通过EAX寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过EAX寄存器返回。

  2. 如果返回值等于8字节,函数将把返回值赋予EAX和EDX寄存器,通过EAX和EDX寄存器返回,EDX存储高位4字节,EAX存储低位4字节。例如返回值类型为__int64或者8字节的结构体通过EAX和EDX返回。

  3. 如果返回值为double或float型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。
    在这里插入图片描述

caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部变量区的一块未命名的地址,这块地址将用来存储callee的返回值。其步骤为:

  1. 函数返回时,callee把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。

  2. 函数返回后,caller通过EAX寄存器找到ReturnValuePointer地址值,然后通过ReturnValuePointer找到返回值,最后,caller把返回值拷贝到负责接收的局部变量上。

你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?
对的,堆栈帧是被销毁了,但是保存返回值的地址被赋给了通用寄存器EAX,返回值在内存中还是存在的,当EAX将地址值拷贝给局部变量时就可以了。

栈帧的销毁
当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反,最后入栈的先出栈。

  1. 如果有对象存储在栈帧中,对象的析构函数会被函数调用。

  2. 从堆栈中弹出先前的通用寄存器的值,将值再次赋给通用寄存器。

  3. ESP加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。

  4. 从堆栈中弹出先前的EBP寄存器的值,将值赋给EBP寄存器,重新指向前一个栈帧栈底

  5. 从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。

  6. ESP加上某个值,回收所有的参数地址。

前面1-5条都是由callee完成的。而第6条,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。calling convention在这里不重点讲解,请自己查阅资料。

EBP和ESP

EBP
栈帧基地址指针,EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定。
1) EBP用于访问参数和局部变量,如上图发现参数地址高于EBP值,局部变量地址低于EBP值。
2)每个参数和局部变量相对EBP的地址偏移值是固定的,对于参数和局部变量是通过EBP值加特定偏移量来访问的,如上图add栈帧中,EBP+8为第一个参数值,EBP-4为第一个局部变量地址
3) 每个EBP地址总是指向前一个栈帧的EBP指针地址

ESP
堆栈指针,指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。

phsh 和 pop 指令的格式
push寄存器:将一个寄存器中的数据入栈。先改变SP,后向SS:SP传送
pop寄存器:用一个寄存器接受出栈的数据。先读取SS:SP数据,后改变SP

博客书写不易,您的点赞收藏是我前进的动力,千万别忘记点赞、 收**藏 ^ _ ^ !

相关链接:

  1. 心中有堆:https://blog.csdn.net/luo_boke/article/details/106928990
  2. 心中有树——基础:https://blog.csdn.net/luo_boke/article/details/106980011
  3. 常见排序算法解析:https://blog.csdn.net/luo_boke/article/details/106762372
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章