uCore lab1

发布于 25 天前  64 次阅读


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

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

预DEBUG

执行make观察编译过程:

出现报错,定位到报错代码:tools/sign.c

bootblock.out文件大小为600B,超出了一个扇区512B的限制。

所以跟进到boot的源代码,将其修改以减小目标文件的大小:

修改bootmain.c中的全局变量为宏定义:

make成功且bootblock.out成功减小到了488B

观察make的输出

# 构建bin/kernel
+ cc kern/init/init.c
+ cc kern/libs/readline.c
+ cc kern/libs/stdio.c
+ cc kern/debug/kdebug.c
+ cc kern/debug/kmonitor.c
+ cc kern/debug/panic.c
+ cc kern/driver/clock.c
+ cc kern/driver/console.c
+ cc kern/driver/intr.c
+ cc kern/driver/picirq.c
+ cc kern/trap/trap.c
+ cc kern/trap/trapentry.S
+ cc kern/trap/vectors.S
+ cc kern/mm/pmm.c
+ cc libs/printfmt.c
+ cc libs/string.c
+ ld bin/kernel
# 构建sign工具与bin/bootblock
+ cc boot/bootasm.S
+ cc boot/bootmain.c
+ cc tools/sign.c
# 使用gcc编译器由tools/sign.c生成可执行文件bin/sign
    gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
    gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
# 使用ld命令链接/boot/bootasm.o、obj/boot/bootmain.o到obj/bootblock.o
+ ld bin/bootblock
    ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o 
    obj/boot/bootmain.o -o obj/bootblock.o
    'obj/bootblock.out' size: 472 bytes
    build 512 bytes boot sector: 'bin/bootblock' success!
# 构建ucore.img
dd if=/dev/zero of=bin/ucore.img count=10000 # 使用dd工具创建一个bin/ucore.img空文件
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0456474 s, 112 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc # 使用dd工具将文件bin/bootblock写入bin/ucore.img, 参数conv=notrunc表示不截断输出文件
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.00281044 s, 182 kB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc # 使用dd工具将文件bin/kernel写入bin/ucore.img起始的1个block后,即bootblock后, 参数seek=1表示从输出文件开头跳过1个block开始写入
138+1 records in
138+1 records out
70775 bytes (71 kB) copied, 0.000473867 s, 149 MB/s

由此结合源码,分析整体项目架构:

  • 编译了部分C代码,生成uCore内核ELF文件kernel
  • 编译bootasm.S与bootmain.c生成bootloader的目标文件bootblock.out
  • 编译sign.c生成规范化工具,并用其将bootblock填充为以0x55 0xAA结尾的512字节的块文件
  • 由bootloader的ELF文件规范化为512B后的bootblock,与内核ELF文件kernel,合并得到可引导的磁盘映像ucore.img

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

任务:

  1. 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
  2. 在初始化位置0x7c00设置实地址断点,测试断点正常。
  3. 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
  4. 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。

qemu+GDB断点调试

由于我的实验环境并非使用了老师所提供的ubuntu磁盘镜像,而是物理机的Arch Linux,故直接使用result中的为原ubuntu定制的脚本会因为环境不同而出现各种问题。所以在此手动配置调试。

首先阅读Makefile中相应行,了解老师的代码究竟执行了什么操作:

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

可以看到make lab1-mon主要完成了如下操作:

  • 第一个是让 qemu 把它执行的指令给记录下来,放到 q.log 这个地方
  • 第二个是和 gdb 结合来调试正在执行的 Bootloader。其中,gdb启动后执行的命令从tools/lab1init中读取

tools/lab1init:

file /bin/kernel
target remote :1234
set architecture i8086
b *0x7c00
continue
x /2i $pc

故操作如下:

shell中:

qemu-system-i386 -s ./bin/ucore.img

GDB中(使用了增强插件gef):

file ./bin/kernel

target remote :1234

b *0x7c00

c

成功断在了bootloader的起始代码处:

使用CGDB下断点于bootmain()处:

最后,下断点于BIOS的起始指令处:

此处需要qemu的-S参数以使得虚拟机启动即暂停,以断在BIOS起始代码处

动态调试中的指令与源码中的指令的比较

最开始gdb未设置set architecture i8086,以至于反汇编得到的代码是以i386的形式呈现的:

可以看到主要的区别为:

  • 源代码是以AT&T格式的汇编编写,而GDB默认将机器码反编译为intel格式的汇编代码
  • 寄存器被错误的反汇编呈现为了32位宽的寄存器。GDB中执行set architecture i8086后得以修复

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

bootloader能够开启i386的保护模式,使得程序进入了一个32位的寻址空间,使得寻址方式发生了变化。其需要完成如下事情:

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
    • i8086时代,CPU的数据总线是16bit,地址总线是20bit(20根地址总线),寄存器是16bit,因此CPU只能访问1MB以内的空间。因为数据总线和寄存器只有16bit,如果需要获取20bit的数据, 我们需要做一些额外的操作,比如移位。实际上,CPU是通过对segment(每个segment大小恒定为64K) 进行移位后和offset一起组成了一个20bit的地址,这个地址就是实模式下访问内存的地址:address = segment << 4 | offset理论上,20bit的地址可以访问1MB的内存空间(0x00000 - (2^20 - 1 = 0xFFFFF))。但在实模式下, 这20bit的地址理论上能访问从0x00000 - (0xFFFF0 + 0xFFFF = 0x10FFEF)的内存空间。也就是说,理论上我们可以访问超过1MB的内存空间,但越过0xFFFFF后,地址又会回到0x00000。上面这个特征在i8086中是没有任何问题的(因为它最多只能访问1MB的内存空间),但到了i80286/i80386后,CPU有了更宽的地址总线,数据总线和寄存器后,这就会出现一个问题: 在实模式下, 我们可以访问超过1MB的空间,但我们只希望访问 1MB 以内的内存空间。为了解决这个问题, CPU中添加了一个可控制A20地址线的模块,通过这个模块,我们在实模式下将第20bit的地址线限制为0,这样CPU就不能访问超过1MB的空间了。进入保护模式后,我们再通过这个模块解除对A20地址线的限制,这样我们就能访问超过1MB的内存空间了。注:事实上,A20就是第21根线,用来控制是否允许对 0x10FFEF 以上的实际内存寻址。称为A20 Gate默认情况下,A20地址线是关闭的(20bit以上的地址线限制为0),因此在进入保护模式(需要访问超过1MB的内存空间)前,我们需要开启A20地址线(20bit以上的地址线可为0或者1)。实现代码:bootasm.S中
  • 初始化GDT表(全局描述符表)
    • 在Protected Mode下,对一个段的描述则包括3方面因素:[Base Address, Limit, Access],它们加在一起被放在一个64-bit长的数据结构中,被称为段描述符。这种情况下,如果我们直接通过一个64-bit段描述符来引用一个段的时候,就必须使用一个64-bit长的段积存器装入这个段描述符。但 Intel 为了保持向后兼容,将段积存器仍然规定为16-bit(尽管每个段积存器事实上有一个64-bit长的不可见部分,但对于程序员来说,段积存器就是16-bit的),那么很明显,我们无法通过16-bit长度的段积存器来直接引用64-bit的段描述符。怎么办?解决的方法就是把这些长度为64-bit的段描述符放入一个数组中,而将段寄存器中的值作为下标索引来间接引用(事实上,是将段寄存器中的高13 -bit的内容作为索引)。这个全局的数组就是GDT。事实上,在GDT中存放的不仅仅是段描述符,还有其它描述符,它们都是64-bit长,我们随后再讨论。GDT可以被放在内存的任何位置,那么当程序员通过段寄存器来引用一个段描述符时,CPU必须知道GDT的入口,也就是基地址放在哪里,所以 Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过 LGDT 指令将 GDT 的入口地址装入此积存器,从此以后,CPU 就根据此积存器中的内容作为 GDT 的入口来访问GDT了。GDT是Protected Mode所必须的数据结构,也是唯一的——不应该,也不可能有多个。另外,正如它的名字(Global Descriptor Table)所蕴含的,它是全局可见的,对任何一个任务而言都是这样。已解决的迷惑:Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。对于GDT表的设置:

asm.h:

/* Normal segment */
#define SEG_NULLASM                                             \
    .word 0, 0;                                                 \
    .byte 0, 0, 0, 0
​
#define SEG_ASM(type,base,lim)                                  \
    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);          \
    .byte (((base) >> 16) & 0xff), (0x90 | (type)),             \
        (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

bootasm.S:

# Bootstrap GDT
.p2align 2                                          # force 4 byte alignment
gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel
​
gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt
    
lgdt gdtdesc
  • 使能和进入保护模式

使能:开启A20,初始化gdt后,将控制寄存器CR0PE(bit0)置为1即可:    

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

进入保护模式:

  • bootloader开始运行在实模式,物理地址为0x7c00,且是16位模式
  • bootloader关闭所有中断,方向标志位复位,ds,es,ss段寄存器清零
  • 打开A20使之能够使用高位地址线
  • 由实模式进入保护模式,使用lgdt指令把GDT描述符表的大小和起始地址存入gdt寄存器,修改寄存器CR0的最低位(orl $CR0PEON, %eax)完成从实模式到保护模式的转换,使用ljmp指令跳转到32位指令模式
  • 进入保护模式后,设置ds,es,fs,gs,ss段寄存器,堆栈指针,便可以进入c程序bootmain    
# Jump to next instruction, but in 32-bit code segment.
    # Switches processor into 32-bit mode.
    ljmp $PROT_MODE_CSEG, $protcseg
​
.code32                                             # Assemble for 32-bit mode
protcseg:
    # Set up the protected-mode data segment registers
    movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
    movw %ax, %ds                                   # -> DS: Data Segment
    movw %ax, %es                                   # -> ES: Extra Segment
    movw %ax, %fs                                   # -> FS
    movw %ax, %gs                                   # -> GS
    movw %ax, %ss                                   # -> SS: Stack Segment
​
    # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
    movl $0x0, %ebp
    movl $start, %esp
    call bootmain

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

到了这一步,bootasm.S中的汇编代码便完成了它的全部任务,通过call bootmain指令将控制权控制权交给了bootmain.c中的C语言代码。

bootmain.c中的C语言代码的主要任务是将uCoreOS的内核ELF文件载入内存中,步骤如下:

bootloader如何读取硬盘扇区的

  • 由readsect()函数实现
  • bootloader进入保护模式并载入c程序bootmain
  • bootmain中readsect函数完成读取磁盘扇区的工作,函数传入一个指针和一个uint_32类型secno,函数将secno对应的扇区内容拷贝至指针处
  • 调用waitdisk函数等待地址0x1F7中低8、7位变为0,1,准备好磁盘
  • 向0x1F2输出1,表示读1个扇区,0x1F3输出secno低8位,0x1F4输出secno的8-15位,0x1F5输出secno的16-23位,0x1F6输出0xe+secno的24-27位,第四位0表示主盘,第六位1表示LBA模式,0x1F7输出0x20
  • 调用waitdisk函数等待磁盘准备好
  • 调用insl函数把磁盘扇区数据读到指定内存

bootloader是如何加载ELF格式的OS

  • 由bootmain()函数实现
  • 调用readseg函数从kernel头读取8个扇区得到elfher
  • 判断elfher的成员变量magic是否等于ELF_MAGIC,不等则进入bad死循环
  • 相等表明是符合格式的ELF文件,循环调用readseg函数加载每一个程序段
  • 调用elfher的入口指针进入OS

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

首先研究一下堆栈跟踪函数的调用位置。可以发现是kern_init()函数(内核入口点)中的grade_backtrace()函数最终调用了print_stackframe(),而这个函数正是需要我们完成的。其最终目的是打印ebp、eip与当前指令执行处对应的参数(假定为4个,遵循了C标准调用的栈帧结构,32位下用栈传参)

void print_stackframe(void){  
    uint32_t ebp = read_ebp(), eip = read_eip();
    for (int i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) {
        cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip);
        // ebp向上移动1个字长为eip
        uint32_t *args = (uint32_t *)ebp + 2;
        // 向上每1个字长都为输入的参数
        for (int j = 0; j < 4; j ++)    cprintf("0x%08x ", args[j]);
        cprintf("\n");
        print_debuginfo(eip - 1);
        // ebp指针指向的位置向上一个地址为上一个函数的eip
        eip = ((uint32_t *)ebp)[1];
        // ebp指针指向的位置存储的上一个ebp的地址
        ebp = ((uint32_t *)ebp)[0];
    }
}

执行效果:

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

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

SETGATE宏的本质是设置生成一个4字节的中断描述表项:

#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;        \
}

vector.S中保存了中断号定义,可以看到所有的中断服务例程最终都是跳到__alltraps进行处理的

最终idt_init()实现如下:

void idt_init(void) {
    extern uintptr_t __vectors[];
    for (int i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
    }
    // 此处需要单独设置user to kernel的中断向量表项,因为DPL即访问权限不同
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
    lidt(&idt_pd);
}

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

依然先分析调用流程。trap.c中,trap()函数将一个中断帧传递给trap_dispatch()函数,然后使用一个switch结构进行传入中断号对应的操作。那么,我们所需要完成的代码便是在时钟中断对应的case中填入实现题目所述功能的代码。具体实现如下:

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

referrence: Angel Kitty