ucore lab1

练习一

题目:理解通过make生成执行文件的过程
列出本实验各练习中对应的OS原理的知识点,并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点。

操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)

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

解答
题目1的解答
等完成lab1后再回头总结。

题目2的解答
首先具体分析Makefile的执行流程,然后再回答题目所问的ucore.img的生成过程。

设置环境变量
第1~139行大部分是设置环境变量、编译选项等,其中关键是第117行和第136行,分别设置了libs和kern目录下的obj文件名,两者合并即为$(KOBJS)。

第117行:生成libs目录下的obj文件名
第117行语句是 ( c a l l a d d f i l e s c c , (call add_files_cc, (calladdfilescc,(call listf_cc,$(LIBDIR)),libs,),可见是调用了add_files_cc函数,输入参数有2个,第2个是libs(目录名),第1个是调用另一个函数listf_cc的返回值

listf_cc函数的定义为listf_cc = ( c a l l l i s t f , (call listf, (calllistf,(1), ( C T Y P E ) ) , 可 见 l i s t f c c 又 调 用 了 l i s t f 函 数 , 调 用 时 传 入 的 第 1 个 参 数 为 (CTYPE)),可见listf_cc又调用了listf函数,调用时传入的第1个参数为 (CTYPE))listfcclistf1(1) = ( L I B D I R ) = l i b s , 第 2 个 参 数 为 (LIBDIR) = libs,第2个参数为 (LIBDIR)=libs2(CTYPE) = c S

listf函数的定义为listf = $(filter $(if ( 2 ) , (2), (2),(addprefix %.,$(2)),%), $(wildcard $(addsuffix ( S L A S H ) ∗ , (SLASH)*, (SLASH),(1)))),将输入参数代入得:listf = $(filter %.c %.S, libs/*),可见此处调用listf的返回结果为libs目录下的所有.c和.S文件。由于lab1的libs目录下只有.h和.c文件,因此最终返回.c文件。

这时,第117行语句可化简为add_files_cc(libs/*.c, libs)

add_files_cc的定义为add_files_cc = ( c a l l a d d f i l e s , (call add_files, (calladdfiles,(1), ( C C ) , (CC), (CC),(CFLAGS) ( 3 ) , (3), (3),(2),$(4)),结合4可化简为add_files(libs/*.c, gcc, $(CFLAGS), libs)

add_files的定义为add_files = $(eval ( c a l l d o a d d f i l e s t o p a c k e t , (call do_add_files_to_packet, (calldoaddfilestopacket,(1), ( 2 ) , (2), (2),(3), ( 4 ) , (4), (4),(5))),而do_add_files_to_packet的定义为:

define do_add_files_to_packet
__temp_packet__ := $(call packetname,$(4))
ifeq ($$(origin $$(__temp_packet__)),undefined)
$$(__temp_packet__) :=
endif
__temp_objs__ := $(call toobj,$(1),$(5))
$$(foreach f,$(1),$$(eval $$(call cc_template,$$(f),$(2),$(3),$(5))))
$$(__temp_packet__) += $$(__temp_objs__)
endef

packetname的定义为packetname = $(if $(1),$(addprefix $(OBJPREFIX),$(1)), ( O B J P R E F I X ) ) , 其 中 (OBJPREFIX)),其中 (OBJPREFIX))(OBJPREFIX)=objs,而$(1)=libs,因此__temp_packet = __objs_libs

toobj的定义为toobj = $(addprefix $(OBJDIR)$(SLASH)$(if $(2),$(2)$(SLASH)), $(addsuffix .o,$(basename $(1)))),其中$(OBJDIR)=obj, ( S L A S H ) = / , 而 输 入 参 数 为 (SLASH)=/,而输入参数为 (SLASH)=/(1)=libs/.c, $(5)=’’,因此__temp_objs_ = obj/libs/.o

综上,第117行的最终效果是__objs_libs = obj/libs/**/*.o

第136行:生成kern目录下的obj文件名
生成过程与第117行类似,不再赘述。第136行的实际效果是__objs_kernel = obj/kern/**/*.o

生成kernel文件
第140~151行是生成kernel文件。由于脚本中的语句往往会引用到前面定义的变量,而前面定义的变量又可能引用到其他文件的变量,为便于分析,下面会将所有相关的语句集中放在一起。

第141行设置生成的kernel目标名为bin/kernel

kernel = $(call totarget,kernel)
totarget = $(addprefix $(BINDIR)$(SLASH),$(1))
BINDIR  := bin
SLASH   := /

第143行指出kernel目标文件需要依赖tools/kernel.ld文件,而kernel.ld文件是一个链接脚本,其中设置了输出的目标文件的入口地址及各个段的一些属性,包括各个段是由输入文件的哪些段组成、各个段的起始地址等。

$(kernel): tools/kernel.ld

第145行指出kernel目标文件依赖的obj文件。最终效果为KOBJS=obj/libs/.o obj/kern/**/.o。

$(kernel): $(KOBJS)
KOBJS   = $(call read_packet,kernel libs)
read_packet = $(foreach p,$(call packetname,$(1)),$($(p)))
packetname = $(if $(1),$(addprefix $(OBJPREFIX),$(1)),$(OBJPREFIX))
OBJPREFIX   := __objs_

第146行打印kernel目标文件名

@echo + ld $@
// output: `+ ld bin/kernel`

第147行是链接所有生成的obj文件得到kernel文件

$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
V       := @
LD      := $(GCCPREFIX)ld
// GCCPREFIX = 'i386-elf-' or ''
// output: ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/stdio.o obj/kern/libs/readline.o obj/kern/debug/panic.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/picirq.o obj/kern/driver/intr.o obj/kern/trap/trap.o obj/kern/trap/vectors.o obj/kern/trap/trapentry.o obj/kern/mm/pmm.o  obj/libs/string.o obj/libs/printfmt.o

第148行是使用objdump工具对kernel目标文件反汇编,以便后续调试。首先toobj返回obj/kernel.o,然后cgtype返回obj/kernel.asm,所以第148行相当于执行objdump -S bin/kernel > obj/kernel.asm,objdump的-S选项是交替显示将C源码和汇编代码。

@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
OBJDUMP := $(GCCPREFIX)objdump
// GCCPREFIX = 'i386-elf-' or ''
asmfile = $(call cgtype,$(call toobj,$(1)),o,asm)
cgtype = $(patsubst %.$(2),%.$(3),$(1))
toobj = $(addprefix $(OBJDIR)$(SLASH)$(if $(2),$(2)$(SLASH)),\
        $(addsuffix .o,$(basename $(1))))
OBJDIR  := obj
SLASH   := /

第149行是使用objdump工具来解析kernel目标文件得到符号表。如果不关注格式处理,实际执行语句等效于objdump -t bin/kernel > obj/kernel.sym。

@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call sy    mfile,kernel)
OBJDUMP := $(GCCPREFIX)objdump
SED     := sed
symfile = $(call cgtype,$(call toobj,$(1)),o,sym)

第151行是调用create_target函数:$(call create_target,kernel),而create_target的定义为create_target = $(eval $(call do_create_target,$(1),$(2),$(3),$(4),$(5))),可见create_target只是进一步调用了do_create_target的函数:do_create_target(kernel)

do_create_target的定义如下。由于只有一个输入参数,temp_objs为空字符串,并且走的是else分支,因此感觉这里的函数调用是直接返回,啥也没干?

// add packets and objs to target (target, #packes, #objs[, cc, flags])
define do_create_target
__temp_target__ = $(call totarget,$(1))
__temp_objs__ = $$(foreach p,$(call packetname,$(2)),$$($$(p))) $(3)
TARGETS += $$(__temp_target__)
ifneq ($(4),)
$$(__temp_target__): $$(__temp_objs__) | $$$$(dir $$$$@)
    $(V)$(4) $(5) $$^ -o $$@
else
$$(__temp_target__): $$(__temp_objs__) | $$$$(dir $$$$@)
endif
endef

生成bootblock
第156行:bootfiles = $(call listf_cc,boot),前面已经知道listf_cc函数是过滤出对应目录下的.c和.S文件,因此bootfiles=boot/*.c boot/*.S

第157行:从字面含义也可以看出是编译bootfiles生成.o文件。

$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
cc_compile = $(eval $(call do_cc_compile,$(1),$(2),$(3),$(4)))
define do_cc_compile
$$(foreach f,$(1),$$(eval $$(call cc_template,$$(f),$(2),$(3),$(4))))
endef

cc_template的定义为

// cc compile template, generate rule for dep, obj: (file, cc[, flags, dir])
define cc_template
$$(call todep,$(1),$(4)): $(1) | $$$$(dir $$$$@)
    @$(2) -I$$(dir $(1)) $(3) -MM $$< -MT "$$(patsubst %.d,%.o,$$@) $$@"> $$@
$$(call toobj,$(1),$(4)): $(1) | $$$$(dir $$$$@)
    @echo + cc $$<
    $(V)$(2) -I$$(dir $(1)) $(3) -c $$< -o $$@
ALLOBJS += $$(call toobj,$(1),$(4))
endef

第159行:bootblock = $(call totarget,bootblock),前面已经知道totarget函数是给输入参数增加前缀"bin/",因此bootblock=“bin/bootblock”

第161行声明bin/bootblock依赖于obj/boot/*.o 和bin/sign文件:$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)。注意toobj函数的作用是给输入参数增加前缀obj/,并将文件后缀名改为.o

第163行链接所有.o文件以生成obj/bootblock.o:$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)。这里要注意链接选项中的-e start -Ttext 0x7C00,大致意思是设置bootblock的入口地址为start标签,而且start标签的地址为0x7C00.(未理解-Ttext的含义)

第164行反汇编obj/bootblock.o文件得到obj/bootblock.asm文件:@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)

第165行使用objcopy将obj/bootblock.o转换生成obj/bootblock.out文件,其中-S表示转换时去掉重定位和符号信息:

@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)

第166行使用bin/sign工具将obj/bootblock.out转换生成bin/bootblock目标文件:@$(call totarget,sign) $(call outfile,bootblock) $(bootblock),从tools/sign.c代码中可知sign工具其实只做了一件事情:将输入文件拷贝到输出文件,控制输出文件的大小为512字节,并将最后两个字节设置为0x55AA(也就是ELF文件的magic number)

第168行调用了create_target函数$(call create_target,bootblock),根据上文的分析,由于只有一个输入参数,此处函数调用应该也是直接返回,啥也没干。

生成sign工具
第173行调用了add_files_host函数:$(call add_files_host,tools/sign.c,sign,sign)

add_files_host的定义为add_files_host = $(call add_files,$(1),$(HOSTCC),$(HOSTCFLAGS),$(2),$(3)),可见是调用了add_files函数:

add_files(tools/sign.c, gcc, $(HOSTCFLAGS), sign, sign)

add_files的定义为add_files = $(eval $(call do_add_files_to_packet,$(1),$(2),$(3),$(4),$(5))),根据前面的分析,do_add_files_to_packet的作用是生成obj文件,因此这里调用add_files的作用是设置__objs_sign = obj/sign/tools/sign.o

第174行调用了create_target_host函数:$(call create_target_host,sign,sign)

create_target_host的定义为create_target_host = $(call create_target,$(1),$(2),$(3),$(HOSTCC),$(HOSTCFLAGS)),可见是调用了create_target函数:create_target(sign, sign, gcc, $(HOSTCFLAGS))

create_target的定义为create_target = $(eval $(call do_create_target,$(1),$(2),$(3),$(4),$(5)))。根据前面的分析,do_create_target的作用是生成目标文件,因此这里调用create_target的作用是生成obj/sign/tools/sign.o

生成ucore.img
第179行设置了ucore.img的目标名:UCOREIMG := $(call totarget,ucore.img),前面已经知道totarget的作用是添加bin/前缀,因此UCOREIMG = bin/ucore.img

第181行指出bin/ucore.img依赖于bin/kernel和bin/bootblock:$(UCOREIMG): $(kernel) $(bootblock)

第182行: ( V ) d d i f = / d e v / z e r o o f = (V)dd if=/dev/zero of= (V)ddif=/dev/zeroof=@ count=10000。这里为bin/ucore.img分配10000个block的内存空间,并全部初始化为0。由于没指定block的大小,因此为默认值512字节,则总大小为5000M,约5G。

备注:在类UNIX 操作系统中, /dev/zero 是一个特殊的文件,当你读它的时候,它会提供无限的空字符(NULL, ASCII NUL, 0x00)。其中的一个典型用法是用它提供的字符流来覆盖信息,另一个常见用法是产生一个特定大小的空白文件。BSD就是通过mmap把/dev/zero映射到虚地址空间实现共享内存的。可以使用mmap将/dev/zero映射到一个虚拟的内存空间,这个操作的效果等同于使用一段匿名的内存(没有和任何文件相关)。

第183行: ( V ) d d i f = (V)dd if= (V)ddif=(bootblock) of=$@ conv=notrunc。这里将bin/bootblock复制到bin/ucore.img

第184行: ( V ) d d i f = (V)dd if= (V)ddif=(kernel) of=$@ seek=1 conv=notrunc。继续将bin/kernel复制到bin/ucore.img,这里使用了选项seek=1,意思是:复制时跳过bin/ucore.img的第一个block,从第2个block也就是第512个字节后面开始拷贝bin/kernel的内容。原因是显然的:ucore.img的第1个block已经用来保存bootblock的内容了。

第186行:$(call create_target,ucore.img),由于只有一个输入参数,因此这里会直接返回。

总结ucore.img的生成过程
编译libs和kern目录下所有的.c和.S文件,生成.o文件,并链接得到bin/kernel文件

编译boot目录下所有的.c和.S文件,生成.o文件,并链接得到bin/bootblock.out文件

编译tools/sign.c文件,得到bin/sign文件

利用bin/sign工具将bin/bootblock.out文件转化为512字节的bin/bootblock文件,并将bin/bootblock的最后两个字节设置为0x55AA

为bin/ucore.img分配5000MB的内存空间,并将bin/bootblock复制到bin/ucore.img的第一个block,紧接着将bin/kernel复制到bin/ucore.img第二个block开始的位置

题目3的解答
问题: 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
答:

大小为512字节
最后两个字节为0x55AA

练习二

题目:使用qemu执行并调试lab1中的软件
为了熟悉使用qemu和gdb进行的调试工作,我们进行如下的小练习:

从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
在初始化位置0x7c00设置实地址断点,测试断点正常。
从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和bootblock.asm进行比较。
自己找一个bootloader或内核中的代码位置,设置断点并进行测试
解答
问题1:从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行
tools/gdbinit的内容如下。可见,这里是对内核代码进行调试,并且将断点设置在内核代码的入口地址,即kern_init函数

file bin/kernel
target remote :1234
break kern_init
continue

为了从CPU加电后执行的第一条指令开始调试,需要修改tools/gdbinit的内容为:

set architecture i8086
file bin/bootblock
target remote :1234
break start
continue

执行make debug,这时会弹出一个QEMU窗口和一个Terminal窗口,这是正常的,因为我们在makefile中定义了debug的操作正是启动QEMU、启动Terminal并在其中运行gdb。

debug: $(UCOREIMG)
    $(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
    $(V)sleep 2
    $(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"

Terminal窗口此时停在0x0000fff0的位置,这是eip寄存器的值,而cs寄存器的值为0xf000. (遇到一个问题:此时无法正确反汇编出代码,使用x来查询内存0xfff0处的值时显示全0,不知道什么原因)

The target architecture is assumed to be i8086
0x0000fff0 in ?? ()
Breakpoint 1 at 0x7c00: file boot/bootasm.S, line 16.

输入si,执行1步,程序会跳转到0xe05b的地方。查看寄存器也可以发现eip的值变为0xe05b,而cs的值不变,仍然是0xf000.

反复输入si,以单步执行。(由于BIOS中全是汇编代码,看不懂其功能)。

问题2:在初始化位置0x7c00设置实地址断点,测试断点正常
我直接在tools/gdbinit中设置了断点break start,由于boot loader的入口为start,其地址为0x7c00,因此这和break *0x7c00效果是相同的。

设置断点后,输入continue或c,可以看到程序在0x7c00处停了下来,说明断点设置成功。

问题3:从0x7c00开始, 将反汇编代码与bootasm.S和bootblock.asm进行比较
反汇编的代码与bootblock.asm基本相同,而与bootasm.S的差别在于:
反汇编的代码中的指令不带指示长度的后缀,而bootasm.S的指令则有。比如,反汇编 的代码是xor %eax, %eax,而bootasm.S的代码为xorw %ax, %ax
反汇编的代码中的通用寄存器是32位(带有e前缀),而bootasm.S的代码中的通用寄存器是16位(不带e前缀)。
问题4:自己找一个bootloader或内核中的代码位置,设置断点并进行测试
这个比较简单,不作记录。

练习三

题目:分析bootloader进入保护模式的过程
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。

提示:需要阅读小节“保护模式和分段机制”和lab1/boot/bootasm.S源码,了解如何从实模式
切换到保护模式,需要了解:

为何开启A20,以及如何开启A20
如何初始化GDT表
如何使能和进入保护模式
解答
正如提示所言,bootloader从实模式切换到保护模式,需要做以下事情:

开启A20门
在内存中建立GDT表并初始化
设置cr0寄存器的PE位为1,表示从实模式切换到保护模式
下面针对每一项具体展开描述。

开启A20门
为何开启A20门?
一开始时A20地址线控制是被屏蔽的(总为0) ,直到系统软件通过一定的IO操作去打开它(参看bootasm.S) 。很显然,在实模式下要访问高端内存区,这个开关必须打开,在保护模式下,由于使用32位地址线,如果A20恒等于0,那么系统只能访问奇数兆的内存,即只能访问0–1M、2-3M、4-5M…,这样无法有效访问所有可用内存。所以在保护模式下,这个开关也必须打开。

如何开启A20?
打开A20 Gate的具体步骤大致如下:

等待8042 Input buffer为空
发送Write 8042 Output Port (P2) 命令到8042 Input buffer
等待8042 Input buffer为空
将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把全局描述符表的大小和起始地址共8个字节加载到全局描述符表寄存器GDTR中。从代码中可以看到全局描述符表的大小为0x17 + 1 = 0x18,也就是24字节。由于全局描述符表每项大小为8字节,因此一共有3项,而第一项是空白项,所以全局描述符表中只有两个有效的段描述符,分别对应代码段和数据段。

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

下面的代码给出了全局描述符表的具体内容。共有3项,每项8字节。第1项是空白项,内容为全0. 后面2项分别是代码段和数据段的描述符,它们的base都设置为0,limit都设置为0xffffff,也就是长度均为4G. 代码段设置了可读和可执行权限,数据段设置了可写权限。(疑问:为什么数据段不设置可读权限?)

// 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
SEG_ASM的定义如下
#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)

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

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

练习四

题目:分析bootloader加载ELF格式的OS的过程
通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS,理解:

bootloader如何读取硬盘扇区的?
bootloader是如何加载ELF格式的OS?
解答
问题1:bootloader如何读取硬盘扇区
分析原理
阅读材料其实已经给出了读一个扇区的大致流程:

等待磁盘准备好
发出读取扇区的命令
等待磁盘准备好
把磁盘扇区数据读到指定内存
实际操作中,需要知道怎样与硬盘交互。阅读材料中同样给出了答案:所有的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);
}

根据代码可以得出读取硬盘扇区的步骤:

等待硬盘空闲。waitdisk的函数实现只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不断查询读0x1F7寄存器的最高两位,直到最高位为0、次高位为1(这个状态应该意味着磁盘空闲)才返回。

硬盘空闲后,发出读取扇区的命令。对应的命令字为0x20,放在0x1F7寄存器中;读取的扇区数为1,放在0x1F2寄存器中;读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中。

发出命令后,再次等待硬盘空闲。

硬盘再次空闲后,开始从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.

问题2: bootloader如何加载ELF格式的OS
分析原理
首先从原理上分析加载流程。

bootloader要加载的是bin/kernel文件,这是一个ELF文件。其开头是ELF header,ELF Header里面含有phoff字段,用于记录program header表在文件中的偏移,由该字段可以找到程序头表的起始地址。程序头表是一个结构体数组,其元素数目记录在ELF Header的phnum字段中。

程序头表的每个成员分别记录一个Segment的信息,包括以下加载需要用到的信息:
uint offset; // 段相对文件头的偏移值,由此可知怎么从文件中找到该Segment
uint va; // 段的第一个字节将被放到内存中的虚拟地址,由此可知要将该Segment加载到内存中哪个位置
uint memsz; // 段在内存映像中占用的字节数,由此可知要加载多少内容
根据ELF Header和Program Header表的信息,我们便可以将ELF文件中的所有Segment逐个加载到内存中

分析代码
bootloader加载os的功能是在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))();
}

首先从硬盘中将bin/kernel文件的第一页内容加载到内存地址为0x10000的位置,目的是读取kernel文件的ELF Header信息。

校验ELF Header的e_magic字段,以确保这是一个ELF文件

读取ELF Header的e_phoff字段,得到Program Header表的起始地址;读取ELF Header的e_phnum字段,得到Program Header表的元素数目。

遍历Program Header表中的每个元素,得到每个Segment在文件中的偏移、要加载到内存中的位置(虚拟地址)及Segment的长度等信息,并通过磁盘I/O进行加载

加载完毕,通过ELF Header的e_entry得到内核的入口地址,并跳转到该地址开始执行内核代码

调试代码
输入make debug启动gdb,并在bootmain函数入口处即0x7d0d设置断点,输入c跳到该入口

单步执行几次,运行到call readseg处,由于该函数会反复读取硬盘,为节省时间,可在下一条语句设置断点,避免进入到readseg函数内部反复执行循环语句。(或者直接输入n即可,不用这么麻烦)

执行完readseg后,可以通过x/xw 0x10000查询ELF Header的e_magic的值,查询结果如下,确实与0x464c457f相等,所以校验成功。注意,我们的硬件是小端字节序(这从asm文件的汇编语句和二进制代码的对比中不难发现),因此0x464c45实际上对应字符串"elf",最低位的0x7f字符对应DEL。

(gdb) x/xw 0x10000
0x10000:        0x464c457f

继续单步执行,由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

可以结合代码中定义的Program Header结构来理解这8个字节的含义。

struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};

还可以使用readelf -l bin/kernel来查询kernel文件各个Segment的基本信息,以作对比。查询结果如下所示,可见与gdb调试结果是一致的。

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

继续单步执行,由0x7d34 movzwl 0x1002c,%esi可知ELF Header的e_phnum字段将加载到esi寄存器,执行完x07d34处的指令后,可以看到esi的值变为3,这说明一共有3个segment。

后面是通过磁盘I/O完成三个Segment的加载,不再赘述。

练习五

题目:实现函数调用堆栈跟踪函数
我们需要在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 –

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

提示:可阅读小节“函数堆栈”,了解编译器如何建立函数调用关系的。在完成lab1编译后,查看lab1/obj/bootblock.asm,了解bootloader源码与机器码的语句和地址等的对应关系;查看lab1/obj/kernel.asm,了解 ucore OS源码与机器码的语句和地址等的对应关系。

要求完成函数kern/debug/kdebug.c::print_stackframe的实现,提交改进后源代码包(可以编译执行) ,并在实验报告中简要说明实现过程,并写出对上述问题的回答。

补充材料:
由于显示完整的栈结构需要解析内核文件中的调试符号,较为复杂和繁琐。代码中有一些辅助函数可以使用。例如可以通过调用print_debuginfo函数完成查找对应函数名并打印至屏幕的功能。具体可以参见kdebug.c代码中的注释。

解答
代码实现
编程前,首先了解下当前情况:在Terminal下输入make qemu,发现打印以下信息后就退出了:

along:~/src/ucore/labcodes/lab1$ sudo make qemu
WARNING: Image format was not specified for 'bin/ucore.img' and probing guessed raw.
         Automatically detecting the format is dangerous for raw images, write operations on block 0 will be restricted.
         Specify the 'raw' format explicitly to remove the restrictions.
(THU.CST) os is loading ...

Special kernel symbols:
  entry  0x00100000 (phys)
  etext  0x001036f3 (phys)
  edata  0x0010e950 (phys)
  end    0x0010fdc0 (phys)
Kernel executable memory footprint: 64KB

分析print_stackframe的函数调用关系

kern_init ->
    grade_backtrace ->
        grade_backtrace0(0, (int)kern_init, 0xffff0000) ->
                grade_backtrace1(0, 0xffff0000) ->
                    grade_backtrace2(0, (int)&0, 0xffff0000, (int)&(0xffff0000)) ->
                        mon_backtrace(0, NULL, NULL) ->
                            print_stackframe ->
                                

找到print_stackframe函数,发现函数里面的注释已经提供了十分详细的步骤,基本上按照提示来做就行了。代码如下所示。
首先定义两个局部变量ebp、esp分别存放ebp、esp寄存器的值。这里将ebp定义为指针,是为了方便后面取ebp寄存器的值。
调用read_ebp函数来获取执行print_stackframe函数时ebp寄存器的值,这里read_ebp必须定义为inline函数,否则获取的是执行read_ebp函数时的ebp寄存器的值。
调用read_eip函数来获取当前指令的位置,也就是此时eip寄存器的值。这里read_eip必须定义为常规函数而不是inline函数,因为这样的话在调用read_eip时会把当前指令的下一条指令的地址(也就是eip寄存器的值)压栈,那么在进入read_eip函数内部后便可以从栈中获取到调用前eip寄存器的值。
由于变量eip存放的是下一条指令的地址,因此将变量eip的值减去1,得到的指令地址就属于当前指令的范围了。由于只要输入的地址属于当前指令的起始和结束位置之间,print_debuginfo都能搜索到当前指令,因此这里减去1即可。
以后变量eip的值就不能再调用read_eip来获取了(每次调用获取的值都是相同的),而应该从ebp寄存器指向栈中的位置再往上一个单位中获取。
由于ebp寄存器指向栈中的位置存放的是调用者的ebp寄存器的值,据此可以继续顺藤摸瓜,不断回溯,直到ebp寄存器的值变为0

void print_stackframe(void) {
 uint32_t *ebp = 0;
 uint32_t esp = 0;

 ebp = (uint32_t *)read_ebp();
 esp = read_eip();

 while (ebp)
 {
     cprintf("ebp:0x%08x eip:0x%08x args:", (uint32_t)ebp, esp);
     cprintf("0x%08x 0x%08x 0x%08x 0x%08x\n", ebp[2], ebp[3], ebp[4], ebp[5]);

     print_debuginfo(esp - 1);

     esp = 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,打印结果如下所示,与实验指导书的结果类似。

ebp:0x00007b38 eip:0x00100bf2 args:0x00010094 0x0010e950 0x00007b68 0x001000a2
    kern/debug/kdebug.c:297: print_stackframe+48
ebp:0x00007b48 eip:0x00100f40 args:0x00000000 0x00000000 0x00000000 0x0010008d
    kern/debug/kmonitor.c:125: mon_backtrace+23
ebp:0x00007b68 eip:0x001000a2 args:0x00000000 0x00007b90 0xffff0000 0x00007b94
    kern/init/init.c:48: grade_backtrace2+32
ebp:0x00007b88 eip:0x001000d1 args:0x00000000 0xffff0000 0x00007bb4 0x001000e5
    kern/init/init.c:53: grade_backtrace1+37
ebp:0x00007ba8 eip:0x001000f8 args:0x00000000 0x00100000 0xffff0000 0x00100109
    kern/init/init.c:58: grade_backtrace0+29
ebp:0x00007bc8 eip:0x00100124 args:0x00000000 0x00000000 0x00000000 0x0010379c
    kern/init/init.c:63: grade_backtrace+37
ebp:0x00007be8 eip:0x00100066 args:0x00000000 0x00000000 0x00000000 0x00007c4f
    kern/init/init.c:28: kern_init+101
ebp:0x00007bf8 eip:0x00007d6e args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
    <unknow>: -- 0x00007d6d --

解释最后一行各个参数的含义
最后一行是ebp:0x00007bf8 eip:0x00007d6e args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8,共有ebp,eip和args三类参数,下面分别给出解释。

ebp:0x0007bf8 此时ebp的值是kern_init函数的栈顶地址,从obj/bootblock.asm文件中知道整个栈的栈顶地址为0x00007c00,ebp指向的栈位置存放调用者的ebp寄存器的值,ebp+4指向的栈位置存放返回地址的值,这意味着kern_init函数的调用者(也就是bootmain函数)没有传递任何输入参数给它!因为单是存放旧的ebp、返回地址已经占用8字节了。

eip:0x00007d6e eip的值是kern_init函数的返回地址,也就是bootmain函数调用kern_init对应的指令的下一条指令的地址。这与obj/bootblock.asm是相符合的。

 7d6c:   ff d0                   call   *%eax
    7d6e:   ba 00 8a ff ff          mov    $0xffff8a00,%edx

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>

练习六

题目:完善中断初始化和处理
请完成编码工作和回答如下问题:

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

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

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

完成这问题2和3要求的部分代码后,运行整个系统,可以看到大约每1秒会输出一次”100 ticks”,而按下的键也会在屏幕上显示。

【注意】除了系统调用中断(T_SYSCALL)使用陷阱门描述符且权限为用户态权限以外,其它中断均使用特权级(DPL)为0的中断门描述符,权限为内核态权限;而ucore的应用程序处于特权级3,需要采用`int 0x80`指令操作(这种方式称为软中断,软件中断,Trap中断,在lab5会碰到) 来发出系统调用请求,并要能实现从特权级3到特权级0的转换,所以系统调用中断(T_SYSCALL)所对应的中断门描述符中的特权级(DPL)需要设置为3。

解答
问题1的回答
问题1:中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
答:中断描述符表一个表项占8个字节,其结构如下:

bit 63…48: offset 31…16
bit 47…32: 属性信息,包括DPL、P flag等
bit 31…16: Segment selector
bit 15…0: offset 15…0
其中第1632位是段选择子,用于索引全局描述符表GDT来获取中断处理代码对应的段地址,再加上第015、48~63位构成的偏移地址,即可得到中断处理代码的入口。

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

题目已经提供中断向量的门类型和DPL的设置方法:除了系统调用的门类型为陷阱门、DPL=3外,其他中断的门类型均为中断门、DPL均为0.

中断处理函数的段选择子及偏移量的设置要参考kern/trap/vectors.S文件:由该文件可知,所有中断向量的中断处理函数地址均保存在__vectors数组中,该数组中第i个元素对应第i个中断向量的中断处理函数地址。而且由文件开头可知,中断处理函数属于.text的内容。因此,中断处理函数的段选择子即.text的段选择子GD_KTEXT。从kern/mm/pmm.c可知.text的段基址为0,因此中断处理函数地址的偏移量等于其地址本身。

完成IDT表的初始化后,还要使用lidt命令将IDT表的起始地址加载到IDTR寄存器中。

根据以上分析,及注释中的提示,不难完成编码,如下所示。

extern uintptr_t __vectors[];

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void idt_init(void) {
    uint32_t pos;
    uint32_t sel = GD_KTEXT;

    /* along: how to set istrap and dpl? */
    for (pos = 0; pos < 256; pos++) {
        SETGATE(idt[pos], 0, sel, __vectors[pos], 0);
    }
        
    SETGATE(idt[128], 1, sel, __vectors[128], 3);

    lidt(&idt_pd);
}

lab1_result中的代码如下所示。答案有几处地方比我写得好:

我定义的sel变量是多余的,浪费内存,直接使用GD_KTEXT即可。
设置DPL时使用DPL_KERNEL, DPL_USER代替0和3,可读性更好。
将extern __vectors放在函数内部,使其仅在本函数内可见,结构上更合理。(是吗?)
不过这里有个问题:答案设置系统调用的门描述符时,中断向量为什么是T_SWITCH_TOK(121)而不是T_SYSCALL(128)?

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void idt_init(void) {
    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);
}

完善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;
        }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值