【Linux系统】—— 程序地址空间
【Linux系统】—— 程序地址空间
- 1 进程(虚拟)地址空间
- 2 虚拟地址到物理地址的映射
- 3 什么是虚拟地址空间
- 4 mm_struct
- 5 为什么要有地址空间
- 5.1 无序变有序
- 5.2 保护物理内存
- 5.3 解耦合
- 6 扩展问题
1 进程(虚拟)地址空间
学习了前面的 C/C++ 我们知道:一个程序的数据存储是需要分区的,C/C++ 中常见的区域有:栈、堆、静态区(数据段)、常量区(代码段)
- 程序段(Text):程序代码在内存中的映射,存放函数体的二进制代码。
- 初始化过的数据(Data):在程序运行初已经对变量进行初始化的数据。
- 未初始化过的数据(BSS):在程序运行初未对变量进行初始化的数据。
- 栈 (Stack):存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。栈区向上生长
- 堆 (Heap):存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。堆区向下生长
我们写段 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]);}return 0;
}
从语言的角度,我们看内存分布是这样的。
学习语言时,我们将上图称为程序地址空间。
程序地址空间是真实的内存吗?
不是!
反向思考一下,如果一个程序按这种方式排布,但内存一共就这么大,你一个程序就划分完了整个内存,其他进程怎么办?
实际上,上图的并不叫程序地址空间,我们称他为进程地址空间或者虚拟地址空间。它是一个系统的概念,不是语言层的概念。
下面我们写段代码验证来上图的空间划分不是物理内存
#include <stdio.h>
#include <unistd.h>int gval = 100;int main()
{pid_t id = fork(); if(id == 0){ while(1){ printf("子进程:gval:%d, &gval:%p, pid:%d, ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);gval++;} } else{ while(1){ printf("父进程:gval:%d, &gval:%p, pid:%d, ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);}}return 0;
}
子进程不断修改 gval 的值,对父进程的 gval 是没有任何影响的,因此父进程的 gval 的值一直不变。
但是父子进程 gval 的地址竟然是一样的!
如果这个地址是物理地址,那就出 bug 了:同一个地址,你读的是 100,我读的是 101,从地址读到的值于进程有关。
所以该地址一定不是内存地址!
它是什么?
我们直接输出结论:它是虚拟地址。是操作系统为进程虚拟化出来的一套全新的地址
。
我们之前 C/C++ 用到的指针,全部都是虚拟地址。
结论:一个进程一个虚拟地址空间,即每一个 task_struct 都对应一个虚拟地址空间
虚拟地址空间单位是字节;在 32 位机器下虚拟地址空间的是 232 个字节,64位机器是 264 个字节
2 虚拟地址到物理地址的映射
上述代码,我们定义了 gval 变量,虽然现在又冒出一个虚拟地址的东西,但无论怎样 gval 肯定要在内存中,不然怎么会被 CPU 读取呢?
即:一个变量在内存中存在物理地址,同时在地址空间上存在一个虚拟地址。
在操作系统中,每个进程创建 OS 都会为其创建一张页表。
一个进程,一套页表!
页表是做虚拟地址和物理地址映射的。
页表一侧是虚拟地址,另一侧是物理地址。
当我们要访问虚拟地址时,系统会自动将虚拟地址通过查也表转换为查找对应的物理地址,进而访问指定变量
所以为什么一个地址会有两个值?
前面我们说过,一个进程一套虚拟地址一套页表,所以子进程也有自己的虚拟地址空间和页表。
子进程的代码和数据全部都是拷贝自父进程的,这也包括地址空间和页表中的内容。这也意味着在子进程的初始化全局数据区中,同样存在着一个全局变量 gval,值为 100,它的虚拟地址为 0x11111111 ,物理地址 0x11223344。
此时的父进程与子进程指向同一个物理地址
这也是为什么代码和数据起始时是父子共享的,因为他们都指向同一个物理内存。
后来,子进程对变量 gval 要进行修改。
因为进程要具有独立性。当系统发现子进程要修改 gval 时,操作系统重新开辟一个空间,将 gval 的内容拷贝进新空间,子进程就得到了一个新的物理地址。系统再将子进程中页表中虚拟地址 0x11111111 映射的物理地址修改成新的地址,构建新的映射关系。
这种机制我们称为写实拷贝!
所以为什么前面代码父子进程打印同一个全局变量,变量值不一样地址却一样?
因为父和子用的都是虚拟地址,而子进程继承了父进程的虚拟地址,所以他们的虚拟地址是一样的。后来子进程修改 gval,在系统内部自动做了写实拷贝,并修改页表的映射关系。最终父子的 gval 虚拟地址一样,物理地址和内容不一样
我们用户能不能看到物理地址呢?
看不到。操作系统将物理地址隐藏起来了,我们只能看到虚拟地址。
3 什么是虚拟地址空间
如何理解虚拟地址空间呢?我们举一个例子
有一个富豪,它有 10 亿资产,由于年轻时比较浪,所以他有四个私生子。这四个私生子并不知道彼此的存在。(认识还叫什么私生子)
私生子 A 是个医生;私生子 B 是个企业家;私生子 C 是个街头混混;私生子 D 是个学生。
富豪分别对小A、B、C、D说:
“小 A 啊,你要是努力做个医生,以后我的10亿美金都是你的了”;“小 B 啊,要是你把你的公司运作的很好
以后我的 10 亿美金就是你的了”;“小C 啊……”;“小D 啊……”
现在列出人物和地址的对应关系:
- 富豪 == 操作系统
- 10 亿美金 == 物理地址
- 私生子 == 每一个进程
- 富豪画的饼 == 地址空间
这个富豪在干嘛?给他的每一个私生子画饼!所以每一个进程都会认为自己有 4GB(32位) 的物理内存,每一个进程都认为自己在独占物理内存
故事还没结束。
一天A说 “老爸给我 1万 美金,我要买医疗器械” 富豪想了想觉得是正事,于是给了 小A 1万美金;小C 又说"老爸,给我两千美金吧,我吃不起饭了" 富豪一听就把钱打过去了。所以我们知道,这四个人都可以用 10亿 美金以内的钱,但是永远用不到 10亿 美金!
所以可以得出结论:地址空间可以理解为操作系统给进程画的饼,它并不是真实的物理地址!
既然富豪给每一个私生子都画了饼,所以对应每一个进程都有一个自己的程序地址空间。
但画的饼总要管理起来吧,举个例子
老板给小王画饼是涨薪,给小李画饼是让其当项目经理。给不同人画不同的饼总要管理起来吧,不然下次老板对小李说涨薪而不是当项目经理,小李当场翻脸。
所以,OS 要管理这些空间,一定要先组织,再管理!所以 OS 给虚拟地址空间定义了一个 struct 结构,对所有地址空间的管理就是对链表的增删查改
虚拟地址空间本质是一个结构体对象
该 struct 结构体类型名称:struct mm_struct
task_struct
中有相应指针指向 mm_struct
4 mm_struct
mm_struct
结构体中应该有哪些属性呢?即虚拟地址空间是如何实现的
我们先来理解区域划分。
虚拟地址空间从 全0 到 全f 一共232个地址,这么多空间划分 多个区域:栈、堆、静态区(数据段)、常量区(代码段)。这些区域是如何划分的呢?
在二维层面,我们只需要记录区域的开始 (start) 和 结束(end) 即可。只要确定了开始和结束,那么 [start, end] 之间的区域随便用
所以mm_struct
中至少包含哪些属性呢?
至少包含各个区域的起始虚拟地址和结束虚拟地址。
如何进行区域调整呢?
只需起始虚拟地址或结束虚拟地址做加减即可。
Linux(2.6.18)部分源码
struct task_struct
{/*...*/struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为 NULL。struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该进程是内核线程时,它的 mm 字段为 NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。/*...*/
}
虚拟空间的组织方式有两种:
- 当虚拟区较少时采取单链表,由 mmap 指针指向这个链表;
- 当虚拟区间多时采取红黑树进行管理,由 mm_rb 指向这棵树。
struct mm_struct
{//其他属性//...struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb; /* red_black树 */unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*///其他属性//...// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。unsigned long total_vm, locked_vm, shared_vm, exec_vm;unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;//其他属性//...
}
不同的程序,其代码和数据的大小肯定是不同的。那进程地址空间 (mm_struct) 中对应的代码段和数据段的大小是不是会随着不同程序代码和数据的体量而变化呢?答案是肯定的。
当我们对应的程序编程进程时,程序的数据和代码要加载到对应的物理内存,一旦加载到物理内存,代码占多少个物理内存字节,系统就会在地址空间中申请同样大小的区域空间(这么说不是很准确,但有助于大家理解)。如何申请虚拟地址空间?现阶段就是调整区域划分!
将程序加载到内存中,要做三步:
- 在虚拟地址空间中调整区域划分
- 加载程序,申请物理空间
- 填充页表建立映射关系
mm_struct
也是个对象,是对象就需要开辟空间和初始化。初始化的值从哪里来?
有相当一部分是程序加载到内存时,从加载的过程中来的。
5 为什么要有地址空间
5.1 无序变有序
对用户来讲,它将来访问地址,相应类型地址一定是连续的。问题是:对应的可执行程序加载时,是必须加载在物理内存的指定位置还是可以随便加载?
其实这个程序加载到物理内存的是什么地方已经不重要了,因为不管你加载到哪,我都可以通过页表的映射关系将无序变有序。
不同的进程的代码和数据其实都是交叉在一起的。甚至有可能你的代码太大了,系统会分批加载
,每次加载还在不同的地方。
但是没关系,有虚拟地址空间和页表的存在,在上层的应用程序看来所有的代码和数据又是有序的
虚拟地址空间第一个意义:将地址从无序变有序
5.2 保护物理内存
学习了上面的内容我们知道:当用户拿着虚拟地址访问某个变量时,必须将虚拟地址转换为物理地址。这个转换工作是谁来做的?我们简单理解是操作系统(实际上还有硬件,这点之后学习),这个过程是自动的并不需要用户关心。
所以我们直接找物理地址不好吗?为什么还要经过一层转化?
举个栗子:
过年了,小明收到了许多压岁钱。每人管小明,小明就将这些钱随便用。后来小明妈妈发现这样不行,对小明说:“小明,你以后的压岁钱交给妈妈来保管,当你需要买东西时,来找妈妈要。”
妈妈为什么要这么做?妈妈说服小明时说的都是好话,但听人说话从来不要听他说了什么,要听他没说什么
当小明找妈妈要钱买辣条时,妈妈一口拒绝。妈妈对小明的请求做拦截了
所以为什么要有虚拟地址这个中间层?
当我们去访问时,操作系统要去查页表。页表中其实远远不止有映射关系,其中之一就是记录每一个地址的读写权限。
虚拟地址空间第二个意义:地址转换过程中,可以对用户的地址和操作进行合法性判定,进而保护物理内存。
当用户想对代码区进行写入操作,操作系统查页表发现代码区只有只读权限,此时操作系统直接做拦截,就实现了对物理内存的保护。
如代码:
char *str = "hello world";
*str = "H";
编译是能编译通过,最多有些编译器报警告。但是运行时就直接崩掉
"hello world"
是字符串常量,放在代码段,而代码段是只读的。当 OS 查页表发现要访问的区域是只读的,而用户要写。OS 不让你转,页表转换失败,进程崩溃。我们以前说这个程序运行崩溃,理由是字符串在字符常量区。可是为什么在字符常量取写入就会崩溃呢?答案就是查页表时,权限拦截了!
为什么野指针有时候会导致进程崩溃?
当我们对一个已释放或者未开辟的地址进行访问时,页表中根本就不存在相应的虚拟地址,操作系统就知道,就直接将这个进程干掉。当然野指针也不是一定会崩溃,如数组的越界访问等
5.3 解耦合
假设进程的代码特别大,比如光代码就 2G (整个内存一共也就4G) 。此时操作系统可能只加载 500M,但在虚拟地址空间中映射 2G,再建立 500M 的虚拟地址和物理地址的映射关系,剩下的 1.5G 只有虚拟地址没有映射物理地址。
操作系统访问完 500M,发现有虚拟地址有而没有物理地址,这时操作系统就会动态加载,将进程运行暂时停止,再建立 500M 的映射关系。这种机制我们称为缺页中断。(了解即可)
因为有地址空间的存在,所以我们在 C、C++ 语⾔上 new/malloc 空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全 0 感知!!
这样物理内存的分配 和 进程的管理就可以做到没有关系,进程只顾自己跑就行了,内存不够什么的进程不用关心
虚拟地址空间第三个意义:让进程管理和内存管理进行一定程度的解耦合
6 扩展问题
我们可以不加载代码数据,只有 task_struct
、mm_struct
、页表
等映射关系吗?
可以的,等到需要时再缺页中断即可
创建进程,先有task_struct、mm_struct等,还是先加载代码和数据?
第一个问题就是第二个问题的答案
如何理解进程挂起?
将页表物理地址清空,只保留虚拟地址,将进程的代码和数据全部换出到磁盘的swap分区。等进程进入运行状态时,再通过缺页中断加载代码和数据
堆区不止一个吧,不止一个起始地址吧?
我们平时用 malloc / new 申请空间,malloc / new了十几次,每个堆空间都有一个起始地址,而上述我们讲的地址空间中的堆空间好像只有一个起始地址和结束地址吧?
mm_struct 内部会维护一张 vm_area_struct
的链表/红黑树,每个 vm_area_struct 节点都标识着一个子空间的开始和结束。而 mm_struct 对应的是对整个地址空间整体的描述。这样就可以以分段不连续的方式划分地址空间
这样哪怕我们在堆区申请多个空间,他们不连续也没关系,每个堆区都会有个 vm_area_struct 结点管理着他们
进程在查对应代码时,其实可以不用关心mm_struct,只需要找对应的vm_area_struct即可。
struct mm_struct{struct vm_area_struct *mmap; /*list of VMAs*//*...*/
}struct vm_area_struct {unsigned long vm_start; //虚存区起始unsigned long vm_end; //虚存区结束/*...*/
}
好啦,本期关于程序地址空间就介绍到这里啦,希望本期博客能对你有所帮助。同时,如果有错误的地方请多多指正,让我们在 Linux 的学习路上一起进步!