建立新秩序

16位的8086已经满足不了人类了, 这时32位的世界应运而生, 它结束了内存容量成为瓶颈的8086时代, 带领着计算机技术进入了IA-32的新纪元. 这个世界的名字叫80386.

在80386制定的新秩序下, 所有通用寄存器的长度都升级到32位, 地址也变成了32位, 这意味着寻址的范围扩大到了 2^32 = 4GB . "4GB怎么可能用得完?" 要知道, 这个新世界刚建立的时候, 内存还都只是1MB的, 因为在8086的世界里, 再大的内存也是浪费.

既然一个通用寄存器的长度就已经是32位, 这已经足够访问4GB的内存空间了, 是不是就可以把段寄存器去掉呢? 理论上是的, 但在现实中, 工业界还得考虑一个关系到产品生死存亡的问题: 兼容. 要知道, 不支持兼容的产品注定是要被市场和历史抛弃的(Intel的IA-64就是这样被无情抛弃了). 于是80386中带有了一个神奇的开关, 只有触发了这个开关, 才能踏入这个全新的世界. 这个开关放在一个叫CR0(control register 0)的寄存器中的PE位, 计算机可以决定自己留在哪个世界.

如果计算机没有打开这个神奇的开关, 那么段寄存器的作用和寻址方式都和8086一模一样. 但在80386的世界里, 分段的寻址方式发生了很大的改变. 首先来感受一下80386中建立的新秩序:

           15              0    31                                   0
  LOGICAL +----------------+   +-------------------------------------+
  ADDRESS |    SELECTOR    |   |                OFFSET               |
          +---+---------+--+   +-------------------+-----------------+
       +------+         V                          |
       | DESCRIPTOR TABLE                          |
       |  +------------+                           |
       |  |            |                           |
       |  |            |                           |
       |  |            |                           |
       |  |            |                           |
       |  |------------|                           |
       |  |  SEGMENT   | BASE          +---+       |
       +->| DESCRIPTOR |-------------->| + |<------+
          |------------| ADDRESS       +-+-+
          |            |                 |
          +------------+                 |
                                         V
              LINEAR  +------------+-----------+--------------+
              ADDRESS |    DIR     |   PAGE    |    OFFSET    |
                      +------------+-----------+--------------+

怎么样? 是不是看上去很厉害的样子? 在进行进一步解释之前, 我们先来消除你心中最大的疑问: 为什么要把分段的过程搞得如此复杂? 还记得8086那个混沌的时代吗? 随着历史的发展, 8086暴露出了两个急需解决的问题:

  • 1MB内存容量的瓶颈
  • 恶意程序等安全问题

请记住, 分段过程搞得如此"复杂", 就是为了解决这两个历史遗留问题.

豁然开朗的视野

为了解决内存容量瓶颈, 80386已经使用了32位的CPU架构. 在新的分段机制里面, 我们自然也希望段的基地址是32位的, 同时也希望段的大小可以自由设定(而不像8086中是固定的64KB), 还希望能够设定粒度大小, 段类型等各种属性... 这无非是希望分段机制用起来可以更加灵活(例如给一个小程序分配一个很大的段是没有必要的), 而段寄存器只有16位, 连32位的基地址都放不下. 别着急, 这都是在80386的预料之中, 80386把一个段的各种属性放在一起, 组成一个段描述符(Segment Descriptor). 所谓段描述符, 就是用来描述一个段的属性的数据结构, 如果能有办法找到一个段描述符, 就可以找到相应的段了. 一个用于描述代码段和数据段的段描述符结构如下:

         DESCRIPTORS USED FOR APPLICATIONS CODE AND DATA SEGMENTS

  31                23                15                7               0
 +-----------------+-+-+-+-+---------+-+-----+-+-----+-+-----------------+
 |                 | | | |A|         | |     | |     | |                 |
 |   BASE 31..24   |G|X|O|V| LIMIT   |P| DPL |1| TYPE|A|  BASE 23..16    | 4
 |                 | | | |L| 19..16  | |     | |     | |                 |
 |-----------------+-+-+-+-+---------+-+-----+-+-----+-+-----------------|
 |                                   |                                   |
 |        SEGMENT BASE 15..0         |       SEGMENT LIMIT 15..0         | 0
 |                                   |                                   |
 +-----------------------------------+-----------------------------------+

           A      - ACCESSED
           AVL    - AVAILABLE FOR USE BY SYSTEMS PROGRAMMERS
           DPL    - DESCRIPTOR PRIVILEGE LEVEL
           G      - GRANULARITY
           P      - SEGMENT PRESENT

段描述符竟然有64位! 段寄存器根本放不下, 于是只好把它们放到内存里了. 那计算机要怎么找到内存中的一个段描述符呢? 聪明的你应该马上想到: 用指针! 但你没有觉得哪里不对吗?

  • 在80386里面, 指针都是32位的, 段寄存器还是放不下啊
  • 即使段寄存器能够放下一个32位的指针, 计算机想切换到其它段的时候, 怎么知道其它段描述符在哪里呢?

80386想出了一种同时解决这两个问题的方法, 那就是你经常使用的数组! 80386把内存中的某一段数据专门解释成一个数组, 名字叫GDT(Global Descriptor Table, 全局描述符表), 数组的一个元素就是一个段描述符. 这样一来就可以通过下标索引的方法来找到所有的段描述符啦. 于是在80386的世界里, 原来的段寄存器就用来存放段描述符的索引, 另外还包含了一些属性, 这样的一个结构叫段选择符(Selector)(更正: i386手册相应的图中位域标识有误, 实际上RPL占2bit, TI占1bit):

                         15                      3 2   0
                        +-------------------------+-+---+
                        |                         |T|   |
                        |           INDEX         | |RPL|
                        |                         |I|   |
                        +-------------------------+-+---+

                         TI  - TABLE INDICATOR
                         RPL - REQUESTOR'S PRIVILEGE LEVEL

GDT能有多大?

你能根据段选择符的结构, 计算出GDT最大能容纳多少个段描述符吗?

剩下的问题就是, 怎么找到这个GDT呢? 由于GDT是全局唯一的, 问题就很好解决了: 在80386中引入一个寄存器GDTR, 专门用来存放GDT的首地址和长度. 需要注意的是, 这个首地址是线性地址, 使用这个地址的时候不需要再次经过分段机制的地址转换. 最后80386和操作系统约定, 让操作系统事先把GDT准备好, 然后通过一条特殊的指令把GDT的首地址和长度装载到GDTR中, 计算机就可以开启上述的分段机制了.

为什么是线性地址?

GDTR中存放的GDT首地址可以是虚拟地址吗? 为什么?

事实上, 80386还允许每个进程拥有自己的描述符表, 称为LDT(Local Descriptor Table, 局部描述符表), 它的结构和GDT一模一样, 同样地也有一个LDTR来存放LDT的位置(实际上存放的是LDT段在GDT中的索引, 详细信息请查阅i386手册). 为了指示CPU在哪一个描述符表里面做索引, 在段选择符中有1个TI位专门来做这件事. 但由于现代操作系统弱化了分段的使用, 故通常不会使用LDT, 只使用GDT就足够了.

现在回去看那个好像很厉害的图, 你已经可以理解80386的分段机制了:

  1. 通过段寄存器中的段选择符TI位决定在哪个表中进行查找
  2. 根据GDTR或LDTR读出表的首地址
  3. 根据段寄存器中的段选择符的index位在表中进行索引, 找到一个段描述符
  4. 在段描述符中读出段的基地址, 和虚拟地址(也称逻辑地址)相加, 得出线性地址

至于在什么情况下使用哪一个段寄存器, 80386继承了8086中段寄存器捆绑约定, 具体内容请阅读上文, 或者查阅i386手册.

如何提高寻找段描述符的效率?

在上述4个步骤中, 如果段寄存器的内容没有改变, 前3个步骤的结果都是一样的. 注意到对GDT或LDT做索引是要访问内存的, 如果每次寻址都需要重复前3步, 就会产生很多不必要的内存访问. 你能想到有什么办法来避免这些不必要的内存访问吗? 请查阅i386手册, 对比一下你的想法和80386的实现是否一样.

耍一耍CPU

把一张图片的首地址和大小装载到GDTR(在NEMU中你确实可以这样做, 用 xxd 命令把一张图片的内容转化成一个数组, 然后修改kernel的代码, 把这个数组的首地址和大小装载到GDTR), 想象一下会发生什么? CPU会如何处理这个"GDT"? 为什么会这样?

绕了一大圈, 其实还是回到了 base + offset 的分段寻址方式上, 但对这个过程的理解让你看到了在真实的计算机上是如何进行最朴素的段式存储管理. 你不需要记住段描述符这些数据结构的细节(需要了解的时候可以查阅i386手册), 更重要的是从问题驱动的角度去理解"为什么要弄得这么复杂".

等级森严的制度

为了构建计算机和谐社会, 80386的前身80286就已经引入了保护模式(protected mode)和特权级(privilege level)的概念. 但由于80286并不能很好地兼容8086操作系统和程序, 因此80286并未得到广泛使用. 80386继续发扬保护模式的思想: 简单地说, 只有高特权级的进程才能去执行一些系统级别的指令(例如之前提到的cli指令等), 如果一个特权级低的进程尝试执行一条它没有权限执行的指令, CPU将会抛出一个异常. 一般来说, 最适合担任系统管理员的角色就是操作系统内核了, 它拥有最高的特权级, 可以执行所有指令; 而除非经过允许, 运行在操作系统上的用户进程一般都处于最低的特权级, 如果它试图破坏社会的和谐, 它将会被判"死刑".

在80386的新世界里, 存在0, 1, 2, 3四个特权级, 0特权级最高, 3特权级最低. 特权级n所能访问的资源, 在特权级0~n也能访问. 不同特权级之间的关系就形成了一个环: 内环可以访问外环的资源, 但外环不能进入内环的区域, 因此也有"ring n"的说法来描述一个进程所在的特权级.

              +---------------------------------------------------+
              | +-----------------------------------------------+ |
              | |                 APPLICATIONS                  | |
              | |     +-----------------------------------+     | |
              | |     |        CUSTOM EXTENSIONS          |     | |
              | |     |     +-----------------------+     |     | |
              | |     |     |    SYSTEM SERVICES    |     |     | |
              | |     |     |     +-----------+     |     |     | |
              | |     |     |     |  KERNAL   |     |     |     | |
              |-|-----+-----+-----+-----+-----+-----+-----+-----|-|
              | |     |     |     |     |LEVEL|LEVEL|LEVEL|LEVEL| |
              | |     |     |     |     |  0  |  1  |  2  |  3  | |
              | |     |     |     +-----+-----+     |     |     | |
              | |     |     |           |           |     |     | |
              | |     |     +-----------+-----------+     |     | |
              | |     |                 |                 |     | |
              | |     +-----------------+-----------------+     | |
              | |                       |                       | |
              | +-----------------------+-----------------------+ |
              +------------------------+ +------------------------+

虽然80386提供了4个特权级, 但大多数通用的操作系统只会使用0级和3级: 内核处在ring 0, 一般的程序处在ring 3, 这就已经起到保护的作用了. 那CPU是怎么判断一个进程是否执行了无权限操作呢? 在这之前, 我们还得了解一下80386中引入的与特权级相关的概念:

  • 在段描述符中含有一个DPL域(Descriptor Privilege Level), 它描述了一个段所在的特权级
  • 在段选择符中含有一个RPL域(Requestor's Privilege Level), 它描述了请求者所在的特权级
  • CPL(Current Privilege Level)指示当前进程的特权级, 一般来说它和当前CS寄存器所指向的段描述符(也就是当前进程的代码段)的DPL相等

80386会在段寄存器更新的时候(也就是切换到另一个段的时候)进行特权级的检查. 我们以数据段的切换为例:

          16-BIT VISIBLE
             SELECTOR            INVISIBLE DESCRIPTOR
        +---------------+-------------------+---+-----------+
     CS |               |                   |CPL|           |
        +---------------+-------------------+-+-+-----------+
                                              |
    TARGET SEGMENT SELECTOR                   |        +-----------+
 +-----------------------+-+---+              +------->| PRIVILEGE |
 |         INDEX         | |RPL|---------------------->| CHECK     |
 +-----------------------+-+---+              +------->| BY CPU    |
                                              |        +-----------+
     DATA SEGMENT DESCRIPTOR              +---+
                                          |
  31                23                15  |             7               0
 +-----------------+-+-+-+-+---------+-+--+--+---------+-----------------+
 |                 | | | |A| LIMIT   | |     |  TYPE   |                 |
 |   BASE 31..24   |G|B|0|V|         |P| DPL |         |   BASE 23..16   | 4
 |                 | | | |L|  19..16 | |     |1|0|E|W|A|                 |
 |-----------------+-+-+-+-+---------+-+-----+-+-+-+-+-+-----------------|
 |                                   |                                   |
 |        SEGMENT BASE 15..0         |        SEGMENT LIMIT 15..0        | 0
 |                                   |                                   |
 +-----------------------------------+-----------------------------------+

一次数据段的切换操作是合法的, 当且仅当

target_descriptor.DPL >= requestor.RPL        # <1>
target_descriptor.DPL >= current_process.CPL    # <2>

两式同时成立, 注意这里的 >= 是数值上的(numerically greater). <1>式表示请求者有权限访问目标段, <2>式表示当前进程也有权限访问目标段. 如果违反了上述其中一式, 此次操作将会被判定为非法操作, CPU将会抛出异常, 通知操作系统进行处理.

对RPL的补充

你可能会觉得RPL十分令人费解, 我们先举一个生活上的例子.

  • 假设你到银行找工作人员办理取款业务, 这时你就相当于requestor, 你的账户相当于target_descriptor, 工作人员相当于current_process. 业务办理成功是因为
    • 你有权限访问自己的账户( target_descriptor.DPL >= requestor.RPL )
    • 工作人员也有权限对你的账户进行操作( target_descriptor.DPL >= current_process.RPL )
  • 如果你想从别人的账户中取钱, 虽然工作人员有权限访问别人的账户( target_descriptor.DPL >= current_process.RPL ), 但是你却没有权限访问( target_descriptor.DPL < requestor.RPL ), 因此业务办理失败
  • 如果你打算亲自操作银行系统来取款, 虽然账户是你的( target_descriptor.DPL >= requestor.RPL ), 但是你却没有权限直接对你的账户金额进行操作( target_descriptor.DPL < current_process.RPL ), 因此你很有可能会被保安抓起来

在计算机中也存在类似的情况: 用户进程(requestor)想对它自己拥有的数据(位于target_descriptor所描述的段中)进行一些它没有权限的操作, 它就要请求有权限的进程(current_process, 通常是操作系统内核)来帮它完成这个操作, 于是就会出现"内核代表用户进程进行操作"的场景, 但在真正进行操作之前, 也要检查这些数据是不是真的是用户进程有权使用的数据.

通常情况下, 内核运行在ring 0, CPL为0, 因此有权限访问所有的段; 而用户进程运行在ring 3, CPL为3, 这就决定了它只能访问同样处在ring3的段. 这样, 只要操作系统内核将GDT, 以及下文将要提到的页表等重要的数据结构放在ring 0的段中, 恶意程序就永远没有办法访问到它们.

上述的规则只是针对切换数据段的行为, 在不同的场景下有不同的规则, 这里就不一一列举了, 需要了解的时候可以查阅i386手册. 可以看到在80386的保护模式下, 通过特权级的概念可以有效辨别出进程的非法操作, 让恶意程序无所遁形, 为构建计算机和谐社会作出了巨大的贡献.

遗憾的是, 根据KISS法则, 我们并不打算在NEMU中引入IA-32保护机制. 我们让所有用户进程都运行在ring 0, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 但我们最好在NEMU的代码中尽可能插入assertion, 以便及时捕捉一些本来应该由IA-32保护机制捕捉的错误(例如段描述符的present位为0).

在NEMU中实现分段机制

理解IA-32分段机制之后, 你需要在NEMU中实现它. 一方面, 你需要在kernel中加入切换到保护模式的代码, 你只需要在 kernel/include/common.h 中定义宏 IA32_SEG , 然后重新编译kernel就可以了. 重新编译后, kernel/src/start.S 的行为如下:

  1. 设置GDTR
  2. 将CR0的PE位置1, 切换到保护模式
  3. 使用ljmp设置CS寄存器
  4. 设置DS, ES, SS寄存器
  5. 为C代码设置堆栈
  6. 跳转到 init() 函数继续进行初始化工作

你需要根据IA-32分段机制理解上述代码. 另一方面, 你需要在NEMU中添加分段机制的功能, 以便让上述代码成功执行. 具体的, 你需要:

  • CPU_state 结构中添加GDTR, CR0和各种段寄存器, 包括CS, DS, ES, SS, 它们的具体结构请参考i386手册. 80386中还引入了两个新的段寄存器GS和FS, 不过我们不会用到它们, 因此可以不模拟它们的功能. LDT我们也不会用到, 和LDT相关的内容也不必模拟. 你还需要在 restart() 函数中对CR0寄存器进行初始化, 让我们模拟的计算机在"开机"的时候运行在"实模式"下.
  • 添加 lgdt 指令.
  • 添加 opcode0F 200F 22mov 指令, 使得我们可以设置/读出CR0. 设置CR0后, 如果发现CR0的PE位为1, 则进入IA-32保护模式, 从此所有虚拟地址的访问(包括 swaddr_read()swaddr_write() )都需要经过段级地址转换.
  • 为了实现段级地址转换, 你需要对 swaddr_read()swaddr_write() 函数作少量修改. 以 swaddr_read() 为例, 修改后如下:

    uint32_t swaddr_read(swaddr_t addr, size_t len, uint8_t sreg) { assert(len == 1 || len == 2 || len == 4); lnaddr_t lnaddr = seg_translate(addr, len, sreg); return lnaddr_read(lnaddr, len); }

    其中 sreg 记录了当前段级地址转换所用到的段寄存器的编码, 关于段寄存器的编码, 请查阅i386手册. 你需要理解段级地址转过的过程, 然后实现 seg_translate() 函数. 再次提醒, 在NEMU中, 只有进入保护模式之后才会进行段级地址转换.

  • 为了实现段寄存器的捆绑规则, 你还需要
    1. Operand 结构体中添加成员 sreg:
      --- nemu/include/cpu/decode/operand.h +++ nemu/include/cpu/decode/operand.h @@ -8,12 +8,15 @@ typedef struct { uint32_t type; size_t size; union {
      uint32_t reg;
      
  • swaddr_t addr;
  • struct {
  • swaddr_t addr;
  • uint8_t sreg;
  • }; uint32_t imm; int32_t simm; }; uint32_t val; char str[OP_STR_SIZE]; } Operand;
    </div></div>

    1. 修改 read_ModR_M() 中的代码, 以确定是和DS, SS中的哪一个进行捆绑, 然后设置 rm->sreg , 这样 swaddr_read()swaddr_write() 就可以使用正确的段寄存器了.
    2. 修改宏 MEM_W()MEM_R(), 以及所有调用 swaddr_read()swaddr_write() 的代码, 为它们添加段寄存器的参数. 特别地:
    3. opcode为 A0, A1, A2, A3mov 指令使用DS寄存器
    4. 一些堆栈操作指令会隐式使用SS寄存器
    5. instr_fetch() 总是使用CS寄存器
    6. 在monitor中, xp 命令读出内存时, 使用DS寄存器; bt 命令打印栈帧链时, 使用SS寄存器
    7. 关于字符串操作指令使用的段寄存器, 请查阅i386手册
  • 添加 opcode8Emov 指令, 使得我们可以设置段寄存器. 设置段寄存器时, 还需要将段的一些属性读入到段寄存器的描述符cache部分(在i386手册中被称为"隐藏部分", invisible part), 我们只需要读入段的base和limit就可以了, 其它属性在NEMU中不使用. 另外还有两点需要注意:
    1. GDTR中存放的GDT首地址是线性地址.
    2. IA-32中规定不能使用 mov 指令设置CS寄存器, 但切换到保护模式之后, 下一条指令的取指就要用到CS寄存器了. 解决这个问题的一种方式是在 restart() 函数中对CS寄存器的描述符cache部分进行初始化, 将base初始化为0, limit初始化为 0xffffffff 即可.
  • 为了设置CS寄存器, 你需要实现 ljmp 指令, 即 JMP ptr16:32 形式的jmp指令, 其作用是"Jump intersegment, 6-byte immediate address", 更多信息请查阅i386手册.

在NEMU中实现分段机制

根据上述的讲义内容, 在NEMU中模拟IA-32分段机制, 如有疑问, 请查阅i386手册. 在 lib-common/x86-inc 目录下的头文件中定义了一些和x86相关的宏和结构体, 你可以在NEMU中包含这些头文件来使用它们.

温馨提示

PA3阶段2到此结束.

results matching ""

    No results matching ""