ICS2017 Programming Assignment
  • Introduction
  • PA0 - 世界诞生的前夜: 开发环境配置
    • Installing a GNU/Linux VM
    • First Exploration with GNU/Linux
    • Installing Tools
    • Configuring vim
    • More Exploration
    • Transferring Files between host and container
    • Acquiring Source Code for PAs
  • PA1 - 开天辟地的篇章: 最简单的计算机
    • 在开始愉快的PA之旅之前
    • 开天辟地的篇章
    • RTFSC
    • 基础设施
    • 表达式求值
    • 监视点
    • i386手册
  • PA2 - 简单复杂的机器: 冯诺依曼计算机系统
    • 不停计算的机器
    • RTFSC(2)
    • 程序, 运行时环境与AM
    • 基础设施(2)
    • 输入输出
  • PA3 - 穿越时空的旅程: 异常控制流
    • 更方便的运行时环境
    • 等级森严的制度
    • 穿越时空的旅程
    • 文件系统
    • 一切皆文件
  • PA4 - 虚实交错的魔法: 分时多任务
    • 虚实交错的魔法
    • 超越容量的界限
    • 分时多任务
    • 来自外部的声音
    • 编写不朽的传奇
  • PA5 - 从一到无穷大: 程序与性能
    • 浮点数的支持
    • 通往高速的次元
    • 天下武功唯快不破
  • 杂项
    • 为什么要学习计算机系统基础
    • 实验提交要求
    • Linux入门教程
    • man入门教程
    • git入门教程
    • i386手册指令集阅读指南
    • i386手册勘误
    • 指令执行例子
Powered by GitBook
On this page

Was this helpful?

  1. PA3 - 穿越时空的旅程: 异常控制流

等级森严的制度

我们在dummy程序中碰到了一条看似奇怪的int指令. 为了解释它, 我们还需要了解它背后折射出来的计算机和谐社会的故事.

为了构建计算机和谐社会, i386强化了保护模式(protected mode)和特权级(privilege level)的概念: 简单地说, 只有高特权级的进程才能去执行一些系统级别的操作, 如果一个特权级低的进程尝试执行它没有权限执行的操作, CPU将会抛出一个异常. 一般来说, 最适合担任系统管理员的角色就是操作系统了, 它拥有最高的特权级, 可以执行所有操作; 而除非经过允许, 运行在操作系统上的用户进程一般都处于最低的特权级, 如果它试图破坏社会的和谐, 它将会被判"死刑".

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

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

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

  • DPL(Descriptor Privilege Level)属性描述了一段数据所在的特权级

  • RPL(Requestor's Privilege Level)属性描述了请求者所在的特权级

  • CPL(Current Privilege Level)属性描述了当前进程的特权级,

一次数据的访问操作是合法的, 当且仅当

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

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

通常情况下, 操作系统运行在ring 0, CPL为0, 因此有权限访问所有的数据; 而用户进程运行在ring 3, CPL为3, 这就决定了它只能访问同样处在ring3的数据. 这样, 只要操作系统将其私有数据放在ring 0中, 恶意程序就永远没有办法访问到它们. 这些保护相关的概念和检查过程都是通过硬件实现的, 只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退, 为构建计算机和谐社会作出了巨大的贡献.

这是多美妙的功能! 遗憾的是, 上面提到的很多概念其实只是一带而过, 真正的保护机制也还需要考虑更多的细节. i386手册中专门有一章来描述保护机制, 就已经看出来这并不是简单说说而已. 根据KISS法则, 我们并不打算在NEMU中加入保护机制. 我们让所有用户进程都运行在ring 0, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 毕竟, 我们也已经从上面的故事中体会到保护机制的本质了: 在硬件中加入一些与特权级检查相关的门电路, 如果发现了非法操作, 就会抛出一个异常, 让CPU跳转到一个固定的地方, 并进行后续处理.

操作系统的义务

既然操作系统位于ring 0享受着至高无上的权利, 自然地它也需要履行相应的义务, 那就是: 管理系统中的所有资源, 为用户进程提供相应的服务. 举一个银行的例子, 如果银行连最基本的取款业务都不能办理, 是没有客户愿意光顾它的. 但同时银行也不能允许客户亲自到金库里取款, 而是需要客户按照规定的手续来办理取款业务. 同样地, 操作系统并不允许用户进程直接操作显示器硬件进行输出, 否则恶意程序就很容易往显示器中写入恶意数据, 让屏幕保持黑屏, 影响其它进程的使用. 因此, 用户进程想输出一句话, 也要经过一定的合法手续向操作系统进行申请, 这一合法手续就是系统调用.

我们到银行办理业务的时候, 需要告诉工作人员要办理什么业务, 账号是什么, 交易金额是多少, 这无非是希望工作人员知道我们具体想做什么. 用户进程执行系统调用的时候也是类似的情况, 要通过一种方法描述自己的需求, 然后告诉操作系统. 用来描述需求最方便的手段就是使用通用寄存器了, 用户进程将系统调用的参数依次放入各个寄存器中(第一个参数放在%eax中, 第二个参数放在%ebx中...). 为了让操作系统注意到用户进程提交的申请, 系统调用通常都会触发一个异常, 然后陷入操作系统. 在GNU/Linux中, 系统调用产生的异常通过int $0x80指令触发. 这个异常和上文提到的非法操作产生的异常不同, 操作系统能够识别它是由系统调用产生的.

Navy-apps已经为用户程序准备好了系统调用的接口了. navy-apps/libs/libos/src/nanos.c中定义的_syscall_()函数已经蕴含着上述过程:

int _syscall_(int type, uintptr_t a0, uintptr_t a1, uintptr_t a2) {
  int ret;
  asm volatile("int $0x80": "=a"(ret): "a"(type), "b"(a0), "c"(a1), "d"(a2));
  return ret;
}

上述内联汇编会先把系统调用的参数依次放入%eax, %ebx, %ecx, %edx四个寄存器中, 然后执行int $0x80手动触发一个特殊的异常. 操作系统捕获这个异常之后, 发现是一个系统调用, 就会调出相应的处理函数进行处理, 处理结束后设置好返回值, 然后返回到上述的内敛汇编中. 内联汇编最后从%eax寄存器中取出系统调用的返回值, 并返回给调用该接口的函数, 告知其系统调用执行的情况(如是否成功等).

我们可以在GNU/Linux下编写一个程序, 来手工触发一次write系统调用:

const char str[] = "Hello world!\n";

int main() {
  asm volatile ("movl $4, %eax;"      // system call ID, 4 = SYS_write
                "movl $1, %ebx;"      // file descriptor, 1 = stdout
                "movl $str, %ecx;"    // buffer address
                "movl $13, %edx;"      // length
                "int $0x80");

  return 0;
}

如果你在64位操作系统上运行它, 你需要在编译的时候加入-m32参数来生成32位的代码. 用户进程执行上述代码, 就相当于告诉操作系统: 帮我把从str开始的13字节写到1号文件中去. 其中"写到1号文件中去"的功能相当于输出到屏幕上.

虽然操作系统需要为用户进程服务, 但这并不意味着操作系统需要把所有信息都暴露给用户程序. 有些信息是用户进程没有必要知道的, 也永远不应该知道, 例如一些与内存管理相关的数据结构. 如果一个恶意程序获得了这些信息, 可能会为恶意攻击提供了信息基础. 因此, 通常不存在一个系统调用来获取这些操作系统的私有数据.

Previous更方便的运行时环境Next穿越时空的旅程

Last updated 6 years ago

Was this helpful?