《一个操作系统的实现》笔记(2)--保护模式

2018-06-08 11:37:04 浏览数 (1)


保护模式

什么实模式和保护模式

这是CPU的两种工作模式,解析指令的方式不同。

在实模式下,16位寄存器需要通过段:偏移的方法才能达到1MB的寻址能力。 物理地址 = 段值 x 16 偏移 此时段值还可以看成地址的一部分,段值为XXXXh表示以XXXX0h开始的一段内存。

在保护模式下,CPU有着巨大的寻址能力,并为操作系统提供了虚拟内存和内存保护。 虽然物理地址的仍然用上面的公式表示,但此时“段”的概念发生了变化,它变成了一个索引,指向一个数据结构的一个表项,表项中详细定义了段的起始地址、界限、属性等内容。这个数据结构就是GDT(全局符号表), 其中的表项叫做描述符(Descriptor),另外有一个指向描述符的指针叫做选择子(Selector)。

内存还是一整块,并没有分段,段的划分来自于CPU,就好比划分行政单位,是另一种维度上的划分。


《PC 汇编语言》 在实模式下,一个段地址的值是物理内存里的一节的首地址,在保护模式下,一个段地址的值是一个指向描述符表的指针。 保护模式使用了一种叫做虚拟内存的技术。虚拟内存的基本思想是仅仅保存程序现在正在使用的代码和数据到内存中。其它数据和代码暂时储存在硬盘中直到它们再次需要时。当一段从硬盘重新回到内存中,它很有可能放在不同于它移动到硬盘之前时的位置的内存中。所有这些都由操作系统透明地执行。程序并不需要因为要让虚拟内存工作而使用不同的书写方法。 在保护模式下,每一段都分配了一条描述符表里的条目。这个条目拥有系统想知道的关于这段的所有信息。这些信息包括:现在是否在内存中; 如果在内存中,在哪;访问权限(例如: 只读)。段的条目的指针是储存在段寄存器里的段地址值。

也是在调api,只不过这个api是由处理器CPU来指定的,调用的形式类似于给寄存器、某块指定位置的内存填二进制数据,然后调用CPU提供的指令或者中断等触发一下就可以了。

代码语言:javascript复制
; 宏 -----------------------------------------------------------------------------
;
; 描述符
; usage: Descriptor Base, Limit, Attr
;        Base:  dd
;        Limit: dd (low 20 bits available)
;        Attr:  dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
    dw  %2 & 0FFFFh             ; 段界限1
    dw  %1 & 0FFFFh             ; 段基址1
    db  (%1 >> 16) & 0FFh           ; 段基址2
    dw  ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1   段界限2   属性2
    db  (%1 >> 24) & 0FFh           ; 段基址3
%endmacro ; 共 8 字节

[SECTION .gdt]
; GDT
;                              段基址,       段界限     , 属性
LABEL_GDT:         Descriptor       0,                0, 0           ; 空描述符
LABEL_DESC_CODE32: Descriptor       0, SegCode32Len - 1, DA_C   DA_32; 非一致代码段
LABEL_DESC_VIDEO:  Descriptor 0B8000h,           0ffffh, DA_DRW      ; 显存首地址
; GDT 结束

GdtLen      equ $ - LABEL_GDT  ; GDT长度
GdtPtr      dw  GdtLen - 1  ; GDT界限
            dd  0       ; GDT基地址
; GDT 选择子
SelectorCode32      equ LABEL_DESC_CODE32   - LABEL_GDT
SelectorVideo       equ LABEL_DESC_VIDEO    - LABEL_GDT
; END of [SECTION .gdt]

段式存储的机制是通过段寄存器和GDT中的描述符共同提供的。 Descriptor这个宏的作用是自动化地把段基址、段界限和段属性安排在一个描述符中合适的位置(由于历史原因,它们都被拆开存放了)。 代码段和数据段描述符:

用c语言可以这样表示:

代码语言:javascript复制
/* 存储段描述符/系统段描述符 */
typedef struct s_descriptor     /* 共 8 个字节 */
{
    u16 limit_low;      /* Limit */
    u16 base_low;       /* Base */
    u8  base_mid;       /* Base */
    u8  attr1;          /* P(1) DPL(2) DT(1) TYPE(4) */
    u8  limit_high_attr2;   /* G(1) D(1) 0(1) AVL(1) LimitHigh(4) */
    u8  base_high;      /* Base */
}DESCRIPTOR;

保护机制就体现在描述符的属性段中,对特定类型段的操作能够受到CPU的限制。 选择子(Selector)的结构:

因为每个描述符占8字节的数据大小,选择子只需要前面15bits就能定位到一个描述符了,最小的3bits表示选择子的属性,有其它用途。

段式寻址:

如何实现由实模式到保护模式的转换

1、准备GDT

初始化GDT中各个描述符的信息。

代码语言:javascript复制
[SECTION .s16]
[BITS   16]
LABEL_BEGIN:
    mov ax, cs
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0100h

    ; 初始化 32 位代码段描述符
    xor eax, eax
    mov ax, cs
    shl eax, 4 ; 注意物理地址的表示=段值*16 偏移,而下面的LABEL_SEG_CODE32就是代码段的偏移
    add eax, LABEL_SEG_CODE32
    mov word [LABEL_DESC_CODE32   2], ax ;LABEL_DESC_CODE32就是保护模式下32位代码段的开始处
    shr eax, 16
    mov byte [LABEL_DESC_CODE32   4], al
    mov byte [LABEL_DESC_CODE32   7], ah

    ; 为加载 GDTR 作准备
    xor eax, eax
    mov ax, ds
    shl eax, 4 ; LABEL_GDT是在数据段上定义的,也要偏移
    add eax, LABEL_GDT      ; eax <- gdt 基地址
    mov dword [GdtPtr   2], eax ; [GdtPtr   2] <- gdt 基地址
2、用lgdt加载gdtr

关键是把GDT的物理地址填充到GdtPtr这个6字节的数据结构中,然后执行如下的指令,把这个6字节的数据加载到寄存器gdtr中。这样CPU就能知道GDT这张表了,之后的内存寻址就会根据这张表的指示去找了。

代码语言:javascript复制
    ; 加载 GDTR
    lgdt    [GdtPtr]
3、实模式下的善后工作和保护模式下的初始化工作。
4、跳转,进入保护模式
代码语言:javascript复制
;...
; 真正进入保护模式
jmp dword SelectorCode32:0  ; 执行这一句会把 SelectorCode32 装入 cs,
                            ; 并跳转到 LABEL_SEG_CODE32处
; END of [SECTION .s16]
;...
[SECTION .s32]; 32 位代码段. 由实模式跳入.
LABEL_SEG_CODE32:
    mov ax, SelectorVideo
    ;...

LDT(Local Descriptor Table)

LDT跟GDT类似,都是描述表,区别仅仅在于局部(Local)和(Global)的不同。 指向LDT中描述符的选择子的T1 位必须置1,在运用它时,需要先用lldt指令加载ldtr,其操作数是GDT中用来描述LDT的描述符。LDT相当于是GDT的二级目录,增加了一个层次。 我们可以把一个单独的任务所用到的所有东西封装在一个LDT中,这种思想是多任务处理的雏形。 多任务所用的段类型如下图,使用LDT来隔离每个应用程序任务的方法,正是关键保护需求之一:

一个LDT的例子:

代码语言:javascript复制
[SECTION .gdt]
; GDT
;                                         段基址,       段界限     , 属性
LABEL_DESC_CODE32: Descriptor       0,  SegCode32Len - 1, DA_C   DA_32  ; 非一致代码段, 32
LABEL_DESC_LDT:    Descriptor       0,        LDTLen - 1, DA_LDT    ; LDT
;...
; GDT 结束

; GDT 选择子
;...
SelectorCode32      equ LABEL_DESC_CODE32   - LABEL_GDT
SelectorLDT     equ LABEL_DESC_LDT      - LABEL_GDT
; END of [SECTION .gdt]

LABEL_BEGIN:
    ; 初始化 LDT 在 GDT 中的描述符
    ;...
    add eax, LABEL_LDT
    mov word [LABEL_DESC_LDT   2], ax
    ;...

[SECTION .s32]; 32 位代码段. 由实模式跳入.
LABEL_SEG_CODE32:
    ;...
    ; Load LDT
    mov ax, SelectorLDT
    lldt    ax

    jmp SelectorLDTCodeA:0  ; 跳入局部任务
;...    
; END of [SECTION .s32]

; LDT
[SECTION .ldt]
LABEL_LDT:
;                            段基址       段界限      属性
LABEL_LDT_DESC_CODEA: Descriptor 0, CodeALen - 1, DA_C   DA_32 ; Code, 32 位

LDTLen      equ $ - LABEL_LDT

; LDT 选择子
SelectorLDTCodeA    equ LABEL_LDT_DESC_CODEA    - LABEL_LDT   SA_TIL ; 选择子是通过地址相减得到的,所以末尾3位都是0,可以利用起来
; END of [SECTION .ldt]

; CodeA (LDT, 32 位代码段)
[SECTION .la]
LABEL_CODE_A:
    mov ax, SelectorVideo
    mov gs, ax          ; 视频段选择子(目的)
    mov edi, (80 * 12   0) * 2  ; 屏幕第 10 行, 第 0 列。
    mov ah, 0Ch         ; 0000: 黑底    1100: 红字
    mov al, 'L'
    mov [gs:edi], ax
CodeALen    equ $ - LABEL_CODE_A
; END of [SECTION .la]

特权级概述–保护的意义

具体可以看《linux内核完全剖析》的4.2.3、4.5保护一节。

特权级示意图

处理器利用特权级来防止运行在较低特权级的程序或者任务访问具有较高特权级的一个段,除非是在受控的条件下。

CPL、DPL、RPL

处理器会对调用方的CPL、RPL跟被调用方的DPL做特权级检查。

1、CPL(Current Privilege Level)

CPL是当前执行的程序或任务的特权级。 存储在cs和ss第0位和第1位上。 通常情况下,CPL等于代码所在的段的特权级。当程序转移到不同特权级的代码段时,处理器将改变CPL。当处理器访问一个与CPL特权级不同的一致代码段时,CPL将保持之前的不变。

2、DPL(Descriptor Privilege Level)

DPL表示段或者门的特权级。 存储在段/门描述符的DPL字段中。

3、RPL(Requested Privilege Level)

存储在选择子的RPL字段中。 操作系统过程往往用RPL来避免低特权级应用程序访问高特权级内的数据。

访问数据段时的特权级检查:


特权级转移

1、通过jmp或call进行直接转移

通过jmp或call所能进行的代码段间转移是非常有限的,对于非一致代码段,只能在相同特权级代码段之间转移。遇到一致代码段也最多能从低到高,而且CPL不会改变。 如果想自由地进行不同特权级之间的转移,显然需要其他几种方式,即运用门描述符或者TSS。

2、调用门

为了对具有不同特权级的代码段提供受控的访问,处理器提供了称为门描述符的特殊描述符集。 门描述符结构,同样是8个字节,直观来看,一个门描述了由一个选择子和一个偏移所指定的线性地址,程序正是通过这个地址进行转移的,这里的S=1表示此描述符是门,而不是普通的数据/代码段描述符。

描述符分为以下4种: - 调用门,TYPE=12; - 陷阱门,TYPE=15; - 中断门,TYPE=14; - 任务门,TYPE=5; 其中,调用门用于在不同特权级之间实现受控的程序转移。 Linux内核中并没有用到调用门。 门调用过程:

通过调用门进行控制转移的特权级检查:

通过调用门和call指令,可以实现从低特权级到高特权级的转移,无论目标代码段是一致的还是非一致的。

3、关于堆栈
  • 短调用:在段内跳转
  • 长调用:在段间跳转 call指令是会影响堆栈的,不同于jmp的是,call就像调用一个函数,也会返回的,长调用和短调用对堆栈的影响是不同的。

短调用时堆栈示意:

短调用返回时堆栈示意:

长调用时的堆栈示意:

长调用返回时的堆栈示意:

在调用门,堆栈发生了切换,call指令执行前后的堆栈已经不再是同一个了。Intel提供了一种机制,将堆栈A的诸多内容复制到堆栈B中,这里参数的复制就是由Param Count一项来决定的,有特权级变换的转移时堆栈如下图,

4、进入ring3–由高特权级进入低特权级

我们手动将ring3的cs、eip等信息压栈,然后执行ret指令就可以转移到低特权级代码中了

5、进入ring0–从低特权级进入高特权级

从低特权级到高特权级转移时需要用到TSS,跟LDT的用法类似,在初始化TSS结构之后,调用ltr指令让CPU加载它,之后再进入ring3。当在ring3中使用调用门进入ring0时,CPU就会去之前设置的TSS结构中找不同特权级的堆栈等信息了。


内存寻址

内存是指一组有序字节组成的数组,每个字节有唯一的内存地址。内存寻址是指对存储在内存中的某个指定数据对象进行定位。

地址变换

地址变换能够让操作系统在给任务分配内存时具有灵活性,并且因为我们可以让某些物理地址不被任何逻辑地址所映射,所以在地址变换过程中同时提供了内存保护功能。 分段和分页操作都使用驻留在内存中的表来指定它们各自的变换信息。这些表只能由操作系统访问,以防止应用程序擅自修改。

虚拟地址(逻辑地址)到物理地址的变换过程如下图,如果没有启用分页机制,那么分段机制产生的线性地址空间就直接映射到处理器的物理地址空间上。

逻辑地址、线性地址和物理地址之间的变换如下图。分段提供了一种机制,用于把处理器可寻址的线性地址空间划分成一些较小的称为段的受保护地址空间区域。段可以用来存放程序的代码、数据和堆栈,或者用来存放系统数据结构(例如TSS或LDT)。

当使用分页时,每个段被划分成页面(通常每页为4KB大小),页面会被存储于物理内存中或硬盘上。操作系统通过维护一个页目录和一些页表(存放在物理内存的某个位置)来留意这些页面。当程序试图访问线性地址空间中的一个地址位置时,处理器就会使用页目录和页表把线性地址转换成一个物理地址,然后在该内存位置上执行所要求的读写操作。 如果当前被访问的页面不在物理内存中,处理器就会中断程序的执行(通过产生一个页错误异常)。然后操作系统就可以从硬盘上把该页面读入物理内存中,并继续执行刚才被中断的程序。

不同进程可以有相同的逻辑地址,原理就是在任务切换时通过改变cr3的值来切换页目录,从而改变地址映射关系。

系统的内存分布

可以利用中断15h得到机器的内存信息,调用的结果是BIOS会填充es:di指向的一块内存,此结构成为ARDS。 地址范围描述符结构(Address Range Descriptor Structure)

ARDS之Type:

一个可能的内存分布:

由于历史原因,系统可用的内存分布得并不是连续的。


中断和异常机制

在实模式下能用的BIOS中断在保护模式下已经不能用了,实模式下的中断向量表被保护模式下的IDT所代替。 IDT的作用是将每一个中断向量和一个描述符对应起来。 联系调用门我们知道,其实中断门或者任务门的作用机理几乎是一样的,只不过使用调用门时使用call指令,而这里我们使用int指令。 中断过程调用:

中断门和陷阱门的结构:


0 人点赞