本文首先介绍一下Linux内存管理方式,着重说明一下用户空间的内存管理,包括Linux虚拟映射以及GLIBC中malloc的实现;然后简要介绍单进程多线程的内存管理方式,主要涉及各线程堆栈空间的分配;
Linux 内存管理
Linux 采用两级保护机制,隔离内核空间和用户程序空间,使用户程序无法直接访问内核,而只能通过系统调用的方式。
对于 32 位 CPU 来说, Linux 虚拟内存空间大小为 4G ,其中内核占据 3G~4G 空间(依据 CPU 体系的不同而有差别),应用程序占据 0~3G 空间。其中用户空间又分为代码段、数据段、 BSS 、堆及栈空间,各段分布如图所示:
上图不连续部分,表示可能存在隔离空间。实际上在我虚拟机上的 Linux ,内核为 2.6.9 ,动态链接库是映射到低地址的 128M 空间里的,可网上众多资料都说其映射到堆栈之间,有待以后研究。下面就来分别介绍各部分内存空间。
内核空间
对于 X86 体系来说,内核占据高端 1G 虚拟内存,其中又分为两部分 linux 内核区域及 vmalloc 区域。
1. 内核区域
Linux 内核可以认为是实地址模式,物理地址经过简单的偏移即构成逻辑地址,即内核编程时,我们可以认为操作的是物理内存,并且这段内存是物理连续的。其中用到两种内存分配方式,整页分配(大内存)、 slab 分配(小内存)。
Linux 内核提供了比较完善的内存管理方式,页分配 _get_free_page ,基于 slab 的小内存分配 kmalloc 。这两个接口的实现都首先调用 __alloc_pages() ,分配物理页面,然后 kmalloc 在分配的物理页面上实现 slab 管理。分配物理页面基于 buddy 算法,在使用 buddy 实现的物理内存管理中最小分配粒度是以页为单位的,大小通常为 4K 。 __alloc_pages() 函数中,多次尝试调用 get_page_from_freelist() 函数从 zonelist 中取得相关 zone ,并从其中返回一个可用的物理页面。
实际上,内核空间的起始地址是物理内存的低地址,内核有自己的页表,经过简单的偏移转换成逻辑地址,另外每个进程也都有自己的页表。
2. vmalloc
这个区域一般占据最高地址 128M 空间,也属于内核区域,不过它对应不连续的物理空间,这里的情况很类似于用户空间分配虚拟内存,内存逻辑上连续,其实映射到并不一定连续的物理内存上。 Linux 内核借用了这个技术,允许内核程序在内核地址空间中分配虚拟地址,同样也利用页表(内核页表)将虚拟地址映射到分散的内存页上。内核提供 vmalloc 函数分配内核虚拟内存,该函数不同于 kmalloc ,它可以分配较 Kmalloc 大得多的内存空间(可远大于 128K ,但必须是页大小的倍数),但相比 Kmalloc 来说 ,Vmalloc 需要对内核虚拟地址进行重映射,必须更新内核页表,因此分配效率上要低一些(用空间换时间)。
用户空间
当应用程序以 elf 格式被加载时, Linux 加载器首先将其加载到 0X0804 8000 处,对 X86 和 PPC 来说, 0X0804 8000~0XC000 0000 这段地址空间才是程序可用的,任何访问到这个范围以外的指令,都会引起段错误。至于为什么 Linux 放着前 128M 内存空间不用, google 一下也没有搜索到答案,有待以后研究。
上面已经说过,每个进程都有自己的页表,记录着逻辑地址与物理页面的对应关系,当进程切换时,新进程页表首地址就被加载到 CR3 寄存器中,内存的访问首先都是从 CR3 开始,找到 TLB (页表缓冲区),通过逻辑地址找出对应的物理页面。另外进程 PCB 中还有许多 vm_struct 指针,每个 vm_struct 记录着一段逻辑地址空间以及对应的操作函数,比如代码空间、栈空间、 mmap 映射空间等,不同的空间,其读写函数以及权限也是不一样的。这些结构体对应的逻辑空间都会有间隔,防止越界访问。
当发生缺页异常时,即访问的逻辑地址对应的物理页面还未映射,或是已经被交换出去时,异常处理程序首先找到这个逻辑地址对应的 vm_struct 。如果找不到对应的 vm_struct ,说明发生了非法访问,否则找到相应的 vm_struct ,查找这段逻辑地址对应的设备文件的地址,并应用结构体中注册的相关函数将其内容调入物理页面。
一般未发生缺页异常或重新映射到物理内存后,内存访问经过页表转换时,页表条目中相应的位代表访问该页的一系列权限,这时就会进行权限检查,做到内存保护。
关于栈空间与 mmap 映射动态链接库的空间,都是由内核管理的,在必要的时候也可以挪动 mmap 映射的位置,为栈的增长提供空间。这里我们主要讨论一下堆空间,即 malloc 内存,它是调用 brk 实现的。这个调用只是扩张堆空间边界,并不对应物理内存,只是在使用时,发生缺页异常后,才由内核映射物理内存。但是,并不是每次 malloc 都会引起 brk 调用, malloc 自身维护一个空闲链,用于收集堆空间上已经释放的空间,当然这个是逻辑地址空间。每次 malloc 调用,首先试图从该空闲链中获取该空间,当链上的元素都不能满足时,才会调用 brk ,扩张堆空间,以获得足够大的逻辑空间。每次 free 时,都会将释放的空间放入空闲链中,并检查该空间相邻的逻辑空间,如果也空闲,则合并为一块较大的空间。实际上 malloc 管理操作会比以上所说的更复杂,具体细节只有研究 glibc 的代码,网上并没有很好的资料。
所以, malloc 这种管理方式类似于 VxWorks 的 内存管理方式,如果频繁的申请、释放小内存,同样会在逻辑空间产生内存碎片。虽然对于不同的内存部分,其逻辑空间并不会连续(像栈空间和堆空间),但是堆 空间内部,通过malloc申请的内存仍然有可能发生越界,破坏本进程相邻的变量内存空间,不过这种越界比较容易定位,因为只是限于本进程内部。至于野指 针,其影响也只限于本进程,对系统和其它进程不会构成威胁。
Linux 线程库
线程最主要的目的就是更好地支持 SMP 以及减小进程上下文切换开销,针对这两大意义,分别开发了核心线程和用户级线程。 Linux 内核仅支持轻量级进程,所以 linux 下的线程库不可能实现完全意义上的 POSIX 线程机制,不能实现用户级调度,以减小切换开销。
Linux 线程库栈空间的分配,依据个 CPU 架构的不同而有所区别,这里只能分析 i386 平台所使用的两种栈组织方式: FLOATING_STACK 方式和用户自定义方式。
在 FLOATING_STACK 方式下, LinuxThreads 利用 mmap() 分配 8MB 空间( i386 系统缺省的最大栈空间大小,如果有运行限制( rlimit ),则按照运行限制设置),使用 mprotect() 设置其中第一页为非访问区。该 8M 空间的功能分配如下图:
低地址被保护的页面用来监测栈溢出。对于用户指定的栈,在按照指针对界后,设置线程栈顶,并计算出栈底,不做保护,正确性由用户自己保证,我想应该也可以调用 mprotect 设置隔离区域,监测堆栈溢出。不论哪种组织方式,线程描述结构总是位于栈顶紧邻堆栈的位置。
至于堆空间,和单线程分配的机制是一样的。