THU《操作系统》学习笔记——实验一:bootloader启动ucoreos


THU《操作系统》学习笔记—— 实验一:bootloader启动ucoreos

实验前置理论-1 启动顺序:

1.1 x86寄存器初始值

  x86寄存器初始值
第一条指令地址
  计算机在加电之后,寄存器被初始化成上图中的初始值,Intel 80836加电后的第一条指令的地址是段寄存器CS中隐含的Base的值(从第一幅图中可以看到为FFFF0000H)加上EIP中的值(0000FFF0H),也就是FFFFFFF0H。注意这一块内存地址是BIOS的EPROM(可擦除可编程只读存储器)所在地,是只读的,从这个地址取得第一条指令。这个指令一般是长跳转指令,更新CS和EIP跳到BIOS代码去做初始化工作。目前CPU还处于实模式,寻址能力只有1MB,长跳转指令也是跳到一个可以被访问的1MB内存空间里面执行。


1.2 处于实模式的段

  CS和EIP是一个16位的寄存器,它们相加为什么能形成20位的地址?是在于16位的段寄存器CS左移了四位,左移四位之后再叠加上EIP的值,才形成了实模式下最终的寻址方式。所以CS:IP=CS*16+IP。
寻址方式示意图


1.3 从BIOS到Bootloader

BIOS到Bootloader
  一块扇区大小是512字节,BIOS加载存储设备上的第一块扇区到内存0x7c00的地址,然后跳转到0x7c00执行代码,这512字节的代码将会完成后续的加载工作,这个扇区的代码我们称为Bootloader。


1.4 从Bootloader到OS

从Bootloader到OS
  Bootloader第一件干的事是让CPU从实模式转变为保护模式,这个过程称为使能保护模式。一旦使能了保护模式,那么段机制也就自动加载上来了,段机制也使能了。

  Bootloader第二件事是从存储设备中读取kernel(内核),它需要把OS的代码读到内存中来。

  Bootloader最后把控制权交给OS去让它进一步执行,怎么交?就是把CS和EIP的值指向内核所在内存地址的起点,跳到这来就相当于把控制权交给了OS。


1.5 段机制

段机制示意图
  在段机制中,寄存器起了一个指针的作用,它指向了段描述符,在段描述符里描述了一个段落的起始地址和它的大小。所以可以通过CS里的Selector的值找到OS代码段起始地址在什么地方。
段机制示意图2
  在一个段寄存器里,会保存一块地址叫段选择址(Seg Selector),通过它查找段描述符表中的一个段描述符,它们之间也就是一种映射关系。这个段描述符会存着基址(Base Address)和大小,这个起始地址加上Offset(EIP的值)得到线性地址。这个时候还没有启动页机制,线性地址也就等同于物理地址。

  那么如何建立好段机制?很重要的一点就是需要有一个大的数组,把各个段描述符装进去,我们把它称为GDT(Global Descriptor Table 全局描述符表/段表),这个GDT是由Bootloader来建立的。Bootloader会描述好段描述符表的一个大致的空间,然后给出它的位置和大小,然后通过一个特殊的指令就可以让CPU能够找到GDT的首地址。得到这个表的首地址之后呢,通过内部的GDTR寄存器来保存这个首地址,然后使得各个段寄存器可以和GDT建立对应关系。

  前面说到段寄存器一共有16位,其中的高13位放的就是GDT的Index,低位的两位是RPL(Requested Privilege Level),表明这个段的优先级的级别,在x86用了2个bit表示这个值,意味着它可以表示0,1,2,3四个特权级,CPU中的最高级是0,而应用程序会放在3这个特权级里。这个TI一般设置成0,0表示要查的表是GDT,1表示LDT(本地描述符)。

  建立完段机制后,使能保护模式需要靠对控制寄存器CR0设置它的第0位bit为1,之后就意味着CPU进入保护模式,段机制是在保护模式下自动使能的。
段机制示意图3


1.6 加载ELF格式的UcoreOS kernel

ELF Header
Program Header
  Bootloader第二件事就是加载Kernel。UcoreOS编译出来生成的是ELF格式的执行程序,ELF格式的执行程序是Linux里一个很常用的执行文件格式。ELF里面有个头叫ELF Header,ELF Header里面指出了一个Program Header,也就是程序段的头。程序段里面包含了代码段,数据段等等。比如上图中的代码,phoff这是程序段头的Offset,相当于ELF Header的偏移地址它的起始地址在什么地方。以及它的个数phnum,有了这两个信息就可以进一步去查找Program Header这个结构,把这个结构的信息去读出来,比如它的虚地址(va)要往哪个内存地址放,它的起始位置在什么地方,如果程序段是代码段,它的代码段要多大(memsz)。va和memsz这两个信息可以便于把内存中的相应一块区域用来存放代码段或者数据段等等。那么从这个文件的哪个位置开始读呢?offset指出了从文件的哪个位置把段给读进来。

  大致就是说它能够识别出一些重要的关键信息,然后把相应的代码段、数据段从文件读到内存中来。还有它读的实际上是一个个连续的磁盘扇区,把它读到内存里面然后开始一个个进行分析工作。


实验前置理论-2 X86中断处理过程

2.1 中断源

中断源
  这一部分一张图就够了。


2.2 确定中断服务例程(LSR)

确定中断服务例程
  中断产生之后,操作系统需要理解这个中断需要做什么。无论是硬中断(外中断),软中断(内中断)都和一个中断号有对应关系,而这个中断号唯一标识了这个中断的特征。对于每一个中断号呢,都有一个中断处理相应的例程来完成对应操作,这是OS需要去建立好的。

  每一个中断或异常都与一个中断服务例程(Interrupt Service Routine)相关联,这个关联的建立是OS需要去考虑和实现的。我们需要去完成相应的处理过程,但这个处理过程又和具体硬件是相关的,所以说我们需要去了解在X86环境下怎么完成关联的建立。

  上图中可以看到,在X86环境中它有一系列硬件机制支持这种对应关系的建立。它有一个IDT,也就是中断描述符表,跟GDT很类似,只是它专门用来描述中断的。这也是个大数组,然后里面的每项我们称为中断门或陷阱门(文章之后用中断门表示)。每个中断门对应着一个中断号,一个中断号可以有一个Index,我们根据这个中断号可以找到它对应的中断门,然后基于中断门可以进一步获取到跟这个中断门相关的段选择址。我们前面讲的段机制里有段的选择址和段内的偏移量,有了这两个信息就可以知道一个中断服务例程的地址。所以IDT和GDT合在一起,就可以完成中断或者是异常和中断服务例程对应的链接关系的建立。

中断门格式
  IDT表有一个起始地址和大小,保存在IDTR寄存器里。IDT表里的每一项中断门有它的格式,这里面最主要的两个,一个是段描述符,第二个是它的offset,有了这两个信息其实它的中断服务例程地址也就知道了。

确定中断服务例展示图
  产生了一个中断之后,根据这个中断我们可以知道它的中断号,CPU会根据中断号查询IDT找到它的中断门,然后从中断门取出它的段选择址,以这个选择址作为Index查找进一步查找GDT/LDT得到段描述符。段描述符里有基址(Base address),再加上中断门里保存的offset,合在一起就形成了相应的线性地址,从而可以指向ISR(中断服务例程)。所以说一旦产生了某个中断,CPU可以自动地在硬件这个层面访问IDT和GDT/LDT这两个表,注意这两个表是OS建立好的,一旦建立好之后,CPU就可以基于这两个表查到中断对应的中断服务例程。当然这个例程也是由OS实现的,这样可以确保一旦产生了某一个中断之后,我们的OS可以及时地相应,去调用相应的函数做出处理。这就是中断处理的一个初始化过程。


2.3 切换到中断服务例程

不同特权级的中断切换对堆栈的影响
  在中断之后,中断会打断当前正在执行的程序,然后去执行刚才所说的中断服务例程,中断服务例程执行完毕之后,再返回到当前被打断的程序中去继续执行这个程序。那么这有一个打断和恢复,这个恢复就需要保存。在上一篇介绍原理的文章说到,在不同的特权级它的处理方式是不一样的,特权级在段描述符里可以看到。段描述符里会设定它在哪一个特权级,比如说CS低两位,如果低两位是0,那么就处于内核态;而如果是3(十进制)代表运行在用户态。在内核态产生的中断依然在内核态,但是在用户态产生的中断也会跳到内核态里执行,这是两种不同的方式,因为这里产生了特权级的变化。对于这个特权级变和没变,中断的保存与恢复也是不一样的。

  上图中左边是说内核态产生中断之后会发生什么变化。可以看到它还是用同一个栈,没有发生变化,只是在这个栈上压了一些被打断一刻的寄存器内容。第一个是有Error Code,这个Error Code代表是特意的严重异常,不是每一个中断或异常都会产生Error Code。第二个会压入EIP和CS,是当前被打断的那个地址或者被打断的下一条地址。第三个是Eflags,当前被打断的标志性的内容。这些是中断时由硬件自动压栈的内容,可以看到都压在同一个栈里面。

  图中右边说的是当发生中断的时候产生不同特权级,意味着产生中断那一刻,应用程序正在用户态执行。我们可以看到第一点,从用户态到内核态它们用的是不同的栈,所以说当特权级变化产生中断之后呢,那么它除了压那些寄存器内容之外,还有很重要的两个信息是ESP和SS。实际上这两个内容是当时产生中断的时候,在用户态里的栈的地址。很明显在执行完毕恢复的时候,对于特权级没有发生变化的情况而言,它还是恢复到同一个特权级,还是在同一个栈里继续向下走。而对于特权级变化的这个情况而言,它会恢复到用户态去执行,不会用内核态去进一步执行。

返回指令
  当x86完成中断处理例程之后,还需要返回到被打断的程序继续执行。这里面对于中断服务例程来说,它会通过一个iret指令完成这个返回。但对于通常程序来说,它是通过ret和retf完成函数的返回,也意味着它们的处理方式是不一样的。对于没有改变特权级的方式,它其实是在同一个栈里把Error Code弹出,根据CS和EIP来跳到被打断的那个地方继续执行,同时还要恢复它的Eflags的值。而如果是改变特权级的方式,它还会把SS和ESP弹出,这是iret返回时要干的事情。对于ret而言,它只是弹出了EIP,也就是跳到当前的下一条指令去执行,而对于retf而言,弹出的是CS和EIP,也就是恢复CS,相当于完成一个远程跳转。

  以上这些指令还只是硬件完成的工作,如果在中断服务例程中对其他寄存器进行修改的话,在修改前需要把寄存器的内容保存起来,在iret返回前恢复回来,从而确保跳回到被中断的应用程序时可以正确执行。


2.4 中断处理-系统调用

中断处理-系统调用
  系统调用其实可以理解为一种特殊的中断,它称为trap(陷入),或者说软中断。应用程序通过系统调用访问OS内核服务,从具体实践上说,系统调用机制的建立和中断机制建立时很接近的。在实践上需要考虑,如何指定中断号,如何完成从用户态到内核态的切换,以及从内核态回到用户态去,这一块有一些特殊的方法,或者是通过一些特殊指令可以完成相应的工作。在Ucore用的还是传统的嵌入方式,比如int 80这种通过软中断的方式来完成系统调用。但是为了完成系统调用,需要在建立IDT的时候对此特殊考虑。这跟其他的中断处理方式不太一样,因为这里很明确地指出了从用户态执行int 80或者int 某一个数能够从用户态切换到内核态,它有一个低优先级到高优先级的转变,这个机制需要在IDT表里设置相应的权限,才能完成这种转变。


3.Lab-1 编程实验

3.1 实现函数调用堆栈跟踪函数

  我们需要在lab1中完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。在如果能够正确实现此函数,可在lab1中执行 “make qemu”后,在qemu模拟器中得到类似如下的输出:

ebp:0x00007b28 eip:0x00100992 args:0x00010094 0x00010094 0x00007b58 0x00100096
    kern/debug/kdebug.c:305: print_stackframe+22
ebp:0x00007b38 eip:0x00100c79 args:0x00000000 0x00000000 0x00000000 0x00007ba8
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp:0x00007b58 eip:0x00100096 args:0x00000000 0x00007b80 0xffff0000 0x00007b84
    kern/init/init.c:48: grade_backtrace2+33
ebp:0x00007b78 eip:0x001000bf args:0x00000000 0xffff0000 0x00007ba4 0x00000029
    kern/init/init.c:53: grade_backtrace1+38
ebp:0x00007b98 eip:0x001000dd args:0x00000000 0x00100000 0xffff0000 0x0000001d
    kern/init/init.c:58: grade_backtrace0+23
ebp:0x00007bb8 eip:0x00100102 args:0x0010353c 0x00103520 0x00001308 0x00000000
    kern/init/init.c:63: grade_backtrace+34
ebp:0x00007be8 eip:0x00100059 args:0x00000000 0x00000000 0x00000000 0x00007c53
    kern/init/init.c:28: kern_init+88
ebp:0x00007bf8 eip:0x00007d73 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
<unknow>: -- 0x00007d72 –

  请完成实验,看看输出是否与上述显示大致一致,并解释最后一行各个数值的含义。

  以上是实验作业要求,以下是完成的代码,在注释里写了代码的意思。

void print_stackframe(void) {
    //这部分英文注释为源代码中的英文提示
     /* LAB1 YOUR CODE : STEP 1 */
     /* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
      * (2) call read_eip() to get the value of eip. the type is (uint32_t);
      * (3) from 0 .. STACKFRAME_DEPTH
      *    (3.1) printf value of ebp, eip
      *    (3.2) (uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]
      *    (3.3) cprintf("\n");
      *    (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
      *    (3.5) popup a calling stackframe
      *           NOTICE: the calling funciton's return addr eip  = ss:[ebp+4]
      *                   the calling funciton's ebp = ss:[ebp]
      */    
    //ebp_v和eip_v分别通过调用read_ebp,read_eip函数获得ebp,eip寄存器的值。
    uint32_t ebp_v=read_ebp();
    uint32_t eip_v=read_eip();
    int i,j;
  
  //这里的宏STACKFRAME_DEPTH为20
    for(i=0;ebp_v!=0 && i< STACKFRAME_DEPTH; i++){
    //首先将ebp和eip的值以0x%08x的格式打印在显示器上
    //%08x为显示8位并会自动在前面补0的十六进制数
        cprintf("ebp:0x%08x eip:0x%08x args:",ebp_v,eip_v);

      //连续打印四个参数,第一个参数位置在当前ebp+8
      //这里因为是uint_32t,size为4个字节,所以+2也就是ebp+8
        uint32_t *args=(uint32_t*)ebp_v+2;
        for(j=0;j<4;j++){
            cprintf("0x%08x ",args[i]);
        }
    
        cprintf("\n");
        print_debuginfo(eip_v-1);//输出C函数的行号和函数名等信息

    //更新eip_v和ebp_v使它们指向函数调用者,调用者eip压栈时保存
    //在ebp+4中,而调用者ebp保存在当前ebp中,具体可以看看函数堆
    //栈的相关知识
        eip_v=((uint32_t*)ebp_v)[1];
        ebp_v=((uint32_t*)ebp_v)[0];
    }
}

附:Ucore实验指导书中函数堆栈相关内容:https://chyyuu.gitbooks.io/ucore_os_docs/content/lab1/lab1_3_3_1_function_stack.html


3.2 完善中断初始化和处理

  请完成编码工作和回答如下问题:

  1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

  2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。

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

  先解决第一个问题,从源代码中可以找到:

/* Gate descriptors for interrupts and traps */
struct gatedesc {
    unsigned gd_off_15_0 : 16;        // low 16 bits of offset in segment
    unsigned gd_ss : 16;            // segment selector
    unsigned gd_args : 5;            // # args, 0 for interrupt/trap gates
    unsigned gd_rsv1 : 3;            // reserved(should be zero I guess)
    unsigned gd_type : 4;            // type(STS_{TG,IG32,TG32})
    unsigned gd_s : 1;                // must be 0 (system)
    unsigned gd_dpl : 2;            // descriptor(meaning new) privilege level
    unsigned gd_p : 1;                // Present
    unsigned gd_off_31_16 : 16;        // high bits of offset in segment
};

  根据代码得到一个描述符中所有的bit相加共有64个,也就是64字节。gd_ss(16)加上gd_off(共32),也就是段选择符加上偏移量一共48位代表中断处理代码的入口。

  第二题为初始化建立IDT(中断描述符表/中断向量表)的函数,有关代码如下:


void idt_init(void) {
   /* LAB1 YOUR CODE : STEP 2 */
   /* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
    *     All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
    *     __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
    *     (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
    *     You can use  "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
    * (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
    *     Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
    * (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
    *     You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
    *     Notice: the argument of lidt is idt_pd. try to find it!
    */
    
    /*#define SETGATE(gate, istrap, sel, off, dpl) {            
      (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        
      (gate).gd_ss = (sel);                                
      (gate).gd_args = 0;                                    
      (gate).gd_rsv1 = 0;                                    
      (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    
      (gate).gd_s = 0;                                    
      (gate).gd_dpl = (dpl);                                
      (gate).gd_p = 1;                                    
      (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        
      }*/

  //有关中断服务例程入口的地址已经保存在了__vectors[]中
   extern uintptr_t __vectors[];
  int i;

//循环的次数为idt表的大小/单个中断门的大小
  for(i=0; i < sizeof(idt) / sizeof(struct gatedesc) ; i++){

      /*第一个参数是要写入的idt表位置,第二个参数中断门设置为0,
  系统段设置为1,第三个参数为段选择符内容,第四个参数为
  偏移量,这里为__vector中的内容,最后一个参数为优先级,
  DPL_KERNEL为0,也就是最高的特权级0*/
      SETGATE(idt[i],0,GD_KTEXT,__vectors[i],DPL_KERNEL);
  }

//为从用户切换到内核而设置
SETGATE(idt[T_SWITCH_TOK],0,GD_KTEXT,__vectors[T_SWITCH_TOK],DPL_USER);

//调用lidt函数(原函数实际上是一个汇编指令),让CPU知道IDT在什么
//地方,从而完成IDT的加载。idt_pd是它的起始地址。
/*static struct pseudodesc idt_pd = {
  sizeof(idt) - 1, (uintptr_t)idt
};*/

lidt(&idt_pd);
}

  第三题只是完成100次时钟中断后就显示100 ticks之类的信息,这就简单了,直接上代码叭:

//这里代码仅是trap_dispatch的一部分
static void
trap_dispatch(struct trapframe *tf) {
  char c;

  switch (tf->tf_trapno) {
  case IRQ_OFFSET + IRQ_TIMER:
      /* LAB1 YOUR CODE : STEP 3 */
      /* handle the timer interrupt */
      /* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
       * (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
       * (3) Too Simple? Yes, I think so!
       */
      ticks++;
    //TICK_NUM = 100
      if(ticks%TICK_NUM==0){
          print_ticks();
      }
    break;

运行效果


总结

  花了两天晚上总算是做完了第一个lab,别看代码短,但是真的好难。。。做这个lab的感觉就是在玩解密游戏,要到各个相关联的文件里的代码找线索,最后才能写出正确的代码,OS真是挺有趣也挺有挑战性的。最近因为要准备期末考了估计OS学习得搁置了,希望接下来能抓紧补下去。我认为计算机基础课学好了还是对自身的发展有很大帮助的,给自己加油🍭🍭


文章作者: 金毛败犬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 金毛败犬 !
评论
  目录