Linux进程学习【进程地址】

📝 前言

在操作系统中,内存管理是一个至关重要的概念。对于开发者来说,理解操作系统如何管理进程的内存空间,不仅能帮助我们写出高效的程序,还能帮助我们更好地理解程序的运行机制。今天,我们将深入探讨虚拟内存、物理内存以及进程地址空间的相关概念,并重点讲解写时拷贝(Copy-on-Write,COW)机制。

在 C/C++ 程序中,内存一般可以分为几个主要区域:栈区、堆区、静态区等。每个区域有着不同的用途:

  • 栈区:用于存储局部变量以及函数调用的栈帧。

  • 堆区:用于动态分配内存(例如通过 mallocnew 等)。

  • 静态区:用于存储全局变量和常量。

然而,程序中的这些内存区域是如何映射到真实物理内存上的?操作系统如何管理每个进程的内存空间?尤其是在多进程的环境下,如何保证进程间的内存隔离?这些问题将通过以下内容来一一解答。

 📖虚拟地址空间

利用前面学习的 fork 函数创建子进程,使得子进程和父进程共同使用一个变量 

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
  int val = 10;
  pid_t id = fork();
  if(id == 0)
  {
  	val *= 2;	//刻意改变共享值
    printf("我是子进程,pid:%d ppid:%d 共享值:%d 共享值地址:%p\n", getpid(), getppid(), val, &val);
    exit(0);
  }

  waitpid(id, 0, 0);

  printf("我是父进程,pid:%d ppid:%d 共享值:%d 共享值地址:%p\n", getpid(), getppid(), val, &val);
  return 0;
}

  • 对于同一块空间,读取到了不同的值,是不可能出现这种情况的

因为真实地址都是 唯一 的,分析:

不同的空间出现同名的情况

父子进程使用的真实物理空间并非同一块空间!
原因:

当子进程尝试修改共享值时,发生 写时拷贝机制

语言层面的程序空间地址不是真实物理地址

一般将此地址称为 虚拟地址 或 线性地址 

结论: 语言层面的地址都是虚拟地址,用户无法看到真实的物理地址,我们所看到的都是虚拟地址。

 一般用户的认知中,C/C++ 程序内存分布如下图所示,直接表示内存中的各个部分

 

📖真实空间分布(mm_struct

但实际上的空间分布是这样的:

无论有多个进程(真实物理地址空间只有一份),此时情况是这样的:
 

什么是 虚拟地址空间的划分

举个例子,如果需要将桌子划分为两块该如何划分,假设桌子长度为100厘米。我们可以将桌子划分为左边区域和右边区域,左边区域为[1,50],右边区域为[50,100]。用计算机语言描述则可以通过两个结构体来描述,一个描述区域宽度,一个描述哪个区域。 

struct area
{
    int start;
    int end;
}
struct desktop
{
    struct area left;
    struct area right;
}
 
struct desktop d;
//me
d.left.start=1;
d.left.end=70;
//同桌
d.right.start=70;
d.right.end=100;

地址空间本质就是内核中的一个结构体对象mm_struct。 

为什么要有地址空间?  

1.将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域。

2.进程管理模块和内存管理模块进行解耦。

3.拦截非法请求---对物理内存的保护。

 在早期程序中,是没有虚拟地址空间的,对于数据的写入和读取,是直接在物理地址上进行的,程序与物理空间直接打交道,存在以下问题:

假设存在野指针问题,此时可能直接对物理内存造成越界读写
程序运行时,每次都需要大小为 4GB 的内存使用,当进程过多时,资源分配就会很紧张,引起进程阻塞,导致执行效率下降
动态申请内存后,需要依次释放,影响整体效率

  为了解决各种问题,大佬们提出了 虚拟地址空间 这个概念,有了 虚拟空间 后,当进程创建时,系统会为其分配属于自己的 虚拟空间,每一个进程都认为自己享有整个 内存空间。需要使用内存时,通过 寻址 的方式,使用物理地址上的空间即可。 

多个进程互不影响,动态使用,做到 效率、资源 双赢
发生越界行为时,寻址 机制会检测出是否发生越界行为,如果发生了,能在其对物理地址造成影响前进行拦截
因为每个进程都有属于自己的空间,OS 在管理进程时,能够以统一的视角进行管理,效率很高
光有 虚拟地址空间 是不够的,还需要一套完整的 ‘‘翻译’’ 机制进行程序寻址,如 Linux 中的 页表 + MMU。

 

 什么是页表?

页表是操作系统用来管理虚拟地址和物理地址之间映射的一个数据结构。它帮助操作系统将程序使用的虚拟地址(程序运行时使用的地址)转化为实际的物理地址(计算机内存的实际位置)。

为什么需要页表?

计算机中,程序使用的是虚拟内存,而实际的内存是物理内存。虚拟地址和物理地址是不一样的。操作系统通过页表来管理虚拟地址和物理地址的关系,确保程序可以正确地访问内存,同时还提供内存保护和隔离。

页表是怎么工作的?

假设程序用虚拟地址访问内存。这个虚拟地址会被分为两部分:

  1. 页号:代表虚拟地址中的页。

  2. 页内偏移量:表示在页内的具体位置。

操作系统通过页表记录虚拟页号和物理页框(物理内存中的一块区域)之间的映射关系。通过这个映射,虚拟地址可以转换为物理地址。

例如:

  • 虚拟地址 0x1234 会经过页表查找,得到对应的物理地址。

页表的结构

  1. 页表项:页表每一项包含一个虚拟页号和对应的物理页框号。

  2. 多级页表:为了节省内存,操作系统使用多级页表,把虚拟地址分成多个部分,逐层查找页表。

页表的读写功能

操作系统不仅使用页表来读取虚拟地址的物理映射,还需要对页表进行写入操作,尤其在以下情况下:

  1. 页面缺失:当程序访问的虚拟地址对应的物理页还没有加载到内存时,操作系统会触发缺页中断。操作系统会更新页表,将虚拟页和新分配的物理页框进行映射。

  2. 写时拷贝:当程序修改数据时,操作系统可能会使用写时拷贝机制。如果多个进程共享同一块内存,操作系统可能会在某个进程修改该内存时,为其分配一个新的物理页,并更新页表,指向新的物理地址。

  3. 权限修改:操作系统可以修改页表项中的权限信息,控制某些区域的内存是否可读、可写、可执行。例如,可以将代码段标记为只读,将堆区标记为可写等。

页表的作用

  • 内存保护:每个进程的虚拟内存地址都通过页表独立管理,避免进程间互相访问内存。

  • 虚拟到物理地址转换:页表将程序使用的虚拟地址转化为物理内存地址。

  • 内存效率:通过分页,操作系统能够更高效地使用内存,避免内存浪费。

  • 管理写操作:操作系统管理内存的读写权限,确保内存访问的正确性和安全性。

🖋️问题反思

此时可以理解为什么会发生同一块空间能读取到不同值的现象了。

程序在运行时操作系统会为其创建相PCB数据结构和相应的数据。这包含了虚拟地址空间mm_stack和页表,程序中的数据的虚拟地址在页表中,页表通过转化,找到在真实的物理地址。

其实,在一开始,程序的数据仅仅加载了一部分到内存当中,这是为了节约空间,防止用不到的数据占用内存。但是,当进程运行时,用到了没有加载到内存的数据,操作系统通过内存管理,在将磁盘中的对应数据加载到内存当中,修改页表中的值,这时进程就通过页表找到了对应的物理地址,内存空间得到了最大的利用,这个过程叫做缺页中断。

当我们用fork()函数创建父子进程时,子进程会将父进程地PCB数据结构拷贝一份,代码共享,子进程的mm_stack和页表一开始跟父亲的一样,父子俩的进程是相互独立的,是两个进程。但是,当其中一方尝试对代码中的值进行修改时,父子俩相同变量对应的值就不一样了,就不能公用同一块物理内存了,此时,发生了写时拷贝,操作系统开辟了一块新的空间,将修改的数据拷贝的其中。修改了对应的页表该变量对应的物理地址,虚拟地址不发生改变。这时候,就解释了为什么一块地址,有两个不一样的值,是以为虚拟地址是假的地址,对应的物理地址不一样。

可不可以直接将父进程的数据全部拷贝到新的空间呢?

可以,但是没有必要这么做。

因为子进程是能够访问父进程的数据的,大部分情况下,是不需要进行全部拷贝过来,那样太浪费空间了;我们通常是要进行写入的时候,OS才会要写入的变量复制一份,重新开一个大小一样的空间,在新开的空间内写入数据,再将新空间的地址交给页表。这是按需申请。通过调整拷贝的时间顺序,达到节省空间的目的。

内核进程调度队列

 一个 CPU 有一个 runqueue

每个 CPU 都有自己的 runqueue,当有多个 CPU 时,需要考虑如何进行进程的负载均衡。

 优先级

  • 普通优先级: 100~139(这是大多数进程的默认优先级范围,类似于 nice 值)。

  • 实时优先级: 0~99(用于实时任务,虽然你提到这个不太关注)。

活动队列

  • 时间片没有结束的所有进程都会按优先级放入活动队列中。

  • nr_active:表示当前有多少个处于运行状态的进程。

  • queue[140]:数组中的每个元素代表一个优先级对应的进程队列,相同优先级的进程按照 FIFO(先进先出)规则进行排队。

  • 调度过程:

    1. 调度器从 0 下标开始遍历 queue[140]

    2. 找到第一个非空的队列,这个队列必定是优先级最高的队列。

    3. 从选中的队列中取出第一个进程并开始执行。

    4. 重复这一过程,直到所有进程都被处理。

  • 优化使用位图(Bitmap): 为了提高查找非空队列的效率,使用一个位图(bitmap[5])来记录每个队列是否为空。由于有 140 个优先级,使用 5 * 32 位的位图可以大大提高查找效率。

过期队列

过期队列的结构与活动队列相同,但它存储的是时间片已经耗尽的进程。

  • 当活动队列中的进程处理完后,调度器会重新处理过期队列中的进程,并重新计算它们的时间片。

activeexpired 指针

  • active 指针始终指向活动队列。

  • expired 指针始终指向过期队列。

  • 随着时间的推移,活动队列中的进程会减少,而过期队列中的进程会增加。每当合适的时候,调度器交换 activeexpired 指针,这相当于重新激活一批新的活动进程。

通过这种方式,调度器能够高效地管理进程调度,并减少查找空队列的开销。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

藤椒味的火腿肠真不错

感谢您陌生人对我求学之路的支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值