进程虚拟地址空间是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)采用多层架构,内存来源分为两条路径:
- 小内存分配:从堆区(arena)分配
- 对于小于
mmap_threshold的分配请求,分配器从内存池(arena)中切分内存 - 内存池不足时,底层通过
brk()扩展program break来扩大堆区
- 对于小于
- 大内存分配:直接使用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_NONEflags:映射类型,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)的工作就是:
- 解析程序依赖的所有共享库
- 按照搜索路径找到对应的.so文件
- 通过mmap将共享库的各个段映射到进程地址空间
- 进行符号重定位,解析函数与变量地址
因此在/proc/pid/maps中,我们会看到一个libc.so对应多个VMA段,分别对应不同权限的节:
r-xp:代码段(.text)r--p:只读数据段(.rodata)rw-p:可写数据段(.data + .bss)
五、栈(Stack)
栈承载了函数调用的全部上下文,是程序运行的基石。
5.1 栈里有什么
栈区域存储的内容包括:
- 函数栈帧:每次函数调用都会创建一个栈帧,包含返回地址、调用者保存的寄存器、局部变量、临时数据、函数参数等
- 进程启动信息:程序启动时,内核会把命令行参数(argv)、环境变量(envp)、辅助向量(auxv)压入栈顶
- 线程私有数据:每个线程的栈中还会存放线程的私有数据
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各字段含义:
- 地址范围:VMA的起始和结束虚拟地址
- 权限:
r读 /w写 /x执行 /s共享 /p私有(写时复制) - 偏移:文件映射中的偏移量,匿名映射为0
- 设备:映射文件所在的设备号
- inode:映射文件的inode号
- 路径名:映射的文件路径,或特殊标记
区域对应关系
我们可以把前面讲的所有区域对应到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进程虚拟地址空间从大的层面分为用户空间和内核空间,其中用户空间主要包含以下几类区域:
- ELF可执行映射
- 可执行代码段(.text):存放机器指令,只读可执行
- 只读数据段(.rodata):存放常量,只读
- 可写静态数据段:.data(已初始化)和.bss(零初始化),可读可写
- 运行时动态映射
- 传统堆:基于brk机制,用于小内存分配
- mmap映射区:包括匿名映射、文件映射、共享库映射等,是现代内存分配的核心
- 栈区域:主线程栈与每个线程的独立栈,用于函数调用上下文
- 内核空间:受特权级保护,用户态不可直接访问
最后补充一句:
.text/.rodata/.data/.bss是ELF文件的Section概念,属于静态视图;程序运行时加载器按Program Header中的Segment进行映射;而堆、栈、共享库等最终都表现为内核管理的VMA。