一、什么是栈
1.定义:
栈:数据结构中的栈,是一个容器,先进后出的属性;计算机系统中,栈是具有以上属性的动态内存区域,程序将数据压入栈,也可以从栈顶弹出,压栈使栈增大,出栈使栈减小,栈总是向下增长(由高地址向低地址);
如图4G虚拟地址空间布局:
ZONE_DMA的范围是0~16M,该区域的物理页面专门供I/O设备的DMA使用。之所以需要单独管理DMA的物理页面,是因为DMA使用物理地址访问内存,不经过MMU,并且需要连续的缓冲区,所以为了能够提供物理上连续的缓冲区,必须从物理地址空间专门划分一段区域用于DMA。
ZONE_NORMAL的范围是16M~896M,该区域的物理页面是内核能够直接使用的。
ZONE_HIGHMEM的范围是896M~结束,该区域即为高端内存,内核不能直接使用。
2.栈的作用、栈中内容
栈,保存了一个函数调用所需要的维护的信息,被称作堆栈帧或活动记录。堆栈帧有如下内容:
(1)函数的返回地址和参数
(2)临时变量:函数内的非静态局部变量(static局部变量存放在.data或者.bss段)、编译时生成的其它临时变量
(3)保存上下文:包括函数调用前后需要保持不变的寄存器。
3.关于esp、ebp寄存器
在i386中,一个函数的活动记录用esp、ebp这两个寄存器划定范围。
esp寄存器始终指向栈顶部,同时也指向当前函数活动记录的顶部;ebp寄存器指向函数活动记录的一个固定位置。
ebp所指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp就可以通过这个值恢复到调用前的值(我理解的就是栈帧回退) 。
4.几个汇编指令:
函数调用标准开头
(1)push ebp :把ebp压入栈中(成为old ebp)
(2)mov ebp,esp :ebp=esp,即把ebp指向esp的位置,这是ebp指向栈顶,此时栈顶就是old ebp
(3)sub exp,xxx :在栈生分配xxx字节的临时空间
(4)push xxx :如有必要,保存名为xxx的寄存器
函数调用标准结尾
(1)pop xxx :如有必要,回复保存过得寄存器
(2)mov esp,ebp :esp=ebp,将esp指向ebp的位置,栈帧回退,回复esp同时回收局部变量空间
(3)pop ebp:从栈中恢复保存ebp的值,
(4)ret :从栈中取得返回地址,并跳转到该位置
其他:
(1)call fun1 (0C31127h):call相当于调用函数,这里重新设置了栈底ebp和栈顶esp
(2)add esp,8 :算数运算指令 加法
(3)lea :装入有效地址
(参考汇编指令大全博客 https://blog.csdn.net/baishuiniyaonulia/article/details/78504758)
二、函数参数带入、栈帧开辟
0.函数参数带入
源代码:
int fun1(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int a = fun1(10, 20);
return 0;
}
查看汇编代码:首先设置断点,在main开始处,启动调试(VS必须处于调试状态才能看到汇编指令窗口),在当程序运行到断点处停止时,依次点击调试->窗口->反汇编;英文显示为"Debug"下的"Windows"子菜单,选择"Disassembly"。
汇编代码如下:
main函数
int main()
{
00C31410 push ebp
00C31411 mov ebp,esp
00C31413 sub esp,0CCh
00C31419 push ebx
00C3141A push esi
00C3141B push edi
00C3141C lea edi,[ebp-0CCh]
00C31422 mov ecx,33h
00C31427 mov eax,0CCCCCCCCh
00C3142C rep stos dword ptr es:[edi]
int a = fun1(10, 20);
00C3142E push 14h
00C31430 push 0Ah
00C31432 call fun1 (0C31127h)
00C31437 add esp,8
00C3143A mov dword ptr [a],eax
return 0;
00C3143D xor eax,eax
}
00C3143F pop edi
00C31440 pop esi
00C31441 pop ebx
00C31442 add esp,0CCh
00C31448 cmp ebp,esp
00C3144A call __RTC_CheckEsp (0C3113Bh)
00C3144F mov esp,ebp
00C31451 pop ebp
00C31452 ret
调试进入main函数栈帧,EBP = 0075FA14, ESP = 0075F93C ,ebp作为栈底寄存器,esp为栈顶寄存器,栈顶栈底唯一标志着一个函数调用栈。
如图main栈帧:
将汇编代码执行到函数调用的地方,查看函数调用参数带入的指令。
int a = fun1(10, 20);
00C3142E push 14h
00C31430 push 0Ah
00C31432 call fun1 (0C31127h)
00C31437 add esp,8
00C3143A mov dword ptr [a],eax
首先观察这里的汇编代码,参数顺序是10,20,在这里汇编指令首先push 14h也就是20,再push 0Ah也就是10。从这里可以看出来,参数入栈的顺序是从右向左入栈的。
参数入栈示意图如图:
进入call fun
int fun1(int a, int b)
{
009813D0 push ebp //将main函数栈帧的栈底地址ebp入栈
009813D1 mov ebp,esp //将main函数栈顶地址赋给ebp寄存器,成为fun函数的栈底
009813D3 sub esp,0CCh //将main函数的栈顶地址esp减少0xc0,成为fun函数的栈顶
009813D9 push ebx
009813DA push esi
009813DB push edi
009813DC lea edi,[ebp-0CCh] //将fun1函数的栈帧空间循环赋值为0xcccc cccc
009813E2 mov ecx,33h
009813E7 mov eax,0CCCCCCCCh
009813EC rep stos dword ptr es:[edi]
int c = a + b;
009813EE mov eax,dword ptr [a]
009813F1 add eax,dword ptr [b]
009813F4 mov dword ptr [c],eax
return c;
009813F7 mov eax,dword ptr [c]
}
函数堆栈调用开栈过程:
1.压入实参,自右向左
2.压入下一行指令地址
3.压入调用方函数的栈底地址(本例中为main的栈底esp)
4.调转到被调用方函数栈帧(本例中为 fun函数)
5.开辟被调用方函数所需要运行的空间(本例中fun函数所需空间位C0)
1.<=8KB参数的带入
接下来我们看看参数入栈会不会根据参数的大小而产生区别。
struct Tmp
{
char a;
};//大小为1字节
int fun1(struct Tmp a, struct Tmp b)
{
return 0;
}
int main()
{
struct Tmp tmp1, tmp2;
tmp1.a = 10;
tmp2.a = 20;
int a = fun1(tmp1,tmp2);
return 0;
}
在这里我们给函数传入两个一字节大小的参数,观察其汇编码。
int a = fun1(tmp1,tmp2);
00991A46 movzx eax,byte ptr [tmp2]
00991A4A push eax
00991A4B movzx ecx,byte ptr [tmp1]
00991A4F push ecx
00991A50 call fun1 (09911E5h)
在这里,我们看看,首先从tmp2中取出值,然后push,再从tmp2中取值,然后push,由此可见,一个字节的参数,采用的是利用push入栈的方式将参数带入。
我们修改代码,将Tmp结构体的大小改为4个字节,观察其入栈 方式
struct Tmp
{
int a;
};//大小为4字节
int fun1(struct Tmp a, struct Tmp b)
{
return 0;
}
int main()
{
struct Tmp tmp1, tmp2;
tmp1.a = 10;
tmp2.a = 20;
int a = fun1(tmp1,tmp2);
return 0;
}
观察其fun1函数调用的汇编码
其汇编码和Tmp结构体为一个字节时候无异,说明在参数为4字节时候,函数参数依旧采用push入栈的方式带入。
int a = fun1(tmp1,tmp2);
00EE365C mov eax,dword ptr [tmp2]
00EE365F push eax
00EE3660 mov ecx,dword ptr [tmp1]
00EE3663 push ecx
00EE3664 call fun1 (0EE11E5h)
接下来来看看Tmp结构体的大小为8字节时候的入栈方式
struct Tmp
{
int a;
int b;
};//大小为8字节
int fun1(struct Tmp a, struct Tmp b)
{
return 0;
}
int main()
{
struct Tmp tmp1, tmp2;
tmp1.a = 10;
tmp1.b = 15;
tmp2.a = 20;
tmp2.b = 25;
int a = fun1(tmp1,tmp2);
return 0;
}
fun1函数调用时候的汇编代码
int a = fun1(tmp1,tmp2);
002D366A mov eax,dword ptr [ebp-18h]
002D366D push eax
002D366E mov ecx,dword ptr [tmp2]
002D3671 push ecx
002D3672 mov edx,dword ptr [ebp-8]
002D3675 push edx
002D3676 mov eax,dword ptr [tmp1]
002D3679 push eax
002D367A call fun1 (02D11E5h)
当前的ebp值为0x0095FD60,那么ebp-18h是谁呢?我们来看看。
0x0095FD44 14 00 00 00 .... tmp2.a ebp-1ch tmp2
0x0095FD48 19 00 00 00 .... tmp2.b ebp-18h
0x0095FD4C cc cc cc cc ???? 缓冲层 ebp-14h
0x0095FD50 cc cc cc cc ???? 缓冲层 ebp-10h
0x0095FD54 0a 00 00 00 .... tmp1.a ebp-0ch tmp1
0x0095FD58 0f 00 00 00 .... tmp1.b ebp-8
0x0095FD5C cc cc cc cc ???? vs编译器变量中间会有防止越界的缓冲层 ebp-4
0x0095FD60 b0 fd 95 00 ???. ebp
首先push的是ebp-18h的四个字节,也就是tmp2.b,然后是tmp2地址上取四个字节,就是tmp2.a。再push的是ebp-8的四个字节也就是tmp1.b,然后是tmp1地址上取四个字节,也就是tmp1.a。由此可见,函数参数是8个字节的时候,依旧是利用push入栈的方式将参数传递。
2.>8KB的参数带入
再看看参数大小如果为12字节时候的参数传递方式。
struct Tmp
{
int a;
int b;
int c;
};//大小为12字节
int fun1(struct Tmp a, struct Tmp b)
{
return 0;
}
int main()
{
struct Tmp tmp1, tmp2;
tmp1.a = 10;
tmp1.b = 15;
tmp1.c = 16;
tmp2.a = 20;
tmp2.b = 25;
tmp2.c = 26;
int a = fun1(tmp1,tmp2);
return 0;
}
利用调试,将查看函数调用时候的汇编码
int a = fun1(tmp1,tmp2);
008F3C58 sub esp,0Ch //给esp栈顶指针减少12
008F3C5B mov eax,esp //将esp的值给eax寄存器
008F3C5D mov ecx,dword ptr [tmp2] //将tmp.a的值写入ecx寄存器
008F3C60 mov dword ptr [eax],ecx //将ecx寄存器的值写入eax寄存器的地址
008F3C62 mov edx,dword ptr [ebp-20h] //将tmp.b的值写入edx寄存器
008F3C65 mov dword ptr [eax+4],edx //将edx寄存器的值写入eax寄存器的地址
008F3C68 mov ecx,dword ptr [ebp-1Ch] //将tmp.c的值写入ecx寄存器
008F3C6B mov dword ptr [eax+8],ecx //将ecx寄存器的值写入eax寄存器的地址
008F3C6E sub esp,0Ch
008F3C71 mov edx,esp
008F3C73 mov eax,dword ptr [tmp1]
008F3C76 mov dword ptr [edx],eax
008F3C78 mov ecx,dword ptr [ebp-0Ch]
008F3C7B mov dword ptr [edx+4],ecx
008F3C7E mov eax,dword ptr [ebp-8]
008F3C81 mov dword ptr [edx+8],eax
008F3C84 call fun1 (08F11E5h)
从上面的汇编代码可以看出,在函数参数为12字节的时候,其参数带入方式和小于等于8字节的时候不同,在这里没有直接的push参数,而是先在main函数的栈顶向上移动12字节,然后将参数的数据拷贝到main函数栈顶开辟的内存中。其方式如图
三、函数返回值返回
1.<=8KB
返回值为4字节的时候
int fun1(int a, int b)
{
int c = a + b;
return c;
}
int main()
{ int a = 10;
int b = 20;
int c = fun1(a,b);
return 0;
}
先运行到函数返回值代码处,查看汇编码
return c;
003413E7 mov eax,dword ptr [c]
接收返回值处查看汇编码
int c = fun1(a,b);
……
00343C44 call fun1 (03411EAh)
00343C49 add esp,8
00343C4C mov dword ptr [c],eax //将eax寄存器的值写入到c的地址中,写入四字节
如汇编码可知,四字节的返回值利用寄存器带回,然后将寄存器的值写入到接受返回值的变量中
返回值为8字节的时候
struct Tmp
{
int a;
int b;
};//大小为8字节
struct Tmp fun1(int a, int b)
{
struct Tmp c;
c.a = a;
c.b = b;
return c;
}
int main()
{
int a = 10;
int b = 20;
struct Tmp c;
c = fun1(a,b);
return 0;
}
先将代码运行到函数返回值出,查看其汇编代码
return c;
00B7142A mov eax,dword ptr [c]
00B7142D mov edx,dword ptr [ebp-8]
将代码运行到接收返回值处,查看其汇编代码
c = fun1(a,b);
……
00D63C44 call fun1 (0D611EFh)
00D63C49 add esp,8
00D63C4C mov dword ptr [c],eax
00D63C52 mov dword ptr [ebp-20h],edx
在接收返回值时候,将寄存器的值写入到接收返回值的变量中。
2.>8KB
struct Tmp
{
int a;
int b;
int c;
};//大小为12字节
struct Tmp __stdcall fun1(int a, int b)
{
struct Tmp c;
c.a = a;
return c;
}
int main()
{
int a = 10;
int b = 20;
struct Tmp c;
c = fun1(a,b);
return 0;
}
c = fun1(a,b);
00953C3C mov eax,dword ptr [b]
00953C3F push eax
00953C40 mov ecx,dword ptr [a]
00953C43 push ecx
00953C44 lea edx,[ebp-0FCh]
00953C4A push edx
00953C4B call fun1 (09511F4h)
从上面的汇编码可以看出来,当函数的返回值为12字节的时候,在参数入栈的最后还入栈了一个寄存器,该寄存器中存储的是一块位于main栈帧上的内存。
return c;
00951A44 mov eax,dword ptr [ebp+8]
00951A47 mov ecx,dword ptr [c]
00951A4A mov dword ptr [eax],ecx
00951A4C mov edx,dword ptr [ebp-0Ch]
00951A4F mov dword ptr [eax+4],edx
00951A52 mov ecx,dword ptr [ebp-8]
00951A55 mov dword ptr [eax+8],ecx
00951A58 mov eax,dword ptr [ebp+8]
而在返回值的时候,先从ebp-8位置取值,去除的正好是参数入栈之后入栈的一块main函数栈帧上的地址,然后将返回的数据写入到该块内存上。
由此可见,当返回值大于8字节的时候是预先在调用方的栈帧上预留一块内存,作为函数返回值存储位置,最后返回值的时候,将返回值的数据写入到该段内存。大致结构如图
四、函数栈帧回退
函数栈帧的回退分为两步,一步是函数栈帧的回退,另一步时函数参数的清除。
函数栈帧回退汇编码如下
00EC13E0 pop edi 将栈帧开辟时候入栈的寄存器出栈
00EC13E1 pop esi
00EC13E2 pop ebx
00EC13E3 mov esp,ebp 让esp = ebp
00EC13E5 pop ebp 让ebp等于栈帧开辟时候入栈的main栈帧,并将其出栈
00EC13E6 ret 返回
经过上面的过程,栈帧就已经回退到了main函数,也就是调用方的栈帧。
下一步,函数参数的清除
00EC1484 call _fun1 (0EC118Bh)
00EC1489 add esp,8 让esp+8清除参数内存
六、4种调用约定
1.定义:
(参考《程序员的自我修养》第10章)
何为调用约定?即调用方和被调用方对于函数的如何调用必须有一个明确的约定,只有双方都遵循这样的约定,函数才能被调用。
2.内容:
约定一般有如下几个方面:
(1)函数参数的传递顺序和方式:通过栈传递?使用寄存器传递?从右向左?从左向右?
(2)栈的维护方式:出栈由函数调用方完成?函数本身完成?
(3)名字修饰的策略:c语言中对于fun函数的声明: int _cdcel foo()
3.种类
1.cdecl为c/c++默认调用惯约定(书中将调用约定也叫调用惯例)
2.thiscall调用约定:为c++的一种特殊调用约定,用于对类成员函数的调用。
VC里是this指针存放在ecx寄存器中,参数从右向左压栈;
gcc中,thiscall和cdecl完全一样,只是将this看做是函数的第一个参数