430 likes | 641 Views
Linux 中的内存管理. 从机器加电启动开始,处理器就在不断的取指令、执行指令。内存分配和内存寻址是关系系统运行很重要的问题。 要更深入理解系统底层的管理机制,难免要涉及内核实现机制的分析。本章将先分析内存管理,然后再通过具体实例进行编程体验。. 关于内核源码学习. 系统实现的具体细节最终落到源代码上。从理解运行机制出发,分析各种数据结构。在理论明了、有一定的程序基础后再阅读梳理代码,这是需要反复且花功夫的一件事。 阅读工具 帮助追踪复制调用,数据结构定义等 windows 环境下利用 Source Insight
E N D
从机器加电启动开始,处理器就在不断的取指令、执行指令。内存分配和内存寻址是关系系统运行很重要的问题。 从机器加电启动开始,处理器就在不断的取指令、执行指令。内存分配和内存寻址是关系系统运行很重要的问题。 要更深入理解系统底层的管理机制,难免要涉及内核实现机制的分析。本章将先分析内存管理,然后再通过具体实例进行编程体验。
关于内核源码学习 系统实现的具体细节最终落到源代码上。从理解运行机制出发,分析各种数据结构。在理论明了、有一定的程序基础后再阅读梳理代码,这是需要反复且花功夫的一件事。 • 阅读工具 帮助追踪复制调用,数据结构定义等 • windows环境下利用Source Insight • linux环境下利用lxr(linux cross reference)或glimpse等 • 阅读顺序 一般按顺序阅读启动代码;然后进行专题阅读,如进程部分,内存管理部分等。在每个功能函数内部应该一步步来。OK,感兴趣的话,反复读。 • 纵向:顺着程序的执行顺序逐步进行。 • 横向:分模块进行 划分不是绝对的,而是经常结合在一起进行 • Linux的启动:顺着linux的启动顺序读,大致流程如下(以X86平台为例): ./arch/x86/boot/bootSect.S ./arch/x86/boot/setup.S ./arch/x86/kernel/head.S ./init/main.c中的start_kernel() • 对于内存管理等部分,可以单独拿出来按模块进行阅读分析。 参考书——《LINUX内核源代码情景分析》
主要内容 1 内存寻址 2 内存空间 内存分配和回收 3
内存寻址 1
简单的说系统运行就是两步:①装入内存、②执行;从装入内核、运行内核,到装入用户程序、运行用户程序。 简单的说系统运行就是两步:①装入内存、②执行;从装入内核、运行内核,到装入用户程序、运行用户程序。 • 怎么从内存中执行第1条指令? • 系统初始化时装入OS内核程序:这意谓着要构建操作系统进程,最关键的还包括给内核分配空间并构建内核占用空间的页表——记录内核在内存的哪里。(内存分配) • 初始化完成后,内核程序已放进内存。执行阶段,处理器只需按规定的机制查询内核的页表找到要执行的指令然后依次执行即可,当然难免遇到程序跳转,可利用栈进行相应的跳转和返回。(内存寻址)
如何运行用户程序的第1条指令? • 由内核装入用户程序:同样要构建用户程序的进程,给用户进程分配空间并构建页表——记录下来进程在内存的哪里。(内存分配) • 用户程序装入后,执行阶段处理器也是按规定的机制查询用户进程的页表找到要执行的指令然后依次执行即可,难免遇到程序跳转,可利用栈进行相应的跳转和返回。(内存寻址) 实际上内存分配和内存寻址方式是密切联系的,先假设用户程序的内存已经分配,看看 指令寻址过程具体是怎样的
4、物理地址、虚拟地址及线性地址 几个地址名词 • 物理地址:将主板上的物理内存条所提供的内存空间定义为物理内存空间,其中每个内存单元的实际地址就是物理地址。 • 虚拟地址:将应用程序员看到的内存空间定义为虚拟地址空间(或地址空间),其中的地址就叫虚拟地址(或虚地址),分段机制下,一般用“段:偏移量”的形式来描述。 • 线性地址:指一段连续的,范围为0到n的地址空间,一个线性地址就是线性地址空间的一个绝对地址。
linux是什么样的地址映射机制? 内存管理有两种,一种是段式管理,另一种是页式管理,而页式管理更为先进。从 80 年代中期开始,页式内存管理进入了各种操作系统(以 Unix 为主)的内核,一时成为操作系统的一个热点。 • 硬件对系统寻址方式的制约: • Intel平台:其 80286 开始实现保护模式的分段机制,但是很快就发现,光有段式内存管理而没有页式内存管理会使它的 X86 系列逐渐失去竞争力以及作为主流 CPU 产品的地位。于是,从 80386到后来的IA32中实现了对页式内存管理的支持。但为了兼容旧的系统结构,它的页式存储管理只能建立在段式存储管理的基础上。 • 其他平台:多数硬件不支持分段机制,而是线性地址分页机制 • linux设计地址映射机制要考虑: • Intel IA32的分段机制是不可禁止的,linux适应IA32就必须支持分段机制; • 但若要方便移植到其他平台,还要方便去除分段机制;
① 何谓“支持硬件分段机制”: 在要求分段的硬件下,地址处理必须的流程是通过段寄存器找到段处理的相关信息:段基址、段界限等。 面对intel这种强硬需求,linux必须提供这些信息,并提供程序流程进行分段处理。但Linux没有妥协,它做了特殊的段机制设计: • linux上的程序文件面向硬件只描述代码段和数据段(各有内核态和用户态)共4个段,且段基址都为0,段界限为4GB。 • 段机制前后地址实际上没有变化,然后进入分页机制 ② 方便移植: • 段机制前后地址实际上没有变化——意谓着在不要求分段的其他硬件平台上,上述的段处理程序就可以很容易的裁掉而不影响分页地址处理。
内存 页机制 页表 地址映射 思考:linux下使每个段基址为0,段界限为4GB。 ①比较a、b段的第一条指令地址,若按段思维,段基址都是0,段内指令从偏移0开始,两个段指令地址不就相同了,即使有分页机制,也无法区别开他们映射的内存地址啊? 段a 段机制 段b 段b 段a
例:i386体系结构的机器上,对c程序helloworld反汇编后观察指令地址——逻辑地址(虚拟地址)。例:i386体系结构的机器上,对c程序helloworld反汇编后观察指令地址——逻辑地址(虚拟地址)。 • linux下常用ELF格式可执行文件,ld总是从地址0x8000000开始安排程序代码段。 • 观察下图可以看到实际上linux下整个虚拟地址空间是线性的。
内存 页机制 页表 地址映射 思考:linux下使每个段基址为0,段界限为4GB。 ①linux的段机制下a、b段的第一条指令地址偏移不是0,所以即使段基址都是0,地址也不会重叠; ②每个段界限最大到4G,内存不够用怎么办? 虚拟内存的关键——只装入部分不足时置换,具体由页机制处理。 段b 段a 段机制 段b 段a 外存
总之 • linux下针对intel硬件设计了段机制,但所有段基址都是0,长度不超过4G。这个段机制前后地址不发生变化。 • 配合linux这种特殊的段设计,最关键的是生成的虚拟地址,虽然看似段:偏移的二维形式,但实际上是一维平坦的地址空间。 在这样的设计下内存分配和内存寻址过程实际上有下面几步 系统段表是为了应付硬件写好的固定内容,用户程序不需要管 ①形成虚拟地址 ②分配内存—构造页表 ③执行——开始段页机制寻址 装入程序时完成
内核栈 ----------------- ----------------- 内核数据段 ----------------- 内核代码段 装入 编译 栈 ----------------- ----------------- 数据段 ----------------- 代码段 链接 分页 分段 地址映射 1)elf文件装入,各种信息生成并记录: • 进程相关: pcb(task_struct) • 内存相关: • 通过读可执行程序中的内容形成虚拟地址相关信息(mm_struct):代码段“从**到**”,数据段“从**到**”等等——形成虚拟内存空间。 • 分配一定的内存,伴随着分配内存就要记录占用的内存信息,页表就构造出来了。 源代码 只装入一部分 内核 ---------------- 堆栈 ----------------- ----------------- 数据段 ----------------- 代码段 内存 ELF 可执行文件 windows 下是 exe格式 页表 a.o b.o c.o 逻辑地址 段:偏移 线性地址 实际上没有变化 虚拟内存地址 物理地址 2)开始执行 读程序代码段第1条虚拟地址,利用页表进行地址映射即可找到指令在物理内存的位置并执行。
一个程序编译连接后形成的地址空间是一个虚拟地址空间 ,但mm_struct形成时才意谓着进程的虚拟地址空间描述完成。 • 页表有填入的实际映射信息,才真正有物理空间,然后随着内存的请页与交换页表内容动态变化。所以4G的虚拟内存不一定非要4G的物理内存。 • 内存寻址过程总结: • 取代码段的第1条指令,此时指令是虚拟地址; • 虚拟地址经过段机制处理后我们称为线性地址,实际上地址没有任何变化; • 利用页表得到指令实际所在的物理内存地址; 在内存就执行,不在就调入。
从数据结构角度理解进程创建过程的内存处理(选看)从数据结构角度理解进程创建过程的内存处理(选看) mm_struct:对整个虚存空间描述 vm_area_struct:逻辑区域方面组织
段标识:偏移 逻辑地址 装入 物理 地址 空间 虚拟 地址 空间 • 四个地址、两个地址空间 • 虚拟地址空间的好处 • 对程序员屏蔽底层地址,更好的支持虚拟处理
2 内存空间
关于虚拟地址空间的理解 • 程序编译链接后,可执行文件里描述了进程指令和数据在哪里——虚拟地址。 • 程序装入时,这些信息被记录到mm_struct里以备给操作系统查看。可以说通过mm_struct进程的线性虚拟地址空间被描述出来。 • 程序执行时,CPU要从mm_struct里记录的代码段开始执行,这里读到第1条指令的地址在哪,而这个地址是虚拟地址。 • 然后就是内存寻址机制,从虚拟地址映射到物理地址。 虚拟地址形成的虚拟地址空间就是虚拟内存空间,最大4G。
虚拟内存、内核空间和用户空间 32位寻址最大可支持4G的内存(在32位的x86机器, 2^32=4G),而实际上linux用户面对的不是物理内存,是4G的虚拟内存。 虚拟内存分两部分: • 内核空间(3G-4G,虚地址0xC0000000到0xFFFFFFFF ) • 编译链接时内核相关程序和数据被编址到这个部分 • 用户空间(较低的3G字节,虚地址0x00000000到0xBFFFFFFF ) • 编译链接时进程的相关代码和数据被编址到这个部分 从具体进程的角度看,每个进程都拥有各自独立的4GB虚拟地址空间(虚拟内存)。 Linux内核空间对系统内的所有进程是共享的 ——每个进程都可以通过系统调用进入内核,所以是共享的;虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000)开始的,它的映射机制和用户的不一样 进程最大拥有3G字节私有虚存空间,这些私有空间映射在内存哪里由各进程的页表决定。 内核空间(1GB) 虚拟地址空 间 进程1的用户空间(3GB) 进程2的用户空间(3GB) 进程n的用户空间(3GB) 程序编译连接后形成的虚拟地址空间,装入时形成mm_struct意谓着进程的虚拟地址空间描述完成。
进程的空间 • 始终从进程虚拟地址空间的角度看
malloc、vmalloc等申请内存的操作函数实际上也都是从4G的虚拟内存中申请空间。 malloc、vmalloc等申请内存的操作函数实际上也都是从4G的虚拟内存中申请空间。 进程通信中提到的共享内存通过将同一块物理内存(常为一种文件)映射到进程A、B各自的内存,两个进程可快速感知对方的修改,这里的映射就是在进程虚拟内存的用户空间上映射的。 也就是说内存分配是①申请虚拟内存②再映射到物理内存,至于这些申请的空间具体映射到内存哪里由系统具体分配和构建的页表决定。
区分用户空间和内核空间 为什么共享内存是最快的IPC: 消息队列和信号量、管道等对象,经由它们的数据需要在内核和用户空间进行额外的数据拷贝;而共享内存和访问它的所有进程都处于用户空间,进程通过地址映射的方式直接读写内存,从而获得非常高的通信效率。当然,由于被映射的物理内存被多个进程共享,共享内存往往需要搭配某种同步机制如信号量。
linux支持多种共享内存方式(选看) ① POSIX共享内存对象 :shm_open创建一个名称为tmp的共享内存区对象后,在/dev/shm/下可以看到对应的文件(tmpfs的文件系统可以看成是直接对内存操作,速度非常快 ) • cat可以看到内容。 • 进程重启共享内存中数据不会丢失,内核自举或显示调用shm_unlink或rm掉文件删除后丢失 ② POSIX文件映射:通过映射一个普通文件(匿名文件或一个打开的命名文件)实现共享内存——mmap()。 • 该方式接口简单,较通用。可利用cat查看映射的文件,要注意考虑进程终止对通信的影响。 ③ systemV共享内存:通过映射特殊存储块shm中的文件实现进程间的共享内存通信——主要有以下几个API:shmget()、shmat()、shmdt()及shmctl()。 • 本方式无法看到文件实体。 • 进程重启共享内存中数据不会丢失,内核自举或显示调用shmdt或使用ipcrm删除后丢失。
举例:在进程用户空间映射一个共享匿名虚存区实现共享内存举例:在进程用户空间映射一个共享匿名虚存区实现共享内存 • 父子进程共享一个4字节的匿名区,利用内存区传递信息 #include <sys/mman.h> #include <stdio.h> #include <unistd.h> #define N 10 int main(){ int i,sum,fd; int *result= mmap (0,4,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,0,0); int pid=fork(); if (pid==0){ //子进程 for(sum=0,i=1;i<N;i++) sum+=i; *result=sum; printf(“child %d write: result =%d,sum=%d\n”,getpid(),*result,sum); }else{ //父进程 wait(0); printf(“farther %d : result =%d,sum=%d\n”,getpid(),*result,sum); } printf(“we’re going to sleep 20 seconds,see us in /proc \n”); sleep(20); if (pid>0) munmap(result,4); sleep(20); } 执行结果:farther: result=45,sum=134518632 父子进程result指向共享的虚存区,有相同值;各自sum变量不同 程序执行后父子进程都会睡眠20秒,到proc的相应pid目录下观察maps文件,可以看到新建的匿名虚存区
运行上页程序,观察proc下进程的虚存映射信息运行上页程序,观察proc下进程的虚存映射信息 共享的虚存区
不同版本虚拟段的处理或许不同, ubuntu下抓图如下: 虽然申请4B,但是实际上分配了a000-b000之间的4K(212)大小的空间,因为内存分配往往以页为单位,i386下一般一页是4K。
mmap() 函数的定义原型: void *mmap (void *start , int length, int prot, int flags, int fd, int offset) • start:映射到用户空间的起始地址,一般为NULL,由系统自动选择 • length:长度(以字节为单位) • prot:表示对所映射区间的访问模式,如可写、可读、可执行等,如PORT_READ。 • flags:用于其他控制目的 MAP_SHARED:与子进程共享虚存区 MAP_PRIVATE: 子进程对这个虚存区是“写时考贝” MAP_LOCKED:锁定这个虚存区,不能交换。 MAP_ANONYMOUS:匿名区,与文件无关 • fd:代表一个已打开的文件 • offset:文件的起点
mmap的两种映射方式: (1)mmap使用特殊文件进行匿名内存映射: • 适用于具有亲缘关系的进程之间; • 子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,由于这里不是一般的继承关系,父子进程都可以通过修改映射区域进行通信。 (2)mmap使用普通文件提供内存映射: • 适用于任何进程之间;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下: fd=open(name, flag, mode); if(fd<0) ... ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0); • 通过mmap()实现共享内存的通信方式有许多特点和要注意的地方,不再详细说明。
将命名文件test映射到进程用户空间 int i,fd; char *buf; fd=open(“test”,O_RDONLY); buf=mmap(0,12,PORT_READ,MAP_PRIVATE,fd,0); for (i=0;i<12;i++) printf(“%c\n”,buf[i]);
3 内存分配和回收
0 0x100000 start_mem end_mem 保留 内核映象 动态内存 • linux的物理存储器划分 • 静态存储器(static memory) 虚拟地址空间的3G-4G实际上映射在物理内存的低端。RAM的1M之前保留做BIOS或其它特定情况所用。另外再分一些永久地存放内核代码以及静态数据。 • 动态存储器(dynamic memory) • RAM中除去静态存储器的部分,动态分配给进程和内核的部分。属于稀缺资源。整个系统的性能取决于如何有效地管理动态存储器。 • 对于动态存储器要尽可能做到:按需分配,不需要时释放。
内存分配都是先分配虚拟内存,再分配物理内存的。 内存分配都是先分配虚拟内存,再分配物理内存的。 • 虚存的分配: • 用户空间上分配虚存:按页; • 内核空间上分配虚存:一种按页分,另一种用slab模式在虚拟地址按字节分配。 • 物理内存的分配 • 分配和回收的基本单位是物理页(i386体系结构下是4K) • 采用伙伴算法,可快速分配连续的物理页面,也解决了外碎片问题。
linux规定high_memory不能超过0xc0000000+896M。若物理地址0<=x<=896M,内核映像地址最大不超过0xc0000000+x。linux规定high_memory不能超过0xc0000000+896M。若物理地址0<=x<=896M,内核映像地址最大不超过0xc0000000+x。 0 0x100000 start_mem end_mem 保留 内核映象 动态内存 内存分配是先分配虚拟空间,然后才分配页面从而虚拟空间映射到物理空间 用户空间0-3G 页方式获得 页为单位分配 slab 虚拟地址空间 内核对象有两种分配虚拟内存方式,采用slab分配模式以字节为单位,或页分配模式 获得 页面 物理地址空间
伙伴算法举例 伙伴关系从最小号开始,成对找伙伴
1)用户空间分配内存valloc/malloc--free #include<unistd.h> #include<stdlib.h> #include<stdio.h> #define A_MEGABYTE 1024*1024 int main(){ char *some_memery; int megabyte=A_MEGABYTE; int exit_code=EXIT_FAILURE; some_memery=(char*)valloc(megabyte); if(some_memery!=NULL){ /* sprintf将格式化字符串输出到目的字符串,本例中目的字串是some_memery */ sprintf(some_memery,“Hello world!\n”); printf("%s",some_memery); free(some_memery); printf("memery is free!\n"); exit_code=EXIT_SUCCESS; } exit(exit_code); }
2)内核空间分配内存 • 内核空间上分配虚存: • 一种按页分,vmalloc • 另一种用slab模式在虚拟地址按字节分配,kmalloc。
Struct kmem_slab_t Struct kmem_cache_t slab 对象 slab 缓冲区 • slab可解决内碎片问题,因为很多数据结构请求分配的内存大小不足以用一个页面那么大。 频繁使用的数据结构可划分为专用缓冲区,如存放一组task_struct类型的对象 内核虚存中划出一片区域。把这片区域划分为多个块,每块就是一个Slab 每个Slab由一个或多个页面组成,每个Slab中存放的是同类型的对象 使用频度低,占空不足一个页面的数据结构可创建一组通用缓冲区来处理。
内核的虚拟内存分配回收函数 ①slab专用缓存区: • kmem_cache_alloc()、mem_cache_free() ②slab通用缓冲区: • kmalloc( )、kfree( ) void *kmalloc(size_t size,int flags); • 这些函数是向slab分配器要虚拟空间,而slab根据情况再通过伙伴算法从内存要物理页面。 • kamlloc分配的内存块要由kfree释放。分配和回收要配对使用,避免内存泄露和其他bug。 ③内核非连续内存区 • 内核程序通过slab不能获得大块内存,所以专门在内核空间保留一些空( VMALLOC_START~4GB ),用vmalloc进行分配: void *vmalloc(unsigned long size)
vmalloc()与 kmalloc()之区别 • 都是内核代码在内核空间分配内存(虚拟内存),但分配的内存在内核空间的不同位置。 • kmalloc()分配的内存处于3GB~high_memory之间, • vmalloc()分配的内存在VMALLOC_START~4GB之间, • 地址连续性不同。 • kmalloc虚拟地址连续,物理地址也连续。硬件设备使用的内存常需要此种。很多内核代码出于性能的考虑常用该方式获得内存;还比如内核中的驱动程序。 • vmalloc虚拟地址连续,但通过分页映射的物理内存不保证物理地址连续。软件程序可使用此类内存。往往在需要大块内存不得已时才用此方式获得内存,如动态装载一个模块到内核中时。