目录
一、fork()问题
我们还是接着上一篇文章,前面说fork()的返回值有两个值,同一个变量竟然有两个不同的值同时存在,这一点是十分反直觉的,难道我们前面所学的地址指针是错误的吗?
我们先对这个问题按下不表,我们看这样一段代码
#include<stdio.h>
#include<unistd.h>
int g_value = 100;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
//child
int cnt = 0;
while(1)
{
printf("I am child pid: %d ppid: %d g_value: %d &g_value: %p\n",
getpid(), getppid(), g_value, &g_value);
cnt++;
if(cnt == 5)
{
printf("child modify g_value g_value: %d -> ", g_value);
g_value = 200;
printf("%d\n", g_value);
}
sleep(1);
}
}
else
{
//father
while(1)
{
printf("I am father pid: %d ppid: %d g_value: %d &g_value: %p\n",
getpid(), getppid(), g_value, &g_value);
sleep(1);
}
}
return 0;
}
我们定义了一个全局变量,然后创建了一个子进程,然后让子进程修改全局变量的值,观察出现的现象
在没有修改全局变量时一切都正常,修改之后,父进程的g_value没有修改,子进程的g_value修改了,这也符合我们的预期,然后我们看g_value的地址,也没有毛病,地址没有修改,证明还是原来的那个变量,但是我们将这两种现象合到一起再看,就会发现这种现象十分的奇怪。
同一个变量,相同的地址,但是内容却不一样。这个现象基本上是颠覆了我们前面的关于地址的认知。
其实,这里面所说的地址绝对不是物理内存地址,而是虚拟内存地址(线性地址)
所有的语言如果有地址的概念,那么所谓的地址一定不是物理地址,而是虚拟地址。
C/C++ 是一种编译型语言,编译之后就已经是二进制可执行程序了。
二、详解程序地址空间
1、基本概念
我们在学习编程语言时一定见过类似的图片
但是,这张图片说的就是对的吗?
下面我们来验证一下
1 #include<stdio.h>
2 #include<stdlib.h>
3
4 int g_value = 100;
5 int g_unvalue;
6
7 int main(int argc, char* argv[], char* env[])
8 {
9 printf("code_adder: %p\n", main);//正文代码
10
11 printf("init_adder: %p\n", &g_value);//已初始化全局数据区
12
13 printf("uninit_adder: %p\n", &g_unvalue);//未初始化全局数据区
14
15 char* heap_mem1 = (char*)malloc(10);
16 char* heap_mem2 = (char*)malloc(10);
17 char* heap_mem3 = (char*)malloc(10);
18 char* heap_mem4 = (char*)malloc(10);
19
20 printf("heap_adder: %p\n", heap_mem1);//堆区
21 printf("heap_adder: %p\n", heap_mem2);
22 printf("heap_adder: %p\n", heap_mem3);
23 printf("heap_adder: %p\n", heap_mem4);
24
25 printf("stack_adder: %p\n", &heap_mem1);//栈区
26 printf("stack_adder: %p\n", &heap_mem2);
27 printf("stack_adder: %p\n", &heap_mem3);
28 printf("stack_adder: %p\n", &heap_mem4);
29
30 for(int i = 0; i < argc; i++)
31 {
32 printf("argv[%d]: %p\n", i, argv[i]);//命令行参数
33 }
34
35 for(int i = 0; env[i]; i++)
36 {
37 printf("env[%d]: %p\n", i, env[i]);//环境变量
38 }
39 return 0;
40 }
我们发现打印出来的结果刚好符合上面的图,我们要是能够仔细的观察也会发现堆和栈有两个箭头,这个箭头代表着堆栈增长方向,堆栈相对而生,堆向高地址方向增长,栈向低地址方向增长,在上面的实验中我们在每个区域定义了多个变量,就是为了验证这个结论
同时,我们还会发现命令行参数和环境变量,在程序地址空间较高的地址处,而且环境变量的地址是高于命令行参数的。
在堆区的地址增长趋势来看,我们使用malloc申请空间只是申请了10个字节,可是它的每个变量的间隔是20个字节而不是10个字节这是为什么呢?
我们知道malloc函数它有一个参数是开辟空间的大小,并且会返回我们申请的空间的起始地址,可是free只有一个参数,是要释放的空间的起始地址,但是并没有显式的传入要释放空间的大小。
我们前面已经了解了malloc和free的基本信息,我们还是使用上面的例子。malloc申请空间时,假如我们要申请10字节的空间,C标准库实际上会多申请空间,多出来的空间用来记录堆的属性信息如:申请空间的时间,申请空间的大小,权限等等……
我们还是以这个图片讲解,说明每个区间的具体含义。
我们以前学过程序地址空间是从0x0000 0000 开始一直到0xFFFF FFFF
但是正文代码的起始位置并不是从全0开始的,他们中间是有间隔的。
接下来是正文代码:正文代码一定是二进制代码,因为这里说的并不是真实的内存,而是虚拟内存,它一定是已经编译之后的结果了。
在正文代码的end之前,它还有一小块区域是字符常量区,它是只读的。它内部存放的是什么?
它内部存储的是字面常量例如"Hello World" 1 2 'a'等等
所有的字面常量都是硬编码进代码的,所以它存在正文代码区
已初始化全局数据区:顾名思义它是用来存放初始化过的全局变量,但是它并不仅仅可以存储初始化的全局变量,它还可以存储static修饰的初始化的静态变量
未初始化全局数据区:与已初始化全局数据区具有相似的定义,只不过它存储的都是未初始化的。
堆:我们应该是相当的熟悉,平时动态开辟的空间都是从这里来的,这也是我们能够自主操纵的空间。
栈:也是我们常说的概念,栈的空间我们并不能操控,它只有在程序运行时才会开辟空间
在这幅图片中,我们发现它将程序地址空间简单分为两部分,【0~3GB】是用户空间
【3GB~4GB】是内核空间
回到我们打印出的例子。这里打印出的地址都不是物理地址,都是虚拟地址。
打印全部都是进程在打印地址,它们是程序运行之后打印的。
2、什么是地址空间?
所谓进程地址空间(process address space),就是从进程的视角看到的地址空间,是进程运行时所用到的虚拟地址的集合。
我们看这个定义有一些懵,也不知道具体的概念。
我们还是看一个例子:
有一个美国的富豪,他具有10亿美元的家产,同时他有三个私生子a,b,c三个私生子并不知道彼此的存在。富豪分别对三个私生子画大饼将来10亿家产全部给他,富豪给每个儿子都画了一张大饼但是他不能拿出30亿分别给每个儿子10亿,私生子向富豪要钱,富豪就给钱,但是私生子们想要10亿美元,但是富豪就是不给,同时富豪也要管理这些大饼,防止出现问题。
富豪就相当于——操作系统
私生子——进程
这里所谓的饼,在现实中是物理内存
这里画的饼就是虚拟内存。
既然这些饼要被管理起来,我们就会想到操作系统的管理理念:先描述,再组织
那么既然要描述虚拟内存也就要有相应的结构体和相应的数据结构将结构体与相应的进程关联起来。
在以前,进程是直接访问物理内存的,这种访问是相当的不安全。
我们假如有三个进程直接被加载到物理内存中,如果我们有一个进程出现野指针,将进程2的某些数据直接修改了,就有可能导致进程2挂掉。假如进程2是用来保存密码的,我们写一个程序,将指针指向进程2的密码,那么我们就会获取到密码,因此这种访问方式并不安全。
现代计算机提出了新的方式
我们知道每一个进程都会有一个PCB结构体task_struct,而task_struct中有一个指针指向该进程的进程地址空间,然后通过映射机制,将虚拟内存映射到物理内存。
虽然这样还是会访问物理地址,但是我们可以在映射那里加入合法性检验。
如果我的虚拟地址是非法的,就会禁止映射。
区域划分
我们会在一个范围里定义出start 和 end 并且规定每个的区间就是我们上面的那幅图
这样就划分出来了不同的区间。
每一个进程都有自己的虚拟地址空间
各个区域的划分都是相对的(堆和栈的空间是可以增长的)
空间的增长和缩小就是对对应区间的start和end的标记值+-特定的范围
其中Linux下的进程地址空间就是一个结构体struct mm_struct
这里截取了一部分内容
这里所谓的映射机制实际上就是页表
地址空间和页表是每一个进程都私有一份的
每一个进程的页表映射的是物理内存的不同区域就能够做到,进程之间不会互相干扰保证进程的独立性。
我们还是回到最初的话题为什么g_value的地址相同但是值并不相同
我们前面了解过:子进程会继承很多父进程的属性
刚开始父子进程的g_value都指向同一块物理内存,因为是从父进程拷贝过来,然后进行个性化的修改,所以父子进程的g_value所在的虚拟地址是相同的。
当子进程尝试修改g_value时,为了保证进程独立性,操作系统会为子进程的g_value重新开辟空间 ,虚拟地址是不受影响的,但是这个虚拟地址被映射到物理地址的其它位置,与父进程的g_value的物理地址不相同。这叫做写时拷贝。
pid_t id = fork();
return会执行两次,return的本质是对id进行写入
会发生写时拷贝:父子进程各自在物理内存中有属于自己的变量空间,只不过在用户层使用同一个变量(虚拟内存)来标识。
程序编译形成可执行程序时,没有加载到内存时,程序的内部就已经有地址了。
地址空间需要OS和编译器都要遵守,从而形成不同的数据分区。
他们采用和Linux一样的编址方式,给每一个变量,每一行代码进行编址也就是说每一个字段都已具有虚拟地址。
将程序加载到内存,并不仅仅是将代码加载进去,虚拟地址也会被加载进去。
也就是说在物理内存中不但有我的代码,而且也会有我的虚拟地址。
程序内部的地址,依旧采用的是编译器编译好的虚拟地址。并且加载到内存时每行代码每个变量都会具有物理地址。
当CPU读取到指令时,指令内部也会有地址,指令内部的地址是虚拟地址还是物理地址呢?
答案是虚拟地址,因为我们将虚拟地址映射到物理地址,CPU读取指令时,因为我们编写的代码一定会有函数调用,函数调用的本质是存储了该函数的地址,这里的地址是虚拟地址。而CPU读取时,代码内部的地址还是虚拟地址,我们并没有对代码内部的地址进行修饰,所以CPU读取的一定是虚拟地址。
而页表它被分为两个部分
页表一面存放的是每一个变量和每一个函数的虚拟地址,另一面存放的是映射到物理内存的地址
总结
以上就是今天要讲的内容,本文仅仅简单介绍了程序地址空间,还有很多的细节并没有讲清楚。