MyOS 之 内存管理

检查内存呢,方法是写入内存并立刻读取,如果读取出来的数据相同则证明有内存,首先要关掉缓存,否则永远都读出正确的了。

386和486的缓存是不一样的。

检查内存会特别慢,128MB有0x08000000字节。4KB为0x1000字节。两者相除有32768.创建一个32768数组,来控制使用与否。如果以字节为单位,直接放大4k倍就是页数了。使用bit更好,比例只有0x003%.

对任何一个普通进程来讲,它都会涉及到5种不同的数据段。稍有编程知识的朋友都能想到这几个数据段中包含有“程序代码段”、“程序数据段”、“程序堆栈段(应该用于递归函数和局部变量的)”等。

代码段:代码段是用来存放可执行文件的操作指令,也就是说是它是可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。

数据段:数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。

BSS段:BSS段包含了程序中未初始化的全局变量,在内存中 bss段全部置零。BSS段同样无法改变大小。

堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)

:栈是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

因此,一个C语言程序会有几个指针控制着栈,一个指针族控制着堆,还有几个指针控制着三个段

堆和栈两个区域关系很“暧昧”,他们一个向下“长”(i386体系结构中栈向下、堆向上),一个向上“长”,相对而生。但你不必担心他们会碰头,因为他们之间间隔很大(到底大到多少,你可以从下面的例子程序计算一下),绝少有机会能碰到一起。

Linux操作系统采用虚拟内存管理技术,使得每个进程都有各自互不干涉的进程地址空间。该空间是块大小为4G的线性虚拟空间,用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址。利用这种虚拟地址不但能起到保护操作系统的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可使用比实际物理内存更大的地址空间,以及使用连续的地址(虽然对于C语言来说连续的地址并没有什么太大作用了)(具体的原因请看硬件基础部分)。

在讨论进程空间细节前,这里先要澄清下面几个问题:

第一、4G的进程地址空间(只有这一个空间)被人为的分为两个部分——用户空间与内核空间。用户空间从0到3G(0xC0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。

第二、用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表(init_mm.pgd),用户进程各自有不同的页表。

第三、每个进程的用户空间都是完全独立、互不相干的。10个进程占用的线性地址一模一样。

甚至说,C语言根本不允许直接操作内存了,它编译成的汇编也是从0开始的,它在自己的空间里设定了各种段,在LDT中规定了哪些可读,哪些可写。如果LDT可用的话,GDT就是用来存放LDT,LDT就是用来存放段。

内存管理,例如伙伴系统,首次分配、邻近适配算法、最佳适配算法,最坏匹配(最坏适应算法)

      一、首次适应算法(First Fit):该算法从空闲分区链首开始查找,直至找到一个能满足其大小要求的空闲分区为止。然后再按照作业的大小,从该分区中划出一块内存分配给请求者,余下的空闲分区仍留在空闲分区链    中。

     特点: 该算法倾向于使用内存中低地址部分的空闲区,在高地址部分的空闲区很少被利用,从而保留了高地址部分的大空闲区。显然为以后到达的大作业分配大的内存空间创造了条件。

     缺点:低地址部分不断被划分,留下许多难以利用、很小的空闲区,而每次查找又都从低地址部分开始,会增加查找的开销。

       二、最佳适应算法(Best Fit):该算法总是把既能满足要求,又是最小的空闲分区分配给作业。为了加速查找,该算法要求将所有的空闲区按其大小排序后,以递增顺序形成一个空白链。这样每次找到的第一个满足要求的空闲区,必然是最优的。孤立地看,该算法似乎是最优的,但事实上并不一定。因为每次分配后剩余的空间一定是最小的,在存储器中将留下许多难以利用的小空闲区。同时每次分配后必须重新排序,这也带来了一定的开销。

     特点:每次分配给文件的都是最合适该文件大小的分区。

     缺点:内存中留下许多难以利用的小的空闲区。

      三、最坏适应算法(Worst Fit):最坏适应算法是将输入的作业放置到主存中与它所需大小差距最大的空闲区中。空闲区大小由大到小排序。

      特点:尽可能地利用存储器中大的空闲区。

      缺点:绝大多数时候都会造成资源的严重浪费甚至是完全无法实现分配。

邻近适配算法:从上一次分配的地址开始查找符合要求的块,所查找到的第一个满足要求的空闲块就分配给进程。

在不考虑算力的情况下,使用首次适配法足够了,当然,可以随时更换适配法更好。

我们所谈论的分段机制也好,分页机制也好,都是「CPU」与「操作系统」这两个层面才会涉及的:CPU提供分段与分页机制,而操作系统去合理地使用这些机制,从而更好地管理内存。C语言程序仅仅是接触到了操作系统,而接触不到CPU,所以它对分段分页机制是完全无感知的。所以如果你想通过在C语言程序中输出地址的方式来感受分段或者分页机制,是不可能的,因为所有你输出的地址,都只是「虚拟地址」。

Linux与Windows的分段机制原理上类似,都是扁平式的,段基址为0,也就是说CS,SS这些寄存器全部都是0,直接把整个虚拟内存看成一整个“段”。所以简单来说,它们并不想使用这个从16位系统遗留下来的分段机制,而CPU为了保持兼容性还保留了这些分段机制,所以现代OS大都使用这种扁平式的分段管理,将CPU「糊弄」过去。

一个C/C++程序将内存划分为几个区域:堆区,栈区,全局区,常量区和代码区。每个区域都有自己的作用,例如栈区存放局部变量,全局区存放初始化的全局变量,代码区存放代码,等等,当然,不是C语言程序可能并不这么做。

a是一个局部变量,存放在「栈」区域中。而栈的地址区域是「动态分配」的,也就是说每次运行程序时动态地去分配栈地址,所以每次运行时a的地址会不一样,输出&a也就不一样了。

而print函数存放在「代码区」中。而代码区是「静态分配」的,也就是说当你的C程序编译链接都完成后,代码区就已经分配完毕了,这也是为什么在反编译后可以看到这个地址,并且每次运行程序时这个地址都是不变的。(同理还有「全局区」,如果你定义一个初始化的全局变量,那么会发现每次运行时它的地址也是不变的)

基本上就是经过了层层封装。

C语言程序使用的地址 - 虚拟地址

页机制转换 - 线性地址,段号+偏移地址,因为一个GDT转换就变成了实际地址,因此也不是太区分。

段机制转换 - 实际地址,物理地址,基址+偏移地址

逻辑地址、有效地址:无论CPU在什么模式下,段内偏移地址又称为有效地址或者逻辑地址(只是叫法不一样罢了),有的说法是加上选择子才是逻辑地址/虚拟地址(这个不用太在意,毕竟现在的系统没有几个不用0基址分段的了),分段机制(GDT表转换)变成线性地址,分页后变成物理地址。

只有分段机制,线性地址就是物理地址,只有分页机制,逻辑地址等于线性地址(线性地址基本从0开始,这样经过二级表结构,31~22位当作表目录,在目录表找到相应表,21~12当作页面找到相应表中的某一项,得到前32~12的物理地址,最后11~0(4K)就是页内偏移),最终指定那一个字节。

Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 (基址)开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。因为在C语言中根本也操作不了地址,或者说用相对虚拟地址就足够了,因此并没有什么问题。

打个比方,比如说你去听课,带了一个纸质笔记本做笔记。笔记本有100张纸,课程有语文、数学、英语三门,对于这个笔记本的使用,为了便于以后复习方便,你可以有两种选择。
第一种是,你从本子的第一张纸开始用,并且事先在本子上做划分:第2张到第30张纸记语文笔记,第31到60张纸记数学笔记,第61到100张纸记英语笔记,最后在第一张纸做个列表,记录着三门笔记各自的范围。这就是分段管理,第一张纸叫段表。


第二种是,你从第二张纸开始做笔记,各种课的笔记是连在一起的:第2张纸是数学,第3张是语文,第4张英语……最后呢,你在第一张纸做了一个目录,记录着语文笔记在第3、7、14、15张纸……,数学笔记在第2、6、8、9、11……,英语笔记在第4、5、12……。这就是分页管理,第一张纸叫页表。你要复习哪一门课,就到页表里查寻相关的纸的编号,然后翻到那一页去复习

 

因此怎么做,首先规划出4G的虚拟内存,规划前1G为内核内存,后3G为用户内存,这里就有两个页表了,当然必然是要分页的,然后段基址都是0,线性地址=逻辑地址,后面再进行页表转换,但实际上,费劲做这些的目的,就是为了运行一个用户级程序,现在多任务并没有实现,因此这个并不着急了。

Linux的内存管理与分配机制真的比较巧妙了,它利用虚拟地址的方法,让每个进程都从0开始并且感觉自己有4G内存可用,但是它也不能随便使用,或者说直接操作虚拟内存地址,必须先mmap出来或者brk出来,就好像分配一样,然后通过页表映射来实现。现在先实现最开始分配吧。

为什么一个程序明明有4G的虚拟内存,还要进行分配呢?

这说的是在一个进程运行过程中的分配,内核现在是4MB,除了第一项是页表外,紧接着就是堆了,然后从上而下是栈,

SS:存放栈的段地址;
SP:堆栈寄存器SP(stack pointer)存放栈顶的偏移地址

BP: 基数指针寄存器BP(base pointer)是一个寄存器,它的用途有点特殊,是和堆栈指针SP联合使用的,作为SP校准使用的,只有在寻找堆栈里的数据和使用个别的寻址方式时候才能用到,比如说,堆栈中压入了很多数据或者地址,你肯定想通过SP来访问这些数据或者地址,但SP是要指向栈顶的,是不能随便乱改的,这时候你就需要使用BP,把SP的值传递给BP,通过BP来寻找堆栈里数据或者地址。这种用法说明,BP是一个可读,随便游走的指针。

SP,BP一般与段寄存器SS 联用,以确定堆栈寄存器中某一单元的地址,SP用以指示栈顶的偏移地址,而BP可 作为堆栈区中的一个基地址,用以确定在堆栈中的操作数地址。

因为接下来sp是需要使用的,如果在函数体内,push了没有pop,那么在后面函数的出去就造成相当的困难,你要知道,在函数调用时,先压入原来的ip,然后才是各种参数,这些参数在函数执行中不可pop出去,只有当ret的时候,系统自动执行,这就为什么说bp是基址,这样sp少pop一个也没有多大关系了。

[bp + 2*4]则是该子函数的第一个参数,[bp+3*4]则是该子函数的 第二个参数,以此类推,有多少个参数则[bp+(n-1)*4](第0个是ebp,第1个是eip,因此从第二个开始)。

实参N~1→主调函数返回地址→主调函数帧基指针EBP→被调函数局部变量1~N(自己开辟的)

地址逐渐向下~

反正,堆栈空间是一定要分配的,在进程切换的时候,页表也会跟着切换,按理说,新建进程必须新建页表,但是对于目前而言,都是在内核态的切换,不仅各个段寄存器不变不说,ss,sp都是0!说好的向下增长呢?

虽然ss为0,但是sp不为0,sp为0x7c00,怎么来的呢?一开始的ipl就是从0x7c00开始的,也就是说,此时ip与sp被赋予了相同值,一个上去,一个下来,bp主要用于函数调用,因此现在为0并没有什么。

而且bp并不是系统调用的,是自己调用的,只是之前自己一直没有使用这种堆栈参数调用而已。

bp的实现全部靠C语言的实现而已,自己近乎用不上。。。毕竟还要pop,push,这是C语言自动 完成的,用汇编做反而麻烦了。

内存的开辟分为两种情况,分别是进程开辟时分配和缺页中断时分配,

先说进程开辟时分配,创建进程时,要创建页表和页目录表,根据进程的大小开辟空间(至少要容得下这个进程代码不是?)创建完毕后,返回一个TSS段,这个TSS段里已经存储了cr3,当然已经插入进程表,代码自然是从0开始,因为ip就是从零开始的。再有一个栈地址和一个堆地址,内核代码是0~1G,代码段是1~2G,堆地址是2~3G,栈地址是3~4G,内核是1G,放在页表中如何表示呢?

一个进程,有4G虚拟内存空间,但必然用不到4G,你只要依次填入页表,页目录表,直到代码刚够即可,这样就可以启动进程了。

缺页中断是中断,触发的,

进程内开辟一块空间怎么算呢?这就是堆分配,堆分配怎么算呢?就C语言来说,内部变量分配在堆栈里,外部变量分配在堆中,不管它怎么说的吧,分配堆,实际上,你要掌握这个堆结构,然后进行规划。

注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。

系统自动分配的放到栈区,自己开辟的是堆区,大致是这样。首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时(缺页中断), 会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。 另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。 

容易产生碎片,这也就是这样的。

那么看看一下缺页中断,首先程序是感觉不到的,程序要分配一块内存,用 call use ebx,地址返回到eax里,就是这样。

在use函数里实现缺页中断,总是要实现缺页中断的。可能就是规划一下堆分配,直接返回地址,当调用的时候,触发登记的缺页中断,调用内存图分配一个4K而已。

整个4G内存空间都是可用的,如何管理内存是编程语言的事情。。。

4MB位置,地址为4*1024*1024=0x400000,实际的4k序号呢?就是/4k=1024,=0x400

最后的出来一个0x80,什么意思呢?就是128,128字节,1024bit,每个 bit表示4kB,刚好4m,正确的。

页图总大小128KB。

最后期望结果出来了。

每日小常识:

在汇编中,或者说GDT中,段的是否可写已经被固定住了。。。。

是可写的,,,其实代码本身也是可写的,,,,GDT的权限控制形同虚设。那么就简单了。

因此绝对可以在函数中设定变量字段,不错。

进程一开始你总得use 一个4K的吧,它只能返回物理地址填入页目录表,TSS中,不断地get物理地址,填入之,而且分页后内存连续就不是很有必要了,因为分页处理导致任何地址访问都要进行分页处理。

实际上没必要了。各种适配法应该都没有作用了,除了分配内存时需要遍历时间稍微多点外,并没有什么太大问题了,解决分配难的问题的话,就是用链表呗,只不过这链表可能非常巨大,但是毫无疑问的是,内存分配问题就这样解决了。

然后根本就不用4MB分配一说。

实际上,这种非连续分配的效率很差,一来用来映射非连续内存线性地址空间有限,二来每次映射都要改写内核的页表,进而就要刷新TLB,这使得分配的速度大打折扣,这对于要频繁申请内存的内核显然是无法忍受的。Linux采用伙伴系统来解决外部碎片的问题。

Linux不是不分页,它只是不用非连续性分配方式,它用伙伴系统,完全就是为了效率。

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值