# 内存-程序核心 程序是在内存中运行的,一名合格的程序员必须了解内存,学习C语言是了解内存布局的最简单、最直接、最有效的途径,C语言简直是为内存而生的,它比任何一门编程语言都贴近内存。 以下问题只有熟知内存后才能理解: - C语言中使用的地址为什么是假的,计算机又是如何通过假的地址访问到真实的物理内存的? - 一个C语言程序在内存中是如何分布的?函数放在哪里?变量放在哪里?字符串放在哪里? - 为什么全局变量在整个程序中都可以使用,而局部变量只能在函数内部使用? - 一个C语言程序可以使用多大的内存? - 操作系统和用户程序之间是如何协作的? - 堆和栈都是什么,它们在程序运行过程中起到什么作用?为什么栈内存的分配效率要高于堆? - 栈溢出是怎么回事,如何利用栈溢出进行攻击? - 内存泄漏、野指针、非法内存访问、段错误都是怎么产生的? - 内存池、线程池、连接池等这些莫名其妙的“池子”是怎么回事? ## 一个程序在计算机中如何运行 程序是保存在硬盘中的,要载入内存才能运行,CPU也被设计为只能从内存中读取数据和指令。 对于CPU来说,内存仅仅是一个存放指令和数据的地方,并不能在内存中完成计算功能,例如要计算 a = b + c,必须将 a、b、c 都读取到CPU内部才能进行加法运算。为了了解具体的运算过程,我们不妨先来看一下CPU的结构。 ```mermaid flowchart LR A[硬盘] B[内存] D[运算器] R[寄存器] F[缓存] A --> B B --> F B <--> R subgraph CPU F --> R D <--> R end ``` 运算单元是CPU的大脑,负责加减乘除、比较、位移等运算工作,每种运算都有对应的电路支持,速度很快。 寄存器(Register)是CPU内部非常小、非常快速的存储部件,它的容量很有限,对于32位的CPU,每个寄存器一般能存储32位(4个字节)的数据,对于64位的CPU,每个寄存器一般能存储64位(8个字节)的数据。为了完成各种复杂的功能,现代CPU都内置了几十个甚至上百个的寄存器,嵌入式系统功能单一,寄存器数量较少。 我们经常听说多少位的CPU,指的就是寄存器的的位数。现在个人电脑使用的CPU已经进入了64位时代,例如 Intel 的 Core i3、i5、i7 等。 寄存器在程序的执行过程中至关重要,不可或缺,它们可以用来完成数学运算、控制循环次数、控制程序的执行流程、标记CPU运行状态等。例如,EIP(Extern Instruction Pointer )寄存器的值是下一条指令的地址,CPU执行完当前指令后,会根据 EIP 的值找到下一条指令,改变 EIP 的值,就会改变程序的执行流程;CR3 寄存器保存着当前进程页目录的物理地址,切换进程就会改变 CR3 的值;EBP、ESP 寄存器用来指向栈的底部和顶部,函数调用会改变 EBP 和 ESP 的值。 那么,在CPU内部为什么又要设置缓存呢?虽然内存的读取速度已经很快了,但是和CPU比起来,还是有很大差距的,不是一个数量级的,如果每次都从内存中读取数据,会严重拖慢CPU的运行速度,CPU经常处于等待状态,无事可做。在CPU内部设置一个缓存,可以将使用频繁的数据暂时读取到缓存,需要同一地址上的数据时,就不用大老远地再去访问内存,直接从缓存中读取即可。 缓存的容量是有限的,CPU只能从缓存中读取到部分数据,对于使用不是很频繁的数据,会绕过缓存,直接到内存中读取。所以不是每次都能从缓存中得到数据,这就是缓存的命中率,能够从缓存中读取就命中,否则就没命中。关于缓存的命中率又是一门学问,哪些数据保留在缓存,哪些数据不保留,都有复杂的算法。 ## CPU指令 要想让CPU工作,必须借助特定的指令,例如 add 用于加法运算,sub 用于除法运算,cmp 用于比较两个数的大小,这称为CPU的指令集(Instruction Set)。我们的C语言代码最终也会编译成一条一条的CPU指令。不同型号的CPU支持的指令集会有所差异,但绝大部分是相同的。 我们以C语言中的加法为例来演示CPU指令的使用。假设有下面的C语言代码: ```c int a = 0X14, b = 0XAE, c; c = a + b; ``` 在VS2010 Debug模式下生成的CPU指令为: ```c mov ptr[a], 0X14 mov ptr[b], 0XAE mov eax, ptr[a] add eax, ptr[b] mov ptr[c], eax ``` mov 和 add 都是CPU指令: 1) mov 用来将一个数值移动到一个存储位置。这个数值可以是一个常数,也可以在内存或者寄存器上;这个存储位置可以是寄存器或者内存。 第一条指令中,`ptr[a]`表示变量 a 的地址,`0X14`是一个数值,`mov ptr[a], 0X14`表示把数值 0X14 移动到 ptr[a] 指向的内存,也就是给变量 a 赋值。第二条指令与此类似。 第三条指令中,`eax`是寄存器的名字,该寄存器常用在加法运算中,用来保存某个加数或运算结果,`mov eax, ptr[a]`表示把变量 a 的值移动到寄存器 eax 中。 第五条指令表示把寄存器 eax 的值移动到变量 c 中,此时 exa 中的值为 a、b 相加的和。 2. add 用来将两个数值相加,这两个数值可以在寄存器或者内存中,add 会将相加的结果放在第一个数所在的位置。第四条指令`add eax, ptr[b]`表示把 eax 和 ptr[b] 中的数值相加,并把结果放在 eax 中。 总起来讲:第一二条指令给变量 a、b 赋值,第三四条指令完成加法运算,第五条指令将运算结果赋值给变量 c。 本节我们讲解了CPU的简单构造以及CPU指令,重点是让大家认识寄存器这个小而快速的存储部件,它在程序运行过程中起着至关重要的作用,CPU就是用它来记录程序的运行状态,然后根据它的值再决定下一步的操作。 ## 虚拟内存 在C语言中,指针变量的值就是一个内存地址,`&`运算符的作用也是取变量的内存地址,请看下面的代码: ```c #include #include int a = 1, b = 255; int main(){ int *pa = &a; printf("pa = %#X, &b = %#X\n", pa, &b); system("pause"); return 0; } ``` 代码中的 a、b 是全局变量,它们的内存地址在链接时就已经决定了,以后再也不能改变,该程序无论在何时运行,结果都是一样的。那么问题来了,如果物理内存中的这两个地址被其他程序占用了怎么办,我们的程序岂不是无法运行了? 幸运的是,这些内存地址都是假的,不是真实的物理内存地址,而是虚拟地址。虚拟地址通过CPU的转换才能对应到物理地址,而且每次程序运行时,操作系统都会重新安排虚拟地址和物理地址的对应关系,哪一段物理内存空闲就使用哪一段。 ### 虚拟地址 虚拟地址的整个想法是这样的:把程序给出的地址看做是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证程序每次运行时都可以使用相同的地址。 例如,上面代码中变量 a 的地址是 0X402000,第一次运行时它对应的物理内存地址可能是 0X12ED90AA,第二次运行时可能又对应 0XED90,而我们的程序不需要关心这些,这些繁杂的内存管理工作交给操作系统处理即可。 让我们回到程序的运行本质上来。用户程序在运行时不希望介入到这些复杂的内存管理过程中,作为普通的程序,它需要的是一个简单的执行环境,有自己的内存,有自己的CPU,好像整个程序占有整个计算机而不用关心其他的程序。 除了在编程时可以使用固定的内存地址,给程序员带来方便外,使用虚拟地址还能够使不同程序的地址空间相互隔离,提高内存使用效率。 #### 使不同程序的地址空间相互隔离 如果所有程序都直接使用物理内存,那么程序所使用的地址空间不是相互隔离的。恶意程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意、但是有 Bug 的程序也可能会不小心修改其他程序的数据,导致其他程序崩溃。 这对于需要安全稳定的计算机环境的用户来说是不能容忍的,用户希望他在使用计算机的时候,其中一个任务失败了,至少不会影响其他任务。 使用了虚拟地址后,程序A和程序B虽然都可以访问同一个地址,但它们对应的物理地址是不同的,无论如何操作,都不会修改对方的内存。 #### 提高内存使用效率 使用虚拟地址后,操作系统会更多地介入到内存管理工作中,这使得控制内存权限成为可能。例如,我们希望保存数据的内存没有执行权限,保存代码的内存没有修改权限,操作系统占用的内存普通程序没有读取权限等。 另外,当物理内存不足时,操作系统能够更加灵活地控制换入换出的粒度,磁盘 I/O 是非常耗时的工作,这能够从很大程度上提高程序性能。 ## 中间层思想 在计算机中,为了让操作更加直观、易于理解、增强用户体验,开发者经常会使用一件法宝——增加中间层,即使用一种间接的方式来屏蔽复杂的底层细节,只给用户提供简单的接口。虚拟地址是使用中间层的一个典型例子。 实际上,计算机的整个发展过程就是不断引入新的中间层: - 计算机的早期,程序都是直接运行在硬件之上,自己负责硬件的管理工作;程序员也使用二进制进行编程,需要处理各种边界条件和安全问题。 - 后来人们不能忍受了,于是开发出了操作系统,让它来管理各种硬件,同时发明了汇编语言,减轻程序员的负担。 - 随着软件规模的不断增大,使用汇编语言编程开始变得捉襟见肘,不仅学习成本高,开发效率也很低,于是C语言诞生了。C语言编译器先将C代码翻译为汇编代码,再由汇编器将汇编代码翻译成机器指令。 - 随着计算机的发展,硬件越来越强大,软件越来越复杂,人们又不满足于使用C语言了,于是 C++、Java、C#、PHP 等现代化的编程语言诞生了。