旷世的忧伤

Huoty's Blog

Linux 进程内存地址空间

进程是操作系统进行资源分配的最小单位,而内存是进程运行必不可少的资源。那么,为什么需要内存呢?CPU 太快,但其数据容量极小且功能单一,而其他 I/O 等硬件功能多样,只是相对于 CPU 来说它们又太慢,所以便需要内存在 CPU 与 I/O 设备间进行缓冲。

现代操作系统均支持多任务,操作系统一般会为每个进程分配 独享的内存空间,这个独享的内存空间只是在进程自己看来是独享的,实际上其只是操作系统为其分配的 虚拟内存空间,虚拟内存在真正被使用时才映射到物理内存上。进程每次访问自己内存空间的某个地址 (虚拟地址)时,操作系统都需要把地址翻译成实际物理内存地址。

虚拟内存是操作系统里的概念。由于数据存储的基本单位都是 Byte(字节),如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8 字节(32 位虚拟地址 -> 32 位物理地址),如果在 4G 内存的情况下,就需要 32GB 的空间来存放虚拟内存地址与物理内存地址的对照表,那么这张表就大得真正的物理地址也放不下,于是操作系统引入了 页(Page) 的概念。

操作系统将整个物理内存划分为很多的页(页的大小一般为 4K,不同操作系统实现可能会不同),之后在给进程进行内存分配时,都以页为单位。这样虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存时只需要 8M 的映射表即可。操作系统虚拟内存到物理内存的映射表,就被称为页表。

由于分页技术的广泛使用,现代的 CPU 设计中便多了一种硬件,即 内存管理单元 MMU(Memory Management Unit),专门用来将翻译虚拟内存地址。

Linux 的虚拟地址空间采用 “分段+分页” 结合的方式实现。分段是将内存划分成各个段落(Segment),每个段落的长度可以不同,且虚拟地址空间中未使用的空间不会映射到物理内存中,所以操作系统不会为这段空间分配物理内存。这样的话,内核为刚创建的进程分配的物理内存可以很小,随着进程运行不断使用内存,内核再为进程按需分配物理内存。也就是说,尽管地址空间的范围和物理内存大小一样,但不会将全部空间映射到物理内存。

在 32 操作系统中,其虚拟地址为 32 位长度,因此其虚拟地址空间的范围为 2 ** 32 = 4GB。Linux 系统将地址空间按 3:1 比例划分,其中用户空间(user space)占 3GB,内核空间(kernel space)占 1GB。Linux 系统进程的虚拟内存地址空间布局如下图所示:

32 Process Memory Space

各个分段的含义:

  • 文本段(Text)

也称为代码段。进程启动时会将程序的代码加载到物理内存中,文本段映射到这片物理内存。

  • 数据段(Data):包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。

  • 未初始化数据(BSS):未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。

  • 栈(Stack)

位于用户空间的顶部,是一个可以动态增长和收缩的内存段落,由栈帧(Stack Frames)组成,进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。栈帧会在函数返回时被清理掉。注意,编译器会将函数参数放入寄存器来优化程序,只有寄存器放不下的参数才使用栈帧来保存。由于栈中数据严格的遵守 FIFO 的顺序,这个简单的设计意味着不必使用复杂的数据结构来追踪栈中的内容,只需要一个简单的指针指向栈的顶端即可,因此压栈(pushing)和出栈(popping)过程非常迅速、准确。

  • 堆(Heap)

与栈一样,堆用于运行时内存分配;但不同的是,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。

栈的内存地址向下增长,堆得内存地址向上增长。

  • 内存映射段(Memory Mapping)

在栈与堆之间,有一个内存映射端。内核通过这一段将文件的内容直接映射到内存,进程可以通过 mmap 系统调用请求这种映射。内存映射是一种方便高效的文件 I/O 方式,所以它也被用来加载动态库。

  • 内核段(Kernel):这部分是操作系统内核运行时所占用内存在各进程虚拟地址空间中的映射。所有进程都有,且映射地址相同,因为都映射到内核使用的内存。这段内存只有内核能访问,用户进程无法访问到该段落。

在 64 位系统中,进程地址空间的大小则是不固定的。以 ARMv8-A 为例,它的 Page 大小可以是 4KB, 16KB 或者 64KB,其还可采用多级页表,因此可以有多种组合的形式。以采用 4KB 的页,4 级页表,虚拟地址为 48 位的系统为例(从ARMv8.2架构开始,支持虚拟地址和物理地址的大小最多为 52 位),其虚拟地址空间的范围为 2 ** 48 = 256TB,按照 1:1 的比例划分,内核空间和用户空间各占 128TB。其内存空间布局如下所示:

64 Process Memory Space

256TB 已经很大很大了,但是面对 64 位系统所具备的 16EB 的地址范围,根本就用不完。为了以后扩展的需要(比如虚拟地址扩大到56位),用户虚拟空间和内核虚拟空间不再是挨着的,但同 32 位系统一样,还是一个占据底部,一个占据顶部,所以这时 user space 和 kernel space 之间偌大的区域就空出来了。

但这段空闲区域也不是一点用都没有,它可以辅助进行地址有效性的检测。如果某个虚拟地址落在这段空闲区域,那就是既不在 user space,也不在 kernel space,肯定是非法访问了。使用 48 位虚拟地址,则 kernel space 的高 16 位都为 1,如果一个试图访问 kernel space 的虚拟地址的高 16 位不全为 1,则可以判断这个访问也是非法的。同理,user space 的高 16 位都为 0。这种高位空闲地址被称为 Canonical。

参考资料:

Top