操作系统学习笔记清华OS

清华大学操作系统课程 ucore Lab 1 系统软件启动过程

2019-03-17  本文已影响0人  AmadeusChan

操作系统也是大三下学得很开心的一门课喽~ 实验报告也写的挺认真的,所以打算在这里把所有实验报告都公开出来,方便有学习操作系统打算的同学可以参考哈哈哈 本系列应该报错操作系统课程ucore-lab从1-8的所有内容~

这个是操作系统课程资料所在网址:https://github.com/chyyuu/os_course_info 方便同学们参考~

以下就是报告内容喽:

Operating Systems Lab 1 系统软件启动过程 实验报告

基本练习

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

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

不妨对整个lab1所提供的Makefile进行解释如下:

PROJ    := challenge
EMPTY   :=
SPACE   := $(EMPTY) $(EMPTY)
SLASH   := /

V       :=
ifndef GCCPREFIX
GCCPREFIX := $(shell if i386-elf-objdump -i 2>&1 | grep '^elf32-i386$$' >/dev/null 2>&1; \
    then echo 'i386-elf-'; \
    elif objdump -i 2>&1 | grep 'elf32-i386' >/dev/null 2>&1; \
    then echo ''; \
    else echo "***" 1>&2; \
    echo "*** Error: Couldn't find an i386-elf version of GCC/binutils." 1>&2; \
    echo "*** Is the directory with i386-elf-gcc in your PATH?" 1>&2; \
    echo "*** If your i386-elf toolchain is installed with a command" 1>&2; \
    echo "*** prefix other than 'i386-elf-', set your GCCPREFIX" 1>&2; \
    echo "*** environment variable to that prefix and run 'make' again." 1>&2; \
    echo "*** To turn off this error, run 'gmake GCCPREFIX= ...'." 1>&2; \
    echo "***" 1>&2; exit 1; fi)
endi
# try to infer the correct QEMU
ifndef QEMU
QEMU := $(shell if which qemu-system-i386 > /dev/null; \
    then echo 'qemu-system-i386'; exit; \
    elif which i386-elf-qemu > /dev/null; \
    then echo 'i386-elf-qemu'; exit; \
    elif which qemu > /dev/null; \
    then echo 'qemu'; exit; \
    else \
    echo "***" 1>&2; \
    echo "*** Error: Couldn't find a working QEMU executable." 1>&2; \
    echo "*** Is the directory containing the qemu binary in your PATH" 1>&2; \
    echo "***" 1>&2; exit 1; fi)
endi
# eliminate default suffix rules
.SUFFIXES: .c .S .h

# delete target files if there is an error (or make is interrupted)
.DELETE_ON_ERROR:

# define compiler and flags
ifndef  USELLVM
HOSTCC      := gcc
HOSTCFLAGS  := -g -Wall -O2
CC      := $(GCCPREFIX)gcc
CFLAGS  := -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc $(DEFS)
CFLAGS  += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
else
HOSTCC      := clang
HOSTCFLAGS  := -g -Wall -O2
CC      := clang
CFLAGS  := -fno-builtin -Wall -g -m32 -mno-sse -nostdinc $(DEFS)
CFLAGS  += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
endif

CTYPE   := c S

LD      := $(GCCPREFIX)ld
LDFLAGS := -m $(shell $(LD) -V | grep elf_i386 2>/dev/null)
LDFLAGS += -nostdlib

OBJCOPY := $(GCCPREFIX)objcopy
OBJDUMP := $(GCCPREFIX)objdump

COPY    := cp
MKDIR   := mkdir -p
MV      := mv
RM      := rm -f
AWK     := awk
SED     := sed
SH      := sh
TR      := tr
TOUCH   := touch -c

OBJDIR  := obj
BINDIR  := bin

ALLOBJS :=
ALLDEPS :=
TARGETS :=
# list all files in some directories: (#directories, #types)
listf = $(filter $(if $(2),$(addprefix %.,$(2)),%),\
          $(wildcard $(addsuffix $(SLASH)*,$(1)))

上述定义了一个获取某一个目录下的所有某类型文件的表达式,该表达式可以使用call函数调用来使用,其中$(if (2),\(addprefix %.,$(2)),%)部分是用于构造一个%.某后缀形式的pattern,$(wildcard $(addsuffix $(SLASH)*,$(1))部分则是被是用来获取当前目录下的而所有文件,并且使用filter函数过滤出这所有文件中具有.$(2) (即call传入的第二个参数)后缀的文件;

# get .o obj files: (#files[, packet])
toobj = $(addprefix $(OBJDIR)$(SLASH)$(if $(2),$(2)$(SLASH)),\
        $(addsuffix .o,$(basename $(1)))

该表达式表示将传入的文件名列表中的所有后缀修改为.o,并且将其添加上这些.o文件的目录,获取到这些.o文件最终应该存放的位置;

# get .d dependency files: (#files[, packet])
todep = $(patsubst %.o,%.d,$(call toobj,$(1),$(2))

将所有.o文件的后缀名修改为.d;

totarget = $(addprefix $(BINDIR)$(SLASH),$(1))

获取由第一个参数传入的binary文件最终应当存放的位置;

# change $(name) to $(OBJPREFIX)$(name): (#names)
packetname = $(if $(1),$(addprefix $(OBJPREFIX),$(1)),$(OBJPREFIX)

给第一个参数传入的所有文件名加上$(OBJPREFIX)前缀;

# 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))
ende

这部分使用define多行定义了一个编译的模板(对单个文件进行编译成object文件),其中若干处$$表示原本的字符$,这是因为后文中将对这个部分执行eval,而$$<即原本的$<表示了依赖目标的值,$@表示了目标的值, 在本部分中,将最终生成出目标文件的依赖文件,以及定义了生成目标文件的规则;

更具体一点,该模板的前半部分是用于生成Makefile .d依赖文件(利用gcc的-MM选项),后半部分则是使用gcc编译出.o文件, 并且将所有.o文件加入到ALLOBJS变量中;

关于上述代码中的$(V)的使用,经过尝试,发现make "V="会输出gcc命令的编译选项、include目录等部分,恰好对应于上述代码中$(V)后的部分,因此猜测$(V)的使用是为了控制是否输出编译过程中的详细信息;

define do_cc_compile
$$(foreach f,$(1),$$(eval $$(call cc_template,$$(f),$(2),$(3),$(4))))
ende

表示将传入的文件列表中的每一个文件都使用cc_template进行生成编译模板;

# add files to packet: (#files, cc[, flags, packet, dir])
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__)
ende

上述代码中,首先使用call packetname生成出某一个packetname对应的makefile中变量的名字,然后使用origin查询这个变量是否已经定义过,如果为定义,则初始化该变量为空;之后使用toobj生成出该packet中所需要的生成的.o文件的文件名列表,然后将其添加到以__temp_packet__这个变量中所存的值作为名字的变量中去,并且使用cc_template生成出该packet生成.d文件和.o文件的代码;

# add objs to packet: (#objs, packet)
define do_add_objs_to_packet
__temp_packet__ := $(call packetname,$(2))
ifeq ($$(origin $$(__temp_packet__)),undefined)
$$(__temp_packet__) :=
endif
$$(__temp_packet__) += $(1)
ende

上述代码表示将某一个.o文件添加到某一个packet对应的makefile中的变量中的文件列表中去;举例,如果要添加a.o到pack这一个packet中,则结果就是__objs_这个变量会执行__objs_pack += a.o这个一个操作;

# 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
ende

上述代码表示将第一个参数传入的binary targets和第三个参数传入的object文件均添加到TARGETS变量中去,之后根据第4个参数是否传入gcc编译命令来确定是否生成编译的规则;

# finish all
define do_finish_all
ALLDEPS = $$(ALLOBJS:.o=.d)
$$(sort $$(dir $$(ALLOBJS)) $(BINDIR)$(SLASH) $(OBJDIR)$(SLASH)):
    @$(MKDIR) $$@
ende

创建编译过程中所需要的子目录;

# --------------------  function end  --------------------
# compile file: (#files, cc[, flags, dir])
cc_compile = $(eval $(call do_cc_compile,$(1),$(2),$(3),$(4)))

# add files to packet: (#files, cc[, flags, packet, dir])
add_files = $(eval $(call do_add_files_to_packet,$(1),$(2),$(3),$(4),$(5)))

# add objs to packet: (#objs, packet)
add_objs = $(eval $(call do_add_objs_to_packet,$(1),$(2)))

# add packets and objs to target (target, #packes, #objs, cc, [, flags])
create_target = $(eval $(call do_create_target,$(1),$(2),$(3),$(4),$(5)))

read_packet = $(foreach p,$(call packetname,$(1)),$($(p)))

add_dependency = $(eval $(1): $(2))

finish_all = $(eval $(call do_finish_all)

接下来这部分则是使用eval来进一步将原先设计好的编译代码的表达式中的变量替换为变量的数值,从而方便后面生成编译的规则,接下来不妨以cc_compile这个表达式的求值为例,说明Makefile中是如何生成编译规则的:

为了方便起见,不妨假设传入cc_compile这个表达式的四个参数分别为main.c, gcc, -Wall, bin, 则不妨首先计算$(call do_cc_compile,$(1),$(2),$(3),$(4))表达式的数值如下:

cc_compile  
=$(eval $(call do_cc_compile,$(1),$(2),$(3),$(4))
=$(eval $$(foreach f,$(1),$$(eval $$(call cc_template,$$(f),$(2),$(3),$(4))))
=$(eval $(foreach f, $(1), $(eval $(call cc_template, $(f), $(2), $(3), $(4)))))
=$(eval $(eval $(call cc_template, $(1), $(2), $(3), $(4)))) (since $(1)=main.c)
=$(eval $(eval 
$$(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)
))
= $(eval
$(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))
)
= $(eval
obj/main.d: main.c | $$(dir $$@)
    @gcc -I./ -Wall -MM main.c -MT "main.o main.d"> main.d
obj/main.o: main.c | $$(dir $$@)
    @echo + cc main.c
    $(V)gcc -I./ -Wall -c main.c -o main.o
)
=
obj/main.d: main.c | obj
    @gcc -I./ -Wall -MM main.c -MT "main.o main.d"> main.d 
obj/main.o: main.c | obj
    @echo + cc main.c
    $(V)gcc -I./ -Wall -c main.c -o main.o

至此通过例子演示了如果使用Makefile来生成一系列编译规则,如果对Makefile文件稍加修改(删去生成规则前面的@),则可以在make的时候得到具体执行的规则,可以发现生成obj/boot/bootasm.d, obj/boot/bootasm.o的实际执行的命令为gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -MM boot/bootmain.c -MT "obj/boot/bootmain.o obj/boot/bootmain.d"> obj/boot/bootmain.dgcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o,与上述例子中展开的结果进行对比,可以确认该分析过程的正确性;

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

该扇区最后两个字节为0X55AA;(bi该扇区有512个字节)

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

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

由于BIOS是在实模式下运行的,因此需要在tools/gdbinit里进行相应设置,将其修改为:

set arch i8086
target remote: 1234

之后再执行make debug,就可以使用gdb单步追踪BIOS的指令执行了;具体调试结果如下图所示;有图可见在刚初始化的时候,cs,eip寄存器的数值分别被初始化为0xf000, 0xfff0, 即第一个执行的指令位于内存中的0xffff0处,该指令是一条跳转指令,跳转到BIOS的主题代码所在的入口;如图所示,使用GDB进行调试可以很方便地观察指令执行过程中的所有寄存器的数值变化;

bios1.png bios2.png

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

0x7c00是bootloader的入口位置,此时CPU仍然处于实模式下,因此只需要设置实地址断点在0x7c00处即可,此时需要的tools/gdbinit文件如下:

set arch i8086
target remote: 1234
b *0x7c00
continue

右下图中的cs:ip=0:0x7c00可以知道,此时停止在了0x7c00处,也就是说断点设置正常;

bootloader1.png

2.3 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。

根据下图,发现根据实验指导书的提示设置了hook-stop之后,可以看到成功地反汇编了从0x7c00开始的执行的指令的汇编代码,与bootasm.S的入口处的代码进行比较,发现除了gdb反汇编出来的指令中没有指定位宽w(word)之外,其余内容完全一致;

// bootasm.S
...
cld
xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
...
bootloader2.png

2.4 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。

在本次实验中,选择了在内核中设置断点,由于内核是运行在32位保护模式下的,并且运行是需要符号表信息,使得可以在调试过程中得知具体运行的是哪一行C代码,因此对tools/gdbinit做若干修改如下:

file bin/kernel
target remote: 1234
b cons_init
continue

由下图可以见成功设置了在cons_init函数处的断点并且对函数进行了调试:

kernel1.png

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

bootloader中从实模式进到保护模式的代码保存在lab1/boot/bootasm.S文件下,使用x86汇编语言编写,接下来将根据源码分析进入保护模式的过程:

bootloader入口为start, 根据bootloader的相关知识可知,bootloader会被BIOS加载到内存的0x7c00处,此时cs=0, eip=0x7c00,在刚进入bootloader的时候,最先执行的操作分别为关闭中断、清除EFLAGS的DF位以及将ax, ds, es, ss寄存器初始化为0;

.globl start
start:
.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    cld                                             # String operations increment

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # Segment number zero
    movw %ax, %ds                                   # -> Data Segment
    movw %ax, %es                                   # -> Extra Segment
    movw %ax, %ss                                   # -> Stack Segment

接下来为了使得CPU进入保护模式之后能够充分使用32位的寻址能力,需要开启A20,关闭“回卷”机制;该过程主要分为等待8042控制器Inpute Buffer为空,发送P2命令到Input Buffer,等待Input Buffer为空,将P2得到的第二个位(A20选通)置为1,写回Input Buffer;接下来对应上述步骤分析bootasm中的汇编代码:

首先是从0x64内存地址中(映射到8042的status register)中读取8042的状态,直到读取到的该字节第二位(input buffer是否有数据)为0,此时input buffer中无数据;

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

接下来往0x64写入0xd1命令,表示修改8042的P2 port;

movb $0xd1, %al
outb %al, $0x64

接下来继续等待input buffer为空:

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

接下来往0x60端口写入0xDF,表示将P2 port的第二个位(A20)选通置为1;

movb $0xdf, %al
outb %al, $0x60

至此,A20开启,进入保护模式之后可以充分使用4G的寻址能力;

接下来需要设置GDT(全局描述符表),在bootasm.S中已经静态地描述了一个简单的GDT,如下所示; 值得注意的是GDT中将代码段和数据段的base均设置为了0,而limit设置为了2^32-1即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

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

因此在完成A20开启之后,只需要使用命令lgdt gdtdesc即可载入全局描述符表;接下来只需要将cr0寄存器的PE位置1,即可从实模式切换到保护模式:

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

接下来则使用一个长跳转指令,将cs修改为32位段寄存器,以及跳转到protcseg这一32位代码入口处,此时CPU进入32位模式:

    ljmp $PROT_MODE_CSEG, $protcseg

接下来执行的32位代码功能为:设置ds、es, fs, gs, ss这几个段寄存器,然后初始化栈的frame pointer和stack pointer,然后调用使用C语言编写的bootmain函数,进行操作系统内核的加载,至此,bootloader已经完成了从实模式进入到保护模式的任务;

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

不妨对bootmain.c中与读取磁盘扇区相关的代码进行分析:

首先是waitdisk函数,该函数的作用是连续不断地从0x1F7地址读取磁盘的状态,直到磁盘不忙为止;

static void
waitdisk(void) {
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}

接下来是readsect函数,其基本功能为读取一个磁盘扇区,关于具体代码的含义如下代码中注释所示:

static void
readsect(void *dst, uint32_t secno) {
    waitdisk(); // 等待磁盘到不忙为止

    outb(0x1F2, 1);             // 往0X1F2地址中写入要读取的扇区数,由于此处需要读一个扇区,因此参数为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); // 输入LBA参数的24-27位(对应到0-3位),第四位为0表示从主盘读取,其余位被强制置为1;
    outb(0x1F7, 0x20);                      // 向磁盘发出读命令0x20

    waitdisk(); // 等待磁盘直到不忙

    insl(0x1F0, dst, SECTSIZE / 4); // 从数据端口0x1F0读取数据,除以4是因为此处是以4个字节为单位的,这个从指令是以l(long)结尾这点可以推测出来;
}

根据上述代码,不妨将读取磁盘扇区的过程总结如下:

  1. 等待磁盘直到其不忙;
  2. 往0x1F2到0X1F6中设置读取扇区需要的参数,包括读取扇区的数量以及LBA参数;
  3. 往0x1F7端口发送读命令0X20;
  4. 等待磁盘完成读取操作;
  5. 从数据端口0X1F0读取出数据到指定内存中;

在bootmain.c中还有另外一个与读取磁盘相关的函数readseg,其功能为将readsect进行进一步封装,提供能够从磁盘第二个扇区起(kernel起始位置)offset个位置处,读取count个字节到指定内存中,由于上述readsect函数只能就整个扇区进行读取,因此在readseg中,不得不连不完全包括了指定数据的首尾扇区内容也要一起读取进来,此处还有一个小技巧就是将va减去了一个offset%512 Byte的偏移量,这使得就算是整个整个扇区读取,也可以使得要求的读取到的数据在内存中的起始位置恰好是指定的原始的va;

bootloader加载ELF格式的OS的代码位于bootmain.c中的bootmain函数中,接下来不妨分析这部分代码来描述加载ELF格式OS的过程:

    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

首先,从磁盘的第一个扇区(第零个扇区为bootloader)中读取OS kenerl最开始的4kB代码,然后判断其最开始四个字节是否等于指定的ELF_MAGIC,用于判断该ELF header是否合法;

    struct proghdr *ph, *eph;
    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);
    }

接下来从ELF头文件中获取program header表的位置,以及该表的入口数目,然后遍历该表的每一项,并且从每一个program header中获取到段应该被加载到内存中的位置(Load Address,虚拟地址),以及段的大小,然后调用readseg函数将每一个段加载到内存中,至此完成了将OS加载到内存中的操作;

    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bootloader所需要完成的最后一个步骤就是从ELF header中查询到OS kernel的入口地址,然后使用函数调用的方式跳转到该地址上去;至此,完整地分析了bootloader加载ELF格式的OS kernel的过程。

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

backtrace.png

根据提示完成了kdebug.c中的print_stackframe函数,实验结果如上图所示, 可见实验结果与实验指导书要求一致;接下来将结合具体代码简要描述实现过程:

  1. 首先使用read_ebp和read_eip函数获取当前stack frame的base pointer以及call read_eip这条指令下一条指令的地址,存入ebp, eip两个临时变量中;
  2. 接下来使用cprint函数打印出ebp, eip的数值;
  3. 接下来打印出当前栈帧对应的函数可能的参数,根据c语言编译到x86汇编的约定,可以知道参数存放在ebp+8指向的内存上(栈),并且第一个、第二个、第三个...参数所在的内存地址分别为ebp+8, ebp+12, ebp+16, ...,根据要求读取出当前函数的前四个参数(用可能这些参数并不是全都存在,视具体函数而定),并打印出来;
  4. 使用print_debuginfo打印出当前函数的函数名;
  5. 根据动态链查找当前函数的调用者(caller)的栈帧, 根据约定,caller的栈帧的base pointer存放在callee的ebp指向的内存单元,将其更新到ebp临时变量中,同时将eip(代码中对应的变量为ra)更新为调用当前函数的指令的下一条指令所在位置(return address),其根据约定存放在ebp+4所在的内存单元中;
  6. 如果ebp非零并且没有达到规定的STACKFRAME DEPTH的上限,则跳转到2,继续循环打印栈上栈帧和对应函数的信息;
void
print_stackframe(void) {
    uint32_t ebp = read_ebp();
    uint32_t ra = read_eip(); 
    for (int i = 0; i < STACKFRAME_DEPTH && ebp != 0; ++ i) {
        cprintf("ebp:0x%08x eip:0x%08x ", ebp, ra);
        uint32_t* ptr = (uint32_t *) (ebp + 8);
        cprintf("args:0x%08x 0x%08x 0x%08x 0x%08x\n", ptr[0], ptr[1], ptr[2], ptr[3]);
        print_debuginfo(ra - 1);
        ra = *((uint32_t *) (ebp + 4));
        ebp = *((uint32_t *) ebp);
    }
}

接下来分析最后一行输出各个数值的意义:

ebp:0x00007bf8 eip:0x00007d6e args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
    <unknow>: -- 0x00007d6d --

根据上述打印栈帧信息的过程,可以推测出打印出的ebp是第一个被调用函数的栈帧的base pointer,eip是在该栈帧对应函数中调用下一个栈帧对应函数的指令的下一条指令的地址(return address),而args是传递给这第一个被调用的函数的参数,为了验证这个想法,不妨在反汇编出来的kernel.asm和bootblock.asm中寻找0x7d6e这个地址,可以发现这个地址上的指令恰好是bootmain函数中调用OS kernel入口函数的指令的下一条,也就是说最后一行打印出来的是bootmain这个函数对应的栈帧信息,其中ebp表示该栈帧的base pointer,eip表示在该函数内调用栈上的下一个函数指令的返回地址,而后面的args则表示传递给bootmain函数的参数,但是由于bootmain函数不需要任何参数,因此这些打印出来的数值并没有太大的意义,后面的unkonw之后的0x00007d6d则是bootmain函数内调用OS kernel入口函数的该指令的地址;

关于其他每行输出中各个数值的意义为:ebp, eip等这一行数值意义与上述一致,下一行的输出调试信息,在*.c之后的数字表示当前所在函数进一步调用其他函数的语句在源代码文件中的行号,而后面的+22一类数值表示从该函数汇编代码的入口处到进一步调用其他函数的call指令的最后一个字节的偏移量,以字节为单位;

ebp:0x00007b38 eip:0x00100a28 args:0x00010094 0x00010094 0x00007b68 0x0010007f
    kern/debug/kdebug.c:306: print_stackframe+22

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

  1. 中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
  1. 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中, 依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个 中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
  1. 请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中 处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向 屏幕上打印一行文字”100 ticks”。

最终实验结果如下图所示:

ticks.png

拓展练习

扩展练习 Challenge 1

从内核态切换到用户态

由于实验指导书要求使用中断处理的形式进行从内核态的用户态的切换,因此不妨考虑在ISR中进行若干对硬件保存的现场的修改,伪造出一个从用户态切换到内核态的中断的现场,然后使用iret指令进行返回,就能够实现内核态到用户态的切换,具体实现如下所示:

首先由于OS kernel一开始就是运行在内核态下的,因此使用int指令产生软中断的时候,硬件保存在stack上的信息中并不会包含原先的esp和ss寄存器的值,因此不妨在调用int指令产生软中断之前,使用pushl指令预想将设置好的esp和ss的内容push到stack上,这样就可以使得进入ISR的时候,trapframe上的形式和从用户态切换到内核态的时候保存的trapframe一致;具体代码实现为在lab1_switch_to_user函数中使用内联汇编完成,如下所示:

    asm volatile (
            "movw %%ss, %0\n\t"
            "movl %%esp, %1"
            : "=a"(ss), "=b"(esp)
         );
    asm volatile (
            "pushl %0\n\t"
            "pushl %1\n\t"
            "int %2"
            :
            : "a"(ss), "b"(esp), "i"(T_SWITCH_TOU)
         );

接下来在调用了int指令之后,会最终跳转到T_SWITCH_TOU终端号对应的ISR入口,最终跳转到trap_dispatch函数处统一处理,接下来的任务就是在处理部分修改trapframe的内容,首先为了使得程序在低CPL的情况下仍然能够使用IO,需要将eflags中对应的IOPL位置成表示用户态的3,接下来根据iret指令在ISA手册中的相关说明,可知iret认定在发生中断的时候是否发生了PL的切换,是取决于CPL和最终跳转回的地址的cs选择子对应的段描述符处的CPL(也就是发生中断前的CPL)是否相等来决定的,因此不妨将保存在trapframe中的原先的cs修改成指向用户态描述子的USER_CS,然后为了使得中断返回之后能够正常访问数据,将其他的段选择子都修改为USER_DS, 然后正常中断返回;具体实现代码如下所示:

    tf->tf_eflags |= FL_IOPL_MASK;
    tf->tf_cs = USER_CS;
    tf->tf_ds = tf->tf_es = tf->tf_gs = tf->tf_ss = tf->tf_fs = USER_DS;

事实上上述代码是具有不安全性的,这是由于在lab1中并没有完整地实现物理内存的管理,而GDT中的每一个段其实除了关于特权级的要求之外内容都是一样的,都是从0x0开始的4G空间,这就使得用户能够访问到内核栈的空间,即事实上上述代码并没有实际完成一个从内核栈到用户态栈的切换,仅仅是完成了特权级的切换而已;

至此完成了从内核态切换到用户态的要求;

从用户态切换到内核态

接下来考虑在用户态切换到内核态的情况,为了使得能够在用户态下产生中断号为T_SWITCH_TOK的软中断,需要在IDT初始化的时候,将该终端号对应的表项的DPL设置为3;接下来考虑在进行用户态切换到内核态的函数中使用int指令产生一个软中断,转到ISR,然后与切换到内核态类似的对保存的trapframe进行修改,即将trapframe中保存的cs修改为指向DPL为0的段描述子的段选择子KERNEL_CS,并且将ds, es, ss, gs, fs也相应地修改为KERNEL_DS,然后进行正常的中断返回,由于iret指令发现CPL和保存在栈上的cs的CPL均为0,因此不会进行特权级的切换,因此自然而不会切换栈和将栈上保存的ss和esp弹出。这就产生了中断返回之后,栈上的内容没能够正常恢复的问题,因此需要在中断返回之后将栈上保存的原本应当被恢复的esp给pop回到esp上去,这样才算是完整地完成了从用户态切换到内核态的要求;

具体实现的核心代码如下所示:

    // 在ISR中修改trapframe的代码
    case T_SWITCH_TOK:
    tf->tf_cs = KERNEL_CS;
    tf->tf_ds = tf->tf_es = tf->tf_gs = tf->tf_ss = tf->tf_fs = KERNEL_DS;
        break;
static void // 从用户态切换到内核态的函数
lab1_switch_to_kernel(void) { 
    asm volatile (
            "int %0\n\t" // 使用int指令产生软中断
            "popl %%esp" // 恢复esp
            :
            : "i"(T_SWITCH_TOK)
        );
}

最终实验结果如下图所示:


challenge1.png

扩展练习 Challenge 2

拓展练习2的内容为实现“键盘输入3的时候切换到用户模式,输入0的时候进入内核模式”, 该功能的实现基本思路与拓展练习1较为类似,但是具体实现却要困难需要,原因在于拓展1的软中断是故意在某一个特定的函数中触发的,因此可以在触发中断之前对堆栈进行设置以及在中断返回之后对堆栈内容进行修复,但是如果要在触发键盘中断的时候切换特权级,由于键盘中断是异步的,无法确定究竟是在哪个指令处触发了键盘中断,因此在触发中断前对堆栈的设置以及在中断返回之后对堆栈的修复也无从下手;(需要对堆栈修复的原因在于,使用iret来切换特权级的本质在于伪造一个从某个指定特权级产生中断所导致的现场对CPU进行欺骗,而是否存在特权级的切换会导致硬件是否在堆栈上额外压入ss和esp以及进行堆栈的切换,这使得两者的堆栈结构存在不同)

因此需要考虑在ISR中在修改trapframe的同时对栈进行更进一步的伪造,比如在从内核态返回到用户态的时候,在trapframe里额外插入原本不存在的ss和esp,在用户态返回到内核态的时候,将trapframe中的esp和ss删去等,更加具体的实现方法如下所示:

注:最终提交的代码中,为了防止上述while (1)循环中打印状态的输出对其他实验内容的输出结果产生干扰,已经将相关打印的代码注释掉了,因此如果需要获得下图的输出效果,如要将init.c中kern_init函数的while (1)循环中打印状态的语句的注释解除掉;

challenge2.png

参考答案分析

知识点列举

在本实验设计到的知识点分别有:

对应到的OS中的知识点分别有:

两者的关系为,前者硬件中的机制为OS中相应功能的实现提供了底层支持;

实验未涉及知识点列举

OS原理中很重要,但是本次实验没有涉及到的知识点有:

实验代码

https://github.com/AmadeusChan/ucore_os_lab/tree/master/lab1

参考资料

如果我的文章给您带来了帮助,并且你愿意给我一些小小的支持的话,以下这个是我的比特币地址~
My bitcoin address: 3KsqM8tef5XJ9jPvWGEVXyJNpvyLLsrPZj

上一篇下一篇

猜你喜欢

热点阅读