ucore 操作系统实验lab1

推荐从bilibili上看清华大学操作系统的视频,里面讲的很清楚。关于虚拟机镜像,我也是从向勇老师所提供的资源上下载的,他的资源里面环境都已经搭建完毕,自己搭建环境会出现很多问题。建议从网上下载,省时省心。
lab1练习一的传送门

练习1:理解通过make生成执行文件的过程

操作系统镜像文件ucore.img是如何一步一步生成的?

1 进入相应文件夹

在这里插入图片描述

2 展现编译过程

利用make V=查看文本内容
在这里插入图片描述
注意在“make V=”之前,需要先清空一下, 否则如上。
在这里插入图片描述
通过大体的阅读,我们不难发现,系统调用了gcc把源代码编译成了.o 文件。
对于很多行,前面都有一个+号,这个是编译指令,多数UNIX平台都通过CC调用它们的C编译程序在这里插入图片描述
ld通过编译又将目标文件,转化成了一个执行程序,即生成了bootblock
在这里插入图片描述
通过这个,我们看出,他生成了两个文件,分别是“bootblock”和“kernel”

所以,综上, ucore.img 的生成过程是
1 编译所有生成bin/kernel所需的文件
2 链接生成bin/kernel
3 编译bootasm.S  bootmain.c  sign.c 
4 根据sign规范生成obj/bootblock.o
5 生成ucore.img

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

先说一下主引导扇区(MBR)干什么用的

主引导扇区在计算机引导过程中起着非常重要的作用,计算机在按下电源后,开始执行主板的BIOS程序,进行完一系列的检测和配置以后,开始按CMOS中设定的系统引导顺序进行引导。
值得一提的是,MBR有且仅有512个字节,在执行MBR的引导程序时,会验证MBR扇区最后两个字节是否为“55AA”,如果是“55AA”,那么系统才会继续执行下面的程序;如果不是“55AA”,则程序认为这是一个非法的MBR,那么程序将停止执行,同时会在屏幕上列出错误信息。

也就是说,我们需要检查是否占用512个字节,并且看后两位字节是否是“55AA”

查看sign.c的文件内容
less tools/sign.c

内容如下

	  1 #include <stdio.h>
      2 #include <errno.h>
      3 #include <string.h>
      4 #include <sys/stat.h>
      5 
      6 int
      7 main(int argc, char *argv[]) {
      8     struct stat st;
      9     if (argc != 3) {
     10         fprintf(stderr, "Usage: <input filename> <output filename>\n");
     11         return -1;
     12     }
     13     if (stat(argv[1], &st) != 0) {
     14         fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
     15         return -1;
     16     }
     17     printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
     18     if (st.st_size > 510) {
     19         fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
     20         return -1;
     21     }
     22     char buf[512];
     23     memset(buf, 0, sizeof(buf));
     24     FILE *ifp = fopen(argv[1], "rb");
     25     int size = fread(buf, 1, st.st_size, ifp);
	 26     if (size != st.st_size) {
     27         fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
     28         return -1;
     29     }
     30     fclose(ifp);
     31     buf[510] = 0x55;
     32     buf[511] = 0xAA;
     33     FILE *ofp = fopen(argv[2], "wb+");
     34     size = fwrite(buf, 1, 512, ofp);
     35     if (size != 512) {
     36         fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
     37         return -1;
     38     }
     39     fclose(ofp);
     40     printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
     41     return 0;
     42 }
     43 

注意代码的第22行到32行

     22     char buf[512];
     23     memset(buf, 0, sizeof(buf));
     24     FILE *ifp = fopen(argv[1], "rb");
     25     int size = fread(buf, 1, st.st_size, ifp);
	 26     if (size != st.st_size) {
     27         fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
     28         return -1;
     29     }
     30     fclose(ifp);
     31     buf[510] = 0x55;
     32     buf[511] = 0xAA;

其中有这么几个关键字眼

char buf[512];
memset(buf, 0, sizeof(buf));//全部置零
buf[510] = 0x55;
buf[511] = 0xAA;

通过这个我们可以看出有如下几点

1.该空间大小为512字节
2.空位全部补0
3.最后一个字节511为0xAA
4.倒数第二个字节510为0x55

这与我们上述一致

练习2: 使用qemu执行并调试lab1中的软件

练习二是要进行一些小练习

从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行

依次输入如下代码

less Makefile
/lab1-mon

对代码进行分析(由于代码太多,我只对重要的摘录分析)

    201 lab1-mon: $(UCOREIMG)
    202         $(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR)/q.log -monitor stdio -hda     202 $< -serial null"
    203         $(V)sleep 2
    204         $(V)$(TERMINAL) -e "gdb -q -x tools/lab1init"
    205 debug-mon: $(UCOREIMG)

这里主要做了两件事

1.qemu把执行的指令记录下来并存放在q.log 中
2.利用gdb调试bootloader

在初始化位置0x7c00设置实地址断点,测试断点正常

设置断点进行测试,断点的代码文件在虚拟机里已经拥有,我们只需要打开即可

less tools/lab1init    (注意,是lab1,不是labl)

打开后,是如下的代码

      1 file bin/kernel			//加载kernel
      2 target remote :1234		//通过静态地址进行连接
      3 set architecture i8086
      4 b *0x7c00				//在初始化位置7c00处设置断点
      5 continue
      6 x /2i $pc				//x是显示地址,pc是指针寄存器,/2为显示两条,i是指令

我们再次输入“make lab1-mon”试试
在这里插入图片描述
我们发现,qemu窗口启动起来了,但是在0x7c01处停止了
输入 x /10i $pc ,可以查看相关的10条指令代码
在这里插入图片描述
输入continue,使它继续运行,Ctrl+c就可以停止运行

练习3:分析bootloader进入保护模式的过程

A20地址线的由来

这是一个历史性问题。在intel处理器8086中,“段:偏移”最大能表示的内存地址是FFFF:FFFF,即10FFEFh,但是8086仅仅有20位寻址地址总线,仅仅能寻址到1MB,假设试图访问1MB以上的内存地址,并不会错误发生,而是回卷。即又回到0000:0000地址,又从零开始寻址。但是到了80286时,真的能够访问到1MB以上的内存了。假设遇到相同的情况,系统不会再回卷寻址,这就造成了向上不兼容,为了保证100%兼容,IBM想出了一个办法。使用8042键盘控制器来控制第20个地址位。这就是A20地址线。

实模式和保护模式

5实模式:实模式就是, 为了实现系统升级的兼容性,如80286的系统表现(包括80286以后的CPU)要与8086/8088 的系统表现一致,就需要80286 CPU访问100000H-10FFEFH之间的地址的时候, 按照对1M求模的方式进行, 无论A20地址线开启关闭与否, 这种内存访问情况称为实模式

保护模式:保护模式就是, 以A20地址线开启为前提,80286 CPU访问100000H-10FFEFH之间的地址的时候, 是访问真实的内存地址,不是求模访问,如访问100001H,就是真真切切地 访问 0x 100001H,而不是求模的 0x000001H 地址, 这种内存访问情况称为保护模式

A20被禁止和被开启的不同结果

如果A20Gate被禁止:对于80286来说,其地址为24bit,其地址表示为EFFFFF;对于80386极其随后的32-bit芯片来说,其地址表示为FFEFFFFF。这种表示的意思是如果
A20Gate被禁止,则其第20-bit在CPU做地址访问的时候是无效的,永远只能被作为0

如果A20 Gate被打开:则其第20-bit是有效的,其值既可以是0,又可以是1

所以:在保护模式下,如果A20 Gate被打开,则可以访问的内存则是连续的;如果A20Gate被禁止,则可以访问的内存只能是偶数段

如何打开A20

在Bootblock.asm中有这样一段代码,是开启A20的:

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

打开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位,确保输入缓冲区为空后再进行后续写操作。

如何初始化GDT表

如何初始化GDT表?
#把gdt表的起始位置和界限装入GDTR寄存器

lgdt gdtdesc            #把gdt表的起始位置和界限装入GDTR寄存器 movl %cr0, %eax          orl $CR0_PE_ON, %eax    
movl %eax, %cr0         #把保护模式位开启
如何使能和进入保护模式

将cr0寄存器的PE位(cr0寄存器的最低位)设置为1,便使能和进入保护模式了。代码如下所示:

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

练习四:分析bootloader加载ELF格式的OS的过程

ELF文件

ELF文件一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。
ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且他们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。

实验过程
bootloader如何读取硬盘扇区的?

在bootmain中,第一句就是读取硬盘扇区

	readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

读取扇区的流程我们通过查询指导书可以看到:
1、等待磁盘准备好;
2、发出读取扇区的命令;
3、等待磁盘准备好;
4、把磁盘扇区数据读到指定内存。
接下来我们需要了解下如何具体的从硬盘读取数据,因为我们所要读取的操作系统文件是存在0号硬盘上的,所以,我们来看一下关于0号硬盘的I/O端口:
在这里插入图片描述
这一张图,是从实验指导书中截取出来的。
读入扇区的过程如下:

/* readsect - read a single sector at @secno into @dst */
static void
#读入第一个扇区
readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();

    outb(0x1F2, 1);                    //读入扇区个数为1
    outb(0x1F3, secno & 0xFF);         //LBA参数的第0-7位
    outb(0x1F4, (secno >> 8) & 0xFF);  //LBA参数的第8-15位
    outb(0x1F5, (secno >> 16) & 0xFF); //LBA参数的第16-23位
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);            //命令0x20: 读扇区

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

    // read a sector 获取数据
    insl(0x1F0, dst, SECTSIZE / 4);
}

从outb()看出是LBA模式的PIO(Program IO)方式访问硬盘,且一次只读取一个扇区。

readseg函数简单包装了readsect,可以从设备读取任意长度的内容:

static void
    readseg(uintptr_t va, uint32_t count, uint32_t offset) {
        uintptr_t end_va = va + count;

        va -= offset % SECTSIZE;

        uint32_t secno = (offset / SECTSIZE) + 1; 
        // 加1因为0扇区被引导占用
        // ELF文件从1扇区开始

        for (; va < end_va; va += SECTSIZE, secno ++) {
            readsect((void *)va, secno);
        }
    }
bootloader是如何加载ELF格式的OS?

先从原理上分析一波

  1. bootloader要加载的是bin/kernel文件,这是一个ELF文件。其开头是ELF header,ELF Header里面含有phoff字段,用于记录program header表在文件中的偏移,由该字段可以找到程序头表的起始地址。程序头表是一个结构体数组,其元素数目记录在ELF Header的phnum字段中。
  2. 程序头表的每个成员分别记录一个Segment的信息
  3. 根据ELF Header和Program Header表的信息,我们便可以将ELF文件中的所有Segment逐个加载到内存中

分析一下bootmain函数

void bootmain(void) {
    // read the 1st page off disk
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF?
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
}

我们不难看出:

  1. 首先从硬盘中将bin/kernel文件的第一页内容加载到内存地址为0x10000的位置,目的是读取kernel文件的ELF Header信息。
  2. 校验ELF Header的e_magic字段,以确保这是一个ELF文件
  3. 读取ELF Header的e_phoff字段,得到Program Header表的起始地址;读取ELF Header的e_phnum字段,得到Program Header表的元素数目。
  4. 遍历Program Header表中的每个元素,得到每个Segment在文件中的偏移、要加载到内存中的位置(虚拟地址)及Segment的长度等信息,并通过磁盘I/O进行加载
  5. 加载完毕,通过ELF Header的e_entry得到内核的入口地址,并跳转到该地址开始执行内核代码

综上可得,bootloader加载ELF格式的OS的流程为

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

练习5:实现函数调用堆栈跟踪函数

首先,可以通过read_ebp()和read_eip()函数来获取当前ebp寄存器和eip 寄存器的信息。

我们知道在push ebp之前会先把调用参数入栈,而ebp长16位(也就是2Byte),所以(ebp+2)[0…3]就是传入参数。

由于函数调用的过程会把上一层的ebp压入栈中,所以当前ebp中存的值正是上一层的ebp。而上一层的eip 事实上已经不是eip了,而是调入这个函数的地方,也就是当前函数的返回地址。

实现过程代码如下:

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]
      */
    uint32_t ebp = read_ebp(), eip = read_eip();
    for (int i = 0; i < STACKFRAME_DEPTH && ebp != 0; i++) {
        cprintf("ebp: 0x%08x eip: 0x%08x args:", ebp, eip);
        for (int ij= 0; j < 4; j++) {
            cprintf(" 0x%08x", ((uint32_t*)(ebp + 2))[j]);
        }
        cprintf("\n");
        print_debuginfo(eip - 1);
        eip = *((uint32_t*) ebp + 1);
        ebp = *((uint32_t*) ebp);
    }
}

练习6:完善中断初始化和处理

完善函数idt_init

idt_init函数的功能是初始化IDT表。IDT表中每个元素均为门描述符,记录一个中断向量的属性,包括中断向量对应的中断处理函数的段选择子/偏移量、门类型(是中断门还是陷阱门)、DPL等。因此,初始化IDT表实际上是初始化每个中断向量的这些属性

  1. 题目已经提供中断向量的门类型和DPL的设置方法:除了系统调用的门类型为陷阱门、DPL=3外,其他中断的门类型均为中断门、DPL均为0
  2. 中断处理函数的段选择子及偏移量的设置要参考kern/trap/vectors.S文件:由该文件可知,所有中断向量的中断处理函数地址均保存在__vectors数组中,该数组中第i个元素对应第i个中断向量的中断处理函数地址。而且由文件开头可知,中断处理函数属于.text的内容。因此,中断处理函数的段选择子即.text的段选择子GD_KTEXT。从kern/mm/pmm.c可知.text的段基址为0,因此中断处理函数地址的偏移量等于其地址本身。
  3. 完成IDT表的初始化后,还要使用lidt命令将IDT表的起始地址加载到IDTR寄存器中。

编码如下:

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
    // (1) 拿到外部变量 __vector
    extern uintptr_t __vectors[];
    // (2) 使用SETGATE宏,对中断描述符表中的每一个表项进行设置
    for (int i = 0; i < 256; i++) {
        uint16_t istrap = 0, off = 0, dpl = 3;
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
    }
    // set for switch from user to kernel 
    SETGATE(idt[T_SWITCH_TOU], 0, GD_KTEXT, __vectors[T_SWITCH_TOU], DPL_USER);
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
    // (3) 调用lidt函数,设置中断描述符表
    lidt(&idt_pd);
}
完善trap函数

trap函数只是直接调用了trap_dispatch函数,而trap_dispatch函数实现对各种中断的处理,题目要求我们完成对时钟中断的处理,实现非常简单:定义一个全局变量ticks,每次时钟中断将ticks加1,加到100后打印"100 ticks",然后将ticks清零重新计数。代码实现如下:

case IRQ_OFFSET + IRQ_TIMER:
        if (((++ticks) % TICK_NUM) == 0) {
            print_ticks();
            ticks = 0;
        }
  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值