UCORE实验1
实验目的
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:
计算机原理 CPU的编址与寻址: 基于分段机制的内存管理 CPU的中断机制 外设:串口/并口/CGA,时钟,硬盘
Bootloader软件 编译运行bootloader的过程 调试bootloader的方法 PC启动bootloader的过程 ELF执行文件的格式和加载 外设访问:读硬盘,在CGA上显示字符串
ucore OS软件 编译运行ucore OS的过程 ucore OS的启动过程 调试ucore OS的方法 函数调用关系:在汇编级了解函数调用栈的结构和处理过程 中断管理:与软件相关的中断处理 外设管理:时钟
实验内容
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。 练习1 练习2 练习3 练习4 练习5 练习6
练习1:理解通过make生成执行文件的过程。
通过静态分析代码来了解: 1.操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果) 2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
补充材料: 如何调试Makefile 当执行make时,一般只会显示输出,不会显示make到底执行了哪些命令。 如想了解make执行了哪些命令,可以执行: $ make "V=" 要获取更多有关make的信息,可上网查询,并请执行 $ man make
分析
1.操作系统镜像文件ucore.img是如何一步一步生成的?
首先在lab1目录下打开终端运行make V ,相当于是设置一个标记使得make详细的编译执行过程可以展示出来:
yeyuhl@ubuntu:~/code/os_kernel_lab-master/ucore_os_lab/labcodes/lab1$ make V=
+ cc kern/init/init.c
gcc -Ikern/init/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o
kern/init/init.c:95:1: warning: ‘lab1_switch_test’ defined but not used [-Wunused-function]
95 | lab1_switch_test(void) {
| ^~~~~~~~~~~~~~~~
+ cc kern/libs/stdio.c
gcc -Ikern/libs/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/stdio.c -o obj/kern/libs/stdio.o
+ cc kern/libs/readline.c
gcc -Ikern/libs/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/readline.c -o obj/kern/libs/readline.o
+ cc kern/debug/panic.c
gcc -Ikern/debug/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/panic.c -o obj/kern/debug/panic.o
kern/debug/panic.c: In function ‘__panic’:
kern/debug/panic.c:27:5: warning: implicit declaration of function ‘print_stackframe’; did you mean ‘print_trapframe’? [-Wimplicit-function-declaration]
27 | print_stackframe();
| ^~~~~~~~~~~~~~~~
| print_trapframe
+ cc kern/debug/kdebug.c
gcc -Ikern/debug/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kdebug.c -o obj/kern/debug/kdebug.o
kern/debug/kdebug.c:251:1: warning: ‘read_eip’ defined but not used [-Wunused-function]
251 | read_eip(void) {
| ^~~~~~~~
+ cc kern/debug/kmonitor.c
gcc -Ikern/debug/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kmonitor.c -o obj/kern/debug/kmonitor.o
+ cc kern/driver/clock.c
gcc -Ikern/driver/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/clock.c -o obj/kern/driver/clock.o
+ cc kern/driver/console.c
gcc -Ikern/driver/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/console.c -o obj/kern/driver/console.o
+ cc kern/driver/picirq.c
gcc -Ikern/driver/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/picirq.c -o obj/kern/driver/picirq.o
+ cc kern/driver/intr.c
gcc -Ikern/driver/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/intr.c -o obj/kern/driver/intr.o
+ cc kern/trap/trap.c
gcc -Ikern/trap/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trap.c -o obj/kern/trap/trap.o
kern/trap/trap.c: In function ‘print_trapframe’:
kern/trap/trap.c:100:16: warning: taking address of packed member of ‘struct trapframe’ may result in an unaligned pointer value [-Waddress-of-packed-member]
100 | print_regs(&tf->tf_regs);
| ^~~~~~~~~~~~
At top level:
kern/trap/trap.c:30:26: warning: ‘idt_pd’ defined but not used [-Wunused-variable]
30 | static struct pseudodesc idt_pd = {
| ^~~~~~
kern/trap/trap.c:14:13: warning: ‘print_ticks’ defined but not used [-Wunused-function]
14 | static void print_ticks() {
| ^~~~~~~~~~~
+ cc kern/trap/vectors.S
gcc -Ikern/trap/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/vectors.S -o obj/kern/trap/vectors.o
+ cc kern/trap/trapentry.S
gcc -Ikern/trap/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trapentry.S -o obj/kern/trap/trapentry.o
+ cc kern/mm/pmm.c
gcc -Ikern/mm/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/mm/pmm.c -o obj/kern/mm/pmm.o
+ cc libs/string.c
gcc -Ilibs/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -c libs/string.c -o obj/libs/string.o
+ cc libs/printfmt.c
gcc -Ilibs/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -c libs/printfmt.c -o obj/libs/printfmt.o
+ ld bin/kernel
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
+ cc boot/bootasm.S
gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
+ cc boot/bootmain.c
gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
+ cc tools/sign.c
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 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: 496 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB, 4.9 MiB) copied, 0.0117931 s, 434 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes copied, 0.000108956 s, 4.7 MB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
154+1 records in
154+1 records out
78916 bytes (79 kB, 77 KiB) copied, 0.000387761 s, 204 MB/s
根据以上编译过程,我们可以把ucore.img生成分成以下几个步骤:
(1)调用GCC将部分.c和.S文件,编译成了目标文件。 kern/init目录:init.c(系统初始化部分) kern/libs目录:stdio.c、readline.c(公共库部分) kern/debug目录:panic.c、kdebug.c、kmonitor.c(内核调试部分) kern/driver目录:clock.c、console.c、picirq.c、intr.c(外设驱动部分) kern/trap目录:trap.c、vectors.S、trapentry.S(中断处理部分) kern/mm目录:pmm.c(内存管理部分) libs目录:string.c、printfmt.c(公共库部分) 即代码+ cc kern/init/init.c 以下到+ ld bin/kernel 以上的过程。 我们截取一个文件的编译过程来讲述相关命令和命令参数的含义,以及说明命令导致的结果:
+ cc kern/libs/readline.c
gcc -Ikern/libs/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/readline.c -o obj/kern/libs/readline.o
关键参数: -march=i686:编译生成的目标文件指令集架构为i686,即Intel 32位指令集。 -fno-builtin:只识别以_builtin_作为前缀的内建函数,并可对其进行优化,并且防止内核代码函数名与内建函数名冲突而被优化的问题(内建函数就是编译器内部使用的函数)。 -fno-PIC:PIC(position independent code),使用绝对位置,而不是相对位置。 -Wall:显示所有编译警告。 -ggdb:尽可能的生成 gdb 的可以使用的调试信息。 -m32:生成32位目标文件。 -gstabs:以 stabs 格式声称调试信息,但是不包括 gdb 调试信息。 -nostdinc:生成OS时不应包含C语言标准库函数,需要自己实现,因此对于C文件里包含的头文件应给出头文件所在目录,如-Ikern/init/、-Ikern/driver/,已-I开头+文件夹表示寻找头文件的路径。 -fno-stack-protector:禁用堆栈保护器。
(2)通过ld,将目标文件链接,生成kernel可执行文件。 使用ld命令链接上面生成的各目标文件,并根据tools/kernel.ld脚本文件进行链接,链接后生成bin/kernel即OS内核文件。
+ ld bin/kernel
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
关键参数: -m:模拟指定的连接器。 -nostdlib:不使用标准库。 -T:指定命令文件。 -o:指定输出文件的名称。
(3)编译并链接生成bootloader。 首先使用gcc将bootasm.S(定义并实现了bootloader最先执行的函数start,此函数进行了一定的初始化,完成了从实模式到保护模式的转换,并调用bootmain.c中的bootmain函数)、bootmain.c(定义并实现了bootmain函数实现了通过屏幕、串口和并口显示字符串。bootmain函数加载ucore操作系统到内存,然后跳转到ucore的入口处执行)生成目标文件,再使用ld将两个目标文件链接,设置entry入口为start段,代码段起始位置为0x7c00,使用sign程序将bootblock.o文件添加主引导扇区的标志,使其作为bootloader。
+ cc boot/bootasm.S
gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
+ cc boot/bootmain.c
gcc -Iboot/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
+ cc tools/sign.c
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 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: 496 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
(4)生成OS镜像文件 dd是一个Unix和类Unix系统上的命令,主要功能为转换和复制文件,这里使用dd来生成最终的ucore镜像文件,块大小(bs)默认为512B。 使用/dev/zero虚拟设备,生成10000个块的空字符(0x00),每个块大小为512B,因此ucore.img总大小为5,120,000B。 接下来两行代码中的转换选项为notrunc,意味着不缩减输出文件。换言之,如果输出文件已经存在,那么只改变指定的字节,然后退出,并保留输出文件的剩余部分。如果没有这个选项,dd命令将创建一个512B长的文件。 将bootloader(bin/bootblock文件)代码复制到ucore.img文件头处,共512B大小,即只修改ucore.img的文件头处的512B。 将kernel(bin/kernel文件)复制到ucore.img距文件头偏移1个块大小的地方,也即ucore.img前512B放bootloader,紧接着放kernel。
dd if=/dev/zero of=bin/ucore.img count=10000
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
总结:ucore.img是一个包含了bootloader和OS的硬盘镜像。kernel即内核,一个用来管理软件发出的资料I/O(输入与输出)要求的电脑程序,将这些要求转译为资料处理的指令并交由中央处理器(CPU)及电脑中其他电子组件进行处理。bootloader即引导加载器,一个用来通电后自检并引导装载操作系统或其他系统软件的计算机程序。ucore.img的生成实际上也是在生成这两个程序。
2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
由上一问可知,生成bootloader时,调用了sign程序用于生成一个符合规范的硬盘主引导扇区,因此我们截取了sign.c的部分代码来进行分析。
char buf[512];
memset(buf, 0, sizeof(buf));
FILE *ifp = fopen(argv[1], "rb");
int size = fread(buf, 1, st.st_size, ifp);
if (size != st.st_size) {
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return -1;
}
fclose(ifp);
buf[510] = 0x55; //把buf数组的最后两位置为 0x55, 0xAA
buf[511] = 0xAA;
FILE *ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) { //大小为512字节
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
fclose(ofp);
printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
该程序主要功能是将输入文件的第511个字节设为0x55,第512个字节设为0xAA。因此系统对于主引导扇区所要求的是:大小为512字节,第510个(倒数第二个)字节是0x55,第511个(倒数第一个)字节是0xAA,其余字节填充为0。
练习2:使用qemu执行并调试lab1中的软件。
为了熟悉使用qemu和gdb进行的调试工作,我们进行如下的小练习: 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。 在初始化位置0x7c00设置实地址断点,测试断点正常。 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
提示:参考附录“启动后第一条执行的指令”,可了解更详细的解释,以及如何单步调试和查看BIOS代码。 提示:查看 labcodes_answer/lab1_result/tools/lab1init 文件,用如下命令试试如何调试bootloader第一条指令:
$ cd labcodes_answer/lab1_result/
$ make lab1-mon
补充材料: 我们主要通过硬件模拟器qemu来进行各种实验。在实验的过程中我们可能会遇上各种各样的问题,调试是必要的。qemu支持使用gdb进行的强大而方便的调试。所以用好qemu和gdb是完成各种实验的基本要素。 默认的gdb需要进行一些额外的配置才进行qemu的调试任务。qemu和gdb之间使用网络端口1234进行通讯。在打开qemu进行模拟之后,执行gdb并输入 target remote localhost:1234 即可连接qemu,此时qemu会进入停止状态,听从gdb的命令。 另外,我们可能需要qemu在一开始便进入等待模式,则我们不再使用make qemu开始系统的运行,而使用make debug来完成这项工作。这样qemu便不会在gdb尚未连接的时候擅自运行了。
gdb的地址断点 在gdb命令行中,使用b *[地址]便可以在指定内存地址设置断点,当qemu中的cpu执行到指定地址时,便会将控制权交给gdb。 关于代码的反汇编 有可能gdb无法正确获取当前qemu执行的汇编指令,通过如下配置可以在每次gdb命令行前强制反汇编当前的指令,在gdb命令行或配置文件中添加:
define hook-stop
x/i $pc
end
即可
gdb的单步命令 在gdb中,有next, nexti, step, stepi等指令来单步调试程序,他们功能各不相同,区别在于单步的“跨度”上。 next:单步到程序源代码的下一行,不进入函数。 nexti:单步一条机器指令,不进入函数。 step:单步到下一个不同的源代码行(包括进入函数)。 stepi:单步一条机器指令。
分析
1.从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
相关背景知识: BIOS启动过程:当计算机加电后,一般不直接执行操作系统,而是执行系统初始化软件完成基本IO初始化和引导加载功能。简单地说,系统初始化软件就是在操作系统内核运行之前运行的一段小软件。对于Intel 80386的体系结构而言,PC机中的系统初始化软件由BIOS 和位于软盘/硬盘引导扇区中的OS Boot Loader一起组成。以Intel 80386为例,计算机加电后,CPU从物理地址0xFFFFFFF0(由初始化的CS:EIP确定,此时CS和IP的值分别是0xF000和0xFFF0))开始执行。在0xFFFFFFF0这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点。BIOS做完计算机硬件自检和初始化后,会选择一个启动设备(例如软盘、硬盘、光盘等),并且读取该设备的第一扇区(即主引导扇区或启动扇区)到内存一个特定的地址0x7c00处,然后CPU控制权会转移到那个地址继续执行。至此BIOS的初始化工作做完了,进一步的工作交给了ucore的bootloader。
实模式:Intel早期的8086 CPU提供了20根地址线,可寻址空间范围即0~
2
20
2^{20}
220的1MB内存空间。但8086的数据处理位宽位16位,无法直接寻址1MB内存空间,所以8086提供了段地址加偏移地址的地址转换机制。其内存寻址方式由16位段寄存器的内容乘以16(10H),当做段基地址,再加上16位偏移地址形成20位的物理地址。其最大寻址空间1MB,最大分段64KB。公式表示为:物理地址(physicaladdress)=段值(segment)*16+偏移(offset)。当CPU加电后,会进入实模式,实模式下所有的段都是可以读、写和可执行的,以此来在内存中寻找指令并执行。
正文: 由于要对BIOS进行单步跟踪,我们会用到到gdb,因此我们先查看Makefile中关于debug的代码。
debug: $(UCOREIMG)
$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
$(V)sleep 2
$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
我们可以看到debug部分的代码是通过tools/gdbinit配置文件中的命令使gdb进行调试的,其中内容为:
file bin/kernel //加载kernel,即加载符号信息
target remote :1234 //对qeum进行连接
break kern_init //将断点设在内核代码的入口函数
continue
由于要让BIOS进行单步调试,我们需要将其修改为:
set architecture i8086 //CPU以16位实模式启动
target remote :1234 //对qeum进行连接
根据背景知识的介绍,我们对cs和eip进行查看,可以看到第一条指令的cs内容为0xf000,eip(pc)内容为0xfff0,一开始CPU以16位的实模式启动,因此物理地址=cs*16+eip=0xffff0即cs:eip=0xf000:0xfff0=0xffff0。 因此地址0xffff0为BIOS的入口地址,该地址存放的指令为跳转指令ljmp,跳转到0xf000:0xe05b=0xfe05b处执行BIOS代码。 对BIOS的执行进行单步追踪: 从0xfe05b此处开始,执行BIOS程序,当BIOS做完计算机硬件自检和初始化后,BIOS读取首扇区MBR上的bootloader代码,并将其放到0x7c00处,进而cpu控制权交给bootloader进行执行。从0xfcf24开始,BIOS将权限交给了bootloader。
2.在初始化位置0x7c00设置实地址断点,测试断点正常。
在0x7c00处设置一个断点,执行c运行到断点,用x/5i $pc查看0x7c00后5条汇编代码,与bootasm.S中前五条指令相同,证实断点正常(也可在gdbinit中修改)。
3.从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
还是查看Makefile中关于debug的代码,在调用qemu时增加-d in_asm -D $(BINDIR)/q.log参数,这样可以将执行过的指令保存在q.log中。 然后make debug,进入gdb中进行调试,在q.log中查看0x7c00处的反汇编代码: 打开bootasm.S和bootblock.asm文件,发现两个文件的代码其实是一样的,且与q.log中0x7c00后的代码是一致。
4.自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
修改gdbinit,将断点设置到bootmain:
file obj/bootblock.o
target remote :1234
break bootmain
continue
gdb可以正常调试:
练习3:分析bootloader进入保护模式的过程。
分析bootloader进入保护模式的过程。(要求在报告中写出分析) BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
提示:需要阅读小节“保护模式和分段机制”和lab1/boot/bootasm.S源码,了解如何从实模式切换到保护模式,需要了解: 为何开启A20,以及如何开启A20。 如何初始化GDT表。 如何使能和进入保护模式。
分析
相关背景知识: bootloader需要完成的工作: 切换到保护模式,启用分段机制; 读磁盘中ELF执行文件格式的ucore操作系统到内存; 显示字符串信息; 把控制权交给ucore操作系统。
保护模式:由前面介绍实模式可知,通过实模式的segment:offset寻址方式最大地址为0xffff<<4+0xffff=0x10ffef,超出了20根地址线物理寻址能力,在8086中会发生地址回卷,最高的第20位被丢掉(从0开始),实际物理内存地址为0x0ffef。随后的处理器,如80286、80386都拥有更多的地址线,为使得其在实模式下与8086表现保持一致,实模式下使得第20根地址线恒为0,发生回卷时,则物理地址不会超出1M的内存空间。因此80386提供了方式用于是否打开A20地址线,若cpu进入保护模式需打开A20。因此,只有在保护模式下,80386的全部32根地址线有效,可寻址高达4G字节的线性地址空间和物理地址空间,可访问64TB的逻辑地址空间,可采用分段存储管理机制和分页存储管理机制。这不仅为存储共享和保护提供了硬件支持,而且为实现虚拟存储提供了硬件支持。通过提供4个特权级和完善的特权检查机制,既能实现资源共享又能保证代码数据的安全及任务的隔离。
分段存储管理机制:分段机涉及4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。 其中段描述符格式如下: 段基地址:规定线性地址空间中段的起始地址。在80386保护模式下,段基地址长32位。因为基地址长度与寻址地址的长度相同,所以任何一个段都可以从32位线性地址空间中的任何一个字节开始,而不像实方式下规定的边界必须被16整除。 段界限:规定段的大小。在80386保护模式下,段界限用20位表示,而且段界限可以是以字节为单位或以4K字节为单位。 段属性:确定段的各种性质。 ①段属性中的粒度位(Granularity),用符号G标记。G=0表示段界限以字节位位单位,20位的界限可表示的范围是1字节至1M字节,增量为1字节;G=1表示段界限以4K字节为单位,于是20位的界限可表示的范围是4K字节至4G字节,增量为4K字节。 ②类型(TYPE):用于区别不同类型的描述符。可表示所描述的段是代码段还是数据段,所描述的段是否可读/写/执行,段的扩展方向等。 描述符特权级(Descriptor Privilege Level)(DPL):用来实现保护机制。 ③段存在位(Segment-Present bit):如果这一位为0,则此描述符为非法的,不能被用来实现地址转换。如果一个非法描述符被加载进一个段寄存器,处理器会立即产生异常。图5-4显示了当存在位为0时,描述符的格式。操作系统可以任意的使用被标识为可用(AVAILABLE)的位。 ④已访问位(Accessed bit):当处理器访问该段(当一个指向该段描述符的选择子被加载进一个段寄存器)时,将自动设置访问位。操作系统可清除该位。
其中段描述表分为全局段描述符表(GDT)与本地段描述符表(LDT),而ucore中只使用了GDT。 全局段描述符寄存器(GDTR)格式如下所示,分为32位基址与16位界限:
地址空间: 逻辑地址空间:从应用程序的角度看,逻辑地址空间就是应用程序员编程所用到的地址空间。 物理地址空间:从操作系统的角度看,CPU、内存硬件(通常说的“内存条”)和各种外设是它主要管理的硬件资源而内存硬件和外设分布在物理地址空间中。 虚拟地址空间:一台计算机只有一个物理地址空间,但在操作系统的管理下,每个程序都认为自己独占整个计算机的物理地址空间。为了让多个程序能够有效地相互隔离和使用物理地址空间,引入线性地址空间(也称虚拟地址空间)的概念。 三种地址关系如下: 启动分段机制,未启动分页机制:逻辑地址–> (分段地址转换) -->线性地址==物理地址 启动分段和分页机制:逻辑地址–> (分段地址转换) -->线性地址–>(分页地址转换) -->物理地址
保护模式下的特权级: CPL:当前特权级(Current Privilege Level) 保存在CS段寄存器(选择子)的最低两位,CPL就是当前活动代码段的特权级,并且它定义了当前所执行程序的特权级别。 DPL:描述符特权(Descriptor Privilege Level) 存储在段描述符中的权限位,用于描述对应段所属的特权等级,也就是段本身真正的特权级。 RPL:请求特权级RPL(Request Privilege Level) RPL保存在选择子的最低两位。RPL说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。RPL的值由程序员自己来自由的设置,并不一定RPL>=CPL,但是当RPL < CPL时,实际起作用的就是CPL了,因为访问时的特权检查是判断:max(RPL,CPL)<=DPL是否成立,所以RPL可以看成是每次访问时的附加限制,RPL=0时附加限制最小,RPL=3时附加限制最大。
开启A20地址线(参考bootasm.S): 等待8042 Input buffer为空; 发送Write 8042 Output Port (P2)命令到8042 Input buffer; 等待8042 Input buffer为空; 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer。
正文: 首先,我们来看bootloader的组成,分别为 asm.h(是bootasm.S汇编文件所需要的头文件,主要是一些与X86保护模式的段访问方式相关的宏定义) bootasm.S(定义并实现了bootloader最先执行的函数start,开启A20,设置GDT,并进入保护模式,进入bootmain函数) bootmain.c(加载kernel到内存,然后跳转到ucore的入口处执行)。 本练习中,我们仅需分析前两个文件。
1.常量与宏
bootasm.S一开始先定义了三个常量 PROT_MODE_CSEG和PROT_MODE_DSEG分别作为内核代码段、数据段的选择子。并且由图可知,二者分别指向GDT[1]和GDT[2],RPL为0,CPL为0。而CRO_PE_ON则是切换到保护模式时的使能标志。 选择子格式如下:
2.关闭中断
bootloader入口地址为start函数,此时处于实模式。首先需要关闭中断,避免产生中断被BIOS中断处理程序处理。之后将各个段寄存器基址设为0。
# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
.code16 # 16位实模式
cli # 关闭中断
cld # 从低地址到高地址
# Set up the important data segment registers (DS, ES, SS). #初始化段寄存器
xorw %ax, %ax # 各段寄存器基址设为0
movw %ax, %ds # -> Data Segment
movw %ax, %es # -> Extra Segment
movw %ax, %ss # -> Stack Segment
3.开启A20
由前面背景知识知开启A20分为4步: (1)等待8042 Input buffer为空,即等待8042芯片为空闲状态。判断8042是否空闲可以通过循环读取8042的状态寄存器到CPU寄存器al,判断al是否为0x2(芯片初始系统状态)来实现。 (2)发送Write 8042 Output Port (P2)命令到8042 Input buffer。 (3)再度等待8042 Input buffer为空。 (4)将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer。 至此A20开启完毕。
4.初始化GDT表
在分段存储管理机制的保护模式下,每个段由如下三个参数进行定义:段基地址、段界限和段属性。而asm.h文件通过宏的方式来定义了初始化段描述符的宏函数,因此我们来查看asm.h:
/* Normal segment */
#define SEG_NULLASM \
.word 0, 0; \
.byte 0, 0, 0, 0
SEG_NULLASM中.word代表生成一个字长度(此处2字节,由16位实模式所决定)的数, 而.byte代表生成一个字节的数。因此SEG_NULLASM声明要生成两个字(每个字2字节)长度的数0,接着生成4个字节的数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)
SEG_ASM中,两个word分别定义为0xffff和0x0000。前者是0xffffffff右移12位变为0x000fffff,然后与0x0000ffff项相与得到0xffff,而后者是0x0000&0xffff=0x0000。对于后面的4个byte,第一个为0x00(段基址23~16 ),第二个为0x90( P=1、DPL=00、S=1、TYPE=type),第三个为0xcf(G=1、D/B=1、L=0、AVL=0、段界限19~16 ),第四个为0x00(段基址31~24)。 再结合上图,由此我们可知这个段描述符大小为8字节,即64位,0xffff对应段描述符0-15位,0x0000对应16-31位,然后4个byte对应高位。所以最后得到的段基地址就是0x00000000。
了解了段描述符如何初始化后,我们再次回到bootasm.S,查看GDT的初始化。
# Bootstrap GDT,设置GDT表
.p2align 2 # force 4 byte alignment,强制4字节对齐
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,GDT边界,三个段,共3 * 8 = 24 B,值为24 - 1 = 23 (0x17)
.long gdt # address gdt,GTD基址,长度32
GDT被设置为4字节对齐,仅定义了GDT[0](空段)、GDT[1](内核代码段)、GDT[2](内核数据段)。代码段和数据段基址均为0,总长度都为整个内存空间4G大小,因此逻辑地址=线性地址。
5.进入保护模式
初始化GDT后,通过lgdt指令来将GDT信息写入GDTR。此时我们已经完成bootloader切换到保护模式的全部准备工作。开启保护模式仅需要打开控制寄存器CR0中相应的标志位,通过异或之前定义的CR0_PE_ON即可实现。
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc #将GDT信息写入GDTR。
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0 #打开保护模式
此时CPU真正进入了32位模式,即保护模式。进入该模式后,ljmp指令重新初始化了代码段寄存器CS的值,其中CS的前12位为0x001,将其乘以8为0x008作为gdt表的偏移值来选择段描述符,所以其选择即为CS段描述符,其Base为0,偏移地址即为protcseg的地址。需要注意的是,由于我们bootloader程序代码段在实模式加载到内存时其从0x7c00的物理地址向高位内存加载,故当我们在分段模式下设定Base地址为0时,偏移地址为protcseg地址时,在保护模式下正好能运行bootloader中protcseg段代码。
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
随后将所有段寄存器设置为PROT_MODE_DSEG(指向内核数据段)。将栈区域设置为0x00~0x7c00,即bootloader之下都是栈的空间,然后使用call指令执行bootmain.c,开始加载kernel。bootmain函数正常情况不会返回,如果返回肯定是bootloader产生错误,进入死循环。
练习4:分析bootloader加载ELF格式的OS的过程。
通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS,来回答 bootloader如何读取硬盘扇区的? bootloader是如何加载ELF格式的OS? 提示:可阅读“硬盘访问概述”,“ELF执行文件格式概述”这两小节。
分析
1.bootloader如何读取硬盘扇区的?
相关背景知识: 磁盘的读取方式主要有两种CHS(Cylinder Head Sector)、LBA(Logical Block Address),CHS通过柱面-磁头-扇区三个值进行定位,较为麻烦。而LBA方式进一步屏蔽了细节,将磁盘按照扇区号进行统一编址,访问时给出扇区号即可。考虑到实现的简单性,bootloader的访问硬盘都是LBA模式的PIO(Program IO)方式,即所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。 一般主板有2个IDE通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7实现的,具体参数见下表。一般第一个IDE通道通过访问IO地址0x1f0-0x1f7来实现,第二个IDE通道通过访问0x170-0x17f实现。每个通道的主从盘的选择通过第6个IO偏移地址寄存器来设置。 磁盘IO地址和对应功能(第6位:为1=LBA模式,为0=CHS模式,第7位和第5位必须为1) 通过0x1f6端口可以设置主从盘、访问方式以及扇区号27—24位。
正文: 一个扇区大小为512字节,读一个扇区的流程(可参看boot/bootmain.c中的readsect函数实现)大致如下: (1)等待磁盘准备好。 (2)发出读取扇区的命令。 (3)等待磁盘准备好。 (4)把磁盘扇区数据读到指定内存。 因此我们来查看bootmain.c文件。 (1)等待磁盘准备好。
#define SECTSIZE 512
#define ELFHDR ((struct elfhdr *)0x10000) // scratch space
/* waitdisk - 等待磁盘准备就绪 */
static void
waitdisk(void) {
while ((inb(0x1F7) & 0xC0) != 0x40)
/* 死循环等待 */;
}
读取0x1f7端口来获取磁盘控制器状态,若第7位为1,表示磁盘正忙,需要等待,若第7位为0,第6位为1,表示准备就绪。 (2)发出读取扇区的命令。
(3)等待磁盘准备好。
/* readsect - read a single sector at @secno into @dst */
/* 读取编号为secno的一个扇区到dst地址处 */
static void
readsect(void *dst, uint32_t secno) {
// 等待磁盘准备就绪
waitdisk();
outb(0x1F2, 1); //读取扇区数目1
outb(0x1F3, secno & 0xFF); //读取扇区号0~7位
outb(0x1F4, (secno >> 8) & 0xFF); //读取扇区号8~15位
outb(0x1F5, (secno >> 16) & 0xFF); //读取扇区号16~23位
outb(0x1F6, ((secno >> 24) & 0xF)|0xE0);
//掩码0xF将扇区号28~31位置零,仅保留24~27位。异或0xE0避开了IO端口第4位,目的是读取主盘。
outb(0x1F7, 0x20); //0x20为读取扇区命令
// 等待磁盘准备就绪
waitdisk();
// 读取该扇区
insl(0x1F0, dst, SECTSIZE / 4); //SECTISE被定义为512字节,而insl每次读取4字节,所以SECTISE要除4。
}
readsect是用来读取一个扇区的函数,其中outb和insl函数都是采用内联汇编的方式,将常用的汇编操作封装为函数的函数,有利于复用,头文件x86.h为一些常用的内联汇编函数封装。
(4)把磁盘扇区数据读到指定内存。
/* *
* readseg - read @count bytes at @offset from kernel into virtual address @va,
* might copy more than asked.
* 从内核的offset偏移处读取count个字节到虚拟地址va中,可能复制的字节数多于请求的count个。
* */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;
// 向下对准到磁盘边界
va -= offset % SECTSIZE;
// 将偏移量转为扇区号,内核始于扇区1
uint32_t secno = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}
readseg函数提供的功能是从磁盘读取count字节数据到虚拟地址va中,但是读取硬盘是以512字节的扇区为单位的。所以实际读入的字节数很可能大于要求读入的字节数。考虑到readseg的偏移读取,通过va -= offset % SECTSIZE来向下对准到磁盘边界,如果读取磁盘中内核文件的起始数据不位于一个扇区的开始,应将va减小,使得完整读取一个扇区后,原va处正好就是内核文件经过偏移offset后的起始数据。
2.bootloader是如何加载ELF格式的OS?
相关背景知识: 我们首先得了解何为ELF,ELF(Executable and linking format)文件格式是Linux系统下的一种常用目标文件(object file)格式,有三种主要类型:可执行文件,可重定位文件,共享目标文件。本实验的OS文件类型即为可执行文件,因此我们仅分析可执行文件。可执行文件用于提供程序的进程映像,加载的内存执行,主要由ELF文件头、程序头、以及相应段组成,而ELF header在文件开始处描述了整个文件的组织。 ELF的文件头包含整个执行文件的控制结构,其定义在libs/elf.h中:
#define ELF_MAGIC 0x464C457FU // "\x7FELF" in little endian ELF魔数,小端存储
/* ELF文件头 */
struct elfhdr {
uint32_t e_magic; // ELF魔数,7f 45 4c 46
uint8_t e_elf[12]; // ELF格式信息,32/64位,大端/小端存储
uint16_t e_type; // 1=relocatable, 2=executable, 3=shared object, 4=core image
uint16_t e_machine; // ELF体系结构类型,3=x86, 4=68K, etc.
uint32_t e_version; // 文件版本,总为1
uint32_t e_entry; // 可执行程序入口虚拟地址
uint32_t e_phoff; // 程序头在文件内的字节偏移量
uint32_t e_shoff; // 节头表在文件内的字节偏移量
uint32_t e_flags; // 处理器相关标识,通常为0
uint16_t e_ehsize; // ELF header文件头字节大小
uint16_t e_phentsize; // 程序头每个条目(entry)的字节大小
uint16_t e_phnum; // 程序头条目数量,即段的个数
uint16_t e_shentsize; // 节头表每个条目(entry)的字节大小
uint16_t e_shnum; // 节头表中条目的数量,即节的个数
uint16_t e_shstrndx; // string name table在节头表中的索引index
};
程序头表则用来描述程序中的各段信息,如代码段、数据段等,这些段组成了最终在内存中执行的程序。程序头表提供了各段在虚拟地址空间和物理地址空间中的大小、位置、标志、访问授权和对齐方面的信息。程序头表同样定义在elf.h中:
struct proghdr {
uint32_t p_type; // 段类型, 如可加载的代码段或数据段、动态链接信息段等
uint32_t p_offset; // 本段在文件内的其实偏移
uint32_t p_va; // 本段在内存中的起始虚拟地址
uint32_t p_pa; // 本段在内存中的起始物理地址,不使用
uint32_t p_filesz; // 本段在文件中的大小
uint32_t p_memsz; // 本段在内存中的大小(如果包含bss节,会更大)
uint32_t p_flags; // 本段相关的标志,如读/写/执行
uint32_t p_align; // 对齐,和硬件页大小一致;如果为0或1,表示不需要对齐。否则,p_align应该是2的正整数幂,p_va和p_offset在对p_align取模后应相等
};
正文: 我们通过readelf -e kernel来查看kernel ELF文件相关信息,下面是ELF文件头的信息: 可见程序入口虚拟地址为0x100000,程序头表偏移为52B,程序头表条目为3。 我们再来查看程序头表的信息: 可见一共创建了三个段,第一个段是代码段,第二个段是数据段,第三个段是空段,这个与前面GDT的分析相符。 现在我们再来分析bootmain.c,其载入OS的代码如下: 分析可得载入流程为: (1)先从硬盘读取一页大小数据(4K)到内存0x10000地址处,即读取kernel的ELF文件头和程序头表加载到ELFHDR(0x10000)处。 (2)然后通过ELF文件头的魔数判断ELF文件格式是否合法。 (3)如果格式合法就根据kernel中的ELF文件头和程序头表中的信息将kernel各段加载到内存中。 (4)最后根据ELF文件头储存的入口信息,找到内核的入口。
练习5:实现函数调用堆栈跟踪函数。
我们需要在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代码中的注释。
分析
相关背景知识: 函数堆栈:堆栈是函数运行时的内存空间,由高地址向低地址增长,ebp寄存器存储栈底地址,esp寄存器存储栈顶地址,始终指向栈顶元素;栈从高地址向地址增长。 函数调用时主要经过以下步骤:
调用者: (1)将被调用函数的参数从右向左依次入栈。 (2)执行call命令,将call命令的下一条指令地址,也即返回地址,压栈,同时跳转到被调用函数执行。 被调用函数: (1)pushl %ebp:将调用者的栈底地址入栈,便于返回到调用者继续执行。 (2)movl %esp, %ebp:%ebp指向当前栈顶,也即与%esp指向相同,此函数的堆栈就此建立函数执行,临时变量压入堆栈。 (3)movl %ebp, %esp:%esp指向该函数的栈底。 (4)popl %ebp:将%ebp栈底指针重新指向调用者函数的栈底地址。 (5)ret:被调用函数从栈顶弹出返回地址,返回到调用函数继续执行。
1.输出堆栈信息
void
print_stackframe(void) {
/* LAB1 : 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]
*/
uint32_t ebp=read_ebp(),eip=read_eip(); //获得ebp与eip的初值
int i,j;
for(i=0;i<STACKFRAME_DEPTH&&ebp;i++){
cprintf("ebp:0x%08x eip:0x%08x args:",ebp,eip);//输出ebp和eip
uint32_t *arguments=(uint32_t *)ebp+2; //接收ebp+2的地址,获得参数
for(j=0;j<4;j++){
cprintf("0x%08x ",arguments[j]); //输出每一参数
}
cprintf("\n");
print_debuginfo(eip-1); //输出caller的信息
eip=((uint32_t *)ebp)[1]; //更新ebp和eip
ebp=((uint32_t *)ebp)[0];
}
}
该代码首先通过函数读取ebp、eip寄存器值,分别表示指向栈底的地址、当前指令的地址。然后遍历堆栈中的信息。按照对应格式输出ebp,eip存储的信息(0x%08x:“0x"为普通字符配合显示16进制格式;”%08x"以对齐的16进制格式输出8位字符,少于8位则前补0)。随后接受ebp+2(指针+2,地址+8)的地址,获得参数(由前面的图可知参数从+8开始)。然后输出每一参数,输出完后再输出caller的信息并且更新ebp和eip(((uint32_t *)ebp)[1]= *(uint32_t *)(ebp + 4),即返回地址;((uint32_t *)ebp)[0]= *(uint32_t *)(ebp),即上一级函数的ebp地址)。
运行结果如下:
2.解释最后一行各个数值的含义
最后一行是ebp:0x00007bf8 eip:0x00007d74 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8,共有ebp,eip和args三类参数,下面分别给出解释。
ebp:0x00007bf8:此时ebp的值是kern_init函数的栈顶地址,从前面练习我们知道,整个栈的栈顶地址为0x00007c00,ebp指向的栈位置存放调用者的ebp寄存器的值,ebp+4指向的栈位置存放返回地址的值,这意味着kern_init函数的调用者(也就是bootmain函数)没有传递任何输入参数给它!因为单是存放旧的ebp、返回地址已经占用8字节了。
eip:0x00007d74:eip的值是kern_init函数的返回地址,也就是bootmain函数调用kern_init对应的指令的下一条指令的地址,反汇编bootmain函数证实了这个判断。
args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8:一般来说,args存放的4个元素是对应4个输入参数的值。但这里比较特殊,由于bootmain函数调用kern_init并没传递任何输入参数,并且栈顶的位置恰好在bootloader第一条指令存放的地址的上面,而args恰好是kern_int的ebp寄存器指向的栈顶往上第2~5个单元,因此args存放的就是bootloader指令的前16个字节。
练习6:完善中断初始化和处理
请完成编码工作和回答如下问题: 1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口? 2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。 3.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
【注意】除了系统调用中断(T_SYSCALL)使用陷阱门描述符且权限为用户态权限以外,其它中断均使用特权级(DPL)为0的中断门描述符,权限为内核态权限;而ucore的应用程序处于特权级3,需要采用’int 0x80’指令操作(这种方式称为软中断,软件中断,Tra中断,在lab5会碰到)来发出系统调用请求,并要能实现从特权级3到特权级0的转换,所以系统调用中断(T_SYSCALL)所对应的中断门描述符中的特权级(DPL)需要设置为3。
要求完成问题2和问题3 提出的相关函数实现,提交改进后的源代码包(可以编译执行),并在实验报告中简要说明实现过程,并写出对问题1的回答。完成这问题2和3要求的部分代码后,运行整个系统,可以看到大约每1秒会输出一次”100 ticks”,而按下的键也会在屏幕上显示。
提示:可阅读小节“中断与异常”。
分析
1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
操作系统需要对计算机系统中的各种外设进行管理,这就需要CPU和外设能够相互通信才行。一般外设的速度远慢于CPU的速度。如果让操作系统通过CPU“主动关心”外设的事件,即采用通常的轮询(polling)机制,则太浪费CPU资源了。所以需要操作系统和CPU能够一起提供某种机制,让外设在需要操作系统处理外设相关事件的时候,能够“主动通知”操作系统,即打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行。在操作系统中,这种机制称为中断机制。
主要的中断类型有外部中断(中断)、内部中断(异常)、软中断(陷阱、系统调用)。 外部中断:用于cpu与外设进行通信,当外设需要输入或输出时主动向cpu发出中断请求。 内部中断:cpu执行期间检测到不正常或非法条件(如除零错、地址访问越界)时会引起内部中断。 系统调用:用于程序使用系统调用服务。
当中断发生时,cpu会得到一个中断向量号,作为IDT(中断描述符表)的索引,IDT表起始地址由IDTR寄存器存储,cpu会从IDT表中找到该中断向量号相应的中断服务程序入口地址,跳转到中断处理程序处执行,并保存当前现场;当中断程序执行完毕,恢复现场,跳转到原中断点处继续执行。而IDT的表项为中断描述符,主要类型有中断门、陷阱门、调用门,其格式如下: 中断门描述符和陷阱门描述符几乎是一样的,以其中中断门描述符的格式来具体展示:
中断描述符作为IDT的表项,每个表项占据8字节(64位),其中段选择子和偏移地址用来代表中断处理程序入口地址,即0到31位,47到63位代表中断处理代码的入口。具体流程是,先通过选择子查找GDT对应段描述符,得到该代码段的基址,基址加上偏移地址为中断处理程序入口地址。 而mmu.h中对中断描述符的定义也佐证了以上结论:
2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
由于每个中断的入口由tools/vectors.c生成,而kern/trap/vectors.S是由tools/vector.c在编译ucore期间动态生成的,包括256个中断服务例程的入口地址和第一步初步处理实现:
# handler
.text
.globl __alltraps
.globl vector0
vector0:
pushl $0
pushl $0
jmp __alltraps
.globl vector1
vector1:
pushl $0
pushl $1
jmp __alltraps
...
# vector table
.data
.globl __vectors
__vectors:
.long vector0
.long vector1
.long vector2
...
__vectors存储了各中断处理程序入口地址,每一个中断处理程序依次将错误码、中断向量号压栈(一些由cpu自动压入错误码的只压入中断向量号),再调用trapentry.S中的__alltraps进行处理。
根据中断描述符格式使用SETGATE宏函数对IDT进行初始化后,在这里先全部设为中断门(istrap为0),中断处理程序均在内核态执行,因此代码段为内核的代码段,DPL应为内核态的0。 SETGATE宏函数: idt_init函数:
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
/* LAB1 : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
extern uintptr_t __vectors[];
//引用另一个文件中的__vectors
for (int i = 0; i < 256; i++)
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
//在IDT中建立中断描述符,其中存储了中断处理例程的代码段GD_KTEXT和偏移量__vectors[i],特权级为DPL_KERNEL。这样通过查询idt[i]就可定位到中断服务例程的起始地址。
SETGATE(idt[T_SYSCALL], 0, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
//为系统调用中断设置用户态权限(DPL3)
lidt(&idt_pd);
//载入LDT,即将LDT存入LDTR
}
3.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
先查看trap函数: 根据注释可以了解到,trap函数是对中断进行处理的过程,所有的中断在经过中断入口函数__alltraps预处理后 (定义在 trapasm.S中) ,都会跳转到这里。在处理过程中,根据不同的中断类型,进行相应的处理。在相应的处理过程结束以后,trap将会返回,被中断的程序会继续运行。而被打断程序会保存trapframe(栈帧)中。
Struct trapframe
{
uint edi;
uint esi;
uint ebp;
...
ushort es;
ushort padding1;
ushort ds;
ushort padding2;
uint trapno;
uint err;
uint eip;
...
}
而__alltraps为各中断处理程序的前置代码,用于继续在栈中完成trapframe结构,依次压入ds、es、fs、gs、通用寄存器,并将数据段切换为内核数据段(代码段在IDT初始化过程中设置为内核代码段),最后压入trapframe结构体指针作为trap函数的参数,再调用trap函数完成具体的中断处理,代码如下:
# vectors.S sends all traps here.
.text
.globl __alltraps
__alltraps:
# push registers to build a trap frame
# therefore make the stack look like a struct trapframe
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
# load GD_KDATA into %ds and %es to set up data segments for kernel
movl $GD_KDATA, %eax
movw %ax, %ds
movw %ax, %es
# push %esp to pass a pointer to the trapframe as an argument to trap()
pushl %esp
# call trap(tf), where tf=%esp
call trap
# pop the pushed stack pointer
popl %esp
# return falls through to trapret...
然后trap_dispatch函数根据trapframe获取中断号去处理相应中断,处理时钟中断的代码如下:
/* trap_dispatch - dispatch based on what type of trap occurred */
static void
trap_dispatch(struct trapframe *tf) {
char c;
switch (tf->tf_trapno) {
case IRQ_OFFSET + IRQ_TIMER:
/* LAB1 : STEP 3 */
/* handle the timer interrupt */
/* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
* (3) Too Simple? Yes, I think so!
*/
ticks++;
if (ticks % TICK_NUM == 0) {
print_ticks();
}
break;
case IRQ_OFFSET + IRQ_COM1:
c = cons_getc();
cprintf("serial [%03d] %c\n", c, c);
break;
case IRQ_OFFSET + IRQ_KBD:
c = cons_getc();
cprintf("kbd [%03d] %c\n", c, c);
break;
//LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
case T_SWITCH_TOU:
case T_SWITCH_TOK:
panic("T_SWITCH_** ??\n");
break;
case IRQ_OFFSET + IRQ_IDE1:
case IRQ_OFFSET + IRQ_IDE2:
/* do nothing */
break;
default:
// in kernel, it must be a mistake
if ((tf->tf_cs & 3) == 0) {
print_trapframe(tf);
panic("unexpected trap in kernel.\n");
}
}
}
运行结果如下
实验收获
通过本次实验,充分了解了一个最基础的操作系统从机器启动到操作系统运行的过程。当CPU加电后,BIOS进行基本IO初始化和引导加载功能。当BIOS完成工作后,把控制权移交给BootLoader,此时CPU处于实模式,随后切换到保护模式读取磁盘中ELF执行文件格式的ucore操作系统到内存,便将控制权移交给ucore操作系统,此时ucore就接管了整个控制权。而ucore的功能是完成基本的内存管理和外设中断管理。这个流程中还涉及到保护模式和分段机制,地址空间,硬盘访问,ELF文件,函数堆栈,中断等相关知识,从理论到实践深入了解了这些知识。
|