0.摘要
C语言是比较接近底层的语言,因此它的很多知识点是和操作系统挂钩的,例如它的内存模型,其实也是操作系统进程的内存模型,本文章就是解释进程,虚拟内存空间,内存模型的相关知识和它们之间的联系
1. 虚拟内存空间
我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。`虚拟地址空间就是一个中间层,相当于在程序和物理内存之间设置了一个屏障,将二者隔离开来。程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。
- 首先我们要解释一个概念——进程( Process) 。简单来说,一个可执行程序就是一个进程,前面我们使用 C语言编译生成的程序,运行后就是一个进程。
进程最显著的特点就是拥有独立的地址空间。
- 严格来说,程序是存储在磁盘上的一个文件,是指令和数据的集合,是一个静态的概念;进程是程序加载到内存运行后一些列的活动,是一个动态的概念。可以
类比面对对象,程序是类,进程就是实例化的对象
一个进程对应一个地址空间,而一个程序可能会创建多个进程
2. 虚拟内存空间如何映射到物理内存?
在 CPU 内部,有一个部件叫做 MMU( Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址,如下图所示:
在页映射模式下, CPU 发出的是虚拟地址,也就是我们在程序中看到的地址,这个地址会先交给 MMU,经过MMU 转换以后才能变成了物理地址。
即便是这样, MMU 也要访问好几次内存,性能依然堪忧,所以在 MMU 内部又增加了一个缓存,专门用来存储页目录和页表。 MMU 内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的 10%的情况无法命中,再去物理内存中加载页表。
有了硬件的直接支持,使用虚拟地址和使用物理地址相比,损失的性能已经很小,在可接受的范围内。
MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。在程序加载到内存以及程序运行过程中, 操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3寄存器(CR3 是 CPU 内部的一个寄存器,专门用来保存页目录的物理地址
)。 MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射 。
每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3 寄存器的值就能够切换到对应的页表。
3. 为什么要用MMU来映射物理内存空间?
- 每个进程的地址不隔离,有安全风险。
由于程序都是直接访问物理内存,所以恶意程序可以通过内存寻址随意修改别的进程对应的内存数据,以达到破坏的目的。虽然有些时候是非恶意的,但是有些存在 bug 的程序可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。
- 内存效率低。
如果直接使用物理内存的话,一个进程对应的内存块就是作为一个整体操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区(虚拟内存)中,以便腾出内存,因此就需要将整个进程一起拷走,如果数据量大,在内存和磁盘之间拷贝时间就会很长,效率低下。
- 进程中数据的地址不确定,每次都会发生变化。
由于物理内存的使用情况一直在动态的变化,我们无法确定内存现在使用到哪里了,如果直接将程序数据加载到物理内存,内存中每次存储数据的起始地址都是不一样的,这样数据的加载都需要使用相对地址,加载效率低(静态库是使用绝对地址加载的)。
4. 虚拟内存有什么用?
- 第一,虚拟内存可以
使得进程对运行内存超过物理内存大小
,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。 - 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是
相互独立的
。进程也没有办法访问其他进程的页表
,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。 - 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
5. 内存模型是咋样的?
我们在学习c语言时会接触到
malloc
函数,它是在堆区分配内存空间,那么什么是堆区呢?什么又是栈区呢?
)
这是c语言的内存模型,可以主要分为以下内存区
内存分区 | 说明 |
---|---|
程序代码区 (code | 存放函数体的二进制代码。一个 C 语言程序由多个函数构成, C 语言程序的执行就是函数之间 的相互调用 |
常量区 (constant) | 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程 序运行期间不能改变。 |
全局数据区 (global data) | 存放全局变量、静态变量等。这块内存有读写权限,因此它们的值在程序运行期间可以任意改 变 |
堆区 (heap) | 一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。 malloc()、 calloc()、 free() 等函数操作的就是这块内存,这也是本章要讲解的重点**(注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。)** |
动态链接库 | 用于在程序运行期间加载和卸载动态链接库 |
栈区 (stack) | 存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈 |
-
在这些内存分区中(暂时不讨论动态链接库),程序代码区用来保存指令,常量区、全局数据区、堆、栈都用来保存数据。对内存的研究,重点是对数据分区的研究 。
-
程序代码区、常量区、全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。
-
常量区和全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在
-
函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。
-
常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。程序员唯一能控制的内存区域就是堆( Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用
一个例子(完美的解决c语言变量的所处内存块问题)
#include <stdio.h>
#include "stdlib.h"
// 字符串在常量区,全局变量在全局区
char *global1 = "junhaozhendeshuai";
// 静态区
static int global2 = 1;
int n;
char* func()
{
char* str = "张三真的帅!";
return str;
}
int main()
{
int a;
char *str2 = "01234";
char ptr[20] = "56789";
char *pstr = func();
char* c = (char *)malloc(100);
// printf("局部变量初始值为:%d, 全局变量初始值为:%d\n", a, n);
printf("全局区/static:&global:%p,&global2:%p,n:%p\n", &global1, &global2, &n);
printf("常量:global1:%p pstr:%p\n",global1,pstr);
printf("堆区:c:%p\n", c);
printf("栈区:&str2:%p,&ptr:%p, ptr:%p, a:%p", &str2, &ptr, ptr, &a);
return 0;
}
输出如下:
全局区/static:&global:0x601050,&global2:0x601058,n:0x601060
常量:global1:0x400758 pstr:0x40076a
堆区:c:0x5402040
栈区:&str2:0xfff000ba8,&ptr:0xfff000bc0, ptr:0xfff000bc0, a:0xfff000ba4
总结如下:
char *global1 = "junhaozhendeshuai"
,global1
这个指针是在全局区,"junhaozhendeshuai"
这个字符串是在常量区,我们要查看global1
这个指针的地址可以通过&global1
来获得,我们要查看junhaozhendeshuai
这个字符串的地址可以通过global1
来获得static int global2 = 1;
, 这个静态变量global2
也在全局区,可以通过&global2
获得它的地址int n;
,这个变量在函数外定义,在全局区,通过&n
来获得地址int a;
,这个函数在main函数中,在栈区,通过&a
来获得地址char *str2 = "01234";
,str2
这个指针是在栈区,"01234"
这个字符串是在常量区,我们要查看str2
这个变量的地址可以通过&str2
获得,我们要查看"01234"
这个字符串的地址可以通过str2
获得,char ptr[20] = "56789";
,ptr
这个指针是在栈区,"56789"
这个字符串也是在栈区,我们要查看ptr
这个变量的地址可以通过&ptr
获得,我们要查看"56789"
这个字符串的地址可以通过ptr
获得
为啥两种字符串的表达形式在内存模型不一样呢?
字符串数组
和char *
表示的字符串有什么区别呢?它们最根本的区别是在内存中的存储区域不一样,字符数组存储在全局数据区或栈区
,第二种形式的字符串存储在常量区
。全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限,而常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。
char *pstr = func();
,调用函数一个指向字符串常量的地址,值得注意的是,由于字符串常量已经被建立出来了,它不会被释放,此时我们把指向它的指针返回回来,pstr
这个指针是在栈区,可以使用&pstr
获得地址,"张三真的帅!"
这段字符串是在常量区,可以用pstr
获得他的地址char* c = (char *)malloc(100)
,很明显,用malloc
开辟出来的内存是在堆区,即c
这个变量的地址在堆区- 最后一点,我们可以发现
全局整型变量你没初始化时它被赋值为0
, 而局部变量你没初始化它被赋值为未知值