满足合理的需求

在之前的PA中, 用户进程都只能安分守己地在运行在NEMU上, 除了"计算"之外, 什么都做不了. 但像"输出一句话"这种合理的需求, 内核必须想办法满足它. 举一个银行的例子, 如果银行连最基本的取款业务都不能办理, 是没有客户愿意光顾它的. 但同时银行也不能允许客户亲自到金库里取款, 而是需要客户按照规定的手续来办理取款业务. 同样地, 操作系统并不允许用户进程直接操作显示器硬件进行输出, 否则恶意程序就很容易往显示器中写入恶意数据, 让屏幕保持黑屏, 影响其它进程的使用. 因此, 用户进程想输出一句话, 也要经过一定的合法手续, 这一合法手续就是系统调用.

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

我们可以在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 $14, %edx;" // length "int $0x80"); return 0; }

用户进程执行上述代码, 就相当于告诉内核: 帮我把从 str 开始的14字节写到1号文件中去. 其中"写到1号文件中去"的功能相当于输出到屏幕上.

虽然操作系统需要为用户进程服务, 但这并不意味着操作系统需要把所有信息都暴露给用户程序. 有些信息是用户进程没有必要知道的, 也永远不应该知道, 例如GDT, 页表. 因此, 通常不存在一个系统调用用来获取GDT这些操作系统私有的信息.

事实上, 你平时使用的 printf , cout 这些库函数和库类, 对字符串进行格式化之后, 最终也是通过系统调用进行输出. 这些都是"系统调用封装成库函数"的例子. 系统调用本身对操作系统的各种资源进行了抽象, 但为了给上层的程序员提供更好的接口(beautiful interface), 库函数会再次对部分系统调用再次进行抽象. 例如 fopen 这个库函数用于创建并打开一个新文件, 或者打开一个已有的文件, 在GNU/Linux中, 它封装了 open 系统调用. 另一方面, 系统调用依赖于具体的操作系统, 因此库函数的封装也提高了程序的可移植性, 在windows中, fopen 封装了 CreateFile 系统调用, 如果在代码中直接使用 CreateFile 系统调用, 把代码放到GNU/Linux下编译就会产生链接错误, 即使链接成功, 程序运行的时候也会产生运行时错误.

并不是所有的库函数都封装了系统调用, 例如 strcpy 这类字符串处理函数就不需要使用系统调用. 从某种程度上来说, 库函数的抽象确实方便了程序员, 使得他们不必关心系统调用的细节.

穿越时空的旅程

异常是指CPU在执行过程中检测到的不正常事件, 例如除数为零, 无效指令, 缺页等. IA-32还向软件提供 int 指令, 让软件可以手动产生异常, 因此上文提到的系统调用也算是一种异常. 那触发异常之后都发生了些什么呢? 我们先来对这一场神秘的时空之旅作一些简单的描述.

为了方便叙述, 我们称触发异常之前用户进程的状态为A. 触发异常之后, CPU将会陷入内核, 跳转到操作系统事先设置好的异常处理代码, 处理结束之后再恢复A的执行. 可以看到, A的执行流程被打断了, 为了以后能够完美地恢复到被打断时的状态, CPU在处理异常之前应该先把A的状态保存起来, 等到异常处理结束之后, 根据之前保存的信息把计算机恢复到被打断之前的状态.

哪些内容表征了A的状态? 在IA-32中, 首先当然是EIP(instruction pointer)了, 它指示了A在被打断的时候正在执行的指令(或者下一条指令); 然后就是EFLAGS(各种标志位)和CS(代码段, CPL). 由于一些特殊的原因, 这三个寄存器的内容必须由硬件来保存. 此外, 通用寄存器(GPR, general propose register)的值对A来说还是有意义的, 而进行异常处理的时候又难免会使用到寄存器. 但硬件并不负责保存它们, 因此需要操作系统来保存它们的值.

异常与函数调用

我们知道进行函数调用的时候也需要保存调用者的状态: 返回地址, 而进行异常处理之前却要保存更多的信息. 尝试对比它们, 并思考两者保存信息不同是什么原因造成的.

要将这些信息保存到哪里去呢? 一个合适的地方就是进程的堆栈. 触发异常时, 硬件会自动将EFLAGS, CS, EIP三个寄存器的值保存到堆栈上. 此外, IA-32提供了 pusha / popa 指令, 用于把通用寄存器的值压入/弹出堆栈, 但你需要注意压入的顺序, 更多信息请查阅i386手册.

等到异常处理结束之后, CPU将会根据堆栈上保存的信息恢复A的状态, 最后执行 iret 指令. iret 指令用于从中断或异常处理代码中返回, 它将栈顶的三个元素来依次解释成EIP, CS, EFLAGS, 并恢复它们. 这样用户进程就可以从A开始继续运行了, 在它看来, 这次时空之旅就好像没有发生过一样.

神奇的传送门

我们在上文提到, 用户进程触发异常之后将会陷入内核. "陷入内核"究竟是怎么样的一个过程? 具体要陷入到什么地方去? 要回答这些问题, 我们首先要认识IA-32中断机制.

在IA-32中, 异常事件的入口地址是通过门描述符(Gate Descriptor)来指示的. 门描述符有3种:

  • 中断门(Interrupt Gate)
  • 陷阱门(Trap Gate)
  • 任务门(Task Gate)

关于中断会在下文作进一步的解释, 而任务门在实验中不会用到. 中断门和陷阱门的结构如下:

                                80386 INTERRUPT GATE
   31                23                15                7                0
  +-----------------+-----------------+---+---+---------+-----+-----------+
  |           OFFSET 31..16           | P |DPL|0 1 1 1 0|0 0 0|(NOT USED) |4
  +-----------------------------------+---+---+---------+-----+-----------+
  |             SELECTOR              |           OFFSET 15..0            |0
  +-----------------+-----------------+-----------------+-----------------+

                                80386 TRAP GATE
   31                23                15                7                0
  +-----------------+-----------------+---+---+---------+-----+-----------+
  |          OFFSET 31..16            | P |DPL|0 1 1 1 1|0 0 0|(NOT USED) |4
  +-----------------------------------+---+---+---------+-----+-----------+
  |             SELECTOR              |           OFFSET 15..0            |0
  +-----------------+-----------------+-----------------+-----------------+

由于IA-32分段机制的存在, 我们必须通过段和段内偏移来表示跳转入口. 因此在中断门和陷阱门中, selector域用于指示目标段的段描述符, offset域用于指示跳转目标在段内的偏移. 这样, 如果能找到一个门描述符, 就可以根据门描述符中的信息计算出跳转目标了.

和分段机制类似, 为了方便管理各个门描述符, IA-32把内存中的某一段数据专门解释成一个数组, 叫IDT(Interrupt Descriptor Table, 中断描述符表), 数组的一个元素就是一个门描述符. 为了找到一个门描述符, 我们还需要一个索引. 对于CPU异常来说, 这个索引由CPU内部产生(例如缺页异常为14号异常), 或者由 int 指令给出(例如 int $0x80 ). 最后, 为了找到IDT, IA-32中使用IDTR寄存器来存放IDT的首地址和长度. 操作系统需要事先把IDT准备好, 然后通过一条特殊的指令把IDT的首地址和长度装载到IDTR中, IA-32中断处理机制就可以正常工作了.

现在是万事俱备, 等到异常的东风一刮, CPU就会按照设定好的IDT跳转到目标地址:

                  IDT                                    EXECUTABLE SEGMENT
           +---------------+                             +---------------+
           |               |                       OFFSET|               |
           |---------------|  +------------------------->| ENTRY POINT   |
           |               |  |      LDT OR GDT          |               |
           |---------------|  |   +---------------+      |               |
           |               |  |   |               |      |               |
INTERRUPT  |---------------|  |   |---------------|      |               |
   ID----->| TRAP GATE OR  |--+   |               |      |               |
           |INTERRUPT GATE |--+   |---------------|      |               |
           |---------------|  |   |               |      |               |
           |               |  |   |---------------|      |               |
           |---------------|  +-->|   SEGMENT     |-+    |               |
           |               |      |  DESCRIPTOR   | |    |               |
           |---------------|      |---------------| |    |               |
           |               |      |               | |    |               |
           |---------------|      |---------------| |    |               |
           |               |      |               | |BASE|               |
           +---------------+      |---------------| +--->+---------------+
                                  |               |
                                  |               |
                                  |               |
                                  +---------------+
  1. 依次将EFLAGS, CS, EIP寄存器的值压入堆栈
  2. 从IDTR中读出IDT的首地址
  3. 根据异常(中断)号在IDT中进行索引, 找到一个门描述符
  4. 把门描述符中的selector域装入CS寄存器
  5. 根据CS寄存器中的段选择符, 在GDT或LDT中进行索引, 找到一个段描述符, 并把这个段的一些信息加载到CS寄存器的描述符cache中
  6. 在段描述符中读出段的基地址, 和门描述符中的offset域相加, 得出入口地址
  7. 跳转到入口地址

需要注意的是, 这些工作都是硬件自动完成的, 不需要程序员编写指令来完成相应的内容. 事实上, 这只是一个大概的过程, 在真实的计算机上还会遇到很多特殊情况, 在这里我们就不深究了. i386手册中还记录了处理器对中断号和异常号的分配情况, 并列出了各种异常的详细解释, 需要了解的时候可以进行查阅.

特殊的原因

我们在上一小节中提到: "由于一些特殊的原因, 这三个寄存器的内容必须由硬件来保存". 究竟是什么特殊的原因, 使得EFLAGS, CS, EIP三个寄存器的值必须由硬件来保存? 尝试结合IA-32中断机制思考这个问题.

在计算机和谐社会中, 大部分神奇的传送门都不能让用户进程随意使用, 否则恶意程序就可以通过 int 指令欺骗操作系统. 例如恶意程序执行 int $0x2 来谎报电源掉电, 扰乱其它进程的正常运行. 因此执行 int 指令需要进行特权级检查, 门描述符中的DPL域将会参与到特权级检查的过程中, 具体的检查规则我们就不展开讨论了, 需要了解的时候请查阅i386手册.

results matching ""

    No results matching ""