天天看点

结合Linux的应用场景看MIPS32架构之内存管理

由于本系列文档在介绍过程中,参考了很多MIPS官方,以及北京君正(Ingenic)的xburst系列处理器的资料,目的仅仅是为拓展MIPS架构以及Linux进自己的绵薄之力,如果有侵权行为时,请告知本人处理,谢谢

1 MIPS32的内存管理

1.1 引子

谈论一个话题,总得有个头儿,我们从哪里开始呢?MIPS官方文档和<<See MIPS Run Linux>>介绍MIPS架构是按照一个模块一个模块进行介绍,每一个模块独立成一个部分,这个是最快的也是最合理的介绍方式,不过没有操作系统的原理作为基础,看过这些文档或者书籍还是不能对MIPS架构有深入的理解。这里我不打算按照这种方式把官方文档再翻译一遍,而是换种思路来聊聊MIPS32的CPU架构.

大部分人编程生涯的入门都是从C语言开始的,在没有接触过计算机体系结构之前的C语言仅仅是停留在语言的层面,对于编程语言是如何控制计算机的没有深入的了解(至于其他高级语言,比如Java,Python等等,对于计算机运行程序的原理就更加偏远了)。所以我们就从最简单的一个C语言代码开始谈起,看看下面一段代码

  1 int c;

  2 

  3 int main(int argc, char *argv[])

  4 {

  5         int a, b;

  6 

  7         a = 5;

  8         b = 2;

  9         c = a * b;

 10 

 11         return 0;

 12 }

上面的代码很简单,只要你学过编程语言(无论是不是C语言),这段代码你肯定能看懂,你一定知道c的值是a*b=2*5=10, 它太简单了,不过还是有几个问题需要提前考虑一下:

<1> 学过C语言的都知道,C程序的入口地址是main函数,可是现实真的是这样吗?

<2> 有没有考虑过,你所知道的CPU是如何执行这段代码的呢?

<3> CPU是执行机器指令的,那么CPU内部的PC,MMU(TLB),CACHE等等是如何共同协作来完成这段代码的执行?

由于我们是基于Linux来介绍,所以这里先把这段代码编译成ELF文件,然后再反汇编出对应的汇编指令

 339 004005e0 <main>:

 340   4005e0:       27bdffe8        addiu   sp,sp,-24

 341   4005e4:       afbe0014        sw      s8,20(sp)

 342   4005e8:       03a0f021        move    s8,sp

 343   4005ec:       afc40018        sw      a0,24(s8)

 344   4005f0:       afc5001c        sw      a1,28(s8)

 345   4005f4:       24020005        li      v0,5

 346   4005f8:       afc20008        sw      v0,8(s8)

 347   4005fc:       24020002        li      v0,2

 348   400600:       afc2000c        sw      v0,12(s8)

 349   400604:       8fc30008        lw      v1,8(s8)

 350   400608:       8fc2000c        lw      v0,12(s8)

 351   40060c:       70621802        mul     v1,v1,v0

 352   400610:       3c020041        lui     v0,0x41

 353   400614:       ac43080c        sw      v1,2060(v0)

 354   400618:       00001021        move    v0,zero

 355   40061c:       03c0e821        move    sp,s8

 356   400620:       8fbe0014        lw      s8,20(sp)

 357   400624:       27bd0018        addiu   sp,sp,24

 358   400628:       03e00008        jr      ra

 359   40062c:       00000000        nop

DUMP文件拿到后,我们先不介绍MIPS架构的指令集以及寄存器等信息,而是看看MIPS32的处理器是如何执行这段指令的,比如340行的地址0x4005e0处的指令27bdffe8.

CPU在开始执行这个程序之前,需要提前做很多准备性的工作,这些工作是由操作系统来完成,这些工作包括哪些内容呢?首先需要由父进程(比如shell)通过fork系统调用fork出一个子进程出来,然后在子进程中调用execve系统调用(execve这个系统调用很复杂,我们这里的重点是看MIPS32的CPU如何执行代码,这里对于这个系统调用不多介绍)去执行这段程序对应的ELF文件.execve系统调用的工作对于理解CPU是如何执行这段代码至关重要,首先思考两个简单的问题:Q1:在OS还没有将PC改为这个程序的入口处前(或者说execv()系统调用返回到用户空间前)实际的(DDR)RAM中是怎样的,有没有将这段代码对应的ELF中的text段读入到其中?Q2:这个代码段是由谁来加载的?

对于Linux内存管理有一定了解的人都清楚上面的答案,下面我们从MIPS体系结构的角度来讨论一下两个问题的具体答案。

execve的过程在这里不进行探讨,但是它的目的必须要说明白,execve执行完毕后会将这个程序对应的ELF文件load到内存中,准确的说应该是根据这个ELF在内存中做好了映射(或者说是布局),做好映射后的图大家应该都知道,如图1

结合Linux的应用场景看MIPS32架构之内存管理

图1 应用程序虚拟内存映射图

execve系统调用并没有把ELF中的所有段中的内容(比如代码段中的指令部分)都读入到物理内存中,因为这么做可能会浪费时间、浪费资源,Linux kernel的做法是将ELF中的代码段,init段(也是代码),只读数据段会在read only segments中做好映射(mapping)(这里的mapping是指将ELF文件中相应的段和虚拟内存进行关联,访问这片虚拟内存,就会读取相应的文件中的数据,类似与我们在用户空间使用的mmap系统调用一样),全局的初始化数据段会在Data Segments中做好mapping,未初始化的数据段会在bss segments中做mapping(当然这部分会在execve中初始化0),堆空间开始时没有做mapping,而是在后期使用过程中不断扩大的,如果ELF文件是动态编译的,那么会将动态链接库映射到memory mapping segment区域,不过这个工作不是execve系统调用完成的,而是由用户空间的linker完成的,而栈顶以及argv,environ变量区域会在execve中指定好.

如果足够仔细,你可能会发现上面这个图和你之前在其他资料上的应用程序内存映射图有不同地方,不同的地方主要体现在地址空间的大小,比如说,在X86上kernel空间是高1G,用户空间是低3G,而这里kernel空间是高2G,用户空间是低2G,这是什么原因呢?这个是由MIPS32的内存映射所决定的.

1.2 MIPS32 R2的内存映射

首先介绍一下MIPS32R2的运行模式,MIPS32R2主要有两种运行模式,一种是kernel模式,一种是user模式(还有一种debug模式,这里我们不过多介绍).不同的模式下虚拟内存和物理内存的映射关系是不同的,如下图2

结合Linux的应用场景看MIPS32架构之内存管理

图2 MIPS32虚拟内存映射图

通过图2我们可以知道,在user mode下,CPU能够访问的内存范围就是在0x0到0x7fffffff之间的2G虚拟内存空间,可能有人会问,如果在这种模式下,要访问高2G的空间,也就是0x80000000以上的虚拟内存怎么办,这时候CPU是访问不到这片空间,如果强制访问那CPU也得有恰当的方式来处理这种”不情之请”,CPU通过”抛出”一个地址错误异常(异常放到后面去介绍)来处理这个操作.低2G的虚拟内存空间这部分的访问特点是经过cache(可选择是否经过)和TLB的,通过图上我们可以看到”mapped”字样,就是这个意思.也就是说,当CPU访问这片空间时,需要经过TLB进行转换翻译后才能知道对应的物理地址在什么地方.这段空间是在应用程序运行时所访问的的空间,

在图2中可以看到,kernel mode下,CPU能够访问的虚拟地址空间是从0x0到0xffffffff整个4G的空间,不过这4G空间又分成了几个小段:

(1)低2G的虚拟内存空间(0x0-0x7fffffff)称为kuseg区,这片空间是”mapped”,访问时需要经过TLB和cache,其实这段空间在内核中copy_to_user或者copy_from_user时会访问到.

(2)从0x80000000到0x9fffffff对应的称为kseg0区域,这个区域的特性是”unmapped cacheable”,”unmapped ”这个就是说访问这段空间时,能够直接找到对应物理地址,是不用经过TLB的,其对应的物理地址直接将最高3bit或者1bit清0得到(高3bit和高1bit清0效果是一致的),也就是0x0-0x1fffffff.而”cacheable”这个是说访问这片空间是可以经过cache的,对MIPS有些了解的人可能纳闷”为什么说可以经过cache”,这是由于访问这片空间可以通过配置CP0的config寄存器来决定是否经过cache,以及经过cache的方式,比如write back或者write through等.这片空间是操作系统内核所用的空间,启动过程中,bootloader也是运行在这片空间中.

(3)从0xa0000000到0xbfffffff对应的称为kseg1区域,这个区域是”unmapped uncached”,”unmapped”的含义以及介绍过了,这片区域对应的物理地址也是经过将高3bit清0得到,也是0x0-0x1fffffff,如果细心点可能发现这和kseg0对应的物理地址空间完全一致啊,是的,kseg1和kseg0对应的物理地址完全一致,但是有一点不同,也就是另一个属性”uncached”,这个是说访问这片虚拟内存空间时,直接访问物理内存空间,是不经过cache的,而kseg0是”cacheable”,可以经过cached.可能有人会有疑问,为什么要这么设计(kseg0和kseg1对应的物理内存空间一致,而访问的属性不同)呢?这个问题不太容易回到,但是有一条是肯定的,考虑到IO空间也是统一编址,需要有一段空间是不能经过cache的.这片空间主要在访问IO空间时使用.

(4)从0xc0000000-0xffffffff的空间是Kseg2和kseg3,每个空间各占512M,这片空间的属性是”mapped”,和kuseg区域一致,也就是访问这片空间的地址时需要经过TLB转换,什么时候会用到这片空间呢?当内核中使用vmalloc时,分配的空间就是在这里.

下面看一下kernel模式下的虚拟内存到物理内存的映射图,如图3

结合Linux的应用场景看MIPS32架构之内存管理

图3 MIPS32的虚拟内核到物理内存的映射

可以看到,0x80000000-0x8fffffff间的kseg0区域映射到物物理内存的0x0到0xfffffff空间,这个空间是256M,是内核,bootloader等代码运行的空间,而0xa0000000-0xafffffff间的kseg1,映射的物理内存空间也是0x0-0xfffffff的256M空间,由于和上面的重合,所以一般情况下我们很少用到这片空间.0xb0000000-0xbfffffff间的kseg1映射到了物理内存的0x10000000-0x1fffffff的256M空间,一般物理内存的0x10000000开始的空间我们映射到了IO空间,所以如果要访问IO空间时我们需要访问这段空间,而0x90000000-0x9fffffff间的空间虽然也映射到了这片物理空间内,但是由于这段空间是经过cache的,所以我们基本不会用到这片空间.

可能有人会有疑问,如果DDR的物理内存大于256M,内核想使用这片空间怎么办?在MIPS32中高于256M的物理内存我们称为高端内存(High memory),如果想使用高端内存,需要配置highmemory的配置选项,通过vmalloc申请(注意,如果没有配置highmemory的话,那么通过vmalloc申请内存所占的虚拟空间仍然是kseg2,也就是0xc0000000,但是实际的物理空间仍然在低256M).

再额外补充一个问题:物理地址和RAM的关系是怎样的呢?实际上DDR仅仅用了物理地址的一部分,物理地址还包括的IO空间(各种外部控制器(UART控制器)的寄存器地址)等,所以DDR的地址分配可能是分段的。

经过上面的介绍后,应该对于图1中的地址和X86不一致的原因以及MIPS32的内存管理有个大概了解了.下面我们开始看看CPU是如何从地址4005e0处取出指令27bdffe8并执行的.

根据上面的分析可以知道,0x4005e0这个地址位于useg区域,是需要经过TLB转换翻译,经过cache缓存的.现在假设CPU的PC寄存器中的内容已经是0x4005e0,那么CPU是如何从这个地址中取出指令并执行的呢?期间经过了哪些需要软件人员关注的环节呢?

注意:MIPS 的PC和ARM的不同点:MIPS的PC是存在的,但是对于软件人员来说不可见,不可直接操作

下面的列出的图5大概描述了MIPS32 R2取一条指令需要经过的步骤

结合Linux的应用场景看MIPS32架构之内存管理

图5 MIPS32 取值过程图

按照前面的例子对这幅图片进行介绍,首先CPU结合当前的运行在user模式,判断0x4005e0这个地址是经过属于useg区域,需要经过TLB转换的,所以取值单元经过第一步去查看TLB中是否有0x4005e0这个虚拟地址的匹配项,如果存在有效的匹配项,那么拿到物理地址后,就会去cache中查看是否已经有这个物理地址对应的有效数据(对应图中的步骤2),如果有,那么取值单元就会取到该地址对应的指令(对应图中的步骤3),如果cache中没有,那么就会在DDR中取出这条指令(对应图中的4-2步骤),以及同时会缓存这条指令以及地址附近的数据到cache中(对应了图中的4-1步骤),如果是第一次执行,这个时候TLB中没有这个地址对应的entry的,该如何处理呢?这时候需要将虚拟地址和物理地址间的映射关系填到TLB中,CPU是通过TLB refill异常来完成这个工作的,在MIPS32体系结构中,这个需要软件人员来处理,而不像ARM或者X86那样硬件自动完成.那么对于这个工作会引来下面的疑问:

Q1:TLB refill到底refill了什么?是怎么完成的?

Q2:refill之后的虚拟地址映射的物理地址一定是有效的吗?如果不一定,那么假如是无效的又该如何处理?

对于上面的两个问题,我们先来看看MIPS32 R2的MMU机制.

1.3 TLB的结构

首先看一下TLB的结构

结合Linux的应用场景看MIPS32架构之内存管理

图6 MIPS32 TLB的结构图

这里的例子中,TLB共有32个entry,可以把这个”entry“形象的理解为每一个entry对应的是一行,一个entry中可分为两大部分:Tag array和Data array。可能有人会问TLB不是处理地址转换的吗?怎么还扯上Data了?其实这里的”Data“并不是问题中的那个”Data“,TLB实际上就是页表的一种硬件CACHE的实现形式。这里Tag array中又包含了4个部分:PageMask,VPN2,G,和ASID,图7详细描述了这部分。而Data array中8部分:PFN0,C0,D0,V0,PFN1,C1,D1,V1,图8详细描述了这部分.

结合Linux的应用场景看MIPS32架构之内存管理

图7  TLB Tag域

<1> Pagemask指的是协处理器CP0的register5,select0寄存器,[24:13]表示的是该寄存器的13到24bit,该部分用于表示页的大小,内核中一般是4k,所以我们在系统启动过程中,该部分会被设置为0,当需要一些大页映射的场景时,比如需要用到hugepages,那么就需要修改这部分,占用了12bit.

<2> VPN2[31:13]表示的是虚拟地址的第13bit到31bit,其中的2的含义是说这个VPN会对应两个物理页,占用了19bit.

<3> G表示的是对于所有的进程这个映射关系都是有用的,全局性的,占用了1bit.

<4> ASID用于表示这个映射关系是仅仅针对特定的进程的,占用了8个bit,可能有人会有疑问,在内核中进程的数目可是可以有很多的,远远超过了8个bit所能容纳的数据,这是如何做到每个进程都有不同的映射关系的呢?实际上很简单,开始ASID从一个数值(kernel中不是0开始的)开始增长,随着新进程被调度,这个数值也不断增加,当增加到255时,再调度新的进程时,就把TLB中的表项全清除,然后这个ASID就可以从开始递增了.不过kernel中这部分代码是有bug的,并且存在了很多年,一直到目前为止都没有修正,这是由于bug的触发很难复现,bug的原因是由于对于kernel中的ASID溢出后处理不完善导致的不同的地址空间的进程可能会非法访问到同样的物理地址上去,或者说是一个私有的物理地址可能会被不同的地址空间的进程所共享。

结合Linux的应用场景看MIPS32架构之内存管理

图8 TLB Data 域

<1> PFN0[31:12]PFN1[31:12],这里代表的是物理页地址的第12bit到31bit,之所以有两个是由于MIPS32 R2采用了一个虚拟页映射两个物理页的的方法,以减少TLB的容量,这是如何做到的呢?还记得前面的VPN2的位数是从13bit到31bit共19bit,而这里是从12bit开始,所以这个12bit就是作为奇偶页的选择bit,如果12bit是0,那么就选择PFN0,否则选择PFN1,既然是从第12bit开始,那么剩下的12bit的物理地址呢?这个是从虚拟地址中直接拿过来就可以了,不需要经过转换,所以这部分我们成之为页内地址.

<2> C0[2:0]C1[2:0]用于这个映射的物理页是否经过cache以及经过cache的方式,写穿(write through)还是写回(write back),写穿代表当要往这个地址写入数据时,直接写到RAM中(如果cache中存在同一个地址,也会写到cache中),就如同”穿”过了cache一样,这种效率较低,但是不存在数据的一致性问题;而写回代表当要往这个地址写入数据时,写入到cache中就”回去”了,而不再将数据写入到RAM中,cache和RAM中数据的一致性问题交由cache处理,这种方式存在效率较高,但是可能会引发一致性问题,这种方式往往和write allocate(写分配)配合使用,即当需要往地址写数据时,如果这个地址在cache中不存在,那么先把这个地址中的数据读入到cache中,然后在往cache中写入数据.对于物理页的这个配置在写用户空间驱动时是至关重要的.

<3> D0和D1代表这个映射关系下的物理页是否可写,如果这个bit是0,那么就表示是不可写的,这时候会触发TLB modified异常,什么时候用这个异常呢?在内核中的写时复制就用了这个机制,进程在fork时,会将父进程的页表中的映射关系都标记为不可写,然后复制到子进程,对应到这里就是将D0或者D1标记为0,当父进程或者子进程在对这些标记为只读的页进行写操作时就会触发这个TLB modified异常,在异常处理中分配新的物理页将原来物理页上的数据copy到这个新的物理页上,并将新的物理页和这个虚拟页进行映射.

<4> V0,V1用于表示这个映射关系是否是有效的,如果是无效的,会触发TLB invalid 异常,在我们调用mmap映射文件时,malloc或者vmalloc分配内存,就用到了这个特性,当调用前面提到的接口分配内存或进行文件映射时,只是在进程的地址空间中分配了一片虚拟空间或者将文件和这个虚拟空间进行了映射,但是开始并没有直接分配物理页和这些虚拟空间进行映射,这时候页表中的映射关系还是invalid的,当访问这片虚拟空间时,在这里发现V bit是0,也就是说这个虚拟到物理的映射关系是invalid,所以触发了TLB invalid异常,这时候在kernel的异常处理函数中会分配新的物理页和访问的虚拟页进行映射,如果是文件映射,还需要将文件中的内容读入到物理页中.

下面看看MIPS32 R2中如何才能准确操作TLB.

1.4 操作TLB的方式

软件控制硬件的方式需要操作的接口也就是寄存器以及指令,操作TLB也需要寄存器和指令.在MIPS32中控制cache\TLB等模块是通过协处理器CP0,协处理器CP0对于MIPS处理器至关重要,不过我们这里不先介绍.看看TLB相关的寄存器.

与TLB相关的寄存器有很多Index, Random, EntryLo0, EntryLo1, Context, PageMask, Wired, BadVAddr, EntryHi等,与TLB相关的指令有TLBP, TLBR, TLBWI, TLBWR. 下面分别介绍一下

先说下指令,

TLBR指令用于从TLB的所有entry中读取出由index寄存器所列出的entry出来,读出来的内容放到EntryLo和EntryHi以及Pagemask寄存器中.

TLBP指令用于从TLB的所有entry中找出和EntryHi寄存器匹配的内容的entry出来,将这个entry的索引放到index寄存器中

TLBWI指令用于将EntryHi和EntryLo以及Pagemask中的内容写入到index寄存器索引的TLB entry中

TLBWR指令用于将EntryHi和EntryLo以及Pagemask中的内容写入到random寄存器索引的TLB entry中.

下面看看相关的寄存器介绍

Index Register (CP0 Register 0, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图9 Index寄存器

这个寄存器的index位用于TLB的索引,主要在TLBP,TLBR以及TLBWI指令时使用.

Random Register (CP0 Register 1, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图10 Random寄存器

该寄存器的random位用于记录TLBWR指令时随机产生的TLB 的索引值

EntryLo0, EntryLo1 Register (CP0 Register 2, 3, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图11 EntryLo系列寄存器

该寄存器中的PFN用于表示物理页帧,C表示对应的这个物理页的cache属性,D表示对应的这个物理页的是否可写,这里dirty并不是真正的脏,而是可写的含义,不要误解.V表示该物理页是否是有效的.G表示这个映射关系是否是全局的,也就是和进程空间无关的.

这个主要在将页表用TLBWI或者TLBWR指令填写到TLB中时所用.它会有指令TLBR所影响.

Context Register (CP0 Register 4, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图12 Context寄存器

该寄存器中的BadVPN2用于记录在TLB异常发生时虚拟地址的31:13bit.

PageMask Register (CP0 Register 5, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图13 PageMask寄存器

该寄存器的Mask用于设定映射页帧的大小,一般在Linux中我们使用的是4K的页,当然也有些特殊情况.

Wired Register (CP0 Register 6, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图14 Wired寄存器

实际上该寄存器用于锁定TLB entry的,在用TLBWR指令操作TLB时,2^wired以下的entry不会被替换掉.但是可以由TLBWI指令替换掉.

BadVAddr Register (CP0 Register 8, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图15 BadVAddr寄存器

由于记录导致TLB 异常或者地址错误(比如在user模式下访问高2G空间)异常的虚拟地址.

EntryHi Register (CP0 Register 10, Select 0)

结合Linux的应用场景看MIPS32架构之内存管理

图16 EntryHi寄存器

在TLB的结构中已经介绍过VPN2和ASID,这里不再赘述.

1.5 Kernel中的内存(页表)管理和MIPS32 TLB异常

下面看看kernel中页表的映射过程,MIPS32在kernel中使用了两级页表完成虚拟地址到物理地址的映射过程,

结合Linux的应用场景看MIPS32架构之内存管理

图17 kernel的页表

找一个虚拟地址对应物理地址中的数据的过程是这样的,首先从进程的task_struct中拿到mm,mm的pgd成员就是进程的一级页表所在页帧,然后将虚拟地址的高10bit,也就是[31:22]拿出来作为pgd的索引,根据这个索引值得到二级页表的所在的页地址,再根据虚拟地址的21:12间的10bit在二级页表中索引得到物理页地址,然后根据虚拟地址的11:0的低12bit到物理页中索引得到了该虚拟地址对应的值.这是kernel中在虚拟地址获取值的过程,对于MIPS32的MMU来说,这里虚拟地址的低12bit,11:0,同样是页内地址,这是一致的,但是对于一级页表,二级页表,MIPS的MMU是不知道的,这点不像ARM那样由硬件规定好了,所以在TLB refill异常时,需要软件人员根据一级和二级页表找到对应的物理页,然后填入到TLB中,TLB refill异常处理的目标是把引起异常的地址所对应的物理页填入到TLB中,而途径是将EntryHi寄存器和EntryLo1 EntryLo0寄存器的内容通过TLBWR指令刷入到TLB中.这里介绍一下详细的过程,EntryHi寄存器的VPN2域由硬件自动填入触发异常时的地址(的高19bit),ASID域是在进程切换时就填入的,所以EntryHi寄存器在TLB refill时是不用考虑的.而EntryLo寄存器的更新需要由软件来完成,我们需要根据触发异常时的地址获取对应的物理地址并填入这个寄存器,如果以内核中用的是二级页表的方式,首先获取一级页表的首地值(mm->pgd),然后在CP0的BadVaddr中获取引发异常的虚拟地址,将虚拟地址的高10bit(31:22)作为索引得到二级页表的虚拟地址,接下来通过CP0的Context寄存器获取引发异常的BadVPN2,也就是虚拟地址的21:12位为索引找到物理页的地址填入到EntryLo中,然后用TLBWR指令将EntryHi,EntryLo1,EntryLo0填入到TLB中。

下面看看某款MIPS32 R2单核处理器的TLB refill异常处理相关代码

<1> 0x80000000:0x3c1b806a lui     k1, 0x806a

<2> 0x80000004:0x401a4000 mfc0    k0, c0_badvaddr

<3> 0x80000008:0x8f7b2990 lw      k1, 0x2990(k1)

<4> 0x8000000c:0x1ad582      srl     k0, k0, 22

<5> 0x80000010:0x1ad080      sll     k0, k0, 2

<6> 0x80000014:0x37ad821     addu    k1, k1, k0

<7> 0x80000018:0x401a2000    mfc0    k0, co_context

<8> 0x8000001c:0x8f7b0000    lw      k1, 0(k1)

<9> 0x80000020:0x1ad042      srl     k0, k0, 0x1 

<10> 0x80000024:0x335a0ff8    andi    k0, k0, 0xff8

<11> 0x80000028:0x37ad821     addu    k1, k1, k0

<12> 0x8000002c:0x8f7a0000    lw      k0, 0(k1)

<13> 0x80000030:0x8f7b0004    lw      k1, 4(k1)

<14> 0x80000034:0x1ad182       srl     k0, k0, 0x6 

<15> 0x80000038:0x409a1000    mtc0    k0, c0_entrylo0

<16> 0x8000003c:0x1bd982      srl     k1, k1, 0x6 

<17> 0x80000040:0x409b1800    mtc0    k1, c0_entrylo1

<18> 0x80000044:0x42000006   tlbwr 

<19> 0x80000048:0x0           nop

<20> 0x8000004c:0x42000018    eret

第一句和第三句实际为了获取当前进程的pgd的地址放到k1寄存器中,在代码中的实现是这样的,

UASM_i_LA_mostly(p, k1, pgdc);

uasm_i_lw(p, ptr, uasm_rel_lo(pgdc), ptr);

这里的看一下pgdc是怎么获取的.

pgdc的定义是这样的long pgdc = (long)pgd_current;

而pgd_current的定义和赋值如下

long pgdc = (long)pgd_current;

 34 #ifdef CONFIG_MIPS_PGD_C0_CONTEXT

 35 

 36 #define TLBMISS_HANDLER_SETUP_PGD(pgd)                                  \

 37 do {                                                                    \

 38         void (*tlbmiss_handler_setup_pgd)(unsigned long);               \

 39         extern u32 tlbmiss_handler_setup_pgd_array[16];                 \

 40                                                                         \

 41         tlbmiss_handler_setup_pgd =                                     \

 42                 (__typeof__(tlbmiss_handler_setup_pgd)) tlbmiss_handler_setup_pgd_array; \

 43         tlbmiss_handler_setup_pgd((unsigned long)(pgd));                \

 44 } while (0)

 45         

 46 #define TLBMISS_HANDLER_SETUP()                                         \

 47         do {                                                            \

 48                 TLBMISS_HANDLER_SETUP_PGD(swapper_pg_dir);              \

 49                 write_c0_xcontext((unsigned long) smp_processor_id() << 51); \

 50         } while (0)

 51 

 52 #else 

 53 

 54 

 59 extern unsigned long pgd_current[];

 60 

 61 #define TLBMISS_HANDLER_SETUP_PGD(pgd) \

 62         pgd_current[smp_processor_id()] = (unsigned long)(pgd)

 63 

 64 #ifdef CONFIG_32BIT

 65 #define TLBMISS_HANDLER_SETUP()                                         \

 66         write_c0_context((unsigned long) smp_processor_id() << 25);     \

 67         back_to_back_c0_hazard();                                       \

 68         TLBMISS_HANDLER_SETUP_PGD(swapper_pg_dir)

 69 #endif

 70 #ifdef CONFIG_64BIT

 71 #define TLBMISS_HANDLER_SETUP()                                         \

 72         write_c0_context((unsigned long) smp_processor_id() << 26);     \

 73         back_to_back_c0_hazard();                                       \

 74         TLBMISS_HANDLER_SETUP_PGD(swapper_pg_dir)

 75 #endif

 76 #endif 

这里的例子是CONFIG_MIPS_PGD_C0_CONTEXT没有配置的,所以会走else部分.

而在进程进行切换时,switch_mm处理时会调用TLBMISS_HANDLER_SETUP_PGD()这个宏,将要调度的进程的pgd赋值到pgd_current.

接着分析TLB refill异常处理,第二句获取引发TLB refill异常的虚拟地址到K0寄存器.

第四句,将K0寄存器的值向右移动22bit,得到虚拟地址的高10bit,也就是得到一级页表的索引.

第五句,将K0寄存器的值向左移动2bit,这么做的目的是为了将这个索引4字节对齐,每一个二级页表的地址在一级页表中占用4字节,所以我们需要对索引做4字节对齐的处理.

第六句,获取二级页表所对应的索引在一级页表中的地址,放到K1中.

第八句,获取以K1为地址的值,并放到K1中,也就是获取了二级页表所在地址放到K1寄存器.

第七句,将CP0的Context寄存器放到K0寄存器中,这是为了获取虚拟地址在二级页表中的索引的第一步.可能有人会问,为什么不再使用CP0的Badvaddr寄存器,这是为了节省一条指令,这再一次证明,内核的开发人员考虑的是多么的细致.

第九句,将K0寄存器中的值向右移动1bit,这是由于Context的[22:4]存放着虚拟地址的[31:13],不是[31:12],为了获取这个虚拟地址对应的物理页在二级页表中的索引,根据图17知道,我们需要获取虚拟地址的[21:12],所以需要将Context寄存器右移动3bit,然后再移动2bit(因为这里是[31:13],而不是[31:12]),所以整体来看就是右移动了1bit,如果在第七句中用了Badvaddr寄存器的话,那么这里至少需要先将这个值右移13bit,然后再向左移动3bit(因为需要将低3bit清0),才能得到,所以可以看出节省了一条指令.

第十句,通过与0xff8,这9个bit进行与操作,得到了虚拟地址在二级页表中的真正的索引值.

第十一句,获取虚拟地址对应在二级页表中的地址放到K1中.

第十二句,将奇数物理页地址放到K0寄存器中.

第十三句,将偶数物理页地址放到K1寄存器中.前面已经介绍过了,在TLB中,一个VPN对应两个物理页.

第十四句,将K0向右移动6bit,这是由于在EntryLo0寄存器中,PFN[31:12]占用该寄存器的[25:6].

第十五句,所以将K0中的值写入到CP0的EntryLo0寄存器中,由此可见,在二级页表中,[31:12]存放了PFN,而[11:6]存放了C,D,V,G等EntryLo寄存器的相关位,而[5:0]存放的无关紧要.

第十六句,第十七句和第十四和十五句类似.

第十八句,通过tlbwr指令,将EntryHi,EntryLo0,EntryLo1随机写入到TLB中.

第十九句,nop,空语句,应该是等待上一条指令完成

第二十句,返回到触发异常的地址处.注意eret这条指令的工作是,一条指令内清除SR(EXL)位,并同时将EPC中保存的地址放到PC中,也就是返回到那个地址开始取指.这里它并没有提到改变CPU的运行模式,所以资料上都会提到CPU在SR(EXL)=1(异常发生时)都会处于Kernel模式,而不管SR(KSU)为何值.这样也就能保证在kenrel模式使用该异常.

上面的好几条基于基址寄存器寻址的指令都是穿插进行,这是为了提高执行的效率,因为基址寄存器需要提前一个周期准备好,否则就需要等待一个周期.

对于上面代码可能有人会有疑问,这段代码中没有保存现场,那现场不会被破坏吗?的确,这段代码没有保护现场,但是仔细看看,修改的寄存器只有CP0的协处理器EntryLo1和EntryLo0,以及K0和K1,K0和K1这两个寄存器比较特殊,C语言编写的程序在编译时是不会用到这两个寄存器的,而在用户态的汇编编写代码的中,也不会用这两个寄存器,只有在kernel态时,异常(包括中断)发生后的保存现场时才会用到这两个寄存器,MIPS虽然不能不像ARM有那么多运行模式,每种不同的模式都有自己独有的一部分寄存器(MIPS只有两种运行模式,不同模式共用一套寄存器),但是MIPS照样能够想出其他办法来解决好这个问题(多增加了两个寄存器)。

在ARM和X86中,这部分是由硬件自动完成,而在MIPS中,这个是软件参与完成的,由于这个异常发生的频率非常高,所以为了能够高效的处理这个异常,MIPS体系结构对于这个异常做了特殊的处理,它有一个独立的入口点,不过这个入口点是可以配置的(关于这两句话的具体含义我们放到异常和中断中来解释),一般在kernel中这个入口点设置为0x80000000,kernel为了优化这个异常的处理,只有二十条指令,这段代码是在启动过程中生成的,而不是编译出来的,这是为了防止编译工具做编译优化时反而降低了性能.

1.6 实际的TLB组织方式

上面提到了,TLB本身就是一种cache,对页表的缓存。在硬件实现上,cache一般有三种方式,直接相连,全相连和组相连.这三种方式的结构在网上有很多资料可以参考,比如

http://blog.chinaunix.net/uid-26817832-id-3244916.html

这里不在啰嗦。

由于TLB的特殊性,要求TLB命中率高,并且对于速度还要求非常快,为了满足这样的目标,就采用小容量的全相连cache的实现方式。MIPS32 R2的TLB的entry是多个,上面介绍的图中列出了32个,如果不考虑页表锁存的因素,由于每一个页映射关系可以放到这32个中的任意一个entry中,所以要访问一个虚拟地址时,需要将这个虚拟地址和TLB中的所有entry都匹配一遍,这个工作相对比较费时的,所以又引出了下面的模型

结合Linux的应用场景看MIPS32架构之内存管理

图22 MIPS32实际上的TLB关系

MIPS32 R2将TLB分为指令TLB(ITLB)和数据TLB(DTLB)以及JTLB,ITLB和DTLB分别只有四个entry,当要访问的数据或者指令对应的地址不在DTLB或者ITLB中时,就会先到JTLB中去查看是否有相匹配的entry,如果有,那么会把相应的entry copy到DTLB或者ITLB中,这样能够减少索引匹配的时间,毕竟在4个里面查找比在32个里面查找快的多.

了解了MIPS32 R2的TLB相关的背景知识后,我们接着分析我们的例子,这是我们的主线.还是分析CPU从地址0x4005e0取指令的过程,如前面分析的,当TLB中没有匹配的地址映射关系时,这时候会触发TLB refill异常,异常处理函数会将进程的ASID,以及引发该异常的虚拟地址和映射的物理地址填到TLB中.异常处理返回后,继续从这个虚拟地址对应的物理地址中取指令,这时候从TLB中能够得到0x4005e0对应的物理地址,可是,这时候的物理地址一定是有效的吗?如果是第一次从0x4005e0取指令,那么这时候的物理地址是无效的,因为在每一个进程被fork时,它的pgd所在的页都被做了初始化,初始化的值也就是pte所在的页面全部集中到bss段中的一个页面中,这样获取的物理地址就变成0,并且TLB data域中的v和d都为0,所以这时候读取这个虚拟地址0x4005e0时,就会因为TLB 的data域中的V=0(映射关系是无效的)会触发另外一种异常,TLB load异常,这个异常处理过程非常复杂,也就是我们常见的do_page_fault的处理.这个异常的处理的思路大概是首先保存现场,根据虚拟地址找到适合的vma,如果找不到正确的,这是一个错误,如果是kernel模式,那么就打出oops,如果是user模式,就发sigsegv信号给这个进程。如果找到了就分配物理内存,填写页表,如果是文件映射,需要将对应偏移的文件内容读取到这个物理内存中;接下来把虚拟页-物理页的对应关系写入tlb中,恢复现场.

由于现实中MIPS32 R2中有JTLB,DTLB,ITLB之分,所以上面的取值过程图可以细化如下

结合Linux的应用场景看MIPS32架构之内存管理

图23 MIPS32 取值过程图

思考这样一个问题,上面分析的是在user模式下,那么TLB相关异常(TLB Refill 、Load)会不会在Kernel模式发生?如果会,那么什么情况下会发生?

实际上TLB的异常是不分user模式还是kernel模式的,都可能发生,甚至在中断处理过程中都可以发生,不过中断处理时我们一般不让这样的情况出现.比如在kernel模式下,用vmalloc分配的内存在读写时会触发TLB异常,在用copy_from_user和copy_to_user时也可能会发生TLB异常.

下面看一副完整的访问地址的流程图

结合Linux的应用场景看MIPS32架构之内存管理

图24 TLB地址转换流程图

这里详细介绍一下这个流程.当CPU访问一个虚拟地址时,

第一步,ASID在进程切换时就已经保存在了EntryHi寄存器中,而VPN是虚拟地址的[31:13],进入第二步;

第二步,判断当前CPU运行的模式,如果是user模式,则进入第三步,如果是kernel模式,则进入第四步;

第三步,判断这个虚拟地址是否是属于user空间,如果属于则进入第六步,否则进入第五步.

第四步,判断这个虚拟地址是属于kseg0或者kseg1地址区域,如果属于kseg0/kseg1,那么这个地址是需要以unmap方式访问,否则进入第六步;

第五步,既然在user模式下访问了非user空间的地址,那么这是一种非法操作,会触发address error异常,进入相应的异常处理程序.

第六步,对比虚拟地址对应的VPN和TLB中的VPN是否匹配,如果不匹配,说明TLB中没有缓存相应的地址转换关系,则进入TLB refill异常.否则进入第七步.

第七步,查看TLB中相匹配的entry中的G位是否为1,如果G不是1,那么表明这个匹配项不是全局的,那么进入第八步,如果G是1那么就表明这是个全局的匹配项,和进程无关,进入第九步;

第八步,判断EntryHi中的ASID和TLB中的ASID是否匹配,如果匹配,则进入第九步,否则说明在TLB中没有和当前虚拟地址匹配的entry,需要对TLB进行refill,所以进入TLB refill异常.

第九步,判断TLB匹配项中的V位是否为1,如果V=1,那么表明当前TLB的这个entry是有效的,进入第十步,如果V=0,那么表明这个entry是无效的,触发TLB invalid异常.

第十步,判断TLB的entry中的D位是否为1,如果D=0,表明这个虚拟地址对应的物理页是只读的,不可写,进入第十一步;如果D=1,表明这个虚拟地址对应的物理页是可读写的,进入第十二步,

第十一步,判断当前的操作是否为write,如果是write操作,那么会触发TLB modified异常.如果是read操作,那么进入第十二步.

第十二步,判断TLB对应entry中的C域是否为二进制的010或者111,如果是,表明当前地址是不经过cache的,直接访问物理内存,否则这个地址是经过cache的,访问相应的cache即可.

1.7 总结与问题

这样我们就把MIPS32的内存管理相关的知识介绍完了,请回顾一下几个问题

1) MIPS32 有几种运行模式,每种运行模式下都是运行了哪些程序,与其相关的MIPS32的内存映射图是怎样的?

2) MIPS32的MMU TLB结构是怎样的?

3) MIPS32的TLB操作接口和指令有哪些,功能是什么?

4) MIPS32的TLB相关异常处理(refill、invalid,modified)的思路是怎样的?

接下来的文章分析MIPS32的cache结构。

继续阅读