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,所以不用占可执行文件的空间(省硬盘),加载到内存时再分配空间。
(小技巧:data
和bss
的区别,就看变量是否初始化过。初始化过的占文件空间,没初始化的不占,启动后自动置0。)
(5)堆(Heap):手动申请的“动态内存”
- 比如用
malloc(100)
或new int[10]
申请的内存,就存在堆里。 - 堆的特点是“向上长”——每次申请新内存,地址会比之前的更高(比如第一次申请在地址0x1000,下次可能在0x1064)。
- 用完要手动释放(
free
或delete
),不然会“内存泄漏”。
(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
非常感谢您的阅读,喜欢的话记得三连哦 |