在任何编译型语言中,栈操作都是非常重要的。利用栈的后进先出特性,可以很方便的解决一些棘手的问题,让CPU分别分配push和pop两条命令专门对栈进行操作。当然还有其他辅助的栈操作指令。对于一些解释型脚本语言,如Javascript、Lua等,它们与宿主语言之间的参数传递也是通过栈来操作的。因此,了解栈操作的基本原理,对于学习和理解高级语言非常有帮助。在这篇文章中,我们继续从最底层的指令代码开始,通过子程序调用(即:函数调用)来了解栈空间是如何操作的,也就是下图:虽然例子是汇编代码,但是指令在那里总共不超过10个代码,每句话都有注释。相信你读起来没有问题!重申一遍:我们不是在学习汇编语言,而是用汇编代码来化繁为简,用最简单的例子来理解堆栈。操作。示例代码表明,代码的功能是:主程序:设置数据段、栈段、栈顶三个寄存器,然后调用子程序(函数调用);子程序:从寄存器si中获取字符串的起始地址,然后计算出字符串的长度,最后通过寄存器ax返回给主程序;主程序调用子程序时,涉及到返回地址的push和pop操作。子程序在计算字符串长度时,为了保护一些使用过的寄存器不被破坏,还涉及入栈和出栈操作。我们的主要目的是研究上述两个操作过程中栈空间的数据变化。以下主程序执行截图是通过工具debug.exe调试的。在调试的过程中,主要关注栈空间中的数据和几个寄存器的值:代码相关:cs,ip栈相关:ss,sp初始状态在执行第一条指令前,先查看所有寄存器valuein:此时我们还没有给数据段寄存器ds和栈段寄存器ss赋值,所以里面的值是没有意义的。只有cs:ip寄存器的值才有意义,此时是076F:0000,指向第一个码。再看指令代码:两个绿框内的指令分别用于设置数据段寄存器ds、栈段寄存器ss和栈顶寄存器sp。这部分内容在之前的文章中已经详细介绍过,这里不再赘述。前5行代码movax、datamovds、axmovax、stackmovss、axmovsp、20h的作用是设置ds、ss和sp。执行完这5行代码后,寄存器中的值为:从上图可以看出,编译器为程序安排了如下地址:将[数据段]安排在076C:0000;把[栈段]安排在076D:0000的位置;将【代码段】安排在076F:0000的位置;虽然数据段的值定义了6个字节的数据(5个字符+1个终止符),但在同堆栈段的起始地址之间,预留了16个字节的空间。我们来画一下此时内存空间的总体布局:我们都知道,当我们准备调用一个子程序时,调用函数后,需要将调用指令后面的指令地址压入栈中。只有这样,被调用的函数才能在执行结束后继续返回正确的指令继续执行。当CPU执行call指令时,会自动将call指令后面的指令地址压入栈中。在执行call指令之前,我们先来看2张图。(1)call的指令码和汇编代码call的汇编代码为:call0018。0018是指指令寄存器ip的值,加上代码段寄存器cs,为:076F:0018,子程序为存放在这个位置的第一条指令:pushbx。注:call的指令码为E80500,E8为call指令的操作码,0005为指令参数(注:低字节放在低地址,即:小端模式)。上一篇文章提到,CPU执行完一条指令后,会自动修改指令寄存器ip为下一条指令的地址。当执行call指令时,ip会自动成为下一条指令的地址,在call指令中加上0005,表示ip加上这个值就是子程序第一条指令的地址。这也是相对地址的概念!后面介绍搬迁的时候,我们会继续讲这个话题。(2)栈空间中的数据此时栈顶寄存器sp的值为0020,即:栈最高地址的下一个位置(为什么是这个位置?前面已经解释过了文章)。这32个字节的内容是没有意义的。因为栈中的数据是否有意义取决于sp寄存器,可以理解为一个指针,有些书上称之为:栈顶指针。调用子程序子程序的作用是计算字符串的长度,所以主程序必须告诉子程序:字符串的起始地址在哪里。在代码的开头,我们放了6个字节的数据段空间,内容是5个字符,加上一个0。主程序通过寄存器si告诉子程序第一个字符0的地址:movsi,0。当子程序被执行,每个字符从si的值所代表的地址中依次取出。现在我们开始执行调用指令。从上面的描述我们可以知道,下一条call指令的地址(076F:0013)会被压栈。由于这里的call指令是段内跳转,所以并没有将cs的值压栈,只是将ip的值压栈。(如果是段间跳转,cs:ip会被压入栈中)我们看一下call指令执行后的两张图:(1)寄存器的值,从图中可以看出sp的值已更改为001E。还记得上一篇文章提到的push操作吗?第一步:sp=sp-2。由于sp的初值为0020,减去2后为001E(均为16进制);Step2:将要压栈的值(即下一条指令0013的地址)放在sp指向的地址处。从图中也可以看出,指令寄存器ip的值变成了0018,也就是子程序的第一条指令(pushbx)的地址。(2)可以看到栈空间中的数据:最后2个字节是0013,是这样的:此时指令寄存器ip指向子程序的第一条指令076F:0018,然后继续执行对!子程序保护使用的寄存器我们知道:CPU中的寄存器都是公用的。在子程序中,为了计算字符串的长度,代码中用到了bx和cx这两个寄存器。但是不知道主程序中是否也用到这2个寄存器。如果我们擅自直接使用它们,改变它们的值,那么子程序执行完返回主程序后,如果主程序也使用这2个寄存器,就会有麻烦了。因此,在子程序开始时,需要将bx、cx入栈进行暂存保护。当子程序返回时,从栈中恢复它们的值,这样就不会对主程序造成潜在的威胁。1、bx入栈前,bx的值为0000,我们将其入栈。还记得上一篇文章中的push操作吗:Step1:将sp的值减2;Step2:将要压栈的值放到sp地址(2字节)处;此时,最高位寄存器sp变为001C(001E-2)。我们看一下栈空间中的数据:此时栈中有两个有意义的数据:返回地址和bx的值。2、cx入栈前,cx的值为005C,我们将其入栈。执行2步入栈操作后,栈顶寄存器sp变为001A(001C-2)。栈空间的数据状态:3.计算字符串的长度。字符串放在数据段中。数据段的段地址ds在主程序开始时就已经设置好了。字符串的首地址,主程序在执行call指令之前已经放在寄存器si中。因此,子程序只是简单地依次取出每个字符,从位置si开始,并检查它是否等于0(jcxz)。如果不为0,则将长度值加1(incbx),然后继续获取下一个字符(incsi);如果是0,则停止获取字符,因为已经遇到了字符串末尾的0。在循环中获取每个字符时,bx寄存器可以用来记录长度,所以bx应该在子程序开始时压栈。读取的每个字符都放在cx寄存器中,因此应在子程序开始时将cx压入堆栈。我们来看检查第一个字符'a'的情况:此时:bx的值为0001,说明长度至少为1。si的值为0001,下一个字符'b'位置ds:si(即:076C:0001)将被删除。这个过程已经循环了6次(loops)。当ds:si指向076C:0005时,即提取字符为0时,直接跳转到标记为over的地址(即:076F:0027)。此时,字符串的长度存储在寄存器bx:0005:4中。将字符串的长度告诉主程序。计算字符串的长度。我们需要把这个值告诉主程序,通常是通过通用寄存器ax。返回结果。因此,执行指令movax,bx将bx的值赋值给ax,主程序就可以从寄存器ax中得到字符串的长度。5、popcx子程序在返回前需要将栈中保存的bx和cx值恢复到寄存器中。另外,由于栈的后进先出特性,需要先将栈顶的数据弹出到cx寄存器中。执行弹出前:sp=001Acx=0000栈中数据如下:popcx指令分为两个动作:Step1:将sp指向的地址单元的中间数据(2字节)放入寄存器cx,所以cx中的值变为:005C;Step2:将sp的值加2,变成001C(001A+2)。此时栈中的数据:6.popbx的执行过程相同:Step1:将sp指向的地址单元的中间数据(2个字节)放入寄存器bx中,所以bx中的值变为A:0000;Step2:将sp的值加2,变成001E(001C+2)。此时栈中的数据状态:7.返回指令retCPU在执行ret指令时还有两个动作:Step1:将sp指向的地址单元的中间数据(2字节)放入指令寄存器ip,所以ip中的值变为:0013;Step2:将sp的值加2,变成0020(001E+2)。此时栈中数据状态为:此时栈顶寄存器sp已经指向代码段空间。这是因为我们在第一次编排的时候并没有腾出栈和代码之间的缓冲空间。反正此时:栈空间中没有有意义的数据;cs:ip指向主程序中call指令的下一条指令(movax,4c00h);所以,当CPU执行下一条指令时,又回到主程序继续执行。..本文转载自微信公众号“IOT物联网小镇”,可通过以下二维码关注。转载本文请联系物联小镇公众号。
