初识Linux地址空间:从父子进程的同一地址不同值说起

一. 案例引入:从一段代码看透虚拟内存

本文通过一个简单的C程序实验,带你揭示Linux进程地址空间的奥秘,理解虚拟地址、物理地址和写时拷贝机制。

引言:从程序地址空间说起

在学习C语言时,我们都见过下面这样的内存布局图:

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

int main()
{
    printf("%s\n", getenv("PATH"));
    return 0;
}

或者这样的环境变量获取代码:

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

int main()
{
    char * env = getenv("MYENV");
    if(env){
        printf("%s\n", env);
    }
    return 0;
}

老师会告诉我们,程序的内存分为代码段、数据段、BSS段、堆、栈等区域。
但那时候,我们可能并不真正理解这背后的机制。
今天,就让我们通过一段代码来深入理解这个概念。

实验:父子进程的内存谜题

初始代码:相同的值和地址

先来看第一段代码:

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

int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    }
    else if(id == 0){ //child
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
}

运行结果如下:

parent[21177]: 0 : 0x601058
child[21178]: 0 : 0x601058

这个结果很好理解:子进程是父进程的副本,父子进程的变量值和地址完全相同。

修改后的代码:相同的地址,不同的值

现在对代码稍作修改:

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

int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    }
    else if(id == 0){ //child,子进程先运行,修改g_val
        g_val=100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        sleep(3); // 确保子进程先执行
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
}

运行结果:

child[22462]: 100 : 0x601058
parent[22461]: 0 : 0x601058

这个结果就很有意思了!

我们发现:

  • 父子进程输出的变量值不同(子进程是100,父进程是0)
  • 但它们的地址却是相同的(都是0x601058)

这引出了两个关键结论:

  1. 变量内容不一样,所以父子进程输出的变量绝对不是同一个物理变量
  2. 地址值相同,说明该地址绝对不是物理地址!

揭秘:虚拟地址空间

  • 在Linux系统中,我们使用C/C++语言看到的地址,全部都是虚拟地址
    当然,如果从C/C++语言层面理解的话,就是语言层面的地址,但是对于操作系统来说就是虚拟的!!!
    从语言层面看内存布局:
    C/C++内存布局

  • 物理地址用户是看不到的,由操作系统统一管理。

  • 操作系统负责将虚拟地址转换成物理地址

什么是虚拟地址?

虚拟地址是进程视角中的内存地址,也称为逻辑地址。
每个进程都认为自己运行在一个独立的、连续的内存空间中,从地址0开始一直到最大地址。
与之相对的是物理地址,这是实际内存硬件中的地址,对应着内存条上的具体存储单元。
在这里插入图片描述

二. Linux地址空间

2.1 什么是地址空间?

在这里插入图片描述

地址空间是操作系统为每个进程提供的一个抽象的内存视图,它定义了进程可以访问的内存地址范围。
在32位系统中,这个范围通常是[0, 2^32-1],也就是4GB的线性地址空间。

  • 简单来说,地址空间就像是给每个进程分配的一个"私有内存领地",进程只能在这个领地内活动,无法直接访问其他进程的内存空间。

  • 验证地址空间的范围和权限:

// 验证地址空间的范围和权限
#include <stdio.h>
#include <stdlib.h>

int global_var;  // 未初始化全局变量 - 在.bss段

int main() {
    static int static_var = 100;  // 已初始化静态变量 - 在.data段
    int stack_var = 200;          // 栈变量
    int *heap_var = malloc(sizeof(int));  // 堆变量
    
    // 所有这些变量都在当前进程的地址空间范围内
    printf("代码地址: %p (可执行)\n", (void*)main);
    printf("数据地址: %p (可读写)\n", (void*)&static_var);
    printf("堆地址: %p (可读写)\n", (void*)heap_var);
    printf("栈地址: %p (可读写)\n", (void*)&stack_var);
    
    // 尝试访问非法地址(超出地址空间或无权访问)
    // int *bad_ptr = (int*)0x00000000;  // 通常为保留区,访问会段错误
    // *bad_ptr = 100;  // 这会触发 segmentation fault
    
    free(heap_var);
    return 0;
}

2.2 地址空间的内部结构

区域划分

地址空间并不是一整块连续的内存,而是被划分为多个功能不同的区域:

struct mm_struct {
    // 代码区(只读)
    long code_start;
    long code_end;
    
    // 常量区(只读)
    long readonly_start;
    long readonly_end;
    
    // 数据段
    long init_start, init_end;      // 已初始化数据
    long uninit_start, uninit_end;  // 未初始化数据
    
    // 堆区(动态增长)
    long heap_start, heap_end;
    
    // 栈区(动态增长)
    long stack_start, stack_end;
};

在这里插入图片描述

各区域功能说明:

  • 代码区:存放可执行指令,具有只读属性
  • 常量区:存放字符串常量等,同样只读
  • 数据段:存放全局变量和静态变量
  • 堆区:动态内存分配区域,向高地址增长
  • 栈区:函数调用、局部变量存储,向低地址增长

为什么需要区域划分?

// 示例:不同区域的变量存储
const char* str = "Hello";  // 字符串常量 -> 常量区(只读)
int global_var = 10;        // 全局变量 -> 数据段
int main() {
    int local_var = 20;     // 局部变量 -> 栈区
    int* ptr = malloc(100); // 动态内存 -> 堆区
    // str[0] = 'h';        // 错误!常量区不可写
    return 0;
}

最本质的回答:区域划分的三大核心原因

  1. 权限控制
    不同内存区域需要不同的访问权限:
  • 代码段:只读 + 可执行(防止被篡改)
  • 数据段:读写 + 不可执行(防止代码注入)
  • 栈/堆:读写 + 不可执行(安全隔离)
  1. 生命周期管理
    不同内存区域有不同的生存周期:
  • 代码段:程序整个生命周期(静态)
  • 全局变量:程序整个生命周期(静态)
  • :函数调用期间(自动)
  • :手动控制(动态)
  1. 管理策略优化
    不同区域需要不同的管理算法:
  • :简单的指针移动(高效)
  • :复杂的分配算法(灵活)
  • 代码段:一次性映射 + 共享(节省内存)
    一句话总结:
    区域划分是为了给不同类型的内存内容提供最适合的"生存环境"——不同的权限、生命周期和管理策略。

2.3 进程的地址空间的实现机制

几个前置知识铺垫:

  • CR3寄存器存储当前进程页表的起始地址
    • 每个进程有一个页表,CR3寄存器保存当前运行进程的页表在物理内存中的起始地址。
    • 进程切换时,操作系统会更新CR3的值。
  • MMU(内存管理单元)硬件负责地址转换
    • CPU中的一个硬件单元,负责将虚拟地址转换为物理地址。
    • 转换过程是:使用CR3找到页表,然后通过多级页表结构找到对应的物理页框,最后结合页内偏移得到物理地址。
  • 页表项包含物理页框号、访问权限等信息
    • 页表中的每一个条目,存储了虚拟页到物理页的映射关系以及权限控制位(如是否可读、可写、可执行,是否已被访问等)

2.4 页表

什么是页表?

页表是操作系统用于实现虚拟内存到物理内存转换的核心数据结构。
它就像是内存世界的"翻译官",将进程看到的虚拟地址"翻译"成实际的物理地址。

// 最简单的理解:页表是一个映射函数
物理地址 = 页表[虚拟地址]

2.5 PCB与页表的关系及页表存储位置

PCB与页表的关系

  1. 包含关系
进程控制块(PCB)
├── 进程ID (PID)
├── 进程状态
├── 程序计数器
├── 寄存器值
├── 打开文件列表
├── **内存管理信息**
│   ├── 页表基地址 (CR3值)
│   ├── 内存映射信息
│   └── 内存限制
└── 调度信息
  1. 关联机制
// 在PCB中存储页表相关信息
struct task_struct {          // Linux的PCB
    pid_t pid;                // 进程ID
    // ...
    struct mm_struct *mm;     // 内存管理结构
    // ...
};

struct mm_struct {            // 内存管理结构
    pgd_t *pgd;               // 页全局目录地址(页表根)
    unsigned long start_code, end_code;
    unsigned long start_data, end_data;
    // ...
};

Linux mm_struct 的简化视图

// Linux 内核中 mm_struct 的实际定义(简化)
struct mm_struct {
    // 线性地址区间定义
    unsigned long mmap_base;    // 内存映射区域基址
    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, env_start, env_end;
    
    // 内存区域链表(更精细的管理)
    struct vm_area_struct *mmap;       // 虚拟内存区域链表
    struct rb_root mm_rb;              // 红黑树用于快速查找
};
  • 图例:
    在这里插入图片描述
  1. 进程切换时的操作
; 进程切换伪代码
switch_process(new_process):
    ; 1. 保存当前进程上下文到其PCB
    save_registers(current_process->pcb);
    
    ; 2. 从新进程PCB中加载页表地址到CR3
    mov cr3, new_process->pcb->mm->pgd
    
    ; 3. 恢复新进程上下文
    load_registers(new_process->pcb);
    
    ; 4. 跳转到新进程执行
    jmp new_process->pcb->program_counter

页表的存储位置

1. 页表存储在物理内存中

在这里插入图片描述

2. 页表存储的特点
// 页表本身的存储特性
struct page_table_storage {
    void *physical_address;  // 页表存储在物理内存中
    size_t size;             // 页表占用内存大小
    int user_accessible;     // 用户进程通常不能直接访问页表
};

关键点

  • 页表本身存储在物理内存
  • 每个进程的页表占用连续的物理内存区域
  • 操作系统内核负责管理页表的内存分配和释放
3. 页表访问的层次结构
CPU访问流程:
1. CPU发出虚拟地址
2. MMU读取CR3寄存器获取当前页表物理地址
3. MMU直接访问物理内存中的页表进行地址转换
4. 得到物理地址后访问实际内存数据

注意:页表查询过程对软件完全透明,由硬件自动完成

** 完整的关系图示**
在这里插入图片描述

总结:
PCB与页表关系:

  • PCB包含页表指针:每个PCB存储该进程页表的物理地址
  • 进程切换依赖:切换进程时,通过更新CR3寄存器来切换页表
  • 一对一关系:每个进程有自己独立的PCB和页表
    页表存储位置:
  • 存储在物理内存:页表本身占用物理内存空间
  • 内核管理:操作系统内核负责分配和管理页表内存
  • 硬件访问:MMU直接访问物理内存中的页表进行地址转换

简单说:PCB是进程的"身份证",其中记录了页表的"住址",而页表本身"住"在物理内存中。

页表与地址转换

地址空间的核心在于虚拟地址到物理地址的转换,这个过程通过页表实现:

虚拟地址空间 → 页表映射 → 物理内存

在这里插入图片描述

缺页中断与惰性加载

现代操作系统采用惰性加载策略,不会一次性将程序全部加载到内存:
在这里插入图片描述

// 当进程访问尚未加载的页面时:
1. CPU触发缺页中断
2. 操作系统捕获中断
3. 从磁盘加载缺失的页面到内存
4. 更新页表映射
5. 重新执行引发中断的指令

这种机制使得:

  • 大程序可以分段加载
  • 节省内存空间
  • 提高系统响应速度

2.6 地址空间的重要性

1. 内存保护与安全性

// 示例:非法访问被拦截
int* ptr = (int*)0x12345678;  // 随机地址
*ptr = 100;  // 地址空间会拦截这个非法访问

保护机制

  • 只读区域写操作 → 段错误
  • 未映射地址访问 → 段错误
  • 越界访问 → 被页表检查拦截

2. 进程独立性

每个进程拥有独立的地址空间和页表:

// 进程A和进程B可能有相同的虚拟地址
// 但通过各自的页表映射到不同的物理地址

进程A: 虚拟地址0x4000 → 物理地址0x8000
进程B: 虚拟地址0x4000 → 物理地址0x9000

3. 模块化解耦

在这里插入图片描述

地址空间实现了进程管理与内存管理的解耦

  • 进程管理器:负责调度、创建进程
  • 内存管理器:负责物理内存分配、页面置换
  • 两者通过地址空间这个抽象层进行交互

简单小结:

  1. 让进程以统一的视角看内存
  2. 增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存
  3. 因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合!

进程创建流程

  1. 创建内核数据结构(task_struct、mm_struct)
  2. 建立页表映射
  3. 加载程序代码和数据(按需加载)
  4. 设置程序计数器,开始执行

2.7 实际应用场景

2.7.1. 内存共享

// 多个进程可以共享相同的物理页
// 通过将不同进程的虚拟地址映射到相同的物理页实现
// 常用于:动态库、进程间通信

2.7.2 写时复制(Copy-on-Write)

// fork()创建子进程时:
// 1. 父子进程共享相同的物理页
// 2. 当任一进程尝试写入时,才复制该页面
// 3. 为写入进程创建新的物理页副本
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值