实验目的

操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-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:理解通过 make 生成执行文件的过程

列出本实验各练习中对应的 OS 原理的知识点,并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点。
在此练习中,大家需要通过静态分析代码来了解:

  1. 操作系统镜像文件 ucore.img 是如何一步一步生成的?(需要比较详细地解释 Makefile 中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
  2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

操作系统镜像文件 ucore.img 是如何一步一步生成的?

首先使用 make "V="可以了解 make 执行了哪些命令,通过分析 make 执行了那些命令
就可以来分析 Makefile 中每一条相关命令和命令参数的含义

# 构建 kernel 内核文件
# 初始化相关
+ cc kern/init/init.c
gcc -Ikern/init/ -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

# 标准IO
+ cc kern/libs/stdio.c
gcc -Ikern/libs/ -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/ -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

# 异常处理相关,便于在发现错误后,调用kernel monitor。
+ cc kern/debug/panic.c
gcc -Ikern/debug/ -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/ -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

# 监视器相关,提供动态分析命令的kernel monitor,便于在ucore出现bug或问题之后,能够进入kernel monitor中,查看当前调用关系。
+ cc kern/debug/kmonitor.c
gcc -Ikern/debug/ -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/ -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/ -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/ -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

# 实现了通过设置CPU的Eflags来屏蔽和使能中断的函数。
+ cc kern/driver/intr.c
gcc -Ikern/driver/ -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/ -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:109:16: warning: taking address of packed member of ‘struct trapframe’ may result in an unaligned pointer value [-Waddress-of-packed-member]
109 |     print_regs(&tf->tf_regs);
    |                ^~~~~~~~~~~~

# 包括256个中断服务例程的入口地址和第一步初步处理时先。
+ cc kern/trap/vectors.S
gcc -Ikern/trap/ -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/ -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

# 设定ucore操作系统在段机制中要用到的全局变量:任务状态栏ts,全局描述符表gdt[],加载全局描述符表寄存器的函数lgdt,临时的内核栈stack(),以及对全局描述符表和任务状态段的初始化函数gdt_init。
+ cc kern/mm/pmm.c
gcc -Ikern/mm/ -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/ -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/ -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

/*
-m 模拟指定的连接器elf_i386
-nostdlib 不使用标准库
-T 指定命令文件为tools/kernel.ld
-o 指定输出文件名字为kernel
*/

# 构建 bootblock
# 定义并实现bootloader最先执行的start函数,此函数进行了一定的初始化,完成了从实模式到保护模式的转换,并调用了bootmain.c中的bootmain函数。
+ cc boot/bootasm.S
gcc -Iboot/ -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

/*
-ggdb:生成可供gdb使用的调试信息。这样才能用qemu+gdb来调试bootloader或ucore
-m32:生成适用于32位环境的代码。我们用的模拟硬件是32位的80386,所以ucore也要是32位的软件
-gstabs:生成stabs格式的调试信息。ucore的monitor可以显示出便于开发者阅读的函数调用栈信息
-nostdinc:不使用标准库。标准库是给应用程序用的,我们是编译ucore内核,OS内核是提供服务的,所以所有的服务要自给自足。
-fno-stack-protector:不生成用于检测缓冲区溢出的代码。
-Os:为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512字节,我们写的简单bootloader的最终大小不能大于510字节。
-I:添加搜索头文件的路径。
-Wall:产生尽可能多的警告信息。
-fno-builtin:除非用__builtin__前缀,否则不进行builtin函数的优化
*/


# 主程序,定义并实现了bootmain函数,实现了通过屏幕、串口和并口显示字符串,bootmain函数加载ucore操作系统到内存,然后跳到ucore的入口处执行。
+ cc boot/bootmain.c
gcc -Iboot/ -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 将 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 命令链接 bootasm.o、bootmain.o 至 bootblock.out
+ 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: 500 bytes
build 512 bytes boot sector: 'bin/bootblock' success!

/*
-m:模拟指定的连接器为elf_i386
-N:指定读取/写入文本和数据段
-e:使用指定的符号start作为程序的初始执行点
-Ttext:使用指定的地址0x7C00作为文本段的起始点
-nonstdlib:不使用标准库
*/

# 构建 ucore.img
# 使用 dd 工具创建 ucore.img 空文件
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.0445599 s, 115 MB/s

# 使用dd工具将bin/bootblock写入ucore.img, 参数conv=notrunc表示不截断输出文件
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes copied, 0.0001275 s, 4.0 MB/s

# 使用dd工具将bin/kernel写入ucore.img起始的1个block后,即bootblock后, 参数seek=1表示从输出文件开头跳过1个block开始写入
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
154+1 records in
154+1 records out
78968 bytes (79 kB, 77 KiB) copied, 0.0009979 s, 79.1 MB/s

这点也可以从 Makefile 文件中得到验证:

# 创建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)
  • dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换。
  • if=文件名:输入文件名,缺省为标准输入。即指定源文件。< if=input file >
  • of=文件名:输出文件名,缺省为标准输出。即指定目的文件。< of=output file >
  • count=blocks:仅拷贝 blocks 个块,块大小等于 ibs 指定的字节数。
  • conv=conversion:用指定的参数转换文件。
  • conv=notrunc:不截断输出文件。

整个过程总结如下:

  1. 编译所有生成 bin/kernel 所需的文件,也就是.c 文件转化为.o 文件。
  2. 链接生成 bin/kernel
  3. 编译 bootasm.S bootmain.c sign.c 转化为.o 文件。
  4. 根据 sign 规范链接生成 bin/bootblock
  5. 生成 ucore.img。先创建一个大小为 10000 字节的块,然后再将 bootblock,kernel 拷贝过去。通过 dd 命令将 bootblock 放到第一个 sector,将 kernel 放到第二个 sector 开始的区域。可以明显看出 bootblock 就是引导区,kernel 则是操作系统内核。

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

通常我们将包含 MBR(主引导记录)引导代码的扇区称为主引导扇区。通 常由 3 部分组成:

  • 主引导程序(MBR,占 446 字节)
  • 磁盘分区表项(占 4×16 个字节,负 责说明磁盘上的分区情况)
  • 结束标志位(占 2 个字节,其值为 55 AA ) 。

上题中的 sign.o 工具可以规范化 bootblock.o,生成 bin/bootblock 引导扇区,因此查看 sign.c 源代码进行分析,代码如下

执行命令 less tools/sign.c,查看代码

#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>

int
main(int argc, char *argv[]) {
    struct stat st;

    // 命令参数检查
    if (argc != 3) {
        fprintf(stderr, "Usage: <input filename> <output filename>\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);

    // 文件大小检查,超过510字节则报错,因为最后2个字节要用作结束标志位
    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));    // 初始化为0
    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;
}

可以看到,硬盘主引导扇区的特征是:

  • 磁盘主引导扇区只有 512 字节
  • 内容不超过 510 字节
  • 最后两个字节为 0x55AA

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

为了熟悉使用qemu和gdb进行的调试工作,我们进行如下的小练习:

  1. 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
  2. 在初始化位置0x7c00设置实地址断点,测试断点正常。
  3. 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
  4. 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。

实验过程

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

由于 BIOS 是在实模式下运行的,因此需要在 tools/gdbinit 里进行相 应设置,所以根据实验指导书附录 B,修改 lab1/tools/gdbinit⽂件的 内容为:

set arch i8086 
target remote: 1234

我们在 Lab 0 的时候已经配置好了 gdbinit,现在可以直接使用。

接着,在 lab1 的目录下打开终端,输入 make debug之后,会弹出多个 窗口,在调试窗口中输入 si 就可以单步跟踪执行,也可以用 x 指令打 印特定数目的汇编代码。

qemu 加载vbios_linux


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

在这里插入代码片修改 gdbinit 文件,代码如下:

file bin/kernel 
target remote :1234 
set architecture i8086 
b *0x7c00 
continue 
x /2i $pc

再次运行 make debug

qemu 加载vbios_加载_02


终端显示如上,可见断点设置成功。

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

首先在 gdbinit 文件中将 continue 后面的 x /2i $pc 改为 x /10i $pc 用来显示 10 条汇编指令。

file bin/kernel 
target remote :1234 
set architecture i8086 
b *0x7c00 
continue 
x /10i $pc

执行 continue 后

qemu 加载vbios_qemu 加载vbios_03


bootasm.S 中的代码进行比较

.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

    # 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

可见,二者的汇编代码部分是一致的。

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

选择 0x7c0c 作为断点并用 c 指令执行到断点为止,效果如下:

Breakpoint 1, 0x00007c00 in ?? () 
=> 0x7c00: cli 
   0x7c01: cld 
(gdb) b *0x7c0c 
Breakpoint 2 at 0x7c0c 
(gdb) c 
Continuing. 

Breakpoint 2, 0x00007c0c in ?? () 
(gdb) x/5i $pc 
=> 0x7c0c: test $0x2,%al 
   0x7c0e: jne 0x7c0a 
   0x7c10: mov $0xd1,%al 
   0x7c12: out %al,$0x64 
   0x7c14: in $0x64,%al

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

BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执 行 bootloader。请分析 bootloader 是如何完成从实模式进入保护模式 的。
提示:需要阅读小节“保护模式和分段机制"和 lab1/bootbootasm.S 源 码,了解如何从实模式切换到保护模式,需要了解:

  • 为何开启A20,以及如何开启A20
  • 如何初始化GDT表
  • 如何使能和进入保护模式

根据提示,我们先阅读小节“保护模式和分段机制”

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

建议先阅读附录 A“关于 A20 Gate”

为何开启 A20

  • i8086 提供了 20 根地址线,但寄存器只有 16 位,因此 CPU 只 能访问 1MB 以内的空间。CPU 想获取数据,需对 segment 左移 4 位,再加上 offest ,最终形成一个 20 位 的地址: address = segment << 4 | offset。
  • 但按这种方式计算出的地址的最大值为 1088KB,超过 20 根 地址线所能表示的范围(1MB=1024KB),会发生回卷(memory wraparound)(和整数溢出有点类似)。但下一代的基于 i80286 的计算机系统提供了 24 根地址线,当 CPU 计算出的 地址超过 1MB 时不会发生回卷,这就造成了向下不兼容。为 了保持完全的向下兼容性,IBM 在计算机系统上加个硬件逻 辑来模仿早期的回绕特征,而这就是 A20 Gate,通过这个模 块我们在实模式下将 第 20 位 的地址线限制为 0,这样 CPU 就无法访问超过 1MB 的空间了。
  • A20 Gate 的方法是把 A20 地址线控制和键盘控制器的一个输 出进行 AND 操作,这样来控制 A20 地址线的打开(使能)和 关闭(屏蔽/禁止)。一开始时 A20 地址线控制是被屏蔽的(总 为 0),直到系统软件通过一定的 IO 操作去打开它。当 A20 地址线控制禁止时,则程序就像在 i8086 中运行,1MB 以上 的地址不可访问;保护模式下 A20 地址线控制打开,之后内 存寻址将不会发生回卷,这时 CPU 可以充分使用 32 位 4G 内 存的寻址能力(内存管理能力)。

如何开启 A20

  • 在当前环境中,所用到的键盘控制器 8042 的 IO 端口只有 0x60 和 0x64 两个端口。8042 通过这些端口给键盘控制器或键盘 发送命令或读取状态。输出端口 P2 用于特定目的。位 0(P20 引脚)用于实现 CPU 复位操作,位 1(P21 引脚)用于控制 A20 信号线的开启与否。我们要操作的位置是输出端口 P2 的 位 1,写入方法为:向 64h 发送 0xd1 命令,表示我们想要修 改端口 P2;然后向 60h 发送想要写入的数据,这里是 0xdf, 实现端口 P2 的位 1(即 P21)置 1。

    当我们想要向 8042 输出端口进行写操作的时候,在键盘缓冲区中 或许还有别的数据尚未处理,因此必须首先处理这些数据。

激活 A20 地址线的流程为:

  1. 禁止中断
  2. 等待,直到 8042 Input buffer 为空为止
  3. 发送 Write 8042 Output Port 命令到 8042 Input buffer
  4. 等待,直到 8042 Input buffer 为空为止
  5. 向端口 P2 写入数据。

如何初始化 GDT 表

初始化 GDT 表:一个简单的 GDT 表和其描述符已经静态储存在引导区中, 载入即可

lgdt gdtdesc

进入保护模式:
通过将 cr0 寄存器 PE 位置 1 便开启了保护模式
cro 的第 0 位为 1 表示处于保护模式

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

通过长跳转更新 cs 的基地址:
上 面 已 经 打 开 了 保 护 模 式 , 所 以 这 里 需 要 用 到 逻 辑 地 址 。 $PROT_MODE_CSEG 的值为 0x80

ljmp $PROT_MODE_CSEG, $protcseg 

.code32 # Assemble for 32-bit mode 
protcseg:

设置段寄存器,并建立堆栈

movl $start, %esp

转到保护模式完成,进入boot主函数。call bootmain即为调用bootmain 函数

call bootmain

如何使能和进入保护模式

x86 引入了几个新的控制寄存器(Control Registers) cr0 cr1 … cr7 ,每个长 32 位。这其中的某些寄存器的某些位被用来控制 CPU 的工作模式,其中 cr0 的最低位,就是用来控制 CPU 是否处于保护模式 的。因为控制寄存器不能直接拿来运算,所以需要通过通用寄存器来进 行一次存取,设置 crO 最低位为 1 之后就已经进入保护模式。但是由于 现代 CPU 的一些特性(乱序执行和分支预测等),在转到保护模式之后 CPU 可能仍然在跑着实模式下的代码,这显然会造成一些问题。因此必 须强制 CPU 清空一次缓冲,最有效的方法就是进行一次 long jump

bootloader 从实模式进入保护模式的过程:

  • 在开启 A20 之后,加载了 GDT 全局描述符表,它是被静态储存在引导 区中的,载入即可。接着,将 crO 寄存器的 bit O 置为 1,标志着从实 模式转换到保护模式。
  • 由于我们无法直接或间接 mov 一个数据到 cs 寄存器中,而刚刚开启 保护模式时,cs 的影子寄存器还是实模式下的值,所以需要告诉 CPU 加载新的段信息。长跳转可以设置 cs 寄存器,CPU 发现了 crO 寄存器第 О位的值是 1,就会按 GDTR 的指示找到全局描述符表 GDT,然后根据索 引值把新的段描述符信息加载到 cs 影子寄存器,当然前提是进行了一 系列合法的检查。所以使用一个长跳转 limp $PROT_MODE CSEG, $protcseg 以更新 cs 基地址,至此 CPU 真正进入了保护模式,拥有了 32 位的处理能力。
  • 进入保护模式后,设置 ds,es,fs,gs,ss 段寄存器,建立堆栈 (0~Ox7c00),最后进入 bootmain 函数。

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

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

  • bootloader 如何读取硬盘扇区的?
  • bootloader 是如何加载 ELF 格式的 OS?

bootloader 如何读取硬盘扇区的?

接着上个练习,打开 bootmain,在 bootmain 中第一句就是读取硬盘扇区,代码如下:

/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
    // 判断磁盘是否处于忙碌状态
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}

/* readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
    //等待磁盘准备好
    waitdisk();

    // 设置磁盘参数
    outb(0x1F2, 1);          // 往0X1F2地址中写入要读取的扇区数,由于此处需要读一个扇区,因此参数为1

    // 0x1F3-0x1F6 设置LBA模式的参数
    outb(0x1F3, secno & 0xFF);                // 输入LBA参数的0-7位
    outb(0x1F4, (secno >> 8) & 0xFF);        // 输入LBA参数的8-15位
    outb(0x1F5, (secno >> 16) & 0xFF);        // 输入LBA参数的16-23位
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);    // 输入LBA参数的24-27位(对应到0-3位),第四位为0表示从主盘读取,其余位被强制置为1
    outb(0x1F7, 0x20);                      // 发出读取扇区的指令

    //等待磁盘准备好
    waitdisk();

    // 从0x1F0端口处读数据,除以4是因为此处是以4个字节为单位的
    insl(0x1F0, dst, SECTSIZE / 4);
}

大致流程如下:

  1. 等待磁盘准备好
  2. 发出读取扇区的命令
  3. 等待磁盘准备好
  4. 把磁盘扇区数据读取到指定内存

各地址代表的寄存器意义如下:

  • 0x1FO R,当 0x1F7 不为忙状态时可以读
  • Ox1F2 R/W,扇区数寄存器,记录操作的扇区数
  • Ox1F3 R/W,扇区号寄存器,记录操作的起始扇区号
  • Ox1F4 R/W,柱面号寄存器,记录柱面号的低 8 位
  • Ox1F5 R/W,柱面号寄存器,记录柱面号的高 8 位
  • 0x1F6 R/W,驱动器/磁头寄存器,记录操作的磁头号、驱动器号和寻 道方式,前 4 位代表逻辑扇区号的高 4 位,DRV=0/1代表主/从驱动器, LBA =0/1 代表 CHS/LBA 方式。
  • Ox1F7 R,状态寄存器,第 6、7 位分别代表驱动器准备好/驱动器忙
  • Ox1F8 W,命令寄存器,Ox20 命令代表读取扇区

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

首先看 elfhdr、proghdr 相关的信息,libs/elf.h 代码如下

#ifndef __LIBS_ELF_H__
#define __LIBS_ELF_H__

#include <defs.h>

#define ELF_MAGIC    0x464C457FU            // 小端格式下"\x7FELF"

/* 文件头 */
struct elfhdr {
    uint32_t e_magic;     // 必须等于ELF_MAGIC魔数
    uint8_t e_elf[12];    // 12 字节,每字节对应意义如下:
// 0 : 1 = 32 位程序;2 = 64 位程序
// 1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式
// 2 : 只是版本,固定为 0x1
// 3 : 目标操作系统架构
// 4 : 目标操作系统版本
// 5 ~ 11 : 固定为 0

    uint16_t e_type;      // 1=可重定位, 2=可执行, 3=共享对象, 4=核心镜像
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // 文件版本,总为1
    uint32_t e_entry;     // 程序入口地址(如果可执行)
    uint32_t e_phoff;     // 程序段表头相对elfhdr偏移位置
    uint32_t e_shoff;     // 节头表相对elfhdr偏移量
    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;     // 节头部个数
    uint16_t e_shstrndx;  // 节头部字符索引
};

/* 程序段表头 */
struct proghdr {
    uint32_t p_type;   // 段类型
// 1 PT_LOAD : 可载入的段
// 2 PT_DYNAMIC : 动态链接信息
// 3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小
// 4 PT_NOTE : 指定辅助信息的位置和大小
// 5 PT_SHLIB : 保留类型,但具有未指定的语义
// 6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小
// 7 PT_TLS : 指定线程局部存储模板

    uint32_t p_offset; // 段相对文件头的偏移值
    uint32_t p_va;     // 段的第一个字节将被放到内存中的虚拟地址
    uint32_t p_pa;     //段的第一个字节在内存中的物理地址
    uint32_t p_filesz; //段在文件中的长度
    uint32_t p_memsz;  // 段在内存映像中占用的字节数
    uint32_t p_flags;  //可读可写可执行标志位。
    uint32_t p_align;   //段在文件及内存的对齐方式
};

#endif /* !__LIBS_ELF_H__ */

结合 boot/bootmain.c 代码分析如下

#define SECTSIZE        512
#define ELFHDR          ((struct elfhdr *)0x10000)

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // read the 1st page off disk
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);    //bootloader先将ELF格式的OS加载到地址0x10000

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

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    // 按照描述表将ELF文件中数据载入内存,将ELF中每个段都加载到特定的地址
    // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
// ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    // 跳转至ELF文件的程序入口点
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

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

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

加载 ELF 格式的 OS 的大致流程为:

  1. 读取 ELF 的头部
  2. 判断 ELF 文件是否合法
  3. 找到 ELF 有关内存位置的描述表
  4. 按照这个描述表将数据载入内存
  5. 根据 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 –
……

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

函数堆栈的原理

理解函数堆栈最重要的两点是:栈的结构,以及 EBP 寄存器的作用。 一个函数调用动作可分解为零到多个 PUSH 指令(用于参数入栈)和一个 CALL 指令。CALL 指令内部其实还暗含了一个将返回地址压栈的动作, 这是由硬件完成的。几乎所有本地编译器都会在每个函数体之前插入类 似如下的汇编指令:

pushl %ebp 
movl %esp,%ebp

这两条汇编指令的含义是:首先将 ebp 寄存器入栈,然后将栈顶指针 esp 赋值给 ebp。
movl %esp %ebp 这条指令表面上看是用 esp 覆盖 ebp 原来的值,其实不 然。因为给 ebp 赋值之前,原 ebp 值已经被压栈(位于栈顶),而新的 ebp 又恰恰指向栈顶。此时 ebp 寄存器就已经处于一个非常重要的地位, 该寄存器中存储着栈中的一个地址(原 ebp 入栈后的栈顶) ,从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下 (栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数 调用时的 ebp 值。

函数调用大概包括以下几个步骤:

  1. 参数入栈:将参数从右向左(或从右向左)依次压入系统栈中。
  2. 返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中, 供函数返回时继续执行。
  3. 代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。
  4. 栈帧调整
  1. 保存当前栈帧状态值,已备后面恢复本栈帧时使用(ebp 入栈)。
  2. 将当前栈帧切换到新栈帧(将 esp 值装入 ebp,更新栈帧底部)。
  3. 给新栈帧分配空间(把 esp 减去所需空间的大小,抬高栈顶)。

而函数返回大概包括以下几个步骤:

  1. 保存返回值,通常将函数的返回值保存在寄存器 eax 中。
  2. 弹出当前帧,恢复上一个栈帧。
  1. 在堆栈平衡的基础上,给 ESP 加上栈帧的大小,降低栈顶,回收当 前栈帧的空间
  2. 将当前栈帧底部保存的前栈帧 EBP 值弹入 EBP 寄存器,恢复出上一 个栈帧。
  3. 将函数返回地址弹给 EIP 寄存器。
  1. 跳转:按照函数返回地址跳回母函数中继续执行。

而由此我们可以直接根据 ebp 就能读取到各个栈帧的地址和值,一般而 言,ss:[ebp+4]处为这但地址,ss:[ebp+8]如六第一个参数值(最后一 个入栈的参数值,此处假设其占用 4 字节内存,对应 32 位系统), ss:[ebp-4]处为第一个局部变量,ss :[ebp]处为上一层 ebp 值。

print_stackframe 函数的实现

这样我们直接根据注释以及之前的相关知识就能比较简单的编写成程 序,如下所示:

/*
|  栈底方向    | 高位地址
|    ...      |
|    ...      |
|  参数3       |
|  参数2       |
|  参数1       |
|  返回地址     |
|  上一层[ebp]  | <-------- [ebp]
|  局部变量     |  低位地址
*/

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]
    */

    //读入ebp,eip
    uint32_t ebp = read_ebp(), eip = read_eip();
    uint32_t *arguments;
    int i, j;

    //如果ebp非零并且没有达到规定的STACKFRAME_DEPTH的上限,则继续循环打印栈上栈帧和对应函数的信息
    for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i++)
    {
        cprintf("ebp: 0x%08x eip:0x%08x args:", ebp, eip);
        arguments = (uint32_t *)ebp + 2;
        for (j = 0; j < 4; j++)
        {
            cprintf("0x%08x  ", arguments[j]);
        }
        cprintf("\n");

        // eip指向的是即将执行的指令,所以如果想要查看当前函数,需要减1。
        print_debuginfo(eip-1);

        // 将ebp和eip设置为上一个栈帧的ebp和eip
        // 注意要先设置eip后设置ebp,否则当ebp被修改后,eip就无法找到正确的ebp
        eip = *((uint32_t *)ebp + 1);
        ebp = *(uint32_t *)ebp;
    }
}

代码分析:先用 read_ebp 与 read_eip 获得最初的 ebp 与 eip 寄存器的 值,根据注释中的要求与输出结果规范,将 ebp 和 eip 输出。参数的值 在 ebp+2 这 个 地 址 , 我 们 用 变 量 arguments 将 其 保 存 , 并 通 过 arguments[0…3]输出参数的值,用 print_debuginfo 输出当前函数信 息,最后用 ebp 指针更新下一次循环时 ebp 与 eip 的值。

代码保存后在labcodes/lab1目录下执行make qemu,应该得到如下结果:

+ cc kern/debug/kdebug.c
+ ld bin/kernel
记录了10000+0 的读入
记录了10000+0 的写出
5120000字节(5.1 MB,4.9 MiB)已复制,0.0255234 s,201 MB/s
记录了1+0 的读入
记录了1+0 的写出
512字节已复制,0.000180683 s,2.8 MB/s
记录了153+1 的读入
记录了153+1 的写出
78784字节(79 kB,77 KiB)已复制,0.000332568 s,237 MB/s
WARNING: Image format was not specified for 'bin/ucore.img' and probing guessed raw.
        Automatically detecting the format is dangerous for raw images, write operations on block 0 will be restricted.
        Specify the 'raw' format explicitly to remove the restrictions.
(THU.CST) os is loading ...

Special kernel symbols:
entry  0x00100000 (phys)
etext  0x0010334f (phys)
edata  0x0010fa16 (phys)
end    0x00110d08 (phys)
Kernel executable memory footprint: 68KB
ebp: 0x00007b28 eip:0x001009a5 args:0x00010094  0x00010094  0x00007b58  0x0010008e
    kern/debug/kdebug.c:306: print_stackframe+21
ebp: 0x00007b38 eip:0x00100c9c args:0x00000000  0x00000000  0x00000000  0x00007ba8
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp: 0x00007b58 eip:0x0010008e args:0x00000000  0x00007b80  0xffff0000  0x00007b84
    kern/init/init.c:48: grade_backtrace2+33
ebp: 0x00007b78 eip:0x001000bc args:0x00000000  0xffff0000  0x00007ba4  0x00000029
    kern/init/init.c:53: grade_backtrace1+40
ebp: 0x00007b98 eip:0x001000dc args:0x00000000  0x00100000  0xffff0000  0x0000001d
    kern/init/init.c:58: grade_backtrace0+23
ebp: 0x00007bb8 eip:0x00100104 args:0x0010337c  0x00103360  0x000012f2  0x00000000
    kern/init/init.c:63: grade_backtrace+34
ebp: 0x00007be8 eip:0x00100051 args:0x00000000  0x00000000  0x00000000  0x00007c4f
    kern/init/init.c:28: kern_init+80
ebp: 0x00007bf8 eip:0x00007d72 args:0xc031fcfa  0xc08ed88e  0x64e4d08e  0xfa7502a8
    <unknow>: -- 0x00007d71 --
++ setup timer interrupts

最后一行的含义是:最初使用堆栈的那一个函数,即 bootmain。 bootloader 设置的堆栈从 Ox7c00 开始,使用 call bootmain 进入 bootmain 函数。call 指令压栈,所以 bootmain 中 ebp 为 Ox7bf8。后面 的 unknow 之后的 0x00007d71 是 bootmain 函数内调用 OS kernel 入口 函 数 的 指 令 的 地 址 。 eip 则 为 O0x00007d71 的 下 一 条 地 址 , 即 0x00007d72。后面的 args 则表示传递给 bootmain 函数的参数,但是由 于 bootmain 函数不需要任何参数,因此这些打印出来的数值并没有实 际意义。

练习 6:完善中断初始化和处理(需要编程)

本练习任务是 完成编码工作和回答如下问题:

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

要求完成问题 2 和问题 3 提出的相关函数实现,提交改进后的源代码包 (可以编译执行),并在实验报告中简要说明实现过程,并写出对问题 1 的回答。完成这问题 2 和 3 要求的部分代码后,运行整个系统,可以 看到大约每 1 秒会输出一次 100 ticks,而按下的键也会在屏幕上显示。

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

在 kern/mm/mmu.h 中可以找到表项的结构代码如下:

/* 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=8*8=64bit,即 8 字节。其中 0-15 位和 48-63 位分别为偏移量的低 16 位和高 16 位,两 者拼接得到段内偏移量,16-31 位 gd_ss 为段选择器。根据段选择子和 段内偏移地址就可以得出中断处理程序的地址。

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

实现后的 idt_init 代码如下

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void idt_init(void)
{
    /* LAB1 YOUR CODE : 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定义于vector.S中
    uint32_t i;
    for (i = 0; i < (sizeof(idt) / sizeof(struct gatedesc)); i++)
    {
    // 该idt项为内核代码,所以使用GD_KTEXT段选择子
    // 中断处理程序的入口地址存放于__vectors[i]
    // 特权级为DPL_KERNEL
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); //为中断程序设置内核态权限
    }
    SETGATE(idt[T_SYSCALL], 0, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); //为T_SYSCALL设置用户态权限
    lidt(&idt_pd); //使用lidt指令加载中断描述符表
}

传入 SETGATE 的参数:

  • 第一个参数 gate 是中断描述符表
  • 第二个参数 istrap 用来判断是中断还是 trap
  • 第三个参数 sel 的作用是进行段的选择
  • 第四个参数 off 表示偏移
  • 第五个参数 dpl 表示中断的优先级

代码分析:题目要求我们为每个中断设置权限,只有 T_SYSCALL 是用户 态权限(DPL_USER),其他都为内核态权限(DPL_KERNEL)。首先通过 _vectors[]获得所有中断的入口,再通过循环为每个中断设置权限(默 认为内核态权限),为 T_SYSCALL 设置用户态权限,最后通过 lidt 将 IDT 的起始地址装入 IDTR 寄存器即可。

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

由于所有中断最后都是统一在 trap_dispatch 中进行处理或者分配的, 因此不妨考虑在该函数中对应处理时钟中断的部分,对全局变量 ticks 加 1,并且当计数到达 100 时,调用 print_ticks 函数,从而完成每隔 一段时间打印 100 ticks 的功能。

实现后的 trap_dispatch 代码如下∶

/* 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 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!
        */

        // 全局变量ticks定义于kern/driver/clock.c
        ticks++;
        if (ticks % TICK_NUM == 0)//每次时钟中断之后ticks就会加1,当加到TICK_NUM次数时,打印ticks并重新开始
            print_ticks();//打印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");
        }
    }
}

运行 make qemu 的结果如下:

qemu 加载vbios_qemu 加载vbios_04

扩展训练 1 challenge1

扩展 proj4,增加 syscall 功能,即增加一用户态函数(可执行一特定系 统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户 态的函数,而用户态的函数又通过系统调用得到内核态的服务(通过网 络查询所需信息,可找老师咨询。如果完成,且有兴趣做代替考试的实 验,可找老师商量)。需写出详细的设计和分析报告。完成出色的可获 得适当加分。

提示:规范一下 challenge 的流程。

kern_init 调用 switch_test,该函数如下:

static void
    switch_test(void) {
        print_cur_status();          // print 当前 cs/ss/ds 等寄存器状态
        cprintf("+++ switch to  user  mode +++\n");
        switch_to_user();            // switch to user mode
        print_cur_status();
        cprintf("+++ switch to kernel mode +++\n");
        switch_to_kernel();         // switch to kernel mode
        print_cur_status();
    }

switchto函数建议通过中断处理的方式实现。主要要完成的代码是在 trap 里面处理 T_SWITCH_TO中断,并设置好返回的状态。

在 lab1 里面完成代码以后,执行 make grade 应该能够评测结果是否正 确。

目 的 是 完 成 init.c 中 的 switch_to_user 和 switch_to_kernel 和 trap.c 中 trap_dispatch()的 case T_SMITCH_Tou 和 caseT_SWITCH_TOK 四个函数

首先是 init.c 中的 switch_to_use 从中断返回时,会多 pop 两位,并用这两位的值更新 ss、sp,所以要先 把栈压两位。

static void
lab1_switch_to_user(void) {
    //LAB1 CHALLENGE 1 : TODO
    asm volatile(
    "sub $0x8,%%esp \n" //留出ss,esp的空间
    "int %0 \n" //中断
    "movl %%ebp,%%esp" //恢复栈指针
    :
    :"i"(T_SWITCH_TOU) //中断号
    );
}
static void
lab1_switch_to_kernel(void) {
    //LAB1 CHALLENGE 1 : TODO
    asm volatile (
    "int %0 \n"
    "movl %%ebp, %%esp \n"
    :
    : "i"(T_SWITCH_TOK)
    );
}

我们这里只需要在 case T_SWITCH_TOU: 和 case T_SWITCH_TOK: 两个 case 处添加修改段寄存器的代码即可:

//LAB1 CHALLENGE 1 : YOUR CODE you should modify below
codes.
   case T_SWITCH_TOU:
    tf->tf_cs=USER_CS;
    tf->tf_ds=USER_DS;
    tf->tf_es=USER_DS;
    tf->tf_ss=USER_DS;
    tf->tf_eflags|= FL_IOPL_MASK; //根据答案,此处设置的flag是为了用户能正常进行IO操作
    break;
   case T_SWITCH_TOK:
    tf->tf_cs = KERNEL_CS;
    tf->tf_ds = KERNEL_DS;
    tf->tf_es = KERNEL_DS;
    tf->tf_eflags &= ~FL_IOPL_MASK;
    break;

这样的话,只要触发 T_SWITCH_TOU 和 T_SWITCH_TOK 编号的中断, CPU 指令流就会通过 ISR 执行到这里,并进行内核态和用户态的切换。

查看成绩:

qemu 加载vbios_qemu 加载vbios_05

扩展练习 Challenge 2(需要编程)

用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0时切换到内核模式”。 基本思路是借鉴软中断(syscall功能)的代码,并且把trap.c中软中断处理的设置语句拿过来。

注意:

  1. 关于调试工具,不建议用lab1_print_cur_status()来显示,要注意到寄存器的值要在中断完成后tranentry.S里面iret结束的时候才写回,所以再trap.c里面不好观察,建议用print_trapframe(tf)
  2. 关于内联汇编,最开始调试的时候,参数容易出现错误,可能的错误代码如下
asm volatile ( "sub $0x8, %%esp \n"
"int %0 \n"
"movl %%ebp, %%esp"
: )

要去掉参数int %0 \n这一行

  1. 软中断是利用了临时栈来处理的,所以有压栈和出栈的汇编语句。硬件中断本身就在内核态了,直接处理就可以了。

首先,我们先理解题意,分析题目可以知道该题目要求的是用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0时切换到内核模式”。

所以,我们的思路就是捕获击键,然后调用上面写的两个函数。

我们发现在trap_dispatch函数中,有⼀个中断是专门⽤来处理键盘 输⼊的,内容如下:

case IRQ_OFFSET + IRQ_COM1:
    c = cons_getc();
    cprintf("serial [%03d] %c\n", c, c);
    break;

这里的作用就是捕获击键然后输出通过键盘输入的字符,所以只需要判断输入的字符然后来切换模式就可以了。这里切换模式也就是调用扩展一写的两个函数,所以改写之后的函数为:

case IRQ_OFFSET + IRQ_KBD:
    c = cons_getc();
    if ( c == '3' ) {
      if(tf->tf_cs != USER_CS) {
        cprintf("switch to user");
      //将CS设置为用户代码段
        tf->tf_cs = USER_CS;
      //将DS、ES、SS设置为用户数据段
        tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
      //设置eflags 确保ucore可以在用户模式下使用IO
        tf->tf_eflags |= FL_IOPL_MASK;
        print_trapframe(tf);
      }else
      {
        cprintf("you are in user!");
      }
    }else if ( c == '0' ) {
      if(tf->tf_cs!=KERNEL_CS)
      {
        cprintf("switch to kernel");
        tf->tf_cs = KERNEL_CS;
        tf->tf_ds = tf->tf_es = tf->tf_ss = KERNEL_DS;
        tf->tf_eflags &= ~FL_IOPL_MASK;
        print_trapframe(tf);
      }else
    {
        cprintf("you are in kernel!");
    }
  }
      cprintf("kbd [%03d] %c\n", c, c);
      break;

可以看到输入3的时候,屏幕输出的结果如下:

switch to usertrapframe at 0x7b7c
edi 0x00000000
esi 0x00010094
ebp 0x00007be8
oesp 0x00007b9c
ebx 0x00010094
edx 0x0000000c
ecx 0x00000000
eax 0x00000003
ds 0x----0023
es 0x----0023
fs 0x----0023
gs 0x----0023
trap 0x00000021 Hardware Interrupt
err 0x00000000
eip 0x00100075
cs 0x----001b
flag 0x00003206 PF,IF,IOPL=3
esp 0x001036fc
ss 0x----0023

输入0的结果如下:

switch to kerneltrapframe at 0x10fcd4
edi 0x00000000
esi 0x00010094
ebp 0x00007be8
oesp 0x0010fcf4
ebx 0x00010094
edx 0x0000000c
ecx 0x00000000
eax 0x00000003
ds 0x----0010
es 0x----0010
fs 0x----0023
gs 0x----0023
trap 0x00000021 Hardware Interrupt
err 0x00000000
eip 0x00100075
cs 0x----0008
flag 0x00000206 PF,IF,IOPL=0

可以看到完成了模式的切换,所以实现是正确的。

实验总结

本次实验中学会了很多知识点,比如基于分段的内存管理机制;CPU 的中断机制;x86 CPU 的保护模式;计算机系统的启动过程;ELF 文件格式; 读取 LBA 模式硬盘的方法;编译 ucore OS 和 bootloader 的过程;GDB 的 使用方法;c 函数的函数调用实现机制等。同时加深了对于 OS 中的物理 内存的管理;外存的访问;OS 的启动过程;OS 中对中断机制的支持;OS 使 用保护机制对关键代码和数据的保护等知识点了的解。除此以外,对比 参考答案和自己的答案,可以发现大部分都是相同的,也有些许差别, 如在练习 2 的调试中,参考答案将 gdb 调试结果输出到 log 文件中,而 本实验中的解答只是将调试结果输出到屏幕上。