目录
一、Linux环境下的内存管理
二、栈的管理
三、堆内存管理
四、mmap映射区
五、内存泄漏与防范
六、常见的内存错误及检测
C程序中定义的函数、全局变量、静态变量经过编译链接后,分别以section的形式存储在可执行文件的代码段、数据段和BSS段中。当程序运行时,可执行文件首先被加载到内存中,各个section分别加载到内存中对应的代码段、数据段和BSS段中。需要动态链接的动态库也被加载到内存中,完成代码的链接和重定位操作,以保证程序的正常运行。一个可执行文件被加载到内存中运行时,它在内存空间的分布如下图所示。
一、Linux环境下的内存管理
在Linux环境下运行的程序,在编译时链接的起始地址都是相同的,而且是一个虚拟地址。Linux操作系统需要CPU内存管理单元的支持才能运行,Linux内核通过页表和MMU硬件来管理内存,完成虚拟地址到物理地址的转换、内存读写权限管理等功能。
可执行文件在运行时,加载器将可执行文件中的不同section加载到内存中读写权限不同的区域,如代码段、数据段、.bss段、.rodata段等。
计算机上运行的程序主要分为两种:操作系统和应用程序。每一个应用程序进程都有4GB大小的虚拟地址空间。为了系统的安全稳定,0~4GB的虚拟地址空间一般分为两部分:用户空间和内核空间。0~3GB地址空间给应用程序使用,而操作系统一般运行在3~4GB内核空间。通过内存权限管理,应用程序没有权限访问内核空间,只能通过中断或系统调用来访问内核空间,这在一定程度上保障了操作系统核心代码的稳定运行。
在Linux环境下,虽然所有的程序编译时使用相同的链接地址,但在程序运行时,相同的虚拟地址会通过MMU转换,映射到不同的物理内存区域,各个可执行文件被加载到内存不同的物理页上。如下图所示,每个进程都有各自的页表,用来记录各自进程中虚拟地址到物理地址的映射关系。
二、栈的管理
栈有两种基本操作:入栈(push)和出栈(pop)。入栈是把一个栈元素压入栈中,而出栈则是从栈中弹出一个栈元素。入栈和出栈都靠栈指针(Stack Pointer,SP)来维护,SP会随着入栈和出栈在栈顶上下移动。如下图所示,根据栈指针SP指向栈顶元素的不同,栈可分为满栈和空栈;根据栈的生长方向不同,栈又分为递增栈和递减栈。
满栈的栈指针SP总是指向栈顶元素,而空栈的栈指针则指向栈顶元素上方的可用空间;一个栈元素入栈时,递增栈的栈指针从低地址往高地址增长,而递减栈的栈指针则从高地址往低地址增长。
在栈的初始化过程中,栈在内存中的起始地址还是有点讲究的。ARM处理器使用的是满递减栈,在Linux环境下,栈的起始地址一般就是进程用户空间的最高地址,紧挨着内核空间,栈指针从高地址往低地址增长。为了防止黑客栈溢出攻击,新版本的Linux内核一般会将栈的起始地址设置成随机的,如下图所示,每次程序运行,栈的初始化起始地址都会基于用户空间的最高地址有一个随机的偏移,每次栈的起始地址都不一样。
Linux默认给每一个用户进程栈分配8MB大小的空间。栈的容量如果设置得过大,则会增加内存开销和启动时间;如果设置得过小,则程序超出栈设置的内存空间又容易发生栈溢出(Stack Overflow),产生段错误。
在设置栈大小时,我们要根据程序中的变量、数组对栈空间的实际需求,设置合理的栈大小。用户在编写程序时,为了防止栈溢出,可以参考下面的一些原则。
● 尽量不要在函数内使用大数组,如果确实需要大块内存,则可以使用malloc申请动态内存。
● 函数的嵌套层数不宜过深。
● 递归的层数不宜太深。
全局变量定义在函数体外,其作用域范围为从声明处到文件结束。其他文件如果想使用这个全局变量,则在自己的文件内使用extern声明后即可使用。全局变量的生命周期在整个程序运行期间都是有效的。
局部变量定义在函数内,其作用域只能在函数体内使用。函数只有在被调用的时候才会在内存中开辟一个栈帧空间,在这个栈空间里存储局部变量及传进来的函数实参等。函数调用结束,这个栈帧空间就被销毁释放了,变量也就随之消失,因此局部变量的生命周期仅仅存在于函数运行期间。每一次函数被调用,临时开辟的栈帧空间可能不相同,局部变量的地址也不相同。
编译器在编译程序时,其实是根据一对大括号{}来限定一个变量的作用域的,示例:
#include <iostream>
void functionWithStatic() {
static int staticVar = 0; // 带有 static 修饰的局部变量
int normalVar = 0; // 普通局部变量
staticVar++;
normalVar++;
std::cout << "Static Variable: " << staticVar << std::endl;
std::cout << "Normal Variable: " << normalVar << std::endl;
}
int main() {
for (int i = 0; i < 3; i++) {
functionWithStatic();
}
return 0;
}
运行结果:
最后我们对变量的作用域做下小结。
全局变量的作用域如下。
● 全局变量的作用域由文件来限定。
● 可使用extern进行扩展,被其他文件引用。
● 也可以使用static进行限制,只能在本文件中被引用。
局部变量的作用域如下。
● 局部变量的作用域由{}限定。
● 可以使用static修饰局部变量来改变它们的存储属性(生命周期),但不能改变其作用域。
三、堆内存管理
我们使用malloc()/free()函数申请/释放的动态内存就属于堆内存,如下图所示,堆是Linux进程空间中一片可动态扩展或缩减的内存区域,一般位于BSS段的后面。
堆内存与栈相比,有相同点,也有区别。
● 堆内存是匿名的,不能像变量那样使用名字直接访问,一般通过指针间接访问。
● 在函数运行期间,对函数栈帧内的内存访问也不能像变量那样通过变量名直接访问,一般通过栈指针FP或SP相对寻址访问。
● 堆内存由程序员自己申请和释放,函数退出时,如果程序员没有主动释放,就会造成内存泄漏。
● 栈内存由编译器维护,函数运行时开辟一个栈帧空间,函数运行结束,栈帧空间随之销毁释放。
malloc()/free()函数的底层实现,其实就是通过系统调用brk向内核的内存管理系统申请内存。内核批准后,就会在BSS段的后面留出一片内存空间,允许用户进行读写操作。申请的内存使用完毕后要通过free()函数释放,free()函数的底层实现也是通过系统调用来归还这块内存的。
当用户要申请的内存比较大时,如大于128KB,一般会通过mmap系统调用直接映射一片内存,使用结束后再通过ummap系统调用归还这块内存。mmap区域是Linux进程中比较特殊的一块区域,主要用于程序运行时动态共享库的加载和mmap文件映射。早期的Linux内核将该区域设置在0x40000000附近,Linux 2.6以后的内核将该区域移到了栈附近,打印mmap映射区域的地址,你会发现大部分地址都在0xBxxxxxxx范围内,紧挨着进程的用户栈。
示例代码:
运行结果:
根据程序的打印结果,我们可以看到:对于用户申请的小块内存,Linux内存管理子系统会在BSS段的后面批准一块内存给用户使用。当用户申请的内存大于128KB时,Linux系统则通过mmap系统调用,映射一片内存给用户使用,映射区域在用户进程栈附近。两次申请的不同大小
的内存,其地址分别位于内存中两个不同的区域:heap区和mmap区。
让a.out进程先不退出,一直死循环运行,以方便我们通过cat命令查看a.out进程的内存布局。
在32位X86平台下我们可以看到,heap区域在.bss段的后面,而mmap区域则紧挨着stack,mmap区域包括进程动态链接时加载到内存的动态链接器ld-2.23.so、动态共享库、使用mmap申请的动态内存。
使用kill命令杀掉a.out进程再重新运行,你会发现&mem_100和&mem_256K的地址打印值发生了变化,每次程序运行的地址可能都不相同。这是因为heap区和mmap区的起始地址和stack一样,也不是固定不变的。为了防止黑客攻击,每次程序运行时,它们都会以一个随机偏移作为起始地址。
通过a.out进程的内存布局我们看到,栈的起始地址并不紧挨着内核空间0xc0000000,而是从0xbf9a2000作为起始地址,中间有一个大约6MB的偏移。heap区也不紧挨着.bss段,它们之间也有一个offset;mmap区也是如此,它和stack区之间也有一个offset。
大量的系统调用会让处理器和操作系统在不同的工作模式之间来回切换:操作系统要在用户态和内核态之间来回切换,CPU要在普通模式和特权模式之间来回切换,每一次切换都意味着各种上下文环境的保存和恢复,频繁地系统调用会降低系统的性能。系统调用还有一个不人性化的地方是不支持任意大小的内存分配,有的平台甚至只支持一个或数倍物理页大小的内存申请,这在一定程度上会造成内存的浪费。举个通俗的例子,内存申请有点类似你去银行存取款。当你需要用钱时,如果每次都是用多少取多少,用1块取1块,用10块取10块,那么估计你天天都得往银行跑,花费在交通、排队上的时间开销无疑是巨大的。同样的道理,当你往银行存钱时,如果只要口袋里有钱,就往银行里存,有1块存1块,有10块存10块,那么估计你也得天天往银行跑。正确的做法应该是:每次取钱时多取一些,放到自己的钱包里,可以多次使用;存钱时也是如此,先存放到钱包里,等攒够了一定数额,再存到银行里,这样就可以大大减少去银行的次数。
为了提高内存申请效率,减少系统调用带来的开销,我们可以参考上面的钱包模式,在用户空间层面对堆内存介入管理。如在glibc中实现的内存分配器(allocator)可以直接对堆内存进行维护和管理。如图5-27所示,内存分配器通过系统调用brk()/mmap()向Linux内存管理子系统“批发”内存,同时实现了malloc()/free()等API函数给用户使用,满足用户动态内存的申请与释放请求。
当用户使用free()释放内存时,释放的内存并不会立即返回给内核,而是被内存分配器接收,缓存在用户空间。内存分配器将这些内存块通过链表收集起来,等下次有用户再去申请内存时,可以直接从链表上查找合适大小的内存块给用户使用,如果缓存的内存不够用再通过brk()系统调用去内核“批发”内存。内存分配器相当于一个内存池缓存,通过这种操作方式,大大减少了系统调用的次数,从而提升了程序申请内存的效率,提高了系统的整体性能。
四、mmap映射区
当用户使用malloc申请大于128KB的堆内存时,内存分配器会通过mmap系统调用,在Linux进程虚拟空间中直接映射一片内存给用户使用。
当我们运行一个程序时,需要从磁盘上将该可执行文件加载到内存。将文件加载到内存有两种常用的操作方法,一种是通过常规的文件I/O操作,如read/write等系统调用接口;一种是使用mmap系统调用将文件映射到进程的虚拟空间,然后直接对这片映射区域读写即可。
文件I/O操作使用文件的API函数(open、read、write、close)对文件进行打开和读写操作。文件存储于磁盘中,我们通过指定的文件名打开一个文件,就会得到一个文件描述符,通过该文件描述符就可以找到该文件的索引节点inode,根据inode就可以找到该文件在磁盘上的存储位置。然后我们就可以直接调用read()/write()函数到磁盘指定的位置读写数据了。文件的读写流程如图5-34左侧图所示。
磁盘属于机械设备,程序每次读写磁盘都要经过转动磁盘、磁头定位等操作,读写速度较慢。为了提高读写效率,减少I/O读盘次数以保护磁盘,Linux内核基于程序的局部原理提供了一种磁盘缓冲机制。如图5-34所示,在内存中以物理页为单位缓存磁盘上的普通文件或块设备文件。当应用程序读磁盘文件时,会先到缓存中看数据是否存在,若数据存在就直接读取并复制到用户空间;若不存在,则先将磁盘数据读取到页缓存(page cache)中,然后从页缓存中复制数据到用户空间的buf中。当应用程序写数据到磁盘文件时,会先将用户空间buf中的数据写入page cache,当page cache中缓存的数据达到设定的阈值或者刷新时间超时,Linux内核会将这些数据回写到磁盘中。
当动态库第一次被链接器加载到内存参与动态链接时,如图5-43所示,动态库映射到了当前进程虚拟空间的mmap区域,动态链接和重定位结束后,程序就开始运行。当程序访问mmap映射区域,去调用动态库的一些函数时,发现此时还没有为这片虚拟空间分配物理内存,就会产生一个请页异常。内核接着会为这片映射内存区域分配物理内存,将动态库文件libtest.so加载到物理内存,并将虚拟地址和物理地址之间的映射关系更新到进程的页表项,此时动态库才真正加载到物理内存,程序才可以正常运行。
对于已经加载到物理内存的文件,Linux内核会通过一个radix tree的树结构来管理这些页缓存对象。在图5-44中,当进程B运行也需要加载动态库libtest.so时,动态链接器会将库文件libtest.so映射到进程B的一片虚拟内存空间上,链接重定位完成后进程B开始运行。当通过映射内存地址访问libtest.so时也会触发一个请页异常,Linux内核在分配物理内存之前会先从radix tree树中查询libtest.so是否已经加载到物理内存,当内核发现libtest.so库文件已经加载到内存后就不会给进程B分配新的物理内存,而是直接修改进程B的页表项,将进程B中的这片映射区域直接映射到libtest.so所在的物理内存上。
通过上面的分析我们可以看到,动态库libtest.so只加载到物理内存一次,后面的进程如果需要链接这个动态库,直接将该库文件映射到自身进程的虚拟空间即可,同一个动态库虽然被映射到了多个进程的不同虚拟地址空间,但是通过MMU地址转换,都指向了物理内存中的同一块区域。此时动态库libtest.so也被多个进程共享使用,因此动态库也被称作动态共享库。
五、内存泄漏与防范
什么是内存泄漏?
在一个C函数中,如果我们使用malloc()申请的内存在使用结束后没有及时被释放,则C标准库中的内存分配器ptmalloc和内核中的内存管理子系统都失去了对这块内存的追踪和管理。失去管理和追踪的这块内存,一直孤零零地躺在内存的某片区域,用户、内存分配器和内存管理子系统都不知道它的存在,它就像内存中的一块漏洞,我们称这种现象为内存泄漏。
预防内存泄漏:
预防内存泄漏最好的方法就是:内存申请后及时地释放,两者要配对使用,内存释放后要及时将指针设置为NULL,使用内存指针前要进行非空判断。
内存泄漏检测工具:
MTrace是Linux系统自带的一个工具,它通过跟踪内存的使用记录来动态定位用户代码中内存泄漏的位置。使用MTrace很简单,在代码中添加下面的接口函数就可以了。
mtrace()函数用来开启内存使用的记录跟踪功能,muntrace()函数用来关闭内存使用的记录跟踪功能。如果想检测一段代码是否有内存泄漏,则可以把这两个函数添加到要检测的程序代码中。
开启跟踪功能后,MTrace会跟踪程序代码中使用动态内存的记录,并把跟踪记录保存在一个文件里,这个文件可以由用户通过MALLOC_TRACE来指定。接下来我们编译、运行这个程序,并使用MTrace来定位内存泄漏的位置。
通过生成的日志文件mtrace.log来定位内存泄漏在程序中的位置。
根据动态内存的使用记录,我们可以很快定位到内存泄漏发生在mcheck.c文件中的第11行代码。
六、常见的内存错误及检测
如图5-47所示,内存管理子系统将一个进程的虚拟空间划分为不同的区域,如代码段、数据段、BSS段、堆、栈、mmap映射区域、内核空间等,每个区域都有不同的读、写、执行权限。
通过内存管理,每个区域都有具体的访问权限,如只读、读写、禁止访问等。数据段、BSS段、堆栈区域都属于读写区,而代码段则属于只读区,如果你往代码段的地址空间上写数据,就会发生一个段错误。在Linux用户进程的4GB虚拟空间上,除了上面我们熟悉的区域,还剩下很多区域,如代码段之前的区域、堆和mmap区域之间的进程空间、内核空间等。这部分内存空间是禁止用户程序访问的。当一个用户进程试图访问这部分空间时,就会被系统检测到,在Linux下系统会向当前进程发送一个信号SIGSEGV,终止该进程的运行。
对于应用程序来说,常见的内存错误一般主要分为以下几种类型:内存越界、内存踩踏、多次释放、非法指针。
内存越界:导致段错误
内存踩踏:如果一个进程中有多个线程,多个线程都申请堆内存,这些堆内存就可能彼此相邻,使用时需要谨慎,提防越界。在内核驱动开发中,驱动代码运行在特权状态,对内存访问比较自由,多个驱动程序申请的物理内存也可能彼此相邻。如果你的程序代码经常莫名其妙地崩溃,而且每次出错的地方也不一样,在确保自己的代码没问题后,也可以大胆地去怀疑一下是不是内存踩踏的问题。
内存践踏检测API:mprotect(),页(page)是Linux内存管理的基本单元,在32位系统中,一个页通常是4096字节,mprotect()要保护的内存单元通常要以页地址对齐,我们可以使用memalign()函数申请一个以页地址对齐的一片内存。
内存检测神器:Valgrind,包含一套工具集,其中一个内存检测工具Memcheck可以对我们的内存进行内存覆盖、内存泄漏、内存越界检测。