在榜首篇文章:计算机底层1 如何从编程言语一步步到可履行程序中,咱们说到,计算机体系中负责计算的是CPU,在这一篇文章中,咱们将要介绍计算机体系中负责存储的内存。
1 内存的本质
从最小的视点看,内存便是一个个最小存储单元组成的,可是每个存储单元要么寄存0,要么寄存1,由于程序在内存中运转,计算机是以二进制方式作业的。最小存储单元也便是比特(bit),也便是1bit要不是0,要不是1。
8个bit 形成一个字节(byte),此刻,咱们为每个字节编号,每个字节都在内存中有相应的地址,这便是内存地址,经过内存地址咱们能够找到这个字节的数据,这便是寻址。
咱们常用4个字节为一个单位,来表示整数。比方int
,一个int
有2^32次方中组合,所以最小的负整数是0x80000000
, 最大的正整数是0x7FFFFFFF
,在十进制表达下,最多也便是10位数,在一些算法中,为了更好的鲁棒性,需求对输入的数字位数做判断,在实践开发中,有的新手会用int
类型来贮存11位的手机号,这就简略呈现溢出过错,导致程序不正常运转或发生不正确的结果。
在当一个变量不仅仅能够保存数值时,还能够保存内存地址时,指针就诞生了。
指针便是贮存着内存地址的变量,是内存地址的更高档的笼统。
也便是说,假定咱们知道一个指针的地址,咱们也就知道了这个指针里贮存的其他一个变量的地址值,经过这个地址值,咱们就能够找到这个地址值上的变量贮存的东西:
如上图所示,咱们知道了地址0x16fd00672
,也就知道了这个地址中的数据Ox16fdff138
,也是一个地址,所以这个指针指向了内存地址为Ox16fdff138
的变量,也就知道了里边贮存的变量42
。
已然指针能够指向其他一个内存地址,那么说明,本来松懈的内存空间能够经过指针连接起来,也就诞生了大学数据结构课里边学的链表,二叉树,图等等复杂的数据结构。
在一些言语里边,把指针给笼统掉了,比方Java,python 里边的变量,“看上去”只能存储数值,而在比方C言语这种对底层有强壮操控力的言语中,关于变量的了解也更加贴近底层。
能够直接看到内存地址,是一种十分强壮的才干,但同时也是一种损坏力很强的才干。有了指针这种能够直接拜访内存的概念,程序员就能够直接操作内存这种硬件,这意味着能够绕过一切笼统,直接对内存进行读写,也意味着或许会直接损坏程序运转时的状况。
2 进程在内存中的样子
咱们在榜首篇文章中介绍过进程的内存地址空间散布:
咱们相同讲过虚拟内存,也便是上面这张内存地址空间的表在实在的物理空间中并不存在,在物理内存中,进程被区分成了巨细相同的“块”,随意地散落在物理内存中。咱们只需求保护好虚拟内存和物理内存之间的映射联系的页表即可。
其他,咱们并不需求保护每个虚拟地址到物理地址的映射,而是将进程地址空间区分为巨细持平的“块”,这一“块”便是一页。
而且,每个进程都有属于自己的页表。这便是为什么即便两个进程向同一个地址写数据也不会有问题,由于实践上它们指向的是不同的物理内存地址。
3 栈区
栈是一种很常见的数据结构,有先进后出(Fist In Last Out)的特点。程序在运转时,栈区里边的栈帧也是这个次序:
假定现在有几个函数,函数A 调用函数B,函数B 调用函数C,那么函数运转时栈区的变化便是:
3.1 函数跳转与回来
当函数A 调用函数B 时,咱们需求知道的是函数B 的榜首条指令和调用完函数B,应该怎样回到函数A。
一般状况下,函数跳转的指令后边都会跟着函数要跳转的地址,例如:
call Ox16fdff138
那么Ox16fdff138
便是函数B 的榜首条指令,至于回到函数A,也很简略,把call 指令的下一条指令放到函数A 的栈帧中,由于栈帧遵循先进后出的规律,在最终一条函数B 的指令指令履行完后,自然栈顶指向的便是函数A 的原下一条指令。
3.2 参数传递回来
在x86-64中,多数状况下,参数的传递与获取回来值是经过寄存器完结的。假定函数A 调用了函数B,函数A 将一些参数写入相应的寄存器,当CPU 履行函数B 时,能够从寄存器中获取参数,相同,函数B 也能够将回来值写入寄存器,函数B 履行完毕后,函数A 能够从寄存器取到回来值。
可是寄存器的空间是有限的,假定当参数数量多于寄存器数量的时分,剩余的参数能够放到栈帧中,这样被调用的函数就能够早年一个函数的栈帧中获取参数了。
3.3 局部变量
局部变量相同能够放在栈帧中,可是假定当局部变量的数量超越寄存器时,这些变量也是要放在栈帧中的。
3.4 寄存器的保存与恢复
有这样的状况:寄存器是CPU 内部资源,CPU 履行函数A时,会运用这些寄存器,函数A 调用函数B,当CPU 履行函数B时,也会运用这些寄存器,那么函数A 放在寄存器中的信息有或许会被函数B 掩盖。
那么很简略想到,咱们应该把寄存器的原始值保存起来,便于后续即便被掩盖了也能恢复。那么把原始值保存到哪里呢?没错,依然是放在栈帧中。
4 堆区
在内存办理中,栈区是用于存储函数运转时信息,局部变量的,当函数调用完结,本来的栈帧信息就会被主动收回。而堆区是用于贮存动态分配得到的内存,需求程序员手动办理内存。
现在假定有一个变量需求横跨多个函数,这个时分,咱们有或许会考虑全局变量,可是全局变量是对一切模块敞开的,这并不安全,咱们并不想这个变量暴露给一切模块,可是同时咱们希望自己办理它的生命,直到咱们以为咱们不再需求它,就将其开释。这个时分,咱们就能够在堆区中恳求一块内存。
C/C++ 中能够经过malloc/new
来在堆区恳求内存,当不需求时,就运用free/delete
来开释。
Objective-C 中,能够经过alloc/new
来在堆区恳求内存,假定是ARC机制,就会主动引证开释,也不需求程序员手动free
。
以上就满足程序员在日常开发中对堆区的运用了,可是在底层的学习中,咱们希望深入了解堆区内存分配器的原理。
咱们或许能够自己手动完结一个malloc 内存分配器。
4.1 把内存组织起来办理
其实咱们的方针很简略:便是要在堆区上处理两个中心问题:
-
完结一个
malloc
函数,也便是假定有人向我恳求一块内存,我应该怎样样从堆区中找到一块内存回来给恳求者 -
完结一个
free
函数,也便是当某块内存用完了,我应该怎样还给堆区
首先,用户恳求的内存是巨细不一的:有8个字节的,有16个字节的,有32个字节的,那么咱们就需求把内存块用某种方式组织起来。
在前面咱们说了,以指针为中心的链表这种数据结构能够把松懈的内存连接起来,这正契合咱们要完结的一整块内存分配,可是实践上,咱们不能像在数据结构课上那样先创建链表,再用来记载信息,由于创建链表自身就需求恳求内存,就要经过内存分配器。所以,咱们必需要把链表与内存的运用信息与内存块自身放在一同,这个链表没有一个显示的指针来告诉咱们下一个内存块在哪,可是咱们能够经过内存运用信息推断出下一块内存节点的方位。
内存的运用信息只需求记载:
- 一个符号,用来标识该内存块是否闲暇
- 一个数字,用来记载该内存块的巨细
由于咱们的内存块上限为2GB,所以咱们能够运用1个比特位来符号该内存块是否闲暇,用31个比特位来记载该内存块的巨细。
这样就形成了一个“链表”:咱们假定知道header 的内存地址,那么咱们也知道了该内存块的巨细,而且header 的内存巨细固定为32bit,那么header 再加上块巨细,便是下一个节点的起始地址,经过这种办法,把内存块连接起来了,运用header 信息,能够遍历一切的内存块。
4.2 内存分配
咱们的内存分配器现已完结内存的连接了,下一步,当用户恳求内存的时分,内存分配器需求找到一个巨细适宜的闲暇内存块,假定用户要恳求4字节的内存,8字节的,16字节的,32字节的,都契合要求,那应该分配那块更好呢?这便是内存分配战略。
- First Fit
最简略的便是每次从头开端查找,找到榜首个满足要求的,就回来。可是很明显,由于这种战略是从头开端,因而很简略在前半部分由于分配内存剩余很多小的内存块,导致下一次内存恳求查找的闲暇内存块数量会越来越多,或许导致查找性能下降。
- Next Fit
KMP 算法(用于字符串匹配的一个经典算法)的发明者之一(K)提出,这个算法也很好了解,和First Fit 不同的是,First Fit 是从头开端查找,而Next Fit 是从上次找到适宜的内存块的方位开端查找,因而,Next Fit 的理论查找速度是大于First Fit 的,可是也有研究标明在内存的运用率上,不及First Fit 战略。
- Best Fit
Best Fit 办法会遍历一切的内存块,然后将满足要求的而且巨细最小的那个闲暇内存块回来,很明显,这种战略更加能够合理运用内存空间,可是也很明显:在速度上远远不及First Fit 和Next Fit 战略。
当然,这里仅仅简略介绍,真实工业级的内存分配器是十分复杂的。
现在咱们找到了适宜的内存,假定咱们恳求的是12字节的内存,而找到的闲暇内存块巨细可供分配出去的也刚好是12字节(16字节减去4字节header),那么这个时分,咱们只需求把header 的符号位符号为已分配,再回来header 之后的地址给用户即可。
可是也有一些状况,比方恳求的仍是12字节,可是分配到的仍是32字节,这样会导致内部有内存被糟蹋,形成内存碎片。
常见的办法是将闲暇内存块进行区分,前一部分设置为已分配而且回来,后一部分变成一个新的内存块。
4.3 开释内存
到现在为止,咱们已的malloc 现已能够分配内存了,还差最终的开释内存。
开释内存的操作很简略,只需求把咱们在恳求内存时得到的地址再减去header 的巨细,就得到了header 信息的内存首地址,然后将其符号为闲暇即可。
可是有一些状况,比方当与开释的内存块相邻的内存块也是闲暇时,假定仅仅只把它符号为闲暇,那么则会呈现下面这种状况:
假定这个时分要恳求20字节的内存,那么即便实践上有20字节的空间,也会失利。
因而,更好的办法是假定相邻内存块是闲暇的,就将其合并成更大的闲暇内存块。
可是新的问题又呈现了:
由于咱们知道header 信息,所以咱们能够很简略知道后一个内存是闲暇的,可是咱们要怎样知道上一个内存块是闲暇的呢?
由于有了信息头header,所以咱们知道后一个内存,那咱们是不是还能够加一个信息尾footer,让footer 信息和header 一样,可是有了footer,咱们经过footer 的地址再减去footer 里边记载的块巨细,咱们就能够得到上一个内存了。
这就像一个隐式的双向链表。
至此,咱们简略的一个内存分配器设计就完毕了,当然,这仅仅简略的原理,实在的分配器是十分复杂的。
5 恳求内存时发生了什么
在刚刚咱们介绍了内存分配器的原理,现在咱们把视角扩展一点,看看CPU 处理内存分配的全过程。
5.1 内核态和用户态
CPU 一般被以为有两种常见的状况:内核态和用户态。每个程序有自己的等级,对应的是CPU 的作业状况,等级越低,CPU 的特权越大,内核态的CPU 特权最大。相反,处在用户态的程序处处受限,由于CPU 的特权小,不能拜访特定的地址空间,不然程序直接完毕,这便是经典的段过错:Segmentation fault
。
操作体系就处于内核态,普通的程序处于用户态。
5.2 体系调用与规范库
在有些场景下,应用程序是需求恳求操作体系的服务的,比方文件的读写、网络数据。操作体系也为程序员供给了一种叫体系调用(System Call) 的机制,经过 System Call
,就能够让操作体系来替代咱们完结一些工作。当然,System Call
都被封装起来了,程序员一般不需求自己直接进行 System Call
,这是由于有了规范库来屏蔽体系差异。
本来体系调用都是和操作体系强相关的,Linux 和Windows 的完全不同,因而咱们需求制定规范,对运用者屏蔽差异,这样程序员的写的程序就能够在不同操作体系上运转了。
在C言语中,这便是规范库。规范库的代码也运转在用户态,一般来说,程序员都是经过规范库去进行文件读写,网络通信的,规范库再依据详细的操作体系挑选对应的体系调用。
所以,最上层是应用程序,应用程序一般只和规范库打交道,规范库经过体系调用和操作体系交互,操作体系再办理底层硬件。
5.3 堆区内存不行了,向操作体系恳求内存
假定内存分配器中的闲暇内存块不行用了,那就会在内存区中拓荒新的区域,那么在哪里拓荒呢?答案是在栈区与堆区之间的闲暇区域。咱们之前在讲栈区的时分说过,栈区会跟着函数调用深度的添加而向下占用更多的内存,相应地,当堆区空间缺乏时,也能够向上占用更多的空间。
咱们运用的malloc
在内存缺乏时,会向操作体系恳求内存,比方在Linux 中,每个进程都保护了一个叫做 brk
的变量,指向堆区顶部,便是用来增大或者减小堆区的。
所以这样下来,恳求内存就不仅仅只局限在用户态的堆区了,假定malloc
没有找到闲暇内存块,就向操作体系宣布恳求(比方brk
)扩展堆区,这个时分,CPU 处于内核态,增大了堆区后,malloc
再次找到适宜的闲暇的内存块,分配内存。
5.4 虚拟内存
咱们之前说过,进程的内存地址空间都是虚拟的,所以在经过malloc
恳求到的内存或许仅仅一张“言而无信”,由于有或许这个时分该地址空间还没有映射到详细的物理内存上,那么什么时分才会真实的分配内存呢?答案是分配物理内存被推迟到了真实运用该内存的那一刻,此刻会发生一个缺页过错(page fault),由于虚拟内存还没有关联就任何物理内存,操作体系捕捉到page fault 后,就会开端分配真实的内存,经过去修正页表建立好虚拟内存与该实在物理内存之间的映射联系,此后程序就能够运用该内存了。
所以,只要操作体系才干分配真实的内存,其发生在内核态,malloc
仅仅是内存的二次分配,而且分配的仍是虚拟内存。
5.5 分配内存的完好流程
当malloc
开端恳求内存时,
-
malloc
开端查找闲暇的内存块,假定能够找到一个巨细适宜的就分配出去 - 假定
malloc
找不到适宜的闲暇内存,那么就会调用brk
等体系调用,扩展堆区,从而获得更多的内存空间 -
malloc
开端调用brk
后,CPU 转入内核态,此刻操作体系中的虚拟内存开端作业,扩展栈区 -
brk
完毕后,CPU 从内核态切换到用户态,malloc
找到一个适宜的内存块后回来 - 程序恳求到了内存,持续运转
- 当程序真实要用到重新恳求的内存时,体系内部呈现缺页中止(page fault),此刻CPU 再次从用户态回到内核态,操作体系开端修正页表建立好虚拟内存和物理内存的映射联系,也便是说:操作体系开端真实分配物理内存,完结后,CPU 再次回到用户态,程序得以持续。
6 内存池
可是有一点,假定频频的malloc
恳求开释内存,无疑对体系性能有必定影响,尤其是在对体系性能比较高的场景。
咱们能够运用内存池技能,也便是针对某种场景完结自己的内存分配战略。
6.1 内存池与内存分配器的对比
- 之前咱们说到的
malloc
是规范库的一部分,可是内存池是处在应用程序层面的。
- 咱们刚刚介绍的内存分配器设计完结比较复杂,而内存池技能就不一样了,内存池是针对某种场景去优化分配内存性能的,通用性低。
6.2 内存池完结技能原理
内存池技能是内存分配器的再一次笼统,一次性恳求一大块内存,在其之上自己办理内存的分配和开释,这样就绕过了规范库和操作体系。
也便是先恳求一大堆内存,运用的时分拿出一个,运用完再放回去,不过这样只能分配特定的数据结构对象。
假定要完结略微复杂的巨细可变的内存池,能够用链表的形式把一切的内存块链接起来,再用一个指针指向当时闲暇块的方位,当内存缺乏时,能够向malloc
恳求新的内存块。
6.3 内存池的线程安全问题
要怎样为每个线程保护一个线程池呢?在上一篇文章中说到的线程局部存储就派上用场了,咱们能够将内存池放在线程局部存储中,这样每个线程都只会操作自己的内存池,不会影响到其他线程。
7 内存相关经典bug
与内存相关的bug 难以排查,当程序呈现异常时,或许间隔真实有问题的代码现已很远了,这导致问题的定位排查十分困难。
7.1 回来局部变量指针
初学指针的人经典过错:
int* func() {
int a = 2;
return &a;
}
int main() {
int *p = func();
*p = 20;
return 0;
}
a
是局部变量,坐落栈区,在函数完结后,会被主动收回,因而,a
本应该不复存在,地址也是无效的,或者在其他函数履行时占用这块地址,也便是这块本来是a
的地址将会被掩盖,那么*p
实践便是在修正其他函数的栈帧,这也说明了为什么一旦发现呈现异常,离真实出问题的这行代码现已很远了。
7.2 过错地了解指针运算
int sum(int *arr, int len) {
int sum = 0;
for(int i = 0; i < len; i++) {
sum += *arr;
arr += sizeof(int);
}
return sum;
}
上面这段代码原意是想给数组里边的数加和,可是过错的了解了指针运算,其实咱们并不需求关心指针指向的数据类型,指针指向的数据类型的巨细便是1个单位,也便是不必arr += sizeof(int);
,只需求arr += 1;
即可。
7.3 解引证有问题的指针
也是初学者常常写出的代码:
int a;
scanf("%d", a);
这种代码在编译时并不会报错,由于scanf会把a 的值当作地址对待,而且从规范输入中获取的数据写到该地址中。
接下来,程序的表现就取决于a 的值了,而上述这段代码中a 的值是不确定的,那么就会呈现以下几种状况:
7.4 读取未初始化的内存
void add() {
int *arr = (int*)malloc(sizeof(int));
*arr += 10;
}
上面这段代码过错的以为从堆上动态分配的内存总是被初始化为0,可是实践上分为两种状况:
-
malloc
自己保护的内存够用时,malloc
会从闲暇的内存块中找到一个回来,可是这块内存有或许之前运用过,仅仅被符号为了闲暇,那么这块内存实践上还保存有上次的信息,此刻不必定为0。 - 假定是
malloc
自己保护的内存不行用时,就会brk
体系调用,向操作体系恳求虚拟内存,这个时分在真实运用时,会触发缺页中止,操作体系再去分配真实的物理内存,这个时分有能够改内存会真实初始化为0.
7.5 数组越界
不同的编程言语在处理数组越界问题时有不同的行为。
有些言语在发现数组越界了,就马上停止程序而且给出停止信息,这种状况下便于排查问题,可是有些言语并不会报错,而是任由越界的数据损坏数组以外的内存空间,那么后续当本来的数据要运用时,就发现原数据现已被越界的数组修正损坏了。
7.6 栈溢出
刚刚的数组越界是发生在堆上的溢出,而像函数等发生在栈帧上的溢出更加简略导致问题,由于栈帧中保存有函数回来地址等重要的信息。
前期黑客运用栈溢出缝隙的原理便是根据对程序中的缓冲区溢出进行运用。
以下是前期黑客运用栈溢出缝隙的一般原理:
- 栈内存结构:在程序履行期间,栈用于存储函数的局部变量、函数参数和回来地址等数据。栈一般是向下增长的,也便是说,新的数据被压入栈时,栈指针向低地址方向移动。
- 缓冲区溢出:当程序接纳用户输入并将其存储在栈中的缓冲区(如字符数组)时,假定用户输入的数据超越了缓冲区的容量,剩余的数据或许会掩盖到栈中的其他数据,包含函数回来地址。
- 掩盖回来地址:黑客运用这一特性,成心结构输入数据,使之超越缓冲区的边界,从而掩盖到保存函数回来地址的栈中方位。这样,当函数测验从函数完毕后回来到回来地址时,它实践上会跳转到黑客所操控的地址,而不是预期的回来地址。
- 履行恶意代码:黑客将恶意代码插入到掩盖的回来地址处。一旦操控流跳转到这个地址,恶意代码就会被履行。这使黑客能够履行各种攻击,例如履行恣意代码、提高权限、绕过安全措施等。
7.7 内存泄漏
void memory_leak() {
int *arr = (int*)malloc(sizeof(int));
return;
}
以上这段代码问题也很明显:恳求了内存后一刻回来,该内存再也没有机会被开释了。这便是内存泄漏。这会导致堆区越来越大,直到进程被操作体系停止。
8 为什么不是SSD?
SSD 是 “Solid State Drive”(固态硬盘)的缩写,是一种用于存储数据的媒体设备。
许多现代计算机,特别是笔记本电脑、台式机和服务器,都装备了固态硬盘(SSD)。这些设备一般会更快地启动,运转应用程序更流通,由于 SSD 具有更快的数据读取和写入速度。
可是,SSD 能够取代内存吗?
答案是不能。
- 首先是速度:尽管SSD 与传统的机械硬盘(HDD)比较,速度快得多,可是和内存比较,还有一个数量级的差异,假定真的把SSD 当作内存运用,那么计算机的运转速度或许会比当时的运转速度慢上10倍。
- 其他一个便是数据读写上:内存的寻址粒度是字节级其他,也便是说每个字节都有它的内存地址,可是SSD 的寻址粒度是”块“级其他,“块”的巨细各有差异,CPU 没办法直接拜访文件中的某个特定字节,也便是不支持“字节寻址”。因而,CPU 没办法在SSD 上运转程序。
- 虚拟内存限制:关于32位体系来说,最大寻址规模只要4GB,即便SSD 有1TB,进程能够真实用到的也不会超越4GB
- 运用寿命:SSD 的制造原理决定了这类存储设备是有运用寿命的。
9 总结
内存和CPU 是计算机中的中心部件,内存中贮存着CPU 履行指令依靠的一切信息。
在内存中又区分出了栈区,堆区,数据区,代码区。函数的调用信息,局部变量贮存都在栈区,在函数调用完结也会主动收回栈区资源,堆区用于动态分配内存,需求程序员手动保护生命周期,数据区里边是全局和静态变量,代码区存储正在履行的程序的机器指令。
在物理内存的基础之上,咱们有了虚拟内存,虚拟内存让进程以为自己独占整个内存空间,这样程序员能够在一片接连的地址空间编程,这带来了极大便当。
10 下一篇文章
计算机底层4 CPU
11 参考资料
- 陆小风.计算机底层的秘密. 电子工业出版社, 2023.
- 计算机底层的秘密 gitbook