时空之旅大揭秘
让我们通过 obj/testcase/hello-inline-asm
这个具体的例子, 亲自体验一趟神秘的旅程. 我们会跟随 hello-inline-asm
程序一同探索这一时空之旅, 当我们从时空之旅结束归来, hello-inline-asm
程序也已经将信息传达出去, 我们的第一个任务也就完成了.
添加传送门
在踏上旅程之前, 你还需要在NEMU中实现IA-32中断机制, 只有这样才能敲开神奇的传送门.
一方面, 你需要在kernel中加入相关的代码, 你只需要在 kernel/include/common.h
中定义宏 IA32_INTR
, 然后重新编译kernel就可以了. 重新编译后, kernel会在 init_cond()
函数中多进行两项初始化工作:
- 重新设置GDT.
- 设置IDT, 具体来说就是填写IDT中每一个门描述符, 设置完毕后通过
lidt
指令装载IDTR.
其它的工作和之前一样, 没有变化.
另一方面, 你需要在NEMU中添加中断机制的功能, 以便让上述代码成功执行. 具体的, 你需要:
- 添加IDTR和
lidt
指令, 注意IDTR中存放的IDT首地址是线性地址. - 添加如下的
raise_intr()
函数来模拟IA-32中断机制的处理过程:#include <setjmp.h> extern jmp_buf jbuf;
void raise_intr(uint8_t NO) { /* TODO: Trigger an interrupt/exception with ``NO''.
* That is, use ``NO'' to index the IDT.
*/
/* Jump back to cpu_exec() */
longjmp(jbuf, 1);
}
</div></div>
其中 longjmp()
的功能相当于跨越函数的goto语句, 执行 longjmp()
会跳转到 cpu_exec()
中 setjmp()
的下一条指令, 这样从 raise_intr()
中"返回"之后就会马上继续执行 cpu.eip
所指向的指令, 也就是异常处理入口指令.
- 添加
int
,iret
,cli
,pusha
,popa
等指令. 关于int
和iret
指令, 实验中不涉及特权级的切换, 查阅i386手册的时候你不需要关心和特权级切换相关的内容. 要注意的是- 执行
int
指令后保存的EIP
指向的是int
指令的下一条指令, 这有点像函数调用, 具体细节可以查阅i386手册. - 你需要在
int
指令的helper函数中调用raise_intr()
, 而不要把IA-32中断处理的代码放在int
指令的helper函数中实现, 因为在后面我们会再次用到raise_intr()
函数.
- 执行
你需要在NEMU中添加IA-32中断机制的支持, 如有疑问, 请查阅i386手册. 实现成功后在NEMU中运行 hello-inline-asm
程序, 你会看到在 kernel/src/irq/irq_handle.c
中的 irq_handle()
函数中触发了BAD TRAP. 这说明用户进程已经成功通过IA-32中断机制陷入内核, 你将会在下面的任务中修复"触发BAD TRAP"的问题.
你可能会认为没有必要在 raise_intr()
函数中使用 longjmp
, 直接一步一步返回到 cpu_exec()
中就可以了. 但如果需要实现处理器异常的识别和处理, longjmp
是最好的选择. 假设你打算在页级转换过程中, 当检测到页表项的present位为0时, 通过 raise_intr(14)
抛出缺页异常, 如果不使用 longjmp
, 你将如何让代码从 raise_intr()
返回到 cpu_exec()
? 如果还需要实现无效指令异常的处理, 你又会怎么办?
你可以通过
man setjmp
man longjmp
查阅 setjmp
和 longjmp
的相关信息. 思考一下, 如果让你实现 setjmp
和 longjmp
, 你将如何实现?
为什么要重新设置GDT? 尝试把 init_cond()
函数中的 init_segment()
注释掉, 编译后重新运行, 你会发现运行出错, 你知道为什么吗?
曲折的旅途
我们的第一次时空之旅被迫止于半路之中, 看来旅途并非我们想象中的那么顺利. 为了找到问题的原因, 我们需要仔细推敲途中的每一处细节.
用户进程执行 int $0x80
之后, CPU将会保存现场, 查阅kenrel设置好的IDT, 跳转到入口函数 vecsys()
, 压入错误码和异常号 #irq
, 跳转到 asm_do_irq
. 在 asm_do_irq
中, 代码将会把用户进程的通用寄存器保存到堆栈上, 这些寄存器的内容连同之前保存的错误码, #irq
, 以及硬件保存的EFLAGS, CS, EIP形成了trap frame(陷阱帧)的数据结构, 它记录了用户进程陷入内核时的状态, 注意到trap frame是在堆栈上构造的. 保存好用户进程的信息之后, kernel就可以随意使用通用寄存器了. 接下来代码将会把当前的 %esp
压栈, 并调用C函数 irq_handle()
.
do_irq.S
中有一行 pushl %esp
的代码, 乍看之下其行为十分诡异, 你能结合前后的代码理解它的行为吗? Hint: 不用想太多, 其实都是你学过的知识.
框架代码在 irq_handle()
函数的开始处设置了 panic()
, 你的任务是理解trap frame形成的过程, 然后重新组织 kernel/include/irq.h
中定义的 TrapFrame
结构体的成员, 使得这些成员声明的顺序和 kernel/src/irq/do_irq.S
中构造的trap frame保持一致. 实现正确之后, 去掉上述的 panic()
, irq_handle()
以及后续代码就可以正确地使用trap frame了. 重新运行 hello-inline-asm
程序, 你会看到在 kernel/src/syscall/do_syscall.c
中的 do_syscall()
函数中触发了BAD TRAP.
irq_handle()
将会根据 #irq
确定异常事件的类型, 从而进行不同的处理. 用户进程执行的 int $0x80
会被识别成系统调用请求, 于是kernel会调用 do_syscall()
对相应的请求进行处理. 由于用户进程的所有现场信息都已经保存在trap frame中了, kernel很容易获取它们, do_syscall()
将根据用户进程之前设置好的系统调用号和系统调用参数进行处理. 我们找到了第二次触发BAD TRAP的原因: kernel并没有实现 SYS_write
系统调用的处理. 为了再次踏上旅程, 你需要在 do_syscall()
中添加 SYS_write
的系统调用.
添加一个系统调用比你想象中要简单, 所有信息都已经准备好了. 根据 write
系统调用的函数声明(参考 man 2 write
), 在 do_syscall()
中识别出系统调用号是 SYS_write
之后, 检查 fd
的值, 如果 fd
是 1
或 2
(分别代表 stdout
和 stderr
), 则将 buf
为首地址的 len
字节输出到屏幕上, 你需要思考如何从trap frame中获取这些信息.
现在又回到了最根本的问题了: 要怎么才能把内容输出到屏幕上? 在真实的计算机中, 这些内容最终是由设备来负责输出的, 但NEMU中还没有添加设备, 所以我们还是先借助NEMU的功能来完成输出吧. 我们使用以下内联汇编来"陷入"到NEMU中:
这正是我们在NEMU中手工加入的 nemu_trap
指令. 接下来修改 nemu/cpu/src/exec/special/special.c
中 nemu_trap()
的helper函数, 如果 %eax
为2, 则输出 %ecx
为首地址的 %edx
字节的内容. 在NEMU中输出就是一件易如反掌的事情了, 不过需要注意, %ecx
表示的地址是虚拟地址, 聪明的你知道应该怎么做了吧.
回到kernel中, 假设执行完上述的 nemu_trap
指令后, "输出"成功了, 处理系统调用的最后一件事就是设置系统调用的返回值. GNU/Linux约定系统调用的返回值存放在 %eax
中, 所以我们只需要修改 tf->eax
的值就可以了. 至于 write
系统调用的返回值是什么, 请查阅 man 2 write
.
系统调用处理结束后, 代码将会一路返回到 do_irq.S
的 asm_do_irq()
中. 接下来的事情就是恢复用户进程的现场, kernel将根据之前保存的trap frame中的内容, 恢复用户进程的通用寄存器(注意trap frame中的 %eax
已经被设置成系统调用的返回值了), 并直接弹出一些不再需要的信息, 最后通过 iret
指令恢复用户进程的EIP, CS, EFLAGS. 用户进程可以通过 %eax
寄存器获得系统调用的返回值, 进而得知系统调用执行的结果.
这样, 一次系统调用就执行完了, 时空之旅圆满结束.
你需要在kernel中添加 SYS_write
系统调用, 让 hello-inline-asm
程序成功输出"Hello, world!"的信息.
运行hello程序
实现 SYS_write
系统调用之后, 我们已经为"使用 printf()
"扫除了最大的障碍了, 因为 printf()
进行字符串格式化之后, 最终会通过 write
系统调用进行输出. 至于格式化的功能, newlib已经为我们实现好了.
我们这次的任务是在NEMU中执行 obj/testcase/hello
程序. 查看其源代码 testcase/src/hello.c
, 除了使用 printf()
输出之外, 源文件中还加入了一些系统调用函数的代码, 这是因为newlib进行链接的时候会用到它们. 但执行流不会到达其中的某些函数, 为了避免疏忽, 我们在那些不会执行到的函数中插入 nemu_assert(0)
. SYS_brk
系统调用用于调整用户进程堆区的大小, kernel中已经实现了这个系统调用了. 另外 syscall()
是一个陷入内核的接口函数, 它会设置好正确的系统调用参数, 然后执行 int $0x80
陷入内核. 我们用到的系统调用的参数不会超过4个, 上述 syscall()
的实现已经能满足我们的需求了.
hello
程序编译通过之后, 你就可以在NEMU中运行它了. 不过在运行过程中, 你会发现又遇到了调皮的浮点指令, 你可以按照PA2中黑客小练习的方式去掉它们.
在NEMU中运行 obj/testcase/hello
程序.
PA4阶段1到此结束.