当前位置:   article > 正文

c语言tips-【虚拟内存空间和c语言内存模型】

c语言内存模型

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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

输出如下:

全局区/static&global:0x601050,&global2:0x601058,n:0x601060
常量:global1:0x400758 pstr:0x40076a
堆区:c:0x5402040
栈区:&str2:0xfff000ba8,&ptr:0xfff000bc0, ptr:0xfff000bc0, a:0xfff000ba4
  • 1
  • 2
  • 3
  • 4

总结如下:

  • 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, 而局部变量你没初始化它被赋值为未知值
声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号