015 程序地址空间入门
🦄 个人主页: 小米里的大麦-CSDN博客
🎏 所属专栏: Linux_小米里的大麦的博客-CSDN博客
🎁 GitHub主页: 小米里的大麦的 GitHub
⚙️ 操作环境: Visual Studio 2022
文章目录
- 程序地址空间入门
- 1. 地址空间的划分
- 1. 内核空间(Kernel Space)
- 2. 命令行参数与环境变量区
- 3. 栈(Stack)
- 4. 保护页(Guard Page)
- 5. 内存映射段(Memory Mapping Segment)
- 6. 堆(Heap)
- 7. BSS 段(未初始化数据段)
- 8. 数据段(已初始化数据段)
- 9. 文本段(Text Segment)
- 2. 实验证明
- 3. 虚拟地址 VS 物理地址
- 主要了解虚拟地址,不做过多解释,点到为止:
- Q1:为什么子进程和父进程的 `g_val` 地址相同?
- Q2:为什么数据相互独立?
- 1. 虚拟地址相同的根本原因:分页机制 —— 类比为“字典”或“目录索引”
- 2. 分页表(Page Table)是如何运作的?
- 3. 分页是如何影响 `fork()` 的?
- 4. fork() + 分页 + COW 的关系
- 4. 小结
- 共勉
程序地址空间入门
程序的 地址空间 是操作系统为每个程序分配的内存区域,它决定了程序如何访问存储在计算机内存中的数据。程序地址空间包括了多个部分,每一部分有不同的用途。通过合理管理地址空间,操作系统可以有效地进行内存分配和保护。
1. 地址空间的划分
程序的地址空间通常分为多个段,每个段具有不同的功能。这些段的划分通常是由操作系统定义的。下图是基于 kernel 2.6.32
和 32位平台
的典型空间布局图:
然而,一个标准的程序地址空间布局图(32 位系统)包括:
更详细点来说则是:
+------------------------+ <-- 0xC0000000(内核空间起始,用户不可访问)
| 内核空间 |
+------------------------+ <-- 0xBFFFFFFF(用户空间结束)
| 栈(Stack) | ↓ 向低地址增长
| - 主线程栈 |
+------------------------+
| 命令行参数与环境变量区 | ← 位于栈的顶部下方(高地址区域)
+------------------------+
| 保护页 | ← 防止栈溢出
+------------------------+
| 内存映射段 |
| - 共享库(.so/.dll) |
| - 文件映射(mmap) |
| - 匿名映射(大块堆内存)|
| - 线程栈(多线程时) | ← 其他线程栈通过 mmap 分配在此区域
+------------------------+
| 堆(Heap) | ↑ 向高地址增长
+------------------------+
| 保护页 | ← 防止堆越界
+------------------------+
| BSS段(未初始化数据) |
+------------------------+
| 数据段(已初始化数据) |
+------------------------+
| 文本段(程序代码) |
+------------------------+ <-- 0x00000000(低地址)
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(232 = 4,294,967,296 字节)。
- 64 位系统:理论上最大可寻址空间是 16 EB(Exabyte)(264),但目前的操作系统通常不会直接使用全部 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。
-
示例:
int global_var; // 未初始化的全局变量,放在 BSS 段
8. 数据段(已初始化数据段)
-
说明:存储已初始化的全局变量和静态变量。
-
示例:
int global_var = 10; // 已初始化的全局变量,放在数据段
9. 文本段(Text Segment)
- 说明:存储程序的机器指令(代码部分),通常是只读的,防止程序运行时意外修改自身代码。
- 特点:
- 文本段不可写入,任何写操作都会触发段错误。
- 共享库的代码也加载到这部分,多个程序可以共享同一份代码副本。
2. 实验证明
- 代码段(.text):存放可执行指令。
- 只读数据段(.rodata):存放字符串常量和
const
全局变量。 - 数据段(.data):存放已初始化全局变量。
- BSS 段(.bss):存放未初始化全局变量。
- 堆区(heap):动态分配的内存,向高地址生长。
- 栈区(stack):局部变量,向低地址生长。
- 命令行参数和环境变量:位于栈区附近的高地址。
实验一代码:
#include <stdio.h>
#include <stdlib.h>
int global_init = 10; // 数据段
int global_uninit; // BSS段int main()
{int stack_var; // 栈static int static_init = 20; // 数据段static int static_uninit; // BSS段char* heap_var = malloc(10); // 堆printf("文本段(代码): %p\n", (void*)main);printf("数据段(已初始化全局变量): %p\n", (void*)&global_init);printf("数据段(已初始化静态变量): %p\n", (void*)&static_init);printf("BSS段(未初始化全局变量): %p\n", (void*)&global_uninit);printf("BSS段(未初始化静态变量): %p\n", (void*)&static_uninit);printf("堆(动态分配内存): %p\n", (void*)heap_var);printf("栈(局部变量): %p\n", (void*)&stack_var);free(heap_var);return 0;
}
运行结果示例:
实验二代码:
#include <stdio.h>
#include <stdlib.h>
int g_unval; // 未初始化全局变量(BSS段)
int g_val = 100; // 已初始化全局变量(数据段)
const int g_const = 200; // 只读常量(可能位于文本段或只读数据段)int main(int argc, char* argv[], char* envp[])
{// 1. 代码段和只读数据printf("==================== 代码段和只读数据 ====================\n");printf("main函数地址(代码段) : %p\n", main);printf("字符串常量地址(只读数据) : %p\n", "hello world");// 2. 数据段和BSS段printf("\n==================== 数据段和BSS段 ====================\n");printf("已初始化全局变量(g_val) : %p\n", &g_val);printf("未初始化全局变量(g_unval) : %p\n", &g_unval);printf("const全局常量(g_const) : %p\n", &g_const);// 3. 堆区printf("\n==================== 堆区 ====================\n");int* p1 = (int*)malloc(10);int* p2 = (int*)malloc(20);printf("小堆块地址(p1) : %p\n", p1);printf("大堆块地址(p2) : %p\n", p2);printf("堆生长方向(p2 - p1) : %ld\n", p2 - p1); // 正数表示向高地址生长// 4. 栈区printf("\n==================== 栈区 ====================\n");int stack_var1;int stack_var2;printf("栈变量1(stack_var1) : %p\n", &stack_var1);printf("栈变量2(stack_var2) : %p\n", &stack_var2);printf("栈生长方向(&stack_var2 - &stack_var1): %ld\n",(long)&stack_var2 - (long)&stack_var1); // 负数表示向低地址生长// 5. 命令行参数和环境变量printf("\n==================== 命令行参数和环境变量 ====================\n");printf("argv数组地址 : %p\n", argv);printf("envp数组地址 : %p\n", envp);for (int i = 0; i < argc; i++){printf("argv[%d]地址 : %p\n", i, argv[i]);}for (int i = 0; envp[i]; i++){printf("envp[%d]地址 : %p\n", i, envp[i]);}// 6. 动态库加载地址printf("\n==================== 动态库 ====================\n");void* libc_addr = (void*)printf;printf("libc函数(printf)地址 : %p\n", libc_addr);free(p1);free(p2);return 0;
}
运行结果示例:
3. 虚拟地址 VS 物理地址
虚拟地址就像是邮寄地址,CPU 先找到它,再由内存管理单元(MMU)翻译成物理地址,就像邮递员最终找到具体的收件地点。
为了更好地理解地址空间的实际运作,需深入虚拟地址与物理地址的关系。先来段代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 100; // 全局变量,存储在数据段int main()
{pid_t id = fork(); // 调用 fork() 创建子进程if (id == 0) // 子进程{ g_val = 200; // 修改全局变量的值printf("child:PID:%d, PPID:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);}else if (id > 0) // 父进程{ sleep(3); // 父进程睡眠 3 秒,等待子进程运行printf("father:PID:%d, PPID:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);}else // fork() 失败{ // 处理 fork() 错误}return 0;
}
运行结果:
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 但地址值是一样的,说明该地址绝对不是物理地址!
- 在 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)
父进程:
+------------+ +------------+
| 0x601054 | ---> | 物理页 A |
+------------+ +------------+子进程(fork后):
+------------+ +------------+
| 0x601054 | ---> | 物理页 A | (共享)
+------------+ +------------+子进程(修改 g_val = 200 后):
+------------+ +------------+ +------------+
| 0x601054 | ---> | 物理页 B | | 物理页 A |
+------------+ +------------+ +------------+(子进程获得新的物理页) (父进程的物理页)
4. fork() + 分页 + COW 的关系
机制 | 影响 |
---|---|
分页 | 每个进程有独立的虚拟地址空间,但可以共享物理页。 |
页表(Page Table) | 记录虚拟地址到物理地址的映射,fork() 后子进程继承父进程的页表。 |
写时复制(COW) | 直到子进程或父进程修改数据时,才会真正分配新的物理内存页。 |
进程隔离 | 虽然地址相同,但修改数据后,两者的物理页不再共享。 |
4. 小结
-
程序地址空间: 是操作系统为每个进程分配的 虚拟内存布局,决定了代码、数据、堆、栈等区域的访问方式。
-
虚拟地址相同,但物理地址可能不同:fork() 只是复制了 页表,不会立即复制物理内存,直到子进程写入数据。
-
虚拟地址 ≠ 物理地址:程序看到的是虚拟地址,由操作系统通过 MMU(内存管理单元) 动态映射到物理内存。
-
分页 + COW 机制确保内存效率:避免了不必要的物理内存复制,只有修改数据时才会真正分配新内存。
总结一句话:虚拟地址是“门牌号”,物理地址是“真实房屋”。 fork()
后,父子进程共享“门牌号目录”(页表),但“房屋”(物理内存)仅在需要时复制(COW),从而实现高效隔离。
共勉