uCore lab1 操作系统实验

uCore lab1 —— 幼儿园

lab1有什么?实现功能后能做啥?

  • 一个bootloader
    • 可以切换到X86保护模式
    • 能够读磁盘并加载ELF执行文件格式
    • 显示字符
  • 一个OS
    • 可以处理时钟中断
    • 显示字符

项目组成

lab1的整体目录结构如下所示:

.

├── boot 

│ ├── asm.h 

│ ├── bootasm.S 

│ └── bootmain.c 

├── kern 

│ ├── debug 

│ │ ├── assert.h 

│ │ ├── kdebug.c 

│ │ ├── kdebug.h 

│ │ ├── kmonitor.c 

│ │ ├── kmonitor.h 

│ │ ├── panic.c 

│ │ └── stab.h 

│ ├── driver 

│ │ ├── clock.c 

│ │ ├── clock.h 

│ │ ├── console.c 

│ │ ├── console.h 

│ │ ├── intr.c 

│ │ ├── intr.h 

│ │ ├── kbdreg.h 

│ │ ├── picirq.c 

│ │ └── picirq.h 

│ ├── init 

│ │ └── init.c 

│ ├── libs 

│ │ ├── readline.c 

│ │ └── stdio.c 

│ ├── mm 

│ │ ├── memlayout.h 

│ │ ├── mmu.h 

│ │ ├── pmm.c 

│ │ └── pmm.h 

│ └── trap 

│ ├── trap.c 

│ ├── trapentry.S 

│ ├── trap.h 

│ └── vectors.S 

├── libs 

│ ├── defs.h 

│ ├── elf.h 

│ ├── error.h 

│ ├── printfmt.c 

│ ├── stdarg.h 

│ ├── stdio.h 

│ ├── string.c 

│ ├── string.h 

│ └── x86.h 

├── Makefile 

└── tools 

├── function.mk 

├── gdbinit 

├── grade.sh 

├── kernel.ld 

├── sign.c 

└── vector.c 

10 directories, 48 files 

其中一些比较重要的文件说明如下:

bootloader部分

  • boot/bootasm.S :定义并实现了bootloader最先执行的函数start,此函数进行了一定的初始化,完成了从实模式到保护模式的转换,并调用bootmain.c中的bootmain函数。

  • boot/bootmain.c:定义并实现了bootmain函数实现了通过屏幕、串口和并口显示字符串。bootmain函数加载ucore操作系统到内存,然后跳转到ucore的入口处执行。

  • boot/asm.h:是bootasm.S汇编文件所需要的头文件,主要是一些与X86保护模式的段访问方式相关的宏定义。

ucore操作系统部分

系统初始化部分:

  • kern/init/init.c:ucore操作系统的初始化启动代码

内存管理部分:

  • kern/mm/memlayout.h:ucore操作系统有关段管理(段描述符编号、段号等)的一些宏定义

  • kern/mm/mmu.h:ucore操作系统有关X86 MMU等硬件相关的定义,包括EFLAGS寄存器中各位的含义,应用/系统段类型,中断门描述符定义,段描述符定义,任务状态段定义,NULL段声明的宏SEG_NULL, 特定段声明的宏SEG,设置中 断门描述符的宏SETGATE(在练习6中会用到)

  • kern/mm/pmm.[ch]:设定了ucore操作系统在段机制中要用到的全局变量:任务状态段ts,全局描述符表 gdt[],加载全局描述符表寄存器的函数lgdt,临时的内核栈stack0;以及对全局描述符表和任务状态段的初始化函数gdt_init

外设驱动部分:

  • kern/driver/intr.[ch]:实现了通过设置CPU的eflags来屏蔽和使能中断的函数;

  • kern/driver/picirq.[ch]:实现了对中断控制器8259A的初始化和使能操作;

  • kern/driver/clock.[ch]:实现了对时钟控制器8253的初始化操作;- kern/driver/console. [ch]:实现了对串口和键盘的中断方式的处理操作;

中断处理部分:

  • kern/trap/vectors.S:包括256个中断服务例程的入口地址和第一步初步处理实现。注意,此文件是由tools/vector.c在编译ucore期间动态生成的;

  • kern/trap/trapentry.S:紧接着第一步初步处理后,进一步完成第二步初步处理;并且有恢复中断上下文的处理,即中断处理完毕后的返回准备工作;

  • kern/trap/trap.[ch]:紧接着第二步初步处理后,继续完成具体的各种中断处理操作;

内核调试部分:

  • kern/debug/kdebug.[ch]:提供源码和二进制对应关系的查询功能,用于显示调用栈关系。其中补全print_stackframe函数是需要完成的练习。其他实现部分不必深究。

  • kern/debug/kmonitor.[ch]:实现提供动态分析命令的kernel monitor,便于在ucore出现bug或问题后,能够进入kernel monitor中,查看当前调用关系。实现部分不必深究。

  • kern/debug/panic.c | assert.h:提供了panic函数和assert宏,便于在发现错误后,调用kernel monitor。大家可在编程实验中充分利用assert宏和panic函数,提高查找错误的效率。

公共库部分

  • libs/defs.h:包含一些无符号整型的缩写定义。

  • Libs/x86.h:一些用GNU C嵌入式汇编实现的C函数(由于使用了inline关键字,所以可以理解为宏)。

工具部分

  • Makefile和function.mk:指导make完成整个软件项目的编译,清除等工作。

  • sign.c:一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。

  • tools/vector.c:生成vectors.S,此文件包含了中断向量处理的统一实现。

编译方法

首先下载lab1.tar.bz2,然后解压lab1.tar.bz2。在lab1目录下执行make,可以生成ucore.img(生成于bin目录下)。ucore.img是一个包含了bootloader或OS的硬盘镜像,通过执行如下命令可在硬件虚拟环境 qemu中运行bootloader或OS:

$ make qemu 

则可以得到如下显示界面*(仅供参考)*

(THU.CST) os is loading ... 
Special kernel symbols:
  entry 0x00100000 (phys) 
  etext 0x00103468 (phys) 
  edata 0x0010ea18 (phys) 
  end   0x0010fd80 (phys)
Kernel executable memory footprint: 64KB 
ebp:0x00007b38 eip:0x00100a55 args:0x00010094 0x00010094 0x00007b68 0x00100084
kern/debug/kdebug.c:305: print_stackframe+21 
ebp:0x00007b48 eip:0x00100d3a args:0x00000000 0x00000000 0x00000000 0x00007bb8 kern/debug/kmonitor.c:125: mon_backtrace+10 
ebp:0x00007b68 eip:0x00100084 args:0x00000000 0x00007b90 0xffff0000 0x00007b94 kern/init/init.c:48: grade_backtrace2+19 
ebp:0x00007b88 eip:0x001000a5 args:0x00000000 0xffff0000 0x00007bb4 0x00000029 kern/init/init.c:53: grade_backtrace1+27 
ebp:0x00007ba8 eip:0x001000c1 args:0x00000000 0x00100000 0xffff0000 0x00100043 kern/init/init.c:58: grade_backtrace0+19 
ebp:0x00007bc8 eip:0x001000e1 args:0x00000000 0x00000000 0x00000000 0x00103480 kern/init/init.c:63: grade_backtrace+26 
ebp:0x00007be8 eip:0x00100050 args:0x00000000 0x00000000 0x00000000 0x00007c4f kern/init/init.c:28: kern_init+79
ebp:0x00007bf8 eip:0x00007d61 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 
: -- 0x00007d60 -- 
++ setup timer interrupts 
0: @ring 0 
0: cs = 8 
0: ds = 10 
0: es = 10 
0: ss = 10 
+++ switch to user mode +++ 
1: @ring 3 
1: cs = 1b 
1: ds = 23 
1: es = 23 
1: ss = 23 
+++ switch to kernel mode +++ 
2: @ring 0 
2: cs = 8 
2: ds = 10 
2: es = 10 
2: ss = 10 
100 ticks 
100 ticks 
100 ticks 
100 ticks

练习1

理解通过make生成执行文件的过程。

问题

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

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

补充材料

如何调试Makefile

当执行make时,一般只会显示输出,不会显示make到底执行了哪些命令。

如想了解make执行了哪些命令,可以执行:(记住,V是大写)

$ make "V=" 

要获取更多有关make的信息,可上网查询,并请执行

$ man make 

实验过程

问题1

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

  • 首先,进入~/ucore_os_lab-master/labcodes_answer/lab1_result目录下
  • 执行make,并查看详情
$ make "V=" 
  • 观察输出结果

(1)通过GCC编译器将Kernel目录下的.c文件编译成OBJ目录下的.o文件。

+ cc kern/init/init.c # 编译init.c
gcc -Ikern/init/ -fno-builtin -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
+ cc kern/libs/readline.c # 编译readline.c
gcc -Ikern/libs/ -fno-builtin -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/libs/stdio.c # 编译stdlio.c
gcc -Ikern/libs/ -fno-builtin -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/debug/kdebug.c # 编译kdebug.c
gcc -Ikern/debug/ -fno-builtin -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
+ cc kern/debug/kmonitor.c # 编译komnitor.c
gcc -Ikern/debug/ -fno-builtin -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/debug/panic.c # 编译panic.c
gcc -Ikern/debug/ -fno-builtin -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
+ cc kern/driver/clock.c # 编译clock.c
gcc -Ikern/driver/ -fno-builtin -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 # 编译console.c
gcc -Ikern/driver/ -fno-builtin -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/intr.c # 编译intr.c
gcc -Ikern/driver/ -fno-builtin -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/driver/picirq.c # 编译prcirq.c
gcc -Ikern/driver/ -fno-builtin -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/trap/trap.c # 编译trap.c
gcc -Ikern/trap/ -fno-builtin -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
+ cc kern/trap/trapentry.S # 编译trapentry.S
gcc -Ikern/trap/ -fno-builtin -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/trap/vectors.S # 编译vectors.S
gcc -Ikern/trap/ -fno-builtin -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/mm/pmm.c # 编译pmm.c
gcc -Ikern/mm/ -fno-builtin -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/printfmt.c # 编译printfmt.c
gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/  -c libs/printfmt.c -o obj/libs/printfmt.o
+ cc libs/string.c # 编译string.c
gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/  -c libs/string.c -o obj/libs/string.o

(2)ld命令根据链接脚本文件kernel.ld将生成的*.o文件,链接成BIN目录下的kernel文件。

+ ld bin/kernel # 链接成kernel
ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o  obj/libs/printfmt.o obj/libs/string.o

(3)通过GCC编译器将boot目录下的.c,.S文件以及tools目录下的sign.c文件编译成OBJ目录下的*.o文件。

+ cc boot/bootasm.S # 编译bootasm.S
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
+ cc boot/bootmain.c # 编译bootmain.c
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
+ cc tools/sign.c # 编译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

(4)ld命令将生成的*.o文件,链接成BIN目录下的bootblock文件。

+ ld bin/bootblock  # 根据sign规范生成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: 488 bytes
build 512 bytes boot sector: 'bin/bootblock' success!

(5)dd命令将dev/zero, bin/bootblock,bin/kernel 写入到bin/ucore.img

# 创建大小为10000个块的ucore.img,每个块默认为512字节,用0填充
dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.042 seconds, 122 MB/s
# 把bootblock中的内容写到第一个块
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0 seconds, Infinity B/s
# 从第二个块开始写kernel中的内容
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
146+1 records in
146+1 records out
74903 bytes (75 kB) copied, 0.001 seconds, 75 MB/s

总结:

# 生成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
  • 为了生成ucore.img,首先需要生成bootblock、kernel
    • 为了生成bootblock,首先需要生成bootasm.o、bootmain.o、sign
      • 递归下去
    • 为了生成kernel,首先需要 kernel.ld init.o readline.o stdio.o kdebug.o
      • 递归下去

问题2

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

我们可以查看tools下的sign.c文件

#include 
#include 
#include 
#include 

int main(int argc, char *argv[]) {
    struct stat st;
    if (argc != 3) {
        fprintf(stderr, "Usage:  \n");
        return -1;
    }
    if (stat(argv[1], &st) != 0) {
        fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
        return -1;
    }
    printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
    if (st.st_size > 510) {
        fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
        return -1;
    }
    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[511] = 0xAA;
    FILE *ofp = fopen(argv[2], "wb+");
    size = fwrite(buf, 1, 512, ofp);
    if (size != 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]);
    return 0;
}

从sign.c的代码来看,一个磁盘主引导扇区只有512字节。且第510个(倒数第二个)字节是0x55,第511个(倒数第一个)字节是0xAA。

练习2

使用qemu执行并调试lab1中的软件。

实验要求

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

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

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

  4. 自己找一个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的执行。

直接看截图吧,就是玩玩gdb那几个命令而已

uCore lab1 操作系统实验_第1张图片

这是直接拿答案的代码了,拿题目的是这样:

(1)进入~/moocos/ucore_lab/labcodes/lab1/bin,即ucore.img所在文件夹。
(2)输入指令:

qemu -S -s -hda ucore.img -monitor stdio

(3)打开gdb。

(4)输入命令,使gdb与qemu通过1234端口进行通信,qemu会停止状态听从gbd命令。

target remote 127.0.0.1:1234

(5)输入命令,单步跟踪。

si

问题2

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

(1)输入命令,设置断点。

b *0x7c00

(2)continue后,测试断点。

c
x/2i $pc

运行结果:

(gdb) target remote 127.0.0.1:1234
Remote debugging using 127.0.0.1:1234
0x0000fff0 in ?? ()
(gdb) b *0x7c00
Breakpoint 1 at 0x7c00
(gdb) c
Continuing.

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x /2i $pc
=> 0x7c00:	cli    
   0x7c01:	cld    

问题3

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

  • 使用meld对比bootasm.S和bootlock.asm的代码

meld /home/moocos/moocos/ucore_lab/labcodes/lab1/boot/bootasm.S /home/moocos/moocos/ucore_lab/labcodes/lab1/obj/bootblock.asm

  • 看就完了,是一样的

uCore lab1 操作系统实验_第2张图片

问题4

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

(略)

补充

在进入练习3前,再复习一下bootloader做了什么:

  • 切换到保护模式,启用分段机制 (修改A20地址线)

  • 读磁盘中ELF执行文件格式的ucore操作系统到内存

  • 显示字符串信息

  • 把控制权交给ucore操作系统

保护模式下,有两个段表:GDT(Global Descriptor Table)和LDT(Local Descriptor Table),每一张段表可以包含8192 (2^13)个描述符,因而最多可以同时存在2 * 2^13 = 2^14 个段。虽然保护模式下可以有这么多段,逻辑地址空间看起来很大,但实际上段并不能扩展物理地址空间,很大程度上各个段的地址空间是相互重叠的。所谓的64TB(2^(14+32) =246)逻辑地址空间是一个理论值,没有实际意义。在32位保护模式下,真正的物理空间仍然只有232字节那么大。(注:在ucore lab中只用到了GDT,没有用LDT。)

练习3

分析bootloader进入保护模式的过程。

实验要求

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

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

  • 为何开启A20,以及如何开启A20

  • 如何初始化GDT表

  • 如何使能和进入保护模式

实验过程

打开lab1/boot/bootasm.S

  • 清理环境:关中断,重要的段寄存器清零
# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.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
  • 开启A20(为了与最早的PC兼容,物理地址线绑定在了低20位,因此高于1MB的地址默认情况下会回零。 此代码撤消了此操作),通过将键盘控制器上的A20线置于高电位,全部32条地址线可用,可以访问4G的内存空间。
    # Enable A20:
    #  For backwards compatibility with the earliest PCs, physical
    #  address line 20 is tied low, so that addresses higher than
    #  1MB wrap around to zero by default. This code undoes this.
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

设备和芯片的I/O端口操作实现,其实没有复杂的东西在里边 ,I/O端口操作主要是看一堆文档,把整个X86架构的PC机所有I/O端口记住,并记住它们每一个数据寄存器、命令寄存器等操作访问标准(也可以称之协议) ; 记住之后,整个过程中就是按标准使用I/O指令。
in, out(只能与DX,AX,AL寄存器结合使用) ;
下面的实现是提供给C使用,语法太晦涩,所以直接使用汇编实现。
inb 从I/O端口读取一个字节(BYTE, HALF-WORD) ;
outb 向I/O端口写入一个字节(BYTE, HALF-WORD);
inw 从I/O端口读取一个字(WORD,即两个字节);
outw 向I/O端口写入一个字(WORD,即两个字节) ;

  • 初始化GDT(全局描述表,Global Descriptor Table),一个简单的GDT表和其描述符已经静态储存在引导区中,载入即可
    # 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
    
    # Bootstrap GDT(这是文件的最后)
.p2align 2                                          # force 4 byte alignment
gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt
  • 进入保护模式:通过将cr0寄存器PE位(第0位)置1便开启了保护模式;通过长跳转更新cs的基地址;设置段寄存器,并建立堆栈(BIOS数据区:0x0000-0x7c00)
    movl %cr0, %eax
    orl $CR0_PE_ON, %eax
    movl %eax, %cr0

    # Jump to next instruction, but in 32-bit code segment.
    # Switches processor into 32-bit mode.
    ljmp $PROT_MODE_CSEG, $protcseg
    # 这一跳就是真的跳到保护模式了
    
.code32                                             # Assemble for 32-bit mode
protcseg:
    # Set up the protected-mode data segment registers
    movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
    movw %ax, %ds                                   # -> DS: Data Segment
    movw %ax, %es                                   # -> ES: Extra Segment
    movw %ax, %fs                                   # -> FS
    movw %ax, %gs                                   # -> GS
    movw %ax, %ss                                   # -> SS: Stack Segment

    # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
    movl $0x0, %ebp
    movl $start, %esp
    call bootmain

使用引导程序GDT和段转换,使虚拟地址与物理地址相同,从而从实模式切换到保护模式,从而使有效内存映射在切换期间不会改变。

练习4

分析bootloader加载ELF格式的OS的过程。

实验要求

通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS,回答以下问题:

  • bootloader如何读取硬盘扇区的?

  • bootloader是如何加载ELF格式的OS?

提示:可阅读“硬盘访问概述”,“ELF执行文件格式概述”这两小节。

实验过程

首先,我们可以先大致了解一下,什么是ELF

接着,查看一下bin下的kernel文件

[~/moocos/ucore_lab/labcodes/lab1/bin]
moocos-> file kernel 
kernel: ELF 32-bit LSB  executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

最后,再来看看bootloader的整个流程,到底他在做什么

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // 首先读取ELF的头部:从磁盘读取第一页(即第一个扇区)
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // 通过储存在头部的幻数判断是否是合法的ELF文件
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // 加载每个程序段
    // ELF头部有描述ELF文件应加载到内存什么位置的描述表,
	// 先将描述表的头地址存在ph
    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头调用entry point,bootloader把控制权转交给uCore
    // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
    // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
	// ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000

bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}

问题1

bootloader如何读取硬盘扇区?

  • 首先看readsect函数
#define SECTSIZE        512
#define ELFHDR          ((struct elfhdr *)0x10000) 
/* readsect - 将@secno的单个扇区读入@dst */
static void
readsect(void *dst, uint32_t secno) {
    // 等待磁盘准备就绪
    waitdisk();
	// 发出读取扇区的命令:用LBA模式的PIO(Program IO)方式来访问硬盘
    outb(0x1F2, 1);                         // 设置读取扇区的数目为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 - 读取扇区

    // 等待磁盘准备就绪
    waitdisk();

    // 读一个扇区
    insl(0x1F0, dst, SECTSIZE / 4);         // 读取到dst位置,除以4是因为这里以DW为单位
}
  • 发现有个等待磁盘准备的函数
/* waitdisk - 等待磁盘准备就绪 */
static void
waitdisk(void) {
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}
  • 与0号硬盘有关的I/O端口

    • IF0H:0号硬盘数据寄存器
    • IF1H:0号硬盘错误寄存器(读时)、0号硬盘Features 寄存器(写时)
    • IF2H:0号硬盘数据扇区计数
    • IF3H:0号硬盘扇区数
    • IF4H:0号硬盘柱面(低字节)
    • IF5H:0号硬盘柱面(高字节)
    • IF6H:0号硬盘驱动器/磁头寄存器
    • IF7H:0号硬盘状态寄存器(读时)、0 号硬盘命令寄存器(写时)
  • 要到加载程序段了,readseg简单包装了readsect,可以从设备读取任意长度的内容。

/* *
 * readseg - 从内核将@offset处的@count字节读取到虚拟地址@va中,
 * 复制的数量可能会超出要求。
 * */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // 取模,保证在一个扇区的空间范围(512B)内
    va -= offset % SECTSIZE;

    // 从字节转换为扇区;内核从扇区1开始
    uint32_t secno = (offset / SECTSIZE) + 1;

    //如果速度太慢,我们可以一次读取很多扇区。
    //我们向内存中写入的内容超出了要求,但这没关系-
    //我们以递增顺序加载。
    for (; va < end_va; va += SECTSIZE, seccno ++) {
        readsect((void *)va, secno);
    }
}

问题2

bootloader是如何加载ELF格式的OS?

  • 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用。
  • 校验e_magic字段。
  • 根据偏移量分别把程序段的数据读取到内存中。

我们看看lib下的elf.h文件(hdr其实就是header的缩写)

struct elfhdr {
    uint32_t e_magic;     // 判断读出来的ELF格式的文件是否为正确的格式
    uint8_t e_elf[12];
    uint16_t e_type;      // 1=可重定位,2=可执行,3=共享对象,4=核心映像
    uint16_t e_machine;   // 3=x86,4=68K等.
    uint32_t e_version;   // 文件版本,总是1
    uint32_t e_entry;     // 程序入口所对应的虚拟地址。
    uint32_t e_phoff;     // 程序头表的位置偏移
    uint32_t e_shoff;     // 区段标题或0的文件位置
    uint32_t e_flags;     // 特定于体系结构的标志,通常为0
    uint16_t e_ehsize;    // 这个elf头的大小
    uint16_t e_phentsize; // 程序头中条目的大小
    uint16_t e_phnum;     // 程序头表中的入口数目
    uint16_t e_shentsize; // 节标题中条目的大小
    uint16_t e_shnum;     // 节标题中的条目数或0
    uint16_t e_shstrndx;  // 包含节名称字符串的节号。
};

总结

我们可以看到boot下的bootmain.c文件,其实前面是有很长一段文档的,大致翻译一下:

  • 这是一个简单的bootloader,唯一的工作就是启动;来自第一个IDE硬盘的ELF内核镜像

  • 磁盘布局

    • 此程序(bootasm.S和bootmain.c)是bootloader;存储在磁盘的第一个扇区中
    • 第二个扇区开始保存内核映像;内核映像必须为ELF格式。
  • 启动步骤

    • 当CPU启动时,它将BIOS加载到内存中并执行

      • BIOS初始化设备,中断例程集
      • 读取引导设备的第一个扇区(硬盘驱动器),进入内存并跳转到它
    • 假设bootloader存储在硬盘的第一个扇区中,此代码将会接管…

    • 控制从bootasm.S开始

    • 设置保护模式
    • 创建堆栈
    • 然后运行C代码,调用bootmain()
    • 该文件中的bootmain()会接管,读取内核并跳转到该内核。

练习5

实现函数调用堆栈跟踪函数(需要编程)。

实验要求

我们需要在lab1中完成kern/debug/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 
: -- 0x00007d72 –
……

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

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

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

补充材料

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

实验过程

先看看文件开头写了啥

#define STACKFRAME_DEPTH 20

extern const struct stab __STAB_BEGIN__[];  // beginning of stabs table
extern const struct stab __STAB_END__[];    // end of stabs table
extern const char __STABSTR_BEGIN__[];      // beginning of string table
extern const char __STABSTR_END__[];        // end of string table

/* debug information about a particular instruction pointer */
struct eipdebuginfo {
    const char *eip_file;                   // source code filename for eip
    int eip_line;                           // source code line number for eip
    const char *eip_fn_name;                // name of function containing eip
    int eip_fn_namelen;                     // length of function's name
    uintptr_t eip_fn_addr;                  // start address of function
    int eip_fn_narg;                        // number of function arguments
};

注:stabs table(Symbol Table String)也就是一种向调试器描述程序的信息格式

/* STABS表中的条目的格式如下 */
struct stab {
    uint32_t n_strx;        // 到名称字符串表的索引
    uint8_t n_type;         // 符号类型
    uint8_t n_other;        // 杂项信息(通常为空)
    uint16_t n_desc;        // 说明字段
    uintptr_t n_value;      // 符号的值
};

有关特定指令指针的调试信息,嗯,啥也没get到,继续看吧

接下来是一大段的注释,我直接翻成中文好了:

stab_binsearch:根据输入的初始值范围[ @ region_left, @ region_right],查找一个包含地址@addr并匹配类型@type,然后将其边界保存到@region_left和@region_right所指向的位置的stab条目。

某些stab类型按指令地址升序排列。例如,标记了函数和N_SO stab,以及源文件的N_FUN stabs (stab entries with n_type == N_FUN)。

给定指令地址,此函数将查找包含该地址的类型为@type的单个stab条目。

搜索在[@ region_left,@ region_right]范围内进行。因此,要搜索整个N stabs,您可以执行以下操作:

left = 0;
right = N - 1;    
stab_binsearch(stabs, &left, &right, type, addr);

搜索会修改* region_left和* region_right,以将@addr括起来。

@region_left指向包含@addr且匹配的stab,而@region_right指向下一个stab之前。
如果 @ region_left> @ region_right,则@addr不包含在任何匹配的stab中。

比如,给定这些 N_SO stabs:

Index Type Address
0 SO f0100000
13 SO f0100040
117 SO f0100176
118 SO f0100178
555 SO f0100652
556 SO f0100654
657 SO f0100849

假定代码:

left = 0;
right = 657;
stab_binsearch(stabs, &left, &right, N_SO, 0xf0100184);
// stab.h中这么定义的:
// #define N_SO        0x64    // main source file name

函数完成任务后,将会设定left = 118, right = 554

// 函数原型
static void 
stab_binsearch(const struct stab *stabs, int *region_left, int *region_right,int type, uintptr_t addr)
// 具体实现太长了,略

只要知道这是个经典的二分搜索就好啦!

debuginfo_eip:在@info结构中填写关于指定的指令地址@addr的信息。如果找到信息了就返回0,否则为负。 但是即使它返回负数,也会将一些信息存储到info中。

// 函数原型
int debuginfo_eip(uintptr_t addr, struct eipdebuginfo *info)
    
/* 调试有关特定指令指针的信息 */
struct eipdebuginfo {
    const char *eip_file;                   // source code filename for eip
    int eip_line;                           // source code line number for eip
    const char *eip_fn_name;                // name of function containing eip
    int eip_fn_namelen;                     // length of function's name
    uintptr_t eip_fn_addr;                  // start address of function
    int eip_fn_narg;                        // number of function arguments
};

print_kerninfo:打印有关内核的信息,包括内核条目的位置,数据和文本段的起始地址,可用内存的起始地址以及内核已使用了多少内存。

// 函数原型
void print_kerninfo(void)

print_debuginfo:读取并打印地址@eip的统计信息,而info.eip_fn_addr应该是相关函数的第一个地址。

// 函数原型
void print_debuginfo(uintptr_t eip)

然后还有一个内联汇编函数

static __noinline uint32_t read_eip(void) {
    uint32_t eip;
    asm volatile("movl 4(%%ebp), %0" : "=r" (eip));
    return eip;
}

给爷看困了,终于到我们要写的东西了

我的天,又是长篇大论啊啊啊啊,顶住!!!!

先看函数吧:

void print_stackframe(void) {
     /* 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]
      */
}

明晰任务很重要!

  • 调用read_ebp()来拿到ebp的值,类型是uint32_t

  • 调用read_eip()来拿到eip的值,类型是uint32_t

  • 从0到栈帧深度

    • 打印ebp,eip

    • 把在(uint32_t)ebp +2 [0…4]中的内容,赋给调用参数的[0…4]

    • 直接cprintf("\n");

    • 调用print_debuginfo(eip-1),打印C调用的函数名,行号啥的

    • 弹出调用的栈帧

(注意:调用函数返回的addr的eip = ss:[ebp+4]ebp = ss:[ebp]

print_stackframe:从当前执行点的嵌套调用指令中打印已保存的eip值的列表

说明:

x86堆栈指针(即esp)指向当前正在使用的堆栈上的最低位置。 堆栈中该位置以下的所有内容都是可用的。 将值压入堆栈将涉及减小堆栈指针,然后将值写入堆栈指针指向的位置。 而弹出一个值则相反。

相反,ebp(基址指针)寄存器主要通过软件约定与堆栈相关联。 进入C函数时,该函数的序言代码通常通过将前一个函数的基址指针压入堆栈来保存该基址指针,然后在函数持续时间内将当前esp值复制到ebp中。 如果程序中的所有函数都遵守该约定,则在执行程序期间的任何给定时刻,都可以通过遵循已保存的ebp指针链并确定是什么嵌套函数调用序列导致此特殊事件来追溯堆栈 点在程序中。 例如,当某个特定函数由于向错误函数传递了错误参数而导致断言失败或出现紧急情况时,此功能特别有用,但是您不确定谁传递了错误参数。 堆栈回溯使您可以找到有问题的功能。

是不是有点抽象?上图!

栈帧,多说一点

uCore lab1 操作系统实验_第3张图片

假设执行一个有两个入口参数的函数,步骤是这样的:

  • 调用函数前,依次压入两个入口参数(push xxx)

  • 压入函数返回地址,即call指令的下一条指令(call func)

  • 压入ebp,再将esp赋给ebp,让它指向自己这个单元,以此为基准(mov ebp,esp)

  • esp开辟局部变量空间(sub esp,72;不一定是72啦)

  • 压入可能要用到的寄存器,保护起来(push ebx/esi/edi/…)

  • 执行程序

  • 保护寄存器出栈(pop edi/esi/ebx/…)

  • 释放局部变量,esp回到基址处(mov ebp,esp)

  • ebp出栈(pop ebp)

  • 返回(ret),eip出栈,返回值放在eax

如果我们在函数中,要操作局部变量或者使用入口参数,就用ebp加上偏移,ebp就是负责栈数据的访问;而esp是用来操作栈顶(低地址处)和开辟栈空间的

而我们经常说的栈帧是啥呢?就是图上画的那块空间

明白一点了吧?继续看说明吧!

内联函数read_ebp()可以告诉我们当前ebp的值。 非内联函数read_eip()很有用,它可以读取当前eip的值,因为在调用此函数时,read_eip()可以轻松地从堆栈读取调用者的eip。
在print_debuginfo()中,函数debuginfo_eip()可以获得有关调用链的足够信息。 最后,print_stackframe()将跟踪并打印它们以进行调试。
注意,ebp链的长度是有限的。 在boot / bootasm.S中,在跳转到内核条目之前,ebp的值已设置为零,即边界。

说了这么一大串,该明白了,看着图整,准没错!

代码:

void print_stackframe(void) {
    // read ebp,eip
    uint32_t ebp = read_ebp();
    uint32_t eip = read_eip();
    int i,j;    // for loop
    for(i=0; i<STACKFRAME_DEPTH&&ebp!=0;i++) {
        cprintf("ebp:0x%08x ",ebp);
        cprintf("eip:0x%08x ",eip);
        // entry args
        uint32_t* args = (uint32_t*)ebp + 2;
        cprintf("args:");
        for(j=0;j<4;j++){
            cprintf("0x%08x ",args[j]);
        }     
        cprintf("\n");
        print_debuginfo(eip-1);
        // unstack
        eip = ((uint32_t*)ebp)[1];
        ebp = ((uint32_t*)ebp)[0];      
    }
}

注意点:

  • ebp不能为0啊,一定要考虑,为0就说明esp还没整活,没整活要你ebp干啥呢对吧
  • 在确认入口参数args时,记得把ebp转成指针,不然会出事
  • 先出eip,再出ebp,就是这么不科学,毕竟只是模拟;先出ebp,ebp被改变,eip就出不去了

输出结果:

(THU.CST) os is loading ...

Special kernel symbols:
  entry  0x00100000 (phys)
  etext  0x001032db (phys)
  edata  0x0010ea16 (phys)
  end    0x0010fd20 (phys)
Kernel executable memory footprint: 64KB
ebp:0x00007b08 eip:0x001009a6 args:0x00010094 0x00000000 0x00007b38 0x00100092 
    kern/debug/kdebug.c:307: print_stackframe+21
ebp:0x00007b18 eip:0x00100cad args:0x00000000 0x00000000 0x00000000 0x00007b88 
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp:0x00007b38 eip:0x00100092 args:0x00000000 0x00007b60 0xffff0000 0x00007b64 
    kern/init/init.c:48: grade_backtrace2+33
ebp:0x00007b58 eip:0x001000bb args:0x00000000 0xffff0000 0x00007b84 0x00000029 
    kern/init/init.c:53: grade_backtrace1+38
ebp:0x00007b78 eip:0x001000d9 args:0x00000000 0x00100000 0xffff0000 0x0000001d 
    kern/init/init.c:58: grade_backtrace0+23
ebp:0x00007b98 eip:0x001000fe args:0x001032fc 0x001032e0 0x0000130a 0x00000000 
    kern/init/init.c:63: grade_backtrace+34
ebp:0x00007bc8 eip:0x00100055 args:0x00000000 0x00000000 0x00000000 0x00010094 
    kern/init/init.c:28: kern_init+84
ebp:0x00007bf8 eip:0x00007d68 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 
    : -- 0x00007d67 --
++ setup timer interrupts

成了!NICE!!!

练习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”,而按下的键也会在屏幕上显示。

提示:可阅读小节“中断与异常”。

补充材料

分段存储管理机制

只有在保护模式下才能使用分段存储管理机制。分段机制将内存划分成以起始地址和长度限制这两个二维参数表示的内存块,这些内存块就称之为段(Segment)。编译器把源程序编译成执行程序时用到的代码段、数据段、堆和栈等概念在这里可以与段联系起来,二者在含义上是一致的。

分段机涉及4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。转换逻辑地址(Logical Address,应用程序员看到的地址)到物理地址(Physical Address, 实际的物理内存地址)分以下两步:

  • 分段地址转换:CPU把逻辑地址(由段选择子selector和段偏移offset组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址(Linear Address)。如果不启动分页存储管理机制,则线性地址等于物理地址。

  • 分页地址转换,这一步中把线性地址转换为物理地址。(注意:这一步是可选的,由操作系统决定是否需要。)

上述转换过程对于应用程序员来说是不可见的。线性地址空间由一维的线性地址构成,线性地址空间和物理地址空间对等。线性地址32位长,线性地址空间容量为4G字节。为了使得分段存储管理机制正常运行,需要建立好段描述符和段描述符表(参看bootasm.S,mmu.h,pmm.c)。

段描述符

在分段存储管理机制的保护模式下,每个段由如下三个参数进行定义:段基地址(Base Address)、段界限(Limit)和段属性(Attributes)。在ucore中的kern/mm/mmu.h中的struct segdesc 数据结构中有具体的定义。

/* segment descriptors */
struct segdesc {
    unsigned sd_lim_15_0 : 16;        // low bits of segment limit
    unsigned sd_base_15_0 : 16;        // low bits of segment base address
    unsigned sd_base_23_16 : 8;        // middle bits of segment base address
    unsigned sd_type : 4;            // segment type (see STS_ constants)
    unsigned sd_s : 1;                // 0 = system, 1 = application
    unsigned sd_dpl : 2;            // descriptor Privilege Level
    unsigned sd_p : 1;                // present
    unsigned sd_lim_19_16 : 4;        // high bits of segment limit
    unsigned sd_avl : 1;            // unused (available for software use)
    unsigned sd_rsv1 : 1;            // reserved
    unsigned sd_db : 1;                // 0 = 16-bit segment, 1 = 32-bit segment
    unsigned sd_g : 1;                // granularity: limit scaled by 4K when set
    unsigned sd_base_31_24 : 8;        // high bits of segment base address
};
  • 段基地址:规定线性地址空间中段的起始地址。在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):当处理器访问该段(当一个指向该段描述符的选择子被加载进一个段寄存器)时,将自动设置访问位。操作系统可清除该位。

全局描述符表

全局描述符表是一个保存多个段描述符的“数组”,其起始地址保存在全局描述符表寄存器GDTR中。GDTR长48位,其中高32位为基地址,低16位为段界限。由于GDT不能有GDT本身之内的描述符进行描述定义,所以处理器采用GDTR为GDT这一特殊的系统段。(注意,全局描述符表中第一个段描述符设定为空段描述符。)GDTR中的段界限以字节为单位。对于含有N个描述符的描述符表的段界限通常可设为8*N-1。在ucore中的boot/bootasm.S中的gdt地址处和kern/mm/pmm.c中的全局变量数组gdt[]分别有基于汇编语言和C语言的全局描述符表的具体实现。

/* *
 * Global Descriptor Table:
 *
 * The kernel and user segments are identical (except for the DPL). To load
 * the %ss register, the CPL must equal the DPL. Thus, we must duplicate the
 * segments for the user and the kernel. Defined as follows:
 *   - 0x0 :  unused (always faults -- for trapping NULL far pointers)
 *   - 0x8 :  kernel code segment
 *   - 0x10:  kernel data segment
 *   - 0x18:  user code segment
 *   - 0x20:  user data segment
 *   - 0x28:  defined for tss, initialized in gdt_init
 * */
static struct segdesc gdt[] = {
    SEG_NULL,
    [SEG_KTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_KERNEL),
    [SEG_KDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_KERNEL),
    [SEG_UTEXT] = SEG(STA_X | STA_R, 0x0, 0xFFFFFFFF, DPL_USER),
    [SEG_UDATA] = SEG(STA_W, 0x0, 0xFFFFFFFF, DPL_USER),
    [SEG_TSS]    = SEG_NULL,
};

#define SEG_NULL                                            \
    (struct segdesc){0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
#define SEG(type, base, lim, dpl)                        \
    (struct segdesc){                                    \
        ((lim) >> 12) & 0xffff, (base) & 0xffff,        \
        ((base) >> 16) & 0xff, type, 1, dpl, 1,            \
        (unsigned)(lim) >> 28, 0, 0, 1, 1,                \
        (unsigned) (base) >> 24                            \
    }

选择子

线性地址部分的选择子是用来选择哪个描述符表和在该表中索引一个描述符的。选择子可以做为指针变量的一部分,从而对应用程序员是可见的,但是一般是由连接加载器来设置的。

  • 索引(Index):在描述符表中从8192个描述符中选择一个描述符。处理器自动将这个索引值乘以8(描述符的长度),再加上描述符表的基址来索引描述符表,从而选出一个合适的描述符。

  • 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0代表应该访问全局

  • 描述符表(GDT),1代表应该访问局部描述符表(LDT)。 请求特权级(Requested Privilege Level,RPL):保护机制,在后续试验中会进一步讲解。

全局描述符表的第一项是不能被CPU使用,所以当一个段选择子的索引(Index)部分和表指示位(Table Indicator)都为0的时(即段选择子指向全局描述符表的第一项时),可以当做一个空的选择子(见mmu.h中的SEG_NULL)。当一个段寄存器被加载一个空选择子时,处理器并不会产生一个异常。但是,当用一个空选择子去访问内存时,则会产生异常

实验过程

先不急着回答问题,我们先弄明白我们的程序是怎么跑的,不然做了半天不白搭嘛

查看kern/init下的init.c文件代码:

int kern_init(void) {
    extern char edata[], end[];
    memset(edata, 0, end - edata);

    cons_init();                // 初始化控制台

    const char *message = "(THU.CST) os is loading ...";
    cprintf("%s\n\n", message);

    print_kerninfo();

    grade_backtrace();

    /* 我们上次的实验完成到这里 */
    
    pmm_init();                 // 初始化物理内存管理

    pic_init();                 // 初始化中断控制器(外设:8259A)
    idt_init();                 // 初始化中断描述符表 

    clock_init();               // 初始化时钟中断
    intr_enable();              // 使能irq中断

    // 下面是挑战内容,先不管
    //LAB1: CAHLLENGE 1 If you try to do it, uncomment lab1_switch_test()
    // user/kernel mode switch test
    //lab1_switch_test();

    /* do nothing */
    while (1);
}

好,现在来看问题!

问题1

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

打开kern下mm的mmu.c文件

/* Gate descriptors for interrupts and traps */
struct gatedesc {
    unsigned gd_off_15_0 : 16;        // low 16 bits of offset in segment
    unsigned gd_ss : 16;            // segment selector
    unsigned gd_args : 5;            // # args, 0 for interrupt/trap gates
    unsigned gd_rsv1 : 3;            // reserved(should be zero I guess)
    unsigned gd_type : 4;            // type(STS_{TG,IG32,TG32})
    unsigned gd_s : 1;                // must be 0 (system)
    unsigned gd_dpl : 2;            // descriptor(meaning new) privilege level
    unsigned gd_p : 1;                // Present
    unsigned gd_off_31_16 : 16;        // high bits of offset in segment
};

首先确认总字节:16+16+5+3+4+1+2+1+16 = 64 bit = 8byte

所以,中断向量表的一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移,两者联合便是中断处理程序的入口地址。

  • 使用段选择符中的偏移值在GDT(全局描述符表) 或 LDT(局部描述符表)中定位相应的段描述符。
  • 利用段描述符校验段的访问权限和范围,以确保该段是可以访问的并且偏移量位于段界限内。
  • 利用段描述符中取得的段基地址加上偏移量,形成一个线性地址。

问题2

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

一样不急,先看函数给的说明:

  • 每个中断服务程序(ISR,Interrupt Service Routine)的入口地址在哪?

    • 所有ISR的入口地址都存储在_ _vector中。 uintptr_t __vectors []在哪?
    • _ _vectors []位于kern / trap / vector.S中,由tools / vector.c产生
    • lab1中尝试“ make”命令,然后您将在kern / trap 中找到vector.S
    • 您可以使用“ extern uintptr_t __vectors [];” 定义此extern变量,稍后将使用它。
  • 现在,您应该在中断描述表(IDT)中设置ISR的入口地址。您可以在此文件中看到idt [256]吗? 是的,它是IDT! 您可以使用SETGATE宏来设置IDT的每个项目

// 是的,我看到idt了!
static struct gatedesc idt[256] = {{0}};

// -------------------------------------------------------------------

/* *
 * Set up a normal interrupt/trap gate descriptor
 *   - istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate
 *   - sel: Code segment selector for interrupt/trap handler
 *   - off: Offset in code segment for interrupt/trap handler
 *   - dpl: Descriptor Privilege Level - the privilege level required
 *          for software to invoke this interrupt/trap gate explicitly
 *          using an int instruction.
 * */
#define SETGATE(gate, istrap, sel, off, dpl) {            \
    (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        \
    (gate).gd_ss = (sel);                                \
    (gate).gd_args = 0;                                    \
    (gate).gd_rsv1 = 0;                                    \
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    \
    (gate).gd_s = 0;                                    \
    (gate).gd_dpl = (dpl);                                \
    (gate).gd_p = 1;                                    \
    (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        \
}
  • 设置完IDT的内容后,您将使用“ lidt”指令让CPU知道IDT在哪。
    • 您不知道这个指令的含义吗? 上Google啊! 查看libs / x86.h了解更多信息。
    • 注意:lidt的参数是idt_pd。 尝试找到它!

答:lidt指令用于把内存中的限长值和基地址操作数加载到IDTR寄存器中。

完成以下代码:

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);
    } 
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
    lidt(&idt_pd);
}

为什么要SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);呢?

因为在发生中断时候,我们需要从用户态切换到内核态,所以得留个口让用户态进来啊。

问题3

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

不急,看说明:

trap:处理或调度异常/中断。

只有当trap()返回时,kern / trap / trapentry.S中的代码将还原保存在trapframe中的旧CPU状态,然后使用iret指令从异常中返回。

代码:

void trap(struct trapframe *tf) {
    // 根据发生的陷阱类型进行调度
    trap_dispatch(tf);
}

所以我们要完成的是trap_dispatch函数中的其中一小段,看起来很simple啊(我猜的)!

case IRQ_OFFSET + IRQ_TIMER:
        /* LAB1 YOUR CODE : 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!
         */
        break;

什么意思呢?

  • 计时器中断后,您应该使用全局变量记录该事件(增加),例如,kern / driver / clock.c中的滴答声
  • 在每个TICK_NUM周期中,您都可以使用诸如print_ticks()之类的函数来打印一些信息。
  • 太简单了?我想也是!

上代码:

case IRQ_OFFSET + IRQ_TIMER:
        ticks++;
        if(ticks%TICK_NUM==0){
            print_ticks();
        }
        break;

然后我们再偷偷改一下print_ticks,嘿嘿嘿

运行结果:

(THU.CST) os is loading ...

Special kernel symbols:
  entry  0x00100000 (phys)
  etext  0x00103481 (phys)
  edata  0x0010ea16 (phys)
  end    0x0010fd20 (phys)
Kernel executable memory footprint: 64KB
ebp:0x00007b08 eip:0x001009a6 args:0x00010094 0x00000000 0x00007b38 0x00100092 
    kern/debug/kdebug.c:307: print_stackframe+21
ebp:0x00007b18 eip:0x00100cad args:0x00000000 0x00000000 0x00000000 0x00007b88 
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp:0x00007b38 eip:0x00100092 args:0x00000000 0x00007b60 0xffff0000 0x00007b64 
    kern/init/init.c:48: grade_backtrace2+33
ebp:0x00007b58 eip:0x001000bb args:0x00000000 0xffff0000 0x00007b84 0x00000029 
    kern/init/init.c:53: grade_backtrace1+38
ebp:0x00007b78 eip:0x001000d9 args:0x00000000 0x00100000 0xffff0000 0x0000001d 
    kern/init/init.c:58: grade_backtrace0+23
ebp:0x00007b98 eip:0x001000fe args:0x001034bc 0x001034a0 0x0000130a 0x00000000 
    kern/init/init.c:63: grade_backtrace+34
ebp:0x00007bc8 eip:0x00100055 args:0x00000000 0x00000000 0x00000000 0x00010094 
    kern/init/init.c:28: kern_init+84
ebp:0x00007bf8 eip:0x00007d68 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 
    : -- 0x00007d67 --
++ setup timer interrupts
New Rap New Star, MDSB!
New Rap New Star, MDSB!
New Rap New Star, MDSB!
New Rap New Star, MDSB!
New Rap New Star, MDSB!
New Rap New Star, MDSB!
New Rap New Star, MDSB!

New Rap New Star, MDSB!

好了好了不开玩笑,看看分数

moocos-> make grade
Check Output:            (2.3s)
  -check ring 0:                             WRONG
   !! error: missing '0: @ring 0'
   !! error: missing '0:  cs = 8'
   !! error: missing '0:  ds = 10'
   !! error: missing '0:  es = 10'
   !! error: missing '0:  ss = 10'

  -check switch to ring 3:                   WRONG
   !! error: missing '+++ switch to  user  mode +++'
   !! error: missing '1: @ring 3'
   !! error: missing '1:  cs = 1b'
   !! error: missing '1:  ds = 23'
   !! error: missing '1:  es = 23'
   !! error: missing '1:  ss = 23'

  -check switch to ring 0:                   WRONG
   !! error: missing '+++ switch to kernel mode +++'
   !! error: missing '2: @ring 0'
   !! error: missing '2:  cs = 8'
   !! error: missing '2:  ds = 10'
   !! error: missing '2:  es = 10'
   !! error: missing '2:  ss = 10'

  -check ticks:                              OK
Total Score: 10/40
make: *** [grade] Error 1

10/40…够了够了!知足了!后面切换用户态内核态的,确实有点难啊我擦,战术性放弃!

你可能感兴趣的:(操作系统,bootloader,内核,堆栈)