一、实验目的
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:
1.计算机原理
- CPU的编址与寻址:基于分段机制的内存管理
- CPU的中断机制
- 外设:串口/并口/CGA,时钟,硬盘
2.Bootloader软件 - 编译运行bootloader的过程
- 调试bootloader的方法
- PC启动bootloader的过程
- ELF执行文件的格式和加载
- 外设访问:读硬盘,在CGA上显示字符串
3.Ucore OS软件 - 编译运行ucore OS的过程
- ucore OS的启动过程
- 调试ucore OS的方法
- 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
- 中断管理:与软件相关的中断处理
- 外设管理:时钟
二、实验内容
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。
三、实验练习
1.练习1:理解通过make生成执行文件的过程。(要求在报告中写出对下述问题的回答)
1.1操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
① 进入Ubuntu20.04,打开终端,通过sudo su指令,输入密码,从普通用户切换到root用户。
② 因为我之前已经换过源了,所以这个实验里不用再换源。输入apt install qemu-system-i386,再输入y,安装qemu
③ 输入sudo dj或ctrl+d或exit切换回普通用户,实验准备部分结束。
④ 进入到OS/ucore_os_lab-master/labcodes/lab1目录下,输入指令make
⑤ 如果连续两次输入make/make V=,会提示没有更改,这时可以make clean后再次make V=
⑥ 运行make V=,观察每一步的make指令,得到以下所有输出:
lab1是ucore的开始,部分函数未完成调用会出现warning
其中,除kernel部分外:
链接生成kernel:
bootblock部分:
链接生成bootblock:
生成ucore.img:
ld部分,由bootmain.o和bootasm.o来生成,再通过gcc编译。
有一个0x7c00是偏移量,如果直接对bootasm.o进行调试,地址是不对的,得加上0x7c00这个偏移量
⑦ 通过man查看,如看ld则输入man ld,回车即可
⑧ Makefile文件内容如下:
PROJ := challenge
EMPTY :=
SPACE := $(EMPTY) $(EMPTY)
SLASH := /
##make “V=”可输出make执行的命令
V := @
#need llvm/cang-3.5+
#USELLVM := 1
##选择交叉编译器检查GCCPREFIX
# try to infer the correct GCCPREFX
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)
endif
##设置QEMU
# 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)
endif
# eliminate default suffix rules
.SUFFIXES: .c .S .h
##如果遇到error或者被中断了就删除所有目标文件
# delete target files if there is an error (or make is interrupted)
.DELETE_ON_ERROR:
##设置编译器选项
# define compiler and flags
ifndef USELLVM
##gcc编译,-g为了gdb调试,-Wall生成警告信息,-02优化处理级别
HOSTCC := gcc
HOSTCFLAGS := -g -Wall -O2
CC := $(GCCPREFIX)gcc
##-fno-builtin不使用C语言的内建函数,-ggdb为GDB生成更丰富的调试信息,-m32用32位编译,-gstabs生成stabs格式调试信息但不包括GDB调试信息,-nostdinc不在系统默认头文件目录中寻找文件,$(DEFS)未定义可用来扩展信息
CFLAGS := -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc $(DEFS)
##$(shell)可以输出shell指令,-fno-stack-protector禁用堆栈保护,-E仅预处理不进行编译汇编链接可以提高速度,-x c指明C语言
##/dev/null指定目标文件,>/dev/null 2>&1标准错误重定向到标准输出,&&先运行前一句,若成功再运行后一句
##意为只预处理,所有出错全部作为垃圾(/dev/null类似垃圾文件)测试能否开启-fno-stack-protector,若能则CFLAGS += -fno-stack-protector
CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
else
##若使用clang,类似处理
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
##源文件类型为.c和.s
CTYPE := c S
LD := $(GCCPREFIX)ld
##shell中命令shell中命令 ld -V可以输出支持的版本,|管道将前者的输出作为后者的输入,grep在输入中搜索elf_i386字串,找到就输出elf_i386
##意味如果支持elf_i386则LDFLAGS := -m elf_i386
LDFLAGS := -m $(shell $(LD) -V | grep elf_i386 2>/dev/null)
##-nostdlib不连接系统标准库文件
LDFLAGS += -nostdlib
OBJCOPY := $(GCCPREFIX)objcopy
OBJDUMP := $(GCCPREFIX)objdump
##定义一些shell命令
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 :=
##在function.mk中定义了大量辅助函数,部分说明参考了引用中的博文
include tools/function.mk
##call:call func,变量1,变量2,...
##listf:列出某地址下某类型的文件
##listf_cc:列出变量1下的.c与.S文件
listf_cc = $(call listf,$(1),$(CTYPE))
# for cc
##将文件打包
add_files_cc = $(call add_files,$(1),$(CC),$(CFLAGS) $(3),$(2),$(4))
##创建目标文件包
create_target_cc = $(call create_target,$(1),$(2),$(3),$(CC),$(CFLAGS))
# for hostcc
add_files_host = $(call add_files,$(1),$(HOSTCC),$(HOSTCFLAGS),$(2),$(3))
create_target_host = $(call create_target,$(1),$(2),$(3),$(HOSTCC),$(HOSTCFLAGS))
##patsubst替换通配符
##cgtype(filenames,type1,type2)把文件名中type1的改为type2,如.c改为.o
cgtype = $(patsubst %.$(2),%.$(3),$(1))
##列出所有目标文件,并按规则改后缀名
objfile = $(call toobj,$(1))
asmfile = $(call cgtype,$(call toobj,$(1)),o,asm)
outfile = $(call cgtype,$(call toobj,$(1)),o,out)
symfile = $(call cgtype,$(call toobj,$(1)),o,sym)
# for match pattern
match = $(shell echo $(2) | $(AWK) '{for(i=1;i<=NF;i++){if(match("$(1)","^"$$(i)"$$")){exit 1;}}}'; echo $$?)
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# include kernel/user
INCLUDE += libs/
CFLAGS += $(addprefix -I,$(INCLUDE))
LIBDIR += libs
$(call add_files_cc,$(call listf_cc,$(LIBDIR)),libs,)
# -------------------------------------------------------------------
# kernel
KINCLUDE += kern/debug/ \
kern/driver/ \
kern/trap/ \
kern/mm/
KSRCDIR += kern/init \
kern/libs \
kern/debug \
kern/driver \
kern/trap \
kern/mm
KCFLAGS += $(addprefix -I,$(KINCLUDE))
$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))
KOBJS = $(call read_packet,kernel libs)
# create kernel target
##将所有文件链接生成kernel
kernel = $(call totarget,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)
# -------------------------------------------------------------------
# create bootblock
##将所有文件链接生成bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
bootblock = $(call totarget,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)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
$(call create_target,bootblock)
# -------------------------------------------------------------------
# create 'sign' tools
##生成sign辅助工具
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
# -------------------------------------------------------------------
##生成ucore.img
# create 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)
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
$(call finish_all)
IGNORE_ALLDEPS = clean \
dist-clean \
grade \
touch \
print-.+ \
handin
ifeq ($(call match,$(MAKECMDGOALS),$(IGNORE_ALLDEPS)),0)
-include $(ALLDEPS)
endif
# files for grade script
TARGETS: $(TARGETS)
.DEFAULT_GOAL := TARGETS
.PHONY: qemu qemu-nox debug debug-nox
qemu-mon: $(UCOREIMG)
$(V)$(QEMU) -no-reboot -monitor stdio -hda $< -serial null
qemu: $(UCOREIMG)
$(V)$(QEMU) -no-reboot -parallel stdio -hda $< -serial null
log: $(UCOREIMG)
$(V)$(QEMU) -no-reboot -d int,cpu_reset -D q.log -parallel stdio -hda $< -serial null
qemu-nox: $(UCOREIMG)
$(V)$(QEMU) -no-reboot -serial mon:stdio -hda $< -nographic
TERMINAL :=gnome-terminal
debug: $(UCOREIMG)
$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
$(V)sleep 2
$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
debug-nox: $(UCOREIMG)
$(V)$(QEMU) -S -s -serial mon:stdio -hda $< -nographic &
$(V)sleep 2
$(V)$(TERMINAL) -e "gdb -q -x tools/gdbinit"
.PHONY: grade touch
GRADE_GDB_IN := .gdb.in
GRADE_QEMU_OUT := .qemu.out
HANDIN := proj$(PROJ)-handin.tar.gz
TOUCH_FILES := kern/trap/trap.c
MAKEOPTS := --quiet --no-print-directory
grade:
$(V)$(MAKE) $(MAKEOPTS) clean
$(V)$(SH) tools/grade.sh
touch:
$(V)$(foreach f,$(TOUCH_FILES),$(TOUCH) $(f))
print-%:
@echo $($(shell echo $(patsubst print-%,%,$@) | $(TR) [a-z] [A-Z]))
.PHONY: clean dist-clean handin packall tags
clean:
$(V)$(RM) $(GRADE_GDB_IN) $(GRADE_QEMU_OUT) cscope* tags
-$(RM) -r $(OBJDIR) $(BINDIR)
dist-clean: clean
-$(RM) $(HANDIN)
handin: packall
@echo Please visit http://learn.tsinghua.edu.cn and upload $(HANDIN). Thanks!
packall: clean
@$(RM) -f $(HANDIN)
@tar -czf $(HANDIN) `find . -type f -o -type d | grep -v '^\.*$$' | grep -vF '$(HANDIN)'`
tags:
@echo TAGS ALL
$(V)rm -f cscope.files cscope.in.out cscope.out cscope.po.out tags
$(V)find . -type f -name "*.[chS]" >cscope.files
$(V)cscope -bq
$(V)ctags -L cscope.files
⑨ 总结:
根据生成ucore的代码可知:
首先要生成一个有10000个块的文件,每个块默认512字节,用0填充dd if=/dev/zero of=bin/ucore.img count=10000。
接着把bootblock中的内容写到第一个块
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
从第二个块开始写kernel中的内容
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
dd的一些参数的含义:
-if表示输入文件,如果不指定,那么会默认从stdin中读取输入
-of表示输出文件,如果不指定,那么会stdout
bs表示以字节为单位的块大小
count表示被赋值的块数
/dev/zero是一个字符设备,会不断返回0值字节\0
conv = notrunc 不截短输出文件
seek=blocks 从输出文件开头跳过blocks个块后再开始复制
这样ucore.img就一步一步的生成了。
1.2一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
答:
磁盘主引导扇区只有512字节
磁盘最后两个字节为0x55AA
由不超过466字节的启动代码和不超过64字节的硬盘分区表加上两个字节的结束符组成
在tools/sign.c中有相应代码用来检测是否符合要求。执行指令less tools/sign.c,查看代码段,截图如下:
2.练习2:使用qemu执行并调试lab1中的软件
① 输入qemu-system-i386 -S -s -hda bin/ucore.img,进入qemu界面
② 输入gdb,开始调试。
③ 再输入target remote:1234连接qemu
④ 输入file bin/指定要调试的文件,如file obj/bootblock.o,输入y进入对应的.o文件进行调试。输入b *0x7c00,在0x7c00处设置断点,再输入c,继续调试。再输入layout asm,进入调试框界面。
⑤ 通过layout split将窗口分为三部分,分别为代码、汇编代码、命令窗口。
⑥ 命令窗口输入si,进入下一条指令;b bootasm.S:51在文件第51行加入断点。
⑦ counting,再输入info registers查看寄存器值
⑧ 输入info registers cr0查看cr0里的值
⑨ 从汇编代码可以看出,cr0的值先放入eax中,再由eax值加1后返回给cr0。通过调试检查是否如此
⑩ 其他一些调试指令:
b bootmain:在bootmain函数处设置断点
bt:查看当前函数调用情况
p:print
s:单步执行
n:进入下一行代码
3.练习3:分析bootloader进入保护模式的过程
① Bootasm.S部分源码分析
② 总结:
先设置寄存器ax,ds,es,ss寄存器值为0;地址线20被封锁,高于1MB的地址默认回卷到0。由于历史原因,A20地址位由键盘控制器芯片8042管理,所有激活A20要给8042发命令激活。
8042有两个I/O端口:0x60和0x64,激活流程位:发送0xd1命令到0x64端口后,发送0xdf到0x60
从实模式转换到保护模式:用到了全局描述符和段表,使得虚拟地址和物理地址匹配,保证转换时有效的内存映射不改变;lgdt汇编指令把GDTR描述符的大小和起始位置存入gdtr寄存器中;将cr0的最后一位设置为1,进入保护模式;指令跳转由代码段跳到protcseg的起始位置。
③ 问题:
为何开启A20,以及如何开区A20
答:
为了兼顾早期的PC机,第20根地址线在实模式下不能使用,超过1MB的地址会默认返回到地址0,所以需要开启A20.
通过将键盘控制器上的A20线置于高电位,全部32条地址线可用,可以访问4G的内存空间。
如何初始化GDT表
答:一个简单的GDT表和其描述符已经静态存储在引导区中,载入lgdt gdtdesc即可初始化GDT表
如何使能和进入保护模式
答:通过将cr0寄存器最低位置1,开启保护模式
4.练习4:分析bootloader加载ELF格式的OS的过程
4.1bootloader如何读取硬盘扇区的?
① 打开bootmain,在bootmain中第一句就是读取硬盘扇区,截图如下:
② 大致流程:等待磁盘准备好->发出读取扇区的命令->等待磁盘准备好->把磁盘扇区数据读取到指定内存
③ readsect从设备的第secno扇区读取数据到dst位置
I/O地址 功能
0x1f0 读数据,当0x1f7不为忙状态时,可以读。
0x1f2 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区
0x1f3 如果是LBA模式,就是LBA参数的0-7位
0x1f4 如果是LBA模式,就是LBA参数的8-15位
0x1f5 如果是LBA模式,就是LBA参数的16-23位
0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘
0x1f7 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据
4.2bootloader是如何加载ELF格式的OS?
1.从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
2.校验e_magic字段;
3.根据偏移量分别把程序段的数据读取到内存中。
5.实现函数调用堆栈跟踪函数(需要编程)
① 函数堆栈的基本原理
一个函数调用动作可分解为:零到多个PUSH指令(用于参数入栈),一个CALL指令。CALL指令内部其实还暗含了一个将返回地址(即CALL指令下一条指令的地址)压栈的动作(由硬件完成)。
几乎所有本地编译器都会在每个函数体之前插入类似pushl %ebp 和movl %esp , %ebp的指令。这样在程序执行到一个函数的实际指令前,已经有以下数据顺序入栈:参数、返回地址、ebp寄存器。
基本栈结构如下图:
一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层ebp值。由于ebp中的地址处总是“上一层函数调用时的ebp值”,而在每一层函数调用中,都能通过当时的ebp值“向上(栈底方向)”能获取返回地址、参数值,“向下(栈顶方向)”能获取函数局部变量值。如此形成递归,直至到达栈底。这就是函数调用栈。
② 进入lab1/kern/debug目录下的kdebug.c,实现print_stackframe函数。函数实现如下图:
③ 在lab1下执行make qemu,在qemu模拟器中得到结果,符合预期。
6.完善中断初始化和处理(需要编程)
6.1中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
答:
中断描述符表中,一个表项占8字节,其中015位和4863为分别为offset的低16位和高16位。16~31位为段选择子。通过段选择子获得段基址,加上段内偏移量即可获得到中断处理代码的路口。
6.2请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
① 编程完善kern/trap/trap.c中的函数idt_init,实现如下:
② 利用了SETGATE宏,在mmu.h中查看这个宏
gate:为相应的idt数组内容,处理函数的入口地址
istrap:系统段设置为1,中断门设置为0
sel:段选择子,这里是GO-KTEXT
#define GD_KTEXT ((SEG_KTEXT)<<3) //kernel text
off:为_vectors数字内容,存在vectors.s中,支持256个中断
dpl:设置优先级,0为内核级,3为用户级
6.3请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
编程完善trap.c中的中断处理函数trap_dispatch,实现如下:
2)运行结果如下,实现中断。