在现代操作系统中,每个进程被分配了独享的虚拟内存地址空间。这个地址空间可以视为一维线性空间,由多个连续的内存页组成。初始时,操作系统会将整个虚拟地址空间分成几个不同的区域,每个区域用于特定的目的。以下是一个常见的布局示例:
- 代码段(Text Segment):也称为只读段,用于存储程序的可执行代码。
- 数据段(Data Segment):用于存储全局变量和静态数据。
- 堆(Heap):动态分配的内存区域,由程序员进行管理。堆的大小可以根据需要进行调整。
- 栈(Stack):用于存储函数调用、局部变量和返回地址等信息。栈是自动管理的,并且具有固定大小。
在32位模式下,虚拟地址空间通常被限制在4GB范围内。而在64位模式下,则能够支持更大的地址空间范围。
虚拟内存地址空间布局
常见的虚拟内存地址空间布局如下:
-
0x00000000 - 0x08048000 (约128MB):保留区域
- 这部分地址空间通常包含了一些系统保留的区域,比如 C 运行库的内容等,用户程序不能直接访问,否则会导致段错误(segmentation fault)。
-
0x08048000 - 0xC0000000:用户空间
- 用户空间包含了进程的代码、数据以及堆和栈等,其中:
- .text 段通常从 0x08048000 开始,存放程序的可执行指令。
- 堆向高地址扩展,用于动态分配内存。
- 栈向低地址增长,用于存放函数调用的参数、局部变量等。
- 用户空间包含了进程的代码、数据以及堆和栈等,其中:
-
0xC0000000 - 0xFFFFFFFF:内核空间
- 这段地址空间是内核的逻辑地址,用户空间的程序不能直接访问,需要通过系统调用等方式切换到内核态才能访问这部分内核虚拟地址空间。
在这种布局下,每个进程都有自己独立的虚拟地址空间,其中用户空间和内核空间各自独立,保证了进程之间的隔离和安全性。
ASLR 机制
Linux 中的 ASLR(Address Space Layout Randomization)机制通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱虚拟地址空间布局,从而增加攻击者猜测系统资源地址的难度,提高了系统的安全性。
ASLR 机制会对以下三个部分进行随机化:
-
Random stack offset:Linux 会在进程启动时将栈顶地址随机化,从而防止攻击者通过栈溢出攻击获取程序控制权。
-
Random mmap offset:Linux 会对每个内存映射段的起始地址进行随机化,从而防止攻击者获取内存映射段的地址,进而执行代码注入等攻击。
-
Random brk offset:Linux 会对堆的起始地址进行随机化,从而防止攻击者通过堆溢出攻击获取程序控制权。
用户栈 Stack 地址段
在 Linux 系统中,每个进程都有一个用户栈(Stack)用于存储函数调用的参数、局部变量等信息。用户栈通常位于进程的虚拟地址空间,紧挨着内核空间的下方。
通过 prlimit 命令可以查看或修改进程的资源限制,包括用户栈的大小。默认情况下,用户栈的大小可能会被限制为 8MB。
这个用户栈的大小限制是为了防止进程无限制地使用栈空间而导致系统资源耗尽或者栈溢出等问题。当进程需要更大的栈空间时,可以通过修改资源限制或者使用特定的系统调用(如 setrlimit)来增加用户栈的大小。
Memory Mapping Segment
Memory Mapping Segment 是指内存映射段,它是用来分配内存区域的一部分地址空间。在 Linux 系统中,可以使用 mmap() 系统调用将文件映射到内存中,也可以通过 mmap() 直接申请一段内存空间来使用。
通过 mmap() 系统调用,可以将一个文件映射到进程的内存地址空间中的 Memory Mapping Segment。这样做的好处是可以直接在内存中对文件进行读写操作,而不需要频繁地进行磁盘 I/O 操作,从而提高了文件操作的效率。
此外,mmap() 也可以用于匿名内存映射,即直接申请一段内存空间来使用,而不与任何文件关联。在这种情况下,可以使用 mmap() 来在指定的内存地址空间申请内存,只要不与已有的虚拟地址冲突即可。需要注意的是,为了实现页对齐(通常是4KB或者1KB),地址需要以 000 结尾。
start_brk和brk
在 Linux 中,start_brk 和 brk 是用来标识堆的起始地址和结束地址的两个值,其中 brk 也被称为 program break。可以使用 brk() 和 sbrk() 这两个函数来改变 program break 的位置。
当在程序中调用 malloc() 函数来申请内存时,通常会在内部调用 sbrk() 函数来将 program break 的位置向上移动,从而扩展堆空间。
而当调用 free() 函数释放内存空间时,可以通过向 sbrk() 函数传递一个负值来将 program break 的位置向下移动,从而收缩堆空间。需要注意的是,brk() 和 sbrk() 所做的工作不仅仅是简单地移动 program break,还需要处理将虚拟内存映射到物理内存地址等相关操作。
在 glibc 中,当申请的内存空间大小不超过 MMAP_THRESHOLD(一个阈值)时,malloc() 函数会使用 brk()/sbrk() 来调整 program break 的位置,这样申请到的内存空间就位于 start_brk 和 brk 标识的范围之间。而当申请的空间大小超过了这个阈值时,malloc() 函数会改用 mmap() 来分配内存空间,这样申请到的内存空间就位于 Memory Mapping Segment 这一段内。
习惯上,整个 Heap 段和 Memory Mapping Segment 段被统称为“堆”。
静态存储区
静态存储区是进程在运行时分配给全局变量、静态变量和常量的内存空间。它们在程序的整个执行过程中都存在,并且在编译和链接阶段就确定了其大小和位置。
-
BSS Segment(未初始化数据段):BSS(Block Started by Symbol)段用于存储未初始化的全局变量和静态变量。在程序加载到内存时,操作系统会为 BSS 段分配一块内存,并将该内存区域初始化为零或空值。
-
Data Segment(已初始化数据段):Data 段用于存储已经初始化的全局变量和静态变量。在程序加载到内存时,操作系统会将 Data 段的内容直接从可执行文件中加载到相应的内存位置。
-
Text Segment(代码段):Text 段存储程序的可执行指令。这些指令在程序运行时是只读的,不能被修改。通常情况下,Text 段是共享的,多个进程可以共享同一个可执行文件的 Text 段,以减少内存占用。
堆和内存映射区共享同一地址空间
新的进程内存布局(默认进程内存布局)导致了栈空间的固定,而堆区域和 MMAP 区域共用一个空间,从而在很大程度上增大了堆区域的大小:
-
栈空间的固定:在新的进程内存布局中,操作系统会为每个进程分配一定的虚拟地址空间用于栈空间。这个栈空间的大小是固定的,无法动态调整,因为栈空间通常用于存储函数调用和局部变量等,其大小需要在程序编译时就确定。
-
堆区域和 MMAP 区域共用一个空间:在默认的进程内存布局中,堆区域和 MMAP 区域会被映射到同一个虚拟地址空间中,它们共享同一块虚拟地址范围。这意味着当堆区域需要扩展时,可以利用未使用的 MMAP 区域空间,从而增大了堆区域的可用空间。
-
增大了堆区域的大小:由于堆区域和 MMAP 区域共用一个空间,并且 MMAP 区域在很大程度上增大了堆区域的可用空间,因此整体上堆区域的大小也得以增加。
64 位模式下虚拟地址空间布局
对于 x86_64 架构的 Linux 系统,每个用户空间进程通常可以访问两个独立的地址空间范围:
-
从 0x0000000000000000 到 0x00007FFFFFFFFFFF,这是用户空间的正常地址范围,共计 128 TB。
-
从 0xFFFF800000000000 到 0xFFFFFFFFFFFFFFFF,这是内核空间的地址范围,同样也是 128 TB。
对于 64 位的 x86_64 和 amd64 架构,在用户空间中,通常会有以下几个段:
-
text 段的起始地址通常为 0x0000000000400000,这是代码段,存储程序的可执行指令。
-
data 段和 bss 段紧随在 text 段后面,用于存储程序的静态数据和未初始化的全局变量。
-
在 heap 段和 bss 段之间以及 stack 段和 0x00007FFFFFFFF000 之间,可能会存在由地址空间布局随机化(ASLR)引起的随机 brk offset。heap 段向上增长,用于动态分配内存;stack 段向下增长,用于存储函数调用和局部变量。
在经典布局下,Memory Mapping Segment(mmap 段)的起始地址会通过页对齐后从某一地址开始。在 amd64 架构下,页的大小可以是 4K、2M 或者 1G,而不像 x86 (_64) 架构下的统一页大小为 4K。因此,mmap 的起始范围会根据系统的页大小而有所不同。
在 x86_64 架构下,mmap 段的起始地址通常固定为 0x00002AAAAAAAB000(当然也可以通过设置随机的 mmap offset 来改变),并且该地址是向上增长的。这个地址是经典布局下 mmap 段的默认起始地址,用于映射文件或设备到内存中,提供了一种灵活的内存管理方式。
Linux 下控制虚拟地址空间布局
在 Linux 系统下,可以通过以下两个内核参数配置虚拟地址空间布局:
-
vm.legacy_va_layout:该参数用于控制内核是否启用经典布局。如果设置为 0,则启用现代布局;如果设置为 1,启用经典布局。
-
kernel.randomize_va_space:该参数用于控制 ASLR 的随机 brk offset 是否启用。如果设置为 0,则不启用随机 brk offset;如果设置为 1,则启用随机 brk offset。
这两个参数的默认值都是 1,即启用了经典布局和 ASLR 的随机 brk offset。
# 是否使用经典的进程内存布局
gary@...~$ cat /proc/sys/vm/legacy_va_layout
# 0: 使用新的进程内存布局
0
gary@...~$ sysctl -w vm.legacy_va_layout=1
# 是否开启 ASLR 地址空间布局随机化
# 当设置值为 1 时,地址空间会被随机化。
# 栈本身的位置
# 虚拟动态共享对象(VDSO)页面
# 共享内存区域
# 将选项设置为值为 2 将类似于 1,并添加数据段。
gary@...~$ cat /proc/sys/kernel/randomize_va_space
2
gary@...~$ sysctl -w kernel.randomize_va_space=0
VDSO
在 Linux 中,VDSO(Virtual Dynamic Shared Object)是一种特殊的内核映射文件,它位于用户空间,用于提供一些高效的系统调用接口。VDSO 的目的是减少用户空间应用程序对内核的频繁切换,从而提高系统的性能。
VDSO 在虚拟地址空间布局中占据了一个固定的位置,通常位于进程地址空间的最高地址部分。通过 VDSO,应用程序可以直接访问一些系统调用,而无需进行陷入内核的开销。
在控制虚拟地址空间布局中的 VDSO 方面,主要涉及到 kernel.randomize_va_space 参数。当该参数的值设置为 1 时,VDSO 页面的位置会被随机化,增加系统的安全性。这样,攻击者很难通过事先计算出的固定 VDSO 地址来进行针对性攻击。
Linux 系统默认采用了经典的虚拟地址空间布局,并启用了地址空间随机化(ASLR),包括对 VDSO 页面的位置进行随机化。
通常的虚拟地址空间布局包括以下部分:
-
Text Segment(代码段):存放程序的可执行指令,起始地址通常为0x0000000000400000或类似的值。
-
Data Segment(数据段):存放已经初始化的全局变量和静态变量等数据。
-
BSS Segment(未初始化数据段):存放未初始化的全局变量和静态变量等数据,通常会被初始化为0。
-
Heap 地址段:用于动态分配内存,包括使用malloc()等函数分配的内存。
-
Memory Mapping Segment(映射段):用于映射文件或设备到内存,例如使用mmap()函数进行内存映射。
-
Stack 地址段:用于存储函数调用的局部变量、函数参数和函数调用的返回地址等。
-
vvar(Virtual Variable):用于访问一些内核变量的特殊地址。
-
vdso(Virtual Dynamic Shared Object):提供一些高效的系统调用接口,避免频繁陷入内核。
-
vsyscall(Virtual System Call):提供一些常见系统调用的快速接口。
关于 vvar、vdso 和 vsyscall 的详细解释如下:
vsyscall:vsyscall 是一种将一些常用的系统调用函数直接映射到用户空间的机制。例如,对于读取时间的系统调用 gettimeofday(),内核会将其实现映射到 vsyscall 区域,这样用户空间程序可以直接调用这些系统调用而不需要陷入内核态,可以提高效率。然而,vsyscall 区域固定且较小,存在安全问题。
vdso:为了解决 vsyscall 固定区域和安全问题的限制,引入了 vdso 机制。vdso 将一些常用的系统函数的实现映射到一个独立的共享库文件中(通常是 linux-vd.so),用户空间程序可以通过调用这个共享库文件中的函数来获得相应的系统调用服务。vdso 的地址是随机的,并且可以包含更多的系统调用函数,因此更加灵活和安全。
vvar:vvar 区域用于存放一些特定的内核变量,用户空间程序可以通过调用 vdso 中的函数来访问这些变量,从而获取所需的信息。vvar 区域的地址是随机的,也增强了安全性。
通过 sysctl 设置成经典布局后,vdso 和 vvar 的位置会随着 Memory Mapping Segment 的变动而改变。Memory Mapping Segment 的起始地址固定为 0x00002AAAAAAAB000,并且向上增长。
在这种布局下,vdso 和 vvar 通常会被映射到 Memory Mapping Segment 中的某些区域,它们的具体位置会随着系统的内存映射情况而发生变动。这可以增加系统的安全性,并且使得内存布局更加灵活和可配置。
Linux 虚拟内存和物理内存
Linux 中的虚拟内存和物理内存之间存在着映射关系。每个进程有自己独立的虚拟内存空间,这种独立性使得链接器在链接可执行文件时可以设定内存地址而不必考虑实际的物理内存地址,从而简化了内存管理和提高了系统的灵活性。
当不同的进程使用相同的代码时,例如库文件中的代码,系统可以将这些共享的代码存储在物理内存中的一个位置,各个进程只需要将自己的虚拟内存映射到这一位置即可实现代码的共享,从而节省内存空间,减少重复存储,提高系统的效率。
在程序需要分配连续的内存空间时,虚拟内存空间可以分配连续的空间,而无需实际的物理内存空间也是连续的,这样就可以更好地利用内存碎片,减少内存浪费,优化内存的使用效率和系统性能。
参考:Linux内核源码分析(内存调优/文件系统/进程管理/设备驱动/网络协议栈)教程
Linux内核学习资料、学习群:739729163
Linux内核源码学习