深入浅出从硬件和软件角度去理解内存

目录

硬件角度

level1 - 分段机制

level2 - 分页机制

level3 - 虚拟内存到物理地址的转换

软件角度

1. 什么是进程

2. 进程地址空间

2.1 栈内存(statck)

2.2 堆内存(heap)

2.3 数据段

2.4 代码段


硬件角度

一开始我们只是通过纸上打孔来记录数据。后来出现了内存器,执行指令只是分为加载+运行。 

最开始的程序运行时只能跑一个进程的,那就不需要复杂的内存管理,把我弄到固定的位置,然后这片区域都是我的。而且有多大的内存我就用多大的,一旦我进程想用的内存比拥有的物理内存大的时候,崩了就完事了。

特点:单进程 单操作系统 直接使用物理内存

这样的问题随着时代的发展问题就来了。
问题一 :单进程用不完资源那不是浪费?

问题二 :我要是物理内存不够,又没钱升级硬件怎么办?

问题三 :因为我的软件直接操作接触的物理内存,这个和硬件靠的太近,我们都知道移植性就差了?

为了解决问题,多进程应运而生:多进程之间的这个内存怎么处理,总不能让腾讯的数据访问到快播的吧,想象你正在看剧,突然内容变成了学习内容,怕怕。为了解决这个问题,在操作系统编译的时候主存划分了很多的静态分区。有进程的时候,你就看看哪里能放下你,你去那里待着。

问题又来了
问题一 :程序大小那不是必须和分区匹配,起码不能比分区小

问题二 :这个进程的数目那就是固定了啊,那不是买电脑还得多一个电脑能跑多少个进程的参数

问题三 :地址空间固定,进程不能膨胀啊。(想想咱们平时LOL,不运行的时候几个G,运行起来几百个,那肯肯定是玩不了了)

问题四 :进程之间的边界真的能控制的很好吗?现在这么完备的内存管理下还经常出现内存踩踏事件。

解决方法就是:动态的分区

就是给操作系统整一个分区,剩下的有进程时,需要多大分割多大。

这样一整,敏感的你就知道了,分割多了,那不是内存的这个空洞就多了,碎片就多了,那咋整呢?

得规整内存,只能迁移进程了,迁移进程你不可能做,只能操作系统了,而这个过程很消耗时间(自己磁盘整理过的都知道哈),需要大量数据的换入换出。尤其是在进程运行的时候内存不够了,然后你得去迁移,等个一个小时,电脑我都想砸了。

迁移只有这个进程的位置也变了,这个寻址方式就算是相对寻址,那个相对的对象总是绝对的,因此程序编写你就说头疼不。

同时当这个程序是恶意的,那我不是就可以为所欲为,因为大家都是直接对应的物理内存,我偏不去我该去的地方,我就在你工作的时候来骚扰你一下,你就说怕不怕。
 

于是又产生这几个问题:

如何内存保护、内存运行重定位、使用效率低下无法忍受等问题

解决办法

level1 - 分段机制

为了解决进程间内存保护的问题,提出了虚拟内存。

通过增加一层虚拟内存,进程访问虚拟内存,虚拟内存由操作系统映射到物理内存。

对于进程来说它就不需要关系实际的物理地址,当访问到没有映射的物理内存时,操作系统会捕捉到这个违法操作。

同时进程是使用的虚拟内存,因为程序也具有移植性

但是啊进程就算是操作虚拟内存但是最后也是映射到物理内存,如果给进程映射的物理内存不够的时候,那还是得迁移。换出到磁盘进行迁移,粒度是整个进程,这么大的io肯定很漫长。

想想一个程序中的数据,在不断的运行使用的只有那么一部分,于是把常用的放在内存,不常用的放在磁盘中。那么换入换出的就是那么一少部分数据。然后这里就创建了更细的粒度–分页机制

想想为什么你的电脑内存条才8个G却能跑几十G的游戏。

level2 - 分页机制

现在我们知道分页粒度很细。进程的虚拟地址、硬件的物理地址都按照分页的粒度。

常用的代码和数据以页留在内存,不常用的去磁盘,这样就节省了物理内存(内存那么贵)

进程的虚拟内存页通过CPU的硬件单元映射到物理内存页

物理页称为物理页面或者页帧
进程空间的虚拟页面称为虚拟页

操作系统为了管控这些物理页面,给页帧创建了编号页帧号 PFN

现在的页表常见的4KB最常见,还有16K、64K。在某些特点的场景下,比如那种超大服务器系统TB量级,可能页面是M或者G级别。

到这里就说说那个CPU的硬件单元

其实虽然什么不想做的事情都扔给操作系统,但是做人不能这么狗,尤其是内存管理这么严重的事情,还有就是安全性,于是用CPU的硬件单元–MMU来管控这个内存的映射。

ARM处理器的内存管理单元包括TLB和Table Walk Unit两个部件。

TLB是一块高速缓存,用于缓存页表转换的结果,从而减少内存访问的时间。就拿缓存的概念去理解。当TLB 没有,miss了。那我就只能去内存的转换页表中获取这个映射的结果,获取到对应的物理地址后再将我的虚拟地址换成物理地址去最终的目的地查看学习资料。

当然不是说有个这个玩意就什么不用做了。
一个完整的页表翻译和查找的过程叫作页表查询(Translation Table Walk),页表查询的过程由硬件自动完成,但是页表的维护需要软件来完成。

页表查询是一个相对耗时的过程,理想的状态是TLB里缓存有页表转换的相关信息。当TLB未命中时,才会去查询页表,并且开始读入页表的内容。(要是这个TLB整大点,不是可以加快,不考虑钱的话)

因此页表的维护是软件的,所以在Linux内核内存的学习中,后面会有内存初始化,创建页表这些东西。
 

level3 - 虚拟内存到物理地址的转换

上面那个图里面,如果是TLBs命中后就直接拿到了物理地址,去兑换奖品,但是miss掉以后,那就得走Table Walk Uint就是得页表转换,VA–>PA(V:虚拟 virtual, P:物理 physical, A:地址address)

整个流程:

处理器根据页表基地址控制寄存器TTBCR和虚拟地址来判断使用哪个页表基地址寄存器,是TTBR0还是TTBR1。(一个基值是内核的,一个用户态的)

页表基地址寄存器中存放着一级页表的基地址。

处理器根据虚拟地址的bit[31:20]作为索引值()4K页表,在一级页表中找到页表项。一级页表一共有4 096个页表项。

第一级页表的表项中存放有二级页表的物理基地址。处理器将虚拟地址的 bit[19:12]作为索引值,在二级页表中找到相应的页表项。二级页表有256个页表项(2^12 * 2^8 * 4kb(2^12)==》32位)。

二级页表的页表项里存放有 4KB 页的物理基地址,加上最后的VA 12位,因此处理器就完成了页表的查询和翻译工作。
(将整个4MB分成了4096份* 256份*4KB)
(这就是为什么内存越大,页表项也得越大,不然页表项的内存就变大的)
(表项存的是基地址,而虚拟内存放的都是索引)

图 7.4 所示为 4KB 映射的一级页表的表项,bit[1:0]表示一个页映射的表项,bit[31:10]指向二级页表的物理基地址。

4KB是2^12

64位的ARM 一般常用的是48,那么只剩36位(其他的位干啥了呢,记住这个问题哈哈哈)

这里还是讨论32位:

下图展示两个进程以及各自的页表和物理内存的对应关系图,这里假定页大小是4K,32位地址总线进程地址空间大小为(2^32)4G,这时候页表项有 4G / 4K = 1048576个,每个页表项为一个地址,占用4字节,1048576 * 4(B) /1024(M) = 4M,也就是说一个程序啥都不干,页表大小就得占用4M。如果每个页表项都存在对应的映射地址那也就算了,但是,绝大部分程序仅仅使用了几个页,也就是说,只需要几个页的映射就可以了,如下图,进程1的页表,只用到了0,1,1024三个页,剩下的1048573页表项是空的,这就造成了巨大的浪费,为了避免内存浪费,计算机系统开发人员想出了一个方案,多级页表。

 我们先看下图,这是一个两级页表,对应上图中的进程1。先计算下两级页表的内存占用情况。

一级页表占用= 1024 * 4 B= 4K,

2级页表占用 = (1024 * 4 B) * 2 = 8K。

总共的占用情况是 12K,相比一级页表 4M,节省了99.7%的内存占用。

我们来看下两级页表为啥能够节省这么大的内存空间,相比于上图单级页表中一对一的关系,两级页表中的一级页表项是一对多的关系,这里是1:1024, 这样就需要 1048576 / 1024 = 1024 个一级页表项。相当于把上图的单级页表分成1024份。一级页表项PTE0表示虚拟地址页01023,PTE1表示虚拟地址页10242047。如果对应的1024个虚拟地址页存在任意一个真实的映射,则一级页表项指向一个二级页表项,二级页表项和虚拟地址页一一对应,在上图中,进程1的虚拟页0,1,1024存在映射,0,1虚拟页属于这里的PTE0,1024属于PTE1。一级页表项中如果为null,表示对应的1024个虚拟页没有使用,所以就不需要二级页表了,节省了空间。当然,如果虚拟地址页完全映射的话,多级页表的占用=一级页表项(1024 * 4B) + 二级页表项(1024 1024 4B) = 4M + 4K,比单级映射多了4K,不过这种情况基本上没有可能,因为进程的地址空间很少有完全映射的情况。正是因为省却了大量未映射的页表项使得页表的空间大幅减少。

其实这个差异就是我以前一来就把全部的虚拟页表和物理页表建立了映射关系,那我这个页表就需要4M。

现在我将这个4M的页表分成了1024份,需要几份就申请创建几份页表,而不是一来就把所有的页表都和物理页面挂上钩。

然后分成了这1024个,我需要在抽象一层4kb的页表去指向这1024个页表各自的基地址。

因为从物理内存层面一层一层的提到最上层的时候,也方便我们对于这个虚拟地址的组成:

一级页表索引+二级页表索引+VA(每次页表的内容都是下一基的基地址)
(这个图片稍微有点理想,一般都是4096 + 256的组合,而不是1014 + 1024的组合,不过大概这个道理就行)

那几个特殊的位是内存的属性。这个后面再补充。这个是ARM硬件架构上针对安全内存、设备内存的一些位。
 

软件角度

1. 什么是进程

简单来讲,进程就是运行中的程序。

进一步讲,进程是在用户空间中,加载器根据程序头提供的信息,将程序加载到内存并运行的实体

任何一个程序,想要正常运行都需要内存资源,用来存放诸如变量、常量、函数代码等等。这些不同的内容,所存储的内存区域是不同的,且不同的区域有不同的特性。因此我们需要研究进程的内存布局,逐个了解不同内存区域的特性。

2. 进程地址空间

每个C语言进程都拥有一片结构相同的虚拟内存,大小一般为4G,所谓的虚拟内存,就是从实际物理内存映射出来的地址范围,最重要的特征是所有的虚拟内存布局都是相同的,极大地方便操作系统内核管理不同的进程。例如三个完全不相干的进程p1、p2、p3,它们很显然会占据不同区段的物理内存,但经过系统的变换和映射,它们的虚拟内存的布局是完全一样的。

第一部分为 “用户空间”,用来映射其整个进程空间(0x0000 0000-0xBFFF FFFF)即3G字节的虚拟地址;

第二部分为 “内核空间”,用来映射(0xC000 0000-0xFFFF FFFF)1G字节的虚拟地址。

可以看出Linux系统中每个进程的页面目录的第二部分是相同的,所以从进程的角度来看,每个进程有4G字节的虚拟空间, 较低的3G字节是自己的用户空间,最高的1G字节则为与所有进程以及内核共享的系统空间。

虚拟内存中,内核区段对于用户应用程序而言是禁闭的,它们用于存放操作系统的关键代码,另外由于 Linux 系统的历史性原因,在虚拟内存的最底端 0x00000000 ~ 0x08048000 之间也有一段禁闭的区段,该区段也是不可访问的

上图左边展示的是内核内存地址空间(1G),右边展示的是用户内存地址空间,每当进程切换用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的,进程访问内核空间的方式:系统调用和中断。内核空间地址有自己对应的页表(init_mm.pgd),用户进程各自有不同的页表。

上图右边讲解了进程地址空间中使用的数据段种类:

  • 代码段(text):代码段是用来存放可执行文件的操作指令,也就是说是它是可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。
  • 数据段(data):数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。
  • BSS 段:BSS段包含了程序中未初始化的全局变量,在内存中 bss段全部置零。
  • 栈(stack):栈是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
  • 堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)

2.1 栈内存(statck)

栈内存指的是从0xC000 0000向下增长的这部分区域,具有后进先出的特点。

栈的全称是运行时栈(Run-time Stack),栈会随着进程的运行而不断发生变化:一旦有新的函数被调用,就会立即在栈顶分配一帧内存,专门用来存放该函数内定义的局部变量(形参),当一个函数执行完毕返回之后,它所占用的那帧内存将被释放。

栈还有一个名称叫堆栈,堆栈与堆没有关系。

系统对栈空间大小进行了限制,最大一般为8MB,可以通过以下代码来查看系统对栈空间大小的限制。

系统对栈空间大小进行了限制,最大一般为8MB,可以通过以下代码来查看系统对栈空间大小的限制。

cat  /proc/1/limits

什么东西存储在栈内存中?

1. 环境变量

2. 命令行参数(传递给你程序)

栈中的环境变量和命令行参数在程序开始运行时就被固定在栈底,且进程在整个运行期间不再发生变化;假如进程运行时对环境变量的个数或值做了修改,则为了能够容纳修改后的内容,新的环境变量将会被复制放在**堆**中。

3. 局部变量(包括形参)

4. 为了可以实现函数的嵌套调用和返回,栈还必须包含函数切换时当下的代码地址和相关寄存器的值,这个过程称为保存现场,等被调用函数执行结束之后,再恢复现场。

栈内存有什么特点?(运行时栈)

空间有限,尤其在嵌入式环境下。因此不可以用来存储尺寸太大的变量。
每当一个函数被调用,栈就会向下增长一段,用以存储该函数的局部变量(包括形参)。
每当一个函数退出,栈就会向上缩减一段,将该函数的局部变量所占内存归还给系统。
注意:
栈内存的分配和释放,都是由系统规定的,我们无法干预。

示例代码:
void func(int a, int *p) // 在函数 func 的栈内存中分配
       {
            double f1, f2;        // 在函数 func 的栈内存中分配
        ...                   // 退出函数 func 时,系统的栈向上缩减,释放内存
    }

    int main(void)
    {
           int m  = 100;  // 在函数 main 的栈内存中分配
        func(m, &m);  // 调用func时,系统的栈内存向下增长
    }


2.2 堆内存(heap)

堆内存(heap)又被称为动态内存、自由内存,简称堆。堆是唯一可被开发者自定义的区段,开发者可以根据需要申请内存的大小、决定使用的时间长短等。但又由于这是一块系统“飞地”,所有的细节均由开发者自己把握,系统不对此做任何干预,给予开发者绝对的“自由”,但也正因如此,对开发者的内存管理提出了很高的要求。对堆内存的合理使用,几乎是软件开发中的一个永恒的话题。

堆内存基本特征:

1. 相比栈内存,堆的总大小仅受限于物理内存,在物 理内存允许的范围内,系统对堆内存的申请不做限制。

2. 相比栈内存,堆内存从下往上增长。

3. 堆内存是匿名的,只能由指针来访问。

4. 自定义分配的堆内存,除非开发者主动释放,否则永不释放,直到程序退出。

相关API:

功能:将ptr所指向的堆内存大小扩展为size
原型:void *realloc(void *ptr,size_t size)
返回值:成功:返回扩展后的内存的基地址,可能等于扩展前的地址,也可能不等于。
       失败:返回NULL
备注:当size为0时,该函数相当于free(ptr)
           
           
示例:
int *p = malloc(sizeof(int)); // 申请1块大小为 sizeof(int) 的堆内存
bzero(p, sizeof(int));        // 将刚申请的堆内存清零

*p = 100; // 将整型数据 100 放入堆内存中
free(p);  // 释放堆内存

// 申请3块连续的大小为 sizeof(double) 的堆内存
double *k = calloc(3, sizeof(double));

k[0] = 0.618;
k[1] = 2.718;
k[2] = 3.142;
free(k);  // 释放堆内存    


注意:

malloc()申请的堆内存,默认情况下是随机值,一般需要用 bzero() 来清零。
calloc()申请的堆内存,默认情况下是已经清零了的,不需要再清零。
free()只能释放堆内存,并且只能释放整块堆内存,不能释放别的区段的内存或者释放一部分堆内存。
释放内存的含义:

释放内存意味着将内存的使用权归还给系统。
释放内存并不会改变指针的指向。
释放内存并不会对内存做任何修改,更不会将内存清零。

2.3 数据段

数据段的大小在进程一开始运行就是固定的。
地址从高到低,将数据段分为.bss段、.data段、.rodata段三部分。

1. .bss段专门用来存放未初始化的静态数据(static修饰的局部变量、static修饰的全局变量以及全局变量),它们会在程序刚运行时被系统初始化为0;在程序文件中,它们是没有值的。

2. .data段专门存放已经初始化的静态数据,这个初始值从程序文件中获取

3. .rodata段用来存放只读数据,即常量;比如进程中所有的字符串、字符常量、整型数据、浮点型数据等。

静态数据-static

C语言中,静态数据有三种:

1. 普通全局变量:定义在函数外部的变量。

2. 静态局部变量:定义在函数内部,且被static修饰的变量。

3. 静态全局变量: 定义在函数外部的变量。且被static修饰的变量。

int a; // 全局变量,退出整个程序之前不会释放, 默认被初始化为0,存放在数据段中的 .bss
void f(void)
{
    static int b; // 静态局部变量,退出整个程序之前不会释放,默认被初始化为0,存放在数据段中的.bss
    printf("%d\n", b);
    b++;
}

int main(void)
{
    f();
    f(); // 重复调用函数 f(),会使静态局部变量b的值不断增大
}


为什么需要静态数据?

1. 全局变量在默认的情况下,对所有文件可见,为某些需要在各个不同文件和函数间访问的数据提供操作上的方便。

2. 当我们希望一个函数退出后依然能保留局部变量的值,以便于下次调用时还能用时,静态局部变量可帮助实现这样的功能。

注意1:

若定义时未初始化,则系统会将所有的静态数据自动初始化为0
静态数据初始化语句,只会执行一遍。
静态数据从程序开始运行时便已存在,直到程序退出时才释放。

注意2:

static修饰局部变量:使之由栈内存临时数据,变成了静态数据–生命周期变了作用范围
不变。

static修饰全局变量:使之由各文件可见的静态数据,变成了本文件可见的静态数据。

static修饰函数:使之由各文件可见的函数,变成了本文件可见的静态函数。

2.4 代码段

地址从高到低,将代码段分为.text段以及.init段两部分。

.text段也叫正文段,用来存放用户程序代码(所有用户自定义函数)。
.init段用来存储系统给每一个可执行程序自动添加的初始化代码,这部分代码功能包括:环境变量的准备、命令行参数的组织和传递等,并且这部分数据被存放在栈底。

int a;       // 未初始化的全局变量,放置在.bss 中
int b = 100; // 已初始化的全局变量,放置在.data 中

int main(void)
{
    static int c;       // 未初始化的静态局部变量,放置在.bss 中
    static int d = 200; // 已初始化的静态局部变量,放置在.data 中
    
    // 以上代码中的常量100、200放置在.rodata 中
}

注意:数据段和代码段内存的分配和释放,都是由系统规定的,我们无法干预。
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

爱思考的发菜_汽车网络信息安全

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值