c和c++的内存服务模型与计算机网络里面的协议分层模型有点类似。计算机网络协议大体分为5层:应用层、传输层、网络层、数据链接层和物理层。其中,上层仅仅只需在下层所提供的服务之上构建自己的服务,而不用关心它的下下层所提供的服务。例如,http应用要完成相应的功能就只需考虑传输层tcp所能提供的服务,不需要知道网络层能提供什么服务。
回到内存分配的问题上来,其实,应用程序的内存分配也有明确的层级概念。当应用程序需要内存时,它管c++标准库要。所以应用程序只需知道c++标准库能给它提供内存配置的功能就够了。c++的new算子(操作符)1 提供虚拟内存分配的能力,这一点,c++程序员们心知肚明 2。通过编译器解释之后,new算子被展开,然后调用c++标准库提供的全局operator new(std::sizt_t)函数申请内存。
当你写下:
Foo* p = new Foo();
经编译器解释,然后生成类似下面的代码:
Foo* p;
// don't catch exceptions thrown by the allocator itself
void* raw = operator new(sizeof(Foo)); //step 1:调用全局的operator new操作符申请虚拟内存。
// catch any exceptions thrown by the ctor
try {
//step 2:调用placement new函数在raw开始的虚拟地址位置构造一个Foo对象。
//等价于((Foo*)raw)->Foo::Foo();
p = new(raw) Foo(); // call the ctor with raw as this
}
catch (...) {
// oops, ctor threw an exception
operator delete(raw);//step 3:如果构造函数抛出异常,则将分配的内存返还。
throw; // rethrow the ctor's exception
}
值得注意的是,当构造函数出现异常时,并不会调用析构函数,不过,会调用对象内部的非指针成员变量的析构函数3。所以如果直接用原始指针作为成员变量,那么在对象构造函数为指针成员变量分配资源之后,成功返回之前,如果抛出了异常,那就会导致资源泄漏4。所以,强调一下Koening&Moo认为的c++最重要的三个建议之一: 避免使用指针。但是完全不用指针几乎是不可能的,所以,作为替代,可以而且必须选择智能指针。
仔细考察后发现new的关注点是替应用程序向堆索要内存,并向上层应用提供虚拟内存。如果不考虑细节,new的行为其实等同于malloc的行为5。既然new提供向堆索要内存的服务,那么,它可以把精力集中在怎么与堆交互上--如何扩展有效堆内存,如何维护有效堆内存。
每个进程都有标准的虚拟空间布局:代码段、数据段、向上增长的堆以及向下的栈还有最顶层的受保护的系统空间。这里,有效堆空间是可以动态增长的,栈区的大小一般初始化为1M,所以,就地址可访问性来说,如果不特别设置,栈区的大小是固定的。过小的栈空间容易导致段错误就是这个原因。你可以试试定义一个大数组,或者搞个深度非常大的递归调用来观察这个现象6。
进程某时刻的快照:
快照中位于有效堆顶与栈之间的虚拟地址区间(图中虚拟地址区间(0x080DE000, 0XBF7EC000])对进程来说是无效的,这意味着这部分的内存不可访问,也就是说,这时如果要执行一条访存的cpu指令,且被访问的内存地址落在(0x080DE000, 0XBF7EC000]区间的话,会引发一个段错误,应用程序就死掉了。这里发生了什么呢?
解释上面的现象需要一定的操作系统基础。当cpu拿到一条访存指令时,它会到指令所给出的虚拟地址处去取数据,我们知道,硬件部分有一个叫MMU的单元,它负责将一个虚拟地址翻译成对应的物理地址。在这个翻译过程中,MMU会检查地址的合理性:例如地址是在用户空间还是内核空间,地址是否已经映射到了物理地址之类的。对应上面的情况,MMU检测到要访问的虚拟地址还没有映射到物理地址上,这是通过查看页表项中的有效位判断出来的。对于已经映射到物理内存上的页,页表项有效位置1,对于还没有映射的页,页表项有效位置0,这表示对应的数据不在物理内存中,也许位于磁盘交换区,等待对换进物理内存中。当还有一种可能,就是被访问的地址根本就不在进程的有效虚拟地址空间中。这里有效地址空间是指进程可访问而不会出现段错误的空间。既然存在有效地址空间和无效地址空间之分,那么,必然需要一定的数据结构记录这些信息。这个数据结构就是mm_struct:
struct mm_struct
{
struct vm_area_struct *mmap; //指向虚拟区间(VMA)链表
struct rb_root mm_rb; //指向red_black树
struct vm_area_struct *mmap_cache; //找到最近的虚拟区间
unsigned long(*get_unmapped_area)(struct file *filp,unsigned long addr,unsigned long
len,unsigned long pgoof,unsigned long flags);
void (*unmap_area)(struct mm_struct *mm,unsigned long addr);
unsigned long task_size; //拥有该结构体的进程的虚拟地址空间的大小
pgd_t *pgd; //指向页全局目录
int map_count; //虚拟区间的个数
spinlock_t page_table_lock; //保护任务页表和mm->rss
struct list_head mmlist; //所有活动mm的链表
unsigned long total_vm,locked_vm,shared_vm,exec_vm;
usingned long stack_vm,reserved_vm,def_flags,nr_ptes;
unsingned long start_code,end_code,start_data,end_data;
//代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data
unsigned long start_brk,brk,start_stack;
//start_brk和brk记录有关堆的信息,start_brk是用户虚拟地址空间初始化时堆的起始地址,
//brk是当前堆的结束地址,start_stack是栈的起始地址
unsigned long arg_start,arg_end,env_start,env_end;
//参数段的开始arg_start,结束arg_end,环境段的开始env_start,结束env_end
}
每个进程都有一个task_struct,学名PCB(进程控制块),内核用这个数据结构来记录和维护进程的所有信息,这个结构体有一个成员mm,它就是一个mm_struct类型的指针,用来描述进程的虚拟地址空间。mm_struct里面有个十分重要的成员变量mmap,一个vm_area_struct类型的指针,指向进程的vm_area_struct链表。这个链表很重要,链表的每一个vm_area_struct对象分别对应一个虚拟内存段(区间)7。vm_area_struct也是虚拟内存管理的最基本的管理单元,它描述的是一段连续的、具有相同访问属性的虚拟内存空间,大小为物理内存页大小的整数倍。
struct vm_area_struct
{
struct mm_struct *vm_mm; //虚拟地址区间所在的地址空间
unsigned long vm_start; //在vm_mm中的起始地址
unsigned long vm_end; //在vm_mm中的结束地址
struct vm_area_struct *vm_next; //指向下一个虚拟区间
struct rb_node vm_rb;
union
{
struct
{
struct list_head list;
void *parent;
struct vm_area_struct *head;
} vm_set;
struct raw_prio_tree_node prio_tree_node;
} shared;
struct list_head anon_vma_node;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; //对这个区间进行操作的函数
unsigned long vm_pgoff;
struct file* vm_file;
void *vm_private_data;
unsigned long vm_truncate_count;
}
因为每个虚拟区间来源可能不同,有的可能来自可执行映像(.text/.data/.bss…),有的可能来自共享库,而有的可能是动态内存分配的内存区(heap),这导致进程所使用到的虚存空间不连续,且各部分虚存空间的访问属性也可能不同,因此linux把虚拟内存分割成多个vm_area_struct,每个由vm_area_struct结构所描述的区间的范围不同,处理操作不同,并利用了虚拟内存处理例程vm_ops来抽象对不同来源的虚拟内存的处理方法8。
现在,继续解释上面提到的段错误现象。由于MMU检测到要访问的虚拟地址还没有映射到物理地址上,所以硬件产生了一个缺页异常,内核转到缺页异常处理例程,该例程查找current进程的vm_area_struct链表,如果被访问的虚拟地址落在了某个vm_area_struct区间,则该次缺页异常是一次成功的缺页异常,因为被访问的虚拟地址是一个有效地址,只是它此时不在任何一个物理页上,不过没关系,既然它是有效的,缺页异常例程就能够找到包含它的vm_area_struct区间,一旦找到这个vm_area_struct,缺页异常例程就能调用结构体中的成员vm_ops->nopage,这个函数会建立有效映射,当从缺页异常返回时,会再次执行上一条导致缺页异常的访存指令,这次访存就不存在问题了。不幸的是,我们遇到的情况不是这种,没有这么顺利,缺页异常例程无法找到包含被访问虚拟内存地址的vm_area_struct区间,既然找不到,那么就能推断出导致缺页异常的地址是个无效地址,果断产生一个段错误,并对current进程进行dump。
上面提到当cpu访问的虚拟地址不是有效地址时会产生一个段错误给执行该条cpu指令的进程,也看到了一张进程执行到某个时刻时的虚拟内存空间快照,并说明了图中虚拟地址区间(0x080DE000, 0XBF7EC000]中的虚拟地址均是无效地址。事实上,虚拟地址空间也是一种资源,被占用的虚拟地址空间是进程可以合理使用的虚拟地址资源,还有相当大的一部分虚拟地址空间未被占用,它们要么位于无效虚拟地址区间中,进程无法访问这些地址,要么是进程通过free()返还回去的虚拟地址空间或者由于函数调用返回进行退栈时返还回去的虚拟地址空间,这些被返还的地址空间是有效虚拟地址区间的一部分,是进程可以访问且不会导致段错误的区间,只不过,它们不是合理的虚拟地址区间,这意味着进程对其访问的结果是不确定的,如果还保留指向这些地址的指针,那这些指针就是野指针,千万不要访问野指针,因为野指针造成的错误是很难debug的。
回到核心话题上来:如何扩展有效堆内存呢。
堆在进程虚拟内存空间中是一段由三个边界条件确定的连续虚拟内存空间资源。如上图,三个边界条件分别是:起点,扩展点,最大可达点。图上的mapped region位于起点和扩展点之间,是有效的虚拟地址空间,其中的任何地址都可访问但不一定都是合理的,也许你不留心就会访问到一个野指针,千万留心!unmapped region位于扩展点和最大可达点之间,是无效的虚拟地址空间,对其中的任意虚拟地址的访问都会导致段错误。所谓扩展有效堆内存从视觉上来说就是将扩展点朝着最大可达点方向移动。brk和sbrk系统调用可以成全我们10。
int brk(const void *addr);
void* sbrk(intptr_t incr);
malloc和free(或者说new和delete)一起提供了扩展有效堆内存和维护有效堆内存的服务。前面已经介绍了扩展有效堆内存的手段,下面介绍维护有效堆内存的手段。
库函数malloc和free(或者说new和delete)维护关于有效堆内存的链表12,通过合理的定义链表的数据结构,库函数malloc和free(或者说new和delete)就能知道哪些地址资源已经被分陪给了进程,哪些地址资源还是进程未占用的。简单地理解,当进程调用malloc(size_t size)(或者new)时,malloc(size_t size)查找链表,根据使用的分配算法(例如:first-fit、slab…)找到合适的地址区间,然后将其归入到已分配内存链表中,在归还给空闲链表之前不再参与到虚拟内存资源的分配过程中。成功找到之后,malloc将地址区间的首地址返回给进程,进程便可以合理地使用这块内存。有时,由于size太大,malloc(size_t size)无法在空闲链表中找到一块合适的地址区块,它便会通过mmap系统调用扩展有效堆内存,以期带来更多有效的虚拟地址资源,然后从这块新得到的地址区间中分配进程申请的内存,并将剩下的地址区间加入到空闲链表中,将分配给进程的内存区间的首地址返回给进程以供其使用。
C++标准提供的三种形式的new操作符13:
void* ::operator new(std::size_t size) throw(std::bad_alloc);
//最常用的
//用法:new T
void* ::operator new(std::size_t,const std::nothrow_t&)throw();
//nothrow new
//用法:new (std::nothrow) T
void* ::operator new(std::size_t, void* ptr)throw();
//inplace new
//用法:new(ptr)T(args)
它们都定义于全局命名空间中,前面两个操作符申请堆内存,最后一个操作符从指定的已分配内存中构建一个对象。此外,基于重载机制,c++允许定义类自己的new操作符函数,而且会优先使用类中定义的new操作符.
考察了核心的malloc(或者new)提供的服务后,我们发现,new的作用有点类型传输层TCP/UDP,它们都为应用程序提供了清晰明确的接口以及核心的服务。就像TCP/UDP提供的服务需要下一层提供支持,new提供的服务也需要下一层的支持,这里的下一层就是(s)brk/mmap系统调用。更进一步,这些系统调用又需要内核和硬件的支持,因为当我们扩展堆的时候,需要内核创建或者扩展vm_area_struct,需要MMU支持虚拟页到物理页之间的映射。而且,还有一个概念贯穿始终,那就是抽象,我们抽象了进程的地址空间。抽象的好处在于可以抛开实际的物理设施来构建服务,依赖于抽象设施的模块不用考虑物理设施的实现方式。正是抽象,我们可以让每一个进程都有自己的内存空间,当我们为进程申请内存的时候只需要管理进程的虚拟内存资源而不是其他。