该吃吃,该喝喝,遇事儿别往心上隔😎
一、虚拟地址空间
1.虚拟地址的引出(看不到物理地址,只能看看虚拟地址喽)
1 #include <stdio.h> 2 #include <unistd.h> 3 4 5 int global_value = 100; 6 int main() 7 { 8 pid_t id = fork(); 9 if(id < 0) 10 { 11 printf("fork error\n"); 12 return 1; 13 } 14 else if(id == 0) 15 { 16 int cnt = 0; 17 while(1) 18 { 19 printf("我是子进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value); 20 sleep(1); 21 cnt++; 22 if(cnt == 10) 23 { 24 global_value = 300; 25 printf("子进程已经更改了全局的变量啦..........\n"); 26 } 27 } 28 } 29 else 30 { 31 while(1) 32 { 33 printf("我是父进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value); 34 sleep(2); 35 } 36 } 37 sleep(1); 38 return 0; 39 }
1.
从程序的运行结果可以看出一些端倪,就是一个全局变量在地址并未改变的情况下,竟然出现了不同的值,这说明什么呢?首先一个变量肯定是只能有一个值的,但是地址只有一个,而变量的值却出现了两个,那么就必须说明一个结论,现在在内存中应该出现了两个变量了,因为一个变量是绝对不可能出现两个值的,所以我们可以推导出的结论就是内存中现在一定出现了两个全局变量global_value。
2.
我们继续推导,一个变量在内存中绝对是只能有一个地址的,这是铁定的事实,也是一定正确的结论,那么我们打印出来的地址就肯定不是真实的地址,因为真实的地址只能对应真实的一个变量,也就是说,其实现在的内存中有两个全局变量,分别属于子进程和父进程两个进程,并且他们都拥有自己的物理内存地址,但是他们两个变量共用了一个虚拟地址,运行结果打印出来的地址其实就是这个虚拟地址,所以之前我们所学习到的C/C++程序中所看到的所有地址,实际上都是虚拟的地址!!!,而不是真正在内存中操作系统分配给变量的实实在在的物理地址。
真正的物理地址,用户一概看不到,这些物理地址由OS统一进行管理!!!
3.
虚拟地址还有另外两个名字,分别叫做线性地址和逻辑地址
2.虚拟地址空间布局(系统角度:六个段。语言角度:五个段。)
1.
在内存中是以字节为单位存储信息的,每一个字节单元都会有唯一一个内存物理地址,又叫实际地址、绝对地址或二进制地址,物理地址空间从0开始编号,每次按顺序增加1,所以虚拟地址空间是呈线性增长的。
2.
在32位操作系统下,理论上应该有2^32次方个物理地址,也就是4×2的30次方个地址,1G=1024MB,1MB=1024KB,1KB=1024byte,1byte=8bit,1bit=1二进制位,所以在32位操作系统下,分配给进程的虚拟地址空间大小为4G。
3.代码段(又称为文本段、程序代码区,公共代码区):
存放函数体的二进制代码,一个C程序由多个函数体构成,C程序的执行其实就是函数之间的相互调用,换种说法就是,存放程序执行的机器指令(二进制指令),代码段是只读的,这样的好处就是可以防止其他进程恶意修改正在运行的进程的二进制指令,程序的执行就是从代码段中的main函数开始执行,程序运行结束后由操作系统回收此区域。
4.栈段(又称为堆栈区):
栈是向下增长的,与堆段的增长和收缩方向正好相反,函数的局部变量、返回值,形参等都在栈区上,函数调用时开辟的栈帧就是在栈段上。
5.堆段:
程序运行时动态申请的内存空间就是在堆段上开辟的,由开发人员手动申请,手动释放,若不手动释放,在程序结束之后由操作系统回收,例如malloc或new等申请的内存空间就在堆段上,堆的内存分配属于动态分配,在未显示调用delete或free释放申请的空间时,其生命周期为进程的生命周期。
6.未初始化数据段(又称为BSS段,Block Started by Symbol,以符号开头的块):
包含所有未初始化的全局变量和局部static变量,此段中的所有变量都由零或者空指针初始化,该区域大小在编译阶段就已确定,运行时的内存分配属于静态内存分配,该区域有读写权限,所以此段中的变量的值在程序运行期间可以任意改变。
7.已初始化数据段(又称为DS段,Data Segment,数据段):
用于存储初始化的全局变量、static变量、对象、也包括字符串、数组等常量,但基本类型的常量不包含在其中,他们会被编译成指令存放于代码段中,段大小在编译时就已确定,所以内存的分配也属于静态内存分配。
数据段有只读区域和读写区域,分别是.data和.rodata,.data段保存的是哪些已经初始化了的全局变量或局部静态变量,.rodata段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量,有的人又把.rodata段称为常量区,《程序员的自我修养》书上称之为只读数据段。
8.有些人把已初始化数据段和未初始化数据段合起来叫做静态区或全局区或直接叫数据段,这也很好理解,因为这两个段里面的变量都是静态或全局变量,所以叫做静态区或全局区也理所应当。(多提一句,static修饰的全局变量会由原来的外部链接属性改为内部链接属性)
9.内核空间:
非常抱歉,我当前的这个水平无法给您讲解透彻内核空间的知识,下面的知识内容是我从腾讯云开发社区找到的内容,如果您能看懂,您可以详细了解一下下面的内容,对于我拙劣的编程水平,再次深表歉意,希望我能快快成长起来吧!
映射段也被称为共享区)
10.程序在未运行之前,虚拟地址空间中是没有栈区和堆区的。
3.感性理解一下虚拟地址空间(操作系统画给进程的大饼)
1.进程它会认为自己是独占系统资源的(事实上并不是)
下面讲一个故事来让大家更好的理解究竟什么是虚拟地址空间!!!
在美国有一个名叫peter的大富翁,他手上有10亿美金,但是呢,他没有结婚,因为他总觉得总有刁民想害朕,他睡觉都需要保镖在门外守护着他,防止意外事件发生,不结婚的peter就在外面乱搞,所以他有3个私生子,但是这三个私生子互相之间是不知道对方的存在的,他们都以为自己是peter的唯一一个儿子,son1是某个工厂的老板,son2是某个金融领域公司的CEO,son3在MIT就读,peter为了让这三个孩子能够认真学习工作,就开始画饼了,画饼分好饼和坏饼,peter分别单独见了这三个孩子,画的饼都是一样的,说,儿子呀,你好好干,爸就你这么一个儿子,等将来爸驾鹤西去的时候,我手上的十亿美金就全是你的了,petr对三个儿子画了相同的饼,每个儿子都会和peter要钱,比如大儿子要个几十万美金,要给员工发工资,二儿子要个几万美金想买块手表,三儿子要个几千美金想买双鞋什么的,但peter知道,这几个儿子是不会直接张口要十亿美金的,因为这是peter画给他们各自的饼,如果这个饼很容易实现,那怎么称之为画大饼呢?所以这几个儿子,平常就会不定期的问peter要上一些钱,但绝不会张口要十亿美金。
2.
故事讲完了,peter其实相当于我们的操作系统,答应给儿子的十亿美金就是虚拟地址空间,儿子就相当于进程,平常给到儿子手里的美金才是实际分配给进程的物理内存空间。
3.
peter画饼动动嘴皮子就可以了,那操作系统给进程是如何画饼的呢?操作系统其实是通过一个叫mm_struct的数据结构来给操作系统画饼的,这个数据结构定义出来的对象其实就是操作系统给进程分配的虚拟地址空间,每个进程都以为他自己是独占4G的内存空间的,但是实际上并不是这样,操作系统只会分配给进程他实际需要的空间大小,而不是说直接把4G的空间全都给进程,这4GB大小只是一个范围而不是空间,也就是一个区域性的概念。
4.
虚拟地址空间的本质就是内核的一种数据结构,叫做mm_struct结构体,这个结构体是描述虚拟地址空间的最核心的结构。
4.mm_struct内部结构(详谈OS画的大饼)
1.
下面就是mm_struct中各个区域的划分,每个区域都有自己的区域起始地址和区域结束地址,但这些地址都是虚拟地址,这都是操作系统画给进程的大饼。
2.
代码区,BSS段,数据段等都是在编译阶段固定好空间大小的,但栈和堆的区域是可以在进程运行的过程当中不断变化的,所以这两个区域的空间大小是随时进行调整的,区域调整的本质就是修改区域的起始地址和结束地址。
3.
定义局部变量,调用函数,malloc、new开辟空间本质上就是在扩大栈区或堆区
函数调用结束,free、delete释放空间本质上就是在缩小栈区或堆区
4.每个进程都要有自己的虚拟地址空间,这个虚拟地址空间是4GB大小,如果是64位操作系统,自然虚拟地址空间大小就是16GB,因为2 ^ 64次方就是2 ^ 32 × 2 ^ 32也就是4GB×4GB=16GB。(左边算出来是错误的,这是网络上很多人都容易踩的坑,不是那么算的,应该是4GB×42亿九千万的大小,内存非常非常的大,这里说的是理论值,实际计算机的地址总线最多是48根)
下面是task_struct当中的两个指向进程对应的虚拟地址空间的两个指针成员。
task_struct实际上就是通过这两个成员来和mm_struct结构体进行联系,每一个进程都会有唯一的mm_struct结构体
//关于进程的地址空间,指向进程的地址空间。(链表和红黑树) struct mm_struct *mm, *active_mm;
下面是mm_struct结构体内容。
struct mm_struct { //指向线性区对象的链表头 struct vm_area_struct * mmap; /* list of VMAs */ //指向线性区对象的红黑树 struct rb_root mm_rb; //指向最近找到的虚拟区间 struct vm_area_struct * mmap_cache; /* last find_vma result */ //用来在进程地址空间中搜索有效的进程地址空间的函数 unsigned long (*get_unmapped_area) (struct file *filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags); unsigned long (*get_unmapped_exec_area) (struct file *filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags); //释放线性区时调用的方法, void (*unmap_area) (struct mm_struct *mm, unsigned long addr); //标识第一个分配文件内存映射的线性地址 unsigned long mmap_base; /* base of mmap area */ unsigned long task_size; /* size of task vm space */ /* * RHEL6 special for bug 790921: this same variable can mean * two different things. If sysctl_unmap_area_factor is zero, * this means the largest hole below free_area_cache. If the * sysctl is set to a positive value, this variable is used * to count how much memory has been munmapped from this process * since the last time free_area_cache was reset back to mmap_base. * This is ugly, but necessary to preserve kABI. */ unsigned long cached_hole_size; //内核进程搜索进程地址空间中线性地址的空间空间 unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */ //指向页表的目录 pgd_t * pgd; //共享进程时的个数 atomic_t mm_users; /* How many users with user space? */ //内存描述符的主使用计数器,采用引用计数的原理,当为0时代表无用户再次使用 atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */ //线性区的个数 int map_count; /* number of VMAs */ struct rw_semaphore mmap_sem; //保护任务页表和引用计数的锁 spinlock_t page_table_lock; /* Protects page tables and some counters */ //mm_struct结构,第一个成员就是初始化的mm_struct结构, struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung * together off init_mm.mmlist, and are protected * by mmlist_lock */ /* Special counters, in some configurations protected by the * page_table_lock, in other configurations by being atomic. */ mm_counter_t _file_rss; mm_counter_t _anon_rss; mm_counter_t _swap_usage; //进程拥有的最大页表数目 unsigned long hiwater_rss; /* High-watermark of RSS usage */、 //进程线性区的最大页表数目 unsigned long hiwater_vm; /* High-water virtual memory usage */ //进程地址空间的大小,锁住无法换页的个数,共享文件内存映射的页数,可执行内存映射中的页数 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; unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */ struct linux_binfmt *binfmt; cpumask_t cpu_vm_mask; /* Architecture-specific MM context */ mm_context_t context; /* Swap token stuff */ /* * Last value of global fault stamp as seen by this process. * In other words, this value gives an indication of how long * it has been since this task got the token. * Look at mm/thrash.c */ unsigned int faultstamp; unsigned int token_priority; unsigned int last_interval; //线性区的默认访问标志 unsigned long flags; /* Must use atomic bitops to access the bits */ struct core_state *core_state; /* coredumping support */ #ifdef CONFIG_AIO spinlock_t ioctx_lock; struct hlist_head ioctx_list; #endif #ifdef CONFIG_MM_OWNER /* * "owner" points to a task that is regarded as the canonical * user/owner of this mm. All of the following must be true in * order for it to be changed: * * current == mm->owner * current->mm != mm * new_owner->mm == mm * new_owner->alloc_lock is held */ struct task_struct *owner; #endif #ifdef CONFIG_PROC_FS /* store ref to file /proc/<pid>/exe symlink points to */ struct file *exe_file; unsigned long num_exe_file_vmas; #endif #ifdef CONFIG_MMU_NOTIFIER struct mmu_notifier_mm *mmu_notifier_mm; #endif #ifdef CONFIG_TRANSPARENT_HUGEPAGE pgtable_t pmd_huge_pte; /* protected by page_table_lock */ #endif /* reserved for Red Hat */ #ifdef __GENKSYMS__ unsigned long rh_reserved[2]; #else /* How many tasks sharing this mm are OOM_DISABLE */ union { unsigned long rh_reserved_aux; atomic_t oom_disable_count; }; /* base of lib map area (ASCII armour) */ unsigned long shlib_base; #endif };
二、为什么要存在虚拟地址空间?(页表的引出)
1.虚拟存储技术是操作系统管理进步的体现(可怜的进程不知道自己已经被画饼了)
虚拟内存是计算机系统内存管理的一种技术。它的优点是使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)。而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
2.进程访问物理空间的方式更为安全(页表拦截非法请求)
1.
进程在和外部设备IO的过程,所占内存大小基本为4KB,4096字节的大小,也就是4096个连续的内存物理地址,这部分区域被称之为页page。
2.
进程无法直接接触物理内存,只能通过虚拟地址依靠页表映射物理地址的方式,来间接访问物理内存。
3.如果让进程直接访问物理内存,可能出现恶意进程修改物理内存的事件发生,并且如果进程越界访问,这也可能修改其他的进程,对其他进程进行非法操作,这是非常不安全的。
4.
因为虚拟地址是包含所有的地址的,也就是4GB的空间,虽然是虚拟的,但是进程可以使用呀,所以如果进程在虚拟地址中访问了某个本不该属于当前进程的地址,接下来在通过页表映射到物理地址的这个阶段中,页表就会拦截进程非法访问地址的请求。
5.
正是因为页表的存在,致使所有的进程访问的虚拟地址都会映射到合法的物理内存上,想象一下,如果进程直接访问的是物理内存,那只要进程中出现野指针或越界访问的问题,操作系统很有可能直接就挂了,计算机也就跟着崩溃了,所以这时候虚拟地址空间和页表也就随之而生了。
3.进程之间代码和数据能够解耦,保证进程独立性的特征(写时拷贝:什么数据被修改,就拷贝什么数据)
下面的讲解可以更加具体的解惑,在文章开头时,代码运行结果引出的虚拟地址问题
1.
当进程的任何一方,尝试写入某些数据时,操作系统会先进行数据的拷贝,然后更改页表映射,最后再让尝试写入的进程对数据进行修改,操作系统这样的技术被称之为写时拷贝。
2.
所以操作系统为了保证进程的独立性,通过虚拟地址空间,再到页表,让不同的进程使用的同一虚拟地址,能够映射到不同的物理内存处。
3.
每个进程都有自己独立的内核数据结构,也就是独立的PCB,独立的虚拟地址空间,独立的页表,操作系统通过写时拷贝这种技术,使得进程之间的数据也达到独立。
4.
进程=内核数据结构+进程对应的代码和数据,现在内核数据结构是独立的,进程对应的代码和数据起始也是独立的,(如果数据不被写入,进程之间也只是以只读的方式共享数据,代码由于不会被修改,所以父子进程代码也是以只读的方式共享,如果是两个毫不相干的进程,那他们的代码其实也是独立的,所以进程对应的代码和数据可以看成是独立的),当然进程也就完完全全是独立的啦!!!
多提一嘴,代码是代码(英文),数据是数据(数字),他们组成程序。
5.
写时拷贝是一种可以推迟甚至免除拷贝数据的技术。 内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。 只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。 也就是说, 资源的复制只有在需要写入的时候才进行 ,在此之前,只是以只读方式共享。 这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。
4.使编译器编译后的程序 && 内存中的进程 ==> 均以统一的视角看待程序对应的代码和数据(程序和进程:两份虚拟地址空间,分别在磁盘和内存中,进程的虚拟地址空间源于程序的)
1.
磁盘上的可执行程序,在没有加载到内存的时候,其实就有地址了,例如各个函数在汇编下的调用跳转,都是通过地址来实现的,所以不要认为只有操作系统会遵守虚拟地址空间的规则,编译器也是要遵守这样的规则的,在编译器编译代码的时候,就是按照虚拟地址空间的方式来对代码和数据进行编址的!!!
2.
只不过磁盘上可执行程序的虚拟地址空间当中没有栈和堆,因为这两个段是在运行的时候才产生的,编址的时候也是在32位或64位环境下进行编址的,并且这些地址也全部都是虚拟的,这些地址都是在程序内部,也就是在磁盘上。
3.
当程序load到内存里面的时候,天然的各个函数或者变量就拥有了物理内存地址,因为在编译器编译程序的时候,在磁盘里编译器就已经按照虚拟地址空间(BSS段、Data段、Text段)对程序对应的代码和数据进行编址了,所以当程序加载到内存的时候,操作系统就会对应的分配给程序物理地址,这是一个天然的过程。
4.
程序加载到内存的时候,其实就创建进程了,与之对应的进程描述符task_struct和内存描述符mm_struct这两个数据结构也就被操作系统创建出来了,那么mm_struct中的区域的起始和结束地址哪里来呢?操作系统不能胡编乱造一堆地址给进程吧,你画饼也得画的像个饼啊,好歹听上去得不离谱吧!要不然就不是画饼了,可以称之为扯淡了就。那么操作系统如何填充mm_struct呢?
5.
很简单,你之前的程序不是已经按照虚拟地址空间编址了吗?而且你的程序现在也已经加载到内存了,操作系统自然的就给程序分配了物理地址,所以操作系统现在知道程序的虚拟地址和物理地址了,并且操作系统也知道程序的Text段、BSS段、Data段的内容,所以此时操作系统直接就把程序的虚拟地址搬到mm_struct里面,mm_struct中无论是区域的起始和结束地址,还是区域里面程序对应的代码和数据,都被操作系统填充好了,至于栈和堆空间的开辟,在CPU读取mm_struct的代码段的时候,也就是在进程运行期间由操作系统分配相应的空间就好了。
6.
所以操作系统在程序加载到内存后,他首先肯定知道程序的虚拟地址,其次他又自然的给程序分配了物理地址,在填充mm_struct时,用的就是程序的虚拟地址,直接拷贝过去就完事了。
7.
CPU读取的永远都是虚拟地址,它压根就无法接触和见到物理地址。
例如平常在debug版本下调试程序的时候,程序就是运行起来的,此时CPU内部的寄存器读到的就是虚拟地址,虚拟地址会通过页表映射从而找到内存上真正的物理地址,通过这个真实的物理地址,找到函数或者变量的实体,从而完成进程的运行。
8.
在Visual Studio2022或其他版本编译器中,编译程序有32位或者64位的选项,那是在干什么呢?那其实就是在进行虚拟地址空间编址的选择呢,你是选择64比特位的方式进行虚拟地址空间编址呢?还是选择32位虚拟地址空间编址,一个是4GB一个是16GB,但也没啥用,因为都是虚拟的,仅仅只是空间范围而已。编译器和进程一样也被画饼,所以CPU在读取加载到内存上的程序的时候,读取的就是程序的虚拟地址,背后通过操作系统在不断的切换虚拟地址和物理地址,以便使得CPU读取的地址始终是虚拟地址。
9.
在磁盘上的地址其实有一种更为普遍官方化的叫法,叫做逻辑地址。
线性地址、虚拟地址、逻辑地址在理解层面其实是一样的,但是在不同的场景下他们拥有了不同的名字,比如你在学校叫张三,公司里叫小张,家里叫张张,你其实就一个名字,但在不同的场景下,你有了不同的叫法。
10.
磁盘上程序的编址方式是按照线性编址的,由于是虚拟的,所以程序认为从0x00000000到0xFFFFFFFF的地址,程序他自己都可以使用,这样的编址方式是比较新的,称之为虚拟地址,你也可以将他叫做逻辑地址。
而另一种编址方式不是按照线性编制的方式进行的,而是按照起始地址加偏移量的方式进行编址,这些地址是实实在在的,并不是虚拟的,所以它被称之为逻辑地址,人家是实实在在的逻辑地址。当然这种编址方式是比较老的,上面线性编址的方式是比较新的。
我现在这个水平讲不明白老的那种编址方式,等到我变成大佬的时候,会详细给大家进行讲解的,到时候在重新写一篇博客。
下面放了一张逻辑地址和虚拟地址的区别,能看懂的伙伴就看一下吧,看不懂的就按照我上面说的简易的方式理解就行。