系统相关
首页 > 系统相关> > 揭开内存的秘密,让一切真相大白

揭开内存的秘密,让一切真相大白

作者:互联网

从设计原来上来讲,CPU自身是不存取数据的,指令和数据必须通过内存才能读入CPU,然后将计算结果返回到内存中,也就是说CPU只负责计算和处理指令,其内容和结果都保存在内存中,掌控了内存就控制了一切,能够与硬件直接对话的底层语言都是围绕内存工作的。

虚拟内存

在计算机发展初期,程序是直接运行在硬件之上的,这时cpu和内存完全由程序员管理,需要程序员自己处理各种边界条件和安全问题,此时程序所操作的内存是真实的,也是不安全的。直接在硬件上写程序,优点是代码透明,性能高,缺点是大部分精力都放在解决各种硬件问题,效率极其低下,后来人们不能忍受这种繁杂的工作,于是发明了操作系统,让操作系统管理硬件,例如分配cpu和内存资源,程序运行时好像自身拥有整个cpu和内存,程序员可以将精力放在业务逻辑上,实际上程序使用的cpu资源和内存地址是虚拟的,操作系统成为建立在硬件基础上的硬件抽象层,这和虚拟机的概念相似,区别是操作系统是建立在硬件之上的中间层,使得程序可以跨越硬件,虚拟机是建立在操作系统和语言之间的中间层,使程序可以跨平台,在不同的操作系统中运行。语言层次越高,开发效率越高,性能越低,因此从开发效率上看java>c>汇编,从性能上看汇编>c>java。当拥有了操作系统这个中间层后,如果程序依然能够直接操作内存,那么就相当危险了,一个恶意程序不仅会破坏其它的程序的内存,还会威胁到操作系统自身,不同程序之间也可能发生内存地址冲突。可以说现代程序都是建立在操作系统上的半成品,它们不仅需要调用操作系统提供的接口,还由操作系统统一分配资源并协调多个进程运行,虚拟内存在运行时会被操作系统映射为实际物理地址,这使得各个程序使用的内存相互隔离,互不影响,同时当一个程序发生内存溢出或者占用过多cpu资源时操作系统会强行停止它。操作系统还会通过设置内存权限提升安全性,例如用于保存数据的内存没有修改权限,也没有执行权限,而操作系统自身占用的内存连读取权限都没有。有了操作系统这个中间层后,不仅程序开发效率得到巨大的提升,用户的安全性也得到保障,一个程序崩溃不会导致整个系统瘫痪,多个程序也能同时运行,充分利用硬件资源。另一方面,由于操作系统会消耗一部分内存,应用程序能够使用的内存要小于物理内存,而实际工作中所有的应用程序可能会超出剩余的内存大小,甚至超出物理内存大小,这时操作系统还会将硬盘空间当作内存使用来降低程序运行的门槛,因此虚拟内存实际上包含物理内存和硬盘空间,很多书籍只将硬盘代替内存使用的那部分称为虚拟内存,这是不全对的。

虚拟内存的大小除了硬件限制外,还被操作系统和编译模式限制,如果编译为32位,最大可使用的物理内存为232=4GB,64位应用程序最大可以使用的虚拟内存理论上为264字节,这个大小远远超过目前硬件所能制造的大小,使用64位进行寻址也会大大增加地址的转换成本,因此现在的64操作系统对虚拟地址进行了限制,只使用低48位,也就是256TB,像win7 64位专业版最大只支持192G内存。

内存分页

从上一节我们可以得出虚拟内存需要解决3个问题:
 虚拟地址到真实地址的映射
 设置权限解决安全问题
 使用硬盘代替内存解决大小限制问题
因为这些需求,仅通过偏移值处理虚拟内存到物理内存的映射是不够的,因为它不能解决权限问题,也不能解决硬盘替代问题;为每个虚拟内存地址到物理内存地址的映射创建一个数组也是行不通的,拿32位系统来说,内存地址使用4字节的无符号int来表示,最大寻址为2^32=4G,也就是说有4G个元素,如果数组的索引和元素值都使用int表示,数组的大小为44=16G,这远远超过了4G的大小,除此之外还需要空间描述权限和介质问题,这显然是不现实的。要合理解决虚拟内存到物理内存的映射,操作系统使用了内存分页技术,将虚拟内存分割为一块块的小内存,这些块称为内存页,映射表只记录虚拟内存页到物理内存页的地址映射,物理内存地址计算方式为:物理内存地址=物理页地址+虚拟页偏移。例如一个虚拟页内存地址为0xA0000000,映射的物理页地址为0xB000000,那么虚拟地址0xA0000001映射的物理地址为0xB0000001。使用分页技术后数组元素便大大减少了,从而让映射表的体积变得合理。32位操作系统的具体做法是:每页设置为4k,这样4G内存只需1M个页面,即数组元素个数为1M,由于每个元素的大小为4字节的int,映射表的大小变为1M4=4M,这个大小就可以接受了,接下来表示1M只需要1个int的20位,对于虚拟地址来说高20位表示虚拟页面地址,低12位表示页偏移地址,创建映射表数组时,元素索引为虚拟页面地址,元素值分为2个部分,高20位表示物理页面地址,物理页面地址+虚拟内存的低12位偏移值便可完成虚拟地址的映射,数组元素多出的低12位用于描述内存的权限和使用介质等信息,信息表的体积减小了,描述的信息反而增多了,是不是很聪明的想法?这种思路所形成的映射关系如下图:
在这里插入图片描述

当然分页也会带来一些问题,页面设置太大会导致内存空间浪费,因为向操作系统申请内存时会从新的页面开始,页面设置太小会导致映射表体积庞大,4k对于计算机来说是一个合理的值,4M的映射表对于当前计算机配置来说不值一提,上面的映射关系称为一级页表。然而对于内存空间宝贵的微型系统来说,4M都嫌多了,这时可以使用二级、三级或多级内存分页来进一步节省内存。

多级页表

一级页表的思路是不管应用程序是否用到了虚拟内存,一次完成所有虚拟内存的映射,因此32位系统页表固定大小为4M,为了进一步节省内存空间,可以只记录已经用到的虚拟内存,此时需要将一级页表进行拆分。二级页表将一级页表拆分成1024个小页表,每一个页表只记录一页,即有1k个小页表,每个页表大小仍然为4k。这些小页表在应用程序申请内存时生成,可以存在于不同的物理页,彼此之间也是不连续的,为了查找它们,还要建立一个页表目录,此时一个虚拟地址要同时包含页目录和页表才能找到物理地址,那么此时虚拟内存如何构成呢?我们先来看看一级页表的虚拟内存结构:
在这里插入图片描述

一级页表下的虚拟内存高20位用于查找物理页地址,物理地址记录在一级映射表中,因此虚拟地址的高20位存放映射表数组下标,也就是页表数组下标,低12位记录页偏移。二级页表的虚拟内存高10位用于查找小页的物理地址,这个物理地址记录在页目录中,因此高10位存放页目录的下标,中间10位仍然存放页表的下标,低12位仍然存放偏移量,结构如下图:
在这里插入图片描述

二级页表在映射时需要经过2次查找,第一次先通过高10位找到虚拟小页的物理地址,第二次通过中间10位在小页中查找物理页地址,最后通过偏移量算出物理地址,如下图:
在这里插入图片描述

对于64位操作系统,虚拟地址空间达到256TB,即使使用二级页表也会占据不少的内存空间,因此可以进一步拆分,思路与二级页表相同。对于多级页表,页表大小和应用程序所使用的虚拟内存成正比,当虚拟内存全部被使用这种极端情况下,多页页表所占用的空间反而更大,例如二级页表满载时体积为4M+4k,4k为增加的目录页,不过极端情况很少出现,对于性能来说,二级页表需要通过2次查找,3级页表需要通过3此查找,n级页表需要通过n此查找,级别越高查找越慢,由此可以看出,内存越大,分页级别越高,存取的代价就越高。

MMU 部件

从页表知识我们知道要完成虚拟地址到物理地址的映射要经过多次转换,还要进行计算,如果纯靠操作系统来完成,会成倍降低程序性能,如果能由硬件完成则会好很多,在cpu内部,有一个部件叫做mmu,它是专门负责将虚拟地址映射到物理地址的,如图:

在页映射模式下,CPU 发出的是虚拟地址,也就是我们在程序中看到的地址,这个地址会先交给 MMU,经过
MMU 转换以后才能变成了物理地址。即便是这样,MMU 也要访问好几次内存,性能依然堪忧,所以在 MMU 内部又增加了一个缓存,专门用来存储页目录和页表。MMU 内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的 10%的情况无法命中,再去物理内存中加载页表。
在这里插入图片描述

MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。对于多级页表,每个程序在运行时都有自己的一套页表,切换程序时,只要更新 CR3 寄存器的值就能够切换到对应的页表,CR3 是 CPU 内部的一个寄存器,专门用来保存页目录,在程序加载到内存以及程序运行过程中,操作系统会不断更新程序对应的页表,并将页目录中的物理地址保存到 CR3寄存器。MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射。有了硬件的直接支持,使用虚拟地址和使用物理地址相比,损失的性能已经很小,在可接受的范围内。

内存权限控制

前面我们讲过虚拟内存的低12位用于存放内存的控制信息,不管采用一级分页还是多级分页,这部分内容都不会发生变化,它表示该地址使用物理内存还是映射到硬盘,程序是否有访问权限,是否有执行权限等。更好的是MMU在映射地址时会处理该内存信息,MMU在收到虚拟内存地址时首先检查低12位,如果有权限则映射,如果没有权限则返回异常,操作系统在处理这个异常时一般比较粗暴,会直接终止该程序,然后报告该内存没有读写权限,或者非法操作。通常应用程序崩溃都会因为bug导致最后内存权限问题,如图:
在这里插入图片描述

如果你用C语言去操作该内存地址,也会得到同样的错误,例如:

#include <stdio.h>
int main()
{
	char *str = (char*) 0X000007FFF2E5FE2C4; //使用数值表示一个明确的地址
	str="abc";
	return 0;
}

注意上面的代码在不同的机器上不一定会报错,因为每台机器的运行环境和虚拟内存分布都是不同的,这里只用于举例。

Windows内存布局

Windows是闭源的,具体内存分布细节无法深究,windows只给出了简单说明:
 对于 32 位系统,内核占用较高的 2GB,剩下的 2GB 分配给用户程序;
 对于 64 位系统,内核占用最高的 248TB,用户程序占用最低的 8TB。
因此对于64位操作系统内存地址分配空间如下图:

在这里插入图片描述

Linux内存布局

因为Linux开源,所以其内存布局是透明的,Linux也分为内核空间和用户空间,对于32位系统,Linux会将高地址的1GB空间分配给内核,程序只能使用剩下的3GB内存。64位Linux将高 128TB 的空间分配给内核使用,而将低 128TB 的空间分配给用户程序使用,如下图所示:
在这里插入图片描述

可以看到对于64位系统,Windows用户内存区域有8T,Linux有128T,实际上凭当前计算机的配置内存达到1T都难,虽然定义的数量差别巨大,但在使用上没有什么区别。

内核模式和用户模式

前面讲过内存分为操作系统区和用户区,操作系统区所在的内存区域是不允许内存访问的,如果用户要执行比较底层的功能,例如输出输出、内存申请等,必须调用操作系统提供的接口而不能直接向硬件发号施令。应用程序在调用操作系统内核时,应用程序处于挂起状态,另外为了提升性能,内核程序和用户程序可能需要共享某段内存,因为如不能共享,只能通过频繁切换进程来交换数据,而切换进程消耗是巨大的,不仅需要寄存器进栈出栈,还会使 CPU 中的数据缓存失效、MMU 中的页表缓存失效,这将导致内存的访问在一段时间内相当低效。为了兼顾安全性和性能,程序运行分为内核模式和用户模式,从 Intel 80386 开始,CPU 可以运行在 ring0 ~ ring3 四个不同的权限级别,也对数据提供相应的四个保护级别,不过 Linux 和 Windows 只利用了其中的两个运行级别:一个是内核模式,对应 ring0 级,操作系统的核心部分和设备驱动都运行在该模式下。另一个是用户模式,对应 ring3级,操作系统的用户接口部分(例如 Windows API)以及所有的用户程序都运行在该级别。

当启动用户模式的应用程序时,Windows 会为该应用程序创建进程,进程为应用程序提供专用的虚拟地址空间和专用的句柄表格。由于应用程序的虚拟地址空间为专用空间,一个应用程序无法更改属于其他应用程序的数据。每个应用程序都孤立运行,如果一个应用程序损坏,则损坏会限制到该应用程序,其他应用程序和操作系统不会受该损坏的影响。用户模式应用程序的虚拟地址空间除了为专用空间以外,还会受到限制,在用户模式下运行的处理器无法访问为该操作系统保留的虚拟地址,以防止应用程序更改并且可能损坏关键的操作系统数据。

相反在内核模式下运行的所有代码都共享单个虚拟地址空间,这表示内核模式驱动程序未从其他驱动程序和操作系统自身独立开来,如果内核模式驱动程序意外写入错误的虚拟地址,则属于操作系统或其他驱动程序的数据可能会受到损坏,如果内核模式驱动程序损坏,则整个操作系统会瘫痪。

当内核空间和用户空间存在大量数据交互时,可以设置内核和用户共享地址空间,此时只需要进行模式切换而不需要切换进程,它能够最大限度的降低内核空间和用户空间之间的数据拷贝。

Windows内核

Windows的内核分为三层,最底层为硬件抽象层,中间为内核层,最上面为执行体,如下图所示:
在这里插入图片描述

硬件抽象层用于隔离硬件,我们安装的第三方驱动程序都在这里,内核层包括一些底层服务,例如内存管理、线程调度、IO管理以及文件系统、网络通信等,这些程序运行效率很高并且硬件在设计时也考虑到操作系统的优化,很多时候应用程序需要依赖这些服务实现底层操作,但是应用程序不能直接调用这些服务,因为它们处于内核模式,为了安全Windows提供一组系统DLL,最终通过ntdll.dll切换到内核模式下的执行体API函数中以调用内核中的系统服务。ntdll.dll是连接用户模式代码和内核模式系统服务的桥梁,对于内核提供的每一个系统服务,该DLL都提供一个相应的存根函数,以NT作为前缀。举个例子,当用户模式程序需要读取设备数据时,它就调用DLL提供的Win32 API函数,如ReadFile。ReadFile首先到达系统DLL(NTDLL.DLL)中的一个入口点,NtReadFile函数,然后这个用户模式的NtReadFile函数接着调用系统服务接口,最后由系统服务接口调用内核模式中的服务例程,该例程同样名为NtReadFile,换句话说就是ntdll.dll负责将用户模式的函数映射为内核模式的函数。系统中还有许多与NtReadFile相似的服务例程,它们同样运行在内核模式中,为应用程序请求提供服务,并以某种方式与设备交互。它们首先检查传递给它们的参数以保护系统安全或防止用户模式程序非法存取数据,然后创建一个称为“I/O请求包(IRP)”的数据结构,并把这个数据结构送到某个驱动程序的入口点。在刚才的ReadFile调用中,NtReadFile将创建一个主功能代码为IRP_MJ_READ的IRP。实际的处理细节可能会有不同,但对于NtReadFile例程,可能的结果是,用户模式调用者得到一个返回值,表明该IRP代表的操作还没有完成。用户模式程序也许会继续其它工作然后等待操作完成(异步),或者立即进入等待状态(同步)。不论哪种方式,设备驱动程序对该IRP的处理都与应用程序无关。驱动程序完成一个I/O操作后,通过调用一个特殊的内核模式服务例程来完成该IRP,完成操作是处理IRP的最后动作,它使等待的应用程序恢复运行。以上就是应用程序通过windows提供的API,经过内核的3层到达硬件的过程,可以直接理解为C语言的scanf()函数执行过程,scanf()不能直接调用内核函数,更不能直接访问硬件,而是通过windows提供的dll访问自身内核,在执行的期间还要经过多次校验以保证调用的安全性。

Windows子系统是系统不可缺少的组成部分,它与系统内核一起构成用户应用程序的执行环境,Windows子系统既有内核模式部分,包括图形和窗口管理,也有用户模式部分,包括一个单独的子系统进程和一组链接到各个应用程序中的系统DLL。

Linux内核

Linux内核功能和Windows相似,简单来说就是向下管理硬件,向上提供调用接口,架构如下图:
在这里插入图片描述

  1. Linux的内核要比Windows小很多,结构也相对简单,因此Linux的内核也称为微内核,它不像Window那样将底层功能做成一个个的服务,而是根据核心功能直接提出了5个子系统,分别负责如下的功能:
  2. Process Scheduler,也称作进程管理、进程调度,负责管理CPU资源,以便让各个进程可以以尽量公平的方式访问CPU。
  3. Memory Manager,内存管理,负责管理内存资源,以便让各个进程可以安全地共享机器的内存资源。另外,内存管理会提供虚拟内存的机制,该机制可以让进程使用多于系统可用的内存,不用的内存会通过文件系统保存在外部非易失存储器中,需要使用的时候,再取回到内存中。
  4. VFS(Virtual File System),虚拟文件系统。Linux内核将不同功能的外部设备,例如硬盘、输入输出设备、显示设备等等,抽象为可以通过统一的文件操作接口(open、close、read、write等)来访问。这就是Linux系统“一切皆是文件”的体现,其实Linux做的并不彻底,因为CPU、内存、网络等还不是文件,如果真的需要一切皆是文件,还得看贝尔实验室正在开发的"Plan 9”。
  5. Network,网络子系统。负责管理系统的网络设备,并实现多种多样的网络标准。
  6. IPC(Inter-Process Communication),进程间通信。IPC不管理任何的硬件,它主要负责Linux系统中进程之间的通信。
    在Linux中应用程序不能直接调用内核函数,用户模式是一种受限模式,如果用户访问了禁区,则用户进程将被杀死,用户模式必须通过系统调用或库函数切换至内核模式后,才允许访问硬件资源。

实际上Windows,Linux, mac以及世界上的大多数操作系统内核都是由C写的,因为C语言运行效率高,能够直接访问硬件,还有着可靠的移植性,操作系统提供的库函数也是用C写的,使用C语言调用操作系统内核功能具有优势,无需翻译也无需进行数据类型的转换。

C程序内存模型
程序内存在地址空间中的分布情况称为内存模型,对于一个32位的应用程序,一种经典的内存模型如下:
在这里插入图片描述

各个分区的说明如下:
在这里插入图片描述

其中程序代码区和常量区存是不可写的,一个存放二进制指令,一个存放数据,全局数据区是可以写的,这3个区在程序启动时加载到内存,大小是固定的,是静态内存,动态链接库存放C语言运行时需要用到的系统库,也是一个固定区域。大小变化的区域只有2个,一个是栈,一个是堆,栈中的内容由函数控制,函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。我们唯一能够控制的内存是堆,它占据了内存模型的绝大部分空间,如果不够用还可以向操作系统申请,堆中的数据只有主动释放或程序运行结束后才会被释放。

函数栈

从上节内容可知,栈处于内存地址较高的区域,而且空间可以向下增长,它存放函数参数、局部变量、局部数组等局部变量,作用就是辅助函数执行,当调用函数时动态分配空间,函数运行完毕后这段内存被回收。栈实际上是数据结构的一种,可以用链表实现,特点是存储方式采用先进后出,如下图:
在这里插入图片描述

生活中也有很多栈的例子,最典型的例子就是子弹弹夹,先放进弹夹的子弹总是在后面打出,栈的大小由两个位置标记,一个是栈底,一个是栈顶。当一个函数调用另外一个函数时,先执行的函数会被挂起,随之挂起的还有为它分配的空间,相当于入栈;当函数执行完毕后,会回到上一个函数调用点继续执行,占用的空间也会被释放,相当于出栈,与我们自己编写的栈不同的是,进栈出栈以及内存分配的工作都是由操作系统内核完成的,而且得到硬件的支持,cpu使用ebp(Extend Base Pointer)寄存器指向栈底,而使用 esp(Extend Stack Pointer)寄存器指向栈顶,由于栈底位于高地址,栈顶位于低地址,随着数据进栈出栈,esp 的值会不断变化,进栈时 esp 的值减小,出栈时 esp 的值增大,如下图所示:
在这里插入图片描述

可见程序栈是向下增长的,对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了(可以在编译前设置),程序运行期间不能再改变,如果函数使用的内存总和超出最大值,就会发生栈溢出(Stack Overflow)错误。VC/VS默认栈大小是 1M,C-Free 默认是 2M,Linux GCC 默认是 8M,也就是说如果我们在函数中创建局部数组过大,就可能发生栈溢出,在全局或堆上创建则好得多,因为全局和堆空间比栈大得多。另外即使局部变量和参数占用的内存不大,如果递归调用的层次太多同样会导致栈溢出。

函数在栈上是如何运行的

当发生函数调用时,会将函数运行所需要的全部信息压入栈中,这些信息称为栈帧(Stack Frame)或活动记录(Activate Record),一个栈帧包含以下几方面内容:

函数返回地址指的是函数执行完成后从哪里开始继续执行后面的代码,C 语言代码最终会被编译为机器指令,确切地说,返回地址应该是下一条机器指令的地址。对于参数和局部变量,有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中。编译器自动生成的临时数据比如当函数返回值的长度较大时,会先将返回值压入栈中,然后再交给函数调用者。一些需要保存的寄存器,例如 ebp、ebx、esi、edi 等,之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。下图是一个函数栈的示意图:
在这里插入图片描述

理论上栈帧应该处于ebp和esp之间,但在实际工作中ebp指向old ebp,这是为了方便函数执行完毕后出栈,old ebp记录函数调用之前寄存器的值。函数调用时先将实参、返回地址、ebp 寄存器压入栈中,这表示上一个函数开始挂起,然后再分配一块内存供局部变量、返回值使用,这块内存一般比较大,足以容纳所有数据,并且会有冗余,但如果算出分配的内存累加后超过编译时设置的栈大小就会报告栈溢出,最后将其他寄存器的值压入栈中,函数开始执行。执行完毕后按照先入后出的顺序分别弹出寄存器的值、局部变量、返回值,然后弹出old ebp,返回地址以及参数,恢复到调用之前的状态,然后接着执行下一条命令。在这个过程中,由于压入栈的数据都是临时的,不像全局变量有固定内存地址,而且之间可能会有间隙,还需要知道它们属于哪个函数,这时需要根据当前函数的ebp进行定位,ebp 的值加上偏移量就是数据的地址。实际上数据入栈的顺序也是可以调整的,不同的语言采用的顺序不一样,出栈方式也可能不同,必须采用一致的约定才能让调用方和被调用方协调,这种约定称为调用惯例(Calling Convention)。调用惯例包含几个方面内容:

可见,如果要使用不同语言编写的函数相互调用时,必须考虑到函数惯例,cdecl 是 C 语言默认的调用惯例,注意__cdecl 并不是标准关键字,上面的写法在 VC/VS 下有效,但是在 GCC 下,要使用__attribute((cdecl)),除了 cdecl,还有其他调用惯例,请看下表:
在这里插入图片描述

当需要使用函数惯例时,可以在函数声明或函数定义时指定,例如:

#include <stdio.h>

int  __cdecl max(int m, int n);

int main()
{
	int a = max(10, 20);
	printf("a = %d\n", a);
	return 0;
}

int  __cdecl max(int m, int n)
{
	int max = m>n ? m : n;
	return max;
}

现在像pascal这样的语言已经很少使用,因此函数惯例在实际编程中已经很少看到。为了更清晰的看到一个函数入栈和出栈的过程,我们使用一个具体的C语言代码来演示:

void func(int a, int b)
{
	int p = 12, q = 345;
}

int main()
{
	func(90, 26);
	return 0;
}

由于C语言采用默认管理cdecl,参数将从右到左入栈,由调用方负责将参数出栈,函数的进栈出栈过程如下图
所示:
在这里插入图片描述

进栈过程:
当程序启动时,main函数先进栈,由于没有任何参数和局部变量,因此栈中只有寄存器的值,进入步骤②后原来的函数挂起,开始执行语句func(90, 26),先将实参 90、26 压入栈中,再将返回地址压入栈中,这些工作都由main() 函数(调用方)完成。这个时候 ebp 的值并没有变,仅仅是改变 esp 的指向。到了步骤③,就开始执行 func() 的函数体了,首先将原来 ebp 寄存器的值压入栈中(也即图中的 old ebp),并将 esp 的值赋给 ebp,这样 ebp 就从 main() 函数的栈底指向了 func() 函数的栈底,完成了函数栈的切换,由于此时 esp 和 ebp 的值相等,所以它们也就指向了同一个位置。接着为局部变量、返回值等预留足够的内存,如步骤④所示。由于栈内存在函数调用之前就已经分配好了,所以这里并不是真的分配内存,而是将 esp 的值减去一个整数,例如 esp - 0XC0,就是预留 0XC0 字节的内存。接下来将 ebp、esi、edi 寄存器的值依次压入栈中,然后将局部变量的值放入预留好的内存中。注意,第一个变量和 old ebp 之间有 4 个字节的空白,变量之间也有若干字节的空白,为什么要留出这么多的空白,岂不是浪费内存吗?这是因为我们使用 Debug 模式生成程序,留出多余的内存,方便加入调试信息,以 Release 模式生成程序时,内存将会变得更加紧凑,空白也被消除,如果开启了编译器优化选项,所得到的内存可能差别更大。可以发现,在函数的实际调用过程中,形参是不存在的,不会占用内存空间,栈中只有实参,而且是在执行函数体代码之前、由调用方压入栈中的。为局部变量分配内存时,仅仅是将 esp 的值减去一个整数,预留出足够的空白内存,不同的编译器在不同的模下会对这片空白内存进行不同的处理,可能会初始化为一个固定的值,也可能不进行初始化,所以局部变量初始值可能为任意值。

出栈过程:
步骤⑦到⑨是函数 func() 出栈过程,函数 func() 执行完成后开始出栈,首先将 edi、esi、ebx 寄存器的值出栈,接着将局部变量、返回值等数据出栈,此时直接将 ebp 的值赋给 esp,这样 ebp 和 esp 就指向了同一个位置。接下来将 old ebp 出栈,并赋值给现在的 ebp,此时 ebp 就指向了 func() 调用之前的位置,即 main() 活动记录的 old ebp 位置,如步骤⑨所示。可以看到出栈是进栈的逆向操作,压入栈中的old ebp记录前一个函数挂起点的位置。实际上函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据,前面我们讲局部变量在函数运行结束后立即被销毁其实是错误的,这只是为了让大家更容易理解,对局部变量的作用范围有一个清晰的认识,栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。请看下面的代码:

#include <stdio.h>

int *p;

void func(int m, int n)
{
	int a = 18, b = 100;
	p = &a;
}

int main()
{
	int n;
	func(10, 20);
	n = *p;
	printf("n = %d\n", n);
	return 0;
}

在 func() 中,将局部变量 a 的地址赋给 p,在 main() 函数中调用 func(),函数刚刚调用结束,还没有其他函
数入栈,局部变量 a 所在的内存没有被覆盖掉,所以通过语句 n = *p;能够取得它的值。通过这个例子也可以看出,在C中全局变量不要引用局部变量地址,例如引用一个数组,当栈空间被覆盖时,数组可能消失。

栈攻击原理

由于C语言不检查数组边界,如果向函数中的数组写入过多的数据导致数组溢出,从而改写了后面栈内存,就会导致函数调用失败,例如一个函数栈内存如下:
在这里插入图片描述

字符串str的容量只有9个字符,如果超过这个数值,就会覆盖下面空白即寄存器值,此时可能导致栈溢出错误,如下图:
在这里插入图片描述

因为返回地址所在的内存被覆盖掉了,函数执行完毕后找不到下一条指令,所以返回错误。如果str是由外部输入的,例如gets(str),遇到黑客用户精心构造栈溢出,让返回地址指向恶意代码,那就比较危险了,这就是常说的栈溢出攻击。

在堆上动态分配内存

首先我们要搞清楚相关的名词,栈也叫堆栈,堆栈不是指的堆和栈,堆是一个单独的概念,英文名称是heap,栈的英文名称是stack。前面我们讲过,代码区、常量区、全局数据区属于静态存储区,在程序启动时就已经分配好了,它们大小固定,不能由程序员分配和释放,只能等到程序运行结束由操作系统回收。栈区和堆区的内存在程序运行期间可以根据实际需求来分配和释放,但是栈空间的申请和释放不受我们的控制,它只是辅助函数运行的,而且大小仍然有限,特点是分配和回收效率比较高,因为实际上只是通过修改ebp和esp的指向来操作的,这就是对已有的空间重复利用。堆是真正由我们控制的内存区域,也是唯一可以控制的内存区域,我们可以每次申请不同大小的空间存放任何类型的数据,而且可以在使用过程中随时调整大小,空间的释放也是手动进行的,可以说内存操作全盘掌握在自己手中。缺点是操作系统不会看管堆中的数据,如果用完忘记释放就会成为垃圾,由于C语言不像java那样有虚拟机帮助回收垃圾,不断制造垃圾会吃光所有的内存。其实运行在虚拟机中的那些高级语言,在运行时创建的对象或者动态数组也是在堆中创建的,但虚拟机可以帮你管理堆中的数据,当然在C中我们也可以模仿java虚拟机的垃圾回收机制来编写垃圾回收程序,但那也会遇到很大的挑战,而平时养成用完就回收的习惯是一种更好的选择。

现在我们来学习在堆上申请动态内存的方法,有2个方法,一个是malloc()函数,格式如下:
void malloc(size_t size)
这个方法在堆上申请一个空间,size_t实际上是无符号整数,申请后返回空间的首地址,失败返回NULL。需要说的是void类型,虽然这个类型我们已经很熟悉了,它表示函数不返回任何数据,但void的指针我们还是第一次见到。void其实是一个基本数据类型,它表示无类型,在程序中只能定义void类型的指针,不能定义void类型的变量,可以说
void是专门配合动态内存分配而设定的,它表示这段空间是一个纯地址,不代表任何类型的数据,void类型的指针可以和其它数据类型相互转换,而且这种转换是自动的,例如:

#include<stdio.h>

int main(int argc, char* argv[])
{
	int a=1;
	float b=3.33;
	
	int *p1=&a;
	float *p2=&b;
	void *p;
	
	p=p1;
	p=p2;
	p1=p;
	p2=p;
	
    return 0;
}

这里无需写成p=(void*) p1,或者p1=(int*) p,编译器会自动进行强制转换,当然添加强制转换代码会更加清晰。下面通过malloc()动态申请一个指定长度的数组。

#include<stdio.h>
#include<stdlib.h>

int main(int argc, char* argv[])
{
	unsigned int len;
	int* p;
	
	scanf("%ud",&len), getchar();
	p=malloc(len*sizeof(int));
	while(len--) *(p+len)=0;
    return 0;
}

动态内存分配函数都是stdlib库提供的,在使用之前需要导入stdlib.h,由于系统会自动转换voip指针,可以直接将任何数据类型的指针接受malloc()方法的返回值。这个例子中虽然将指针类型转化为了int指针,但数组并未初始化,需要手动将每个单元值设置为0,对于数组来说其实还有更方便的函数calloc(),格式为:
void* calloc(size_t num, size_t size)
num是单元个数,size是单元大小,而且calloc()还会将所申请的每个单元都初始化为0,这显然是为方便数组设计的,现在我们将上面的代码修改如下:

#include<stdio.h>
#include<stdlib.h>

int main(int argc, char* argv[])
{
	unsigned int len;
	int* p;
	
	scanf("%ud",&len),getchar();
	p=calloc(len,sizeof(int)); //得到一个默认值为0的数组 
    return 0;
}

前面说过要养成内存使用后记得释放的好习惯,释放动态内存的方法是free()函数,格式为:
void free(void* ptr);
ptr是之前用malloc()和calloc()申请的内存,这个方法只有一个参数且没有返回值,编译器又能自动进行类型转换,因此使用起来很简单,我们将上面的代码加上内存释放的代码:

int main(int argc, char* argv[])
{
	unsigned int len;
	int* p;
	
	scanf("%ud",&len),getchar();
	p=calloc(len,sizeof(int)); //得到一个默认值为0的数组 
	free(p); //用完记得释放内存 
	p=NULL; //将p设置为NULL以免再次使用该内存
    return 0;
}

没有释放的动态内存,操作系统只会在应用程序结束后清空堆中所有的数据。最后还有一个调整动态内存大小的函数realloc(),格式为:
void* realloc(void* ptr, size_t size)
其中ptr是需要扩展空间的首地址,size是新的空间大小,realloc()在扩展内存时会遇到两种情况,一种是原空间后有足够的空间进行扩展,此时返回的地址与原地址相同,另外一种情况是扩展时后面的空间不够用,此时只能开辟一个新的空间并且将原有的数据复制过去,此时返回新空间的首地址,当然缩小空间不会遇到问题,如果系统空间本身不够用则会返回NULL。这个方法显然是创建动态数组的利器,特别是它的性能很高,当我们需要对数组扩容时,先改变空间的大小,然后直接追加新的元素,例如:

#include<stdio.h>
#include<stdlib.h>

int main(int argc, char* argv[])
{
	int *p=calloc(3,sizeof(int));//创建一个包含3个元素的int数组
	p[0]=0,p[1]=1,p[2]=2;
	p=realloc(p,sizeof(int)*4);//对数组进行扩容
	p[3]=3;//添加元素
	printf("%d,%d,%d,%d\n",p[0],p[1],p[2],p[3]);
	return 0;
}

可以看到使用堆创建的数组可以在运行时改变长度,这也是因为C中的数组本身并不进行越界检查,缺点变成了优点,只要空间足够可以继续向后存取,不是堆上的数组千万不要这么做。考虑到性能,要避免每添加一个元素就重设内存的大小,我们可以在初始化动态数组时申请稍大一些的内存,等到不断添加的元素超过申请的内存大小时再多扩展一些空间,从而避免频繁重设空间大小,因为每次重设空间时后面未必有足够的空闲内存,如果没有就会开辟新的空间导致大量的复制工作,这其实就是java动态数组的思想,java在创建动态数组时,可以给出一个建议大小。

最后堆的方法只关心内存空间的开辟和回收,并不关心里面存放什么,我们可以在开辟的内存中反复放置不同的数据类型,有了堆后完全打破了死板的结构,动态数组,链表等都是在堆上创建的,很多高级语言的虚拟机就是C或C++写的,这些高级语言中的动态数组也是在堆上创建的。

内存分配背后的池化技术

堆上的内存虽然自由,但同时会面对自由带来的管理问题,程序可能在任意时间发出申请和释放内存的请求,申请的大小可以从几个字节到几个 GB,更重要的是内存的申请和释放没有顺序可言,这就对内存管理提出了很高的要求。可能你会想,把堆的管理交给操作系统内核去做不就行了吗?操作系统本来就是管理硬件和程序的高手。实际上操作系统的确有这样的能力,它能为程序分配内存空间,也能回收程序结束后的空间,但是前面讲过调用操作系统内核同时是一个复杂的过程,如果每次申请或者释放堆空间都要进行系统调用,那么系统开销将会很大,当程序对堆的操作比较频繁时,这样做的结果会严重影响程序的性能。一个比较合理的想法是申请堆的函数先向操作系统申请一块适当的内存空间,由堆函数负责管理这块空间,当这块内存空间够用时优先分配此空间给程序,不够时再调用操作系统内核申请新的内存空间,同时堆函数还负责收集释放的空间,并将释放的空闲空间进行合并,这种思路称为池化技术。池化技术具有很复杂的算法,因为随着程序运行,不断申请和释放内存,会出现不连续的空闲区域,如图:
在这里插入图片描述

带阴影的方框是已被分配的内存,白色方框是空闲内存或已被释放的内存,以malloc()函数为例,当程序需要内存时,malloc() 首先遍历空闲区域,看是否有大小合适的内存块,如果有就分配,如果没有就向操作系统申请(发生系统调用),因此malloc()申请的内存空间是连续的,这也是程序所需的。那么malloc()具体是如何实现空间管理的呢?通常它会使用一个链表连接空闲空间,如图:
在这里插入图片描述

当一个空闲空间被使用时,会将此空间拆分为两个部分:
在这里插入图片描述

当释放的两个空闲空间相邻时会被合成一个空闲空间,如下图:
在这里插入图片描述

由于单向链表只能向一个方向搜索,在合并或拆分内存块时不方便,所以大部分 malloc() 实现都会使用双链表,如图:
在这里插入图片描述

next引用下一块内存空间地址,pre引用上一块内存空间地址,used表示当前空间是否已使用,malloc()返回的是荷载或空闲空间的起始地址。使用链表管理内存然思路简单、容易理解,但存在很多问题,例如:
 一旦链表中的 pre 或 next 指针被破坏,整个堆就无法工作,而这些数据恰恰很容易被越界读写所接触到。
 小的空闲区域往往不容易再次分配,形成很多内存碎片。
 经常分配和释放内存会造成链表过长,增加遍历的时间。
针对链表的缺点,后来人们提出了位图和对象池的管理方式,而现在的 malloc() 往往采用多种方式复合而成,
不同大小的内存块往往采用不同的措施,以保证内存分配的安全和效率,对于C语言来说编译器越先进,内存池技术就越成熟,除了 C 标准库自带的 malloc(),还有一些第三方的实现,比如 Goolge 的 tcmalloc和 jemalloc。

规避野指针和内存泄漏

C程序之所以容易发生崩溃,通常是因为不正当的指针和内存操作,对一个未知地址进行操作要么没有权限,要么破坏了程序运行,如果一个指针指向未知地址,俗称野指针,如下面代码:
#include <stdio.h>
int main()
{
char *str;
gets(str);
puts(str);
return 0;
}
如果不对str进行初始化,它指向的地址是未知的,多数情况下会遇到没有访问权限这种错误。野指针的出现还发生在指针指向释放的内存或者全局变量引用局部变量的时候,当释放的内存再次被使用时,对野指针的操作就会破坏程序运行,当栈内存被重新分配时,野指针就成了函数运行的隐患。规避野指针需要养成良好的编程习惯:

我们再来看看内存泄漏,由于C和C++都没有垃圾回收机制,堆上申请的内存必须手动回收,某些情况是忘记了调用free()函数,某些情况是无意中制造了垃圾,例如:

#include <stdio.h>
#include <stdlib.h>
int main()
{
	char *p = malloc(100 * sizeof(char));
	p = malloc(50 * sizeof(char));
	free(p);
	p = NULL;
	return 0;
}

这段代码使用同一个指针申请了两块内存,导致第一块内存失去了地址引用,成为永久的垃圾,这类情况还包括将p指向其它的地址或将p设置为NULL,另外只调用malloc()函数而不返回也会造成内存泄漏。要避免内存泄漏需要养成下面的习惯:

大端和小端

大端和小端是指数据在内存中的存储模式,它由 CPU 决定:
大端模式(Big-endian)是指将数据的低位(比如 1234 中的 34 就是低位)放在内存的高地址上,而数据的高位(比如 1234 中的 12 就是高位)放在内存的低地址上。
小端模式(Little-endian)是指将数据的低位放在内存的低地址上,而数据的高位放在内存的高地址上,这和我们的思维习惯是一致,比较容易理解。

对于一次能处理多个字节的 CPU,由于架构不同,必然存在着如何安排多个字节的问题,也就是大端和小端模式。以 int 类型的 0x12345678 为例,它占用 4 个字节,如果是小端模式,那么在内存中的分布情况为:
在这里插入图片描述

如果是大端模式(Big-endian),那么分布情况正好相反:
在这里插入图片描述

我们的 PC 机上使用的是 X86 结构的 CPU,它是小端模式,51 单片机是大端模式,很多 ARM、DSP 也是小
端模式,部分 ARM 处理器还可以由硬件来选择是大端模式还是小端模式,如果你的代码只工作在PC上,那么不用关心采用大端还是小端模式,当为单片机开发程序时才需关心大小端。

内存对齐

cpu按照字长来处理指令和载入数据,32位cpu一次处理4个字节,64位cpu一次处理8个字节,但是数据类型不一定是4字节和8字节,例如char只占一个字节,如果每次读取的字节数大于数据类型的长度,多读取的字节数就浪费掉了,如果每次读取的字节数小于数据类型长度,就必须读两次来拼接,这显然不利于性能,为了提升读取性能,计算机采用空间换取时间的读取方式,将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐,我们使用如下代码来验证C语言编译时是否采用了内存对齐:
#include <stdio.h>

typedef struct
{
int a;
char c;
int b;
} S;

int main()
{
S t =
{ 1, ‘c’, 2 };
printf("%p,%p,%p\n", &t.a, &t.c, &t.b);
printf("%d\n", sizeof(t));
return 0;
}
在结构体中c本来只占用1个字节,但是采用内存对齐后,使用4个字节储存,结构体的实际长度位12,这就是构体成员之间有间隙的原因。内存对齐并不是C语言的特性,它属于计算机运行原理,就像用IEEE 754规范储存浮点一样,编译器只是遵循这个原理,将程序编译为32位和64位内存对齐方式也会不同。

标签:真相大白,函数,内核,int,揭开,内存,页表,操作系统
来源: https://blog.csdn.net/jaj2003/article/details/112993815