015 程序地址空间入门

015 程序地址空间入门
小米里的大麦程序地址空间入门
程序的 地址空间 是操作系统为每个程序分配的内存区域,它决定了程序如何访问存储在计算机内存中的数据。程序地址空间包括了多个部分,每一部分有不同的用途。通过合理管理地址空间,操作系统可以有效地进行内存分配和保护。
1. 地址空间的划分
程序的地址空间通常分为多个段,每个段具有不同的功能。这些段的划分通常是由操作系统定义的。下图是基于 kernel 2.6.32
和 32位平台
的典型空间布局图:
然而,一个标准的程序地址空间布局图(32 位系统)包括:
更详细点来说则是:
1 | +------------------------+ <-- 0xC0000000(内核空间起始,用户不可访问) |
1. 内核空间(Kernel Space)
- 范围:
0xC0000000
~0xFFFFFFFF
(占用 1GB 空间,32 位系统下)。
[!NOTE]
据搜索:Linux 现在广泛使用 64 位系统, 现在主流云服务商(如阿里云、腾讯云、华为云)的 默认系统都是 64 位(包括 CentOS、Ubuntu、Debian 等)。CentOS 7 默认只提供 64 位版本,32 位需要手动编译,非常少见。
怎么查看自己的服务器是几位?
在 Xshell 里运行以下命令查看:
uname -m
- 返回
x86_64
:说明是 64 位系统。- 返回
i686
或i386
:说明是 32 位系统(几乎不会出现)。在 64 位系统 下,内核空间和用户空间的划分会有所不同。
- 虚拟地址空间总大小:
- 32 位系统:最大可寻址空间是 4GB(2^32^ = 4,294,967,296 字节)。
- 64 位系统:理论上最大可寻址空间是 16 EB(Exabyte)(2^64^),但目前的操作系统通常不会直接使用全部 64 位地址空间。
- 内核空间与用户空间划分:
在常见的 64 位 Linux 系统(如 CentOS、Ubuntu)中,用户空间与内核空间的划分通常是:
- 内核空间(Kernel Space): 占用最高的 128TB(
0xFFFF800000000000
到0xFFFFFFFFFFFFFFFF
),用户程序无法访问。- 用户空间(User Space): 占用最低的 128TB(
0x0000000000000000
到0x00007FFFFFFFFFFF
),程序代码、堆、栈、共享库等都在这个范围内。
注意:实际物理内存远小于此,操作系统通过稀疏地址映射管理。- 总结:
- 在 32 位系统 下,用户空间通常是 3GB,内核空间是 1GB。
- 在 64 位系统 下,用户空间可以高达 128TB,内核空间也可达 128TB,两者远比 32 位系统宽裕。
- 说明:内核空间是操作系统内核运行的地方,用户程序无法直接访问。 任何直接访问都会触发 段错误(Segmentation Fault)。
- 用途:管理硬件、进程调度、内存管理、系统调用等。
2. 命令行参数与环境变量区
- 位置:紧挨着栈的顶部。
- 说明: 当程序启动时,命令行参数(
argc
和argv
)以及环境变量(envp
)会存储在这个区域。通过getenv()
、argc/argv
来访问它们。 - 示例:运行
./a.out hello world
时,argv
会保存"hello"
和"world"
这两个参数。
3. 栈(Stack)
- 方向:从高地址向低地址增长(↓)。
- 说明:栈用于管理函数调用,包括:
- 函数的返回地址。
- 局部变量、函数参数等。
- 栈帧(Stack Frame):每次函数调用都会在栈上创建新的栈帧。
- 特点:
- 栈的内存分配是自动的,函数调用结束后,栈内存会自动回收。
- 如果栈空间耗尽,会触发 栈溢出(Stack Overflow)。
4. 保护页(Guard Page)
- 位置:保护页位于栈底和堆顶,防止越界
- 说明:保护页是操作系统设置的特殊内存页,目的是防止栈或堆意外越界。
- 机制:一旦程序试图访问保护页,系统会触发段错误(
Segmentation Fault
),保护程序免受内存破坏。
5. 内存映射段(Memory Mapping Segment)
位置: 堆和栈之间。
说明:该区域通过
mmap()
系统调用进行映射,包含:- 共享库(Shared Libraries):动态链接库(
.so
、.dll
),如libc.so
。 - 文件映射(File Mapping):用来映射文件到内存。
- 匿名映射(Anonymous Mapping):用于分配大块堆内存或创建内存池。
- 线程栈(Thread Stack):在多线程程序中,每个线程都有自己独立的栈,分布在这个区域。
- 共享库(Shared Libraries):动态链接库(
6. 堆(Heap)
- 方向:从低地址向高地址增长(↑)。
- 说明:堆用于动态内存分配,通过
malloc()
、calloc()
、new
等操作分配,free()
、delete
释放。 - 特点:
- 堆的生命周期由程序员管理,忘记释放内存会导致 内存泄漏。
- 堆的大小可以动态扩展,直到达到栈或内存映射段的边界。
7. BSS 段(未初始化数据段)
说明:存储程序中未初始化的全局变量和静态变量。系统在程序启动时自动将它们初始化为 0。
示例:
1
int global_var; // 未初始化的全局变量,放在 BSS 段
8. 数据段(已初始化数据段)
说明:存储已初始化的全局变量和静态变量。
示例:
1
int global_var = 10; // 已初始化的全局变量,放在数据段
9. 文本段(Text Segment)
- 说明:存储程序的机器指令(代码部分),通常是只读的,防止程序运行时意外修改自身代码。
- 特点:
- 文本段不可写入,任何写操作都会触发段错误。
- 共享库的代码也加载到这部分,多个程序可以共享同一份代码副本。
2. 实验证明
- 代码段(.text):存放可执行指令。
- 只读数据段(.rodata):存放字符串常量和
const
全局变量。 - 数据段(.data):存放已初始化全局变量。
- BSS 段(.bss):存放未初始化全局变量。
- 堆区(heap):动态分配的内存,向高地址生长。
- 栈区(stack):局部变量,向低地址生长。
- 命令行参数和环境变量:位于栈区附近的高地址。
实验一代码:
1 |
|
运行结果示例:
实验二代码:
1 |
|
运行结果示例:
3. 虚拟地址 VS 物理地址
虚拟地址就像是邮寄地址,CPU 先找到它,再由内存管理单元(MMU)翻译成物理地址,就像邮递员最终找到具体的收件地点。
为了更好地理解地址空间的实际运作,需深入虚拟地址与物理地址的关系。先来段代码:
1 |
|
运行结果:
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 但地址值是一样的,说明该地址绝对不是物理地址!
- 在 Linux 地址下,这种地址叫做虚拟地址。
结论:虚拟地址相同,但值独立,证明物理地址不同。我们在用 C/C++
语言所看到的地址,全部都是虚拟地址!物理地址用户一概看不到,由 OS
统一管理。OS
必须负责将虚拟地址转化成物理地址。
虚拟地址空间独立性
- 每个进程的虚拟地址空间独立,与物理内存解耦,由 MMU 映射到物理内存。
- 进程访问的地址是虚拟地址,由 MMU(内存管理单元) 转换为物理地址。
- 虚拟地址相同 ≠ 物理地址相同:父子进程的相同虚拟地址可能指向不同的物理内存。
- 每个进程的虚拟地址空间独立,与物理内存解耦,由 MMU 映射到物理内存。
写时复制(Copy-On-Write, COW)机制
优势
- 隔离性:进程间内存互不可见,防止相互干扰。
- 简化开发:程序员无需关心物理内存布局。
- 高效利用:支持分页、交换等技术,扩展可用内存。
fork()
创建子进程时:- 子进程复制父进程的虚拟地址空间结构(如代码段、数据段)。
- 实际物理内存未被复制,父子进程共享同一物理页,标记为 只读。
COW 机制([Linux 5.11.4 内核 COW 机制源码分析](Linux 5.11.4内核COW机制源码分析 | woodpenker’s blog)): 在
fork()
之后,父子进程会 共享同一块物理内存,但如果 任何一个进程试图修改,Linux 才会创建新的物理内存,从而保证它们的数据互不影响。[!CAUTION]
- 当任一进程尝试写入共享页(如子进程修改
g_val
),触发 页错误(Page Fault)。 - 操作系统 复制该物理页,为子进程创建新副本,更新页表映射。
- 此后,父子进程的相同虚拟地址指向 不同的物理内存。
- 当任一进程尝试写入共享页(如子进程修改
地址相同的本质
- 虚拟地址是进程内部的逻辑地址,由编译器和链接器在程序加载时确定。
- 子进程继承父进程的虚拟地址布局,因此变量地址值相同。
主要了解虚拟地址,不做过多解释,点到为止:
Q1:为什么子进程和父进程的
g_val
地址相同?在 Linux 进程管理中,每个进程都有 独立的虚拟地址空间,但是多个进程可以在 各自的虚拟地址空间 中看到相同的地址。
fork()
之后,子进程获得了父进程的完整拷贝。fork()
会创建一个 新的进程,这个新进程的 地址空间 是 父进程的完整副本,由于子进程 继承了父进程的虚拟地址空间布局,它的全局变量g_val
也会出现在相同的虚拟地址。
Q2:为什么数据相互独立?
虽然子进程和父进程的变量 在虚拟地址上相同,但它们 实际上是两个独立的物理内存区域。
1. 虚拟地址相同的根本原因:分页机制 —— 类比为“字典”或“目录索引”
上面的现象其实与分页机制有密切关系! 结合分页的概念再解释:
在 Linux 和大多数现代操作系统中,进程的 地址空间是基于“分页(Paging)”管理的。分页的作用主要是:
- 将进程的虚拟地址映射到物理地址,实现内存的高效管理。
- 隔离进程的内存空间,不同进程看到的地址相同,但实际物理地址不同。
2. 分页表(Page Table)是如何运作的?
- 在 Linux 的 分页机制 中,每个进程都有一个 页表(Page Table),它 记录虚拟地址到物理地址的映射。
fork()
后,子进程 的页表会复制父进程的页表,但两者 指向相同的物理页,直到发生 写操作 时,才会真正分配新物理页。
3. 分页是如何影响 fork()
的?
fork()
后,子进程会 继承父进程的虚拟地址空间,因此在 虚拟地址 层面,它们的全局变量g_val
地址相同(0x601054
)。- 但是,操作系统 并不会立即复制父进程的所有物理内存页,而是采用 写时复制(Copy-On-Write, COW) 机制。
示意图(分页机制 + COW)
1 | 父进程: |
4. fork() + 分页 + COW 的关系
机制 | 影响 |
---|---|
分页 | 每个进程有独立的虚拟地址空间,但可以共享物理页。 |
页表(Page Table) | 记录虚拟地址到物理地址的映射,fork() 后子进程继承父进程的页表。 |
写时复制(COW) | 直到子进程或父进程修改数据时,才会真正分配新的物理内存页。 |
进程隔离 | 虽然地址相同,但修改数据后,两者的物理页不再共享。 |
4. 小结
程序地址空间: 是操作系统为每个进程分配的 虚拟内存布局,决定了代码、数据、堆、栈等区域的访问方式。
虚拟地址相同,但物理地址可能不同:fork() 只是复制了 页表,不会立即复制物理内存,直到子进程写入数据。
虚拟地址 ≠ 物理地址:程序看到的是虚拟地址,由操作系统通过 MMU(内存管理单元) 动态映射到物理内存。
分页 + COW 机制确保内存效率:避免了不必要的物理内存复制,只有修改数据时才会真正分配新内存。
总结一句话:虚拟地址是“门牌号”,物理地址是“真实房屋”。 fork()
后,父子进程共享“门牌号目录”(页表),但“房屋”(物理内存)仅在需要时复制(COW),从而实现高效隔离。