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

Linux操作系统从入门到实战(二十三)详细讲解进程虚拟地址空间

Linux操作系统从入门到实战(二十三)详细讲解进程虚拟地址空间

  • 前言
  • 一、程序地址空间的划分
    • 1. 什么是“进程的虚拟地址空间”?它和真实的物理内存有什么关系?
    • 2. 这张虚拟地址空间是怎么划分的?每个区域存啥东西?
      • (1) 代码段(.text):存“指令”的“只读区”
      • (2) 只读数据段(.rodata):存“不会变的常量”
      • (3) 数据段(.data):存“初始化过的全局/静态变量”
      • (4) 未初始化数据段(.bss):存“没初始化的全局/静态变量”
      • (5)堆(Heap):手动申请的“动态内存”
      • (6) 栈(Stack):存“临时变量和函数调用”
      • (7) 环境变量/命令行参数区:存启动信息
      • (8)额外补充:内核空间(高地址部分)
    • 3. 堆和栈都是存数据的,它们有啥不一样?
    • 4. Linux的虚拟地址空间和Windows比,有什么特别的?
    • 5. 虚拟地址怎么变成真实的物理地址?
  • 二、虚拟地址
    • 1. 从一个现象说起:为什么fork后父子进程地址相同,修改后却互不影响?
      • 现象1:未修改变量时,父子进程的变量值和地址都相同
      • 现象2:子进程修改变量后,值不同但地址仍相同
      • 第一步:先理解“虚拟地址”为什么会“重复”
      • 第二步:“写时复制(COW)”机制
      • 第三步:虚拟地址空间到底是什么?如何让进程“觉得自己独占内存”?
      • 操作系统如何实现的两大核心手段
        • 1. 数据结构:给每个进程记“内存账”
        • 2. 地址翻译:把“虚拟地址”转成“物理地址”
      • 补充:“缺页异常”是什么?
    • 2. 总结
  • 三、虚拟地址的隔离性
  • 四、为什么要有虚拟地址空间?
    • 1. 隔离安全:一个程序崩了,其他的不受影响
    • 2. 内存不够时,能借硬盘的空间用
    • 3. 多个程序能“共用”同一份数据,省空间
    • 4. 程序员写代码更简单


前言

  • 上一篇博客中,我们介绍了进程切换与进程调度;
  • 这一篇,我们就来详细聊聊进程的虚拟地址空间

我的个人主页,欢迎来阅读我的其他文章
https://blog.csdn.net/2402_83322742?spm=1011.2415.3001.5343
我的Linux知识文章专栏
欢迎来阅读指出不足
https://blog.csdn.net/2402_83322742/category_12879535.html?spm=1001.2014.3001.5482


一、程序地址空间的划分

1. 什么是“进程的虚拟地址空间”?它和真实的物理内存有什么关系?

咱们先想个生活中的例子:你用电脑时,每个程序(比如浏览器、微信)都是一个“进程”。

  • 每个进程运行时,都需要“内存”来存数据(比如浏览器打开的网页内容)。

  • 但这里的“内存地址”,并不是直接对应电脑里插的内存条(物理内存)的真实地址,而是一个“假地址”——这就是虚拟地址空间

  • 简单说:虚拟地址空间是操作系统给每个进程“画的一张饼”,进程里的代码和数据都存在这张“饼”的不同位置(虚拟地址)上。

而真实的物理内存(内存条),就像仓库里的货架,操作系统会悄悄把“虚拟地址”对应的内容,搬到物理内存的货架上(或者反过来)。

为啥要搞“虚拟地址”这一套

  • 安全:每个进程的虚拟地址空间是独立的,你用浏览器的虚拟地址,绝对访问不到微信的虚拟地址,避免互相干扰。
  • 方便:进程不用关心物理内存够不够,操作系统会用“硬盘临时空间”(交换分区)帮忙“扩容”,让进程觉得自己独占了一整块内存。

2. 这张虚拟地址空间是怎么划分的?每个区域存啥东西?

Linux会把虚拟地址空间从“低地址”到“高地址”分成几个区域,就像饼被切成了几块,每块有专门的用途。
在这里插入图片描述

// (3)数据段(.data):初始化过的全局变量
int global_initialized = 10;// (4)未初始化数据段(.bss):未初始化的全局变量
int global_uninitialized;// (3)数据段(.data):初始化过的静态全局变量
static int static_global_initialized = 20;// (4)未初始化数据段(.bss):未初始化的静态全局变量
static int static_global_uninitialized;// (2)只读数据段(.rodata):字符串常量
const char* rodata_string = "hello world";// (1)代码段(.text):函数指令
int add(int a, int b) {// (6)栈(Stack):函数参数a、b和局部变量resultint result = a + b;return result;
}int main(int argc, char* argv[]) {// (7)环境变量/命令行参数区:argc和argvprintf("命令行参数数量: %d\n", argc);if (argc > 1) {printf("第一个参数: %s\n", argv[1]);}// (6)栈(Stack):局部变量int local_var = 5;static int static_local = 30;  // (3)数据段(.data):初始化过的静态局部变量static int static_local_uninit; // (4)未初始化数据段(.bss):未初始化的静态局部变量// (2)只读数据段(.rodata):const常量const int const_var = 100;// (5)堆(Heap):动态分配的内存int* heap_memory1 = (int*)malloc(sizeof(int) * 10);int* heap_memory2 = (int*)malloc(sizeof(int) * 5);// 使用堆内存for (int i = 0; i < 10; i++) {heap_memory1[i] = i;}// 调用函数(代码段中的指令)int sum = add(local_var, global_initialized);// 释放堆内存free(heap_memory1);free(heap_memory2);return 0;
}

(1) 代码段(.text):存“指令”的“只读区”

比如你写了一段C代码int add(int a, int b) { return a+b; },编译后这段代码会变成电脑能看懂的“指令”(比如CPU执行的二进制操作)。

  • 这些指令就存在代码段里,而且是“只读”的——防止不小心被修改(比如程序运行中改了指令,可能就跑飞了)。

(2) 只读数据段(.rodata):存“不会变的常量”

  • 比如代码里写const char* str = "hello world";,这个"hello world"就是常量,存这里。
  • 它也是“只读”的,你要是想改str[0] = 'H',程序会直接崩溃(因为操作系统不允许改只读区)。

(3) 数据段(.data):存“初始化过的全局/静态变量”

  • 比如int global = 10;(全局变量,初始化过)、static int s = 20;
  • (静态变量,初始化过),这些变量在程序启动时就有确定的值,存在这里。

(4) 未初始化数据段(.bss):存“没初始化的全局/静态变量”

  • 比如int empty_global;(全局变量,没赋值)、static int empty_s;(静态变量,没赋值)。
  • 这些变量在程序启动时会被自动设为0,所以不用占可执行文件的空间(省硬盘),加载到内存时再分配空间。

(小技巧:databss的区别,就看变量是否初始化过。初始化过的占文件空间,没初始化的不占,启动后自动置0。)

(5)堆(Heap):手动申请的“动态内存”

  • 比如用malloc(100)new int[10]申请的内存,就存在堆里。
  • 堆的特点是“向上长”——每次申请新内存,地址会比之前的更高(比如第一次申请在地址0x1000,下次可能在0x1064)。
  • 用完要手动释放(freedelete),不然会“内存泄漏”。

(6) 栈(Stack):存“临时变量和函数调用”

  • 比如函数里的局部变量int a = 5;,或者函数调用时的参数、返回地址,都存在栈里。栈的特点是“向下长”——每次调用函数,新的临时变量会存在比之前更低的地址(比如之前在0x7fffffff,新变量可能在0x7ffffffe)。函数执行完,栈会自动回收这些临时变量(不用手动释放)。

(7) 环境变量/命令行参数区:存启动信息

比如你在终端用./a.out 123运行程序,123这个命令行参数,还有PATH这类环境变量,就存在这里。

(8)额外补充:内核空间(高地址部分)

虚拟地址空间的最顶端(高地址),还有一块“内核空间”,专门给操作系统内核用(比如进程调度、硬件驱动)。用户写的程序(用户态)不能直接访问这里,保证内核安全。

3. 堆和栈都是存数据的,它们有啥不一样?

用两个例子对比

区别栈(Stack)堆(Heap)
谁来管理操作系统自动管(函数进栈/出栈)程序员手动管(malloc/free
大小限制一般很小(比如Linux默认8MB)很大(理论上接近物理内存+交换分区)
生长方向向下长(地址越来越小)向上长(地址越来越大)
速度快(操作系统直接操作,不用复杂计算)慢(需要找空闲内存块,记录分配信息)

比如:函数里的int x = 1;在栈上(自动释放);int* p = malloc(4);在堆上(必须free(p),不然内存一直被占)。

4. Linux的虚拟地址空间和Windows比,有什么特别的?

  • 地址更“随机”Linux有个叫ASLR(地址空间布局随机化)的功能,每次程序启动,代码段、堆、栈这些区域的虚拟地址都会“随机变(比如这次栈在0x7fffff000,下次可能在0x7ffffe000)。这样黑客很难猜到关键数据的地址,更安全。
  • 栈的“规矩”Linux的栈严格向下长,而Windows虽然用户态也向下,但内核态可能不一样(咱们写用户程序不用关心内核态)。
  • 文件格式不同:Linux的可执行文件是ELF格式,Windows是PE格式,它们描述“哪些内容该放到虚拟地址哪个区域”的方式不一样,但最终加载到虚拟地址空间的逻辑差不多。

5. 虚拟地址怎么变成真实的物理地址?

靠两个“工具人”:

  • MMU(内存管理单元):CPU里的一个硬件,专门负责把虚拟地址“翻译”成物理地址。
  • 页表:操作系统给每个进程维护的“翻译手册”,记录虚拟地址和物理地址的对应关系(比如虚拟地址0x1000对应物理地址0x8000)。

进程访问虚拟地址时,MMU会查页表,找到对应的物理地址,再去物理内存里读数据——整个过程进程完全“不知情”,它以为自己直接用的是物理地址。

二、虚拟地址

1. 从一个现象说起:为什么fork后父子进程地址相同,修改后却互不影响?

我们先通过两段代码看看进程创建(fork)后变量的特性,这会引出一个关键问题:为什么父子进程中变量的地址相同,修改后的值却互不影响

现象1:未修改变量时,父子进程的变量值和地址都相同

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main() {pid_t id = fork();  // 创建子进程if (id < 0) {perror("fork");  // 处理fork失败的情况return 0;} else if (id == 0) {  // 子进程printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);} else {  // 父进程printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);  // 确保进程输出完成return 0;
}

在这里插入图片描述

输出结果(示例):

parent[2103459]: 0 : 0x556c35f6e014
child[2103460]: 0 : 0x556c35f6e014

在这里插入图片描述

这里,父子进程的g_val值都是0,且地址(&g_val)完全相同。

现象2:子进程修改变量后,值不同但地址仍相同

#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) {  // 子进程g_val = 100;  // 子进程修改全局变量printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);} else {  // 父进程sleep(3);  // 等待子进程先修改完成printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}

输出结果(示例):

child[2103476]: 100 : 0x55d68f846014
parent[2103475]: 0 : 0x55d68f846014

在这里插入图片描述

此时,子进程的g_val变成了100,父进程仍为0,但两者的地址(&g_val)依旧相同。

  • 这就像两个房子用了同一个门牌号,进门后看到的东西却不一样——
  • 这背后的核心就是“虚拟地址”和操作系统的“写时复制”机制。
  • 我们一步步拆解

第一步:先理解“虚拟地址”为什么会“重复”

  • 每个进程启动后,操作系统都会给它分配一套虚拟地址空间——可以理解为进程专属的“内存地图”。
  • 这张地图上的地址(比如全局变量g_val的位置)是固定的,就像“建国路88号”这个门牌号,无论父进程还是子进程,它们的“地图”都按同一规则绘制,因此&g_val的虚拟地址自然相同。

但关键在于:

  • 这张“地图”是每个进程独有的!就像北京和上海都有“建国路88号”,门牌号相同,实际却是两个完全不同的地方。
  • 父进程的“建国路88号”对应物理内存的一块区域,子进程的“建国路88号”可以对应另一块区域——虚拟地址相同,不代表背后的物理内存相同

第二步:“写时复制(COW)”机制

fork的作用是“复制一个进程”,但如果直接把父进程的所有内存数据复制给子进程,会浪费大量时间和内存(比如父进程用了1GB内存,复制一次就耗1GB,没必要)。

操作系统的解决方案是“先共享,后复制”:

  • fork时不复制数据,而是让父子进程暂时共享同一块物理内存,同时给这块内存标记“只读”(只能读,不能改);
  • 当双方都只读取数据(比如未修改的g_val)时,用的是同一块物理内存,因此值相同;
  • 当子进程要修改数据(比如g_val = 100)时,“只读”标记会触发保护机制:操作系统立刻复制一份g_val所在的物理内存给子进程,再让子进程修改新内存。

最终结果是:子进程改的是“新复制的物理内存”,父进程用的还是“原来的物理内存”,两者虚拟地址(门牌号)不变,但物理内存已分离——这就是“地址相同,修改后互不影响”的原因

举个生活例子理解COW

你(父进程)有一本笔记(g_val)放在桌上(物理内存),给儿子(子进程)复制了一把钥匙(fork),钥匙上的地址是“客厅桌子第一层”(虚拟地址)。

  • 一开始,你俩用钥匙打开的是同一张桌子上的笔记(共享物理内存),看到的内容相同
  • 当儿子想改笔记(写操作)时,你立刻复印一份笔记放在另一张桌子,偷偷把儿子钥匙对应的“实际桌子”换成新桌子——但钥匙上的“客厅桌子第一层”(虚拟地址)没变;
  • 最后,儿子改的是新桌子上的笔记,你看的还是原来的,钥匙地址相同,内容却不同。

第三步:虚拟地址空间到底是什么?如何让进程“觉得自己独占内存”?

虚拟地址空间是操作系统给进程的“内存幻觉”——无论物理内存是否足够、是否零散,进程都觉得自己拥有一块从0到最大值的连续内存,里面整齐划分了代码段、数据段、堆、栈等区域(就像一张规划好的“大饼”)。

这种“幻觉”的好处是:

  • 方便进程使用:不用关心物理内存的实际情况,直接按连续地址读写;
  • 安全隔离:每个进程的“大饼”独立,无法通过自己的虚拟地址访问其他进程的内存。

操作系统如何实现的两大核心手段

1. 数据结构:给每个进程记“内存账”
  • task_struct:进程的“身份证”,记录进程ID、状态等信息,包含一个指向mm_struct的指针;
  • mm_struct:进程的“内存布局图”,标记虚拟地址空间中各区域的位置(如代码段起止地址、堆的当前大小、栈的起始位置等)。

有了这两个结构,操作系统就能清楚知道每个进程的虚拟地址空间如何规划。

2. 地址翻译:把“虚拟地址”转成“物理地址”

进程用虚拟地址读写数据时,必须通过“翻译”找到对应的物理内存,这依赖两个工具:

  • 页表:进程的“地址翻译字典”,记录虚拟内存块(页)与物理内存块(页)的对应关系(比如虚拟页1→物理页5);
  • MMU(内存管理单元):CPU中的硬件“翻译官”,自动查页表将虚拟地址转为物理地址,再访问物理内存。

举个例子理解地址翻译
你(进程)想读“建国路88号”(虚拟地址)的东西:

  • 你告诉MMU(翻译官)地址,MMU查页表(字典),发现“建国路88号”对应物理内存的“海淀区中关村大街1号”;
  • MMU去“海淀区”拿数据返回给你,你全程只知道“建国路88号”,不知道背后的物理地址。

补充:“缺页异常”是什么?

有时进程访问的虚拟地址在页表里找不到对应物理页(比如刚用malloc申请内存,还没分配物理页),MMU会触发缺页异常。操作系统会立刻找一块空闲物理页,更新页表(绑定虚拟页和新物理页),再让进程继续运行——就像查字典时发现某个词没收录,先补进去再继续查。

2. 总结

  • fork后父子进程地址相同却互不影响,核心是虚拟地址独立(门牌号可重复)写时复制(修改时才复制物理内存)
  • 虚拟地址空间是操作系统给进程的“内存幻觉”,靠task_struct(身份证)和mm_struct(布局图)记录内存规划,靠页表(字典)和MMU(翻译官)完成地址翻译,让进程“觉得自己独占连续内存”。

简言之:虚拟地址是“假地址”,但操作系统用一系列机制让它“像真的一样”工作,既安全又高效。

三、虚拟地址的隔离性

咱们用“大富翁游戏”来打个比方,就能秒懂虚拟地址的“隔离性”了。

想象一下,大富翁里每个玩家(比如你、我、小明)都有一本自己的“房子编号本”,上面写着“101号房”“202号房”这些编号——这些编号就是虚拟地址

但关键是:

  • 你的“101号房”和我的“101号房”,不是同一个实际的房子(物理内存)。就像你家小区的101和我家小区的101,编号一样,却是完全分开的地方;
  • 你只能用自己编号本上的编号找房子,没法直接闯进我的房子(进程只能访问自己的虚拟地址,碰不到其他进程的内存)。

这就是“隔离性”——每个程序(进程)都被圈在自己的“编号本”里,就算编号重复,也不会互相干扰

举个实际的例子

你的微信和浏览器,可能都有一个叫“0xbfffffff”的虚拟地址(比如栈的位置),但这俩地址对应电脑里完全不同的物理内存,微信卡了不会影响浏览器,就是因为这种隔离。

再补充两个小知识点:

  • 就像给房子装锁,虚拟地址也有“权限”(比如程序的代码段只能读不能改,防止乱改代码出问题);
  • 系统还会偶尔“打乱”编号本的顺序(内存随机化),比如这次101号在左边,下次在右边,让坏人很难猜到具体位置,更安全。

四、为什么要有虚拟地址空间?

  • 你可能会问:直接用物理内存地址不行吗?为啥非要搞个“虚拟地址”这么绕的东西?其实这都是为了让电脑用起来更方便、更安全。咱们分点说:

1. 隔离安全:一个程序崩了,其他的不受影响

如果没有虚拟地址,所有程序都直接用物理内存地址,就像大家共用一本“房子编号本”。

  • 这时如果一个程序出了错(比如乱改地址),可能会不小心改到其他程序的内存——比如你用浏览器时,一个网页崩溃了,结果微信也跟着关掉了,这多糟?

有了虚拟地址,每个程序的“编号本”独立,就算一个程序乱搞,也只能影响自己的“房子”,不会波及其他程序

2. 内存不够时,能借硬盘的空间用

  • 电脑物理内存(就是你买电脑时说的“8G内存”“16G内存”)可能不够用,比如你同时开了10个网页、一个游戏、一个视频剪辑软件。

  • 虚拟地址空间能帮你“骗”过程序:当内存不够时,系统会把暂时不用的数据(比如后台网页的内容)挪到硬盘上存着(这叫“swap分区”),等程序需要时再挪回来。

程序自己感觉不到内存不够,还以为一直用着“足够的内存”

3. 多个程序能“共用”同一份数据,省空间

  • 比如电脑里的“基础工具库”(比如处理文字、计算的通用代码),几乎所有程序都会用到。
  • 如果每个程序都把这份库复制一份存在自己的内存里,太浪费空间了(比如一份库100M,10个程序就多占1G)。

有了虚拟地址,系统可以让所有程序的虚拟地址“指向同一份物理内存里的工具库”(只要大家只看不用改),相当于10个程序共用一本字典,不用每人买一本。

4. 程序员写代码更简单

  • 如果直接用物理内存,程序员得天天操心“这块内存被别人占了吗?”“内存够不够?”“地址是不是连续的?”。

有了虚拟地址,程序员只需要按“连续的虚拟地址”写代码就行(比如“从100号到200号存数据”),不用管实际物理内存是不是零散的、够不够——这些麻烦事全交给系统处理了


以上就是这篇博客的全部内容,下一篇我们将继续探索Linux的更多精彩内容。

我的个人主页
欢迎来阅读我的其他文章
https://blog.csdn.net/2402_83322742?spm=1011.2415.3001.5343
我的Linux知识文章专栏
欢迎来阅读指出不足
https://blog.csdn.net/2402_83322742/category_12879535.html?spm=1001.2014.3001.5482

非常感谢您的阅读,喜欢的话记得三连哦

在这里插入图片描述

http://www.dtcms.com/a/332141.html

相关文章:

  • Java 大视界 -- Java 大数据在智能教育虚拟学习环境构建与学习体验增强中的应用(399)
  • 本地生活|MallBook 分账赋能浙江本地生活服务平台,助力实现资金流转效率与合规性的双提升!
  • Flink的状态管理
  • 手机分身空间:空间自由切换,一机体验双重生活!
  • 机械加工元件——工业精密制造的璀璨明珠
  • 【Golang】:流程控制语句
  • Python基础(Flask①)
  • 科技展厅通过多媒体技术能如何创新展示,超越展墙展板的固有限制?
  • 基于HTML5与Tailwind CSS的现代运势抽签系统技术解析
  • Rust+Python双核爬虫:高并发采集与智能解析实战
  • 基于单片机的超市储物柜设计
  • 超高车辆碰撞预警系统如何帮助提升城市立交隧道安全?
  • CERT/CC警告:新型HTTP/2漏洞“MadeYouReset“恐致全球服务器遭DDoS攻击瘫痪
  • UE UDP通信
  • 接口芯片断电高阻态特性研究与应用分析
  • UDP协议特点与网络通信
  • MIPI-csi调试
  • 物联网系统中传感器到网关到物联网平台的传输路径、协议、原理、用途与架构详解
  • 【机器学习深度学习】OpenCompass 评测指标全解析:让大模型评估更科学
  • tun/tap 转发性能优化
  • 当云手机出现卡顿怎么办?
  • 自适应UI设计解读 | Fathom 企业人工智能平台
  • 基于微信小程序的家教服务平台的设计与实现/基于asp.net/c#的家教服务平台/基于asp.net/c#的家教管理系统
  • Boost库中boost::function函数使用详解
  • OpenCV-循环读取视频帧,对每一帧进行处理
  • GoLand深度解析:智能开发利器与cpolar内网穿透方案的协同实践
  • 0814 TCP通信协议
  • 一款开源的远程桌面软件,旨在为用户提供流畅的游戏体验,支持 2K 分辨率、60 FPS,延迟仅为 40ms。
  • 数据库访问模式详解
  • [TryHackMe](知识学习)---基于堆栈得到缓冲区溢出