学习笔记之内存管理

内存

程序是存在硬盘中的,要载入内存才能运行,CPU也只能从内存中读取数据和指令。

内存仅仅是一个存放指令和数据的地方,并不能在内存中完成计算功能。

一般打印或者看到的内存地址都是指的虚拟地址,不是真正的物理内存地址。虚拟地址通过CPU的转换才能对应到物理地址,而且每次程序运行时操作系统都会重新安排虚拟地址和物理地址的对应关系。

虚拟地址空间,就是程序可以使用的虚拟地址的有效范围。虚拟地址和物理地址的映射关系是由操作系统决定的,那么虚拟地址空间的大小也是由操作系统决定的。

数据总线决定了CPU单次的数据处理能力,主频决定了CPU单位时间内的数据处理次数。

数据总线和地址总线不是一回事,数据总线用在CPU和内存之间传输数据地址总线用在内存上定位数据

编译模式

编译模式,现在的编译器都提供两种编译方式:32位和64位

  1. 32位模式下:一个指针或者地址占用4字节的内存,共有32位,理论上能够访问的虚拟内存空间大小为 2 32 2^{32} 232 ,即4GB,有效虚拟地址范围是0-0xFFFFFFFF。也就说不管物理内存有多大,32位模式下,最大的虚拟内存也只有4GB。如果内存中剩余的空间不足以容纳当前程序,那么操作系统会将内存中暂时用不到的一部分数据写到磁盘中。
  2. 64位模式下:一个指针或者地址占用8字节的内存,共有64位,理论上能够访问的虚拟内存空间大小为 2 64 2^{64} 264。当前的物理内存不可能达到这么大,所以一般仅使用虚拟地址的低48位(6个字节),总的虚拟地址空间大小为 2 48 2^{48} 248即256TB。

32位操作系统只能运行32位的程序,64位的操作系统能够运行32位和64位的程序,但是64位操作系统运行32位程序会浪费一部分资源。

内存对齐/补齐

内存对齐:第一个数据成员放在offset为0的地方,对齐按照对齐系数和自身所占用的字节数,两者比较小的那个进行对齐。

内存补齐:在struct或者union数据成员完成各自对齐之后,struct或者union本身也要对齐,对齐按照对齐系数和struct或者union中最大数据成员长度中比较小的那个进行。先局部成员对齐,然后再全局对齐。结构体补齐是为了让结构体定义的数组的时候,数组内部可以满足内存对齐的要求。

内存对齐的优点:数据结构尽可能的在自然边界上对齐,内存是一组一组读取的,为了访问未对齐的内存,处理器需要做两次内存访问,内存的单位是字节,CPU通过地址总线来访问内存一次能处理几个字节的数据,就命令地址总线读取几个字节 的数据。32位CPU一次可以处理4字节的数据,那么就从内存中读取4个字节的数据。64位的CPU就一次读取8个字节的数据。所以32位CPU只能对4的倍数的内存寻址,64位只会对8的倍数的内存寻址。并不能直接访问任意编号的内存地址。

扫描二维码关注公众号,回复: 16332314 查看本文章

所以32位的设备默认的是4字节对齐,64位的设备默认的是8字节对齐。可以通过==#pragma pack(n)==指定n字节对齐。

一个变量最好位于一个寻址步长的范围内,这样就可以一次读取到变量的值。如果跨步长储存,就需要读两次,效率就降低了。将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐

内存对齐虽然和硬件有关,决定对齐方式的是编译器,如果硬件是64位的,却以32位的方式编译,那么还是会按照4个字节对齐。

分页机制

​ 程序运行时只会频繁使用一小部分数据,把整个程序读取到内存中,会降低程序的运行效率。采用分页机制对虚拟地址空间和物理地址空间进行分割和映射。分页是将地址空间分为大小相等的若干份,这样的一份称为一页。以页为单位对内存进行换入换出

页的大小是固定的,由硬件决定,或者是硬件支持多种大小的页,由操作系统来选择页的大小。目前几乎所有的PC使用的页的大小都是4KB。每个程序都有自己的内存空间,程序使用虚拟地址读写的时候,必须转化为实际的物理地址。虚拟地址转化为物理地址使用的就是页表。使用页表的时候,只要知道了所在页和页内的偏移,就能够找到数据,转为物理地址。

一个页的大小为4K= 2 12 2^{12} 212,32位系统的虚拟地址为4G= 2 32 2^{32} 232,一共包含了1M= 2 20 2^{20} 220个页面。所以可以定义一个数组包含1M= 2 20 2^{20} 220个元素,每个元素的值为页面的标号,长度为4字节= 2 32 2^{32} 232,整个数组共占用4MB的内存空间。这个数组就叫页表,记录了地址空间中所有页的编号。

页表的每一个元素是4字节= 2 32 2^{32} 232,但是页面一个只有1M= 2 20 2^{20} 220个,所以只需要20bit来表示页面编号就行。其余的12bit用来表示页内偏移。所以0 - 11表示页内偏移12 - 31表示页表数组下标

在CPU内部有一个MMU部件,内存管理单元,用来负责将虚拟地址映射为物理地址。CPU发出的虚拟地址,地址会先交给MMU,经过MMU转换后才会变成物理地址。

程序内存在地址空间中的分布情况称为内存模型

内存中有一部分内存空间是给内核使用的,称为内核空间,windows分配高地址的2G内存给内核,linux分配了高地址的1G内存给内核

内存模型

linux的内存模型内核空间,未分配空间,动态链接库,未分配空间,全局数据区(全局变量和静态变量),常量区(一般常量和字符串常量),代码区,保留区域。

程序代码区、常量区、全局数据区在程序加载到内存后就已经分配好了,并且在程序运行期间一直存在,不能销毁也不能增加。所以全局变量和静态变量、字符串常量在哪里都能够访问,因为他们是一直存在的。

函数被调用时,会将参数局部变量返回地址等信息压入栈中,函数结束执行后,信息销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部。

程序员唯一控制的内存区域是堆(heap),堆常常占据虚拟空间中的绝大部分,堆内存在程序主动释放前会一直存在,不会随着函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

全局变量的内存在编译的时候就已经分配好了,默认初始值为0局部变量的内存在函数调用时分配,初始值不确定,由编译器决定。

内核模式和用户模式

内核空间存放的是操作系统内核代码和数据,是被所有程序共享的。

用户程序调用系统API函数称为系统调用(system call),发生系统调用的时候会暂停用户程序,转而执行内核代码,访问内核空间,这个称为内核模式

用户空间保存的是应用程序的代码和数据,是程序私有的,当执行程序自己的代码的时候,称为用户模式

计算机经常会在内核模式和用户模式之间切换,当用户需要输入输出、申请内存等底层操作的时候,就必须调用操作系统提供的API函数,进入内核模式。操作完成后,继续执行应用程序的代码,就会回到用户模式。

用户模式就是执行应用程序代码,访问用户空间;内核模式就是执行内核代码,访问内核空间

处于安全性和稳定性的考虑,CPU可以运行在ring0 — ring3四个不同的权限等级,对数据提供四个不同级别的保护。不过win和linux只利用了其中的两个级别,一个是内核模式,对应ring0级别;一个是用户模式,对应ring3级别。

为什么不让内核独享4G内存空间?

如果内核处于一个独立的进程,独享内存空间的话,每次系统调用都需要切换进程,切换进程的消耗是巨大的,不仅需要寄存器进栈出栈,还会是CPU中的数据缓存失效,MMU中的页表缓存失效。内核与用户进程共享地址空间,发生系统调用的时候进行的是模式切换,仅需要寄存器进栈出栈,不会导致缓存失效。

内核内存管理

**vmalloc()**保证分配的地址在虚拟地址空间上是连续的,**kmalloc()**保证分配的地址在物理地址空间上和虚拟地址空间上都是连续的。只有硬件才需要得到连续的物理地址空间。因为硬件设备存在于mmu以外,不知道虚拟地址。

很多的内核代码都使用kmalloc()来获取内存,而不是使用vmalloc来获取内存。这主要是出于性能的考虑,因为vmalloc函数为了把物理地址上不连续的页转化为虚拟地址上连续的页,必须建立页表项。通过vmalloc获取的页需要一个一个地进行映射,导致vmalloc比直接内存映射的TLB抖动要大得多。内核代码大多数都是使用kmalloc。

vmalloc和kmalloc比较的话,vmalloc会睡眠不能在其他不允许阻塞的情况下使用。

slab

为了便于数据的频繁分配和回收,常常会使用到空闲链表,空闲链表包含可以供使用的、已经分配好的数据结构块。当需要一个新的数据结构实例的时候,就可以从空闲链表中抓取一个,并且不需要分配内存,把数据放入。当这个数据结构的实例不在需要的时候,就把它放回空闲链表,而不是释放它。所以空闲链表就相当于是对象高速缓存,快速存储频繁使用的对象类型

在内核中空闲链表面临的问题是不能全局控制,当内存吃紧的时候,内核无法通知每个空闲链表,让其收缩缓存大小以释放内存。实际上内核不知道是否存在任何的空闲链表。所以linux内核中提供了slab层,slab分配器扮演了通用数据结构缓存层的角色

slab分配器在下面这几种基本原则之间寻求平衡:

  1. 频繁使用的数据结构也会频繁的分配和释放,因此应当缓存数据
  2. 频繁分配和回收必然会导致内存碎片,为了避免这种情况,空闲链表的缓存会连续的存放。因为以及释放的数据结构又会放回到空闲链表,不会导致碎片。
  3. 回收的对象可以立即投入下一次分配,所以对于频繁的分配和释放,空闲链表能够提高其性能
  4. 如果分配器知道对象大小、页的大小和总的高速缓存的大小,可以做出更加明智的决策。
  5. 如果让部分的缓存专属于单个处理器,分配和释放就可以不加SMP。
  6. 如果分配器是于NUMA相关的,就可以从相同的内存节点为请求者进行分配。
  7. 对存放的对象进行着色,以防止多个对象映射到相同的高速缓存行

slab分配器的基本思想就是将内核中经常使用的对象放到高速缓存中,并且由系统保持为初始的可利用状态。slab分配器有三个基本目标

  1. 减少伙伴算法在分配小块连续内存时产生的内部碎片
  2. 将频繁使用的对象缓存起来,减少分配、初始化和释放对象的时间开销
  3. 通过着色技术调整对象以更好的使用硬件高速缓存。

slab分配器为每一种对象分配一个高速缓存,这个缓存可以看做是同一类型对象的一种储备。每个高速缓存所占用的内存区被划分为多个slab每个slab由一个或者多个连续的页框组成。每个页框包含若干个对象。

所有高速缓存通过双链表组织在一起,形成高速缓存链表cache_chain,每个kmem_cache结构中并不包含对具体slab的描述,而是通过kmem-list3组织结构各个slab,slab描述符中的list标明了当前slab处于三个slab链表的其中一个。

高速缓存分为两大类,普通的高速缓存专用高速缓存。普通的高速缓存并不针对内核中特定的对象,首先为mem_cache结构体本省提供高速缓存,这类缓存保存在cache_cache变量中,该变量即代表cache_chain链表中的第一个元素。专用高速缓存是根据内核所需通过制定具体的对象而创建。

slab层的关键就是避免频繁的分配和释放页,slab层只有当给定的高速缓存部分中既没有满也没有空的slab时才会调用页分配函数。

页缓存

文件一般是存放在磁盘中,CPU并不能直接访问磁盘中的数据,需要将磁盘中的数据读取到内存中,再访问。由于读写磁盘数据的速度比读写内存要慢很多,所以为了避免每次读写文件时,都需要对硬盘进行读写操作,linux内核使用页缓存(page cache)机制来文件中的数据进行缓存。

linux内核以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为页缓存)与文件中的数据块进行绑定。当用户对文件进行读写时,实际上是对文件的页缓存进行读写。所以当对文件进行读写操作时,会有两种情况:

  1. 当从文件中读取数据时,如果要读取的数据所在的页缓存已经存在,那么就直接把页缓存的数据拷贝给用户即可。否则内核首先会申请一个空闲的内存页,然后从文件中读取数据到页缓存,并且把页缓存的数据拷贝到用户。
  2. 当向文件中写入数据时,如果要写入的数据所在的页缓存已经存在,那么直接从新数据写入到页缓存即可。否则,内核首先会申请一个空闲的内存页,然后从文件中读取数据到页缓存,并且把新数据写入到页缓存中。

MMAP

mmap的全称是memory map,即内存映射。mmap的作用是将文件映射到内存中,然后再通过对映射区的内存操作,间接对磁盘中的文件进行操作 。

传统的修改文件的方式一般有三步:将文件读取到内存中,修改内存的内容,将修改后的内存内容写入到文件中。在内核中使用的是页缓存与文件的数据块关联起来,所以应用程序读写文件时,实际操作的是页缓存。

在传统的读写文件的方法中,可以发现如果可以直接从用户空间中直接读写页缓存,而不用将数据从内核中的页缓存中拷贝到用户空间中的buffer中,就可以加快文件的修改。使用mmap方式就可以达到这个效果。

file -> page cache -> user buffer(read) -> user buffer(write) -> page cache -> file

使用mmap系统调用可以将用户空间的虚拟内存地址和文件进行映射,对映射后的虚拟内存地址进行读写操作就如同对文件进行读写操作一样。

file -> page cache -> (mmap) -> VMA

因为读写文件都是通过页缓存,mmap映射的也是文件的页缓存,并不是磁盘中的文件本身。由于mmap映射的是文件的页缓存,所以涉及到了同步的问题,即页缓存什么时候将修改的文件同步到磁盘的文件中。linux内核不会主动将mmap映射的页缓存同步到磁盘中,而是需要用户主动触发。同步一般有四个时间:

  1. 调用msync函数主动进行数据同步。
  2. 调用munmap函数对文件进行接触映射关系。
  3. 进程退出时。
  4. 系统关机时。

函数原型:void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

返回值:成功返回创建的映射区的首地址。失败返回MAP_FAILED宏。

参数说明:

  • addr:指定映射的虚拟内存起始地址,通常设置为NULL,由系统指定。
  • length:映射到内存的文件长度。
  • prot:映射区的保护方式,常用的有:读 — PROT_READ,写 — PROT_WRITE,读写 — PROT_READ|PROT_WRITE
  • flags:映射区的特性,MAP_SHARED — 写入映射区的数据会写回文件,且允许其他映射该文件的进程共享;MAP-PRIVATE — 会产生一个映射区的复制(copy-on-write),对此区域所做的修改不会写回原文件。
  • fd:要映射文件的文件描述符。
  • offset:文件偏移位置,以文件开始处的偏移量,必须是4K的整数倍,通常是0,表示从文件头开始映射。

mmap函数使用时映射区域大小必须是物理页大小(page_size)的整数倍,因为内存的最小粒度是页,进程虚拟地址空间和内存的映射也是以业为单位。的获益mmap从磁盘到虚拟地址空间的映射也必须是页。

int fd = open(filepath, O_RDWR, 0644);                           // 打开文件void *addr = mmap(NULL, 8192, PROT_WRITE, MAP_SHARED, fd, 4096); // 对文件进行映射

映射建立以后,即使文件关闭,映射依然存在,因为映射的是磁盘的地址,不是文件本身,和文件句柄无关,同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

MUMAP

函数原型:int mumap(void *start, size_t length)

返回值:解除成功返回0,失败返回-1.

参数说明:

  • start:映射的起始地址。
  • length:文件中映射到内存的部分的长度。

栈能使用的内存的大小是有限的,一般是1M-8M,这个在编译的时候就决定了,程序在运行期间不能改变,程序使用的栈内存超出了最大值,就会发生栈溢出。栈的内存大小和编译器有关,编译器会为栈内存指定一个最大值,Linux gcc默认为8M,VC/VS默认为1M。

一个程序可以包含多个线程,每个线程都有自己的栈,栈的最大值是针对线程来说的,不是针对程序。

栈也经常称为堆栈,堆依然称为堆,堆栈这个概念中不包含堆。

栈帧

发生函数调用的时候,会将函数运行时需要的信息全部压入栈中,这个称为**栈帧(stack frame)**或活动记录(active record)。栈帧主要包含以下几方面的内容:

  1. 函数的返回地址和参数:一个函数在执行完后会继续执行函数后面的下一条语句,所以返回地址就是下一条语句在内存中的地址。
  2. 参数和局部变量。有些编译器在开启优化选项之后,会通过寄存器来传递参数,而不是将参数压入栈中
  3. 编译器自动生成的临时数据。编译器自动生成的临时数据有时会压入栈中,如当函数返回值的长度较大的时候,会将返回值先压入栈中,再传递给函数调用者。当返回值较小时,会直接将返回值放入寄存器,再传递给函数调用者。
  4. 一些需要保存的寄存器。之所以需要保存是因为在函数退出时能够恢复到函数调用之前的场景,继续执行三层函数。

函数调用实例如下所示:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 参数 | 返回地址 | old ebp | 局部变量、返回值 | old ebx | old esi | old edi |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
                ^                                                       ^
                |                                                       |
                ebp                                                    esp

ebp寄存器应该指向栈底,但是实际是指向的old ebp

发生函数调用的时候:

  1. 将所有或者一部分的参数压入栈中,如果有其他的参数没有入栈,那么使用某些特定的寄存器传递。
  2. 把当前指令的下一条指令的地址压入栈中。
  3. 跳转到函数体执行。

注:不同的编译器在不同的编译模式下产生的函数栈并不是完全相同的。第2步和第3步由指令call一起执行。跳转到函数体之后即开始执行函数。执行函数开头的时候一般是:

  1. push ebp:把old ebp压入栈中。
  2. mov ebp,esp:ebp=esp(栈底指向栈顶)。
  3. 【可选】sub esp,XXX:在栈上分配XXX字节的临时空间。
  4. 【可选】push XXX:如有必要,保存名为XXX寄存器(部分寄存器可能需要在函数调用前后保持不变,这样函数就可以在调用开始时将这些寄存器压入栈中,在结束后再去除)。

函数结束调用时一般是:

  1. 【可选】pop XXX:恢复保存过的寄存器。
  2. mov esp,ebp:恢复ESP同时回收局部变量空间。
  3. pop ebp:从栈中恢复保存的ebp的值。
  4. ret:从栈中取得返回地址,并跳转到该位置。

数据的定位:bsp的值会随着数据的入栈而不断变化,所以数据是通过ebp来定位的,ebp的值是固定的,数据相对ebp的偏移也是固定的,ebp的值加上偏移量就是数据的值。

void func(int a, int b){
    float f = 28.5;
    int n = 100;
    //TODO:
}

func(15, 92);


+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|92|15| 返回地址 | old ebp | 28.5 | 100 | 冗余的内存 | old ebx | old esi | old dei |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
                ^                                                              ^
                |                                                              |
                ebp                                                           esp

注:GCC有个参数**-fomit-frame-pointer**可以取消帧指针(ebp),即不使用任何帧指针,而是通过esp来定位帧上变量的位置。这样可以多出来一个ebp寄存器使用,但是坏处是帧上寻址速度会变慢,而且没有帧指针后,无法准确定位函数的调用轨迹。

函数调用

发生函数调用的时候,函数的实参由调用方压入栈中供被调用方使用,双方需要约定参数是从左到右入栈还是从右到左入栈。

函数调用方,和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例,一般包含下面内容:

  1. 函数参数的传递方式,是通过栈传递还是寄存器传递
  2. 函数参数的传递顺序,即从左到右还是从右到左入栈。
  3. 参数的弹出方式,函数调用结束后需要将压入栈中的参数全部弹出,使得栈在函数调用前后保持一致。
  4. 函数名修饰方式,函数名在编译时会被修改,调用惯例可以决定如何修饰函数名。

函数调用惯例在函数声明和函数定义的时候都可以指定,语法为:

返回值类型  调用惯例  函数名(函数参数)

在函数声明处是为了调用方指定调用惯例,在函数定义处是为函数本身指定调用惯例。__cdecl或者__attribute__是默认的调用惯例。

举例:

void func(int a, int b){
    int p =12, q = 345;
}
int main(){
    func(90, 26);
    return 0;
}

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
|返回地址|old ebp|预留内存|old ebx|old esi|old edi|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
                ^                              ^
                |                              |
               ebp                            esp

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
|返回地址|old ebp|预留内存|old ebx|old esi|old edi|26|90|返回地址|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
                ^                                            ^
                |                                            |
               ebp                                           esp

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
|返回地址|old ebp|预留内存|old ebx|old esi|old edi|26|90|返回地址|old ebp|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
                                                                    ^
                                                                    |
                                                                 ebp,esp
    
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|返回地址|old ebp|预留内存|old ebx|old esi|old edi|26|90|返回地址|old ebp|预留内存|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
                                                                    ^        ^
                                                                    |        |
                                                                   ebp      esp 

    ...........

函数进栈步骤

  1. main函数进栈
  2. 执行function(90,26),先将实参90和26压入栈中,再将返回地址压入栈中,这些工作都有调用方main()函数完成。ebp指针没有变,只改变了esp的指向。
  3. 执行func()函数体,将原来ebp寄存器中的值压入栈中,并将esp的值赋给ebp,这样ebp就从main()函数的栈底指向了func()函数的栈底,完成了函数的切换。
  4. 为局部变量、返回值等预留足够的内存,这个栈内存是在函数调用前就已经分配好了,这里不是真正的分配,只是将esp的值减去一个整数,这个整数的值就是预留的内存。
  5. 将ebp、esi、edi寄存器的值依次压入栈中。
  6. 将局部变量的值放入预留好的内存中,第一个变量和old ebp之间有4个字节的空白,变量之间也有若干字节的空白。这个空白是因为在debug模式下,预留多余的内存加入调试信息,release模式下生成程序没有空白。

函数出栈步骤

  1. 函数func()执行完成后开始出栈,首先将寄存器edi、esi、ebx寄存器出栈。
  2. 将局部变量、返回值等数据出栈时,直接将ebp的值赋给esp,这样ebp和esp就指向了同一个位置。
  3. 将old ebp出栈,并赋值给现在的ebp,此时ebp就指向了func()调用之前的位置,即main()的old ebp位置。
  4. 最后根据返回地址找到下一条指令的位置,并将返回地址和实参都出栈,此时esp就指向了main()的栈顶。

在实际的函数调用过程中,形参是不存在的不会占用内存空间,内存中只有实参,而且是在执行函数体代码之前,由调用方式压入栈中的。

未初始化的局部变量的值是垃圾值,因为在为局部变量分配内存的时候,仅仅是将esp的值减去一个整数,预留足够的空白内存,不同的编译器在不同模式下会对这片空白内存进行不同的处理。

函数出栈只是增加了esp寄存器的值,使它指向上一个数据,并没有销毁之前的数据,所以局部变量在函数运行结束后立即销毁其实是错误的。

栈溢出攻击:局部变量数组也是在栈上分配内存,c语言不会对数组溢出进行检测,数据溢出导致覆盖函数返回地址情况称为栈溢出错误。如果精心构造一个栈溢出,让返回地址指向恶意代码,就是栈溢出攻击。

动态内存分配

在进程的地址空间中,代码区、常量区、全局数据区的内存在程序启动时就已经分配好了,称为静态内存分配。栈区和堆区的内存在程序运行期间可以根据实际需求来分配和释放,在程序启动时有足够的内存,称为动态内存分配

栈:栈区内存由系统分配和释放,不受程序员控制。

堆:堆区内存完全有程序员掌控,想分配多少就分配多少,想什么时候释放就什么时候释放。

程序启动的时候会为栈区分配一块大小适当的内存,当函数调用超过了分配的内存的时候,编译器就会在函数代码中插入针对栈的动态内存分配函数,这样函数被调用时才分配内存,不调用就不分配内存。

堆(heap)是唯一的程序员控制的内存区域,在堆上分配内存需要用到的函数有:malloc()、calloc()、realloc()和 free()

需要注意的是:

  1. 每个内存分配函数必须有一个对应的free()函数,释放后不能再次使用被释放的内存。
  2. 在内存分配的时候最好不要直接使用数字指定内存空间的大小
  3. free§不能改变指针p的值,p依然是指向以前的内存,为了防止再试使用该内存,建议将p的值手动设置为NULL。

非法内存操作

如果一个指针指向的内存没有访问权限,或者指向了一块已经释放掉的内存,那么就无法对该指针进行操作,这样的指针称为野指针。野指针会导致段错误

如果动态分配的内存没有释放,那么这段内存会已知被程序占用,知道程序结束由操作系统回收,这就是内存泄漏

函数的存储类型

C语言共有 4 个关键字用来指明变量的存储类别:auto(自动的)、static(静态的)、register(寄存器的)、extern(外部的)。可以通过C语言中的关键字来控制变量的存放区域。

寄存器

  • 一般寄存器:AX、BX、CX、DX

    • AX:累积暂存器,BX:基底暂存器,CX:计数暂存器,DX:资料暂存器
  • 索引暂存器:SI、DI

    • SI:来源索引暂存器,DI:目的索引暂存器
  • 堆叠、基底暂存器:SP、BP

    • SP:堆叠指标暂存器,BP:基底指标暂存器
  • EAX、ECX、EDX、EBX:为ax,bx,cx,dx的延伸,各為32位元

  • ESI、EDI、ESP、EBP:为si,di,sp,bp的延伸,32位元

    • eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。
  • EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。

  • EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。

  • ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。

  • EDX 则总是被用来放整数除法产生的余数。

  • ESI/EDI分别叫做"源/目标索引寄存器"(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串.

  • EBP是"基址指针"(BASE POINTER), 寄存器存放当前线程的栈底指针,它最经常被用作高级语言函数调用的"框架指针"(frame pointer).

  • ESP 专门用作堆栈指针,被形象地称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,ESP也就越来越小。在32位平台上,ESP每次减少4字节。

猜你喜欢

转载自blog.csdn.net/qq_41323475/article/details/127856483