操作系统学习 ucore lab1


ucore是一个清华大学出品的教学用操作系统。其以MIT的xv6&jos,harvard的os161,和linux为蓝本。

它循序渐进,适合操作系统的初学者,当然需要对c语言,计算机原理有一定的了解。

其源代码可以在https://github.com/chyyuu/ucore_lab上找到。clone下来是分门别类好的代码,答案,以及需要参考的文档。

在开始做题之前,还需要一些准备工作。

由于整个lab依托于gcc和qemu,所以只能在linux环境下进行。而且在开始之前,还需要先安装gcc和qemu。

我个人使用的是ubuntu 14.04,apt安装的方法:

sudo apt-get install build-essential

sudo apt-get install qemu-system


某些同学安装的linux虚拟机的版本比较低,源当中的qemu版本也较低,调试的时候可能出现不能够设置断点的BUG。

亲测2.0版本以上就没问题可以用qemu-system-i386 --version来查看自己的qemu的版本。如果版本有问题的话……我也不知道该怎么更新源来获得正确的版本。直接去下载源代码make吧,具体最好找找教程,需要不少依赖包,虽然可以直接通过错误提示逐个安装,毕竟不如一次性搞定方便。


对于ucore,在下载来的代码的/labcode/lab1目录下,可以找到一个Makefile文件。我们的操作都围绕这个文件来进行。

该Makefile当中提供了如下命令:

make    最朴素的编译,生成ucore.img,即操作系统镜像。

make V=   编译并输出编译过程的详细信息

make qemu  编译并用qemu启动这个镜像

make debug   编译并用qemu启动,再使用gdb连接到qemu上,打开tui界面进行调试。


了解了这些基础命令之后,就能够开始真正做题了。





练习 1:理解通过 make 生成执行文件的过程。(要求在报告中写出对下述问题的回答)
        在此练习中,大家需要通过阅读代码来了解:
        1. 操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中
            每一条相关命令和命令参数的含义,以及说明命令导致的结果)
        2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?


通过阅读make V=的输出,就可以知道所有的编译过程。无非是一层层编译,然后最后通过dd命令组合生成的elf文件,生成镜像文件。对makefile的语法比较了解的话,阅读Makefile就并不困难。在其中你可以找到除朴素的编译过程之外的另外一些小细节。比如它是通过tools/sign.c文件来修饰编译生成的bootloader文件。再比如当make debug时,它通过tools/gdbinit文件来载入gdb的命令,这都能够为后续的调试提供一些便利。


如果一句句解释Makefile的话,长篇大论且没什么营养,所以直接跳过好了。需要着重强调的是,Makefile通过一系列命令生成了bootblock和kernel这两个elf文件,之后通过dd命令将bootblock放到第一个sector,将kernel放到第二个sector开始的区域。可以明显看出bootblock就是引导区,kernel则是操作系统内核。

而在这之前还通过sign对bootblock进行了修饰,在512个字节的最后两个字节写入了0x55AA,作为引导区的标记。所以上面这两个问题的答案已经很明显了。





练习 2:使用 qemu 执行并调试 lab1 中的软件。(要求在报告中简要写出练习过程)
        为了熟悉使用 qemu 和 gdb 进行的调试工作,我们进行如下的小练习:
        1. 从 CPU 加电后执行的第一条指令开始,单步跟踪 BIOS 的执行。
        2. 在初始化位置 0x7c00 设置实地址断点,测试断点正常。
        3. 从 0x7c00 开始跟踪代码运行,将单步跟踪反汇编得到的代码与 bootasm.S 和 bootblock.asm
           进行比较。
        4. 自己找一个 bootloader 或内核中的代码位置,设置断点并进行测试。


关于这道题,我们首先需要知道bios是什么。它往往是储存在固件当中的一段代码。在操作系统启动之前对硬件进行检查,然后再加载运行引导区的代码。

bios的具体代码没必要一条条分析。首先要知道它是运行在实模式下的,该模式下地址为16位偏移量加上4位段基址。在用gdb调试时需要特别注意这点,由于ucore为i386的操作系统,gdb调试时默认是运行在i386模式下,需要用set arch i8086,才能正确反汇编十六位的代码。

bios的第一条指令位于0xf000:0xfff0这个地址,这是一条跳转指令,会跳转到0xf000:0xe05b这个地址。从这里开始就是bios的代码了。

为了顺利到达这个位置,我们需要首先观察gdbinit文件中的指令,它在kern_init函数设了断点,再执行了continue。所以只要将continue命令去掉,make debug命令执行后就自动停在了0xffff0地址处。然而此处由于gdb的tui默认观察的是$pc处的指令,而没有加上$cs带来的段基址,导致其显示的指令是错误的。只有用x /i 0xffff0这样的方式才能看到正确的指令。而单步调试跟踪寄存器值变化可以看出,这样得到的代码才是正确的。


跳过bios部分的代码,开始调试0x7c00部分。根据bootasm.S及之前Makefile链接时相关指令给出的信息,这个地址是预先指定的bootloader代码加载的位置。刚开始对比gdb中的代码和bootblock.asm开始,会觉得它们长得似乎都是一模一样的……但是在某一条命令开始,它们突然就出现了区别。思忖良久,它们的区别实际上在于,bootblock.asm当中的代码是i386下反汇编的结果,而到此代码还应运行于实模式下。因此gdb中在i8086下反汇编的代码才是正确的。




练习 3:分析 bootloader 进入保护模式的过程。(要求在报告中写出分析)
           BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行 bootloader。请分析
           bootloader 是如何完成从实模式进入保护模式的。


观察代码可以看到一大通莫名其妙的in,out命令,它们的作用是与i/o设备交互。由于文档中已经附带了足够的信息,我们可以看出这些指令的目的就是与A20总线交互,打开它的高地址部分,以使我们能不止操作20位地址,为进入保护模式做准备。这个过程很冗长(我可以写吗?我要写了!我真的写了~)如此过后,终于成功完成了这个操作。之后就是加载gdt(全局描述符表,具体是什么参见文档)。然后将cr0寄存器的最低位置1.。ok,已经进入了保护模式,接下来而已把体系结构修改回i386了。




练习 4:分析 bootloader 加载 ELF 格式的 OS 的过程。(要求在报告中写出分析)
        通过阅读 bootmain.c,了解 bootloader 如何加载 ELF 文件。通过分析源代码和通过 qemu 来运行并调
        试 bootloader&OS,
        1. bootloader 如何读取硬盘扇区的?
        2. bootloader 是如何加载 ELF 格式的 OS?
        提示:可阅读 3.2.3“硬盘访问概述”,3.2.4“ELF 执行文件格式概述”。

就纯粹的难度而言,这问题比上面的还要简单一些,毕竟从汇编转到了c语言。主要还是要熟悉c语言各种强制类型转换的语法,以及能熟练运用一个代码跳转的软件,比如使用ctags的sublime或者vim。读取硬盘扇区仍然是通过in和out指令,只不过现在封装成了c语言的内联函数而已。仍然是之前说到过的那个冗长的过程(我可以写吗?我要写了!我真的写了~)。只不过多设置了一些参数来控制这个读取过程罢了。只要理解它的意义即可。

加载elf格式的文件的过程比较神奇,它先读取了elf文件头。然后根据这个文件头读取各分段的文件,之后再调用了elf文件头中定义的入口地址处的代码,也就是kern_init函数。这么说好像很简单,实际上它通过各种强制类型转换,这段代码给我的印象是写的非常c语言。

到此,内核已经被加载,引导区的使命完成了。





练习 5:实现函数调用堆栈跟踪函数 (需要编程)
         我 们 需 要 在 lab1 中 完 成 kdebug.c 中 函 数 print_stackframe 的 实 现 , 可 以 通 过 函 数
         print_stackframe 来跟踪函数调用堆栈中记录的返回地址。
 

如果直接根据这个要求写出对应的代码,我想还是比较难做到的,然而函数中已经给出了足够的注释。因此只要熟悉i386下的函数调用堆栈结构,就能轻松完成。

<span style="font-size:18px;">uint32_t ebp = read_ebp();
uint32_t eip = read_eip();
while (ebp)
{
    uint32_t * pbp = (uint32_t *) ebp;
    uint32_t * args = (uint32_t *)ebp+2;
    uint32_t arg1 = args[0];
    uint32_t arg2 = args[1];
    uint32_t arg3 = args[2];
    uint32_t arg4 = args[3];
    cprintf("ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x\n",ebp,eip,arg1,arg2,arg3,arg4);
    print_debuginfo(eip-1);
    eip = *(pbp+1);
    ebp = *pbp;
}
</span>

我的代码其实还是有些冗长了,有些不必要的部分完全可以删掉。写的时候的考虑是,尽量使它写起来对我自己而言好理解一些。至少现在看来,还是达到了这个目的的。





练习 6:完善中断初始化和处理 (需要编程)
       请完成编码工作和回答如下问题:
       1. 中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
       2. 请编程完善 kern/trap/trap.c 中对中断向量表进行初始化的函数 idt_init。在 idt_init 函数
           中,依次对所有中断入口进行初始化。使用 mmu.h 中的 SETGATE 宏,填充 idt 数组内容。注意除了系统调用中断(T_SYSCALL)以外,其它中断均使用中断门描述符,权限为内核态权限;而系统调用中断使用 异常,权限为陷阱门描述符。每个 中断的入口由tools/vectors.c 生成,使用 trap.c 中声明的 vectors 数组即可。

      3清华大学计算机系 操作系统课程实验 2013秋季
      3. 请编程完善 trap.c 中的中断处理函数 trap,在对时钟中断进行处理的部分填写 trap 函数中处理时钟中断的部分,使操作系统每遇到 100 次时钟中断后,调用 print_ticks 子程序,向屏幕上打印一行文字”100 ticks”。
  


跟上一题一样,完全按照注释与给出的文档里的信息来补全代码。注意到很多宏可以在相关的头文件中找到,因此最好先通读文档中的项目结构部分,能省不少时间。

extern uintptr_t __vectors[];
int i=0;
for (i=0;i<256;++i){
    SETGATE(idt[i],0,KERNEL_CS,__vectors[i],DPL_KERNEL);
}
SETGATE(idt[T_SYSCALL],1,KERNEL_CS,__vectors[T_SYSCALL],DPL_USER);
lidt(&idt_pd);
初始化中断向量表的代码如上。这个__vectors数组是用汇编代码声明的,在vectors.S当中。这也是c语言很神奇的一点,从这里就可以看出它跟汇编语言紧密结合的程度。剩余的那些宏基本都可以在trap.h或者memlayout.h,mmu.h当中找到。其意义是相当明确的,因此就不客气使用了。


switch (tf->tf_trapno) {
case IRQ_OFFSET + IRQ_TIMER:
if (++ticks==100){
   print_ticks();
   ticks=0;
}
break;
........................
}

而在中断中输出计数值就太简单不过了。





扩展练习 Challenge(需要编程)
扩展 proj4,增加 syscall 功能,即增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务

前面的编程部分都只是跟着注释亦步亦趋而已就可以解决。challenge部分则没有那么多的注释提供信息。文档中推荐通过中断来完成内核态和用户态切换的操作。

在开始之前,最好先完整阅读vector.S,trapentry.S,trap.c文件,以了解中断完整的调用过程。总体还是比较清晰的,就是一系列压栈,调用,最后作为参数传送给trap函数。

在开始切换之前,我们首先要知道内核态和用户态指的究竟是什么。它们即ring0和ring3,指的是当前代码运行的特权等级。这个特权级由cs中标识特权级的几位声明。访问数据段时,数据段的特权级(DPL)必须大于等于代码段的特权级(CPL),同时还有一个东西叫做i/o特权级(IOPL),其作用方式同上。

查阅资料可以得知,从内核态切换到用户态只能通过iret指令修改cs的值。也就是修改之前压入栈中的值,iret指令将栈中值弹出时便成功更改了cs的值。同时为了之后的访问不出问题,我们需要将ds,es,ss的dpl也一并修改,并修改iopl,这样接下来代码才能顺畅运行。

static void
lab1_switch_to_user(void) {
// LAB1 CHALLENGE 1 : TODO
asm volatile (
   "sub $0x8, %%esp;" 
   "int %0;"//调用 T_SWITCH_TOU 中断
   "movl %%ebp, %%esp" //恢复栈指针
   :
   : "i"(T_SWITCH_TOU)
);
}

在 trap_dispatch 当中对应部分加入:
if (tf->tf_cs!=USER_CS){
   tf->tf_ss = USER_DS;
   tf->tf_cs = USER_CS;
   tf->tf_ds = USER_DS;
   tf->tf_es = USER_DS;
   tf->tf_fs = USER_DS;
   tf->tf_gs = USER_DS;
   tf->tf_eflags |= FL_IOPL_MASK;
}
break;
上面使用的是gcc嵌入asm的语法。在调用中断之前先修改esp的原因是,切换特权级时,iret指令会额外弹出ss和esp,但调用中断时并未产生特权级切换,因此并未压入对应ss和esp。需要预先留出空间防止代码出错。

而从用户态切换到内核态也是相应的。有所不同的是,由于用户态调用中断时本来就自动切换到内核态,因此不会产生上述的问题,无需额外压入ss和esp。


已经完成了切换之后,增加一个系统调用的过程非常简单,无需赘述。

  • 2
    点赞
  • 25
    收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
评论 3

打赏作者

陈文青

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值