等级森严的制度

我们在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). <1>式表示请求者有权限访问目标数据, <2>式表示当前进程也有权限访问目标数据. 如果违反了上述其中一式, 此次操作将会被判定为非法操作, CPU将会抛出异常, 跳转到一个约定好的代码位置, 然后通知操作系统进行处理.

对RPL的补充

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

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

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

通常情况下, 操作系统运行在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号文件中去"的功能相当于输出到屏幕上.

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

results matching ""

    No results matching ""