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
  • 穿越时空的旅程
  • 加入ASYE
  • 准备IDT
  • 触发异常
  • 保存现场
  • 事件分发
  • 系统调用处理
  • 恢复现场

Was this helpful?

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

穿越时空的旅程

穿越时空的旅程

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

我们之前提到, CPU检测到异常之后, 就会跳转到一个地方, 这个过程是由位于硬件层次i386中断机制支撑的. 在i386中, 上述跳转的目标通过门描述符(Gate Descriptor)来指示. 门描述符是一个8字节的结构体, 里面包含着不少细节的信息, 我们在NEMU中简化了门描述符的结构, 只保留存在位P和偏移量OFFSET:

   31                23                15                7                0
  +-----------------+-----------------+---+-------------------------------+
  |           OFFSET 31..16           | P |          Don't care           |4
  +-----------------------------------+---+-------------------------------+
  |             Don't care            |           OFFSET 15..0            |0
  +-----------------+-----------------+-----------------+-----------------+

P位来用表示这一个门描述符是否有效, OFFSET用来指示跳转目标.

为了方便管理各个门描述符, i386把内存中的某一段数据专门解释成一个数组, 叫IDT(Interrupt Descriptor Table, 中断描述符表), 数组的一个元素就是一个门描述符. 为了从数组中找到一个门描述符, 我们还需要一个索引. 对于CPU异常来说, 这个索引由CPU内部产生(例如除零异常为0号异常), 或者由int指令给出(例如int $0x80). 最后, 为了在内存中找到IDT, i386使用IDTR寄存器来存放IDT的首地址和长度. 我们需要通过软件代码事先把IDT准备好, 然后通过一条特殊的指令lidt在IDTR中设置好IDT的首地址和长度, 这一中断处理机制就可以正常工作了. 现在是万事俱备, 等到异常的东风一刮, CPU就会按照设定好的IDT跳转到目标地址:

           |               |
           |   Entry Point |<----+
           |               |     |
           |               |     |
           |               |     |
           +---------------+     |
           |               |     |
           |               |     |
           |               |     |
           +---------------+     |
           |offset |       |     |
           |-------+-------|     |
Exception  |       | offset|-----+
   ID----->+---------------+
           |               |
           |Gate Descriptor|
           |               |
 IDT------>+---------------+
           |               |
           |               |

但感觉有什么不太对劲? 异常处理结束之后, 我们要怎么返回异常之前的状态呢? 为了方便叙述, 我们称触发异常之前状态为S. 为了以后能够完美地恢复到S, 在开始真正的处理异常之前应该先把S保存起来, 等到异常处理结束之后, 才能根据之前保存的信息把计算机恢复到S的样子. 哪些内容表征了S? 首先当然是EIP了, 它指示了S正在执行的指令(或者下一条指令); 然后就是EFLAGS(各种标志位)和CS(代码段寄存器, 里面包含CPL的信息). 由于一些特殊的原因, 这三个寄存器的内容必须由硬件来保存. 要将这些信息保存到哪里去呢? 一个合适的地方就是进程的堆栈. 触发异常时, 硬件会自动将EFLAGS, CS, EIP三个寄存器的值保存到堆栈上.

于是, 触发异常后硬件的处理如下: 1. 依次将EFLAGS, CS, EIP寄存器的值压入堆栈 1. 从IDTR中读出IDT的首地址 1. 根据异常(中断)号在IDT中进行索引, 找到一个门描述符 1. 将门描述符中的offset域组合成目标地址 1. 跳转到目标地址

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

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

加入ASYE

在AM的模型中, 异常处理的能力被划分到ASYE模块中. 老规矩, 我们还是分别从NEMU和AM两个角度来体会硬件和软件如何相互协助来支持ASYE的功能.

准备IDT

首先是要准备一个有意义的IDT, 这样以后触发异常时才能跳转到正确的目标地址. 具体的, 你需要在NEMU中添加IDTR寄存器和lidt指令. 然后在nanos-lite/src/main.c中定义宏HAS_ASYE, 这样以后, Nanos-lite会多进行一项初始化工作: 调用init_irq()函数, 这最终会调用位于nexus-am/am/arch/x86-nemu/src/asye.c中的_asye_init()函数. _asye_init()函数会做两件事情, 第一件就是初始化IDT: 1. 代码定义了一个结构体数组idt, 它的每一项是一个门描述符结构体 1. 在相应的数组元素中填写有意义的门描述符, 例如编号为0x80的门描述符就是将来系统调用的入口地址. 需要注意的是, 框架代码中还是填写了完整的门描述符(包括上文中提到的don't care的域), 这主要是为了在QEMU中进行differential testing时也能跳转到正确的入口地址. QEMU实现了完整的中断机制, 如果只填写简化版的门描述符, 就无法在QEMU中正确运行. 但我们无需了解其中的细节, 只需要知道代码已经填写了正确的门描述符即可. 1. 在IDTR中设置idt的首地址和长度

_asye_init()函数做的第二件事是注册一个事件处理函数, 这个事件处理函数由_asyn_init()的调用者提供. 关于事件处理函数, 我们会在下文进行更多的介绍.

触发异常

为了测试是否已经成功准备IDT, 我们还需要真正触发一次异常, 看是否正确地跳转到目标地址. 具体的, 你需要在NEMU中实现raise_intr()函数(在nemu/src/cpu/intr.c中定义) 来模拟上文提到的i386中断机制的处理过程:

void raise_intr(uint8_t NO, vaddr_t save_addr) {
  /* TODO: Trigger an interrupt/exception with ``NO''.
   * That is, use ``NO'' to index the IDT.
   */

}

需要注意的是:

  • PA不涉及特权级的切换, 查阅i386手册的时候你不需要关心和特权级切换相关的内容.

  • 通过IDTR中的地址对IDT进行索引的时候, 需要使用vaddr_read().

  • PA中不实现分段机制, 没有CS寄存器的概念.

    但为了在QEMU中顺利进行differential testing, 我们还是需要在cpu结构体中添加一个CS寄存器,

    并在restart()函数中将其初始化为8.

  • 由于中断机制需要对EFLAGS进行压栈, 为了配合differential testing,

    我们还需要在restart()函数中将EFLAGS初始化为0x2.

  • 执行int指令后保存的EIP指向的是int指令的下一条指令, 这有点像函数调用, 具体细节可以查阅i386手册.

  • 你需要在int指令的helper函数中调用raise_intr(),

    而不要把中断机制的代码放在int指令的helper函数中实现,

    因为在后面我们会再次用到raise_intr()函数.

保存现场

成功跳转到入口函数vecsys()之后, 我们就要在软件上开始真正的异常处理过程了. 但是, 进行异常处理的时候不可避免地需要用到通用寄存器, 然而看看现在的通用寄存器, 里面存放的都是异常触发之前的内容. 这些内容也是现场的一部分, 如果不保存就覆盖它们, 将来就无法恢复异常触发之前的状态了. 但硬件并不负责保存它们, 因此需要通过软件代码来保存它们的值. i386提供了pusha指令, 用于把通用寄存器的值压入堆栈.

vecsys()会压入错误码和异常号#irq, 然后跳转到asm_trap(). 在asm_trap()中, 代码将会把用户进程的通用寄存器保存到堆栈上. 这些寄存器的内容连同之前保存的错误码, #irq, 以及硬件保存的EFLAGS, CS, EIP, 形成了trap frame(陷阱帧)的数据结构. 我们知道栈帧记录了函数调用时的状态, 而相应地, 陷阱帧则完整记录了用户进程触发异常时现场的状态, 将来恢复现场就靠它了.

注意到trap frame是在堆栈上构造的. 接下来代码将会把当前的%esp压栈, 并调用C函数irq_handle()(在nexus-am/am/arch/x86-nemu/src/asye.c中定义).

事件分发

irq_handle()的代码会把异常封装成事件, 然后调用在_asye_init()中注册的事件处理函数, 将事件交给它来处理. 在Nanos-lite中, 这一事件处理函数是nanos-lite/src/irq.c中的do_event()函数. do_event()函数会根据事件类型再次进行分发. 我们刚才触发了一个未处理的8号事件, 这其实是一个系统调用事件_EVENT_SYSCALL(在nexus-am/am/am.h中定义). 在识别出系统调用事件后, 需要调用do_syscall()(在nanos-lite/src/syscall.c中定义)进行处理.

系统调用处理

我们终于正式进入系统调用的处理函数中了. do_syscall()首先通过宏SYSCALL_ARG1()从现场r中获取用户进程之前设置好的系统调用参数, 通过第一个参数 - 系统调用号 - 进行分发. 但目前Nanos-lite没有实现任何系统调用, 因此触发了panic.

添加一个系统调用比你想象中要简单, 所有信息都已经准备好了. 我们只需要在分发的过程中添加相应的系统调用号, 并编写相应的系统调用处理函数sys_xxx(), 然后调用它即可.

回过头来看dummy程序, 它触发了一个号码为0的SYS_none系统调用. 我们约定, 这个系统调用什么都不用做, 直接返回1.

处理系统调用的最后一件事就是设置系统调用的返回值. 我们约定系统调用的返回值存放在系统调用号所在的寄存器中, 所以我们只需要通过SYSCALL_ARG1()来进行设置就可以了.

恢复现场

系统调用处理结束后, 代码将会一路返回到trap.S的asm_trap()中. 接下来的事情就是恢复用户进程的现场. asm_trap()将根据之前保存的trap frame中的内容, 恢复用户进程的通用寄存器 (注意trap frame中的%eax已经被设置成系统调用的返回值了), 并直接弹出一些不再需要的信息, 最后执行iret指令. iret指令用于从异常处理代码中返回, 它将栈顶的三个元素来依次解释成EIP, CS, EFLAGS, 并恢复它们. 用户进程可以通过%eax寄存器获得系统调用的返回值, 进而得知系统调用执行的结果. 在它看来, 这次时空之旅就好像没有发生过一样.

需要提醒的是, ASYE还有其它的API, 但我们暂时不会用到, 现在可以先忽略它们.

Previous等级森严的制度Next文件系统

Last updated 6 years ago

Was this helpful?