在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 | 《小游戏与项目》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

作为一名 C/C++ 开发者,你一定无数次写过打印指针地址的代码。当你运行程序时,屏幕上会输出一连串看似杂乱无章的十六进制数。但你有没有想过:这些地址真的是物理内存的地址吗?为什么它们的分布如此有规律?如果两个进程打印出了相同的地址,它们真的访问了同一块内存吗?

很多人学了很久 C 语言,却对"地址"的本质一知半解——这是学习操作系统底层原理时最大的障碍之一。

不过不用担心——本文将带你从一段简单的 C 代码出发,逐步深入到 Linux 内核源码,彻底搞懂进程地址空间的本质。你将明白虚拟地址与物理地址的区别,理解操作系统如何通过页表实现内存隔离,以及内核中的 mm_structvm_area_struct 是如何管理这一切的。

通过本文,你将掌握:

技能 应用场景
理解进程地址空间的布局 分析程序内存分布、排查内存越界问题
区分虚拟地址与物理地址 理解进程隔离机制、编写多进程程序
掌握页表与缺页中断原理 理解内存分配延迟策略、优化内存使用
理解 mm_struct 和 VMA 阅读 Linux 内核源码、进行内核开发

📌 前置知识: 基本的 C 语言编程基础,了解 Linux 常用命令


一、🔍 从一段代码开始:C语言中的地址之谜

让我们先运行这段经典的内存布局探测代码,看看它会输出什么:

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

int g_unval;           // 未初始化全局变量
int g_val = 100;       // 已初始化全局变量

int main(int argc, char *argv[], char *env[])
{
    const char *str = "helloworld";  // 字符串常量
    
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);

    static int test = 10;  // 静态局部变量
    char *heap_mem = (char*)malloc(10);
    char *heap_mem1 = (char*)malloc(10);
    char *heap_mem2 = (char*)malloc(10);
    char *heap_mem3 = (char*)malloc(10);

    printf("heap addr: %p\n", heap_mem);
    printf("heap addr: %p\n", heap_mem1);
    printf("heap addr: %p\n", heap_mem2);
    printf("heap addr: %p\n", heap_mem3);
    printf("test static addr: %p\n", &test);
    printf("stack addr: %p\n", &heap_mem);   // 指针变量本身的地址
    printf("stack addr: %p\n", &heap_mem1);
    printf("stack addr: %p\n", &heap_mem2);
    printf("stack addr: %p\n", &heap_mem3);
    printf("read only string addr: %p\n", str);

    for(int i = 0; i < argc; i++) {
        printf("argv[%d]: %p\n", i, argv[i]);
    }
    for(int i = 0; env[i]; i++) {
        printf("env[%d]: %p\n", i, env[i]);
    }

    free(heap_mem);
    free(heap_mem1);
    free(heap_mem2);
    free(heap_mem3);
    return 0;
}

在 64 位 Linux 系统上,运行结果如下(你的地址可能不同,但分布规律完全一致):

code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4819
env[0]: 0x7ffd0f9a4821

观察这些输出,我们可以发现几个惊人的规律:

  1. 地址从低到高有序分布:代码段地址最低,然后是全局变量、静态变量,接着是堆,最后是栈和命令行参数/环境变量
  2. 堆地址递增heap_memheap_mem3 的地址依次增大(0x1791010 → 0x1791030 → 0x1791050 → 0x1791070
  3. 栈地址递减&heap_mem&heap_mem3 的地址依次减小(0x7ffd0f9a4368 → 0x7ffd0f9a4360 → ...
  4. 静态变量和全局变量地址接近test 的地址(0x601038)和 g_val0x601034)、g_unval0x601040)挨在一起
  5. 字符串常量和代码段地址接近"helloworld" 的地址(0x400800)和 main 函数的地址(0x40055d)非常接近

💡 这一切都不是巧合。这些地址的分布,完美对应了 Linux 进程地址空间的标准布局。


二、🏗️ 进程地址空间的完整布局

进程地址空间(Process Address Space)是操作系统为每个进程抽象出来的一个连续的、私有的虚拟内存范围。它不是真实的物理内存,而是一个逻辑上的概念。

32 位 Linux 系统中,每个进程都拥有 4GB 的虚拟地址空间(2^32 字节)。其中:

  • 高 1GB0xC0000000 ~ 0xFFFFFFFF)是内核空间,由所有进程共享
  • 低 3GB0x00000000 ~ 0xBFFFFFFF)是用户空间,每个进程独立拥有

64 位系统的虚拟地址空间则大得多,通常使用 48 位虚拟地址,理论上可以寻址 256TB 的空间,用户空间和内核空间各占一半左右。

从低地址到高地址,用户空间的标准布局如下:

区域名称 地址范围(32位示例) 存储内容 权限 增长方向
正文代码段(Text) 0x08048000 附近 程序的机器指令、只读常量 只读、可执行 固定
初始化数据段(Data) 代码段之后 已初始化的全局变量、静态变量 可读、可写 固定
未初始化数据段(BSS) 数据段之后 未初始化的全局变量、静态变量 可读、可写 固定
堆(Heap) BSS段之后 动态分配的内存(malloc/new) 可读、可写 向上(地址增大)
共享库映射区 堆和栈之间 动态链接库的代码和数据 依段而定 向下(地址减小)
栈(Stack) 0xBFFFFFFF 附近 局部变量、函数参数、返回地址 可读、可写 向下(地址减小)
命令行参数&环境变量 栈的最顶端 命令行参数 argv、环境变量 env 可读、可写 固定

2.1 栈与堆:方向相反的"生长者"

最容易混淆的就是栈和堆的增长方向。从代码运行结果可以清晰地看到:

  • 栈向下增长:后定义的局部变量地址更低。栈是从高地址向低地址分配内存的,每次函数调用,栈帧都会被压入栈顶(地址更低的位置);函数返回时,栈帧弹出
  • 堆向上增长:后 malloc 的内存地址更高。堆是从低地址向高地址分配内存的,每次 malloc 都会在当前堆的末尾申请新的空间

⚠️ 这种相反的增长方向,最大化地利用了中间的空闲地址空间。栈和堆向中间生长,直到它们相遇,此时内存耗尽。

2.2 静态变量的"错觉":为什么它看起来在代码段?

很多初学者会误以为静态变量存储在代码段,因为它的地址和代码段很接近。但实际上:

  • 静态局部变量和全局变量存储在数据段(Data 或 BSS),而不是代码段
  • 代码段是只读的,而静态变量是可写的,这就决定了它不可能在代码段
  • 它们地址接近,只是因为数据段紧跟在代码段之后

在我们的例子中:

  • g_val = 100(已初始化全局变量)和 static int test = 10(已初始化静态变量)都存储在初始化数据段(Data)
  • g_unval(未初始化全局变量)存储在未初始化数据段(BSS)。BSS 段的特点是在程序加载时会被内核自动初始化为 0,这就是为什么未初始化全局变量默认值是 0 的原因

2.3 字符串常量:真正的只读区域

字符串常量 "helloworld" 存储在只读数据段(rodata),它通常和代码段合并在一起,拥有相同的只读权限。如果你尝试修改字符串常量的内容:

char *str = "helloworld";
str[0] = 'H';  // 段错误(Segmentation fault)

程序会立即崩溃,触发段错误。这是因为页表中标记了这个区域为只读,任何写入操作都会被操作系统拦截。


三、🧪 虚拟地址的铁证:fork 的"魔法"

现在,让我们运行第二段代码,它将彻底颠覆你对"地址"的认知:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int g_val = 0;

int main()
{
    int id = fork();
    if(id == 0) {
        // 子进程
        while(1) {
            printf("子进程---g_val:%d---&g_val:%p---pid:%d---ppid:%d\n",
                   g_val, &g_val, getpid(), getppid());
            g_val++;
            sleep(1);
        }
    } else {
        // 父进程
        while(1) {
            printf("父进程---g_val:%d---&g_val:%p---pid:%d---ppid:%d\n",
                   g_val, &g_val, getpid(), getppid());
            sleep(1);
        }
    }
    return 0;
}

运行结果会让你大吃一惊:

父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:0---&g_val:0x60104c---pid:11886---ppid:11887
父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:1---&g_val:0x60104c---pid:11886---ppid:11887
父进程---g_val:0---&g_val:0x60104c---pid:11887---ppid:3807
子进程---g_val:2---&g_val:0x60104c---pid:11886---ppid:11887
...

核心现象:

  1. 父子进程打印出的 &g_val 地址完全相同(都是 0x60104c
  2. 子进程不断修改 g_val 的值,但父进程的 g_val 始终是 0

这在物理内存中是绝对不可能发生的!如果两个指针指向同一个物理地址,那么一个修改,另一个必然会看到变化。

唯一的解释:我们在 C/C++ 中看到的所有地址,都是虚拟地址(Virtual Address),而不是物理地址(Physical Address)。


四、🎯 虚拟地址空间的本质:操作系统的"大饼"

4.1 大富翁与私生子的故事

为了理解虚拟地址空间,我给你讲一个经典的比喻:

操作系统是一个大富翁,拥有整个城市的土地(物理内存)。每个进程都是大富翁的私生子。

大富翁不想让私生子们互相打架,也不想让他们知道自己有多少兄弟,更不想让他们随意闯入自己的豪宅(内核空间)。于是,他给每个私生子都画了一张一模一样的城市地图(4GB 虚拟地址空间),并告诉他们:“这整个城市都是你的,你可以随便用。”

每个私生子都信以为真,以为自己独占了整个城市。但实际上,大富翁只在他们真正需要盖房子(访问内存)的时候,才会给他们分配一块真实的土地(物理内存页),并在他们的地图上做个标记(页表映射)。

如果两个私生子都在地图的同一个位置(相同虚拟地址)盖了房子,大富翁会给他们分配不同的真实土地,让他们互不干扰。

这就是虚拟地址空间的核心思想:让每个进程都以为自己独占了整个内存,而实际上操作系统在背后管理着物理内存的分配和映射。

4.2 4GB 的由来与真相

很多人会问:“32 位系统每个进程都有 4GB 虚拟地址空间,那如果有 100 个进程,岂不是需要 400GB 物理内存?”

这是对虚拟地址空间最大的误解。虚拟地址空间只是一个逻辑上的地址范围,和物理内存大小没有直接关系。

  • 32 位系统的虚拟地址是 32 位的,所以可以表示 2^32 = 4294967296 个地址,也就是 4GB
  • 64 位系统通常使用 48 位虚拟地址,所以可以表示 2^48 = 281474976710656 个地址,也就是 256TB

操作系统不会也不可能为每个进程一次性分配 4GB 物理内存。实际上,当进程启动时,操作系统只分配了最基本的内核数据结构(task_struct、mm_struct、页表),只有当进程真正访问某个虚拟地址时,才会通过缺页中断机制分配物理内存。

💡 这就是"延迟分配"策略——用多少,给多少,绝不浪费。


五、🔧 内核如何管理虚拟地址空间:mm_struct 与 VMA

5.1 内存描述符:mm_struct

在 Linux 内核中,每个进程的虚拟地址空间由一个叫做 mm_struct 的结构体描述,它定义在 <linux/mm_types.h> 中。每个进程的 task_struct(进程控制块)里都有一个指向自己 mm_struct 的指针。

mm_struct 的核心成员如下:

struct mm_struct {
    struct vm_area_struct *mmap;    // 指向虚拟内存区域(VMA)链表
    struct rb_root mm_rb;           // VMA的红黑树,用于快速查找
    unsigned long task_size;        // 虚拟地址空间大小

    // 各个段的起始和结束地址
    unsigned long start_code, end_code;   // 代码段
    unsigned long start_data, end_data;   // 数据段
    unsigned long start_brk, brk;         // 堆的起始和当前结束
    unsigned long start_stack;            // 栈的起始地址
    unsigned long arg_start, arg_end;     // 命令行参数
    unsigned long env_start, env_end;     // 环境变量

    pgd_t *pgd;  // 页全局目录(Page Global Directory)指针
    // ... 其他成员
};

你看,我们之前看到的所有段的地址,都在 mm_struct 里有对应的记录。当堆需要增长时,操作系统只需要修改 brk 的值;当栈需要增长时,只需要向下扩展 start_stack。这就是为什么区域划分如此灵活的原因。

5.2 虚拟内存区域:vm_area_struct

你可能会问:“mm_struct 里只有一个堆的起始和结束地址,但我 malloc 了很多次,这些内存块是怎么管理的?”

这就引出了另一个重要的结构体:vm_area_struct(虚拟内存区域,简称 VMA)。

mm_struct 并没有把整个用户空间当作一个整体来管理,而是把它划分成了多个独立的 VMA。每个 VMA 代表一个连续的、具有相同属性(权限、映射类型等)的虚拟地址范围。

例如:

  • 代码段是一个 VMA,属性是 VM_READ | VM_EXEC(只读、可执行)
  • 数据段是一个 VMA,属性是 VM_READ | VM_WRITE(可读、可写)
  • 堆是一个 VMA,属性是 VM_READ | VM_WRITE
  • 每个动态链接库的代码段和数据段,也都是各自独立的 VMA

vm_area_struct 的核心成员:

struct vm_area_struct {
    unsigned long vm_start;     // 虚拟区域起始地址
    unsigned long vm_end;       // 虚拟区域结束地址
    struct vm_area_struct *vm_next, *vm_prev;  // 链表指针
    struct rb_node vm_rb;       // 红黑树节点
    struct mm_struct *vm_mm;    // 所属的 mm_struct
    pgprot_t vm_page_prot;      // 该区域的页权限
    unsigned long vm_flags;     // 标志位
    // ... 其他成员
};

所有的 VMA 通过链表红黑树两种方式组织起来:

  • 链表用于顺序遍历所有 VMA
  • 红黑树用于快速查找某个地址属于哪个 VMA,时间复杂度是 O(log n)

这就回答了"堆的复数性"问题:虽然我们看到的堆是一个连续的地址范围,但它实际上是一个 VMA。而 malloc 分配的内存块,是在这个 VMA 内部进行的更细粒度的管理,由 C 标准库的 malloc 实现(比如 ptmalloc)负责。


六、🌉 虚拟地址到物理地址的桥梁:页表与缺页中断

6.1 页表:映射的核心

虚拟地址和物理地址之间的映射关系,是通过 页表(Page Table) 来维护的。每个进程都有自己独立的页表,存储在内核空间中。

Linux 采用分页机制来管理内存,它把虚拟地址空间和物理内存都划分成固定大小的页(Page)。在 x86 系统上,默认页大小是 4KB

分页机制的核心思想是:以页为单位进行映射和内存分配。

一个 32 位的虚拟地址会被分成两部分:

  • 高 20 位:页号(Page Number)
  • 低 12 位:页内偏移(Offset)

地址转换过程:

  1. CPU 根据虚拟地址的页号,在页表中查找对应的物理页号(Physical Page Number)
  2. 将物理页号与页内偏移组合,得到最终的物理地址
  3. 用物理地址访问实际的内存单元

6.2 缺页中断:按需分配的精髓

当进程访问一个虚拟地址时,如果页表中没有对应的映射(即该虚拟地址还没有分配物理内存),CPU 会触发一个 缺页中断(Page Fault)

缺页中断的处理流程:

  1. CPU 触发中断:访问未映射的虚拟地址
  2. 操作系统介入:保存当前进程状态,转入内核态
  3. 检查合法性:确认该虚拟地址是否在进程的合法地址范围内(通过遍历 VMA)
  4. 分配物理页:如果合法,从空闲物理页中分配一页
  5. 更新页表:建立虚拟地址到物理地址的映射
  6. 恢复执行:重新执行触发中断的指令

💡 这就是"按需分页"(Demand Paging)机制——进程启动时并不加载全部数据,只有真正访问时才分配物理内存。这大大节省了物理内存的使用。

6.3 写时复制(Copy-On-Write)

还记得前面 fork 的例子吗?父子进程共享同一个物理页,直到其中一个尝试写入时,才会复制一份新的物理页。这就是 写时复制(Copy-On-Write, COW) 机制。

COW 的流程:

  1. fork 时,父子进程的页表指向相同的物理页,页表项标记为只读
  2. 任一进程尝试写入时,触发缺页中断
  3. 操作系统发现是 COW 页,于是复制一份新的物理页
  4. 修改写入进程的页表,指向新的物理页,并标记为可写
  5. 重新执行写入指令

这就是父子进程能看到相同虚拟地址、但互不影响的秘密!


七、🤔 几个思考题

学完本文,来试试回答这些问题:

1️⃣ 为什么 32 位系统每个进程的虚拟地址空间是 4GB,而不是其他大小?

答: 因为 32 位系统的虚拟地址是 32 位的,可以表示 2^32 = 4294967296 个不同的地址。每个地址对应 1 字节,所以总空间是 4294967296 字节 = 4GB。这是由地址总线的位数决定的,是硬件层面的限制。

💡 拓展:64 位系统理论上可以寻址 2^64 = 16EB(艾字节),但实际只使用 48 位虚拟地址,可寻址 256TB,这是因为目前的硬件和软件还不需要那么大的地址空间。

2️⃣ 如果两个进程打印出相同的指针地址,它们访问的是同一块物理内存吗?

答: 不是。每个进程拥有独立的虚拟地址空间和独立的页表。相同的虚拟地址在不同进程的页表中会映射到不同的物理页。操作系统通过页表实现了进程间的内存隔离,确保一个进程无法直接访问另一个进程的内存。

💡 拓展:这也是进程间通信(IPC)需要特殊机制(如管道、共享内存、消息队列)的原因——默认情况下进程内存是完全隔离的。

3️⃣ 为什么栈向下增长,而堆向上增长?

答: 这是为了最大化利用地址空间。栈和堆是程序运行时使用最频繁的动态内存区域,它们从两端向中间生长。这种设计让两者可以共享中间的空闲区域,避免了预先为栈或堆分配固定大小空间的浪费。只有当它们相遇时,才会出现内存耗尽的情况。

💡 拓展:在 32 位系统中,栈通常从 0xC0000000(3GB 边界)附近开始向下增长;在 64 位系统中,栈的初始位置更高。

4️⃣ 修改字符串常量为什么会触发段错误?

答: 字符串常量存储在只读数据段(rodata),页表中该区域的页表项被标记为只读(Read-Only)。当 CPU 执行写入操作时,内存管理单元(MMU)会检查页权限,发现写入操作不被允许,于是触发保护异常,操作系统将其转换为**段错误(SIGSEGV)**信号发送给进程。

💡 拓展:这也是代码段(Text Segment)不能被修改的原因——页权限保护是操作系统实现内存保护的核心机制。

5️⃣ 为什么未初始化的全局变量默认值为 0?

答: 未初始化的全局变量存储在 BSS 段。BSS 段在可执行文件中不占用磁盘空间(只记录大小),但在程序加载时,操作系统会为 BSS 段分配物理内存,并自动初始化为 0。这是由操作系统加载器(loader)完成的,目的是确保程序在启动时所有全局变量都有确定的初始值。

💡 拓展:相比之下,已初始化的全局变量存储在 Data 段,其初始值保存在可执行文件中,加载时直接从文件读取到内存。


本节完

✅ 本节完…

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐