C语言内存管理


内存管理

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

虚拟内存

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

虚拟内存的大小除了硬件限制外,还被操作系统和编译模式限制,如果编译为32位,最大可使用的物理内存为2^32=4GB(UINT_MAX),64位应用程序最大可以使用的虚拟内存理论上为2^64=16EB(ULONG_MAX),二进制与十进制单位对照如下表:
在这里插入图片描述

这个大小远远超过目前硬件所能制造的大小,使用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相似,简单来说就是向下管理硬件,向上提供调用接口,架构如下图:
在这里插入图片描述

Linux的内核要比Windows小很多,结构也相对简单,因此Linux的内核也称为微内核,它不像Window那样将底层功能做成一个个的服务,而是根据核心功能直接提出了5个子系统,分别负责如下的功能:

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

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

C程序内存模型

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

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

程序代码区、常量区、全局数据区都属于静态区,它们在程序运行期间一直存在,程序代码区和常量区存是不可写的,只有全局数据区是可以写的,C中的字符串常量被放入常量区,函数中的static变量被放入全局数据区。静态区在程序启动时加载到内存,大小是固定的。动态区包含栈、堆、和动态链接库区,动态链接库区存放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,也就是说如果我们在函数中创建的数组过大或者递归层次太多就会发生栈溢出,在全局或堆上创建数组则好得多。每次调用函数之前编译器需要知道分配的栈区大小,因此如果在函数中创建变长数组时,下标变量必须先传给函数。通过编译器参数可以修改栈和堆的初始大小,在VS中如下:

在这里插入图片描述

在项目属性中,堆栈保留大小和堆保留大小设置程序启动时操作系统为栈和堆提供的虚拟内存大小,单位为字节。从下面的提示内容中可以看到VS默认的栈和堆的大小都是1M。

函数在栈上是如何运行的

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

  • 函数的返回地址
  • 参数和局部变量
  • 编译器自动生成的临时数据
  • 一些需要保存的寄存器

函数返回地址指的是函数执行完成后从哪里开始继续执行后面的代码,C 语言代码最终会被编译为机器指令,确切地说,返回地址应该是下一条机器指令的地址。对于参数和局部变量,有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数ÿ

  • 4
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值