☰
Linux --- 进程虚拟地址空间
2026/10/24 5:24:08 网站建设 项目流程

进程虚拟地址空间是Linux系统编程的骨架,fork、exec、mmap、动态链接、线程栈、内存泄漏、段错误、写时复制(COW)等机制,全部建立在地址空间的基础之上。

“text、data、bss、heap、stack”的五层模型便于入门,但混淆了静态ELF文件结构与运行时内存布局两个完全不同的视角,也忽略了现代Linux系统中mmap、多线程栈、内核空间隔离等关键机制。

ELF:Executable and Linkable Format,Linux / Unix 体系下的标准二进制文件格式,静态程序、动态程序、目标文件.o、动态库.so、核心转储 core 文件,都是 ELF。


一、Section / Segment / VMA

1.1 ELF Section:链接器视角

Section(节)是编译与静态链接阶段的概念,是链接器进行符号解析、重定位、段合并的基本单元。

  • 典型的节:.text、.rodata、.data、.bss、.symtab、.strtab、.rel.text等
  • 描述结构:Section Header Table(节头表)
  • 查看工具:readelf -S <elf_file>
  • 作用:组织二进制文件的内容,方便链接器进行合并、重定位与符号管理

节头表描述了文件中所有节的信息,每个节有自己的类型、属性、地址、大小等元数据。但节的划分是面向链接器的,不是所有节都会被加载到内存中(比如符号表、调试信息等节仅用于链接与调试,运行时不需要加载)。

1.2 ELF Segment:加载器视角

Segment(段)是程序加载阶段的概念,是操作系统加载ELF文件时进行内存映射的基本单元。

  • 典型的段:PT_LOAD(可加载段)、PT_DYNAMIC(动态链接信息)、PT_INTERP(解释器路径)等
  • 描述结构:Program Header Table(程序头表)
  • 查看工具:readelf -l <elf_file>
  • 作用:告诉内核如何把文件内容映射到进程虚拟地址空间

elf(5)明确指出:一个段包含一个或多个节。链接器会把权限属性相同的节打包进同一个PT_LOAD段中,内核加载时以段为单位进行内存映射,而不是逐个节映射。

1.3 VMA:内核内存管理视角

VMA(Virtual Memory Area,虚拟内存区域)是运行时内核视角的概念,是内核管理进程虚拟地址空间的最小单位。

  • 每个VMA是一段连续的、具有相同访问权限和属性的虚拟地址范围
  • 查看工具:cat /proc/<pid>/maps
  • 作用:内核页表管理、缺页异常处理、内存权限控制的基础

我们常说的“堆、栈、共享库映射”,本质上都是一个个VMA。ELF段加载后会对应到VMA,但运行时还会动态创建很多VMA(比如堆扩展、mmap映射、线程栈等)。

Section是链接视图,Segment是加载视图,VMA是运行时视图。三者从不同层面描述了程序的内存布局,不可混为一谈。


二、ELF可执行映像:四大节

.text、.rodata、.data、.bss是ELF文件中最核心的四个节,也是程序静态代码与数据的载体。

2.1 .text:可执行代码段

.text段存放编译后的机器指令,是程序逻辑的载体。

  • 内容:函数的机器码、启动代码、异常处理指令等
  • ELF类型:SHT_PROGBITS(程序定义的内容,存在于文件中)
  • ELF属性:SHF_ALLOC | SHF_EXECINSTR(运行时占用内存、包含可执行指令)
  • 运行时权限:R-X(可读、可执行、不可写)

例如一个简单的加法函数:

int add(int a, int b) { return a + b; }

编译后生成的机器指令全部归入.text段。由于该段默认没有写权限,直接修改代码段的内存会触发SIGSEGV(段错误),这也是Linux系统基本的安全防护。

2.2 .rodata:只读数据段

.rodata段存放只读数据,是程序中常量的载体。

  • 内容:字符串常量、const修饰的全局变量、跳转表等
  • ELF类型:SHT_PROGBITS
  • ELF属性:SHF_ALLOC
  • 运行时权限:R--(只读、不可执行、不可写)
const char* str = "hello world";

问:str变量在哪里?字符串"hello world"在哪里?

答案是两个完全不同的位置:

  • 若str是局部变量:变量本身存储在栈(或寄存器)中,保存的是一个地址值
  • 字符串常量"hello world":存储在.rodata段中

因此str = "world"是合法的(修改指针变量本身的值),但str[0] = 'H'是非法的(修改只读段的数据),C++的const类型系统也会在编译期阻止这一行为。

2.3 .data:已初始化数据段

.data段存放已初始化的、可写的全局变量与静态变量。

  • 内容:有明确初始值的全局变量、静态变量
  • ELF类型:SHT_PROGBITS
  • ELF属性:SHF_ALLOC | SHF_WRITE(运行时占用内存、可写)
  • 运行时权限:RW-(可读、可写、不可执行)
  • 文件特性:变量的初始值保存在ELF文件中,占用磁盘空间

例如:

int g1 = 100; // 全局已初始化 → .data static int g4 = 300; // 静态已初始化 → .data

这些变量的初始值100、300会被写入ELF文件的.data段,程序加载时直接映射到内存。

2.4 .bss:未初始化数据段

.bss段存放未初始化或初始化为0的全局变量与静态变量,是最容易被误解的节。

  • 内容:未显式初始化、或初始值为0的全局/静态变量
  • ELF类型:SHT_NOBITS(关键:不占用文件中的实际空间)
  • ELF属性:SHF_ALLOC | SHF_WRITE
  • 运行时权限:RW-
  • 文件特性:仅记录大小,不存储实际数据;程序加载时由内核将该区域初始化为全0

SHT_NOBITS类型的节在文件中不占用空间,仅记录概念上的偏移与大小。这一设计的目的是节省磁盘空间。

例如定义一个100MB的全局数组:

char buffer[100 * 1024 * 1024]; // 未初始化全局数组

如果没有.bss段,ELF文件需要存储100MB的0,会极大浪费磁盘空间。有了.bss,文件中只需记录“该区域大小为100MB,运行时清零”即可。

关键对比:

  • .data:占磁盘空间,占运行内存
  • .bss:不占磁盘空间(仅存元数据),占运行内存

三、堆(Heap)与brk机制

3.1 Program Break与brk系统调用

Linux内核中,传统堆的边界由program break(程序间断点)定义,它标志着进程数据段的结束位置。

  • 数据段(.data + .bss)结束之后,就是堆的起始地址
  • program break是堆的当前边界,向高地址方向移动则堆扩展,向低地址移动则堆收缩

操作program break的系统调用是brk()和sbrk():

  • brk(addr):直接将program break设置为指定地址
  • sbrk(increment):将program break增加/减少指定字节数,返回旧的边界地址

提高program break会为进程分配内存,降低则释放内存。应用程序应使用malloc等标准分配接口,而不是直接调用brk/sbrk。

3.2 重要纠正:malloc ≠ 堆

glibc的内存分配器(ptmalloc)采用多层架构,内存来源分为两条路径:

  1. 小内存分配:从堆区(arena)分配
    • 对于小于mmap_threshold的分配请求,分配器从内存池(arena)中切分内存
    • 内存池不足时,底层通过brk()扩展program break来扩大堆区
  2. 大内存分配:直接使用mmap匿名映射
    • 对于大于mmap_threshold的分配请求,分配器直接调用mmap(MAP_ANONYMOUS)分配独立的匿名映射
    • 释放时直接调用munmap()归还内存给系统

mmap_threshold默认值为128KB,并且会根据程序的分配模式动态调整。

除此之外,glibc还引入了多arena机制:为减少多线程下的锁竞争,系统会创建多个内存池(arena),每个线程优先使用自己的arena。arena的数量可通过glibc.malloc.arena_max等参数配置。

malloc分配的内存可能来自传统堆,也可能来自mmap匿名映射。[heap]标记的VMA只是传统堆区,不等于所有malloc内存。


四、mmap

mmap是现代Linux内存管理的核心机制,其重要性远超过传统堆。理解了mmap,才能真正理解进程地址空间。

4.1 mmap定义

mmap():在进程的虚拟地址空间中,创建一段新的虚拟内存区域(VMA),并将其与某种后备存储关联起来。

mmap的原型为:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

参数:

  • prot:内存保护权限,PROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONE
  • flags:映射类型,MAP_PRIVATE(私有写时复制)/MAP_SHARED(共享)/MAP_ANONYMOUS(匿名)等

4.2 mmap的四大分类

按照后备存储的不同,mmap映射可以分为四大类:

(1)匿名映射(Anonymous Mapping)
  • 没有文件作为后备存储,映射内容初始化为0
  • 标志:MAP_ANONYMOUS,fd参数传-1
  • 用途:大内存分配、线程栈、堆的扩展等
(2)普通文件映射(File-backed Mapping)
  • 将磁盘上的普通文件映射到地址空间,访问内存等价于访问文件
  • 分为MAP_PRIVATE(私有,写时复制,修改不回写文件)和MAP_SHARED(共享,修改直接回写文件)
  • 用途:文件快速读写、配置加载、进程间通信等
(3)共享库映射(Shared Library Mapping)
  • 本质是文件映射的一种,动态链接器将.so共享库文件映射到进程地址空间
  • 一个共享库会被拆分成多个不同权限的段:代码段(r-x)、只读数据段(r–)、可写数据段(rw-)
  • 用途:加载libc、libstdc++等动态库
(4)特殊映射
  • 设备映射:映射硬件设备的寄存器地址,用于驱动开发
  • 共享内存:/dev/shm、POSIX共享内存,用于进程间通信
  • vdso/vsyscall:内核映射到用户空间的系统调用加速代码

4.3 共享库

所有动态加载的共享库,本质都是通过mmap映射到进程地址空间的。

动态链接器(ld-linux.so)的工作就是:

  1. 解析程序依赖的所有共享库
  2. 按照搜索路径找到对应的.so文件
  3. 通过mmap将共享库的各个段映射到进程地址空间
  4. 进行符号重定位,解析函数与变量地址

因此在/proc/pid/maps中,我们会看到一个libc.so对应多个VMA段,分别对应不同权限的节:

  • r-xp:代码段(.text)
  • r--p:只读数据段(.rodata)
  • rw-p:可写数据段(.data + .bss)

五、栈(Stack)

栈承载了函数调用的全部上下文,是程序运行的基石。

5.1 栈里有什么

栈区域存储的内容包括:

  1. 函数栈帧:每次函数调用都会创建一个栈帧,包含返回地址、调用者保存的寄存器、局部变量、临时数据、函数参数等
  2. 进程启动信息:程序启动时,内核会把命令行参数(argv)、环境变量(envp)、辅助向量(auxv)压入栈顶
  3. 线程私有数据:每个线程的栈中还会存放线程的私有数据

x86架构下栈是向下增长的(从高地址向低地址延伸),栈指针寄存器(rsp)指向栈顶。

5.2 栈溢出的原理

栈的大小不是无限的,受RLIMIT_STACK资源限制,默认通常为8MB。

当栈访问超出限制范围时,会触及栈下方的守护页(guard page),触发页故障,内核向进程发送SIGSEGV信号,也就是我们常说的“栈溢出”。

例如定义一个超大的局部数组:

void foo() { char buffer[100 * 1024 * 1024]; // 100MB局部数组 }

调用该函数会直接触发栈溢出。

5.3 多线程下的栈模型

同一进程的多个线程共享地址空间,但每个线程拥有独立的栈。

这里的“独立”不是指独立的地址空间,而是指在同一个虚拟地址空间中,每个线程对应一个独立的栈VMA。每个线程的栈指针(rsp)指向自己的栈区域,因此线程的局部变量互不干扰。

进程地址空间中的栈区域包括:

  • 主线程栈:标记为[stack]
  • 其他线程栈:每个线程一个独立的匿名映射段,旧内核中标记为[stack:tid]

六、用户空间与内核空间的划分

6.1 x86-64地址空间布局

根据Linux内核官方文档(arch/x86/x86_64/mm),64位虚拟地址空间分为两大部分,中间是巨大的非规范地址空洞。

4级页表(48位虚拟地址)
  • 用户空间:0x0000000000000000 ~ 0x00007fffffffffff,大小约128TB
  • 非规范空洞:0x0000800000000000 ~ 0xffff7fffffffffff,不可访问
  • 内核空间:0xffff800000000000 ~ 0xffffffffffffffff,大小约128TB
5级页表(57位虚拟地址)
  • 用户空间扩展到约64PB
  • 内核空间起始地址下移,范围相应扩大

需要注意的是,ASLR(地址空间布局随机化)、PIE(位置无关可执行文件)、内核配置等都会改变具体区域的实际地址,教科书式的固定布局只是简化模型。

6.2 内核空间的内部布局

内核空间不是一块整体,而是划分为多个功能区域:

  • 直接物理映射区:线性映射所有物理内存
  • vmalloc/ioremap区:动态分配的内核虚拟内存
  • 内核代码/数据段:内核本身的text、data段
  • KASAN影子内存:内存检测工具的影子区域
  • EFI区域:EFI运行时服务映射
  • 固定映射区:特殊的固定地址映射

6.3 权限隔离

用户态程序运行在特权级3(CPL 3),内核运行在特权级0(CPL 0)。用户态无法直接访问内核空间的任何地址,强行访问会触发页故障,最终由内核发送SIGSEGV信号终止进程。

这是操作系统最基本的安全隔离机制,保证用户程序不能破坏内核或其他进程。


七、用/proc/maps看清真实的地址空间

/proc/<pid>/maps是观察进程地址空间最直接的工具,它列出了进程所有的VMA。每一行的格式为:

地址范围 权限 偏移 设备 inode 路径名 00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/dbus-daemon

各字段含义:

  1. 地址范围:VMA的起始和结束虚拟地址
  2. 权限:r读 /w写 /x执行 /s共享 /p私有(写时复制)
  3. 偏移:文件映射中的偏移量,匿名映射为0
  4. 设备:映射文件所在的设备号
  5. inode:映射文件的inode号
  6. 路径名:映射的文件路径,或特殊标记

区域对应关系

我们可以把前面讲的所有区域对应到maps输出中:

区域类型maps中的表现典型权限
可执行文件代码段对应二进制文件路径r-xp
可执行文件只读数据对应二进制文件路径r--p
可执行文件数据段对应二进制文件路径rw-p
传统堆[heap]rw-p
匿名映射空路径名rw-p
共享库代码段对应.so文件路径r-xp
共享库数据段对应.so文件路径rw-p
主线程栈[stack]rw-p
虚拟动态共享对象[vdso]r-xp

总结

Linux进程虚拟地址空间从大的层面分为用户空间和内核空间,其中用户空间主要包含以下几类区域:

  1. ELF可执行映射
    • 可执行代码段(.text):存放机器指令,只读可执行
    • 只读数据段(.rodata):存放常量,只读
    • 可写静态数据段:.data(已初始化)和.bss(零初始化),可读可写
  2. 运行时动态映射
    • 传统堆:基于brk机制,用于小内存分配
    • mmap映射区:包括匿名映射、文件映射、共享库映射等,是现代内存分配的核心
    • 栈区域:主线程栈与每个线程的独立栈,用于函数调用上下文
  3. 内核空间:受特权级保护,用户态不可直接访问

最后补充一句:

.text/.rodata/.data/.bss是ELF文件的Section概念,属于静态视图;程序运行时加载器按Program Header中的Segment进行映射;而堆、栈、共享库等最终都表现为内核管理的VMA。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询