10.1 程序的内存布局
现代的应用程序都运行在一个内存空间里,在32位系统里,这个内存空间拥有4GB的寻址能力。
一般来讲,应用程序的内存空间里有如下默认的区域:
- 栈 用于维护函数调用的上下文
- 堆 用来容纳应用程序动态分配的内存区域
- 可执行文件映像
- 保留区 并不是一个单一的内存区域,而对受到保护而禁止访问的内存区域的总称。如地址为0的内存。
- 动态链接库映射区 在linux下,如果可执行文件依赖于其他共享库,则会从0x4000 0000 开始的地址分配相应的空间。
10.2 栈与调用惯例
在经典的操作系统中,栈总是向下增长的。
栈顶由称为esp的寄存器进行定位。
栈底由称为ebp的寄存器进行定位。
压栈使栈顶的地址减小,出栈是栈顶的地址变大。
栈保存了一个函数调用所需要维护的信息,被称为堆栈帧。堆栈帧一般包括以下几个方面:
- 函数的返回地址和参数
- 临时变量
- 保存的上下文
函数调用过程中的栈:
https://blog.csdn.net/qq_31567335/article/details/84782202
10.2.2调用惯例
函数的调用方和被调用方对函数如何调用有着统一的理解。比如参数的入栈顺序,寄存器的使用与恢复。
这称之为调用惯例,一个调用惯例一般会规定如下几个方面人内容:
- 函数参数的传递方式与顺序
- 栈的维护方式
- 名字修饰
在c语言中,存在多个调用惯例,默认的调用惯例是cdecl。
参数传递 从右至左的顺序压参数入栈。
出栈方 函数调用方
名字修饰 直接在函数名称前加1个下划线
10.2.3 函数返回值传递
eax是函数传递返回值的通道。
但是eax本身只有4个字节。
对于返回5-8字节的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式。
超过8个字节的,比较复杂。
/*returnTest.c*/
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();
}
反汇编结果:
00000560 <return_test>:
560: 55 push %ebp
561: 89 e5 mov %esp,%ebp
563: 57 push %edi
564: 56 push %esi
565: 53 push %ebx
566: 83 c4 80 add $0xffffff80,%esp
569: e8 71 00 00 00 call 5df <__x86.get_pc_thunk.ax>
56e: 05 92 1a 00 00 add $0x1a92,%eax
573: c6 85 74 ff ff ff 00 movb $0x0,-0x8c(%ebp)
57a: 8b 45 08 mov 0x8(%ebp),%eax
57d: 89 c2 mov %eax,%edx
57f: 8d 85 74 ff ff ff lea -0x8c(%ebp),%eax
585: b9 80 00 00 00 mov $0x80,%ecx
58a: 8b 18 mov (%eax),%ebx
58c: 89 1a mov %ebx,(%edx)
58e: 8b 5c 08 fc mov -0x4(%eax,%ecx,1),%ebx
592: 89 5c 0a fc mov %ebx,-0x4(%edx,%ecx,1)
596: 8d 5a 04 lea 0x4(%edx),%ebx
599: 83 e3 fc and $0xfffffffc,%ebx
59c: 29 da sub %ebx,%edx
59e: 29 d0 sub %edx,%eax
5a0: 01 d1 add %edx,%ecx
5a2: 83 e1 fc and $0xfffffffc,%ecx
5a5: c1 e9 02 shr $0x2,%ecx
5a8: 89 ca mov %ecx,%edx
5aa: 89 df mov %ebx,%edi
5ac: 89 c6 mov %eax,%esi
5ae: 89 d1 mov %edx,%ecx
5b0: f3 a5 rep movsl %ds:(%esi),%es:(%edi)
5b2: 8b 45 08 mov 0x8(%ebp),%eax
5b5: 83 ec 80 sub $0xffffff80,%esp
5b8: 5b pop %ebx
5b9: 5e pop %esi
5ba: 5f pop %edi
5bb: 5d pop %ebp
5bc: c2 04 00 ret $0x4
000005bf <main>:
5bf: 55 push %ebp
5c0: 89 e5 mov %esp,%ebp
5c2: 83 c4 80 add $0xffffff80,%esp
5c5: e8 15 00 00 00 call 5df <__x86.get_pc_thunk.ax>
5ca: 05 36 1a 00 00 add $0x1a36,%eax
5cf: 8d 45 80 lea -0x80(%ebp),%eax
5d2: 50 push %eax
5d3: e8 88 ff ff ff call 560 <return_test>
5d8: b8 00 00 00 00 mov $0x0,%eax
5dd: c9 leave
5de: c3 ret
相比于书中的反汇编结果,main函数没有拷贝返回结果这一步骤。应该是编译器做了优化。
简单总结:
(书上介绍的步骤)
main函数先申请足够大的栈空间(add $0xffffff80,%esp)
除了够本地变量n存储外,还为返回结果分配了一个临时空间temp。
将temp的地址作为隐含参数传入return_test函数
return_test在函数结束处,将对象拷贝到temp
return_test用eax返回temp的地址
main将temp中的值拷贝到n对应的地址。
用gcc( 6.3.0)反汇编的结果:
直接将变量n的地址作为参数传入,所以只需要一次拷贝。
10.3 堆与内存管理
因为栈上的数据在函数返回的时候会被释放掉,无法传递至函数外部。需要返回至函数外部的数据,可以申请放入堆中。
在c语言中,使用malloc函数申请内存空间。那么malloc是如何实现的呢?
- 把内存管理交给操作系统内核。因为内核本身就具有内存管理的功能。
这样做性能会比较差。因为系统调用的开销大。 - 程序向操作系统申请一块适当大小的堆空间,然后由自己对内存进行管理。具体来讲,管理着程序堆空间分配的,往往是程序的运行库。
10.3.2 linux进程管理
linux提供了两种堆空间分配的系统调用:brk(), mmap()
brk()的作用实际上就是设置进程数据段的结束地址。即它可以扩大或者缩小数据段。
如果将数据段的结束地址向高地址移动,那么扩大那部分空间就可以被我们使用,把这块空间拿来作为堆空间是最常见的做法之一。
mmap()的使用是向操作系统申请一段虚拟地址。这块虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个文件时,这块空间称为匿名空间,可以拿来作为堆空间。
glibc的malloc函数这样处理用户的空间请求:
对于小于128KB的请求,它会在现有堆空间里面,按照堆分配算法为它分配一块空间并返回。
对于大于128KB的请求,它会使用mmap()函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。
### 10.3.3 windows进程堆管理
略
10.3.4 堆分配算法
- 空闲链表
把堆中各个空闲的块按照链表的方式连接起来,当用户申请一块空间时,可以遍历整个链表,直到找到合适大小的块并将它拆分。当用户释放空间时将它加入到空闲链表中。
空闲链表的实现非常简单,但在释放空间的时候,给定一个已经分配块的指针,堆无法确定这个块的大小。
一个简单的解决办法是用户申请k个字节的空间的时候,实际分配k+4个字节,这4个字节用于存储块的大小。但是这种思路存在很多问题,例如,一旦链表或者记录长度的那4字节被损坏,整个堆就无法工作。而这些数据恰恰容易被越界读写接触到。
- 位图
位图是一种更加稳健的分配方式。
核心思想是将整个堆划分为大量大小相同的块。当用户请求内存的时候,问题分配整数个块的空间给用户。第一个块称之为已分配空间的头。
因此每个块只有可能是三种状态中的一种:头/主体/空闲。因此仅需要2位即可表示一个块。
位图的优点:
速度快。由于整个堆的空闲信息保存在一个数组中,访问数组时容易命中cache。
稳定性好。简单备份位图即可
易于管理
缺点:
分配内存时容易产生碎片
如果堆很大,但是块又很小,那么位图会很大,失去cache命中高的优势。而且位图本身也浪费一定空间。
- 对象池
略
写到后面。
这本书读到这一章就不准备继续下去了。
- 最主要的原因是没时间。当前工作实在是太饱和了。
从9.9号拿到这本书,到现在勉强看到这么多。3个多月过去了。而且这段时间,我就只在看这一本书。这个现状实在是令人担忧,希望工作能有所改变吧。 - 后面的内容与自己现在掌握的知识相差更远了。而且当前来看,无任何应用的机会。所以先把过于有限的时候投入更紧迫的需要学习的内容当中去。
现在养成了看书就要记笔记的习惯,虽然记得内容有时候看起来挺傻逼的。但是我相信这确实是一个好习惯。对于后面的复习,深入理解一定是有帮助的。
2018/12/18 于湖畔花园