分类: LINUX
2014-05-20 12:39:02
要想弄懂Linux内核的工作原理,就必须懂点基本的硬件知识。这里我们主要介绍Intel 80x86系列CPU保护模式下最核心的部件中几个寄存器的作用,这些寄存器在Linux内核运行时起着至关重要的作用。至于其他那些各式各样的硬件设 备,我们在讲解设备驱动时会针对具体的驱动程序来介绍的。首先,大家先看看CPU的主要架构:
EU(通用寄存器、运算器和控制器)执行部件:完成指令所要求的功能。
SU(段寄存器、段转换器)分段部件:完成执行单元的地址请求, 将虚地址转换为线性地址。
PU(TLB、页转换器)分页部件:将线性地址转换为物理地址。
BIU(总线接口)接口部件:完成指令预取请求和执行单元的数据存取请求,数据存取请求优先于指令预取请求。
IPU(控制逻辑和预取队列)预取部件:16字节指令预取队列, 提出预取请求。
IDU(指令译码、6字节指令队列)译码部件:完成指令译码功能。
FPU(片内集成了浮点协处理器):专用于浮点运算的处理部件。
下面,我们针对EU、SU和PU模块做做详细说明,其他模块就暂时不介绍了,相应的Linux专题会涉及到的。
EU模块是CPU中最核心,最重要的部件。现在的奔腾CPU已经发展了若干年了,但其中最起作用的还是加法单元ALU,一组通用寄存器组、一个标志和控制逻辑。如图:
首先,8个32位通用寄存器按使用情况分为三种:指针寄存器、变址寄存器、数据寄存器。
[1] 指针寄存器:主要提供全部或部分偏移量
ESP:专门存放堆栈段中栈顶单元的偏移量。
EBP:存放堆栈段中某个单元的全部/部分偏移量,也可存放32位或16位操作数或运算结果。
[2] 变址寄存器
ESI/EDI:存放主存操作数的全部/部分偏移量,也可存放16位操作数和结果,在多数情况功能可以互换。 但在串操作指令中作用不能互换,源
操作数必须用ESI提供偏移量,目的操作数必须用EDI提供偏移量。
[3] 数据寄存器
◆ 数据寄存器既可以作为4个32位的寄存器,也可以作为8个16位的寄存器 ,还可以作为16个8位的寄存器。
◆ 在程序中,数据寄存器用来存放操作数、运算结果或其他信息。
◆ 数据寄存器在许多指令中要求指明使用,但也有隐含或特定使用,详细情况请查阅相关资料。
其次,4个控制寄存器CR0~CR3
[1] CR0:由80286的MSW寄存器演变而来,并增加了2位,Linux最看重他的PG位——PG=0,允许分页;PG=1,不允许分页。
[2] CR1:未使用
[3] CR2:页故障地址寄存器, 存放出现故障的页的32位线性地址
[4] CR3:页目录基地址寄存器, 存放页目录表的基地址。
最后来看看标志寄存器FR
FR用来记录程序执行时的状态,即两个操作数通过ALU后的状态:
[1] 进位标志位CF(Carry Flag)
[2] 奇偶标志位PF(Parity Flag)
[3] 辅助进位标志位AF(Auxiliary Carry Flag)
[4] 零值标志位ZF(Zero Flag)
[5] 符号标志位SF(Sign Flag)
[6] 溢出标志位OF(Overflow Flag)
[7] 单步标志位TF(Trace Flag)
[8] 中断标志位IF(Interrupt-enable Flag)
[9] 方向标志位DF(Direction Flag)
2 SU模块
下面着重看看SU部件。这个部件也被Linux用到了,但Linux用它的目的并不是遵循Intel手册对地址进行虚拟化,而是利用它来做用户态和内核态的切换。而对地址的虚拟化,则是通过PU单元,也就是分页机制来实现的,具体的内容将在内存管理专题中详细阐述。
首先来看看SU模块的架构图:
处理器提供了6个段寄存器,段寄存器的唯一的目的是存放选择子(16位)。这些段寄存器称为cs, ss, ds, es, fs和gs。尽管只有6个段寄存器,但程序可以把同一个段寄存器用于不同的目的,方法是先将其值保存在存储器中,用完后再恢复。
6个寄存器中3个有专门的用途:
cs——代码段寄存器,指向包含程序指令的段。
ss——栈段寄存器,指向包含当前程序栈的段。
ds——数据段寄存器,指向包含静态数据或者全局数据的段。
其它三个段寄存器作一般用途,可以指向任意的数据段。
每个段由一个8字节的描述子(Segment Descriptor)表示,它描述了段的特征。描述子放在全局描述符表(Global
Descriptor Table, GDT)或局部描述符表(Local Descriptor Table,
LDT)中,这些表位于内存中,如图所示。如果是多CPU,则每个CPU定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加的段,
就可以有自己的LDT。GDT在主存中的基地址和大小存放在gdtr处理器寄存器中,当前正被使用的LDT地址和大小放在ldtr处理器寄存器中。
虚拟地址由16位选择子和32位偏移量组成,段寄存器仅仅存放选择子。CPU的分段单元(SU)执行以下操作:
[1]
先检查选择子的TI字段,以决定描述子对应的描述子保存在哪一个描述符表中。TI字段指明描述子是在GDT中(在这种情况下,分段单元从gdtr寄存器中
得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从ldtr寄存器中得到LDT的线性基地址)。
[2] 从选择子的index字段计算描述子的地址,index字段的值乘以8(一个描述子的大小,其实就是屏蔽掉末尾那三位指示特权级的CPL和指示TI的字段),这个结果与gdtr或ldtr寄存器中的内容相加。
[3] 将对应的描述子从内存拷贝到CPU的隐Cache中,这样,只有在选择子改变的情况下才会修改Cache中的内容。
[4] 把逻辑地址的偏移量与隐Cache中描述子Base字段的值相加就得到了线性地址。
请注意,多亏了与段寄存器相关的不可编程的隐Cache,只有当段寄存器的内容被改变时才需要执行前三个操作。
LDT在Linux中使用得很少,我们就不细说他了,它跟我们下面讲的IDT差不多。
中断描述符表(Interrupt Descriptor Table,IDT)是一个系统表,它与每一个中断或异常向量相联系,每一个向量在表中有相应的中断或异常处理程序的入口地址。内核在允许中断发生前,必须适当地初始化IDT。
IDT的格式与这GDT和LDT的格式非常相似,表中的每一项对应一个中断或异常向量,每个向量由8个字节组成。因此,最多需要256×8=2048字节来存放IDT(Linux有256个中断向量)。
idtr寄存器使IDT可以位于内存的任何地方,它指定IDT的线性基地址及其大小(最大长度)。在允许中断之前,必须用lidt汇编指令初始化idtr。
IDT包含三种类型的描述符,下图显示了每种描述符中的64位的含义。尤其值得注意的是,在40~43位的Type字段的值表示描述符的类型。
这里还要提一个问题,就是TSS技术是个很过时的技术,Linux并没有按照Intel要求的那样把TSSD(任务门)存放到IDT中,而是存放在
全局描述符GDT中。每个CPU的tr寄存器包含了对应TSS的TSSD选择符(这个选择符可以编程),还包含了两个隐藏的非编程字段:TSSD的
Base字段和Limit字段作为隐Cache,这样,处理器就能直接对TSS寻址而不用从GDT中检索TSS的地址。TSS这个东西主要用来在进程切换
的时候保存部分CPU寄存器的内容(其实就主要是堆栈切换的时候使用到的那些寄存器)。Linux只为每个CPU准备一个TSS数据结构——
tss_struct,只是用来存放当前进程的部分寄存器内容,并没有按照Intel推荐的那样为每个进程准备一个TSS数据结构,并存放所有的内容。所
以,按照我的理解,每个进程的那个thread_struct结构存放的内容就是当进程被执行的时候需要tss_struct记住的那些寄存器的内容。
当执行了一条指令后,CS和eip这对寄存器包含下一条将要执行的指令的逻辑地址。在处理那条指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常。如果发生了一个中断或异常,那么控制单元执行下列操作:
1. 确定与中断或异常关联的向量i (0 ≤ i ≤ 255)。
2. 读由idtr寄存器指向的 IDT表中的第i项。
3. 从gdtr寄存器获得GDT的基地址,并在GDT中查找,以读取IDT表项中的选择符所标识的段描述符。这个描述符将会是一个中断门或者一个陷阱门,其含有指定中断或异常处理程序所在段的基地址。
4.
确信中断是由授权的(中断)发生源发出的。首先将当前特权级CPL(存放在cs寄存器的低两位)与段描述符(存放在GDT中)的描述符特权级DPL比较,
如果CPL小于DPL,就产生一个“General
protection”异常,因为中断处理程序的特权不能低于引起中断的程序的特权。对于编程异常,则做进一步的安全检查:比较CPL与处于IDT中的门
描述符的DPL,如果DPL小于CPL,就产生一个“General
protection”异常。这最后一个检查可以避免用户应用程序访问特殊的陷阱门或中断门。
5. 检查是否发生了特权级的变化,也就是说,CPL是否不同于所选择的段描述符的DPL。如果是,控制单元必须开始使用与新的特权级相关的栈。通过执行以下步骤来做到这点:
i. 读tr寄存器,以访问运行进程的TSS段。
ii. 用与新特权级相关的栈段和栈指针的正确值装载ss和esp寄存器。这些值可以在TSS中找到。
iii. 在新的栈中保存ss和esp以前的值,这些值定义了与旧特权级相关的栈的逻辑地址。
6. 如果故障已发生,用引起异常的指令地址装载CS和eip寄存器,从而使得这条指令能再次被执行。
7. 在栈中保存eflags、CS及eip的内容。
8. 如果异常产生了一个硬件出错码,则将它保存在栈中。
9. 装载cs和eip寄存器,其值分别是IDT表中第i项门描述符的段选择符和偏移量字段。这些值给出了中断或者异常处理程序的第一条指令的逻辑地址。
控制单元所执行的最后一步就是跳转到中断或者异常处理程序。换句话说,处理完中断信号后,控制单元所执行的指令就是被选中处理程序的第一条指令。
中断或异常被处理完后,相应的处理程序必须产生一条iret指令,把控制权转交给被中断的进程,这将迫使控制单元:
1. 用保存在栈中的值装载CS、eip或eflags寄存器。如果一个硬件出错码曾被压入栈中,并且在eip内容的上面,那么,执行iret指令前必须先弹出这个硬件出错码。
2. 检查处理程序的CPL是否等于CS中最低两位的值(这意味着被中断的进程与处理程序运行在同一特权级)。如果是,iret终止执行;否则,转入下一步。
3. 从栈中装载ss和esp寄存器,因此,返回到与旧特权级相关的栈。
4.
检查ds、es、fs及gs段寄存器的内容,如果其中一个寄存器包含的选择符是一个段描述符,并且其DPL值小于CPL,那么,清相应的段寄存器。控制单
元这么做是为了禁止用户态的程序(CPL=3)利用内核以前所用的段寄存器(DPL=0)。如果不清这些寄存器,怀有恶意的用户态程序就可能利用它们来访
问内核地址空间。
3 PU模块
分页单元PU模块的目的是把线性地址转换成物理地址。其中一个关键任务是把所请求的访问类型与线性地址的访问权限相比较,如果这次内存访问是无效的,就产生一个缺页异常。
分页单元把所有的主存看成一块一块的,称其为页框(page frame)(有时叫做物理页)。每一个页框是固定的大小(跟分段的最大区别,一般为32位处理器为4k、64位处理器为64k)包含一个页(page)。
把线性地址映射到物理地址的数据结构称为页表(page
table),其存放在主存中,并在启用分页单元之前必须由内核对页表进行适当的初始化。从80386开始,所有的80x86处理器都支持分页,它通过设
置cr0寄存器的PG标志启用。当PG=0时,线性地址就被解释成物理地址。
从80386起,Intel处理器的分页单元处理4KB的页。32位的线性地址被分成3个字段:
目录(Directory)——最高10位
页表(Table)——中间10位
偏移量(Offset)——最低12位
当一个进程运行时,必须有一个分配给它的页目录,其每一个目录项指向一个页表的地址。不过,没有必要马上为进程的所有页表都分配内存。Linux是在当进程实际需要一个页表时才给该页表分配RAM以提高效率。
正在使用的页目录的物理地址存放在控制寄存器cr3中。线性地址内的最高10位(Directory字段)决定页目录中的目录项,而目录项指向适当的页
表。地址的中间10位(Table字段)依次又决定页表中的表项,而表项含有页所在页框的物理地址。最低12位(Offset字段)决定页框内的相对位置
(见图)。由于它是12位长,故每一页含有4096字节的数据。
页目录项和页表项有同样的结构,每项的内容主要包括对应页(页表也是一个页)的索引以及对应页的状态,我们将在存储管理中分段Linux分段分页机制博文中详细介绍。
下面再来谈谈分页的硬件保护方案,分页单元和分段单元的保护方案不同。尽管80x86处理器允许一个段使用四种可能的特权级别,但与页和页表相关的特权级
只有两个,由页目录项和页表项有同样的结构的User/Supervisor标志所控制。若这个标志为0,只有当CPL小于3(这意味着对于Linux而
言,处理器处于内核态)时才能对页寻址;若该标志为1,则总能对页寻址。
此外,与段的三种存取权限(读,写,执行)不同的是,页的存取权限只有两种(读,写)。如果页目录项或页表项的Read/Write标志等于0,说明相应的页表或页是只读的,否则是可读写的。
4 高速缓存
当今的微处理器时钟频率接近几个GHZ,而动态RAM(DRAM)芯片的存取时间是时钟周期的数百倍。这意味着,当从RAM中取操作数或向RAM中存放结果这样的指令执行时,CPU可能等待很长时间。
为此,80x86体系结构中引入了一个叫行(line)的新单位。行由几十个连续的字节组成,它们以脉冲突发模式(burst mode)在慢速DRAM和快速的片上静态RAM(SRAM)之间传送,用来实现高速缓存。
具体的高速缓存实现细节太复杂,我只简单地说说原理:当访问一个RAM存储单元时,CPU从物理地址中提取出子集的索引号并把子集中所有行的标签与物理地
址的高几位相比较。如果发现某一个行的标签与这个物理地址的高位相同,则CPU命中一个高速缓存(cache
hit);否则,高速缓存没有命中(cache miss)。
当命中一个高速缓存时,高速缓存控制器的操作不同,具体取决于存取类型。
对于读操作,控制器从高速缓存行中选择数据并送到CPU寄存器;RAM不被访问且节约了CPU时间,因此,高速缓存系统起到了其应有的作用。对于写操作,
控制器可能采用以下两个基本策略之一,分别称之为通写(writethrough)和回写(writeback)。在通写中,控制器总是既写RAM
也写高速缓存行,为了提高写操作的效率关闭高速缓存。回写方式只更新高速缓存行,不改变RAM的内容,提供了更快的功效。当然,回写结束以后,RAM最终
必须被更新。只有当CPU执行一条要求刷新高速缓存表项的指令时,或者当一个FLUSH硬件信号产生时(通常在高速缓存不命中发生之后),
高速缓存控制器才把高速缓存行写回到RAM中。
当高速缓存没有命中时,高速缓存行被写回到内存中,如果有必要的话,把正确的行从RAM中取出放到高速缓存的表项中。很复杂吧?我们应该大肆庆幸,因为所有这一切都在硬件级处理,内核根本不需要关心。
高速缓存技术正在快速向前发展。例如,第一代Pentium芯片包含一颗称为L1-cache的片上高速缓存。近期的芯片又包含另外的容量更大,速度较
慢,称之为L2-cache,L3-cache的片上高速缓存。多级高速缓存之间的一致性是由硬件实现的。Linux忽略这些硬件细节并假定只有一个单独
的高速缓存。
处理器的cr0寄存器的CD标志位用来启用或禁用高速缓存电路。这个寄存器中的NW标志指明高速缓存是使用通写还是回写策略。
除了通用硬件高速缓存之外, 80x86处理器还包含了另外一个称之为翻译后备缓冲器或TLB(Translation Lookaside
Buffer,有些书上也把这组寄存器叫做“联想存储器”)的高速缓存用于加快线性地址的转换。当一个线性地址被第一次使用时,通过慢速访问RAM中的页
表计算出相应的物理地址。同时,物理地址被存放在一个TLB表项(TLB entry)中,以便以后对同一个线性地址的引用就可以快速地得到转换,如图。
例如CPU给出有效地址为(D,P,W),它把页号P送入输入寄存器,随后立即和TLB各单元的页号进行比较,如与某个单元中的页号相匹配,则把该单元中的块号B送入输出寄存器。这样,就可以用(D,B,W)访问相应的主存单元。
在多处理系统中,每个CPU都有自己的TLB,这叫做该CPU的本地TLB。与硬件高速缓存相反,TLB中的对应项不必同步,这是因为运行在现有CPU上的进程可以使同一线性地址与不同的物理地址发生联系。
当CPU的cr3控制寄存器被修改时,硬件自动使本地TLB中的所有项都无效,这是因为新的一组页表被启用而TLB指向的是旧数据。