当前位置: 首页 > ds >正文

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.3232位平台 的典型空间布局图:

image-20250323175946087

然而,一个标准的程序地址空间布局图(32 位系统)包括:

image-20250402122116475

更详细点来说则是:

+------------------------+  <-- 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 位系统
  • 返回 i686i386:说明是 32 位系统(几乎不会出现)。

64 位系统 下,内核空间和用户空间的划分会有所不同。

  1. 虚拟地址空间总大小:
    • 32 位系统:最大可寻址空间是 4GB(232 = 4,294,967,296 字节)。
    • 64 位系统:理论上最大可寻址空间是 16 EB(Exabyte)(264),但目前的操作系统通常不会直接使用全部 64 位地址空间。
  2. 内核空间与用户空间划分:
    在常见的 64 位 Linux 系统(如 CentOS、Ubuntu)中,用户空间与内核空间的划分通常是:
    • 内核空间(Kernel Space): 占用最高的 128TB0xFFFF8000000000000xFFFFFFFFFFFFFFFF),用户程序无法访问。
    • 用户空间(User Space): 占用最低的 128TB0x00000000000000000x00007FFFFFFFFFFF),程序代码、堆、栈、共享库等都在这个范围内。
      注意:实际物理内存远小于此,操作系统通过稀疏地址映射管理。
  3. 总结:
    • 32 位系统 下,用户空间通常是 3GB,内核空间是 1GB
    • 64 位系统 下,用户空间可以高达 128TB,内核空间也可达 128TB,两者远比 32 位系统宽裕。
  • 说明:内核空间是操作系统内核运行的地方,用户程序无法直接访问。 任何直接访问都会触发 段错误(Segmentation Fault)
  • 用途:管理硬件、进程调度、内存管理、系统调用等。
2. 命令行参数与环境变量区
  • 位置:紧挨着栈的顶部。
  • 说明: 当程序启动时,命令行参数(argcargv)以及环境变量(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):在多线程程序中,每个线程都有自己独立的栈,分布在这个区域。
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;
}

运行结果示例:

image-20250402125741766

实验二代码:

#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;
}

运行结果示例:

image-20250402131235019


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;
}

运行结果:

image-20250402133458095

我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
  • 但地址值是一样的,说明该地址绝对不是物理地址!
  • 在 Linux 地址下,这种地址叫做虚拟地址。

结论:虚拟地址相同,但值独立,证明物理地址不同。我们在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址用户一概看不到,由 OS 统一管理。OS 必须负责将虚拟地址转化成物理地址。


  1. 虚拟地址空间独立性

    • 每个进程的虚拟地址空间独立,与物理内存解耦,由 MMU 映射到物理内存。
      • 进程访问的地址是虚拟地址,由 MMU(内存管理单元) 转换为物理地址。
    • 虚拟地址相同 ≠ 物理地址相同:父子进程的相同虚拟地址可能指向不同的物理内存。
  2. 写时复制(Copy-On-Write, COW)机制

    1. 优势

      • 隔离性:进程间内存互不可见,防止相互干扰。
      • 简化开发:程序员无需关心物理内存布局。
      • 高效利用:支持分页、交换等技术,扩展可用内存。
    2. fork() 创建子进程时

      • 子进程复制父进程的虚拟地址空间结构(如代码段、数据段)。
      • 实际物理内存未被复制,父子进程共享同一物理页,标记为 只读
    3. COW 机制([Linux 5.11.4 内核 COW 机制源码分析](Linux 5.11.4内核COW机制源码分析 | woodpenker’s blog)):fork() 之后,父子进程会 共享同一块物理内存,但如果 任何一个进程试图修改,Linux 才会创建新的物理内存,从而保证它们的数据互不影响。

      [!CAUTION]

      • 当任一进程尝试写入共享页(如子进程修改 g_val),触发 页错误(Page Fault)
      • 操作系统 复制该物理页,为子进程创建新副本,更新页表映射。
      • 此后,父子进程的相同虚拟地址指向 不同的物理内存
  3. 地址相同的本质

    • 虚拟地址是进程内部的逻辑地址,由编译器和链接器在程序加载时确定。
    • 子进程继承父进程的虚拟地址布局,因此变量地址值相同。

主要了解虚拟地址,不做过多解释,点到为止:

Q1:为什么子进程和父进程的 g_val 地址相同?

在 Linux 进程管理中,每个进程都有 独立的虚拟地址空间,但是多个进程可以在 各自的虚拟地址空间 中看到相同的地址。

fork() 之后,子进程获得了父进程的完整拷贝fork() 会创建一个 新的进程,这个新进程的 地址空间父进程的完整副本,由于子进程 继承了父进程的虚拟地址空间布局它的全局变量 g_val 也会出现在相同的虚拟地址


Q2:为什么数据相互独立?

虽然子进程和父进程的变量 在虚拟地址上相同,但它们 实际上是两个独立的物理内存区域

1. 虚拟地址相同的根本原因:分页机制 —— 类比为“字典”或“目录索引”

上面的现象其实与分页机制有密切关系! 结合分页的概念再解释:

image-20250402135849830

在 Linux 和大多数现代操作系统中,进程的 地址空间是基于“分页(Paging)”管理的。分页的作用主要是:

  1. 将进程的虚拟地址映射到物理地址,实现内存的高效管理。
  2. 隔离进程的内存空间,不同进程看到的地址相同,但实际物理地址不同。
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),从而实现高效隔离。

共勉

在这里插入图片描述
在这里插入图片描述

http://www.xdnf.cn/news/15515.html

相关文章:

  • muduo面试准备
  • ThreadLocal深度解析:结构、存储机制与最佳实践
  • Linux ACL权限策略
  • 分享三个python爬虫案例
  • Docker搭建Redis分片集群
  • 【PTA数据结构 | C语言版】字符串连接操作
  • Kotlin集合接口
  • 【数据同化案例1】ETKF求解参数-状态联合估计的同化系统(完整MATLAB实现)
  • 问题记录:Fastjson序列化-空值字段处理
  • 跨域中间件通俗理解
  • 日记-生活随想
  • LVS负载均衡集群概述
  • C++--List的模拟实现
  • 【时时三省】(C语言基础)通过指针引用数组元素2
  • 20250711_Sudo 靶机复盘
  • 【读书笔记】《Effective Modern C++》第4章 Smart Pointers
  • 串口学习和蓝牙通信HC05(第八天)
  • es里的node和shard是一一对应的关系吗,可以多个shard分配到一个node上吗
  • Pandas-数据清洗与处理
  • 构建可落地的企业AI Agent,背后隐藏着怎样的技术密码?
  • redis汇总笔记
  • 什么时候需要用到 multiprocessing?
  • 基于 CentOS 7 的 LVS+DR+Web+NFS 旅游攻略分享平台部署
  • 【RA-Eco-RA6E2-64PIN-V1.0 开发板】ADC 电压的 LabVIEW 数据采集
  • 【读书笔记】《Effective Modern C++》第六章 Lambda Expressions
  • Windows 常用命令
  • vue防内存泄漏和性能优化浅解
  • 如何自动化处理TXT日志,提升工作效率新方式
  • RabbitMQ队列的选择
  • 03.Python 字符串中的空白字符处理