欢迎光临
我们一直在努力

Linux 进程地址空间与写时拷贝

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

在这里插入图片描述

文章目录

    • 1. C 变量与进程中的内存区域
      • 1.1 存储期、使用方式与常见区域
      • 1.2 观察变量对象与指针变量本身
      • 1.3 实际布局不是固定地址表
    • 2. fork 后地址相同而内容不同
    • 3. 地址需要放在进程的映射关系中理解
      • 3.1 程序看到的通常是虚拟地址
      • 3.2 内核管理映射,硬件执行地址转换
    • 4. 通过 /proc/PID/maps 对照内存区域
      • 4.1 查看正在等待的示例
      • 4.2 对照地址,而不是只看标签
      • 4.3 mm_struct 与 VMA 的职责
    • 5. fork 与写时拷贝
      • 5.1 先保证逻辑独立,再优化物理复制
      • 5.2 复制单位通常是页,不是单个变量
    • 6. 地址空间带来的隔离与分配灵活性
    • 7. 小结

在前面的内容中,子进程把 value 改成 20,父进程仍看到 10。这说明普通私有数据互不影响。但如果再把地址打印出来,就会出现一个值得认真解释的现象:两个进程中的数值地址可以相同,内容却不同。

这里并没有违背指针的规则。问题在于,我们平时用 %p 看到的地址,不是可以脱离进程直接定位一块物理内存的编号。

1. C 变量与进程中的内存区域


1.1 存储期、使用方式与常见区域

先接回熟悉的 C 知识:全局变量、静态局部变量具有静态存储期;普通局部变量常由调用栈承载;malloc 分配的对象具有动态分配的生存期。

在 Linux 上观察普通用户程序时,通常可以看到下面这些区域或映射:

常见名称常见内容需要保留的边界
代码相关映射 程序的机器指令 可执行文件通常对应多个权限不同的映射
只读数据相关映射 字符串字面量、某些常量等 不要通过指针修改字符串字面量
数据区 显式初始化的部分静态存储期对象 具体分类还受初始化内容和工具链影响
BSS 相关区域 未显式初始化或零初始化的部分静态存储期对象 不等于磁盘文件中必须逐字节存满同样大小的零
堆与其他动态分配映射 分配器用于满足 malloc 等请求的内存 较大分配等可能来自独立映射,不一定都位于 [heap]
栈 调用相关信息、常见自动变量等 优化后部分变量可能只在寄存器中,不能假定每个变量总有固定栈位置
共享库等映射 C 库、动态链接器及其他映射内容 地址空间不止“代码、堆、栈”几块

1.2 观察变量对象与指针变量本身

编写并保存下面的完整程序为 address_regions.c:

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

// 显式初始化静态全局变量 → 存放在 .data 数据区
static int global_init = 7;
// 未初始化静态全局变量 → 存放在 .bss 段,程序加载时自动置0
static int global_zero;

int main(void)
{
// 静态局部变量:静态存储期,只初始化一次,存放在 .data
static int static_local = 9;
// 普通局部自动变量:存储在栈上,函数调用时创建,函数退出销毁
int local = 11;
// text指针变量本身在栈;字符串字面量"linux"存放在只读数据段
const char *text = "linux";
// heap_value指针变量本身在栈;malloc分配的内存在堆(或mmap映射区)
int *heap_value = malloc(sizeof *heap_value);
if (heap_value == NULL)
{
perror("malloc");
return 1;
}
// 向堆内存写入数据
*heap_value = 13;

// 打印各个变量的虚拟地址,观察不同内存区域地址分布
printf("global_init = %p\\n", (void *)&global_init);
printf("global_zero = %p\\n", (void *)&global_zero);
printf("static_local = %p\\n", (void *)&static_local);
printf("local = %p\\n", (void *)&local);
printf("heap object = %p\\n", (void *)heap_value); // 堆内存地址
printf("heap pointer = %p\\n", (void *)&heap_value); // 存放heap_value这个指针变量本身,在栈
printf("literal = %p\\n", (void *)text); // 字符串字面量(只读段)地址
printf("text pointer = %p\\n", (void *)&text); // text指针变量本身,在栈

puts("Press Enter to free the allocation and exit.");
// 暂停程序
(void)getchar();

free(heap_value);
return 0;
}

gcc -std=c11 address_regions.c -o address_regions
./address_regions

运行示例: 在这里插入图片描述 地址分组观察:

  • global_init / global_zero / static_local 0x55e9a5976010、0x55e9a597601c、0x55e9a5976014 地址挨在一起,属于静态存储区(.data/.bss),不在栈上。

  • local / heap pointer / text pointer 0x7ffd1a5c2554、0x7ffd1a5c2560、0x7ffd1a5c2558 地址非常接近,都是栈上的局部变量。

    • heap_value:栈上的指针变量,存着堆内存的起始地址
    • text:栈上的指针变量,存着只读段字符串字面量的起始地址
  • heap object = 0x55e9c609c2a0 malloc分配出来的堆内存,独立区域。

  • literal = 0x55e9a5974008 字符串字面量"linux",放在只读数据段,地址和全局静态变量相近,但属于只读映射。

  • 地址会随运行环境变化,这里重点是要区分两组容易混淆的对象:

    • heap_value 保存动态分配对象的地址,&heap_value 是局部指针变量自身的地址。
    • text 指向字符串字面量,&text 是局部指针变量自身的地址。

    因此,“指针变量是局部变量”和“指针指向动态分配对象”可以同时成立,指针本身与被指对象不必在同类区域。

    static_local 虽然写在函数内部,但具有静态存储期,不应仅凭书写位置把它划入栈中。

    所有对象地址都显式转换为 void *,配合 %p 输出。

    1.3 实际布局不是固定地址表

    多次执行程序,部分地址通常会变化,这是地址空间布局随机化等机制的正常结果。

    常见 64 位 Linux 上,栈通常向较低地址扩展;传统堆区域可能向较高地址扩展。但不能进一步推出“后声明的局部变量地址一定更小”或“后一次 malloc 的地址一定更大”。编译器布局、分配器复用和不同映射都可能改变观察结果。

    2. fork 后地址相同而内容不同


    下面用一个全局变量和一个动态分配对象,验证同一份程序创建出的父子进程。

    保存为 fork_memory.c:

    #include <errno.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <unistd.h>

    // 静态全局变量,存放在.data段,fork后父子进程各自拥有独立副本
    static int number = 10;

    int main()
    {
    int *heap_value = malloc(sizeof *heap_value);
    if (heap_value == NULL) {
    perror("malloc");
    return 1;
    }
    *heap_value = 30;

    // fork之前:父进程打印全局变量、堆变量的地址和值
    printf("before: global@%p=%d heap@%p=%d\\n",
    (void *)&number, number, (void *)heap_value, *heap_value);

    pid_t child = fork();
    if (child < 0) {
    perror("fork");
    free(heap_value);
    return 1;
    }

    if (child == 0) {
    // ========== 子进程执行分支 ==========
    // 修改全局变量,触发写时拷贝,产生独立内存副本
    number = 100;
    // 修改堆上的数据,同样触发写时拷贝,子进程拥有独立堆副本
    *heap_value = 300;
    printf("child: global@%p=%d heap@%p=%d\\n",
    (void *)&number, number, (void *)heap_value, *heap_value);
    free(heap_value);
    return 0;
    }

    // ========== 父进程执行分支 ==========
    // waitpid阻塞等待子进程退出,回收子进程资源
    pid_t result;
    result = waitpid(child, NULL, 0);

    // 子进程修改不会影响父进程的内存,父进程的值保持不变
    printf("parent: global@%p=%d heap@%p=%d\\n",
    (void *)&number, number, (void *)heap_value, *heap_value);
    free(heap_value);
    return 0;
    }

    gcc -std=c11 fork_memory.c -o fork_memory
    ./fork_memory

    运行示例: 在这里插入图片描述

    输出行全局变量地址全局变量值动态对象地址动态对象值
    before 地址 0x55d828daf010 10 地址 0x55d830bdf2a0 30
    child 仍为 0x55d828daf010 100 仍为 0x55d830bdf2a0 300
    parent 仍为 0x55d828daf010 10 仍为 0x55d830bdf2a0 30

    子进程中的 free(heap_value) 也不会释放父进程自己的那份动态分配对象;父进程最后仍应释放自己的分配。两个进程各自释放各自的分配,不等于一个进程对同一对象重复 free。

    3. 地址需要放在进程的映射关系中理解


    3.1 程序看到的通常是虚拟地址

    在这里讨论的普通 Linux 用户进程中,指针数值属于该进程的虚拟地址空间。使用它访问内存时,还需要通过该进程对应的映射关系定位实际物理内存。

    因此,完整的理解不能只保留“地址数值 A”,还需要知道“哪个进程中的 A”。进程甲的 A 和进程乙的 A,可以映射到不同物理页。

    在这里插入图片描述

    一旦放回各自的映射关系,“相同地址、不同内容”就不矛盾了。

    3.2 内核管理映射,硬件执行地址转换

    内核负责建立和维护映射与访问权限。CPU 的内存管理单元(MMU)依据相关转换信息执行地址转换,页表等结构提供映射依据。

    这不是“每访问一个变量,都先调用一次内核函数来翻译地址”。正常的地址访问由硬件机制处理;需要补充映射、处理写保护等情况时,才可能触发异常并进入内核处理。

    页是理解这一机制的基本单位。普通页常见大小为 4 KiB,但具体以系统为准,还可能存在更大的页。可在服务器查询基本页大小:

    getconf PAGESIZE

    在这里插入图片描述

    4. 通过 /proc/PID/maps 对照内存区域


    4.1 查看正在等待的示例

    在第一个 SSH 会话运行 ./address_regions,保持它等待回车;第二个会话使用:

    pgrep -x address_regions

    此时可得到这个正在运行进程的PID: 在这里插入图片描述 然后将打印的PID对应数字替换下方的12345:

    pid=12345
    cat /proc/"$pid"/maps

    在这里插入图片描述 maps 的一行通常包含:

    地址范围 权限 文件偏移 设备号 inode 路径或区域标记

    地址范围采用左闭右开区间,权限中的常见字符是:

    字符含义
    r 可读
    w 可写
    x 可执行
    p 私有映射
    s 共享映射
    – 对应位置的权限未设置

    这里的权限描述内存映射,不能与 ls -l 的文件权限字段混为一谈。

    4.2 对照地址,而不是只看标签

    把程序输出的地址放进 maps 的某个地址范围中,可以观察它落在哪段映射:

    • 全局变量地址通常落在程序相关的可写区域。
    • 局部变量与局部指针变量自身通常落在栈相关映射。
    • 字符串字面量通常位于程序的只读映射。
    • 带 x 的程序映射有助于识别代码相关区域。

    同一个可执行文件出现多行正常,因为不同部分具有不同的权限和映射要求。

    maps 展示的是虚拟地址范围和映射属性,但不能直接证明某两个虚拟页当前是否共享同一物理页。

    4.3 mm_struct 与 VMA 的职责

    从内核管理的角度,可以把普通用户进程的内存关系理解为:

    任务管理信息 → 地址空间描述 → 多个虚拟内存区域及其映射
    task_struct mm_struct VMA 等相关信息

    mm_struct 描述地址空间相关信息;VMA,即虚拟内存区域,描述一段具有相应属性的虚拟地址范围。区域描述和页表分工不同:前者说明“这段地址是什么、允许做什么”,后者参与落实页级映射。

    5. fork 与写时拷贝


    5.1 先保证逻辑独立,再优化物理复制

    fork 需要让父子进程都能使用创建时继承的内容,但很多子进程随后很快执行其他程序,或者只读取其中一部分数据。如果一开始就完整复制所有可复制物理内存,可能做了大量随后用不到的工作。

    Linux 对普通私有内存通常使用写时拷贝,Copy-on-Write,COW。概念过程如下:

  • 创建子进程的地址空间管理信息,建立相应映射。
  • 适合共享的私有物理页可以暂时由父子共同引用,并通过写保护等机制控制写入。
  • 某一方写入时触发相应处理,内核在需要时准备独立页、复制原有内容并更新映射。
  • 写入方在自己的页上完成修改,另一方仍看到自己的原内容。
  • 在这里插入图片描述

    5.2 复制单位通常是页,不是单个变量

    一个 int 可能只有几个字节,但写时拷贝通常按页处理。多个对象若位于同一页,对其中一个对象的写入可能使所在页需要独立;不能想成内核每次只复制那一个变量。

    如果某页已经不再需要与其他使用者共享,写入时也未必必须再做一次物理复制。重要的是保证私有映射的行为,而非每次写入都机械执行相同动作。

    6. 地址空间带来的隔离与分配灵活性


    地址空间让每个进程使用自己的映射视图。一个进程中的地址,不能仅凭数值就拿去访问另一个进程的任意内存。错误指针访问可能导致该进程收到异常信号,而不是自然地指向旁边某个进程的变量。

    这种隔离还依赖访问权限与内核保护,并不意味着所有越界写都会立刻被发现:若错误地址仍落在该进程可写的有效映射中,它可能悄悄破坏自身数据。虚拟内存不能替代 C 程序的边界检查。

    映射也允许连续的虚拟地址对应不连续的物理页,减轻必须寻找大块连续物理内存的要求。某些内存内容可以按需建立或加载,不必把整个进程完整装入一段连续物理空间。

    不过,“按需申请”并不等于 malloc 成功后就永远不会遇到内存压力,也不等于 malloc 每次都完全不占用物理内存。分配器复用、内核策略和实际访问都会影响结果。

    回看开头,父子变量的地址相同,是创建后保留了相同数值的虚拟地址;数值不同,是私有地址空间的写入效果被隔离。地址数值、映射关系、物理页内容,应当分成三个层次理解。

    映射和创建机制可进一步查阅 man 5 proc_pid_maps、man 2 fork、man 2 mmap等。

    7. 小结


    • Linux 中每个进程拥有独立的虚拟地址空间,进程之间内存天然隔离,一个进程的虚拟地址数值无法直接访问其他进程内存。

    • 虚拟地址空间划分代码段、只读数据段、.data、.bss、堆、栈、共享库映射等区域;指针变量本身与它指向的内容可处于不同内存区域。

    • fork创建子进程时不会立刻复制全部物理内存页,采用写时拷贝(COW)机制。

    • fork 初期父子进程共享同一份物理内存,页表标记为只读;任意进程执行写操作时,内核才复制对应内存页,父子各自持有独立副本。

    • 父子进程打印出来的虚拟地址数值相同,但修改数据互不影响;虚拟地址相同不等于物理内存是同一块。

    • malloc成功仅代表分配到虚拟地址,不一定立刻占用物理内存;虚拟地址连续,对应的物理内存页可以不连续。

    在这里插入图片描述

    赞(0)
    未经允许不得转载:171主机测评 » Linux 进程地址空间与写时拷贝
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址