程序员的自我修养——第十章——内存
注:这一章的内容比较经典,之前看“深入理解计算机系统”的时候,也有看到栈帧(Stack Frame),但是不是很清楚,通过这一章的讲解,更清楚了。如果能再结合讲讲GDB调试的话就更完美了。
栈:栈用于维护函数调用的上下文,离开了站函数调用就没法实现。
堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。
Linux进程地址空间内存布局:
在操作系统中,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶的地址增大。
程序栈实例
在栈底的地址是0xbfffffff,而esp寄存器表明了栈顶,地址为0xbffffff4。在栈上压入数据会导致esp减小,弹出数据使得esp增大。
栈保存了一个函数调用所需要的维护信息,这通常被称为“栈帧”(Stack Frame)或活动记录。堆栈帧一般包括如下几方面内容:
·函数的返回地址和参数
·临时变量:函数的非静态局部变量
·保存的上下文:在函数调用前后需要保持不变的寄存器
一个函数的活动记录用ebp和esp这两个寄存器划定范围:esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。
函数的活动记录
固定不变的ebp可以用来定位函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等。
i386下函数的调用过程:
·把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
·把当前指令的下一条指令的地址压入栈中
·跳转到函数体执行
在i386函数体的“标准”开头是这样的:
·push ebp: 把ebp压入栈中(称为old ebp)
·mov ebp, esp: ebp = esp (这时ebp指向栈顶,而此时栈顶就是old epb)
·【可选】sub esp, xxx: 在栈上分配xxx字节的临时空间
·【可选】push xxx:如有必要,保存名为xxx寄存器(可重复多个)
·【可选】pop xxx:如有必要,恢复保存过的寄存器(可重复多个)
·mov esp, ebp: 恢复esp同时回收局部变量空间
·pop ebp: 从栈中恢复保存的ebp的值
·ret: 从栈中取得返回地址,并跳转到该位置
函数的调用惯例:
·函数参数的传递顺序和方式
·栈的维护方式
·名字修饰策略
在C语言中,默认调用惯例是cdecl,任何一个没有显示指定调用惯例的函数都默认是cdecl惯例: 参数传递——从右至左顺序压参数入栈 ; 出栈方——函数调用方 ; 名字修饰——直接在函数名前加1个下划线。
有这样一个函数:
int foo(int n, float m)
{
int a = 0, b = 0;
...
}
foo被修饰之后变为_foo
调用:
·将m压入栈
·将n压入栈
·调用_foo,此步又分为两步:
- 将返回地址(即调用_foo之后的下一条指令的地址)压入栈:
- 跳转到_foo执行
foo函数栈布局
如果我们有如下代码:
void f(int y)
{
printf("y = %d",y);
return 0;
}
int main()
{
int x = 1;
f(x);
return 0;
}
这些代码形成的堆栈格局如下:
图中箭头表示地址的指向关系,而带下划线的代码表示当前执行的代码。
注意:在理解的时候牢记:栈是向下增长的(但地址的大小不是),堆栈最下面的部分是当前执行的部分。比如说在main()中调用了f(),f()的信息位于堆栈的最下方,上方的数据信息表示的是main部分的。
函数返回值:
对于返回5-8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。eax返回低4字节,edx返回高4字节。
超过8字节的情况该怎么办?
#include<stdio.h>
typedef struct big_thing
{
char buf[128];
}big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0] = 0;
return b;
}
int main()
{
big_thing n = return_test();
return 0;
}
汇编:(这里使用的是VS11 beta 运行的结果,跟书上有不同)
int main()
{
003C1480 push ebp
003C1481 mov ebp,esp
003C1483 sub esp,25Ch
003C1489 push ebx
003C148A push esi
003C148B push edi
003C148C lea edi,[ebp-25Ch]
003C1492 mov ecx,97h
003C1497 mov eax,0CCCCCCCCh
003C149C rep stos dword ptr es:[edi]
003C149E mov eax,dword ptr ds:[003C8000h]
003C14A3 xor eax,ebp
003C14A5 mov dword ptr [ebp-4],eax
big_thing n = return_test();
003C14A8 lea eax,[ebp-1D0h]
003C14AE push eax
003C14AF call return_test (03C10E6h)
003C14B4 add esp,4
003C14B7 mov ecx,20h
003C14BC mov esi,eax
003C14BE lea edi,[ebp-258h]
003C14C4 rep movs dword ptr es:[edi],dword ptr [esi]
003C14C6 mov ecx,20h
003C14CB lea esi,[ebp-258h]
003C14D1 lea edi,[n]
003C14D7 rep movs dword ptr es:[edi],dword ptr [esi]
return 0;
003C14D9 xor eax,eax
}
003C14DB push edx
003C14DC mov ecx,ebp
003C14DE push eax
003C14DF lea edx,ds:[3C150Ch]
003C14E5 call @_RTC_CheckStackVars@8 (03C1087h)
003C14EA pop eax
003C14EB pop edx
003C14EC pop edi
003C14ED pop esi
003C14EE pop ebx
003C14EF mov ecx,dword ptr [ebp-4]
}
003C14F2 xor ecx,ebp
003C14F4 call @__security_check_cookie@4 (03C101Eh)
003C14F9 add esp,25Ch
003C14FF cmp ebp,esp
003C1501 call __RTC_CheckEsp (03C113Bh)
003C1506 mov esp,ebp
003C1508 pop ebp
003C1509 ret
003C150A mov edi,edi
003C150C add dword ptr [eax],eax
003C150E add byte ptr [eax],al
003C1510 adc al,15h
003C1512 cmp al,0
003C1514 js main+95h (03C1515h)
003C1516 ?? ??
003C1517 inc dword ptr [eax+20000000h]
003C151D adc eax,6E003Ch
函数return_test()返回一个128字节的结构。不可能通过eax来传递。
003C14A8 lea eax,[ebp-1D0h]
将栈上的一个地址(ebp-1D0h)存储在eax里,接着下一行
003C14AE push eax
将这个地址压入栈中,然后就接着调用return_test函数。这个从形式上无疑是将数据ebp-1D0h作为参数传入return_test函数,然而return_test函数没有参数,因此我们可以讲这个数据称为是“隐含参数”。
003C14D7 rep movs dword ptr es:[edi],dword ptr [esi]
rep movs 是一个复合指令,大致意思是重复movs指令制导ecx寄存器为0.于是“rep movs a,b”的意思就是将b指向位置上的若干个双字(4字节)拷贝到由a指向的位置上,拷贝双字的个数由ecx指定。实际上这条指令相当于:
memcpy(a ,b ,ecx * 4)
下面四行
003C14B7 mov ecx,20h
003C14BC mov esi,eax
003C14BE lea edi,[ebp-258h]
003C14C4 rep movs dword ptr es:[edi],dword ptr [esi]
含义相当于:
memcpy(ebp-258h, eax , 0x20 * 4);
即将eax指向位置上的0x20个双字拷贝到ebp-258h的位置,ebp-258h这个地址就是变量n的地址。
可见return_test返回的结构体仍然是由eax传出的,只不过这次eax存储的式结构体的指针。
那么return_test具体是如何返回一个结构体的呢?
big_thing return_test()
{
00A813D8 add byte ptr [ebx+56h],dl
00A813DB push edi
00A813DC lea edi,[ebp-14Ch]
00A813E2 mov ecx,53h
00A813E7 mov eax,0CCCCCCCCh
00A813EC rep stos dword ptr es:[edi]
00A813EE mov eax,dword ptr ds:[00A88000h]
00A813F3 xor eax,ebp
00A813F5 mov dword ptr [ebp-4],eax
big_thing b;
b.buf[0] = 0;
00A813F8 mov eax,1
00A813FD imul eax,eax,0
00A81400 mov byte ptr b[eax],0
return b;
00A81408 mov ecx,20h
00A8140D lea esi,[b]
00A81413 mov edi,dword ptr [ebp+8]
00A81416 rep movs dword ptr es:[edi],dword ptr [esi]
00A81418 mov eax,dword ptr [ebp+8]
}
根据rep movs的功能,下面4条指令
00A81408 mov ecx,20h
00A8140D lea esi,[b]
00A81413 mov edi,dword ptr [ebp+8]
00A81416 rep movs dword ptr es:[edi],dword ptr [esi]
可以翻译为:
memcpy([ebp + 8], &b, 128);
在这里,[ebp + 8]指的是 *(void * *)(ebp + 8), 即将地址ebp + 8上存储的值作为地址,由于ebp实际上保存的旧的ebp,因此ebp+4指向压入栈中的返回地址,ebp+8则指向函数的参数。
而我们知道,return_test是没有真正参数的,只有一个“伪参数”,由函数调用方悄悄传入,那就是ebp-1D0h这个值,也就是说[ebp + 8] = old_ebp -1D0h
int main()
{
003C1480 push ebp
003C1481 mov ebp,esp
003C1483 sub esp,25Ch
003C1489 push ebx
003C148A push esi
003C148B push edi
003C148C lea edi,[ebp-25Ch]
我们可以看到main函数在保存了ebp之后,就直接将栈增大了25Ch个字节,区间[ebp-1D0h, ebp -1D0h+128]落在[ebp, ebp - 25Ch]内部。
大概的思路是这样的:
·首先main函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里成为temp
·将temp对象的地址作为隐藏参数传递给return_test函数
·return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出
·return_test返回后,main函数将eax指向的temp对象的内容拷贝给n
void return_test(void *temp)
{
big_thing b;
b.buf[0] = 0;
memcpy(temp, &b, sizeof(big_thing));
eax = temp;
}
int main()
{
big_thing temp;
big_thing n;
return_test(&temp);
memcpy(&n, eax, sizeof(big_thing));
}
C语言在函数返回时使用一个临时的栈上的内存区域作为中转,结果返回值对象会被拷贝两次,因而不到万不得已,不要轻易返回大尺寸对象。
C++中如果返回较大的对象会有非常多的额外开销,因此C++程序进了避免返回对象。C++返回值优化技术,在某些场合下对象的拷贝减少到1次,例如:
cpp_obj return_test()
{
return cpp_obj();
}
Linux内存堆管理:
Linux提供了两种堆空间分配方式,即两个系统调用:一个是brk() 系统调用,另外一个是mmap().
brk()的作用实际上就是设置紧凑的数据段的结束地址,即它可以扩大或缩小数据段。
mmap()的作用和Windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,这块虚拟地址空间可以映射到某个文件。
#include <sys/mman.h>
void *mmap(void *start,size_t length,int prot,int flags, int fd, off_t offset);
int munmap(void *start, size_t length);
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果其实地址设置为0,那么Linux系统会自动挑选合适的起始地址。 prot/flags这两个参数用于设置申请的空间的权限(可读、可写、可执行)以及映射类型(文件映射、匿名空间等),最后两个参数用于文件映射时指定文件描述符合文件偏移。
注:mmap有很多用法,以后应该有博文会涉及
用mmap实现malloc:
void *malloc(size_t nbytes)
{
void *ret = mmap(0,nbytes,PROT_READ | PROT_WRTIE, MAP_PRIVATE | MAP_ANONYMOUS,0,0);
if(ret == MAP_FAILED)
return 0;
return ret;
}
在一台只有512MB内存的和1.5G交换空间的机器上测试malloc的最大空间申请数,不论怎么样都不会超过1.9GB.
Windows 进程堆管理:
对于Windows来说,每个线程默认的栈大小是1MB,在线程启动时,系统会为它在进程中分配相应的空间作为栈。
对管理器提供了一套与堆相关的API可以用来创建、分配、释放和销毁堆空间:
·HeapCreate: 创建一个堆
·HeapAlloc:在堆里面分配内存
·HeapFree: 释放已经分配的内存
·HeapDestroy:销毁一个堆
HeapCreate就是创建一个堆空间,它会向操作系统批发一块内存空间,HeapAlloc就是在堆空间里面分配一块小的空间并返回给用户。如果堆空间不足的话,它还会通过VirtualAlloc向操作系统批发更多的内存直到操作系统也没有空间分配为止。
malloc申请的内存,进程结束以后还会不会存在?
答:不存在。当进程结束以后,所有与进程相关的资源,包括进程的地址空间、物理内存、打开的文件、网络链接等都被操作系统关闭或者回收。
堆分配算法:
- 空闲链表
空闲链表的方法实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,知道找到合适大小的块并且将它拆分。当用户释放空间时将它合并到空闲链表中。
空闲链表是这样一种结构,在堆里的每个空闲空间的开头(或结尾)有一个头(header),头结构里记录了一个(prev)和下一个(next)空闲的地址,也就是说,所有空闲的块形成了一个链表。
- 位图:
将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一块我们称为头(head),其余块称为主体(body)。使用一个整数数组来记录块的使用情况,由于每个块只有头、主体、空闲三种状态,因此只需要两位即可以表示一个块,因此称为位图。
优缺点:
·速度快
·稳定性好
·块不需要额外信息,易于管理
·分配内存的时候容易产生碎片
·堆很大,块很小,那么位图会很大