转自: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结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。
从一些基本的知识和概念开始
1) 程序的堆栈是由处理器直接支持的。在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:
因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。
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完成的。
开始讨论堆栈是如何工作的
我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:
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;
}
|
这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。
堆栈的建立
我们从main函数执行的第一行代码,即int result=foo(3,4); 开始跟踪。这时main以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:
图1
参数入栈
当foo函数被调用,首先,caller(此时caller为main函数)把foo函数的两个参数:a=3,b=4压入堆栈。参数入栈的顺序是由函数的调用约定(Calling Convention)决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4先压入堆栈,a=3后压入,如图:
图2
返回地址入栈
我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是0x00171482,由于call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:
图3
代码跳转到被调用函数执行
返回地址入栈后,代码跳转到被调用函数foo中执行。到目前为止,堆栈帧的前一部分,是由caller构建的;而在此之后,堆栈帧的其他部分是由callee来构建。
EBP指针入栈
在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它暂存在堆栈中,在foo函数退出时恢复。同时,给EBP赋于新值。
1)将EBP压入堆栈
2)把ESP的值赋给EBP
图4
这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址,你还会发现发现,EBP+4的地址就是函数返回值的地址,EBP+8就是函数的第一个参数的地址(第一个参数地址并不一定是EBP+8,后文中将讲到)。因此,通过EBP很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。
为局部变量分配地址
接着,foo函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,直接为所有的局部变量分配空间,比如在foo函数中有ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用push命令分配地址,本质上并没有差别,特此说明)如图所示:
图5
奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔8个字节)如下图所示:
图6
我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。
通用寄存器入栈
最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所示:
图7
至此,一个完整的堆栈帧建立起来了。
![document_thumb_thumb4 document_thumb_thumb4](https://i-blog.csdnimg.cn/blog_migrate/6da519fa16649af7111e6e3e11430aa1.png)
上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。
1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。
2)在A函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立B函数的堆栈帧。例如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。如下图所示:
图8
3)函数用EBP寄存器来访问参数和局部变量。我们知道,参数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。而在特定的堆栈帧中,每个参数或局部变量相对于EBP的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过EBP加上某个偏移量来访问的。比如,在foo函数中,EBP+8为第一个参数的地址,EBP-8为第一个局部变量的地址。
4)如果仔细思考,我们很容易发现EBP寄存器还有一个非常重要的特性,请看下图中:
图9
我们发现,EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道EBP+4地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。
返回值是如何传递的
堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(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)
{
...
}
|
1
2
3
4
5
6
|
struct
MyStruct
{
int
value1;
__int64
value2;
bool
value3;
};
|
这时,在调用foo函数时参数的入栈过程会有所不同,如下图所示:
图10
caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向caller局部变量区的一块未命名的地址,这块地址将用来存储callee的返回值。函数返回时,callee把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值,最后,caller把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。
你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。
堆栈帧的销毁
当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)
1)如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。
2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。
3)ESP加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。
4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。
5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。
6)ESP加上某个值,回收所有的参数地址。
前面1-5条都是由callee完成的。而第6条,参数地址的回收,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。
函数的调用约定(calling convention)
函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有2个办法可以指定函数使用的调用约定:
1)在函数定义时加上修饰符来指定,如
1
2
3
4
|
void
__thiscall mymethod();
{
...
}
|
常用的调用约定有以下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寄存器来传递了。
反编译代码的跟踪(不熟悉汇编可跳过)
以下代码为和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未清理参数)
|
参考
Debug Tutorial Part 2: The Stack
http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx
http://www.360doc.com/content/10/1126/23/3267996_72551321.shtmlhttp://www.cnblogs.com/dwlsxj/p/Stack.html