C 语言内存管理

摘自:C语言内存系列-漫漫程序之路

经常听说的计算机存储单元主要包括:寄存器,高速缓存、内存、外部存储器(固态硬盘、机械硬盘)等。读写速度也是由高到低依次排列。其中高速缓存无需程序员管理;无论是程序还是数据,都需要加载(Load) 到内存中才能被读取执行;外部存储器空间最大,但并不能被CPU 直接访问。如果我们的程序所需的内存空间太大,操作系统就会在硬盘上划出一部分空间用于暂时存储用不着的内存中的数据,这块空间被称为虚拟内存(virtual memory)。但是频繁与硬盘交互数据会拖慢计算机的运行速度。

虚拟地址

以下代码摘自:https://www.cnblogs.com/still-smile/p/14900347.html

#include <stdio.h>
#include <stdlib.h>
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 来说是虚拟的。而且这样做还有一个好处,就是可以将不同程序的内存相互隔离,避免误操作。

计算机的整个发展过程就是不断引入新的中间层

如果一层指针不能解决问题,那就再加一层。中间层的增加使得我们程序的灵活性增加,也更加容易理解。

编译模式

  • 32 位操作系统可以通过两次寻址访问超过4GB 的内存;
  • 64 位操作系统,一般限制只能访问256TB 的内存空间。

内存对齐

CPU 总是按数据总线宽度进行寻址,比如32 位CPU 一次会读取4 字节(起始位置为4 的整数倍:0x00,0x04,0x08,…),64 位一次读取8 字节。如果能保证内存中的数据都是按4 或8 整齐排列,那么就能提高程序的运行效率。为此,程序在编译时一般都会将变量、结构体等基于内存对齐,当然这样做可能会造成内存资源的浪费。全局变量在release 版本会对齐,局部变量不会自动对齐,跟编译器软件有关。

内存分页

在程序运行时,某个时间段内只会频繁的用到某一小部分数据,大部分数据不需要加载到内存。于是我们可以通过分页(paging)的方法减小内存换入换出的粒度,提高程序运行效率。一般来说,一页的大小是4 KB。我们把虚拟空间中的页叫做虚拟页(VP,virtual Page),物理内存中的页叫做物理页(PP,Physical Page),同理还有磁盘页(DP,Disk Page)。有些虚拟页会被映射到同一块物理页以共享内存。当程序访问到不存在的虚拟页时会触发页错误(Page Fault),操作系统将会接管进程,加载相关内容到物理页,同时建立与虚拟页之间的联系。

页表转换

现代计算机操作系统都使用分页来管理内存,程序内只能访问到虚拟内存,需要通过页表(Page Table)数据结构才能映射到物理内存地址。

  • 一级页表:长度为4 字节,高20 位表示页表数组下标,低16 位表示页内偏移。不管程序多大,都要分配4M 的内存空间用于页表管理,包括这4M(1M 页表项 x 4 字节)也应在页表中。

因为默认页大小为4K,虚拟内存按4GB 计算刚好有1M 个,用20 位刚好能表示,低12 位刚好能表示4K 大小的页内偏移。

  • 二级页表:为了节省空间,可以将一级页表拆分为页目录(高10 位)+页表项(次10 位)+业内偏移(低12 位)。

相当于我们把4GB 内存先分成了1024 个4MB 的页表项,又把每个页表项分成了1024 个4KB 的页。这样我们最多需要用到4KB + 4MB 空间存储页表信息。其中页目录的4KB 是固定大小,页表项的4MB 是按需要在内存中不同的位置灵活创建的。参考:https://www.zhihu.com/question/63375062

  • 更多级页表:对于64 位系统可以分更多等级,原理一致

只要能保证一级页表能覆盖整个虚拟内存地址,各次级页表可以在需要时被创建即可,这样在一般情况下是可以节省内存空间的,而且让程序更灵活。

想一想: 于是我们需要首先分配4KB 给页目录,然后再分配4KB 给第一个页表项,其中页表项的第一个页指向页目录,第二个页指向它自己,剩余的空间可以用于分配其他内存?这些操作都应该是人为规定的才对!

内存管理单元

CPU 有一个专门的内存管理单元(MMU),负责将虚拟地址映射到物理地址,并且其中内置了高速缓存,用于存储页目录和页表数据。但是MMU 并不会参与构建页表,在操作系统运行时会不断更新页表信息,并将页目录的物理地址保存到CR3 寄存器,MMU 则会根据这个寄存器找到页目录、页表,最终完整内存转换。
每个进程都有自己的一套页表,切换程序时需要刷新CR3 寄存器。
此外,MMU 还能控制内存的访问权限:通过二级页表的第12 位,来表示页是在物理内存还是在磁盘,是否可读写等。

https://www.cnblogs.com/still-smile/p/14900421.html 中的代码为例,程序访问了受保护的虚拟内存地址,则报运行时错误:

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

想一想: 虽然每个进程都有自己的页表,但是这个页表并不是自己创建的,而且有一部分内容是进程自己也不能访问的,需要操作系统给分配可访问的内容才行。

C 程序的内存模型

Linux

一般在32 位系统中,操作系统要占高地址的1~2GB 的空间(内核空间kernel)。用户进程最多只能寻址到3GB(用户空间user space)。在64 位系统中是128TB+128TB 对半分的设计。
而用户程序从低到高一般可分为:代码区、常量区、全局数据区、堆区、动态链接库区和栈区。程序员唯一能自由管理的就是堆区,但是如果不能及时释放无效数据,可能会造成内存泄露,直到主程序退出。
32 位程序的栈空间一般为2~10MB。

Windows

相关资料较少,但分区的思路应该是一致的。

内核模式与用户模式

讲到内核模式就要讲进程切换,进程切换需要考虑重置页表、寄存器等操作。

想一想: CPU 在某一时刻只能执行一个进程,那该如何调用内核进程呢?应该是用户进程试图发出一个信号(可以是中断、也可以是其他信号)用于唤醒内核进程,内核进程执行完之后重新休眠吧。然后就算是内核进程休眠了,她所拥有的内存空间也不能直接被用户进程访问。

函数调用约定

C 语言内存分配

堆栈溢出攻击

内存池

操作系统分配内存应该是先查看页表数据,然后根据物理内存的空闲情况然后再分配吧,在程序推出后,操作系统会统一回收相关的页表信息。但实际上这样的效率不高,还不如直接分配给程序一段内存,让程序自己管理。malloc 函数就是一次向程序申请开通一大块儿内存,然后自己再按需分配给程序。为了避免程序反复申请、释放内存造成大量碎片,则需要设计内存池的管理算法。

Register 变量

大量简单循环的数学运算可以在变量声明前加register 选项,可能会加快运算速度。

参考

  1. 操作系统中的多级页表到底是为了解决什么问题?