详解32位Linux系统内存地址映射
我们先看一段简单的C程序:
我们先来看一张图:
我们平时所说的x86 32位指的是:80386往后到现在的同一个体系的CPU处理芯片,但是x86这个芯片是从8086开始到现在的。
我们经常说CPU多少位,位数指的是CPU的计算能力,位数越高,计算能力越强,在CPU的ALU(算术逻辑单元)里面,主要做我们的数字的加法操作(包括减,乘除,都是转成加法来运算操作),CPU的位数指的是CPU的ALU一次性可以计算的最长的整数的位数,也就是ALU的宽度,ALU从寄存器取数据,寄存器从总线取数据。
我们看图中,8位的芯片的地址总线是16条,就是2的16次方,即64k,寻64k个地址,在当时,8080和8085这2种芯片还是非常可观,性价比很高的。
所以,位数表示的是ALU的宽度,一次性可以计算的最长的整数的位数
数字是从数据总线过来的,我们有3条总线:控制总线,地址总线,数据总线。
控制总线用来发0或者1,也就是读信号或者写信号。
地址总线用来在内存上定位地址。
数据总线用来从内存中获取或者写数据。
8080 16位的芯片的地址总线是20条 寻址范围是2^20,也就是1M
8080和8085的8位的芯片的寄存器就是1字节,地址总线是16条,而数据0X0012是16位的,2字节大小,是放不进一个寄存器的,得分开存储,数据位数和地址位数不对等,那是怎么存储的?
这2个芯片的汇编指令非常复杂,因为它集成了很多16位的汇编指令,为了解决CPU的数据的位数和地址的位数不对等的情况。
实地址模式
然后就到了x86体系的第一个芯片8086,数据的位数到16位了,和地址总线16一样了,但是发现到这个时候,地址总线不够用了,需要寻更大的地址,所以地址总线改到了20条,此时面临的问题和我们之前8位芯片面临的问题一样了,同样的,基于之前的解决方案,可以在8086中集成很多20位的汇编指令,但是不是一个好的设计,非常复杂,况且要开辟1个体系!
所以从x86的第一个芯片8086(16位)开始,我们在CPU里面增加了4个段寄存器(是16位的,存2个字节大小),叫CS(代码段寄存器),DS(数据段寄存器),SS(堆栈段寄存器),ES(扩展段寄存器)
我们还有一个IP寄存器,在16位的话,是2个字节大小,存储偏移量的。
此时有这样一个规定:此时划分的内存就是物理内存,它把内存划分成一段一段的,规定:这个内存段必须每个段的起始地址必须是16的倍数。范围是16-2^16,即16-64k之间,就是代码和数据,堆栈有多少。
我们把内存按照这样的规则划分以后,每一块内存都有范围和起始地址,此时把内存的起始地址写在相应的段寄存器中,比如说,这块内存存储的是指令,我们就把存储指令的这段内存的起始地址存储在CS中,如果这块内存存储的是数据,静态变量,全局变量,存储在DS中。如果这块内存是专门供函数运行使用的,被称为栈内存,有函数运行的局部变量和栈帧,这块内存的起始地址存储在SS。16位芯片的地址总线是20条,意味着从CPU发送的地址都是20位的地址,那寻址是寻20位地址,把这块内存的起始地址写到段寄存器中,段寄存器只有16位,20位的地址是不可能写到16位的段寄存器中的。
前面规定了,每一块内存的起始地址都是16的倍数,一个数字如果能够被16整数,那么这个数字的二进制的特征:一个数字左移4位,就是*=16,也就是说,一个数字能被16整数,它的二进制的低4位全部为0!物理内存永远都是从0地址开始的。那么一个20位的地址,低4位全部是0,只需要存储20位的高16位就可以了,就可以把内存块的起始地址的高16位存储到段寄存器中了!!!
所以说,段寄存器存储的不是我们真真正正的内存的起始地址,是我们内存起始地址的高16位。
寻址方式变成:首先,CPU指令中有指令译码器,像mov lea call能够区分访问指令还是指令的跳转,CPU本身根据指令译码器把指令翻译一下,就知道你想访问的是数据段,还是堆栈段,还是指令段。假设现在访问的是一个全局变量,在数据段,
CPU根据指令译码器,知道我们要访问的是数据,找存储数据的内存,它知道存储数据的内存的起始地址在DS中,而且它还知道,DS只是存储起始地址的高16位,所以,首先把DS中的内容左移4位,恢复成20位的地址,也就是存储数据的内存的起始地址了,然后再放到总线上,因为地址总线是20位的。现在知道是内存的起始地址,但是这个内存存储很多的全局变量,静态变量,怎么知道当前的数据在这块内存的哪里呢?这时候IP寄存器存储的是偏移量啊!根据偏移量找到这个最终的数据的地址!
数据的地址就是物理地址,所以我们访问的都是物理内存。
在8086这个芯片的时候,操作系统根本不存在,没有任何权限的控制(用户态,内核态),所以,此时,只要调用驱动接口,可以任意的去改变这些段寄存器的内容。因为访问的都是物理内存,可以通过特殊的手法把DS寄存器修改成指令段的内存的起始地址,找到指令段内存,写数据,这都是可以的,因为没有任何操作系统,内核进行权限的控制,所以可以用指针任意的偏移,修改哪里的数据内容都可以。
我们把这种地址访问模式称作:实地址模式,也称作实模式
实地址模式下,可以访问的物理内存最大可以访问:1M
32位的x86 CPU,一上电,强制进入实模式,虽然你装了4G的物理内存,但是操作系统没有起来的时候,CPU只能访问1M的内存,因为CPU是在实模式(数据总线16位,地址总线20位)
所以说,内核的iamge都加载从开始0x100000,到虚拟地址0xC0100000
因为0x100000之前,是实模式运行的。
前1M存储的是实模式用到的代码,显卡的缓存,驱动代码。
实模式下访问内存是极其不安全的,访问CPU的寄存器也没有任何有效的保护。我们访问内存,不仅仅要找到内存的起始地址,还要知道内存段的大小,因为我们寻址是:起始地址+IP寄存器的偏移量,得防止越界,防止加载到其他段了。如果IP寄存器的偏移量大于段的大小了,这个偏移量就是无效的,会产生错误。还要记录内存的访问权限,因为代码段只能读不能写。这些信息是必须要的,保护CPU和物理内存。很显然,这些信息不可能一股脑放在段寄存器中,因为段寄存器是16位的,都是x86体系的,只能新增,不能改变继承的东西的结构,所以,到了80386 32位的CPU,它的段寄存器还是16位的,2个字节,存个地址还要把低4位去掉,更别说存储那些安全信息了。
所以,为了存储这些安全信息,我们应该要另外的设计方案
所以,从80386开始,我们又增加了寄存器:
GDTR(全局的段描述符表,不是32位的,我们暂且看成是32位的,这个地址模式已经到80386的32位地址模式了 ),
LDTR(局部的段描述符表)
GDTR存储的是全局段描述符表的地址,这个段描述符表处在内存中,我们可以看作1个数组GDT。
数组中存放的是
我们要访问这个数组,缺个索引,缺个下标。
内存的起始内存在实模式下是在段寄存器下放着,现在放在GDT表中了,现在把段寄存器空下来了,下标放在段寄存器中。
现在的模式:
根据指令译码器,CPU知道要访问的是一个数据,还是指令,还是栈内容,比如说,访问数据,它就知道DS寄存器存储的是数据段内存的详细信息在GDT的哪个元素位(下标),然后去数组上对应的下标访问该数据段的详细信息。
段寄存器是整个体系,所以后面的继承了前面的段寄存器,位数是不能改变的,所以还是16位,低2位,总共可以表示4个值,但是现在操作系统只用了2个值,00和11,Windows和Linux都是这样。
从这个权限控制就可以直接控制用户态是不可能访问内核态的内存,在内核态可以访问用户态的内存。因为权限。
第3位,0表示使用GDTR,表示这个数据段的内存信息是在GDT表中存的,如果是1,表示使用LDTR,表示这个数据段的内存信息是在LDT表中存的。
高13位表示的序号,8192个数字,意味着GDT的个数是8192个
我们看Linux 2.4版本的内核代码
操作系统的入口是:
从汇编开始的
我们看末尾:
在全局的段描述符表项,有12个是被系统预留下来的,能够给用户用的是
GDT,LDT是怎么存储的?
每一个项的大小是8个字节
右下角是低位,左上角是高位。B开头的总共大小是32位(起始地址)
长度(L表示的):20位
2^20表示1M的长度。
1M的长度是多长?
我们看紧挨的高8位的G这个位,G这个位非常重要
G这个位取2个值,0:表示这个段描述符表项描述的内存的长度单位是字节
1:表示表示这个段描述符表项描述的内存的长度单位是页面(4K)
图中的蓝圈就是权限控制了。
保护模式下内存分段的地址映射
新的内核都没有LDT(处在单个进程中的)
我们假设是用LDT
数据段右移3位,得到高13位,是序号!作为GDT的序号,找到段描述符的表项
检查内核有没有开启内存分页机制:检查CRO寄存器的PG位,为1就开启了
不管是几级映射,映射的原理是一样的
CPU在内总线发出的地址,就是程序上的地址,经过地址映射,把映射后的地址(物理地址)放在外总线上,外总线直接连的是物理内存。
内存分段技术在88年就出来了,内存分页技术在93年创建的,出现的晚一些。
当然是内存分页技术的效率高,粒度小,x86为什么不舍弃内存分段,直接全部采用内存分页呢?因为它要维护同一个体系,内存分段的技术不能扔,因为在前面的程序还是得适用在后面的版本上运行。
映射关系
如果CR0的PG是1,开启内存分页,我们把32位地址分成3份
10位 10位 12位
页目录的下标:可以表示2^10=1024个下标
数组的每个元素存储的是指针,4字节大小。
1024项,每一项是4字节大小。
PG占4K,刚好是一个页面
指针存储的页表的地址,因为也是10位,所以也是1024项,页表的项存储的是一个指针,也是4字节,所以一个页表也是4K。页表存储的是真真正正的物理内存的起始地址。
从PT的项中得到物理页面的起始地址,然后根据后12位计算出物理页面上的偏移量,就得到物理地址了。
所以,我们程序中操作的地址都是虚拟地址空间上的地址,虚拟地址必须得经过页表映射才能得到真真正正的物理地址。
可执行程序在磁盘上存储的,
都是按页面进行映射和管理的。
从哪里可以找到当前进程的PG页目录的地址:CR3寄存器:存放当前进程页目录的起始地址。
每一个进程都有自己的指令和数据,所以是有自己的页目录和页表。
在虚拟地址空间上地址连续,经过映射后,物理地址不一定是连续的。
PG PT是每个进程都有自己的一份。
Linux在调度任务的时候,你运行一会,我运行一会,每个CPU执行多个任务,以时间片为单位,给进程分配资源,
进程调度的函数:
切换进程的函数:
每个进程有自己独立的地址空间,所以要切换地址空间
我们看:
最后一行把pgd存储到CR3寄存器中,为什么?因为CPU在执行某个进程的时候,在进行地址映射的时候,默认认为CR3存储的就是当前进程的页目录的起始地址,所以在进程切换的时候,要把下一个进程的页目录的地址放到CR3寄存器中。
通过虚拟地址最高的10位,作为页目录的下标,可以找到页表的地址。页目录和页表和物理页面都是4K大小,都是在物理内存上存,物理内存是从0开始,也就是说,页目录和页表的地址都是4k的整数倍。一个数字如果是4k的整数倍的话,它的二进制特点是:低12位是0。物理内存是按页面管理的,物理内存是从0开始,每个页面都是4k的整数倍,低12位都是0。PT的低12位是0,我们根本不用去存储。所以**,在页目录PG中,我们只需要用高20位存储PT的地址。**
页目录的低12位用来存储权限!!!(页表有没有分配,能不能访问)
同样,一个物理页面,起始地址也是4k的整数倍,它的低12位也是0,所以在页表项,用高20位存储物理地址就可以,剩下12位用来存储权限(物理页面能不能访问,能不能读写,是不是脏页),最低的这位是
我们的进程非常多,每一个进程的运行都要分配物理页面,内存肯定不够,那么我们进程在运行的时候,内核会把当前进程运行的页面放在物理内存当中,根据LRU算法,会把时间长没有被使用的页面从物理内存中换到swap交换分区中。提高系统的性能。
前4个物理页面的起始地址:
1个16进制位表示4个二进制位
低12位 000都是0,不用存储
像数组的下标,其实,物理页面在页表存储的就是下标存储的,因为低12位都是0
物理页面在内核中的表示:
一个结构体表示一个物理页面了
定义一个全局的指针。
内核初始化,先检测物理内存有多大,比如我装的是4G的物理内存,它根据总大小,划分很多页面,比如说20000个页面,用这个指针动态开辟一个数组,数组的每一个项的类型都是上面的结构体类型。
物理页面在内核中是如何管理的?
动态开辟的数组管理的。数组的大小是在内核启动 初始化的时候实时检测 物理内存的大小,按4k一个页面大小分配的,划分多少个就给数组开辟多少项。数组每一项存储物理页面的各种信息。
物理页面的下标存储在PT中,PT存储就是0, 1,2 , 3 ,4,,,,(省略3个0嘛)。在mem_map中找,就可以找到相应的物理页面的信息了,这就是我们把物理页面也叫做框号的原因,因为它们在PT存储的就是数组的下标
当前进程有1024个PG项,1个PG项指向1024个PT,1个PT表示4K的物理内存,所以整个二级页表映射,可以映射4G的物理内存,把我们32位linux下的一个进程的虚拟空间(4G)全部映射完!!!
LDT的特点
LDTR存储的是不是LDT的地址,整个寻找的入口是GDT的入口,LDTR和段寄存器是一样的,本身保存的是序号,算出来的序号是GDT的数组下标
然后GDT中下标对应的内存指的是LDT的内存信息
映射过程表
地址映射是软硬件结合,
软件指的是内核,页目录的值,页表项的值,CR3的值,GDT和 LDT,都是存储在内存上的,是由内核提供的原材料。MMU进行计算。
硬件指的是MMU,刚才所述的把地址划分成3部分,高位10位作为页目录的下标,页目录的高20位找到页表的地址,页表的高20位找到物理地址,这一切计算的操作都是MMU负责的
逻辑地址到线性地址的映射
线性地址到物理地址的映射
这些工作是由虚拟内存管理方案做的
能不能在用户空间定义一个指针指向内核空间的内存?
不可以,因为权限不够
能不能在内核空间定义一个指针指向用户空间的内存?
可以,权限够。内核空间运行的是内核的代码,交换的是用户空间的页面,CR3保存的页目录的地址是内核线程执行的页目录的起始地址,找不到用户空间页面对应的信息。当你想访问一个页面,不用担心这个页面不在物理页面,如果这个页面在交换分区中,会触发MMU的缺页异常,交给内核,触发内核的页面交换机制,会牺牲一个脏页,从交换分区交换而来,调到物理内存中,不会存在内核指针指向用户空间,页面在不在用户空间。
用户空间和内核空间的页面映射机制是不一样的。我们前面说的是用户空间的地址映射。内核空间的地址映射很简单。用户空间和内核空间的分界线是在3GB处,
内核空间的地址映射就是简单的线性映射,
给一个物理地址:0x2000
对应的内核的虚拟地址:
内核是从1M开始放的。
内核的物理地址-虚拟地址:
实验
黑屏是操作系统的界面,灰色屏幕是调试的界面
这个程序是死循环,不会结束
我们通过操作系统去控制它的执行
局部变量在栈内存
我们要找的是ss
低2位表示权限
低第3位的1表示使用LDT
前面13位表示序号:2
我们要修改data的值,访问LDT[2]
LDT在哪里?
找GDT[13]
一个GDT表项占8个字节
一个w查看4个字节
内存分页:
物理地址
写上4字节的0了