C语言的内存

cpu工作原理

#include <stdio.h>
#include <stdlib.h>
struct{
    int a;
    char b;
    int c;
}t={ 10, 'C', 20 };

int main(){
    printf("length: %d\n", sizeof(t));
    printf("&a: %X\n&b: %X\n&c: %X\n", &t.a, &t.b, &t.c);
    system("pause");
    return 0

      程序写好编译后保存在磁盘,然后加载到内存中运行的,一名合格的程序员必须了解内存,学习C语言更是要多了解些内存的知识点,C语言是一门偏向硬件的编程语言。

1、想理解清楚内存,先要弄清楚CPU的组成、工作原理和必要的一些相关概念
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

CPU总线

      习惯上人们把和CPU直接相关的局部总线叫做CPU总线或内部总线,而把和各种通用扩展槽相接的局部总线叫做系统总线或外部总线。

具体地,CPU总线一般指CPU与芯片组之间的公用连接线,又叫前端总线(FSB)。不管是内部总线还是外部总线,我们可以把它们理解成城市中的主干道和一般道路。

通常,总线可分为三类:数据总线,地址总线,控制总线,数据、地址和控制信号是分开传输的。三者配合起来实现CPU对数据和指令的读写操作。

寄存器和CPU指令

      寄存器(Register)是CPU内部非常小、非常快速的存储部件,它的容量很有限,对于32位的CPU,每个寄存器一般能存储32位(4个字节)的数据,对于64位的CPU,每个寄存器一般能存储64位(8个字节)的数据。

      为了完成各种复杂的功能,现代CPU都内置了几十个甚至上百个的寄存器,嵌入式系统功能单一,寄存器数较少。

我们经常听说多少位的CPU,即指的是寄存器能存储数据的位数,也是数据总线位数(总线的条数)。
现在个人电脑使用的CPU已经进入了64位时代,例如 Intel 的 Core i3、i5、i7 等。

      寄存器在程序的执行过程中至关重要,不可或缺,它们可以用来完成数学运算、控制循环次数、控制程序的执行流程、标记CPU运行状态等。

寄存器有很多种,例如:

  1. EIP(Extern Instruction Pointer )寄存器的值是下一条指令的地址,
    CPU执行完当前指令后,会根据 EIP 的值找到下一条指令,改变 EIP 的值,
    就会改变程序的执行流程,CPU中EIP就是我们上面说的程序计数器PC;
  2. CR3(Control Register )寄存器保存着当前进程页目录的物理地址,切换进程就会改变 CR3 的值;
  3. EBP(Extended Base Pointer)、ESP(Extended Stack Pointer) 寄存器用来指向栈的底部和顶部,
    函数调用会改变 EBP 和 ESP 的值。
  4. EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。
  5. EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
  6. ECX 是计数器(counter), 是重复前缀指令和LOOP指令的内定计数器。
  7. EDX 则总是被用来放整数除法产生的余数。
  8. ESI/EDI分别叫做"源/目标索引寄存器"(source/destination inde因为在很多字符串操作指令中,ESI指向源串,EDI指向目标串.

CPU指令

要想让CPU工作,必须借助特定的指令,例如 add 用于加法运算,sub 用于除法运算,cmp 用于比较两个数的大小,这称为CPU的指令集(Instruction Set)。

我们的C语言代码最终也会编译成一条一条的CPU指令。
不同型号的CPU支持的指令集会有所差异,但绝大部分是相同的。

我们以C语言中的加法为例来演示CPU指令的使用。
假设有下面的C语言代码:

int a = 0X14, b = 0XAE, c;
c = a + b;

生成的CPU指令为(这是假的,不是真的指令,只是模拟CUP的处理过程):

mov  ptr[a], 0X14
mov  ptr[b], 0XAE
mov  eax, ptr[a]
add  eax, ptr[b]
mov  ptr[c], eax

总起来讲:第一二条指令给变量 a、b 赋值,
第三四条指令完成加法运算,
第五条指令将运算结果赋值给变量 c。
-----我们在程序里使用的内存地址是假的,是虚拟地址
在C语言中,指针变量的值就是一个内存地址,&运算符的作用也是取变量的内存地址,请看下面的代码:

#include <stdio.h>

int a = 1, b = 255;
int main(){
    int *pa = &a;
    printf("pa = %p, &b = %p\n", pa, &b);
    return 0;
}

我执行了两次,打印输出同样的地址。
在这里插入图片描述
      代码中的 a、b 是全局变量,它们的内存地址在链接时就已经决定了,以后再也不能改变,该程序无论在何时运行,结果都是一样的。
      那么问题来了,如果物理内存中的这两个地址被其他程序占用了怎么办,我们的程序岂不是无法运行了?

幸运的是,这些内存地址都是假的,不是真实的物理内存地址,而是虚拟地址。

      在程序运行时,需要使用真正的地址了,CPU会把虚拟地址转换成真正的内存的物理地址,而且每次程序运行时,操作系统都会重新安排虚拟地址和物理地址的对应关系,哪一段物理内存空闲就使用哪一段。
为什么要在程序与物理地址之间,加一个虚拟地址,不能让程序直接操作物理地址?

  1. 使用虚拟地址才能在编程中有一个确定地址,而物理地址不能确定
    我们知道编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000011,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了。

  2. 使用虚拟地址可以让不同程序的地址空间相互隔离
    如果所有程序都直接使用物理内存,那么程序所使用的地址空间不是相互隔离的。恶意程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意、但是有 Bug 的程序也可能会不小心修改其他程序的数据,导致其他程序崩溃。

      这对于需要安全稳定的计算机环境的用户来说是不能容忍的,用户希望他在使用计算机的时候,其中一个任务失败了,至少不会影响其他任务。

      使用了虚拟地址后,程序A和程序B虽然都可以访问同一个地址,但它们对应的物理地址是不同的,无论如何操作,都不会修改对方的内存。

  1. 提高内存使用效率
    使用虚拟地址后,操作系统会更多地介入到内存管理工作中,这使得控制内存权限成为可能。由操作系统更多的管理内存,当物理内存不够用,操作系统自动将不常用的数据转存到磁盘,用的时候在读回来,哪些内存在用,哪些内存没在用,OS可以动态判断,比我们程序员直接在程序里管理内存,更好,内存的使用率更高。

虚拟地址空间以及编译模式

      所谓虚拟地址空间,就是程序可以使用的虚拟地址的有效范围。
      虚拟地址和物理地址的映射关系由操作系统决定,相应地,虚拟地址空间的大小不仅由操作系统决定,还会受到编译模式的影响。重点讨论一下编译模式,要了解编译模式,还得从CPU来说起:

CPU的数据处理能力
      CPU是计算机的核心,决定了计算机的数据处理能力和寻址能力,也即决定了计算机的性能。
      CPU一次能处理的数据的大小由寄存器的位数和数据总线的宽度(有多少根数据总线)决定,我们通常所说的多少位的CPU,除了可以理解为寄存器的位数,也可以理解数据总线的宽度,通常情况下它们是相等的。

CPU实际支持多大的物理内存
      CPU支持的物理内存只是理论上的数据,实际应用中还会受到操作系统和其他条件的限制,例如,Win7 64位家庭版最大仅支持8GB或16GB的物理内存,Win7 64位专业版或企业版能够支持到192GB的物理内存。Win10 64位系统支持4G 8G 16G 32G 64G 128G 256G内存,理论上可以无限支持,但也要主板能支持才行。
编译模式
      为了兼容不同的平台,现代编译器大都提供两种编译模式:32位模式和64位模式。
32位编译模式
      在32位模式下,一个指针或地址占用4个字节的内存,共有32位,理论上能够访问的虚拟内存空间大小为 2^32 = 0X100000000 Bytes,即4GB,有效虚拟地址范围是 0 ~ 0XFFFFFFFF。

也就是说,对于32位的编译模式,不管实际物理内存有多大,程序能够访问的有效虚拟地址空间的范围就是0 ~ 0XFFFFFFFF,也即虚拟地址空间的大小是 4GB。换句话说,程序能够使用的最大内存为 4GB,跟物理内存没有关系。

如果程序需要的内存大于物理内存,或者内存中剩余的空间不足以容纳当前程序,那么操作系统会将内存中暂时用不到的一部分数据写入到磁盘,等需要的时候再读取回来,而我们的程序只管使用 4GB 的内存,不用关心硬件资源够不够。

如果物理内存大于 4GB,例如目前很多PC机都配备了8GB\16GB\32GB的内存,那么程序也无能为力,它只能够使用其中的 4GB。

64位编译模式
      在64位编译模式下,一个指针或地址占用8个字节的内存,共有64位,理论上能够访问的虚拟内存空间大小为 2^64。这是一个很大的值,几乎是无限的,就目前的技术来讲,不但物理内存不可能达到这么大,CPU的寻址能力也没有这么大,实现64位长的虚拟地址只会增加系统的复杂度和地址转换的成本,带不来任何好处,所以 Windows 和 Linux 都对虚拟地址进行了限制,仅使用虚拟地址的低48位(6个字节),总的虚拟地址空间大小为 2^48 = 256TB。
需要注意的是:
      1)32位的操作系统只能运行32位的程序,64位操作系统可以同时运行32位的程序和64位的程序。
      2)64位的CPU运行64位的程序才能发挥它的最大性能,运行32位的程序会白白浪费一部分资源。

      目前计算机可以说已经进入了64位的时代,之所以还要提供32位编译模式,是为了兼容一些老的硬件平台和操作系统,或者某些场合下32位的环境已经足够,使用64位环境会增大成本,例如嵌入式系统、单片机、工控等。

      这里所说的32位环境是指:32位的CPU + 32位的操作系统 + 32位的程序。

      另外需要说明的是,32位环境拥有非常经典的设计,易于理解,适合教学,课程里不特别说明默认都是基于32位来分析讲解相关内存的知识点。
下面代码是64位环境和32位环境下运行效果

#include <stdio.h>

int a = 1, b = 255;
int main(){
    int *pa = &a;
    printf("pa = %p, &b = %p\n,%d\n", pa, &b,sizeof(pa));
    return 0;
}
//执行与结果,分别有64位模式我32位模式编译执行
   gcc -g demo1.c -o demo1.exe     
   ./demo1
   pa = 0000000000403010, &b = 0000000000403014,8
//32位模式
    gcc -m32 -g demo1.c -o demo1.exe
    ./demo1
    pa = 00403004, &b = 00403008,4

内存对齐

       计算机内存是以字节(Byte)为单位划分的,理论上CPU可以访问任意编号的字节,但实际情况并非如此。

      CPU 通过地址来访问内存,通过地址在内存中定位要找的目标数据,我们叫寻址,CPU在寻址的时候它不是从0 1 2 3…挨着寻址的,有跳跃的步长的,这个步长和CPU的位数有关系!

       以32位的CPU为例,实际寻址的步长为4个字节,也就是只对地址为 4 的倍数的内存寻址,
例如 0、4、8、12、1000 等,而不会对编号为 1、3、11、1001 的内存寻址。如下图所示:

在这里插入图片描述

这样做可以以最快的速度寻址:不遗漏一个字节,也不重复对一个字节寻址。

对于程序来说,一个变量最好位于一个寻址步长的范围内,这样CPU读变量的效率就比较高,否则效率就低些。

例如:一个 int 类型的数据,如果地址为 8,那么很好办,对编号为 8 的内存寻址一次就可以。如果编号为 10,就比较麻烦,CPU需要先对编号为 8 的内存寻址,读取4个字节,得到该数据的前半部分,然后再对编号为 12 的内存寻址,读取4个字节,得到该数据的后半部分,再将这两部分拼接起来,才能取得数据的值。
编译器会优化代码,根据变量大小,尽量将变量放在合适的位置,避免跨步长存储,这称为内存对齐。

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

struct{
    int a;
    char b;
    int c;
}t={ 10, 'C', 20 };

int main(){
    printf("length: %d\n", sizeof(t));
    printf("&a: %d\n&b: %d\n&c: %d\n", &t.a, &t.b, &t.c);
    return 0;
}

length: 12 ,就是编译器进行了内存对齐。本来两个int型加一个字符型,是9个字节的长度。
在这里插入图片描述

-----编译器之所以要内存对齐,是为了更加高效的存取成员 c,而代价就是浪费了3个字节的空间。
编译器编译时对齐的规则
      每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数),具体的对齐规则没有统一的标准,也比较复杂,内存对齐本身对我们编程来讲,基本上也是透明的,所以不去过多深入研究了,但基本东西我们要了解。
      可以通过预编译命令#pragma pack(n),n=1,2,4,8,16 来一定程度上影响这一“对齐系数”,但不绝对的。
如果上面那个例子,对齐系数改成1,那么就只占用9个byte,但这样会使得cpu负担更大。

除了结构体,变量也会进行内存对齐,请看下面的代码:

#include <stdio.h>
#include <stdlib.h>
int m;
char c;
int n;
int main(){
    printf("&m: %X\n&c: %X\n&n: %X\n", &m, &c, &n);
    system("pause");
    return 0;
}
//执行结果
&m: 407978
&c: 407974
&n: 407970

它们的地址都是4的整数倍,并相互挨着。

内存分页机制

      前面已经了解了,虚拟地址的使用是有价值,有意义的,所以程序运行的时候,操作系统会自动帮我们,把虚拟地址映射为物理地址,但这个映射转换机制的实现,有很多思路:
1)以程序为单位进行映射
在这里插入图片描述
    如果物理内存不足,被换入换出到磁盘的是整个程序,这样势必会导致大量的磁盘读写操作,严重影响运行速度,所以这种方法还是显得粗糙,粒度比较大。
2)内存分页机制
        现代计算机都使用分页(Paging)的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小当内存不够时数据在内存与磁盘之间的换入换出的粒度,提高程序运行效率。
    分页(Paging)的思想是指把地址空间人为地分成大小相等(并且固定)的若干份,这样的一份称为一页,就像一本书由很多页面组成,每个页面的大小相等。如此,就能够以页为单位对内存进行换入换出:
(1)当程序运行时,只需要将必要的数据从磁盘读取到内存,暂时用不到的数据先留在磁盘中,
什么时候用到什么时候读取。
(2)当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,
不用把整个程序都写入磁盘。

关于页的大小
    页的大小是固定的,由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
比如 Intel Pentium 系列处理器支持 4KB 或 4MB 的页大小,那么操作系统可以选择每页大小为 4KB,
也可以选择每页大小为 4MB,但是在同一时刻只能选择一种大小,所以对整个系统来说,也就是固定大小的。

目前几乎所有PC上的操作系统都是用 4KB 大小的页。
假设我们使用的PC机是32位的,那么虚拟地址空间总共有 4GB,按照 4KB 每页分的话,总共有 2^32 / 2^12 = 2^20 = 1M = 1048576 个页;物理内存也是同样的分法。

根据页进行映射
    如下图中,当程序1,2运行时,初始化时,要用到的程序段数据先载入的内存中,当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,
不用把整个程序都写入磁盘。
在这里插入图片描述
分页机制究竟是如何实现的?
        现代操作系统都使用分页机制来管理内存,这使得每个程序都拥有自己的地址空间。程序运行的时候虚拟地址必须转换为实际的物理地址,才能真正在内存条上读写数据。如下图所示:
在这里插入图片描述
直接使用数组转换,不靠谱
        最容易想到的映射方案是使用数组:每个数组元素保存一个物理地址,而把虚拟地址作为数组下标,这样就能够很容易地完成映射,并且效率不低。如下图所示:
在这里插入图片描述
        但是这样的数组有 2^32 个元素,每个元素大小为4个字节,总共占用16GB的内存,显现是不现实的!怎么办?
使用分页机制,为什么分页后,这个关系数据会压缩!

使用一级页表
        既然内存使用分页机制,内存就是分页的,那么我们定位数据就不用定位到内存的每个字节,只需定位到数据所在的页,以及数据在页内的偏移(也就是距离页开头的字节数),就能够转换为物理地址。

例如:
    一个 int 类型的值保存在下标为 12 页,页内偏移 240B,那么对应的物理地址就是 2^12 * 12 + 240 = 49392。

        32位系统虚拟地址空间大小为 4GB,总共包含 2^32 / 2^12 = 2^20 = 1K * 1K = 1M = 1048576 个页面,我们可以定义一个这样的数组:它包含 2^20 = 1M 个元素,每个元素的值为物理内存的页面编号,长度为4字节,整个数组共才占用4MB的内存空间。这样的数组就称为页表(Page Table),它记录了地址空间中所有页的编号。

        为了让这个4MB大小的数组,保存的下上面16GB才能保存的虚拟地址和物理地址之间的映射关系,我们做一个规定:虚拟地址和页表数组中的元素值,都分两部分:
虚拟地址:
在这里插入图片描述
页表数组中的元素值:
在这里插入图片描述
        物理页属性:是否有读写权限、是否已经分配物理内存、是否被换出到硬盘等。
-----一个虚拟地址 0XA010B100,它的高20位是 0XA010B,对应页表数组的小标,后面的100是在物理页面内的数据偏移量。假设页表数组小为 0XA010B 元素的值为 0X0F70AAA0,它的高20位为 0X0F70A,对应数据位于第 0X0F70A 个物理页面编号,它的低12位是 0XAA0,对应物理页的属性 ,有了物理页面索引和页内偏移量,就可以算出物理地址了。经过计算,最终的物理地址为 0X0F70A * 2^12 + 0X100 = 0XF70A100。
在这里插入图片描述

        可以发现,有的页被映射到物理内存,有的被映射到硬盘,不同的映射方式可以由页表数组元素的低12位来控制。
-----使用这种方案,不管程序需要多大的内存,每个程序都会对应的页表数组占用4M的内存空间(页表数组也必须放在物理内存中)。

为了进一步压缩空间,从而会使用二级、三级页表甚至多级页表,具体原理我们以二级页表为例:

二级页表
        上面的页表数组共有 2^20 = 2^10 * 2^10=1M 个元素,为了压缩页表的存储空间,可以将上面的页表分拆成 2^10 = 1K = 1024 个小的页表数组,这样每个小页表数组只包含 2^10 = 1K = 1024 个元素,占用 2^10 * 4 = 4KB 的内存,也即一个页面的大小。这 1024 个小的页表数组本身可以存储在不同的物理页,它们之间可以是不连续的。

        那么问题来了,既然这些小的页表分散存储,位于不同的物理页,该如何定位它们呢?
也就是如何记录它们的编号(也即在物理内存中位于第几个页面)。

        1024 个页表有 1024 个索引,所以不能用一个指针指向它们,必须将这些索引再保存到一个额外的数组中。
        这个额外的数组有1024个元素,每个元素记录一个页表所在物理页的编号,长度为4个字节,总共占用4KB的内存,我们将这个额外的数组称为页目录,因为它的每一个元素对应一个小页表。
        如此,只需使用一个指针记下页目录的地址,等到进行地址转换时,可以根据这个指针找到页目录,再根据页目录找到页表,最后找到物理地址,前后共经过3次间接转换。
        那么,如何根据虚拟地址找到页目录和小页表中相应的元素呢?
-----我们不妨将虚拟地址分割为三部分,高10位作为页目录中元素的下标,中间10位作为小页表中元素的下标,最后12位作为数据在物理页内偏移量,如下图所示:

在这里插入图片描述
        前面我们说过,知道了物理页的索引和页内偏移就可以转换为物理地址了,在这种方案中,页内偏移可以从虚拟地址的低12位得到,但是物理页索引却保存在 1024 个分散的小页表中,所以就必须先根据页目录找到对应的页表,再根据页表找到物理页索引。
在这里插入图片描述
        采用这样的两级页表的一个明显优点是,如果程序占用的内存较少,分散的小页表的个数就会远远少于1024个,对应占用存储空间越小(远远小于4M)。

        在极少数的情况下,程序占用的内存非常大,布满了虚拟地址空间,这样小页表的数量可能接近甚至等于1024,再加上页目录占用的存储空间,总共是 4MB+4KB,比上面使用一级页表的方案还多出4KB的内存。
对于32位系统来说这是可以容忍的,因为很少出现如此极端的情况。
        也就是说,使用两级页表后,页表占用的内存空间不固定,它和程序本身占用的内存空间成正比,从整体上来看,会比使用一级页表占用的内存少得多。
        上面对应的是32位环境虚拟地址空间只有4G,对于64位环境,虚拟地址空间达到 256TB,如果使用二级页表占用的存储空间依然还是过大,从而继续使用三级页表甚至多级页表,这样就会有多个页目录,虚拟地址也会被分割成更多个部分,思路和上面是一样的,就继续分析着玩儿了!

MMU部件以及对内存权限的控制

        通过页表完成虚拟地址和物理地址的映射时,要经过多次转换,还要进行计算,如果由操作系统来完成这项工作,那将会成倍降低程序的性能,得不偿失,所以这种方式还是不现实的,还得继续想办法。

MMU
        在CPU内部,有一个部件叫做MMU(Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址,如下图所示:
在这里插入图片描述
         即便是这样,MMU也要访问好几次内存,性能依然堪忧,所以在MMU内部又增加了一个缓存,专门用来存储页目录和页表。MMU内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的10%的情况无法命中,再去物理内存中加载页表。
        有了硬件的直接支持,使用虚拟地址和使用物理地址相比,损失的性能已经很小,终于在可接受的范围内了。
        MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。
        在程序加载到内存以及程序运行过程中,操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3 寄存器。MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射。
        每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3 寄存器的值就能够切换到对应的页表。
对内存权限的控制
        MMU 除了能够完成虚拟地址到物理地址的映射,还能够对内存权限进行控制。在如一级页表数组中,每个元素占用4个字节,也即32位,我们使用高20位来表示物理页编号,还剩下低12位,这12位就用来对内存进行控制,例如,是映射到物理内存还是映射到磁盘,程序有没有访问权限,当前页面有没有执行权限等。

        操作系统在构建页表时将内存权限定义好,当MMU对虚拟地址进行映射时,首先检查低12位,看当前程序是否有权限使用,如果有,就完成映射,如果没有,就产生一个异常,并交给操作系统处理。操作系统在处理这种内存错误时一般比较粗暴,会直接终止程序的执行。
请看下面的代码:

#include <stdio.h>
int main() {
    char *str = (char*)0XFFF00000;  //使用数值表示一个明确的地址
    printf("%s\n", str);
    return 0;
}

      这段代码不会产生编译和链接错误,但在运行程序时,为了输出字符串,printf() 需要访问虚拟地址为 0XFFFF00000 的内存,但是该虚拟地址是被操作系统占用的(后面知识点马上会讲解),程序没有权限访问,会被强制关闭。而在Linux下,会产生段错误(Segmentation fault),相信大家在编程过程中会经常见到这种经典的内存错误。

操作系统的内存分布

      不同的操作系统的内存分布是不一样的,我们说说Linux,Windows是闭源操作系统,很难说清楚它内部的内存分布,Linux我们也只需要大致了解一下内存分布就可以解释我们在编程中的很多问题,了解我们在前面讲的这个图,基本就够用了:
在这里插入图片描述
内核空间和用户空间
      对于32位环境,理论上程序可以拥有 4GB 的虚拟地址空间,我们在C语言中使用到的变量、函数、字符串等都会对应内存中的一块区域。

      但是,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间(Kernel Space)。

      Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),
而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。
也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)。

内核模式和用户模式

      首先我们要解释一个概念——进程(Process):
简单来说,一个可执行程序就是一个进程,前面我们使用C语言编译生成的程序,运行后就是一个进程。进程最显著的特点就是拥有独立的地址空间。
      严格来说,程序是存储在磁盘上的一个文件,是指令和数据的集合,是一个静态的概念;进程是程序加载到内存运行后一些列的活动,是一个动态的概念。
      前面我们在讲解地址空间时,一直说“程序的地址空间”,这其实是不严谨的,应该说“进程的地址空间”。一个进程对应一个地址空间,而一个程序可能会创建多个进程。
            内核空间存放的是操作系统内核代码和数据,是被所有程序共享的,在程序中修改内核空间中的数据不仅会影响操作系统本身的稳定性,还会影响其他程序,这是非常危险的行为,所以操作系统禁止用户程序直接访问内核空间。
            要想访问内核空间,必须借助操作系统提供的 API 函数,执行内核提供的代码,让内核自己来访问,这样才能保证内核空间的数据不会被随意修改,才能保证操作系统本身和其他程序的稳定性。
            用户程序调用系统 API 函数称为系统调用(System Call);
发生系统调用时会暂停用户程序,转而执行内核代码,访问内核空间,这称为内核模式(Kernel Mode)。
      用户空间保存的是用户的应用程序代码和数据,是程序私有的,其他程序一般无法访问。
当执行用户自己的应用程序代码时,称为用户模式(User Mode)。
计算机会经常在内核模式和用户模式之间切换:
(1)当运行在用户模式的应用程序需要输入输出、申请内存等比较底层的操作时,
就必须调用操作系统提供的 API 函数,从而进入内核模式;
(2)操作完成后,继续执行应用程序的代码,就又回到了用户模式。

总结:用户模式就是执行应用程序代码,访问用户空间;内核模式就是执行内核代码,访问内核空间(当然也有权限访问用户空间)。
为什么要区分两种模式,安全考虑,怕用户的程序轻轻松松搞死操作系统
      我们知道,内核最主要的任务是管理硬件,包括显示器、键盘、鼠标、内存、硬盘等,并且内核也提供了接口,供上层程序使用。
      当程序要进行输入输出、分配内存、响应鼠标等与硬件有关的操作时,必须要使用内核提供的接口。
      但是用户程序是非常不安全的,内核对用户程序也是充分不信任的,当程序调用内核接口时,内核要做各种校验,以防止出错。
            从 Intel 80386 开始,出于安全性和稳定性的考虑,CPU 可以运行在 ring0 ~ ring3 四个不同的权限级别,也对数据提供相应的四个保护级别。不过 Linux 和 Windows 只利用了其中的两个运行级别:
(1)一个是内核模式,对应 ring0 级,操作系统的核心部分和设备驱动都运行在该模式下。
2)另一个是用户模式,对应 ring3 级,操作系统的用户接口部分以及所有的用户程序都运行在该级别。
为什么内核和用户程序要共用地址空间,为了效率
       既然内核也是一个应用程序,为何不让它拥有独立的4GB虚拟地址空间,而是要和用户程序共享、占用有限的内存呢?
       让内核拥有完全独立的地址空间,就是让内核处于一个独立的进程中,这样每次进行系统调用都需要切换进程。
切换进程的消耗是巨大的,不仅需要寄存器进栈出栈,还会使CPU中的数据缓存失效、MMU中的页表缓存失效,这将导致内存的访问在一段时间内相当低效。
而让内核和用户程序共享地址空间,发生系统调用时进行的是模式切换,模式切换仅仅需要寄存器进栈出栈,不会导致缓存失效;与进程切换比起来,效率大大提高了。

栈(Stack)

       程序的虚拟地址空间分为多个区域,栈(Stack)是其中的一个区域。
栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。
       栈内存由系统自动分配和释放:发生函数调用时就为函数运行时用到的数据分配内存,
函数调用结束后就将之前分配的内存全部销毁。
所以局部变量、参数只在当前函数中有效,不能传递到函数外部。

栈的概念
       在计算机中,栈可以理解为一个特殊的容器,用户可以将数据依次放入栈中,然后再将数据按照相反的顺序从栈中取出。

也就是说,最先放入的数据最后才能取出,而最后放入的数据必须最先取出。
这称为先进后出(First In Last Out)原则。
放入数据常称为入栈或压栈(Push),取出数据常称为出栈或弹出(Pop)。
如下图所示:
在这里插入图片描述
       从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。
       在现代计算机中,通常使用ebp寄存器指向栈底,而使用esp寄存器指向栈顶。随着数据的进栈出栈,esp 的值会不断变化,进栈时 esp 的值减小,出栈时 esp 的值增大。
ebp和esp重叠,表示栈是空的。
在这里插入图片描述
栈的大小以及栈溢出
       对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这是编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。

注意:一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的内存空间大小是针对线程来说的,而不是针对程序。

       栈内存的大小和编译器/OS有关,Windows平台栈内存大小有编译器决定,Linux是OS决定。默认大小一般都是几个M,win64是2m,linux上一般为8m。

       当程序使用的栈内存大于默认值(或者修改后的值)时,就会发生栈溢出(Stack Overflow)错误。

#include <stdio.h>
int main(){
    char str[1024*1024*8] = {0};  //局部变量都会保存栈中
    printf("xiong\n");
    return 0;
}
//执行结果
什么都不打印,因为数组已经用尽了栈空间,后面的代码不会执行了

更改Win默认的栈空间大小

gcc -Wl,--stack=16777216  *.c

更改Linux默认栈空间的大小
(1)查看linux默认栈空间的大小
  通过命令 ulimit -s 查看linux的默认栈空间大小,默认情况下为8192 KB 即8MB。

(2)临时改变栈空间的大小
  通过命令 ulimit -s 设置大小值临时改变栈空间大小。例如:ulimit -s 102400,即修改为100MB。

(3)永久修改栈空间大大小。有两种方法:
       方法一:可以在/etc/rc.local 内加入 ulimit -s 102400 则可以开机就设置栈空间大小,
任何用户启动的时候都会调用。
       方法二:修改配置文件/etc/security/limits.conf
一个函数在运行过程中,在栈上到底是怎样变化的?
函数的调用和栈是分不开的,没有栈就没有函数调用,那么函数在栈上是如何被调用的?

栈帧/活动记录
       当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。

活动记录一般包括以下几个方面的内容:

  1. 函数的返回地址,例如:
int a, b, c;
func(1, 2);
c = a + b;

       站在C语言的角度看,func() 函数执行完成后,会继续执行c=a+b;语句,那么返回地址就是该语句在内存中的位置。本质上是应该是PC里记录的下一条指令的地址。

  1. 参数和局部变量。有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中,我们暂时不考虑这种情况。

  2. 编译器自动生成的临时数据。例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。当返回值的长度较小(char、int、long 等)时,不会被压入栈中,而是先将返回值放入寄存器,再传递给函数调用者。

  3. 一些需要保存的寄存器,例如 ebp栈底指针、ebx、esi、edi 等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。
    在这里插入图片描述
    理论上 ebp 寄存器应该指向栈底,但在实际应用中,它却指向了old ebp。在寄存器名字前面添加“old”,表示函数调用之前该寄存器的值。

当发生函数调用时:
(1)实参、返回地址、ebp 寄存器首先入栈;
(2)然后再分配一块内存供局部变量、返回值等使用,
这块内存一般比较大,足以容纳所有数据,并且会有冗余;
(3)最后将其他寄存器的值压入栈中。
关于栈帧里数据的定位
    由于 esp栈顶指针 的值会随着数据的入栈而不断变化,要想根据 esp 找到参数、局部变量等数据是比较困难的,所以在实现上是根据 ebp 来定位栈内数据的。ebp 的值是固定的,数据相对 ebp 的偏移也是固定的,ebp 的值加上偏移量就是数据的地址。

例如一个函数的定义如下:

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

调用形式为:

func(15, 92);

那么函数的活动记录如下图所示:
在这里插入图片描述
    这里我们假设两个局部变量挨着,并且第一个变量和 old ebp 也挨着(根据运行模式不同它们之间可能有4个字节的空白),如此,第一个参数的地址是 ebp+12,第二个参数的地址是 ebp+8,第一个局部变量的地址是 ebp-4,第二个局部变量的地址是 ebp-8。

函数调用惯例

我们知道,一个C程序由若干个函数组成,C程序的执行实际上就是函数之间的相互调用。请看下面的代码:

#include <stdio.h>
void funcA(int m, int n){
    printf("funcA被调用\n");
}
void funcB(float a, float b){
    funcA(100, 200);
    printf("funcB被调用\n");
}
int main(){
    funcB(19.9, 28.5);
    printf("main被调用\n");
    return 0;
}

函数的参数(实参)由调用方压入栈中供被调用方使用,它们之间要有一致的约定。

函数调用方和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例(Calling Convention)。

某调用惯例一般规定以下几方面的内容:

  1. 函数参数的传递方式,是通过栈传递还是通过寄存器传递(这里我们只讲解通过栈传递的情况)。

  2. 函数参数的传递顺序,是从左到右入栈还是从右到左入栈。

  3. 参数弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。
    这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。

  4. 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。
    在C语言中,存在多种调用惯例,可以在函数声明或函数定义时指定,例如:

#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;
}

返回值类型 调用惯例 函数名(函数参数)
    在函数声明处是为调用方指定调用惯例,而在函数定义处是为被调用方(也就是函数本身)指定调用惯例。

__cdecl是C语言默认的调用惯例,编程中,我们其实很少去指定调用惯例,这个时候就使用默认的 __cdecl。

注意:__cdecl 并不是标准关键字,上面的写法在 VC/VS 下有效,但是在 GCC 下,要使用 __attribute__((cdecl))。
调用惯例表:
在这里插入图片描述
实例来深入剖析函数进栈出栈的过程

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

}

    函数使用默认的调用惯例 cdecl,即参数从右到左入栈,由调用方负责将参数出栈。
在这里插入图片描述
        为什么要留出这么多的空白,岂不是浪费内存吗?运行程序有两种模式,调式模式和发行模式,Debug模式生成程序,留出多余的内存,方便加入调试信息;以Release模式生成程序时,内存将会变得更加紧凑,空白也被消除。

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

未初始化的局部变量的值为什么是垃圾值
    为局部变量分配内存时,仅仅是将 esp 的值减去一个整数,预留出足够的空白内存,不同的编译器在不同的模式下会对这片空白内存进行不同的处理,可能会初始化为一个固定的值,也可能不进行初始化。这种不确定性,我们最好就认为是垃圾。

再次强调一个认知
    经过上面的分析可以发现,函数出栈只是在增加 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);   //18,是a的值
    return 0;
    return 0;
}

//运行结果:
n = 18

C语言动态内存分配(堆内存)

        在进程的地址空间中,代码区、常量区、全局数据区的内存在程序启动时就已经分配好了,它们大小固定,不能由程序员分配和释放,只能等到程序运行结束由操作系统回收。这称为静态内存分配。
      栈区和堆区的内存在程序运行期间可以根据实际需求来分配和释放,不用在程序刚启动时就备足所有内存。这称为动态内存分配。
      使用静态内存的优点是速度快,省去了向操作系统申请内存的时间,缺点就是不灵活,缺乏表现力,例如不能控制数据的作用范围,不能使用较大的内存。
      而使用动态内存可以让程序对内存的管理更加灵活和高效,需要内存就立即分配,而且需要多少就分配多少,从几个字节到几个GB不等;不需要时就立即回收,再分配给其他程序使用。

栈和堆的区别
1)栈
        程序启动时操作系统给程序对应的每条线程分配一块大小适当的内存做栈区,容量不大,默认一般1-8M
对于一般的函数调用这已经足够了,函数进栈出栈只是 ebp、esp 寄存器指向的变换,
或者是向已有的内存中写入数据,不涉及内存的分配和释放。大部分情况下并没有真的分配栈内存,仅仅是对已有内存的操作。栈区内存由系统分配和释放,不受程序员控制;

2)堆
        堆不是程序启动的时候分配的,是程序中调用函数malloc() ,这个函数会去向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用,当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”,
所以堆可以很大,也可以很小!
        当然 malloc() 在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是 malloc() 需要一个算法来管理堆空间,这个算法就是堆的分配算法(不去玩儿了)。
        总结:堆区内存完全由程序员掌控,想分配多少就分配多少,小到几个字节,大到几个GB,都可以,想什么时候释放就什么时候释放,非常灵活。
动态内存分配函数
        堆(Heap)是唯一由程序员控制的内存区域,我们常说的动态内存分配也是在这个区域。在堆上分配和释放内存需要用到C语言标准库中的几个函数:
malloc()、calloc()、realloc() 和 free()。

  1. malloc()
    原型为:void* malloc (size_t size);作用:malloc() 函数用来动态地分配一块堆内存里的空间,参数size 为需要分配的内存空间的大小,单位字节。返回值:成功返回分配的内存地址,失败则返回NULL。
    几点注意:
    (1)由于申请内存空间时可能有也可能没有,所以需要自行判断是否申请成功,再进行后续操作。
    (2)申请的内存空间没有做任何初始化的,里边数据是未知的垃圾数据。
    (3)在分配内存时最好不要直接用数字指定内存空间的大小,这样不利于程序的移植。
    因为在不同的操作系统中,同一数据类型的长度可能不一样。
    为了解决这个问题,C语言提供了一个判断数据类型长度的操作符,就是 sizeof。
    char *ptr = (char )malloc(10sizeof(char)); // 分配10个字符的内存空间,用来存放字符
    (4)函数的返回值类型是 void *,void 并不是说没有返回值或者返回空指针,而是返回的指针类型未知。
    所以在使用 malloc() 时通常需要进行强制类型转换,将 void 指针转换成我们希望的类型,例如:
#include <stdio.h>  /* printf, scanf, NULL */
#include <stdlib.h>  /* malloc, free, rand, system */
int main ()
{
    int i,n;
    char * buffer;
    printf ("输入字符串的长度:");
    scanf ("%d", &i);
    buffer = (char*)malloc((i+1)*sizeof(char));  // 字符串最后包含 \0
    if(buffer==NULL) 
      exit(1);  // 判断是否分配成功,异常退出,exit(0)是正常退出
    // 随机生成字符串
    for(n=0; n<i; n++){
        buffer[n] = rand()%26+'a';
    }   
    buffer[i]='\0';
    printf ("随机生成的字符串为:%s\n",buffer);
    free(buffer);  
    // 释放内存空间,不能忘记,有借有还再借不难,大量的借了不还,就没可用内存了,
    // 这种情况我们叫内存泄露,这个我们编程中尽力避免的东西。
    return 0;
}

//执行与结果
chcp 65001   //win中解决中文乱码命令
gcc -g heapmalloc.c -o heap1.exe
./heap1
输入字符串的长度:100
随机生成的字符串为:phqghumeaylnlfdxfircvscxggbwkfnqduxwfnfozvsrtkjprepggxrpnrvystmwcysyycqpevikeffmznimkkasvwsrenzkycxf
  1. calloc()
    原型:void* calloc(size_t n, size_t size);
    功能:calloc() 在内存中动态地分配 n 个长度为 size 的连续空间(n*size 个字节长度的内存空间),并将每一个字节都初始化为 0。
    calloc() 函数是对 malloc() 函数的简单封装,和malloc比就是多了一个初始化。
    返回值:成功返回分配的内存地址,失败则返回NULL。
#include <stdio.h>
#include <stdlib.h>
int main(){
    int i=0,n;
    int * pData = NULL;
    printf("请输入一个数字的数目:");
    scanf("%d",&i);
    pData = (int *)calloc(i,sizeof(int));
    if(pData == NULL)    //如果地址空间为空,不玩了,异常退出,exit(0)是正常退出
       exit(1);
    for(n=0;n<i;n++){
        printf("填进去的第几个数字: %d ",n+1);
        scanf("%d",&pData[n]);
    }

    //填进去的数字输出
    printf("填进去的数字为:  ");
    for(n=0;n<i;n++){
        printf("%d",pData[n]);
    }
    printf("\n");
    //用完后,释放内存,避免内存泄漏
    free(pData);
    return 0;
}
  1. realloc()
    原型:void* realloc(void *ptr, size_t size);
    功能:realloc() 对 ptr 指向的内存重新分配 size 大小的空间,size 可比原来的大或者小,
    还可以不变(如果你无聊的话)。
    当 malloc()、calloc() 分配的内存空间不够用时,就可以用 realloc() 来调整已分配的内存。
        如果 ptr 为 NULL,它的效果和 malloc() 相同,即分配 size 字节的内存空间。
    如果 size 的值为 0,那么 ptr 指向的内存空间就会被释放,但是由于没有开辟新的内存空间,所以会返回空指针;类似于调用 free()。
    返回值:分配成功返回新的内存地址,可能与 ptr 相同,也可能不同;失败则返回 NULL。
    几点注意:
    (1)指针 ptr 必须是在动态内存空间分配成功的指针,如后面的指针是不可以的:int *i; int a[2];
      会导致运行时错误,可以简单的这样记忆:用 malloc()、calloc()、realloc() 分配成功的指针,或者是NULL指针,才能被 realloc() 函数接受。
    (2)成功分配内存后 ptr 将被系统回收,一定不可再对 ptr 指针做任何操作,包括 free();
    相反的,可以对 realloc() 函数的返回值进行正常操作。
    (3)如果是扩大内存操作会把 ptr 指向的内存中的数据复制到新地址(新地址也可能会和原地址相同,但依旧不能对原指针进行任何操作);如果是缩小内存操作,原始据会被复制并截取新长度。
    (4)如果分配失败,ptr 指向的内存不会被释放,它的内容也不会改变,依然可以正常使用。
#include <stdio.h>
#include <stdlib.h>
int main ()
{
    int input,n;
    int count = 0;    //循环计数器
    int* numbers = NULL;
    int* more_numbers = NULL;
    do {
        printf ("Enter an integer value (0 to end): ");
        scanf ("%d", &input);
        count++;
        more_numbers = (int*) realloc (numbers, count * sizeof(int));
        if (more_numbers!=NULL) {
            numbers=more_numbers;
            numbers[count-1]=input;
        }else {
            free (numbers);
            puts ("Error (re)allocating memory");
            exit (1);
        }
    } while(input!=0);
    printf ("Numbers entered: ");
    for (n=0;n<count;n++) 
     printf("%d ",numbers[n]);
    free (numbers);                    //more_numbers不用自已手工释放
    system("pause");
    return 0;
}
  1. free()
    原型:void free(void* ptr);
    功能:释放由 malloc()、calloc()、realloc() 申请的内存空间。
    几点注意:
    (1) 每个内存分配函数必须有相应的 free 函数,释放后不能再次使用被释放的内存。
    (2) free(ptr ) 并不能改变指针 ptr 的值,ptr 依然指向以前的内存,为了防止再次使用该内存,建议将 ptr 的值手动置为 NULL。
        没有这个步骤,ptr就变成了指向释放了的内存或者指向没有权限的内存,这种指针叫“野指针”。
        野指针是我们编程中要尽量避免的东西!
    free(ptr);
    ptr = NULL;
    (3)free() 只能释放动态分配的内存空间,并不能释放任意的内存。下面的写法是错误的:
    int a[10];
    // ...定义好的变量,是分配栈空间的不是堆内存了
    free(a);
        如果 ptr 所指向的内存空间不是由上面的三个函数所分配的,或者已被释放,那么调用 free() 会有无法预知的情况发生。
    (4)如果 ptr 为 NULL,那么 free() 不会有任何作用。
#include <stdlib.h>
int main ()
{
    int * buffer1, * buffer2, * buffer3;
    buffer1 = (int*) malloc (100*sizeof(int));
    buffer2 = (int*) calloc (100,sizeof(int));
    buffer3 = (int*) realloc (buffer2,500*sizeof(int));
    free (buffer1);
    buffer1 = NULL;
    free (buffer3);
    buffer3 = NULL;
    return 0;
}

总结:什么时候要自己分配内存使用堆空间?
栈:先进后出的连续存储方式,容量小,一般程序中的局部变量名和指针什么的都在栈上存储!
堆:非连续的存储方式,容量大,一般程序中占用空间较大的数据,比如数组、大字符串就放在堆上。

  • 14
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

qq_33406021

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

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

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

打赏作者

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

抵扣说明:

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

余额充值