《程序员的自我修养-链接加载与库》读书笔记(2)---程序环境-内存

来源:互联网 发布:win10如何软件禁用网络 编辑:程序博客网 时间:2024/05/29 19:30

程序的运行环境由内存、运行库、系统调用(作为程序和操作系统内核的中介)组成,下面几节将分别介绍这几部分

 

程序的内存布局:

现代的应用程序都运行在一个内存空间中,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)//ebpesp此时须相等,不等跳转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

 

可以看到,通过在函数调用前占固定大小的一些空间,很容易对汇编码作修改,从而实现我们的钩子技术

 

调用惯例:

函数调用方和被调用方须就如何调用有一个约定,称为调用惯例。

调用惯例规定如下几方面:

函数参数的传递顺序和方式

栈的维护方式

名字修饰策略:不同的调用惯例有不同的修饰策略

 

edeclC语言的默认惯例

naked call:用在特定的场合,编译器不产生任何保护寄存器的代码

 

C++有一个特殊的调用惯例,叫thiscallVCthis指针存在于ecx寄存器中,对于gccthiscallcdecl一样,只是将this作为第一个参数

 

函数返回值传递:

前面的例子中,小于4字节的返回值使用eax传递,对于5_8字节对象,一般使用eaxedx联合返回,其中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:向操作系统申请虚拟空间,可通过参数映射到文件或纯空间;

Glibcmalloc函数在处理空间请求时,对于小于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向操作系统申请。

原创粉丝点击