目录
1、程序地址空间
程序段(Text):程序代码在内存中的映射,存放函数体的二进制代码。
初始化过的数据(Data):在程序运行初已经对变量进行初始化的数据。
未初始化过的数据(BSS):在程序运行初未对变量进行初始化的数据。
栈 (Stack):存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。
堆 (Heap):存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。
1.1 研究背景
- kernel 2.6.32
- 32位平台
1.2 程序地址空间
空间布局图:
为了加深理解,可以使用代码进行大概测试
1.3 空间布局图代码测试
验证一:
#include<stdio.h> #include<stdlib.h> int un_g_val; int g_val = 100; int main(){ printf("main: %p\n",main);//正文代码地址 printf("init: %p\n",&g_val);//初始化数据地址 printf("uninit: %p\n",&un_g_val);//未初始化数据地址 char *p1 = (char*)malloc(16); char *p2 = (char*)malloc(16); char *p3 = (char*)malloc(16); char *p4 = (char*)malloc(16); printf("heap1: %p\n",p1);//堆区地址 printf("heap2: %p\n",p2);//堆区地址 printf("heap3: %p\n",p3);//堆区地址 printf("heap4: %p\n",p4);//堆区地址 printf("stack: %p\n",&p1);//栈区地址 printf("stack: %p\n",&p2);//栈区地址 printf("stack: %p\n",&p3);//栈区地址 printf("stack: %p\n",&p4);//栈区地址 return 0; }
运行结果:
[customer@VM-4-10-centos 15Lesson]$ ./myproc main: 0x40057d init: 0x60103c uninit: 0x601044 heap1: 0x1d25010 heap2: 0x1d25030 heap3: 0x1d25050 heap4: 0x1d25070 stack: 0x7ffebfb2c638 stack: 0x7ffebfb2c630 stack: 0x7ffebfb2c628 stack: 0x7ffebfb2c620
验证二:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> int un_g_val; int g_val = 100; int main(int argc,char *argv[],char *env[]){ printf("code addr: %p\n",main); printf("init global addr: %p\n",&g_val); printf("uninit global addr: %p\n",&un_g_val); static int test = 10; char *heap_mem = (char*)malloc(10); printf("heap addr: %p\n\n",heap_mem); printf("test stack addr: %p\n",&test); printf("stack addr: %p\n",&heap_mem); 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; }
运行结果:
[customer@VM-4-10-centos adress]$ make gcc -o myproc myproc.c -std=c99 [customer@VM-4-10-centos adress]$ ./myproc code addr: 0x40057d init global addr: 0x60103c uninit global addr: 0x601048 heap addr: 0xcd0010 test stack addr: 0x601040 stack addr: 0x7ffcacd073f0 argv[0]: 0x7ffcacd0874a env[0]: 0x7ffcacd08753 env[1]: 0x7ffcacd08769 env[2]: 0x7ffcacd08781 env[3]: 0x7ffcacd0878c env[4]: 0x7ffcacd0879c env[5]: 0x7ffcacd087aa env[6]: 0x7ffcacd087cd env[7]: 0x7ffcacd087e0 env[8]: 0x7ffcacd087ee env[9]: 0x7ffcacd08836 .......................
由运行结果可看出:
- 正文代码地址在低地址处(字面常量硬编码进入代码区,都是只读的)
- 初始化数据地址在正文代码地址之上(静态变量,初始化全局变量)
- 未初始化数据地址在初始化数据地址之上(未初始化静态变量,未初始化全局变量)
- 其次是堆,堆是由低地址向高地址进行开辟空间,向高地址增长
- 中间相差巨大的落空区域含共享区(后续学习)
- 最后是栈区,栈是由高地址到低地址进行使用,向低地址增长
我们所看到的地址,是真实存在内存上的物理地址吗?答案是:不是
向堆区申请空间时,并不是真实的申请所需要的字节空间,而会有标准库进行多申请字节空间,用于记录申请空间的属性信息(申请时间,申请大小,访问权限等信息)(cookie数据),free在释放空间时,便可根据起始位置获取属性信息,得到需要释放空间的大小
1.4 用户空间及内核空间
32位环境下:
Linux的虚拟地址空间范围为0~4G,Linux内核将这4G字节的空间分为两部分, 将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间
注: 1.这里是32位内核地址空间划分,64位内核地址空间划分是不同的
1.5 Linux及windows对比
上述代码在windows系统下编译器所打印出的结果跟Linux并不一样
如下visual studio2022运行结果:
code addr: 002012E9 init global addr: 0020A000 uninit global addr: 0020A150 heap addr: 0062A000 test stack addr: 0020A004 stack addr: 0030FD00 argv[0]: 00629B20 env[0]: 0062ABE8 env[1]: 0062AC38 env[2]: 006225A8 env[3]: 00622600 env[4]: 00622670 env[5]: 006226D8 env[6]: 00622740 env[7]: 006227A0 env[8]: 006227F0 env[9]: 00622840
因此上面结论,默认只在Linux下有效
1.6 分析Linux下虚拟地址及物理地址
代码分析:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/types.h> int g_val = 100; int main(){ pid_t ret = fork(); if(ret < 0){ perror("fork"); exit(-1); }else if(ret == 0){ int cnt = 0; while(1){ printf("I am child. pid = %d, ppid = %d, g_val = %d, &g_val = %p\n\n"\ ,getpid(),getppid(),g_val,&g_val); sleep(1); ++cnt; if(cnt == 5){ g_val = 200; printf("child change g_val 100 -> 200 success\n"); } } }else{ while(1){ printf("I am father. pid = %d, ppid = %d, g_val = %d, &g_val = %p\n\n"\ ,getpid(),getppid(),g_val,&g_val); sleep(1); } } return 0; }
运行结果:
[customer@VM-4-10-centos virtual]$ ./myproc I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 100, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 100, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 100, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 100, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 100, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c child change g_val 100 -> 200 success I am child. pid = 20976, ppid = 20975, g_val = 200, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 200, &g_val = 0x60106c I am father. pid = 20975, ppid = 32118, g_val = 100, &g_val = 0x60106c I am child. pid = 20976, ppid = 20975, g_val = 200, &g_val = 0x60106c
结果分析:
1.进程具有独立性,子进程将数据做修改,即使是全局变量,也并不影响父进程
2.可观察到,数据修改,但其地址是一样的,确互不影响
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在用C/C++等几乎所有语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
OS必须负责将 虚拟地址(也叫线性地址) 转化成 物理地址 。
2、进程地址空间
2.1 地址空间概念
上述打印出的地址,是程序加载到内存形成进程之后打印出来的,因此,以之前说‘程序的地址空间’是不准确的,准确的应该说成进程地址空间。
地址空间(address space)表示任何一个计算机实体所占用的内存大小。比如外设、文件、服务器或者一个网络计算机。地址空间包括物理空间以及虚拟空间
内核中的地址空间,本质将来也一定是一个数据结构,将来一定要和一个特定的进程关联起来!要访问物理地址,虚拟地址需要先进行映射!非法请求便会禁止映射,保护了物理内存!
地址空间是一种内核数据结构mm_struct,它里面至少有各个区域的划分,而进程控制块PCB包含着进程地址空间数据结构的指针,如图
2.2 地址空间及页表映射分析
地址空间及页表(用户级页表)是每一个进程都所私有的一份
只要保证,每一个进程的页表,映射的是物理内存不同的区域,就能做到,进程之间不会互相干扰,保证了进程的独立性!!(哪怕每个进程的地址空间是一样的,但是只要页表不同,就保证了其映射到物理内存不同的区域)
2.3 写时拷贝及虚拟地址再次分析
因此可以解释上述1.6 分析Linux下虚拟地址及物理地址代码及结果:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/types.h> int g_val = 100; int main(){ pid_t ret = fork(); if(ret < 0){ perror("fork"); exit(-1); }else if(ret == 0){ int cnt = 0; while(1){ printf("I am child. pid = %d, ppid = %d, g_val = %d, &g_val = %p\n\n"\ ,getpid(),getppid(),g_val,&g_val); sleep(1); ++cnt; if(cnt == 5){ g_val = 200; printf("child change g_val 100 -> 200 success\n"); } } }else{ while(1){ printf("I am father. pid = %d, ppid = %d, g_val = %d, &g_val = %p\n\n"\ ,getpid(),getppid(),g_val,&g_val); sleep(1); } } return 0; }
分析如下图,使用系统调用接口创建新的进程时,fork后的数据代码,父子进程将会同时执行,同时增加新的进程控制块,父子进程通过刚开始相同的页表指向相同的物理空间,其所使用的进程地址空间对应的位置也是相同的,父子进程指向同一个g_val,因此,父进程和子进程对应的g_val的地址是相同的,但是,当子进程尝试修改g_val变量时,为保证进程的独立性,操作系统识别到当前子进程通过页表找到g_val,想修改g_val,此时,操作系统会重新开辟一段空间,将上述值拷贝下来,修改映射关系,因此使用不同的物理内存地址,互不影响,互相独立
父子进程创建时使用相同的虚拟地址,而进行修改时,经操作系统识别,重新复制一份,并开辟新的空间,经过页表映射的是不同的物理地址,此时修改的是不同的物理地址的数据,其虚拟地址不受影响,这种策略叫做写时拷贝。
写时拷贝(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
2.4 fork后ret保存不同的返回值分析
代码:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/types.h> int main(){ pid_t ret = fork(); if(ret < 0){ perror("fork"); exit(-1); }else if(ret == 0){ int cnt = 0; while(1){ printf("I am child. pid = %d, ppid = %d\n"\ ,getpid(),getppid()); sleep(1); } }else{ while(1){ printf("I am father. pid = %d, ppid = %d\n"\ ,getpid(),getppid()); sleep(1); } } return 0; }
分析:
fork函数在return时之前,会创建新的进程控制块,而此时return便会同时执行两次,而return的本质,就是对父子进程ret变量进行写入,此时便会进行写时拷贝,父子进程各自拥有属于自己的物理空间,虽然对应虚拟地址相同,但其对应的物理地址的值不同,只不过在用户层我们用同一个变量(虚拟地址)来标识了
3、拓展及总结
3.1 程序内部地址及指令内部地址
当我们的程序,在编译的时候,形成可执行程序(可重定向二进制文件)的时候,没有被加载到内存时,我们程序内部,有地址吗?
[customer@VM-4-10-centos adress]$ objdump myproc -afh myproc: file format elf64-x86-64 myproc architecture: i386:x86-64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x0000000000400490 Sections: Idx Name Size VMA LMA File off Algn 0 .interp 0000001c 0000000000400238 0000000000400238 00000238 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 1 .note.ABI-tag 00000020 0000000000400254 0000000000400254 00000254 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .note.gnu.build-id 00000024 0000000000400274 0000000000400274 00000274 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 3 .gnu.hash 0000001c 0000000000400298 0000000000400298 00000298 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .dynsym 00000078 00000000004002b8 00000000004002b8 000002b8 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 5 .dynstr 00000046 0000000000400330 0000000000400330 00000330 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 6 .gnu.version 0000000a 0000000000400376 0000000000400376 00000376 2**1 CONTENTS, ALLOC, LOAD, READONLY, DATA
通过上述命令查看可知,VMA(Virtual Memory Address)虚拟存储地址可以看出:
其实已经有地址了,程序需要链接动静态库,而使用动静态库函数就是用函数的地址进行调用,可执行程序其实在编译的时候,内部就已经有了地址!
地址空间不要仅仅理解成为是OS内部要遵守的,其实编译器也要遵守!!!即编译器编译代码时,就已经给我们形成了各个区域(代码区、数据区),并且,采用和Linux内核中一样的编址方式,给每一个变量,每一行代码都进行了编址,故,程序在编译的时候,每一个字段早已经具有了一个虚拟地址。
程序内部的地址,依旧用的是编译器编译好的虚拟地址,当程序加载到内存时,每行代码,每个变量便具有了一个物理地址(外部地址)
3.2 CPU执行指令分析
CPU执行指令步骤解释:
- 由上述可知,一段代码被编译生成可重定向二进制可执行文件时,每行代码、每个变量便根据Linux内核编址方式一样,就形成了各个区域,并进行了编址,即虚拟内存地址。
- 运行可执行文件时,构建PCB,同时进程控制块包含了进程地址空间的数据结构,并且使用代码编译好的VMA划分填充mm_struct
- 地址空间形成,便由操作系统使用虚拟地址使用页表映射物理地址,此时,cpu便可以通过物理地址,找到对应需要执行的指令
- cpu从物理地址读取指令时,指令内部也有地址(如,代码内部函数做跳转时,是按照编址方式即虚拟地址VMA进行编址的,使用的时虚拟地址方案,当加载至内存时,并没有更改指令内容,所以CPU读取指令内部为虚拟地址),指令内部是虚拟地址,执行完当前指令,便会跳转至进程地址空间虚拟地址,根据跳转的虚拟地址处代码再次找到页表映射该代码的物理地址,循环往复,执行该进程。
3.3 总结—为什么要有地址空间?
第一个原因:
- 凡是非法的访问或者映射,操作系统都会识别到,并终止你这个进程!
- 所有的进程崩溃,就是进程退出!页表还有对其映射关系维护的读写权限以及其他权限,因此是操作系统杀掉了这个进程。
- 地址空间的存在,有效拦截我们对物理内存空间访问的本质就是有效的保护了物理内存。
- 因为地址空间和页表是OS创建并维护的,也就意味着凡是想使用地址空间和页表进行映射,也一定要在OS的监管下进行访问!
- 也有效保护了物理内存中的所有合法数据,包括各个进程,以及内核相关的有效数据。
第二个原因:
- 因为有地址空间及也表映射的存在,我们的物理内存中,可以对未来的数据进行在物理内存中任意位置的加载。
- 正是由于其存在,物理内存的分配 和 进程的管理,可以做到互不相干!!
- 物理内存分配在Linux内核中映射内存管理模块,而进程的管理映射进程管理模块,就完成了解耦合!
- 所以,我们在C、C++等语言上new、malloc空间的时候,本质实在虚拟地址空间内存申请的。
- 如果申请的是物理内存,不立马使用,便会产生空间的浪费!所以,本质上,(因为有了地址空间的存在,上层申请空间,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,而当你真正对物理空间访问的时候,才执行相关的管理算法,帮你申请内存,构建也表映射关系),然后,再让你进行内存的访问!其中括号内容是由操作系统自动完成,用户,包括进程,完全不知道!(涉及技术:缺页中断)(分配内存采用了——延迟分配策略,提高整机的效率)
第三个原因:
- 因为在物理内存中理论上可以任意位置加载,那么物理内存中几乎所有的数据和代码在内存中是乱序的!无疑会增加cpu访问时间,但是,因为页表的存在,它可以将地址空间的虚拟地址和物理地址进行映射,那么是不是在进程视角所有的内存分布,都可以是有序的!!
- 地址空间 + 页表 的存在,可以将物理内存空间的分布有序化。
- 地址空间是OS给进程画得大饼,结合上述,进程要访问的物理内存中的数据代码,并没有在物理内存中,同样的,也可以让不同的进程映射到不同的物理内存,便很容易做到进程独立性的实现!!
- 进程的独立性可以通过地址空间 + 页表的方式实现
总结:
因为有地址空间的存在,每个进程都认为自己拥有4GB(32位环境下)空间,并且各个区域是有序的,进而可以通过页表映射到不同的区域,来实现进程的独立!(每个进程都认为自己独占内存,并不知道其他进程的存在)
3.4 重新理解挂起
加载本质就是创建进程,那么是不是必须了立马把所有的程序代码和数据加载到内存中,并创建内核数据结构建立映射关系?
答案是,不是
在最极端的情况下,甚至只有内核结构被创建出来了!这样的进程就赋予一个状态,就是新建状态。理论上,可以实现对程序的分批加载!如大型提及游戏,加载内存就采用了分批加载!
加载的本质就是换入,既然可以分批加载,执行完的代码,不再使用,就可以分批换出!
甚至这个进程不会短时间再次被执行,比如阻塞了,此时这个进程的数据和代码便会被换出到磁盘,这个状态便是挂起!
全局页表目录存放在进程描述符task_struct的域mm所指向的内存描述符mm_struct的与pgd里。 windman521 2011-12-21 每个进程都有自己的页表,保存在task_struct中。
页表映射的时候,不仅仅映射的是内存,磁盘中的位置,也是可以映射的!