文章目录
- 虚拟地址空间
- 用户空间
- 内核空间
- 用户空间内存分配
- malloc
- 内核空间内存分配
- kmalloc
- vmalloc
虚拟地址空间
在早期的计算机中,程序是直接运行在物理内存上的,而直接使用物理内存,通常都会面临以下几种问题:
- 内存缺乏访问控制,安全性不足
- 各进程同时访问物理内存时,可能会产生访问内存空间重叠的现象,没有独立性
- 物理内存极小,而并发执行进程所需又大,容易导致内存不足
- 进程所需空间不一,容易导致内存碎片化问题。
基于以上几种原因,Linux通过 mm_struct
结构体来描述了一个虚拟的,连续的,独立的地址空间,也就是我们所说的虚拟地址空间。
原理: 当程序被载入内存时,向其呈现出比实际拥有的地址空间大得多的内存——虚拟地址空间,让程序误认为自己目前独占电脑内存,能够占用电脑所有的内存,访问所有内存地址,同时建立虚拟地址与物理地址之间的映射。这就允许多个程序可以同时运行且各个程序之间能够访问的物理内存区域不重叠,也杜绝了程序直接操作地址的风险,同时也提高物理地址的使用效率。
值得注意的是,在建立了虚拟地址空间后,并没有立刻分配实际的物理内存,而是当进程需要实际访问内存资源的时候,才由内核的 请求分页机制
产生 缺页中断
,这时才会建立虚拟地址和物理地址的映射,调入物理内存页;如果此时物理内存已经耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。通过这种方法,就能够保证我们的物理内存只在实际使用时才进行分配,避免了内存浪费的问题。
下图则为Linux下的虚拟地址空间:
32位Linux
的地址空间(232 B = 4 GB)被一分为二:0~3G为用户空间 , 3~4G为内核空间。
- 操作系统和驱动程序运行在内核空间 ,内核模式下,操作系统可以访问机器的全部资源。
- 应用程序运行在用户空间 , 用户模式下,应用程序不能完全访问硬件资源。
当进程运行在 内核空间 时,它就处于 内核态 ;当进程运行在 用户空间 时,它就处于 用户态 。两个空间不能简单地使用指针传递数据,因为 Linux
使用了虚拟内存机制,用户空间的数据可能被换出,当内核空间使用用户空间指针时,对应的数据可能不在内存中。
用户空间
用户空间即进程在用户态下能够访问的虚拟地址空间,每个进程都有自己独立的用户空间,大小为 3G
。
用户空间由以下部分组成:
- 栈: 栈用来存放程序中临时创建的局部变量,如函数的参数、内部变量等。每当一个函数被调用时,就会将参数压入进程调用栈中,调用结束后返回值也会被放回栈中。同时,每调用一次函数就会在调用栈上维护一个独立的 栈帧 ,所以在递归较深时容易导致栈溢出。栈内存的申请和释放由 编译器 自动完成,并且 栈容量由系统预先定义 。栈从高地址向低地址增长。
栈帧从低到上依次是(从高地址到低地址的方向):
- 参数
- 返回地址:将当前代码区
调用函数指令
的下一条指令地址
压入栈中,供函数返回时继续执行。 - ebp(帧指针):指向当前的栈帧的底部
- 局部变量
- esp(栈指针): 始终指向栈帧的顶部
- 文件映射段: 也叫共享区,文件映射段中主要包括 共享内存、动态链接库 等共享资源,从低地址向高地址增长。
共享资源以动态链接库为例:
-
动态链接库中的函数都与位置无关,即每次被加载进入内存映射区时的位置都是不一样的,因此使用的是其本身的逻辑地址,经过变换成线性地址(虚拟地址),然后再映射到内存。
-
而静态库被链接到可执行文件中,因此其位于 代码段 ,每次在地址空间中的位置都是固定的。
- 堆: 堆用来存放动态分配的内存。堆内存由 用户 申请分配和释放,从低地址向高地址增长。不同于数据结构中的堆,存储空闲内存的方式类似链表,因此空闲内存分布不连续。
- BSS段: 存放程序中
未初始化
的全局变量
和静态变量
,全局变量未初始化
时,其默认值为0
,因此也保存 初始化为0的全局变量 。具体体现为一个占位符,并不给该段的数据分配空间,只是记录数据所需空间的大小。 - 数据段: 存放程序中
已初始化
的全局变量
与静态变量
。 - 代码段: 存放程序执行指令,也可能包含一些只读的常量(
.rodata段
)。这块区域的大小在程序运行时就已经确定,并且为了防止代码和常量遭到修改,代码段被设置为只读。 - 保留区(受保护的地址): 大小为128M,位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。
大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。
小结堆和栈的区别:
由于:
- 栈没有内存碎片问题,堆容易造成内存碎片。
- 堆没有专门的系统支持,效率很低,
- 堆可能引发用户态和内核态切换,内存申请的代价更为昂贵。
所以栈在程序中应用最广泛,函数调用也利用栈来完成,调用过程中的参数、返回地址、栈基指针和局部变量等都采用栈的方式存放。所以,建议仅在分配大量或大块内存空间时使用堆。
内核空间
内核空间即进程陷入 内核态 后才能够访问的空间。虽然每个进程都具有自己独立的虚拟地址空间,但是这些虚拟地址空间中的内核空间 ,其实都关联的是 同一块物理内存 ,如下图:
通过这种方法,保证了进程在切换至内核态后能够快速的访问内核空间。
内核空间主要分为 直接映射区 和 高端内存映射区 两部分:
直接映射区:
从内核空间起始位置开始,从低地址往高地址增长,最大为 896M
的区域即为直接映射区。
直接映射区的 896M
的 虚拟地址
与 物理地址(ZONE_DMA + ZONE_NORMAL)
的前 896M
进行直接映射,所以虚拟地址和分配的物理地址都是连续的。
那么它们是如何转换的呢?其实它们之间存在着一个偏移量 PAGE_OFFSET
,偏移量的大小即为 0xC0000000
。
虚拟地址 = PAGE_OFFSET + 物理地址
高端内存映射区:
物理内存中 ZONE_DMA + ZONE_NORMAL
被直接联系到虚拟内存的 直接映射区
中,那么对于剩下的 896M~4G
大小的 ZONE_HIGHMEM
,寻址工作就交给了高端内存映射区。
由于我们的内核空间只有 1G
,而直接映射区又占据了 896M
,因此我们将剩下的 128M
空间划分成了三个高端内存的映射区,从上往下分别是:
- 动态内存映射区: 该区域的特点是 虚拟地址连续,但是其对应的物理地址并不一定连续。该区域使用内核函数
vmalloc
进行分配,分配的虚拟地址的物理页可能会处于低端内存,也可能处于高端内存。 - 永久内存映射区: 该区域可以访问 高端内存 。使用
alloc_page(_GFP_HIGHMEM)
分配高端内存页,或者使用kmap
将分配的高端内存映射到该区域。 - 固定内存映射区: 该区域的 每个地址项都服务于特定的用途 ,如
ACPI_BASE
。
用户空间内存分配
malloc
在C语言中,我们可以使用 malloc
来在用户空间中动态的分配内存,而 malloc
作为库函数,其本质就是对系统调用进行了一层封装,因此在不同的系统下其实现不同。
在Linux中,当我们申请的内存小于 128K
时,malloc
会使用 sbrk
或者 brk
在堆区分配内存。而当我们申请大于 128K
的大块空间时,会使用 mmap
在映射区进行分配。
但是由于上述的 brk/sbrk/mmap
都属于系统调用,因此当我们每次调用它们时,就会从用户态切换至内核态,在内核态完成内存分配后再返回用户态。
倘若每次申请内存都要因为系统调用而产生大量的CPU开销,那么性能会大打折扣。并且堆也有容易产生内存碎片的问题。
malloc是如何实现解决这个问题的呢?
为了减少内存碎片和系统调用的开销,malloc
在底层采用了 内存池 来解决这个问题。
它会先申请大块内存作为堆区,然后将这块内存拆分为多个不同大小的内存块,以 块 作为内存管理的基本单位。同时,会使用 隐式链表 来连接所有的 内存块 ,包括已分配块和未分配块。为了方便内存空闲块的管理,malloc
采用 显式链表 来管理所有的 空闲块 。
当我们调用 malloc
进行内存分配时,就会去搜索空闲链表,找到满足需求的内存块,如果内存块过大,则会将内存块拆分为两部分,即一部分用来分配,另一部分则变为新的空闲块。
同理,当我们释放内存块时,会通过遍历隐式链表,判断释放块前后内存块是否空闲,来决定是否需要合并内存块
内核空间内存分配
在内核空间中,通过与 malloc
类似的两个系统调用来进行内存的分配,它们 分别是 kmalloc
和 vmalloc
.
kmalloc
kmalloc
用于为内核空间的 直接内存映射区 分配内存。
kmalloc
以字节为分配单位,通常用于分配小块内存,并且 kmalloc
确保分配的页在 物理地址 上是 连续的 ( 虚拟地址 也必然 连续 ) 。并且 kmalloc
为了防止内存碎片的问题,其底层页面分配算法是基于 slab分配器 实现的。
vmalloc
vmalloc
用于为内核空间中的 动态内存映射区 进行内存分配。
vmalloc
分配的内存 只保证了虚拟地址是连续的,而物理地址不一定连续 。它 记录非连续的物理内存块至页表 ,再通过 修正页表的映射关系 ,把内存映射到虚拟地址空间的连续区域。
如上图,就是内核空间中进行内存分配的具体流程。