ucore操作系统实验lab1(练习1~6)

学习笔记

环境配置

实验指导书里有环境配置的相关说明,但实际操作后会发现更新包的过程非常缓慢,因为用的是系统默认源,建议大家先换成国内源,如阿里源、中科大源等,网上有很多用命令换源的操作指导文章,不做赘述,如果有不会使用命令行换源的,我的另一篇文章展示了如何通过图形界面换源,仅供参考。(https://blog.csdn.net/weixin_45484297/article/details/103005370)

练习一

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

生成kernal文件

键入make "V="查看执行过程;
链接生成
kernel
文件代码:

$(kernel): tools/kernel.ld

$(kernel): $(KOBJS)
	@echo + ld $@
	$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
	@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
	@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

$(call create_target,kernel)

生成bootblock

生成bootblock代码:

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
	@echo + ld $@
	$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
	@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
	@$(OBJDUMP) -t $(call objfile,bootblock) | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,bootblock)
	@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
	@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

$(call create_target,bootblock)

生成sign工具

$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)

生成ucore.img

UCOREIMG	:= $(call totarget,ucore.img)

$(UCOREIMG): $(kernel) $(bootblock)
	$(V)dd if=/dev/zero of=$@ count=10000
	$(V)dd if=$(bootblock) of=$@ conv=notrunc
	$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc

$(call create_target,ucore.img)

简单总结执行过程

  1. 编译libs和kern目录下所有的.c和.S文件,生成.o文件,并链接得到bin/kernel文件;
  2. 编译boot目录下所有的.c和.S文件,生成.o文件,并链接得到bin/bootblock.out文件;
  3. 编译tools/sign.c文件,得到bin/sign文件;
  4. 利用bin/sign工具将bin/bootblock.out文件转化为512字节的bin/bootblock文件,并将bin/bootblock的最后两个字节设置为0x55AA;
  5. 为bin/ucore.img分配内存空间,并将bin/bootblock复制到bin/ucore.img的第一个block,紧接着将bin/kernel复制到bin/ucore.img第二个block开始的位置。

主引导扇区的特征

这里截取部分源码

fclose(ifp);
    buf[510] = 0x55;
    buf[511] = 0xAA;
    FILE *ofp = fopen(argv[2], "wb+");
    size = fwrite(buf, 1, 512, ofp);
    if (size != 512) {
        fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
        return -1;
    }
    fclose(ofp);

可以看出其特征为:
1.大小为512字节
2. 最后两个字节为0x55AA

练习二

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

问题一

修改lab1/tools/gdbinit 内容为:

set architecture i8086
target remote :1234

用cd命令切换到lab1,执行make debug,而后会弹出一个qemu框和一个gdb调试框,在dgb调试界面执行si进行单步跟踪,执行如下命令查看BIOS代码:

x /2i $pc

单步执行一次后位置变为0xe05b,如下:
ucore操作系统实验lab1(练习1~6)_第1张图片

问题二

修改 gdbinit文件内容为:

set architecture i8086
target remote :1234
b *0x7c00
c
x/2i $pc

按照问题一的步骤执行,得到初始位置为0x7c00:
ucore操作系统实验lab1(练习1~6)_第2张图片

问题三

改写makefile文件内容为:
在这里插入图片描述
然后再执行make debug,在bin文件里会生成一个q.log文件,打开文件找到对应地址如下:
ucore操作系统实验lab1(练习1~6)_第3张图片
将其与bootasm.S和bootblock.asm进行比较,可以发现,反汇编的代码与bootblock.asm基本相同,而与bootasm.S有所差别:
1.反汇编的代码中的指令不带指示长度的后缀,而bootasm.S的指令则有。比如,反汇编 的代码是xor %eax, %eax,而bootasm.S的代码为xorw %ax, %ax;
2.反汇编的代码中的通用寄存器是32位(带有e前缀),而bootasm.S的代码中的通用寄存器是16位(不带e前缀)。

问题四

修改gdbinit文件,在0x7c12处设置断点:

set architecture i8086
target remote :1234
break *0x7c4a

执行make debug如下:
在这里插入图片描述

练习三

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

开启A20门

A20的初始值是0,所以其地址线控制是被屏蔽的,访问超过1MB的地址时,就会从0循环计数,即不可访问,而在保护模式下 A20 地址线控制是要打开的,所以需要通过将键盘控制器上的A20线置于高电位,使得全部32条地址线可用。
打开A20 Gate的具体步骤大致如下:

  1. 等待8042 Input buffer为空
  2. 发送Write 8042 Output Port (P2) 命令到8042 Input buffer
  3. 等待8042 Input buffer为空
  4. 将8042 Output Port(P2) 对应字节的第2位置1,然后写入8042 Input buffer

打开A20 Gate的功能是在boot/bootasm.S中实现的,代码分为seta20.1和seta20.2两部分,其中seta20.1是往端口0x64写数据0xd1,告诉CPU我要往8042芯片的P2端口写数据;seta20.2是往端口0x60写数据0xdf,从而将8042芯片的P2端口设置为1. 两段代码都需要先读0x64端口的第2位,确保输入缓冲区为空后再进行后续写操作。

seta20.1:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.1

    movb $0xd1, %al                                 # 0xd1 -> port 0x64
    outb %al, $0x64                                 # 0xd1 means: write data to 8042's P2 port

seta20.2:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.2

    movb $0xdf, %al                                 # 0xdf -> port 0x60
    outb %al, $0x60                                 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

初始化GDT表

boot/bootasm.S中的lgdt gdtdesc负责载入GDT表;

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

如何使能和进入保护模式

将cr0寄存器的PE位(cr0寄存器的最低位)设置为1;

 movl %cr0, %eax
 orl $CR0_PE_ON, %eax
 movl %eax, %cr0

练习四

分析bootloader加载ELF格式的OS的过程。(要求在报告中写出分析)
通过分析源代码和通过qemu来运行并调试bootloader&OS
bootloader如何读取硬盘扇区的?
bootloader是如何加载ELF格式的OS?

问题一

阅读材料中给出了大致过程:

  1. 等待磁盘准备好
  2. 发出读取扇区的命令
  3. 等待磁盘准备好
  4. 把磁盘扇区数据读到指定内存

实际操作中,所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。硬盘共有8个IO地址寄存器,其中第1个存储数据,第8个存储状态和命令,第3个存储要读写的扇区数,第4~7个存储要读写的起始扇区的编号(共28位)。

代码分析

bootloader读取扇区的功能是在boot/bootmain.c的readsect函数中实现的:

static void readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();

    outb(0x1F2, 1);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors

    // wait for disk to be ready
    waitdisk();

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);
}

分析代码可以得到读取硬盘扇区的步骤:

  1. 等待硬盘空闲。waitdisk的函数实现只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不断查询读0x1F7寄存器的最高两位,直到最高位为0、次高位为1(这个状态应该意味着磁盘空闲)才返回
  2. 硬盘空闲后,发出读取扇区的命令。对应的命令字为0x20,放在0x1F7寄存器中;读取的扇区数为1,放在0x1F2寄存器中;读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中
  3. 发出命令后,再次等待硬盘空闲
  4. 硬盘再次空闲后,开始从0x1F0寄存器中读数据。注意insl的作用是"That function will read cnt dwords from the input port specified by port into the supplied output array addr.",是以dword即4字节为单位的,因此这里SECTIZE需要除以4

问题二

读取完磁盘之后,开始加载ELF格式的文件,直接插入代码注释:

void bootmain(void) {
        // 读取ELF的头部
        readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

        // 判断是否是合法的ELF文件
        if (ELFHDR->e_magic != ELF_MAGIC) {
            goto bad;
        }

        struct proghdr *ph, *eph;

        // ELF头部有描述ELF文件应加载到内存什么位置的描述表,这里读取出来将之存入ph
        ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
        eph = ph + ELFHDR->e_phnum;

        // 按照描述表将ELF文件中数据载入内存
        for (; ph < eph; ph ++) {
            readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
        }
        // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
        // ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000
        // 根据ELF头部储存的入口信息,找到内核的入口
        ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

    bad:
        outw(0x8A00, 0x8A00);
        outw(0x8A00, 0x8E00);
        while (1);
    }

总结一下就是:

  1. 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用
  2. 校验e_magic字段
  3. 根据偏移量分别把程序段的数据读取到内存中

代码调试

  1. 输入make debug启动gdb,并在bootmain函数入口处即0x7d0d设置断点,输入c跳到该入口;
  2. 单步执行几次,运行到call readseg处,由于该函数会反复读取硬盘,为节省时间,可在下一条语句设置断点,避免进入到readseg函数内部反复执行循环语句;(或者直接输入n即可,不用这么麻烦)
  3. 执行完readseg后,可以通过x/xw 0x10000查询ELF Header的e_magic的值,查询结果如下,确实与0x464c457f相等,所以校验成功。注意,我们的硬件是小端字节序(这从asm文件的汇编语句和二进制代码的对比中不难发现),因此0x464c45实际上对应字符串"elf",最低位的0x7f字符对应DEL;
(gdb) x/xw 0x10000
0x10000:        0x464c457f
  1. 继续单步执行,由0x7d2f mov 0x1001c,%eax可知ELF Header的e_phoff字段将加载到eax寄存器,0x1001c相对0x10000的偏移为0x1c,即相差28个字节,这与ELF Header的定义相吻合。执行完0x7d2f处的指令后,可以看到eax的值变为0x34,说明program Header表在文件中的偏移为0x34,则它在内存中的位置为0x10000 + 0x34 = 0x10034.查询0x10034往后8个字节的内容如下所示:
(gdb) x/8xw 0x10034
0x10034:        0x00000001      0x00001000      0x00100000      0x00100000
0x10044:        0x0000dac4      0x0000dac4      0x00000005      0x00001000

可以使用readelf -l bin/kernel来查询kernel文件各个Segment的基本信息,以作对比:

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x00100000 0x00100000 0x0dac4 0x0dac4 R E 0x1000
  LOAD           0x00f000 0x0010e000 0x0010e000 0x00aac 0x01dc0 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
  1. 继续单步执行,由0x7d34 movzwl 0x1002c,%esi可知ELF Header的e_phnum字段将加载到esi寄存器,执行完x07d34处的指令后,可以看到esi的值变为3,这说明一共有3个segment;
  2. 后面是通过磁盘I/O完成三个Segment的加载,不再赘述。

练习五

实现函数调用堆栈跟踪函数 (需要编程)
完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址,并解释最后一行各个数值的含义。

打开kern/debug/kdebug.c,找到print_stackframe函数:

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 (uint32_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]
      */
}

注释已经给得十分详细,但在码代码之前需要我们注意注释中的两个函数,以便于我们理解整个调用流程。
read_eip() 函数定义在kdebug.c中:

static __noinline uint32_t
read_eip(void) {
    uint32_t eip;
    asm volatile("movl 4(%%ebp), %0" : "=r" (eip));
    return eip;
}

read_ebp() 函数定义在libs/x86.h中:

static inline uint32_t
read_ebp(void) {
    uint32_t ebp;
    asm volatile ("movl %%ebp, %0" : "=r" (ebp));
    return ebp;
}

实现函数如下:

  1. 定义两个局部变量ebp、eip分别存放ebp、eip寄存器的值,调用read_ebp()以获得ebp的值,调用read_eip()来获取eip的值,这里将ebp定义为指针,是为了方便取ebp寄存器的值;
  2. 由于变量eip存放的是下一条指令的地址,因此将变量eip的值减去1;
  3. 以后变量eip的值从ebp寄存器指向栈中的位置再往上一个单位中获取;
  4. ebp寄存器指向栈中的位置存放的是调用者的ebp寄存器的值,可以不断回溯,直到ebp寄存器的值变为0。
void print_stackframe(void) {
 uint32_t *ebp = (uint32_t *)read_ebp();
 uint32_t eip = read_eip();
 while (ebp)
 {
     cprintf("ebp:0x%08x eip:0x%08x args:", (uint32_t)ebp, eip);
     cprintf("0x%08x 0x%08x 0x%08x 0x%08x\n", ebp[2], ebp[3], ebp[4], ebp[5]);
     print_debuginfo(eip - 1);
     eip = ebp[1];
     ebp = (uint32_t *)*ebp;
 }
 /* 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 (uint32_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]
   */
}

编写完后执行make qemu,打印得到如下结果,与实验指导书结果类似:
ucore操作系统实验lab1(练习1~6)_第4张图片
最后一行的解释:

  1. ebp:0x0007bf8 此时ebp的值是kern_init函数的栈顶地址,从obj/bootblock.asm文件中知道整个栈的栈顶地址为0x00007c00,ebp指向的栈位置存放调用者的ebp寄存器的值,ebp+4指向的栈位置存放返回地址的值,这意味着kern_init函数的调用者(也就是bootmain函数)没有传递任何输入参数给它!因为单是存放旧的ebp、返回地址已经占用8字节了。
  2. eip:0x00007d6e eip的值是kern_init函数的返回地址,也就是bootmain函数调用kern_init对应的指令的下一条指令的地址。
  3. args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 一般来说,args存放的4个dword是对应4个输入参数的值。但这里比较特殊,由于bootmain函数调用kern_init并没传递任何输入参数,并且栈顶的位置恰好在boot loader第一条指令存放的地址的上面,而args恰好是kern_int的ebp寄存器指向的栈顶往上第2~5个单元,因此args存放的就是boot loader指令的前16个字节!可以对比obj/bootblock.asm文件来验证(验证时要注意系统是小端字节序)。
00007c00 <start>:
    7c00:   fa                      cli    
    7c01:   fc                      cld    
    7c02:   31 c0                   xor    %eax,%eax
    7c04:   8e d8                   mov    %eax,%ds
    7c06:   8e c0                   mov    %eax,%es
    7c08:   8e d0                   mov    %eax,%ss
    7c0a:   e4 64                   in     $0x64,%al
    7c0c:   a8 02                   test   $0x2,%al
    7c0e:   75 fa                   jne    7c0a <seta20.1>

练习六

完善中断初始化和处理 (需要编程)
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”。

问题一

中断描述符表一个表项占8个字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成偏移量,即第16~ 32位是段选择子,第0~ 15、48~63位构成的偏移地址,通过段选择子去GDT中找到对应的基地址,然后基地址加上偏移量就是中断处理程序的地址。

问题二

查看原函数注释:

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!
      */
}

SETGATE在mmu.h中有定义:

#define SETGATE(gate, istrap, sel, off, dpl)

函数的代码实现:

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!
      */
    extern uintptr_t __vectors[];
    int i;
    for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
    }
	// set for switch from user to kernel
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
	// load the IDT
    lidt(&idt_pd);
}

问题三

代码实现如下:

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 ++;
        if (ticks % TICK_NUM == 0) {
            print_ticks();
        }

执行make qemu,得到如下结果:
ucore操作系统实验lab1(练习1~6)_第5张图片
参考资料(https://www.cnblogs.com/wuhualong/p/ucore_lab1_report.html)

建议:大家在做这个实验时如遇到困难可查看指导书提供的answer,会有更深的理解。

你可能感兴趣的:(ucore操作系统实验lab1(练习1~6))