一、内存管理的层次关系
用户层 | ||
---|---|---|
STL | 自动分配、自动释放 | 调用C++ |
C++ | new/delete、构造/析构 | 调用C |
C | malloc\calloc\realloc\free | 调用POSIX\Linux |
POSIX | sbrk\brk | 调用Kernal |
Linux | mmap\munmap | 调用Kernal |
系统层 | ||
Kernal | kmalloc /vmalloc | 调用驱动Driver |
Driver | get_free_page | ... |
二、进程映像
程序与进程:
-
程序是存储在磁盘上的可执行文件,当程序被运行时,系统会把程序从磁盘加载到内存中运行,正在运行中的程序称为进程,一个程序可以同时被加载多次,形成多个进程,每个进程相互独立,由操作系统管理
什么是进程映像:
-
进程在内存空间中的分布使用情况就称为进程映像,从低地址到高地址分别是:
各个内存段的存储什么数据:
-
代码段(text)
-
存储二进制指令、字面值常量、被const修饰过的原data段的数据
-
权限r-- 或者 r-x 权限只读
-
-
数据段(data)
-
存储初始化过的全局变量和静态变量
-
-
静态数据段(BSS)
-
存储未初始化过的全局变量和静态变量
-
进程一旦加载,此内存段会被操作系统自动清零,默认值是0
-
如果初始化的值给0,依然还在BSS
-
-
堆区(heap)
-
要程序员动态分配、动态释放,从低地址向高地址扩招
-
使用malloc系列函数进行内存管理
-
而malloc系列函数底层调用操作系统的API(brk\sbrk\mmap\munmap)
-
-
栈区(stack)
-
存储非静态局部变量、块变量,包括函数的参数(除了main函数的参数)、返回值
-
内存扩展从高地址向低地址扩展
-
栈区与堆区中间有一段预留空间,一个作用为了预留,第二个是让共享库的内存以及共享内存使用此段内存
-
-
命令行参数与环境变量表
-
里面存储环境变量表以及命令行传给main的参数内容
-
// 打印出每个内存段中各种数据的地址
// 通过ps -aux 查看出进程id
// /proc/进程号/maps 该文件能查看该进程的内存分布情况
// 通过打印的内存地址,分析maps文件的内存段分布情况
#include <stdio.h>
#include <stdlib.h>
const int const_global = 10; // 常全局变量
int init_global = 10; // 初始化全局变量
int uninit_global; // 未初始化全局变量
int main(int argc,const char* argv[])
{
const static int const_static = 10; // 常静态局部变量
static int init_static = 10;// 初始化静态局部变量
static int uninit_static; // 未初始化静态局部变量
const int const_local; // 常局部变量
int prev_local; // 前局部变量
int next_local; // 后局部变量
int arr[10];
int* prev_heap = malloc(sizeof(int)); // 前堆变量
int* next_heap = malloc(sizeof(int)); // 后堆变量
const char* literal = "literal"; // 字面值常量
extern char** environ; // 环境变量
printf("-----命令行参数与环境变量-----<高>\n");
printf("环境变量:%p\n",environ);
printf("命令行参数:%p\n",argv);
printf("-------------栈-------------------\n");
printf("常局部变量:%p\n",&const_local);
printf("前局部变量:%p\n",&prev_local);
printf("后局部变量:%p\n",&next_local);
printf("arr[0]:%p arr[1]:%p arr[2]:%p\n",&arr[0],&arr[1],&arr[2]);
printf("-------------堆-------------------\n");
printf("后堆变量:%p\n",next_heap);
printf("前堆变量:%p\n",prev_heap);
printf("-----------BSS--------------------\n");
printf("未初始化全局变量:%p\n",&uninit_global);
printf("未初始化静态变量:%p\n",&uninit_static);
printf("----------data--------------------\n");
printf("初始化全局变量:%p\n",&init_global);
printf("初始化静态变量:%p\n",&init_static);
printf("---------代码段-------------------\n");
printf("常静态局部变量:%p\n",&const_static);
printf("常全局变量:%p\n",&const_global);
printf("字面值常量:%p\n",literal);
printf("二进制指令:%p\n",main);
printf("------------------------------<低>\n");
getchar();
}
三、虚拟内存
-
首先要理解操作系统不能把真实的物理内存直接分配给进程使用,如果真的这样做,那么会面临严重的安全问题,进程可以根据或得到的真实物理内存地址,通过指针访问其它进程的内存进行破坏,甚至会影响操作系统的安全,所以才会引入虚拟内存的概念
什么是虚拟内存:
-
虚拟内存是操作系统对进行内存空间地址进行管理的一套精心设计的逻辑层面的内存空间概念
-
在32位系统下,操作系统规定给每个启动的进程拥有4G大小虚拟内存,但是这4G的虚拟内存不能直接使用的,其实就是系统给进程画的饼,当进程真正要存储数据需要使用内存时,系统会把一部分虚拟内存与物理内存进行映射,必须进行映射后的虚拟内存才能正常使用
-
如果非要使用没有映射过的虚拟内存,操作系统一定会在运行时产生段错误
用户空间与内核空间:
-
每个进程的4G虚拟内存中,根据使用者不同分为两个部分:
用户空间:
-
[0x00000000~0xC0000000]部分,有3GB大小,在经过系统的映射后,在应用程序中使用,但是程序不能直接访问内核空间的代码和数据,但是可以通过系统调用(API)让当前进程从用户态进入内核态,间接地与系统内核进行数据交互
内核空间:
-
[0xC0000000~0xFFFFFFFF]部分,有1GB大小,只有操作系统才能使用,里面存储的是操作系统为服务进程与该进程进行交互所必需的相关数据,内核空间又操作系统来管理,内核空间不会随着进程的切换而改变
-
但是用户空间对应不同的进程,一旦进程切换,用户空间使用的物理内存也会被内核随之切换,这样就确保了不同进程的用户空间是完全独立的。
-
因此不同进程之间使用虚拟内存进行交互数据是毫无意义的,所以下面两个程序直接通过虚拟内存地址交互数据是毫无意义的
// 程序A
#include <stdio.h>
int main(int argc,const char* argv[])
{
int num = 123456;
printf("%lu\n",(unsigned long)&num);
getchar();
}
// 程序B
#include <stdio.h>
int main(int argc,const char* argv[])
{
int* p = NULL;
unsigned long num = 0;
scanf("%lu",&num);
p = (int*)num;
printf("%d\n",*p); // 无法访问程序A的num内存
}
-
在内核态下,进程运行在3~4GB空间范围内,此时CPU可执行任何指令,代码不受任何的限制,可以自由地访问任何有效地址,甚至可以直接访问端口
-
在用户态,进程运行在0~3GB空间范围内,此时执行的代码要受到一定的限制,CPU会做很多的检查,例如:检查进程只能使用映射过的虚拟地址等
进程使用虚拟内存的好处有哪些?
-
操作系统给每个进程分配4GB的虚拟内存,可以让进程之间进行隔离,避免进程之间相互影响、破坏
-
操作系统把4GB的虚拟内存划分成用户空间(0~3GB)和内核空间(3~4GB),可以让进程与操作系统之间进行隔离,避免进程去破坏操作系统的正常运行
-
通过给进程使用虚拟内存,避免暴露真实的物理内存地址,也是对操作系统的保护
-
操作系统还可以让磁盘上的文件与虚拟内存进行映射,当物理内存不够用时,还可以通过映射磁盘来替代,虽然速度慢一些,相当于能让用户使用比真实物理内存更大的空间
四、内存映射
自动映射
-
当程序执行时,操作系统会把它加载到内存形成进程后,会自动地给text、data、bss、stack、命令行参数、环境变量表进行自动映射
手动映射
-
在程序首次使用malloc申请内存时,此时malloc手中没有堆内存可以分配,也就是说没有任何映射过的内存可以供分配使用,malloc会找系统申请映射33页虚拟内存,这33页映射后交由malloc管理分配,之后再找malloc申请内存时,malloc会从这33页中直接分配给调用者,如果33页分配完,继续重复以上操作,这个过程malloc底层调用了操作系统的API接口函数完成映射的操作
-
如果程序需要的堆内存并不多,距离33页差距很大,那么直接使用malloc是方便,但是也造成了很大的内存映射浪费,所以通过学习直接调用系统的API接口函数,来更精确地映射内存
-
关于malloc获取虚拟内存空间的实现过程,与内核版本有关,大体逻辑:
-
如果分配的内存小于128Kb,底层调用sbrk\brk
-
如果分配的内存大于128Kb,底层调用mmap\munmap
-
以上只是简单情况,具体实际可能会更复杂
-
五、Linux系统内存管理API
遵循POSIX标准的内存管理API:
#include <unistd.h>
// brk和sbrk在内部维护一个指针p(void*),p指针指向当前堆内存中已经映射成功的最后一个字节的下一个地址位置
void *sbrk(intptr_t increment);
功能:根据参数increment来调整p的位置,既可以映射内存,也可以取消映射
incremen:移动增量
>0 p+incremen 映射内存
0 p 获取p的位置
<0 p+incremen 取消映射
返回值:移动前,p指针的位置
int brk(void *addr);
功能:通过直接移动p到addr地址位置,来映射和取消映射
addr: 直接移动到的位置
> p 映射内存
< p 取消映射
返回值:成功0 失败-1
注意:sbrk和brk都可以单独映射、取消映射,但是一般都会配合一起使用,通过sbrk映射内存,通过brk取消映射
注意:sbrk和brk只能对虚拟内存进行映射、取消映射,无法做到精细化管理,只是维护一个位置指针
练习:计算出100~100000之间的所有素数,存储在堆内存,尽量不浪费内存
#include <stdio.h>
#include <unistd.h>
#include <stdbool.h>
bool is_prime(int num)
{
for(int i=2; i<=num/2; i++)
{
if(0 == num%i) return false;
}
return true;
}
int main(int argc,const char* argv[])
{
int* start = sbrk(0);
for(int i=100; i<100000; i++)
{
if(is_prime(i))
{
int* p = sbrk(4);
*p = i;
}
}
int* p = start,*end = sbrk(0);
while(p < end)
{
printf("%d ",*p++);
}
brk(start);
}
Linux系统提供的内存管理函数:
#include <sys/mman.h>
// mmap和munmap底层不维护任何东西
// 通过mmap映射内存,是以一页为单位来映射的
void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
功能:
1、用户空间的虚拟内存与物理内存建立映射关系
2、用户空间的虚拟内存与文件建立映射关系
addr:想要映射的虚拟内存的首地址,如果是NULL,则由操作系统自动计算
length:要映射的字节数
prot:映射后的内存权限
PROT_EXEC 执行权限
PROT_READ 读权限
PROT_WRITE 写权限
PROT_NONE 无权限
如果要有多个权限,进行按位或操作
flags:设置参数
MAP_SHARED 共享映射,映射的内容对其它进程是可见的,如果此时映射给文件,相当于可以输出到文件中
MAP_PRIVATE 私有映射,映射的内容其它进程不可见
MAP_ANON 拒绝对文件进行映射,会忽略fd、offset参数
MAP_FIXED 如果提供了addr无法进行映射,则直接失败,系统不会自动调整
fd:文件描述符,类似于文件指针,如果不使用则给0即可
offset:映射文件后从该偏移值开始操作文件
返回值:映射成功后的虚拟内存首地址,失败返回0xFFFFFFFF
int munmap(void *addr, size_t length);
功能:取消映射
addr:已经映射的内存首地址
length:内存字节数
返回值:成功0 失败-1
mmap()的优点
-
使用read()或write()系统调用需要从用户缓冲区进行数据读写,而使用映射文件进行操作,可以避免多余的数据拷贝。
-
除了潜在的页错误,读写映射文件不会带来系统调用和上下文切换的开销。就像直接操作内存一样简单。
-
当多个进程映射同一个对象到内存中,数据在进程间共享。只读和写共享的映射在全体中都是共享的;私有可写的尚未进行写时拷贝的页是共享的。
-
在映射对象中搜索只需要一般的指针操作。而不必使用lseek)。
mmap()的缺陷
-
映射区域的大小通常是页大小的整数倍。因此,映射文件大小与页大小的整数倍之间有空间浪费。对于小文件,较大比重的空间被浪费。例如对于4kb的页,一个7字节的映射浪费了4089字节。
-
存储映射区域必须在进程地址空间内。对于32位的地址空间,大量的大小各异的映射会导致大量的碎片出现,使得很难找到连续的大片空内存。这个问题在64位地址空间明显减少.
-
创建和维护映射以及相关的内核数据结构有一定的开销。通过上节提到的消除读写时的不必要拷贝的,这些开销可以忽略,对于大文件和频繁访问的文件更是如此。
-
基于以上理由,处理大文件(浪费的空间只占很小的比重),或者在文件大小恰好被page大小整除时(没有空间浪费)优势很明显。
内存管理总结:
-
mmap、munmap底层不维护任何东西,只会返回映射后的内存首地址
-
brk、sbrk底层维护一个位置指针,通过该指针的位置决定映射、取消映射
-
每个进程都有4G的虚拟内存,所有的虚拟内存编号都是一个数字而已,必须与物理内存建立映射后才能使用,否则段错误
-
平时所说的内存的申请和释放有两层含义
-
权限的分配和回收
-
映射关系的建立和取消
-
-
重点是理解Linux内存管理机制,而不是这四个函数的用法