程序的运行环境由内存、运行库、系统调用(作为程序和操作系统内核的中介)组成,下面几节将分别介绍这几部分
程序的内存布局:
现代的应用程序都运行在一个内存空间中,32bit系统下,内存空间拥有4G的寻址能力,其中,不同的地址区间有不同地位,大多数操作系统都将一部分挪给内核使用,称为内核空间,像win下会将高地址的2G空间作为内核空间,linux将高地址的1G空间作为内核空间。
其余空间称作用户空间,一般分为以下几段:
栈:用于维护函数调用上下文,一般从高地址向地址方向增长
堆:容纳程序动态分配的内存,堆通常在栈的下方,一般从低地址向高地址方向增长,也即和栈增长方向相反,二者从两边向中间增长
可执行文件映像区
保留区:非连续单一的区域,是对内存中受到保护禁止访问的内存区总称
动态链接库映射区:用于映射程序装载的动态链接库
栈:
栈是计算机中的重要数据结构,为先进后出的数据存储方式,函数调用及临时变量都是利用栈实现的。
为深入理解栈在函数调用中的实现,我们先看一个例子:
#include <string>
#include <stdio.h>
using namespace std;
int foo()
{
return 123;
}
int main(int argc, char* argv[])
{
int ret = foo();
printf("return value of foo() is %d./n", ret);
return 0;
}
几个概念说明:
在i386下,栈顶使用esp寄存器进行定位,ebp寄存器则指向活动记录的一个固定位置,一般称为帧指针
栈保存了一个函数调用的所有信息,成为堆栈帧,其包括如下内容:
函数返回地址和参数
临时变量
上下文
i386下堆栈帧如下:
参数
返回地址
ebpà
OLD EBP
局部变量
其它数据
保存的寄存器
esp-à
即先将函数参数压栈,再压栈当前指令的下一条指令,再跳转到函数中执行
下面我们来分析汇编码:
汇编码如下:
9:
10: int main(int argc, char* argv[])
11: {
00401060 push ebp //老的ebp压栈,便于函数返回时恢复以前的ebp
00401061 mov ebp,esp//ebp指向新栈栈顶,即给esp赋值
00401063 sub esp,44h//在栈上预留空间,请考虑栈是向下生长的,减小esp相当于预留栈空间,即68字节
00401066 push ebx
00401067 push esi
00401068 push edi//从ebp-44h地址开始压栈几个寄存器,确保调用前后几个寄存器值不变
//下面四步为编译器加入的调试信息,将栈上分配的空间全部初始化值为0xCC
00401069 lea edi,[ebp-44h]
0040106C mov ecx,11h
00401071 mov eax,0CCCCCCCCh
00401076 rep stos dword ptr [edi]
12: int ret = foo();
00401078 call @ILT+0(foo) (00401005)//即跳转到foo入口:
00401005地址处对应指令:jmp foo (00401030)
0040107D mov dword ptr [ebp-4],eax//通过eax传递返回值
13:
14: printf("return value of foo() is %d./n", ret);
00401080 mov eax,dword ptr [ebp-4]
00401083 push eax
00401084 push offset string "return value of foo() is %d./n" (0042001c)
00401089 call printf (004011d0)
0040108E add esp,8//使用参数存放以及eax所占空间
15: return 0;
00401091 xor eax,eax//eax置零
16: }
00401093 pop edi//调用完毕后先恢复压栈的几个寄存器
00401094 pop esi
00401095 pop ebx
00401096 add esp,44h//释放预留栈空间
00401099 cmp ebp,esp
0040109B call __chkesp (00401250)//ebp和esp此时须相等,不等跳转chekesp函数处理,报异常
004010A0 mov esp,ebp//恢复esp为调用前位置
004010A2 pop ebp//恢复老的ebp
004010A3 ret//取得返回地址
钩子技术实现原理:
win下,有些函数调用标准进入指令序列如下:
nop
nop
nop
nop
nop
FUNCTION: //函数实际入口
Mov edi, edi //2字节占位符
Push ebp //标准进入序列
Mov ebp, esp
每Nop占用一个字节
实际中,完全可以对函数内容作如下修改:
LABEL:
Jmp REPLEACE_FUNC//占五字节
FUNCTION: //函数实际入口
Jmp LABEL //近跳指令,占2字节
Push ebp //标准进入序列
Mov ebp, esp
可以看到,通过在函数调用前占固定大小的一些空间,很容易对汇编码作修改,从而实现我们的钩子技术
调用惯例:
函数调用方和被调用方须就如何调用有一个约定,称为调用惯例。
调用惯例规定如下几方面:
函数参数的传递顺序和方式
栈的维护方式
名字修饰策略:不同的调用惯例有不同的修饰策略
edecl为C语言的默认惯例
naked call:用在特定的场合,编译器不产生任何保护寄存器的代码
C++有一个特殊的调用惯例,叫thiscall,VC中this指针存在于ecx寄存器中,对于gcc,thiscall和cdecl一样,只是将this作为第一个参数
函数返回值传递:
前面的例子中,小于4字节的返回值使用eax传递,对于5_8字节对象,一般使用eax和edx联合返回,其中eax存储低四字节,edx存储高字节;
下面我们来研究大于8字节的返回类型参数传递原理:
(下面为一段代码的微码,蓝色为编译器填充部分的微码表示)
typedef struct big_thing
{
char buf[128];
} big_thing;
big_thing return_test(void* temp)
{
big_thing b;
b.puf[0] = 0;
memcpy(temp, &b, sizeof(big_thing));//数据拷给temp
eax = temp;//temp地址指向eax
return b;
}
int main()
{
big_thing n = return_test();
//
big_thing temp;//栈上开辟一块空间
big_thing n;
return_test(&temp);//作为隐参传给return_test
memcpy(&n, eax, sizeof(big_thing));//完成赋值
}
可以看到,如果返回值类型尺寸太大,返回时值对象会被copy两次,性能较低,所以轻易不要直接返回大对象。
堆:
堆是一块巨大的内存空间,常常占据虚拟空间的绝大部分。在该空间中,程序申请的内存在程序主动放弃前会一直保持有效。
具体来说,运行库会向操作系统申请一大块堆空间,由程序申请,从而降低频繁系统调用,缩减系统开销。
下面我们来看看运行库是如何向操作系统申请内存的:
Linux进程堆管理:
Linux提供两种方式申请堆空间,如下:
Brk:设置进程数据段的结束地址,将数据段结束地址向高地址移动,扩大的空间就可以作为堆空间;
Mmap:向操作系统申请虚拟空间,可通过参数映射到文件或纯空间;
Glibc的malloc函数在处理空间请求时,对于小于128K的请求,则直接在现有堆空间中使用堆分配算法分配一块返回,对于大于128K的申请,则先由mmpa分配一块匿名空间,而后在其中为用户分配空间。Mmap申请内存时,大小必须为系统页的整数倍,以避免出现大量碎片。
Win进程堆管理:
Win使用一个叫virtualAlloc的方法向系统申请空间,并提供四个API来进行堆管理:
HeapCreate:创建一个堆,向系统一次批发一大块内存
HeapAlloc:在堆中分配内存
HeapFree:释放已分配内存
HeapDestroy:销毁堆
这几个函数由malloc包装,用户不必关心
Win系统提供两个堆管理器,一份是win子系统和内核之间的接口,位于NTDLL.dll中,用户程序、运行库和子系统使用该库;
内核Ntoskrnl.exe中,也有一个堆管理器,负责内核堆空间分配,内核,内核组件,驱动使用的堆都是用该堆管理器;
堆分配算法:
1. 空闲链表
空闲链表法将堆空闲块以链表方式串起来,用户申请时,堆链表遍历,返回合适大小并将其拆分,该方法缺点是一旦链表被破坏或记录已分配堆长度字节被破坏,堆无法正常工作。
2. 位图
将堆切分为等大的块,使用另外一张表记录块使用状态,分为头/主体/空闲,即使用2bit即能表示一个块,因此成为位图。
该方法优点为速度快,稳定性好,易于管理;缺点是分配时易产生碎片,另外,如果堆很大,块很小,那么位图将很大,占用空间,也可能失去cache命中率高的优势。
3. 对象池
特定场合下,分配对象大小较固定,我们可以基于该特征设计高效的堆算法,称为对象池,每次返回固定大小空间。
实际使用时,如glibc下,多采用混合策略,小于64bit的使用对象池,大于512的使用最佳适配算法(接到内存申请时,在空闲块表中找到一个不小于请求的最小空块进行分配),介于二者之间的采取折中策略,对于大于128K的,直接由mmap向操作系统申请。