【Linux】进程(6):程序地址空间

目录

[一 程序地址空间图](#一 程序地址空间图)

[二 两个问题](#二 两个问题)

[1 char *s ="hello world";对s解引用去修改会报错:*s = 'H" 为什么?](#1 char *s ="hello world";对s解引用去修改会报错:*s = 'H" 为什么?)

[2 static(静态)修饰局部变量:作用域不变,生命周期变成全局?](#2 static(静态)修饰局部变量:作用域不变,生命周期变成全局?)

[三 回顾一个历史例子 引入虚拟地址概念](#三 回顾一个历史例子 引入虚拟地址概念)

[四 虚拟地址空间概念&&解决历史问题](#四 虚拟地址空间概念&&解决历史问题)

[1 页表](#1 页表)

[2 写时拷贝](#2 写时拷贝)

[五 理解虚拟地址空间](#五 理解虚拟地址空间)

[1 什么是](#1 什么是)

[2 理解区域划分](#2 理解区域划分)

[3 怎么实现的](#3 怎么实现的)

[六 为什么要有虚拟地址空间](#六 为什么要有虚拟地址空间)

[理由1 更好的对内存进行保护](#理由1 更好的对内存进行保护)

[理由2 把进程的数据和代码在空间排布上,从无序到有序](#理由2 把进程的数据和代码在空间排布上,从无序到有序)

[理由3 进程管理模块和内存管理模块完成了解耦合](#理由3 进程管理模块和内存管理模块完成了解耦合)

如果程序可以直接操作物理空间会造成什么问题?

[七 补充点](#七 补充点)


一 程序地址空间图

以前在学习的时候,我们肯定见过一张类似于下面的图:

程序地址空间是内存吗?

不是,代表的是进程的虚拟地址空间

堆,栈是相对而生:堆是自下而上生长,栈是自上而下生长

堆栈就是栈

对程序地址空间各个区域进行分布验证:

bash 复制代码
#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";
 printf("code addr: %p\n", main);
 printf("init global addr: %p\n", &g_val);
 printf("uninit global addr: %p\n", &g_unval);
 static 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 static 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;
}

运行结果:

bash 复制代码
$ ./a.out 
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf

二 两个问题

1 char *s ="hello world";对s解引用去修改会报错:*s = 'H" 为什么?

进程存在,地址空间必须一直存在

字符串常量是统一编址的,和代码段编址在一起,属于程序的只读数据区,无法被修改;
定义指针变量指向字符串常量时,指针保存的是字符串的起始地址,该地址指向的是代码区的只读数据;

一个字符串能否被修改,带不带const没有直接的 "必然关联":
const的本质是让编译器做语法层面的检查,强制程序员避免修改该内存,在编译阶段就暴露修改只读数据的问题;
即使不加const,直接修改字符串常量的内存,依然会触发程序错误(运行时崩溃),因为其物理存储在只读区。

2 static(静态)修饰局部变量:作用域不变,生命周期变成全局?

1) 作用域不变

static 不改变变量的 "访问权限",只是改变它的 "存储位置"

2)生命周期变成全局

普通局部变量存放在栈区,函数调用结束就会被销毁,下次调用重新创建;static 局部变量存放在静态数据区,程序启动时分配内存,整个程序运行期间都存在,直到程序退出才释放。


三 回顾一个历史例子 引入虚拟地址概念

我们以前在进程方面讲到过fork会让父子进程有两个不同的取值

忘了的友友可以看一下我的这篇文章:【Linux】进程(2):进程概念与操作理解

这个时候我们就知道,c/c++看到的地址,绝对不是物理地址,而是虚拟地址

我们也可以写一个代码来验证一下:

bash 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
 pid_t id = fork();
 if(id < 0){
 perror("fork");
 return 0;
 }
 else if(id == 0){ //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程
再读取 
 g_val=100;
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }else{ //parent
 sleep(3);
 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 sleep(1);
 return 0;
}

运行结果:

bash 复制代码
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8

所以我们就得到结论:

变量内容不一样,所以父子进程输出的变量绝对不是同一个变量 但地址值是一样的,说明,该地址绝对不是物理地址!

在Linux地址下,这种地址叫做 虚拟地址 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理

OS必须负责将 虚拟地址 转化成 物理地址 。


四 虚拟地址空间概念&&解决历史问题

1 页表

页表 是操作系统为每个进程维护的一张映射表,它的唯一核心作用是:将进程使用的虚拟地址,翻译成硬件能识别的物理内存地址。

页表左侧是虚拟内存地址,右侧是物理地址,页表本质上是虚拟地址到物理地址转化的

假设进程看到的虚拟地址是0x10601958,它在物理内存上有一定空间,假设是0x12309,那么在页表上就会建立 一个一对一的映射关系,虚拟地址空间可以帮我们找到页表。某一个地址经过查表,就能找到它在物理内存的地址

因为1访问内存的基本单位是字节,所以每个字节都有地址,就表示地址和字节是绑定的

因为虚拟地址和物理地址是一对一映射的,假设是32位系统,所以有2^32个地址,表达的虚拟空间个数1有4GB个(2 ^ 32 * 1byte = 4GB)

进程一定有如下结构:PCB,虚拟地址空间,页表

他们都会被子进程继承,会导致和父进程指向同一块空间,且是浅拷贝

2 写时拷贝

进程是具有独立性的!→ 一个进程运行不能影响另一个进程。

若子进程想要修改数据,系统会在底层重新开辟一块同等大小的空间,把原空间的内容拷贝进来;

修改子进程页表的物理空间地址,父进程的虚拟地址空间不受影响;

因此上层看到的虚拟地址是一样的,但打印出来的值不一样,这个过程称为写时拷贝(Copy-on-Write);
核心特点:只有修改数据时,才会为子进程重新开辟物理内存空间。

另一种情况:共享地址空间的进程,是用时间换内存的策略。

写时拷贝:是操作系统优化进程创建的一种技术,fork 创建子进程时,父子进程共享物理内存页,只有当其中一方尝试修改内存数据时,才会为修改方复制一份物理页,避免不必要的内存拷贝,提升效率。

同样的,在 C 语言和 C++ 中,使用malloc或new申请动态内存时,并不会立刻在物理内存中分配空间 。如果刚申请就立刻占用物理内存,而程序又暂时用不上,会造成严重的资源浪费。

这个过程的实际逻辑是:

当调用malloc或new时,操作系统首先在进程的虚拟地址空间中标记出一段连续的虚拟地址范围,此时并没有分配任何物理内存页。
只有当程序真正访问这段虚拟地址时,操作系统才会通过页表完成虚拟地址到物理地址的映射,并在物理地址空间中为其分配实际的物理内存。

这种延迟分配的设计,不仅能大幅提升内存资源的利用率,还与系统的内存安全机制紧密相关。


五 理解虚拟地址空间

1 什么是

我们通过一个故事来理解:

有一个大富翁,他有很多很多的钱,大概有十个亿。他给他的每一个私生子都画饼说:我的这笔钱以后都由你继承(私生子互相不知道对方的存在),那么这个时候就会让私生子产生一种错觉:我独占资源---为什么后面介绍

在这个里面:大富翁就是操作系统,十个亿就是内存,私生子就是进程,画的饼就是虚拟地址空间

画饼的原因:操作系统想要进程有独占系统空间的错觉(原因见标题六)

那么画的饼本身需不需要被管理? 给每个人画画的饼都不同,需要!

如何管理? 先描述,再组织--->在内核中就是一个结构体对象:struct mm_struct

去银行存钱也是这个道理:存钱就是银行给你画了一个数字的饼,实际的钱可能拿去做别的事情

2 理解区域划分

我们同样通过一个故事理解一下区域划分:

我们以前上学的时候是不是都在课桌上画过三八线呢 假设课桌的长度是100cm,小花和小胖划分三八线。1-50cm是属于小花的部分,51-100cm是属于小胖的部分,那此时1-50cm的任意一个cm编号小花都可以随意使用。

个桌面(100cm):对应进程的虚拟地址空间 (范围 [1, 100])。

三八线(50cm 处):对应虚拟地址空间的分区边界,用来划分不同的地址段。

小花的区域 [1, 50]:对应分配给某个模块 / 数据的虚拟地址区间,在这个区间内的地址都可以被直接使用。

小胖的区域 [50, 100]:对应另一块虚拟地址区间。

修改区域空间大小:让这个空间的end修改到x,另一个空间的开始start也修改到x--->叫做扩展或者缩小区域

3 怎么实现的

我们要结合Linux的内核代码来看这个问题:

  1. 内核中地址的表示

在操作系统内核中,通常用 unsigned long 类型来表示地址(包括虚拟地址和物理地址),这是因为它能覆盖足够大的地址范围,满足现代系统的寻址需求。

  1. start_code 和 end_code

start_code 是代码段的起始地址,end_code 是代码段的结束地址。

这两个地址限定了当前进程可以访问的代码区域范围 ,确保进程只能在合法的代码地址内执行指令。

  1. 虚拟地址空间各区域大小的决定因素
    堆区、栈区:大小会随程序运行时的动态申请(如malloc、new或函数调用栈的变化)而变化,属于动态调整的区域。
    代码段、数据段:大小由编译后的二进制可执行程序本身的代码和数据量决定,属于静态固定的区域。

  2. 代码加载与地址映射

代码被加载到物理内存时,每一行代码都会对应一个物理地址。

同时,操作系统会为其分配对应的虚拟地址,并将这两个地址的映射关系写入页表中,后续程序通过虚拟地址访问代码时,会通过页表转换到对应的物理地址。


六 为什么要有虚拟地址空间

理由1 更好的对内存进行保护

虚拟地址到物理地址的转化,实际不是页表,而是一个叫做mmu的硬件自动完成的,进程如果想访问物理内存,会多一次转化,可以更好的对内存进行保护

我们引入一个故事更好的解释:

我" 直接去商店买东西:对应进程直接访问物理内存 。这种方式没有中间层,容易出现 "乱花钱"(进程越权访问、内存碎片化、地址冲突等问题 )。

"我" 先找老妈要钱,老妈再去买:对应进程通过虚拟地址访问物理内存

"老妈" 就像操作系统内核 ,负责管理内存(零花钱)。

进程(我)只能看到虚拟地址(想买的东西),实际物理地址(买东西的钱)由内核(老妈)统一分配。

内核会检查访问权限(老妈拦截没用的商品),确保内存安全和高效利用。

页表的作用:就像老妈手里的 "账本",记录了每个虚拟地址(想买的东西)对应的物理地址(实际的钱和商品),完成地址转换。

通过妈妈到达商店,那么很多不合理的行为就会被拦截下来

只给虚拟地址,转化工作由mmu完成,相当于在内存和进程之间增加来一个"妈妈"的角色

因为有页表的存在,才能更好的对内存进行管理

页表中还存在很多标志位(可读、可写、可执行等),比如代码段对应的物理内存被标记为只读,能避免恶意修改或越权访问。

理由2 把进程的数据和代码在空间排布上,从无序到有序

问题:当malloc申请一百个字节的空间时,内存是立即申请吗?

当程序调用 malloc 申请内存时**,内核仅在进程的虚拟地址空间中标记一段连续的虚拟地址范围,并不会立即分配物理内存页**。只有当程序首次对这段虚拟地址进行写入(或读取)操作时,内核此时才会分配物理内存页,并更新页表映射。

问题:当我们创建进程时,是否一定把代码和数据,立即加载到内存?

进程创建时,可执行文件的代码和数据不会被全部加载到物理内存,而是先建立虚拟地址空间与磁盘上文件的映射 。当程序执行到某段未加载的代码时,将对应代码段从磁盘加载到物理内存,这种 "按需加载" 的方式减少了内存占用,加快了程序启动速度

问题:对一个进程来讲,自己的代码和数据,或任意数据,在=放在物理内存在任意位置,行吗?

可以!

有了虚拟地址空间,每个进程都以为自己的代码区和数据区都是如下这样排列的:

只不过是有的大有的小。
其实就相当于画大饼:认为自己可以独占内存

理由3 进程管理模块和内存管理模块完成了解耦合

每个进程都拥有独立的虚拟地址空间,让进程认为自己独占整个内存,且地址布局一致(比如代码段、数据段、堆、栈的布局固定)。

虚拟地址空间让进程管理和内存管理模块解耦:进程管理负责虚拟地址的分配,内存管理负责物理内存的调度,二者分工明确,提升了系统的可维护性和扩展性。

如果程序可以直接操作物理空间会造成什么问题?

早期的计算机中,要运行一个程序,会把这个程序全部装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。

那当计算机同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110M。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种分配方法可以保证程序 A 和程序 B 都能运行,但是这种简单的内存分配策略问题很多。
安全风险

每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域 ,如果是一个木马病毒,那么它就能随意修改内存空间,让设备直接瘫痪。
地址不确定

众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行。如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的。比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有 10 个进程在运行了,那执行a.out的时候,内存地址就不一定了。
效率低下

如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的。如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存。但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然!

地址空间和页表是 OS 创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也一定要在 OS 的监管之下进行访问!也顺便保护了物理内存中的所有合法数据 ,包括各个进程以及内核的相关有效数据!

因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。

因为有地址空间的存在,所以我们在 C、C++ 语言上new、malloc空间的时候,其实是在地址空间上申请的,物理内存甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全 0 感知!

因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的


七 补充点

解决方案:链表(VMA 链表)

Linux 内核通过vm_area_struct结构体 来描述一段连续的虚拟内存区域(VMA),并将这些 VMA 组织成链表(或红黑树),放在进程的mm_struct中管理。

mm_struct:代表进程的整个虚拟地址空间,其中的mmap字段指向vm_area_struct链表的表头。

vm_area_struct:描述一段连续的虚拟内存,关键成员包括:

vm_start:这段虚拟内存的起始地址

vm_end:这段虚拟内存的结束地址(不包含)

vm_mm:指向所属的mm_struct

核心作用

当堆区、共享区等出现不连续的内存块时,内核会为每一段连续的虚拟内存创建一vm_area_struct节点,并用链表把它们串起来
这样,即使物理内存不连续,在进程视角下,虚拟内存的分布依然可以被有序管理,同时也支持延迟分配、页表映射等虚拟内存特性

图解如下:

相关推荐
慵懒的猫mi2 小时前
deepin UOS AI 助手接入钉钉(DingTalk)配置指南
linux·数据库·人工智能·ai·钉钉·deepin
returnthem2 小时前
Ubuntu 22.04 + XFCE4 + 非 Snap 版 Firefox + VNC/noVNC 部署全步骤
linux·ubuntu·firefox
Maverick062 小时前
Oracle PDB 创建
运维·数据库·oracle
顶点多余2 小时前
Linux中基础IO知识全解
linux·服务器·算法
等风来不如迎风去2 小时前
【linux】tar [选项] 归档文件名 要打包的文件/目录..
linux·运维·elasticsearch
小周学学学2 小时前
vmware的python自动化:批量迁移虚拟机
运维·自动化·vmware·虚拟化
Gold Steps.2 小时前
GitOps之Jenkins 构建镜像自动更新 Helm 并触发 ArgoCD 自动同步
运维·ci/cd·云原生
一殊酒2 小时前
【Docker】实战用例:前后端分离项目多容器Docker化设计
运维·docker·容器
yuuki2332332 小时前
【Linux】Linux基本指令 & 权限全解析
java·linux·服务器