当前位置: 首页 > news >正文

Linux进程概念(五)进程地址空间

地址空间排布

这段空间中自上而下,地址是增长的,栈是向地址减小方向增长,里面存放函数中的临时变量,而堆是向地址增长方向增长,malloc开辟的地址空间存放在堆区,堆栈之间的共享区域,主要用来加载动态库。

验证地址空间排布
#include<stdio.h>
#include<stdlib.h>
int g_val_1;//未初始化
int g_val_2 = 100;//初始化
////
int main(int argc, char *argv[], char *env[])
{printf("code addr: %p\n", main);//代码起始地址const char *str = "hello bit";printf("read only string addr: %p\n", str);//str是指针变量(栈区),str指向字符常量"h"(字符常量区)printf("init global value addr: %p\n", &g_val_2);//printf("uninit global value addr: %p\n", &g_val_1);char *mem = (char*)malloc(100);char *mem1 = (char*)malloc(100);char *mem2 = (char*)malloc(100);printf("heap addr: %p\n", mem);printf("heap addr: %p\n", mem1);printf("heap addr: %p\n", mem2);printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);static int a = 0;int b;int c;printf("a = stack addr: %p\n", &a);printf("stack addr: %p\n", &b);printf("stack addr: %p\n", &c);int i = 0;for(; argv[i]; i++)printf("argv[%d]: %p\n", i, argv[i]);for(i=0; env[i]; i++)printf("env[%d]: %p\n", i, env[i]);
}

运行结果:

我们可以看到代码区的地址是最小的,

static修饰的全局变量,编译的时候已经被编译到全局数据区了。

#include<stdio.h>
#include<unistd.h>
int g_val = 0;
int main()
{printf("begin.....%d\n",g_val);pid_t id = fork();if(id==0){//childint count = 0;while(1){printf("child: pid: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);sleep(1);count++;if(count == 5){g_val = 100;}}}else if(id>0){//fatherwhile(1){printf("father: pod: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);sleep(1);}}else{//todo}return 0;
}

在五秒后,子进程的g_val变成了100,但父进程的g_val没有改变,但神奇的是,他们俩的地址竟然还完全相同。

怎么可能同一个变量,同一个地址,同时读取,读到了不同的内容。

结论:如果变量的地址,是物理地址,不可能存在上面的现象。绝对不是物理地址,是线性地址或者是虚拟地址。平时写的c++/c用的指针,指针里面的地址全部都不是物理地址

进程地址空间的概念

每一个进程在创建时不仅要创建内核pcb还要创建进程地址空间

父进程pcb中会有指针指向这块地址空间,

页表是一种key-value式的表格,左侧对应虚拟地址,右侧对应实际物理地址

当父进程创建子进程时,先创建子进程pcb结构,子进程有自己独立的页表结构,此时子进程数据和代码都和父进程相同。子进程一开始页表为空,一个全局变量在父子进程具有相同的虚拟地址,当子进程往页表中写入时,发现父子进程指向相同的物理地址,系统会为子进程重新开辟一段空间来存储这个全局变量,此时父子进程相同的虚拟地址会映射到不同的物理地址。

先经过写时拷贝--是由操作系统自动完成的。本质重新开辟空间,但是在这个过程中,左侧的虚拟地址是0感知的,不会影响他。所在上上面例子中,将g_val改成100后进行打印,得到的地址相同,都是虚拟地址(子进程的虚拟地址继承自父进程),但内容不同是因为父进程通过虚拟地址查页表映射到一块物理地址,而子进程通过页表映射映射到物理内存的另一个地址。

地址空间究竟是什么?

什么叫做地址空间?

前提是一个进程一定是正在运行,在32位计算机中,有32位的地址和数据总线,cpu和内存之间的线叫做系统总线,内存和外设之间的线叫做io总线。拷贝的本质是磁盘向内存充放电的过程。,每一根地址总线只有0,1,32跟会有2的32次方中组合,2^32*1bit=4GB.

所以地址空间就是你的地址总线排列组合形成地址范围[0,2^32)

所谓的进程地址空间,本质是一个描述进程可视范围的大小

如何理解地址空间上的区域划分?-

地址空间一定要存在各种区域划分,要对线性地址进行start和end即可。地址空间本质是内核的一个数据结构对象,类似pcb一样,地址空间也是要被操作系统管理的,先描述后组织。

struct mm_struct
{unsigned long code_start;//代码区unsigned long code_end;unsigned long init_start;//初始化区unsigned long init_end;unsigned long uninit_start;//未初始化区unsigned long uninit_end;unsigned long heap_start;//堆区unsigned long heap_end;unsigned long stack_start;//栈区unsigned long stack_end;//...等等
}

所以在创建一个进程时,先要创建对应pcb,再创建对应结构体mm_struct,并划分区域,32位默认大小为4GB。在范围内,连续的空间中,每一个最小单位都可以有地址,每个地址都可以被小胖(进程)直接使用。

为什么要有进程地址空间

我们再来思考什么叫做进程?以及为什么要有进程地址空间?

如果进程直接访问物理内存,那么看到的地址就是物理地址,而语言中有指针,如果指针越界了,一个进程的指针指向了另一个进程的代码和数据,那么进程的独立性,便无法保证,因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,如果里面的数据有账号密码就可以改密码,即使操作系统不让改,也可以读取。

增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,在这个转换的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。

我们在写代码的时候肯定了解过指针越界,我们知道地址空间有各个区域,那么指针越界一定会出现错误吗?

不一定,越界可能他还是在自己的合法区域。比如他本来指向的是栈区,越界后它依然指向栈区,编译器的检查机制认为这是合法的,当你指针本来指向数据区,结果指针后来指向了字符常量区,编译器就会根据mm_struct里面的start,end区间来判断你有没有越界,此时发现你越界了就会报错了,这是其中的一种检查,第二种检查为:页表因为将每个虚拟地址的区域映射到了物理内存,其实页表也有一种权限管理,当你对数据区进行映射时,数据区是可以读写的,相应的在页表中的映射关系中的权限就是可读可写,但是当你对代码区和字符常量区进行映射时,因为这两个区域是只读的,相应的在页表中的映射关系中的权限就是只读,如果你对这段区域进行了写,通过页表当中的权限管理,操作系统就直接就将这个进程干掉。

页表

cr3寄存器

在cpu中会有一个cr3寄存器,这个寄存器会保留当前页表的起始地址,本质上属于进程的硬件上下文,所以当进程切换时,会带走寄存器的数据。(这个cr3是物理地址)

页表会给我们提供很好的权限管理

我们来思考一个问题,我们如何知道某个区域是只读还是可写入的呢?

页表中会有一个标志位来说明该区域是只读还是可写入。若一个位置权限是可读,当我们尝试对这个位置进行写入时,此时页表会直接进行拦截,相当于进行了一次非法操作,操作系统会直接终止这个进程。

此时我们来看一段代码

char *str ="hello bit";
*str='H';
return 0;

按照我们之前的理解,“hello bit”存在于字符常量区,是不可以被修改的。

但我们今天深入思考一下,为什么代码是只读?字符常量区是只读的?他们是如何加载到只读区域的?物理内存没有只读的概念,可以随意进行读写,没有权限管理。

这是因为在页表中的地址映射关系中,页表中的标志位是只读的,所以操作系统才会拦截你,跨权限时进程才会被操作系统终止。

我们再来思考一个问题

我们知道,当内存空间不足的时候,处于阻塞态的进程是可以被挂起的,代码和数据被换出内存,那我们如何知道这个进程已经被挂起了呢?你怎么知道你的进程的代码和数据在不在内存中?

如果进程在内存,并且状态时R,说明处于运行态,如果代码和数据在内存中,并且状态是S,说明进程正在被阻塞,可是Linux内核状态没有挂起转换态,我们如何知道进程此时是被挂起呢?

操作系统对大文件可以实现分批加载(打游戏时游戏大小十几个G,而你的内存只有八个G),现在操作系统用多少给你加载多少。页表中还有一个标志位来表示对应的代码和数据是否已经被加载到内存中。若这个标志位为1.表示已经被加载,我们直接读取对应的物理地址,找到对应的物理内存进行访问。当标志位为0表示当前代码和数据并未加载到内存中,操作系统会触发缺页中断。我们会找到这个可执行程序的代码和数据,在内存中申请一块内存,然后把可执行程序剩余的代码和数据加载到内存中,然后把这段内存的地址填写到对应的页表当中。我们再访问可以访问对应的代码和数据了。

这个时候我们就知道为什么某个区域是只读还是可读可写了,因为通过页表对他进行管理。

我们再思考一下

当进程在被创建的时候,是先创建内核数据结构还是先加载对应的可执行程序呢?

答案是先创建内核数据结构,即先要为该进程创建对应的pcb,进程地址空间,页表对应关系,然后才慢慢加载可执行程序,可能你的程序已经跑起来了,但还没加载完

因为有地址空间和页表的存在,我们可以将进程管理模块和内存管理模块进行解耦合!

我们再来谈进程。进程=内核数据结构(tast_struct&&mm_struct地址空间空间&&页表)+程序的代码和数据。所以进程在切换的时候不仅要切换pcb还要切换进程地址空间和页表。

我们经常说进程具有独立性,是怎么做到的?每一个进程都有独立的内核数据结构,在物理内存中加载的代码和数据都是独立的。在物理内存中加载的数据可以随便加载,是无序的,但经过页表的映射,可以在虚拟地址中有序的排布,这就有了各种区域(正文代码区,初始化区,未初始化区等等)

http://www.lryc.cn/news/602925.html

相关文章:

  • 吃透 lambda 表达式(匿名函数)
  • 关闭 UniGetUI 自动 Pip 更新,有效避免 Anaconda 环境冲突教程
  • 3.DRF视图和路由
  • sqlite3学习---基础知识、增删改查和排序和限制、打开执行关闭函数
  • SpringBoot数学实例:高等数学实战
  • (二)Eshop(RabbitMQ手动)
  • 【计算机网络】OSI七层模型
  • Qt项目中使用 FieldManager 实现多进程间的字段数据管理
  • EXCEL怎么使用数据透视表批量生成工作表
  • 十七、K8s 可观测性:全链路追踪
  • django 按照外键排序
  • 未授权访问
  • 项目如何按时交付?重点关注的几点
  • 进程间通信————system V 共享内存
  • Python day27
  • 在rsync + inotify方案中,如何解决海量小文件同步效率问题?
  • 从视觉到智能:RTSP|RTMP推拉流模块如何助力“边缘AI系统”的闭环协同?
  • 如何解决pip安装报错ModuleNotFoundError: No module named ‘nbconvert’问题
  • Java设计模式-通俗举例
  • 铜金矿数据分组优化系统设计与实现
  • 扩展和插件功能
  • 网络 编程
  • C#_运算符重载 operator
  • Joint.cpp - OpenExo
  • Windows 11 下 Anaconda 命令修复指南及常见问题解决
  • MCP error -32000: Connection closed
  • ESP32学习-按键中断
  • 【unitrix】 6.20 非零整数特质(non_zero.rs)
  • Laravel 分页方案整理
  • 小智源码分析——音频部分(二)