【Linux系统】探秘内存黑匣子:程序地址空间的底层真相与趣味实践

在这里插入图片描述


一、前言

嘿,小伙伴们!还记得咱们之前聊过的 fork 函数吗?当时说它会返回两次,而且在讲变量为啥在父子进程里会有不同的值时,提到了进程独立性和写时拷贝。不过,为啥同一个变量名,父子进程看到的内容却不一样呢?这背后藏着一个超有意思的概念 —— 程序地址空间!今天,就让我们一起揭开它神秘的面纱,一探究竟吧!

二、编程语言视角下的地址空间

学 C/C++ 的时候,肯定见过这张经典的地址空间图(以 32 位机为例哈)。32 位地址线就像一群有超能力的小精灵,它们能组合出 2 32 2^{32} 232 种状态,也就是 4G 个地址。每个地址对应一个字节,所以整个地址空间就是 4G 大小啦!这里面的内核空间是操作系统的 “专属领地”,进程的各种数据结构都存放在这里哦。

在这里插入图片描述

2.1 动手验证一下

咱们用代码来实际感受感受地址空间的分布,先看这段代码:

#include <stdio.h>    
#include <stdlib.h>    

int g_val_1; // 一个没初始化的全局变量,就像一个空盒子
int g_val_2 = 100; // 初始化好的全局变量,里面装着数字100
int main(int argc, char* argv[], char* env[])    
{    
    printf("code addr:%p\n", main);// 打印main函数地址,看看代码区在哪
    const char *str = "Hello word";// 定义一个字符串常量,像一句固定的台词
    printf("read only string addr:%p\n", str);// 打印字符常量区地址    
    printf("init global value addr:%p\n", &g_val_2);// 已初始化全局变量地址    
    printf("uninit global value addr:%p\n", &g_val_1); // 未初始化全局变量地址    
    char* men1 = (char*)malloc(100);    
    printf("heap addr-men1:%p\n", men1);// 堆区地址,像一个动态仓库    
    printf("stack addr-str:%p\n", &str); // 栈区地址    
    static int a = 10;// 静态局部变量,有点特别哦    
    printf("static a add:%p\n", &a); // 静态局部变量地址    
    int i = 0;    
    for(; argv[i]; i++)    
        printf("argv[%d],addr:%p\n", i, argv[i]);// 打印命令行参数地址   
    for(i = 0; env[i]; i++)    
        printf("env[%d],addr:%p\n", i, env[i]);// 打印环境变量地址                                                                                                                                           
    return 0;    
}

可能的运行结果(示例):

code addr:0x555555554810
read only string addr:0x555555556014
init global value addr:0x555555556020
uninit global value addr:0x55555555601c
heap addr-men1:0x555555758780
stack addr-str:0x7ffc406769c8
static a add:0x555555556018
argv[0],addr:0x555555554770
env[0],addr:0x7ffc40676e78
env[1],addr:0x7ffc40676e88
env[2],addr:0x7ffc40676e98
... // 省略更多环境变量的输出

这里还有两个超有趣的小知识:

  • 堆栈空间就像在玩 “背靠背” 游戏,堆区从低地址开始 “生长”,栈区从高地址开始 “生长”。
    • 栈区地址增长
int main()    
{    
    int a;    
    int b;    
    int c;    
    int d;    
    printf("stack addr:%p\n", &a);    
    printf("stack addr:%p\n", &b);    
    printf("stack addr:%p\n", &c);    
    printf("stack addr:%p\n", &d);                                                                                                                                                                               
}    

可能的运行结果(示例):

stack addr:0x7ffd4612376c
stack addr:0x7ffd46123768
stack addr:0x7ffd46123764
stack addr:0x7ffd46123760

可以看到栈区地址是由高向低增长的。

  • 堆区地址增长
int main()    
{    
    char* mem1 = (char*)malloc(100);    
    char* mem2 = (char*)malloc(100);    
    char* mem3 = (char*)malloc(100);    
    char* mem4 = (char*)malloc(100);    
    printf("Heap addr:%p\n", mem1);    
    printf("Heap addr:%p\n", mem2);    
    printf("Heap addr:%p\n", mem3);    
    printf("Heap addr:%p\n", mem4);                                                                                                                                                                              
    return 0;    
}

可能的运行结果(示例):

Heap addr:0x555555758780
Heap addr:0x555555758800
Heap addr:0x555555758880
Heap addr:0x555555758900

可以看到堆区地址是由低向高增长的。

  • 堆栈地址离得老远,中间还藏着一块区域,等讲动静态库的时候再给大家揭秘!

还有哦,静态变量也很有意思,看这段代码:

int g_val_1; 
int g_val_2 = 100; 
int main()    
{    
    printf("code addr:%p\n", main);    
    const char *str = "Hello word";    
    printf("read only string addr:%p\n", str);    
    printf("init global value addr:%p\n", &g_val_2);    
    printf("uninit global value addr:%p\n", &g_val_1); 
    char* men1 = (char*)malloc(100);    
    printf("heap addr-men1:%p\n", men1);    
    printf("stack addr-str:%p\n", &str);    
    static int a = 10;                                                                                                                                                                     
    printf("static a add:%p\n", &a);    
    return 0;    
}
code addr:0x56205750d169
read only string addr:0x56205750d288
init global value addr:0x56205770e010
uninit global value addr:0x56205770e00c
heap addr-men1:0x562057b0f740
stack addr-str:0x7ffd78d26858
static a add:0x56205770e008

从上述模拟结果可以看到,静态局部变量 a 的地址 0x56205770e008 与已初始化全局变量 g_val_2 的地址 0x56205770e010 以及未初始化全局变量 g_val_1 的地址 0x56205770e00c 是比较接近的,验证了 static 修饰的局部变量在编译时被放置到了全局数据区这一特点。不过要注意哦,它只是延长了 “寿命”,作用域还是只在 main 函数里。而且这些代码都是在 Linux 系统下验证的,要是放到 Windows 的 VS 里,结果可能就不一样啦!

三、虚拟地址闪亮登场

接下来,咱们通过一个超酷的例子来认识虚拟地址!看代码:

int g_val = 100;                                                                                                                                                                                                  
int main()
{
    pid_t pid = fork();

    if(pid == 0)
    {
        int cnt = 5;
        // 子进程的“表演时间”
        while(1)
        {
            printf("I am child, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);
            sleep(1);
            if(cnt)
            {
                cnt--;
            }    
            else    
            {    
                g_val = 200;    
            }    
        }    
    }    
    else    
    {    
        // 父进程也来“凑热闹”
        while(1)    
        {    
            printf("I am parent, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);    
            sleep(1);    
        }    
    }    
    return 0;    
}

可能的运行结果(示例),这里假设子进程的 PID 为 12345,父进程的 PID 为 12344(实际会根据系统分配有所不同):

I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:100, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:200, &g_val:0x555555556020
I am parent, pid:12344, ppid:1000, g_val:100, &g_val:0x555555556020
I am child, pid:12345, ppid:12344, g_val:200, &g_val:0x555555556020
... // 持续打印,父子进程g_val值不同

这段代码创建了一个子进程,还定义了全局变量 g_val 初始值是 100。父子进程都去访问 g_val,子进程里还有个局部变量 cnt,一开始是 5,每次循环就减 1,减到 0 的时候把 g_val 改成 200。

运行结果超神奇!子进程修改 g_val 后,父子进程读到的 g_val 值不一样,这倒是符合我们之前说的进程独立性。可奇怪的是,打印出来的地址明明一样,为啥数据不同呢?真相只有一个 —— 这个地址根本不是真实的物理地址,而是虚拟地址!真实的物理地址可存不了两个不同的数据哦。

3.1 揭开神秘面纱 —— 地址空间概念

之前咱们说进程就是 task_struct + 代码和数据,其实没那么简单!进程一创建,操作系统就像个贴心的 “大管家”,不仅给它创建 PCB,还会专门打造一个 “私人空间”—— 进程地址空间。我们写代码用的地址就来自这里。进程地址空间其实是内核创建的一个结构体,PCB 里有个指针指向它。虚拟地址和物理地址之间靠页表 “牵线搭桥”,每个进程都有自己的页表哦。

3.2 深入剖析神奇现象

每个进程都有自己独立的 PCB、进程地址空间和页表,子进程的这些东西大多是照着父进程 “复制粘贴” 来的。就拿全局变量 g_val 来说,物理内存里它只有一份,在父进程里它有个虚拟地址 0X601054,子进程创建的时候,也给 g_val 分配了同样的虚拟地址 0X601054,页表的映射关系也是从父进程继承来的,所以父子进程打印 g_val 的地址是一样的,共享代码也是这个原理。

但当子进程要修改 g_val 时,为了保证父子进程数据独立,就会触发写时拷贝。操作系统就像个 “裁判”,看到子进程要改共享数据,马上喊 “暂停”!然后给子进程在物理内存里重新开辟一块空间,把 g_val 放进去,再修改页表的映射关系。这个过程完全是操作系统自动完成的,子进程还蒙在鼓里呢!就好比你朋友要来家里,你觉得家里乱,让他等会儿,你收拾屋子的时候他根本不知道。而虚拟地址就像个 “淡定哥”,根本不关心底层的这些操作,一点不受影响!

四、细节解释

4.1 地址空间究竟是什么?

核心概念

地址空间(Address Space)是操作系统分配给每个进程的一个独立的逻辑内存范围,用于存储程序的代码、数据、堆、栈等内容。每个进程的地址空间都是相互隔离的,并且与物理内存的实际分布无关。它为进程提供了一种逻辑上的“统一视角”,让进程感觉自己独占整个内存。

地址空间的本质

在 Linux 中,地址空间是由内核用结构体 struct mm_struct 描述的。这个数据结构存储了进程地址空间的范围(起始地址和结束地址)以及内存的划分方式。类似于 PCB(进程控制块)描述进程的状态信息,struct mm_struct 是描述进程地址空间的核心结构。

地址空间结构体(struct mm_struct
struct mm_struct
{
	unsigned long start_code;   // 代码段的起始地址
	unsigned long end_code;     // 代码段的结束地址
	unsigned long start_data;   // 数据段的起始地址
	unsigned long end_data;     // 数据段的结束地址
	unsigned long start_brk;    // 堆的起始地址(用于动态分配)
	unsigned long brk;          // 堆的当前结束地址
	unsigned long start_stack;  // 栈的起始地址(通常是高地址)
	// 其他字段...
};
解读细节
  1. start_code 和 end_code: 表示可执行代码段(text segment)的范围。这部分存储程序的机器指令,是只读的,不能被修改。
  2. start_data 和 end_data: 表示全局变量和静态变量的数据段。这部分可以被修改,包括已初始化和未初始化的数据。
  3. start_brk 和 brk: 表示堆(heap)的范围,用于动态分配内存,比如通过 mallocnew 进行的分配。
  4. start_stack: 表示栈(stack)的起始地址,通常在地址空间的高端,用于函数调用和局部变量。

这些字段定义了地址空间的逻辑布局,也为进程的内存管理提供了一个清晰的结构化描述。

重要补充

每个进程的地址空间中,“每一个最小单位地址”都可以通过虚拟内存机制映射到物理内存中。地址空间的划分和管理是为了让进程可以安全、高效地访问内存,而不需要关心底层的实际物理地址。

4.2 为什么要有地址空间?

地址空间的存在解决了多个实际问题,同时带来了许多优势。主要原因有以下几点:

原因一:让进程以统一的视角看待内
  • 问题:
    如果没有地址空间,进程需要直接操作物理内存中的数据。这会带来以下困难:
    1. 物理内存的地址可能在程序运行期间发生变化,例如当进程被挂起或重新加载到内存中时。
    2. 程序的代码和数据可能分散在物理内存的不同位置,进程很难有序地访问它们。
    3. 如果程序需要动态分配内存,会因为物理内存的不连续性而导致碎片化和复杂的管理。
  • 解决: 地址空间将虚拟地址映射到物理地址,提供了一种逻辑上的“连续性”。进程只需按虚拟地址的顺序访问内存,而无需关心实际的物理分布。
  • 好处:
    • 简化了程序设计,让程序员可以假设整个内存是连续且独占的。
    • 操作系统可以根据需要动态调整物理内存分配,提高了内存利用率。

原因二:保护物理内存
  • 问题:
    如果进程直接访问物理内存,很容易因为程序错误(例如指针越界、非法写入)而破坏其他进程或内核的数据。
  • 解决: 地址空间和虚拟内存机制为每个进程创建了一个隔离的逻辑视图。进程的所有内存访问请求会先经过内存管理单元(MMU),MMU 会根据页表将虚拟地址映射到物理地址。在这个过程中:
    • 系统可以拦截非法访问(如越界访问、修改只读段),并触发保护机制(如触发 segmentation fault 信号)。
    • 内核可以保证不同进程之间的内存隔离,避免了恶意或错误进程干扰其他进程。
  • 好处:
    • 提高系统安全性和稳定性。
    • 防止物理内存被错误或恶意修改。

原因三:模块解耦合
  • 问题: 如果没有地址空间,进程的内存管理逻辑和操作系统的物理内存管理将强耦合在一起。这会导致设计复杂度增加,并且难以扩展。
  • 解决: 地址空间和页表的存在将进程管理模块内存管理模块解耦合:
    • 进程只需管理自己的虚拟地址空间,而不需要关心底层的物理内存分布。
    • 操作系统负责将虚拟地址动态映射到物理地址,统一管理物理内存。
  • 好处:
    • 简化了操作系统的设计。
    • 提高了系统的可扩展性和灵活性。

4.3 页表

CR3寄存器
  • 什么是CR3寄存器? 在 x86 架构的 CPU 中,CR3寄存器用于存储当前运行进程的页表首地址。这是进程切换时保存和加载的重要硬件上下文之一,确保每个进程都能够独立管理自己的地址空间。
  • 作用:
    1. 当 CPU 执行指令并需要访问内存时,CR3 寄存器中的页表地址被用来查询虚拟地址和物理地址之间的映射。
    2. 当操作系统切换进程时(上下文切换),CR3 寄存器的值也会随之切换,指向新进程的页表。
    3. CR3 中存储的是物理地址,而不是虚拟地址,因为页表本身需要直接操作物理内存。
  • 硬件上下文: 当前进程被切换时,操作系统会保存与之相关的所有硬件上下文,包括 CR3 的内容。当进程被重新调度时,这些上下文会被加载回来。

页表是由页表项组成的
  • 页表项(Page Table Entry, PTE): 页表本质上是一个数据结构,它包含了多个页表项。每个页表项描述一个虚拟页到物理页的映射关系,同时存储一些权限和状态信息。
  • 页表项的主要字段:
    1. Present 位(存在位):
      • 表示该页是否在物理内存中。
      • 如果为 1,表示该页存在于物理内存,可以正常访问。
      • 如果为 0,则可能引发缺页中断,操作系统会将所需的页加载到物理内存。
    2. Read/Write 位(读/写位):
      • 控制该页是否允许写操作。
      • 如果设置为只读,则尝试写入会引发保护异常(保护内存安全)。
      • 一些代码段(如只读的常量字符串)会设置为只读,以防止意外修改。
    3. User/Supervisor 位(用户/超级用户位):
      • 控制该页是否允许用户态程序访问。
      • 如果为 1,表示用户态程序可以访问;
      • 如果为 0,则仅内核态程序可以访问。

  • 权限管理的意义:

    页表项通过硬件(如 CPU)检查权限位来确保内存访问的合法性:

    • 遇到非法访问(如用户态尝试修改内核态数据),会触发异常(如段错误)。
    • 这种机制提高了系统的安全性和稳定性。

小提示:
物理内存本身没有“权限”这个概念,所有物理内存都是可读可写的。页表项的权限(如只读)是由 CPU 的内存管理单元(MMU)强制执行的。例如,字符常量区(如 "Hello World")被定义为只读,是因为页表中对应的物理页被设置了只读权限。

示例代码的解释
#include <stdio.h>    
      
int main()    
{    
    char* str = "Hello World";    
    *str = 'B';  // 试图修改只读内存                                                  
    return 0;    
}
  1. 这段代码试图修改字符串 "Hello World"
  2. 该字符串存储在只读的代码段中(由页表项设置为只读)。
  3. 当程序运行到 *str = 'B'; 时,CPU 会检查页表项的权限位,发现这是只读页,触发保护异常(如段错误 Segmentation Fault)。
  4. 如果在定义 str 时加上 const,编译器会在编译阶段报错,从而避免运行时错误。

缺页中断
  • 惰性加载(Lazy Loading): 操作系统并不会一次性将整个程序的所有内容加载到物理内存中,而是按需加载(即使用时再加载)。
    • 比如,大型游戏可能有几十 GB 数据,而物理内存只有 8 GB 或 16 GB。通过分批加载的方式,可以在有限内存中运行大型程序。
  • 缺页中断的触发:
    1. 当进程访问某个虚拟地址时,CPU 首先通过页表检查对应页是否在物理内存中。
    2. 如果页表项的 Present 位为 0(页面不存在于物理内存),则会触发缺页中断(Page Fault)。
    3. 操作系统接管控制,按照以下步骤处理:
      • 在磁盘上找到对应的页内容(如可执行程序的一部分)。
      • 选择一个空闲的物理页,或在内存不足时,通过页面置换算法(如 LRU)释放一个物理页。
      • 将磁盘上的页加载到物理内存。
      • 更新页表项,将新加载的物理地址填入,并将 Present 位设置为 1
    4. 控制返回用户态程序,重新执行引发缺页的指令。
  • 缺页中断的意义:
    • 实现虚拟内存的高效利用,避免物理内存不足的问题。
    • 通过分批加载程序和数据,显著降低内存占用。

与进程创建的关系
  • 进程创建时:
    • 操作系统会为其分配必要的内核数据结构,如 PCB(进程控制块)、地址空间(struct mm_struct)、页表等。
    • 页表的初始状态是空的,所有页表项的 Present 位都设置为 0
  • 进程运行时:
    • 当进程尝试访问某些代码或数据时,会触发缺页中断,操作系统动态加载需要的内容。
  • 挂起进程时:
    • 操作系统会将进程的代码和数据从内存清理出去(释放物理内存)。
    • 对应页表项的 Present 位设置为 0

结语

到这里,我们的程序地址空间探秘之旅就要告一段落啦!从编程语言视角下的地址空间布局,到虚拟地址的神奇奥秘,再到页表、写时拷贝这些底层机制,每一步都充满了惊喜与挑战。原来,我们编写的每一行代码、定义的每一个变量,背后都有如此精妙的内存魔法在默默运行。
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!

在这里插入图片描述

评论 74
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值