堆栈的工作原理


目录(?) [+]
  1. 从一些基本的知识和概念开始
  2. 开始讨论堆栈是如何工作的
  3. 堆栈的建立
    1.     参数入栈 
  4. 图2
    1.    返回地址入栈
  5. 图3
    1.     代码跳转到被调用函数执行
    2.    EBP指针入栈
    3.     为局部变量分配地址
  6. 图6
    1. 通用寄存器入栈
  7. 返回值是如何传递的
  8. 堆栈帧的销毁
  9. 函数的调用约定calling convention
  10. 反编译代码的跟踪不熟悉汇编可跳过
  11. 参考

转自:http://blog.csdn.net/lee244868149/article/details/49493715

每一个使用c语言的都应该知道栈的重要性,我们能够使用C/C++语言写出诸多复杂的程序,很大功劳一部分有归于栈的实现,因为它可以帮助我们实现函数间的嵌套调用。

汇编程序的运行是不需要栈的,所以注定它函数的嵌套层数不会太多,一般是父函数调用子函数,然后在子函数就返回了,很少见到子函数还会调用孙子函数的情况。这是由它的语言特性决定的。因为每当汇编语言调用子函数时,就会将返回的PC地址保存在LR中, 如果子函数还要调用孙子函数,那么执行时也会将子函数的返回地址保存在LR中,这时如果要返回父函数,就需要将返回父PC的地址保存在另外一个寄存器中,比如R0中,这将占用另外一个寄存器。

cpu的寄存器资源是很有限的,如果一个程序相对复杂,函数间有4、5层的嵌套调用,那将会占用至少4、5个寄存器资源,这是不现实的,也一般不会这样做,而且对于一种与硬件联系紧密的汇编语言来说,太复杂的逻辑关系或嵌套关系也不好实现。

汇编语言是底层语言,它没有栈,它也不需要编写很复杂很庞大的程序,即使没有内存(SDRAM),它也能在cpu的片内内存运行以完成一些裸机硬件程序;但是C语言是高级语言,它能够编写复杂庞大的程序,所以它需要函数间的多层调用,它需要用到指针的灵活赋值等等,但是这些都有依赖于栈, 那么栈是怎么让C语言能够变得如此强大呢?


一、栈的基本了解

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

我们知道栈在不同的系统中的增长方向是不一样的,但是栈的结构决定了它是一个先进后出的模型,所以和我们函数调用的过程是类似的,最先调用的函数总是最后返回,而最后调用的函数则是最最先返回,也就后调用先返回。

栈的出栈方式决定函数的返回过程,栈的增长空间支持函数嵌套的复杂程度。


扫描二维码关注公众号,回复: 3341395 查看本文章

二、栈的基本原理

下面是收集的基于ARM平台的一个例子

C语言进行函数调用的时候,常常会传递给被调用的函数一些参数,对于这些C语言级别的参数,被编译器翻译成汇编语言的时候,
就要找个地方存放一下,并且让被调用的函数能够访问,否则就没发实现传递参数了。对于找个地方放一下,分两种情况。
    一种情况是,本身传递的参数就很少,就可以通过寄存器传送参数,因为在前面的保存现场的动作中,已经保存好了对应的寄存器的值,那么此时,这些寄存器就是空闲的,可以供我们使用的了,那就可以放参数,而参数少的情况下,就足够存放参数了,比如参数有2个,那么就用r0和r1存放即可。(关于参数1和参数2,具体哪个放在r0,哪个放在r1,就是和APCS中的“在函数调用之间传递/返回参数”相关了,APCS中会有详细的约定。感兴趣的自己去研究)
     

但是如果参数太多,寄存器不够用,那么就得把多余的参数堆栈中了,即可以用堆栈来传递所有的或寄存器放不下的那些多余的参数。


举例分析C语言函数调用是如何使用堆栈的
    对于上面的解释的堆栈的作用显得有些抽象,此处再用例子来简单说明一下,就容易明白了:
    用:
        arm-inux-objdump –d u-boot > dump_u-boot.txt
    
    可以得到dump_u-boot.txt文件。该文件就是中,包含了u-boot中的程序的可执行的汇编代码,
    其中我们可以看到C语言的函数的源代码,到底对应着那些汇编代码。
    
    下面贴出两个函数的汇编代码,
    一个是clock_init,
    另一个是与clock_init在同一C源文件中的,另外一个函数CopyCode2Ram:
    
        33d0091c <CopyCode2Ram>:
        33d0091c:  e92d4070   push   {r4, r5, r6, lr}
        33d00920:  e1a06000   mov r6, r0
        33d00924:  e1a05001   mov r5, r1
        33d00928:  e1a04002   mov r4, r2
        33d0092c:  ebffffef   bl  33d008f0 <bBootFrmNORFlash>
        ... ...
        33d00984:  ebffff14   bl  33d005dc <nand_read_ll>
        ... ...
        33d009a8:  e3a00000   mov r0, #0 ; 0x0
        33d009ac:  e8bd8070   pop {r4, r5, r6, pc}


        33d009b0 <clock_init>:
        33d009b0:  e3a02313   mov r2, #1275068416   ; 0x4c000000
        33d009b4:  e3a03005   mov r3, #5 ; 0x5
        33d009b8:  e5823014   str r3, [r2, #20]
        ... ...
        33d009f8:  e1a0f00e   mov pc, lr
    
    
    (1)clock_init部分的代码
        可以看到该函数第一行:
            33d009b0:  e3a02313   mov r2, #1275068416   ; 0x4c000000
        就没有我们所期望的push指令,没有去将一些寄存器的值放到堆栈中。这是因为,我们clock_init这部分的内容,
        所用到的r2,r3等等寄存器,和前面调用clock_init之前所用到的寄存器r0,没有冲突,所以此处可以不用push去保存这类寄存器的值,
        不过有个寄存器要注意,那就是r14,即lr,其是在前面调用clock_init的时候,用的是bl指令,所以会自动把跳转时候的pc的值赋值给lr,
        所以也不需要push指令去将PC的值保存到堆栈中。
        而clock_init的代码的最后一行:
            33d009f8: e1a0f00e mov pc, lr
        
        就是我们常见的mov pc, lr,把lr的值,即之前保存的函数调用时候的PC值,赋值给现在的PC,
        这样就实现了函数的正确的返回,即返回到了函数调用时候下一个指令的位置。
        这样CPU就可以继续执行原先函数内剩下那部分的代码了。
    
    (2)CopyCode2Ram部分的代码
        其第一行:
            33d0091c: e92d4070 push {r4, r5, r6, lr}
        
        就是我们所期望的,用push指令,保存了r4,r5,r以及lr。
        用push去保存r4,r5,r6,那是因为所谓的保存现场,以后后续函数返回时候再恢复现场,
        
        而用push去保存lr,那是因为此函数里面,还有其他函数调用:
        
            33d0092c:  ebffffef   bl  33d008f0 <bBootFrmNORFlash>
            ... ...
            33d00984:  ebffff14   bl  33d005dc <nand_read_ll>
            ... ...
        
        也用到了bl指令,会改变我们最开始进入clock_init时候的lr的值,所以我们要用push也暂时保存起来。
        而对应地,CopyCode2Ram的最后一行:
            33d009ac: e8bd8070 pop {r4, r5, r6, pc}
        就是把之前push的值,给pop出来,还给对应的寄存器,其中最后一个是将开始push的lr的值,pop出来给赋给PC,因为实现了函数的返回。
        另外,我们注意到,在CopyCode2Ram的倒数第二行是:
            33d009a8: e3a00000 mov r0, #0 ; 0x0
         
        
        是把0赋值给r0寄存器,这个就是我们所谓返回值的传递,是通过r0寄存器的。
        此处的返回值是0,也对应着C语言的源码中的“return 0”.
        
        
    对于使用哪个寄存器来传递返回值:
    当然你也可以用其他暂时空闲没有用到的寄存器来传递返回值,但是这些处理方式,本身是根据ARM的APCS的寄存器的使用的约定而设计的,
    最好不要随便改变使用方式,最好还是按照其约定的来处理,这样程序更加符合规范。



下面是收集的x86平台的一个例子(个人觉得讲的很好)

    1)本文讨论的编译环境是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他编译环境或高级语言如C#也有意义。

    2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。

    3)  本文讨论的平台为intel x86。

    4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。

    5)结构化异常处理也是通过堆栈来实现的(当你使用try…catch语句时,使用的就是c++对windows结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。

document_thumb_thumb[4]从一些基本的知识和概念开始

    1) 程序的堆栈是由处理器直接支持的。在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:

image

    因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

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

    3) 和堆栈的操作相关的两个寄存器是EBP寄存器和ESP寄存器的,本文中,你只需要把EBP和ESP理解成2个指针就可以了。ESP寄存器总是指向堆栈的栈顶,执行PUSH命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP命令时,首先把ESP指向的数据拷贝到内存地址/寄存器中,然后ESP加4。EBP寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比EBP的值高,而函数的局部变量地址比EBP的值低,因此参数或局部变量总是通过EBP加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为EBP+8。

    4) 堆栈中到底存储了什么数据? 包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有try…catch语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。

    5) 在文中,我们把函数的调用者称为caller(调用者),被调用的函数称为callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由Caller完成的,有些则是由Callee完成的。

document_thumb_thumb4开始讨论堆栈是如何工作的

    我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int  foo1( int  m,  int  n)
{
     int  p=m*n;
     return  p;
}
int  foo( int  a,  int  b)
{
     int  c=a+1;
     int  d=b+1;
     int  e=foo1(c,d);
     return  e;
}
 
int  main()
{
     int  result=foo(3,4);
     return  0;
}

    这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。

document_thumb_thumb4堆栈的建立

    我们从main函数执行的第一行代码,即int result=foo(3,4); 开始跟踪。这时main以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:

image

图1

    参数入栈 

   当foo函数被调用,首先,caller(此时caller为main函数)把foo函数的两个参数:a=3,b=4压入堆栈。参数入栈的顺序是由函数的调用约定(Calling Convention)决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4先压入堆栈,a=3后压入,如图:

image

图2

   返回地址入栈

    我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是0x00171482,由于call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:

image

图3

    代码跳转到被调用函数执行

    返回地址入栈后,代码跳转到被调用函数foo中执行。到目前为止,堆栈帧的前一部分,是由caller构建的;而在此之后,堆栈帧的其他部分是由callee来构建。

   EBP指针入栈

    在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它暂存在堆栈中,在foo函数退出时恢复。同时,给EBP赋于新值。

    1)将EBP压入堆栈

    2)把ESP的值赋给EBP

image

图4

    这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址,你还会发现发现,EBP+4的地址就是函数返回值的地址,EBP+8就是函数的第一个参数的地址(第一个参数地址并不一定是EBP+8,后文中将讲到)。因此,通过EBP很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。 

    为局部变量分配地址

    接着,foo函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,直接为所有的局部变量分配空间,比如在foo函数中有ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用push命令分配地址,本质上并没有差别,特此说明)如图所示:

image

图5

     奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔8个字节)如下图所示:

 image

图6

    我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。

通用寄存器入栈

     最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所示:

image

图7

   至此,一个完整的堆栈帧建立起来了。

document_thumb_thumb4 堆栈特性分析

   上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

   1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。

   2)在A函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立B函数的堆栈帧。例如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。如下图所示:

image

图8 

  3)函数用EBP寄存器来访问参数和局部变量。我们知道,参数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。而在特定的堆栈帧中,每个参数或局部变量相对于EBP的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过EBP加上某个偏移量来访问的。比如,在foo函数中,EBP+8为第一个参数的地址,EBP-8为第一个局部变量的地址。

   4)如果仔细思考,我们很容易发现EBP寄存器还有一个非常重要的特性,请看下图中:

image

图9

   我们发现,EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道EBP+4地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

document_thumb_thumb4返回值是如何传递的

    堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象,balabala….,终于函数完成了它的工作,有些函数需要将结果返回给它的上一层函数,这是怎么做的呢?

    首先,caller和callee在这个问题上要有一个“约定”,由于caller是不知道callee内部是如何执行的,因此caller需要从callee的函数声明就可以知道应该从什么地方取得返回值。同样的,callee不能随便把返回值放在某个寄存器或者内存中而指望Caller能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。下面我们来讲解这个“约定”:  
    1)首先,如果返回值等于4字节,函数将把返回值赋予EAX寄存器,通过EAX寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过EAX寄存器返回。

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

    3)  如果返回值为double或float型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。

    4)如果返回值是一个大于8字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题,我们将详细讲解:

        我们修改foo函数的定义如下并将它的代码做适当的修改:

1
2
3
4
MyStruct foo( int  a,  int  b)
{
...
}
         MyStruct定义为:
1
2
3
4
5
6
struct  MyStruct
{
     int  value1;
     __int64  value2;
     bool  value3;
};

     这时,在调用foo函数时参数的入栈过程会有所不同,如下图所示:

image

图10

    caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部变量区的一块未命名的地址,这块地址将用来存储callee的返回值。函数返回时,callee把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值,最后,caller把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。

    你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。

document_thumb_thumb4堆栈帧的销毁

    当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

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

    2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。

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

    4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。

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

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

    前面1-5条都是由callee完成的。而第6条,参数地址的回收,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。

document_thumb_thumb4函数的调用约定(calling convention)

    函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有2个办法可以指定函数使用的调用约定:

    1)在函数定义时加上修饰符来指定,如

1
2
3
4
void  __thiscall mymethod();
{
     ...
}
    2)在VS工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,选择调用约定(注意:这种做法对类成员函数无效)。

    常用的调用约定有以下3种:

    1)__cdecl。这是VC编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由caller清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如printf方法。由于callee不知道caller到底将多少参数压入堆栈,因此callee就没有办法自己清理堆栈,所以只有函数退出之后,由caller清理堆栈,因为caller总是知道自己传入了多少参数。

    2)__stdcall。所有的Windows API都使用__stdcall。其规则是:参数从右向左压入堆栈,函数退出时由callee自己清理堆栈中的参数。由于参数是由callee自己清理的,所以__stdcall不支持可变数量的参数。

    3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86构架下this指针通过ECX寄存器传递,函数退出时由callee清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。

document_thumb_thumb4反编译代码的跟踪(不熟悉汇编可跳过)

    以下代码为和foo函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

    main函数中 int result=foo(3,4); 的反汇编:

1
2
3
4
5
008A147E  push        4                      //b=4 压入堆栈  
008A1480  push        3                      //a=3 压入堆栈,到达图2的状态
008A1482  call        foo (8A10F5h)          //函数返回值入栈,转入foo中执行,到达图3的状态
008A1487  add         esp,8                  //foo返回,由于采用__cdecl,由Caller清理参数
008A148A  mov         dword ptr [result],eax  //返回值保存在EAX中,把EAX赋予result变量

    下面是foo函数代码正式执行前和执行后的反汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
008A13F0  push        ebp                   //把ebp压入堆栈
008A13F1  mov         ebp,esp               //ebp指向先前的ebp,到达图4的状态
008A13F3  sub         esp,0E4h              //为局部变量分配0E4字节的空间,到达图5的状态
008A13F9  push        ebx                   //压入EBX
008A13FA  push        esi                   //压入ESI
008A13FB  push        edi                   //压入EDI,到达图7的状态
008A13FC  lea         edi,[ebp-0E4h]        //以下4行把局部变量区初始化为每个字节都等于cch
008A1402  mov         ecx,39h
008A1407  mov         eax,0CCCCCCCCh
008A140C  rep stos    dword ptr es:[edi]
......                                       //省略代码执行N行
......
008A1436  pop         edi                    //恢复EDI 
008A1437  pop         esi                    //恢复ESI
008A1438  pop         ebx                    //恢复EBX
008A1439  add         esp,0E4h               //回收局部变量地址空间
008A143F  cmp         ebp,esp                //以下3行为Runtime Checking,检查ESP和EBP是否一致  
008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh)
008A1446  mov         esp,ebp
008A1448  pop         ebp                    //恢复EBP
008A1449  ret                                //弹出函数返回地址,跳转到函数返回地址执行                                            //(__cdecl调用约定,Callee未清理参数)

document_thumb_thumb4[1]参考

Debug Tutorial Part 2: The Stack

Intel汇编语言程序设计(第四版) 第8章

http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx

http://www.360doc.com/content/10/1126/23/3267996_72551321.shtml
http://www.cnblogs.com/dwlsxj/p/Stack.html
转自: http://blog.csdn.net/daleiwang/article/details/50579776

转自:http://blog.csdn.net/lee244868149/article/details/49493715

每一个使用c语言的都应该知道栈的重要性,我们能够使用C/C++语言写出诸多复杂的程序,很大功劳一部分有归于栈的实现,因为它可以帮助我们实现函数间的嵌套调用。

汇编程序的运行是不需要栈的,所以注定它函数的嵌套层数不会太多,一般是父函数调用子函数,然后在子函数就返回了,很少见到子函数还会调用孙子函数的情况。这是由它的语言特性决定的。因为每当汇编语言调用子函数时,就会将返回的PC地址保存在LR中, 如果子函数还要调用孙子函数,那么执行时也会将子函数的返回地址保存在LR中,这时如果要返回父函数,就需要将返回父PC的地址保存在另外一个寄存器中,比如R0中,这将占用另外一个寄存器。

cpu的寄存器资源是很有限的,如果一个程序相对复杂,函数间有4、5层的嵌套调用,那将会占用至少4、5个寄存器资源,这是不现实的,也一般不会这样做,而且对于一种与硬件联系紧密的汇编语言来说,太复杂的逻辑关系或嵌套关系也不好实现。

汇编语言是底层语言,它没有栈,它也不需要编写很复杂很庞大的程序,即使没有内存(SDRAM),它也能在cpu的片内内存运行以完成一些裸机硬件程序;但是C语言是高级语言,它能够编写复杂庞大的程序,所以它需要函数间的多层调用,它需要用到指针的灵活赋值等等,但是这些都有依赖于栈, 那么栈是怎么让C语言能够变得如此强大呢?


一、栈的基本了解

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

我们知道栈在不同的系统中的增长方向是不一样的,但是栈的结构决定了它是一个先进后出的模型,所以和我们函数调用的过程是类似的,最先调用的函数总是最后返回,而最后调用的函数则是最最先返回,也就后调用先返回。

栈的出栈方式决定函数的返回过程,栈的增长空间支持函数嵌套的复杂程度。


二、栈的基本原理

下面是收集的基于ARM平台的一个例子

C语言进行函数调用的时候,常常会传递给被调用的函数一些参数,对于这些C语言级别的参数,被编译器翻译成汇编语言的时候,
就要找个地方存放一下,并且让被调用的函数能够访问,否则就没发实现传递参数了。对于找个地方放一下,分两种情况。
    一种情况是,本身传递的参数就很少,就可以通过寄存器传送参数,因为在前面的保存现场的动作中,已经保存好了对应的寄存器的值,那么此时,这些寄存器就是空闲的,可以供我们使用的了,那就可以放参数,而参数少的情况下,就足够存放参数了,比如参数有2个,那么就用r0和r1存放即可。(关于参数1和参数2,具体哪个放在r0,哪个放在r1,就是和APCS中的“在函数调用之间传递/返回参数”相关了,APCS中会有详细的约定。感兴趣的自己去研究)
     

但是如果参数太多,寄存器不够用,那么就得把多余的参数堆栈中了,即可以用堆栈来传递所有的或寄存器放不下的那些多余的参数。


举例分析C语言函数调用是如何使用堆栈的
    对于上面的解释的堆栈的作用显得有些抽象,此处再用例子来简单说明一下,就容易明白了:
    用:
        arm-inux-objdump –d u-boot > dump_u-boot.txt
    
    可以得到dump_u-boot.txt文件。该文件就是中,包含了u-boot中的程序的可执行的汇编代码,
    其中我们可以看到C语言的函数的源代码,到底对应着那些汇编代码。
    
    下面贴出两个函数的汇编代码,
    一个是clock_init,
    另一个是与clock_init在同一C源文件中的,另外一个函数CopyCode2Ram:
    
        33d0091c <CopyCode2Ram>:
        33d0091c:  e92d4070   push   {r4, r5, r6, lr}
        33d00920:  e1a06000   mov r6, r0
        33d00924:  e1a05001   mov r5, r1
        33d00928:  e1a04002   mov r4, r2
        33d0092c:  ebffffef   bl  33d008f0 <bBootFrmNORFlash>
        ... ...
        33d00984:  ebffff14   bl  33d005dc <nand_read_ll>
        ... ...
        33d009a8:  e3a00000   mov r0, #0 ; 0x0
        33d009ac:  e8bd8070   pop {r4, r5, r6, pc}


        33d009b0 <clock_init>:
        33d009b0:  e3a02313   mov r2, #1275068416   ; 0x4c000000
        33d009b4:  e3a03005   mov r3, #5 ; 0x5
        33d009b8:  e5823014   str r3, [r2, #20]
        ... ...
        33d009f8:  e1a0f00e   mov pc, lr
    
    
    (1)clock_init部分的代码
        可以看到该函数第一行:
            33d009b0:  e3a02313   mov r2, #1275068416   ; 0x4c000000
        就没有我们所期望的push指令,没有去将一些寄存器的值放到堆栈中。这是因为,我们clock_init这部分的内容,
        所用到的r2,r3等等寄存器,和前面调用clock_init之前所用到的寄存器r0,没有冲突,所以此处可以不用push去保存这类寄存器的值,
        不过有个寄存器要注意,那就是r14,即lr,其是在前面调用clock_init的时候,用的是bl指令,所以会自动把跳转时候的pc的值赋值给lr,
        所以也不需要push指令去将PC的值保存到堆栈中。
        而clock_init的代码的最后一行:
            33d009f8: e1a0f00e mov pc, lr
        
        就是我们常见的mov pc, lr,把lr的值,即之前保存的函数调用时候的PC值,赋值给现在的PC,
        这样就实现了函数的正确的返回,即返回到了函数调用时候下一个指令的位置。
        这样CPU就可以继续执行原先函数内剩下那部分的代码了。
    
    (2)CopyCode2Ram部分的代码
        其第一行:
            33d0091c: e92d4070 push {r4, r5, r6, lr}
        
        就是我们所期望的,用push指令,保存了r4,r5,r以及lr。
        用push去保存r4,r5,r6,那是因为所谓的保存现场,以后后续函数返回时候再恢复现场,
        
        而用push去保存lr,那是因为此函数里面,还有其他函数调用:
        
            33d0092c:  ebffffef   bl  33d008f0 <bBootFrmNORFlash>
            ... ...
            33d00984:  ebffff14   bl  33d005dc <nand_read_ll>
            ... ...
        
        也用到了bl指令,会改变我们最开始进入clock_init时候的lr的值,所以我们要用push也暂时保存起来。
        而对应地,CopyCode2Ram的最后一行:
            33d009ac: e8bd8070 pop {r4, r5, r6, pc}
        就是把之前push的值,给pop出来,还给对应的寄存器,其中最后一个是将开始push的lr的值,pop出来给赋给PC,因为实现了函数的返回。
        另外,我们注意到,在CopyCode2Ram的倒数第二行是:
            33d009a8: e3a00000 mov r0, #0 ; 0x0
         
        
        是把0赋值给r0寄存器,这个就是我们所谓返回值的传递,是通过r0寄存器的。
        此处的返回值是0,也对应着C语言的源码中的“return 0”.
        
        
    对于使用哪个寄存器来传递返回值:
    当然你也可以用其他暂时空闲没有用到的寄存器来传递返回值,但是这些处理方式,本身是根据ARM的APCS的寄存器的使用的约定而设计的,
    最好不要随便改变使用方式,最好还是按照其约定的来处理,这样程序更加符合规范。



下面是收集的x86平台的一个例子(个人觉得讲的很好)

    1)本文讨论的编译环境是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他编译环境或高级语言如C#也有意义。

    2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。

    3)  本文讨论的平台为intel x86。

    4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。

    5)结构化异常处理也是通过堆栈来实现的(当你使用try…catch语句时,使用的就是c++对windows结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。

document_thumb_thumb[4]从一些基本的知识和概念开始

    1) 程序的堆栈是由处理器直接支持的。在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:

image

    因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

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

    3) 和堆栈的操作相关的两个寄存器是EBP寄存器和ESP寄存器的,本文中,你只需要把EBP和ESP理解成2个指针就可以了。ESP寄存器总是指向堆栈的栈顶,执行PUSH命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP命令时,首先把ESP指向的数据拷贝到内存地址/寄存器中,然后ESP加4。EBP寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比EBP的值高,而函数的局部变量地址比EBP的值低,因此参数或局部变量总是通过EBP加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为EBP+8。

    4) 堆栈中到底存储了什么数据? 包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有try…catch语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。

    5) 在文中,我们把函数的调用者称为caller(调用者),被调用的函数称为callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由Caller完成的,有些则是由Callee完成的。

document_thumb_thumb4开始讨论堆栈是如何工作的

    我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int  foo1( int  m,  int  n)
{
     int  p=m*n;
     return  p;
}
int  foo( int  a,  int  b)
{
     int  c=a+1;
     int  d=b+1;
     int  e=foo1(c,d);
     return  e;
}
 
int  main()
{
     int  result=foo(3,4);
     return  0;
}

    这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。

document_thumb_thumb4堆栈的建立

    我们从main函数执行的第一行代码,即int result=foo(3,4); 开始跟踪。这时main以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:

image

图1

    参数入栈 

   当foo函数被调用,首先,caller(此时caller为main函数)把foo函数的两个参数:a=3,b=4压入堆栈。参数入栈的顺序是由函数的调用约定(Calling Convention)决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4先压入堆栈,a=3后压入,如图:

image

图2

   返回地址入栈

    我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是0x00171482,由于call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:

image

图3

    代码跳转到被调用函数执行

    返回地址入栈后,代码跳转到被调用函数foo中执行。到目前为止,堆栈帧的前一部分,是由caller构建的;而在此之后,堆栈帧的其他部分是由callee来构建。

   EBP指针入栈

    在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它暂存在堆栈中,在foo函数退出时恢复。同时,给EBP赋于新值。

    1)将EBP压入堆栈

    2)把ESP的值赋给EBP

image

图4

    这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址,你还会发现发现,EBP+4的地址就是函数返回值的地址,EBP+8就是函数的第一个参数的地址(第一个参数地址并不一定是EBP+8,后文中将讲到)。因此,通过EBP很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。 

    为局部变量分配地址

    接着,foo函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,直接为所有的局部变量分配空间,比如在foo函数中有ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用push命令分配地址,本质上并没有差别,特此说明)如图所示:

image

图5

     奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔8个字节)如下图所示:

 image

图6

    我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。

通用寄存器入栈

     最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所示:

image

图7

   至此,一个完整的堆栈帧建立起来了。

document_thumb_thumb4 堆栈特性分析

   上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

   1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。

   2)在A函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立B函数的堆栈帧。例如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。如下图所示:

image

图8 

  3)函数用EBP寄存器来访问参数和局部变量。我们知道,参数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。而在特定的堆栈帧中,每个参数或局部变量相对于EBP的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过EBP加上某个偏移量来访问的。比如,在foo函数中,EBP+8为第一个参数的地址,EBP-8为第一个局部变量的地址。

   4)如果仔细思考,我们很容易发现EBP寄存器还有一个非常重要的特性,请看下图中:

image

图9

   我们发现,EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道EBP+4地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

document_thumb_thumb4返回值是如何传递的

    堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象,balabala….,终于函数完成了它的工作,有些函数需要将结果返回给它的上一层函数,这是怎么做的呢?

    首先,caller和callee在这个问题上要有一个“约定”,由于caller是不知道callee内部是如何执行的,因此caller需要从callee的函数声明就可以知道应该从什么地方取得返回值。同样的,callee不能随便把返回值放在某个寄存器或者内存中而指望Caller能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。下面我们来讲解这个“约定”:  
    1)首先,如果返回值等于4字节,函数将把返回值赋予EAX寄存器,通过EAX寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过EAX寄存器返回。

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

    3)  如果返回值为double或float型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。

    4)如果返回值是一个大于8字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题,我们将详细讲解:

        我们修改foo函数的定义如下并将它的代码做适当的修改:

1
2
3
4
MyStruct foo( int  a,  int  b)
{
...
}
         MyStruct定义为:
1
2
3
4
5
6
struct  MyStruct
{
     int  value1;
     __int64  value2;
     bool  value3;
};

     这时,在调用foo函数时参数的入栈过程会有所不同,如下图所示:

image

图10

    caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部变量区的一块未命名的地址,这块地址将用来存储callee的返回值。函数返回时,callee把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值,最后,caller把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。

    你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。

document_thumb_thumb4堆栈帧的销毁

    当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

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

    2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。

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

    4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。

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

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

    前面1-5条都是由callee完成的。而第6条,参数地址的回收,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。

document_thumb_thumb4函数的调用约定(calling convention)

    函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有2个办法可以指定函数使用的调用约定:

    1)在函数定义时加上修饰符来指定,如

1
2
3
4
void  __thiscall mymethod();
{
     ...
}
    2)在VS工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,选择调用约定(注意:这种做法对类成员函数无效)。

    常用的调用约定有以下3种:

    1)__cdecl。这是VC编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由caller清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如printf方法。由于callee不知道caller到底将多少参数压入堆栈,因此callee就没有办法自己清理堆栈,所以只有函数退出之后,由caller清理堆栈,因为caller总是知道自己传入了多少参数。

    2)__stdcall。所有的Windows API都使用__stdcall。其规则是:参数从右向左压入堆栈,函数退出时由callee自己清理堆栈中的参数。由于参数是由callee自己清理的,所以__stdcall不支持可变数量的参数。

    3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86构架下this指针通过ECX寄存器传递,函数退出时由callee清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。

document_thumb_thumb4反编译代码的跟踪(不熟悉汇编可跳过)

    以下代码为和foo函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

    main函数中 int result=foo(3,4); 的反汇编:

1
2
3
4
5
008A147E  push        4                      //b=4 压入堆栈  
008A1480  push        3                      //a=3 压入堆栈,到达图2的状态
008A1482  call        foo (8A10F5h)          //函数返回值入栈,转入foo中执行,到达图3的状态
008A1487  add         esp,8                  //foo返回,由于采用__cdecl,由Caller清理参数
008A148A  mov         dword ptr [result],eax  //返回值保存在EAX中,把EAX赋予result变量

    下面是foo函数代码正式执行前和执行后的反汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
008A13F0  push        ebp                   //把ebp压入堆栈
008A13F1  mov         ebp,esp               //ebp指向先前的ebp,到达图4的状态
008A13F3  sub         esp,0E4h              //为局部变量分配0E4字节的空间,到达图5的状态
008A13F9  push        ebx                   //压入EBX
008A13FA  push        esi                   //压入ESI
008A13FB  push        edi                   //压入EDI,到达图7的状态
008A13FC  lea         edi,[ebp-0E4h]        //以下4行把局部变量区初始化为每个字节都等于cch
008A1402  mov         ecx,39h
008A1407  mov         eax,0CCCCCCCCh
008A140C  rep stos    dword ptr es:[edi]
......                                       //省略代码执行N行
......
008A1436  pop         edi                    //恢复EDI 
008A1437  pop         esi                    //恢复ESI
008A1438  pop         ebx                    //恢复EBX
008A1439  add         esp,0E4h               //回收局部变量地址空间
008A143F  cmp         ebp,esp                //以下3行为Runtime Checking,检查ESP和EBP是否一致  
008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh)
008A1446  mov         esp,ebp
008A1448  pop         ebp                    //恢复EBP
008A1449  ret                                //弹出函数返回地址,跳转到函数返回地址执行                                            //(__cdecl调用约定,Callee未清理参数)

document_thumb_thumb4[1]参考

Debug Tutorial Part 2: The Stack

Intel汇编语言程序设计(第四版) 第8章

http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx

http://www.360doc.com/content/10/1126/23/3267996_72551321.shtml
http://www.cnblogs.com/dwlsxj/p/Stack.html

猜你喜欢

转载自blog.csdn.net/xiliang_pan/article/details/52174143