目录
[5. 程序地址空间](#5. 程序地址空间)
[5.1 C、C++内存空间布局验证](#5.1 C、C++内存空间布局验证)
[5.2 一个例子,引入虚拟地址](#5.2 一个例子,引入虚拟地址)
[5.3 引入虚拟地址空间 ---- 是什么?](#5.3 引入虚拟地址空间 ---- 是什么?)
[5.4 如何理解空间划分](#5.4 如何理解空间划分)
[5.4.1 什么是虚拟地址空间?](#5.4.1 什么是虚拟地址空间?)
[5.4.2 如何理解空间划分?](#5.4.2 如何理解空间划分?)
[5.5 为什么要有虚拟地址空间?](#5.5 为什么要有虚拟地址空间?)
5. 程序地址空间
完整的叫法是进程地址空间,后续会不断的提到这一概念,每一次的侧重点都是不同的,本次的文章主要介绍 建立程序地址空间的宏观的认知。
5.1 C、C++内存空间布局验证
C、C++内存空间布局不属于语言范畴,而是属于系统范畴。
验证代码:
cpp
#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";
//"helloworld"在字符常量区,str是定义在main函数中的指针变量,该指针变量保存的是该常量字符串的起始地址
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
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); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
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;
}

printf("code addr: %p\n", main);
第一个打印出来的地址就是main函数的地址,以main函数为代表打印代码段的地址
printf("init global addr: %p\n", &g_val);
第二个打印的是已初始化的全局变量,是已初始化数据在全局数据区在做打印
printf("uninit global addr: %p\n", &g_unval);
第三个打印的是未初始化全局数据,打印未初始化数据
接着在申请一堆malloc空间,用指针指向malloc对应的堆空间,将堆空间的地址放在指针里

第四步,打印堆空间的地址,应该访问的是指针变量里面的内容,而不是对指针变量本身取地址
这个指针变量: char* heap_mem 和上面的指针变量:const char* str 都属于main函数内部在栈上定义的临时或局部变量,只不过这个指针char* heap_mem指向的是一个堆空间,char* heap_mem里面放的是堆空间的地址,所以打印堆空间的地址,打印的是指针的内容。
指针变量也是变量,都相当于在main函数中的栈当中开辟的空间,打印栈空间的地址,直接对每个成员变量直接取指针变量的地址

运行结果:


在Windows下验证的话,可能会有不同的结果,同时本篇文中讲的C、C++内存空间布局仅仅认为在Linux下是有效的,Windows和VS更靠近普通用户,写出来的软件会进行一些优化处理,防止一些恶意的软件劫持,本篇文章只考虑Linux。
堆空间向上增长,连续申请上若干个堆空间,得到的连续的堆空间地址应该是连续增大的,说明堆空间向上增长。
栈空间是向下增长的,上面的运行结果中可以看出来是依次减小的。
根据上面的描述,得出:
1、堆、栈相对而生(注意有三个概念:堆是堆,栈是栈,堆栈就是栈)
2、在整个空间布局的中,分为正文的、初始化数据区、未初始化数据区、堆区、栈区、堆栈相对而生,整个空间布局我们认为是从低地址到高地址依次增长
static int test = 10; 一个局部变量如果被static修饰,一般它的作用域不变,在函数内部依旧只能在本函数内部有效,但是它的生命周期却变长了,随着函数调用完毕,并不会结束,下次使用时,还在。

运行结果:已经属于全局的数据了

被static修饰,在编译器编译时就将这个对象当成全局变量了,所以不会随着函数调用结束而结束,生命周期延长。
再有一个细节:

在栈空间定义时,栈的空间是8个字节8个字节的减少,因为用的系统是64位的Linux机器,指针大小占8个字节,整型的话就是4个字节。
上面画的内存空间布局不是物理内存!!!而是进程地址空间(也叫做虚拟地址空间)!!!
5.2 一个例子,引入虚拟地址

运行结果:

可以发现:
父子进程都可以同时执行各自的死循环,父进程和子进程是父子关系,都能看见全局变量,并且全局变量的内容和地址都是一样的,验证了fork之后代码共享,数据也共享,默认情况下父子都能访问。如果父进程定义的是两大数组,子进程也是可以直接看到这两个数组。
在子进程这里加上了修改全局变量的代码:

运行结果:

再次验证结论:
进程之间是具有独立性的。即便是父子进程
那么看的再仔细一点,我们就可以发现父子进程的gval值不相同,但是又是如何做到的gval的地址是同一个呢?

问题:同一个地址,怎么可能会查出来不同的值呢?
不知道这个地址是怎么回事,但是可以100%肯定的是:这个地址根本不是物理内存的地址。
将这样的地址叫做虚拟地址!!!我们历史所学所有的地址,都是虚拟地址!!我们看不到物理地址!!!(不光是C语言、C++任何语言都是看不见物理地址的)
5.3 引入虚拟地址空间 ---- 是什么?

根据上面图的内容,直接解决问题:




在上面的两个例子中,最终落脚点为写时拷贝的问题,就是页表、物理地址、虚拟地址转换的问题。
5.4 如何理解空间划分
5.4.1 什么是虚拟地址空间?
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的 task_struct 结构中,有⼀个指向该进程的mm_struct结构体指
针。
cpp
struct task_struct
{
/*...*/
struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。
struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
/*...*/
}
可以说, mm_struct 结构是对整个用户空间的描述。每⼀个进程都会有自己独⽴的 mm_struct ,
这样每⼀个进程都会有自己独立的地址空间才能互不干扰。先来看看由 task_struct 到
mm_struct ,进程的地址空间的分布情况:

mm_struct 所在的文件是 mm_types.h:
cpp
struct mm_struct
{
/*...*/
struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */
struct rb_root mm_rb; /* red_black树 */
unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/
/*...*/
// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
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只能建立宏观概念。还有一个数据结构:vm_area_struct。
源码中的vm_area_struct的定义:


总结:
什么是虚拟地址空间地址?
虚拟地址空间使用mm_struct一个宏观统计,vm_area_struct一个细腻划分,就可以共同构建出一个进程,看待物理内存的方案,要多细就划分有多细,本质就是在mm_struct的后续新增更多的vm_area_struct。


一个大富翁和他的私生子们的例子:大富翁告诉自己的每个孩子,将来10个亿由他/她一个人继承:

虚拟地址空间本质是一个内核数据结构???就要先理解空间划分。
5.4.2 如何理解空间划分?

所以说,内核数据结构当中是有大量的start和end。



再往回看:







5.5 为什么要有虚拟地址空间?
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?
在早期的计算机中,要运行⼀个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
那当程序同时运⾏多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占⽤内存10M,B需占⽤内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余118M中划分出110M分配给程序B。

这种分配方法可以保证程序A和程序B都能运⾏,但是这种简单的内存分配策略问题很多。
安全风险
每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内
存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
地址不确定
众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中
去运行,如果直接使用物理地址的话,我们⽆法确定内存现在使用到哪⾥了,也就是说拷贝
的实际内存地址每⼀次运⾏都是不确定的,比如:第⼀次执行a.out时候,内存当中⼀个进程
都没有运行,所以搬移到内存地址是0x00000000,但是第⼆次的时候,内存已经有10个进程
在运行了,那执行a.out的时候,内存地址就不⼀定了
效率低下
如果直接使用物理内存的话,⼀个进程就是作为⼀个整体(内存块)操作的,如果出现物理
内存不够用的时候,我们⼀般的办法是将不常用的进程拷贝别到磁盘的交换分区中,好腾出内
存,但是如果是物理地址的话,就需要将整个进程⼀起拷走,这样,在内存和磁盘之间拷贝
时间太长,效率较低。
存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然!
- 地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使⽤地址空间和⻚表进⾏映射,也⼀定要在OS的监管之下来进行访问!!也顺便 保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!
- 因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完 成了解耦合。
因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!
- 因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视⻆所有的内存分布都可以是有序的。

以一个例子更好的理解虚拟地址空间的重要性:
例子:小明将压岁钱交于母亲保管,小明想买作业本,母亲给了money,成功买到作业本,但是小明想买玩具的时候,母亲却不准小明买。

小明母亲可以对小明的操作做审核。小明好比就是进程,商店就是物理内存,商店里面的货物就是内存里保存的代码和数据,而小明母亲就好比是OS+虚拟地址空间+页表。OS是进程最大的大家长。

