练习 1

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

    通过执行命令 make V="",使 make 显示出编译过程中执行的命令:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    + 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
    // 省略相似命令……
    + 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/ -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.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: 504 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.0156548 s, 327 MB/s
    dd if=bin/bootblock of=bin/ucore.img conv=notrunc
    1+0 records in
    1+0 records out
    512 bytes copied, 7.1747e-05 s, 7.1 MB/s
    dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
    154+1 records in
    154+1 records out
    79036 bytes (79 kB, 77 KiB) copied, 0.000255015 s, 310 MB/s

    可以看出,生成镜像主要有以下步骤:

    1. 使用 gcc 编译 kern 目录和 boot 下的所有 .c C 语言文件和 .S 汇编代码,可以看到比起平时的编译多出了很多选项:

      • -fno-builtin:表示防止 gcc 使用自带的内置函数,比如用到的 strcpy,如果没有这个选项,gcc 会跳过我们的代码,使用它自带的 strcpy 函数。
      • -nostdinc:表示不要在系统自带的标准库的目录下搜索包含文件,只在 -I 指定的目录下搜索,这也是为了防止我们自定义的 strcpy 之类的标准库函数和系统自带的产生冲突。
      • -fno-PIC:表示不要生成 PIC (Position Independent Code)。经过查资料得知,生成 PIC 会使代码体积变大,在 https://github.com/chyyuu/ucore_os_lab/pull/22 可以知道,由于新版本 gcc 默认启用 PIC,所以会导致生成的 bootloader 大于 512 字节,无法放进一个扇区中。并且, PIC 需要 bootloader 正确处理重定位,而 uCore 的 bootloader 不支持处理重定位,在后续实验中也会产生问题。
      • -fno-stack-protector:同样是为了减小代码体积。启用栈保护的话在函数调用时会增加额外的代码。
      • -Wall:表示打印出所有警告,可以帮我们尽早地发现可能出现的问题。
      • -gstabs-ggdb :表示生成调试信息,帮助我们后续使用 gdb 进行调试。
      • -m32:生成 32 位代码。
      • -c:只编译不链接。后面由我们自己使用 ld 手动链接。

      值得注意的是,在编译 bootloader 的时候还增加了一个额外的选项:

      • -Os:意思是指示编译器尽可能地优化代码体积,因为 bootloader 在去掉启动标识符和分区表之后只有 466 字节可用,因此需要尽可能小。
    2. 使用 ld 进行链接,将目标文件连接成为可执行文件。一个是 kernel 是操作系统的内核,一个是 bootloader 用来加载我们编写的操作系统。

      • 链接内核的命令是:

        1
        ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/init.o ...
        • -m elf_i386 表示模拟 32 位体系。
        • -nostdlib 不链接标准库。
        • -T tools/kernel.ld 使用 tools/kernel.ld 链接脚本进行链接。

        可以看到 tools/kernel.ld 脚本如下:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        /* Simple linker script for the JOS kernel.
        See the GNU ld 'info' manual ("info ld") to learn the syntax. */

        OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
        OUTPUT_ARCH(i386)
        ENTRY(kern_init)

        SECTIONS {
        /* Load the kernel at this address: "." means the current address */
        . = 0x100000;

        .text : {
        *(.text .stub .text.* .gnu.linkonce.t.*)
        }

        PROVIDE(etext = .); /* Define the 'etext' symbol to this value */

        .rodata : {
        *(.rodata .rodata.* .gnu.linkonce.r.*)
        }

        /* Include debugging information in kernel memory */
        .stab : {
        PROVIDE(__STAB_BEGIN__ = .);
        *(.stab);
        PROVIDE(__STAB_END__ = .);
        BYTE(0) /* Force the linker to allocate space
        for this section */
        }

        .stabstr : {
        PROVIDE(__STABSTR_BEGIN__ = .);
        *(.stabstr);
        PROVIDE(__STABSTR_END__ = .);
        BYTE(0) /* Force the linker to allocate space
        for this section */
        }

        /* Adjust the address for the data segment to the next page */
        . = ALIGN(0x1000);

        /* The data segment */
        .data : {
        *(.data)
        }

        PROVIDE(edata = .);

        .bss : {
        *(.bss)
        }

        PROVIDE(end = .);

        /DISCARD/ : {
        *(.eh_frame .note.GNU-stack)
        }
        }

        比较重要的部分有:

        • 指定了程序起始地址为 0x100000
        • 导出了 etextedataend 地址供后续 C 语言程序使用,以便能够正确初始化内存。
        • 加入内核调试信息。
      • 而链接 bootloader 的命令是:

        1
        ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

        和链接内核的有少许不同:

        • -N:将 text 段设置为可读可写,不对齐 data 段,不链接动态库。
        • -e:程序的入口是 start 标签。
        • -Ttext 0x7C00:程序的 text 段从 0x7C00 开始,因为 BIOS 会将 bootloader 放在 0x7C00 处开始执行。
    3. bootloader 签名,使得操作系统可以正常引导。即在 bootloader 第 511、512 个字节处要写入 0x550xAA

    4. 使用 dd 命令生成镜像。

      1. 首先生成一个 10000 个扇区的全零的镜像。
      2. 然后在第一个扇区(扇区 0)写入大小为 512 字节的 bootloader
      3. 然后在 bootloader 之后(扇区 1)写入真正的操作系统内核。
  2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

    • 大小为 512 字节。
    • 最后两个字节为0x550xAA
    • 由不超过 466 字节的启动代码和不超过 64 字节的硬盘分区表加上两个字节的结束符 0x55AA 组成。

练习 2

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

操作步骤:

  1. 执行 make 命令生成可启动镜像。

  2. 输入命令 qemu-system-i386 -S -s -parallel stdio -hda ./bin/ucore.img -serial null 启动 qemu 模拟器。

    • -s 表示在 tcp::1234 打开 GDB 调试服务器。
    • -S 代表开机后暂停 CPU 执行,只有用户输入命令之后才开始执行。
    • -hda 指定了硬盘的镜像。
    • -parallel-serial 分别指定并行口和串行口的输出重定向位置。

    Screenshot_20190322_151009

    Screenshot_20190322_151025

    可以看到 qemu 启动后没有继续加载,而是暂停了 CPU 的执行。

  3. 启动 gdb,输入 target remote :1234 连接到 qemu 准备调试,并且输入 set architecture i8086 指示 qemu 现在的可执行代码是 16 位的,因为 BIOS 代码是 16 位的。

    Screenshot_20190322_151147

  4. 准备完成之后。Screenshot_20190322_151400

  5. 输入命令

    1
    2
    3
    define hook-stop
    x/i $pc
    end

    在每次暂停执行代码时强制反汇编当前命令。

    输入 stepi 开始单步跟踪 BIOS 执行过程,运行结果如下图所示:Screenshot_20190322_151623

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

    1. 按照上面的操作启动 qemugdb 调试之后,输入 break *0x7c00 在地址 0x7c00 处设下断点。
    2. 然后输入 continue 让 CPU 正常执行。
    3. 可以看到,当 CPU 执行到 0x7c00 处时,遇到了断点,自动暂停执行。此时输入 x/i $pc 进行反汇编,正是 bootloader.S 的第一条指令 cli

    Screenshot_20190322_152021

  7. 在调用 qemu 时增加 -d in_asm -D q.log 参数,便可以将运行的汇编指令保存在 q.log 中。 将执行的汇编代码与 bootasm.Sbootblock.asm 进行比较,看看二者是否一致。

    1. 执行命令 qemu-system-i386 -d in_asm -D q.log -S -s -parallel stdio -hda ./bin/ucore.img -serial null 启动 qemu 并记录执行的汇编代码到 q.log 中。
    2. 执行命令后,用文本编辑器打开 q.log 文件和 bootasm.S 进行比较,定位到地址 0x00007c00 处可以发现它们是一致的。Screenshot_20190322_211958

练习 3

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <asm.h>

# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.

.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag

# 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

# 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

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

# If bootmain returns (it shouldn't), loop.
spin:
jmp spin

# 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

启动过程如下:

  1. 关闭中断,清空主要寄存器。
  2. 打开 A20 模式,通过控制 8042 键盘控制器实现。
  3. 设置全局描述符,将全局描述符表的地址和大小加载到寄存器中。
  4. 设置 CR0 寄存器 &#36;CR0_PE_ON 位,启动保护模式。
  5. 然后在 32 位模式下,设置好段寄存器和栈寄存器,跳转到 bootmain 也就是加载内核 ELF 的程序。

练习 4

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

  1. bootloader 是如何读取硬盘扇区的?

    bootloader 主要通过 readsect()waitdisk() 两个函数进行硬盘扇区的读取。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    static void
    readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();

    outb(0x1F2, 1); // count = 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 - read sectors

    // wait for disk to be ready
    waitdisk();

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);
    }

    首先等待磁盘控制器准备好,然后写入控制信息,0x1F2 是要读取的扇区数,0x1F30x1F6 存储着要进行操作的扇区号码(小端存储),然后向 0x1F7 发送读取指令。等磁盘再次就绪后,通过反复调用 insl 指令 SECTSIZE / 4 次,将磁盘内容以 4 字节为一组读取到内存中。

    基于这两个函数,bootloader 还有函数 readseg() 包装了一下基本操作,用以读取多个扇区。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    static void
    readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // round down to sector boundary
    va -= offset % SECTSIZE;

    // translate from bytes to sectors; kernel starts at sector 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);
    }
    }

    函数的作用是从磁盘的 offset 字节处开始读取 count 个字节到 va 指向的虚拟地址中。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    void
    bootmain(void) {
    // read the 1st page off disk
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF?
    if (ELFHDR->e_magic != ELF_MAGIC) {
    goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    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
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

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

    /* do nothing */
    while (1);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    struct elfhdr {
    uint32_t e_magic; // must equal ELF_MAGIC
    uint8_t e_elf[12];
    uint16_t e_type; // 1=relocatable, 2=executable, 3=shared object, 4=core image
    uint16_t e_machine; // 3=x86, 4=68K, etc.
    uint32_t e_version; // file version, always 1
    uint32_t e_entry; // entry point if executable
    uint32_t e_phoff; // file position of program header or 0
    uint32_t e_shoff; // file position of section header or 0
    uint32_t e_flags; // architecture-specific flags, usually 0
    uint16_t e_ehsize; // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum; // number of entries in program header or 0
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum; // number of entries in section header or 0
    uint16_t e_shstrndx; // section number that contains section name strings
    };

    首先函数加载 ELF 的头部到内存中,判断魔数是否合法,如果合法,则开始读取 ELF 中的每一段到内存中的相应位置。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct proghdr {
    uint32_t p_type; // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va; // virtual address to map segment
    uint32_t p_pa; // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz; // size of segment in memory (bigger if contains bss)
    uint32_t p_flags; // read/write/execute bits
    uint32_t p_align; // required alignment, invariably hardware page size
    };

    ELF 段的信息由 proghdr 结构指示,第一个 proghdr 位于 e_phoff 偏移量,数量则是 e_phnum,其中 p_va 指示段在内存中的虚拟地址,p_memsz 标志了段的大小,p_offset 则是相对于 ELF 文件的偏移量。

    将每一段都读取到了内存中的相应位置之后,就跳转到 ELF 头部中 e_entry 所指示的入口地址执行,控制权移交给操作系统。

练习 5

实现函数调用堆栈跟踪函数 print_stackframe()

通过附录得知,在每个函数体的开始,编译器都会插入如下汇编代码:

1
2
pushl   %ebp
movl %esp, %ebp

这样在程序执行到一个函数的实际指令前,已经有以下数据顺序入栈:参数、返回地址、ebp 寄存器。由此得到类似如下的栈结构(以 cdecl 方式为例):

1
2
3
4
5
6
7
8
9
+|    栈底方向     |  高位地址
| .... |
| .... |
| 参数 3 |
| 参数 2 |
| 参数 1 |
| 返回地址 |
| 上一层[ebp] | <-------- [ebp]
| 局部变量 | 低位地址

此时 ebp 处于非常重要的地位,因为给 ebp 赋值之前,原 ebp 值已经被压栈(位于栈顶),而新的 ebp 又恰恰指向栈顶。从函数堆栈结构可以知道,通过 ebp 寄存器中的地址向上可以获得返回地址和参数,向下可以获取到局部变量,通过它本身可以获取到上一层函数调用的 ebp 地址。

根据这个结构,就可以写出显示函数调用堆栈的函数如下了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
print_stackframe(void) {
uint32_t ebp = read_ebp(), eip = read_eip();

for (int i = 0; i < STACKFRAME_DEPTH && ebp != 0; ++i) {
uint32_t *args = (uint32_t *)ebp + 2;
cprintf("ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x",
ebp, eip, args[0], args[1], args[2], args[3]);
cprintf("\n");
print_debuginfo(eip - 1);
eip = *(uint32_t*) (ebp + 4);
ebp = *(uint32_t*) ebp;
}
}

需要注意的是在遇到 ebp == 0 的情况下,已经没有上一层的函数调用了,应该终止调用。

完成函数编写之后,执行 make qemu 查看执行结果:Screenshot_20190325_095912

输出最后一层是函数调用的最深层,也就是第一个被调用的函数 kern_init() 因为栈地址是从 0x7c00 开始,在从 bootmain() 函数调用到内核初始化函数 kern_init() 时有 call 指令,0x7bfc 地址存放的是返回地址,而编译器在函数体的头部都会插入:

1
2
pushl   %ebp
movl %esp, %ebp

通过查看 kernel.asm 也可以确认:Screenshot_20190325_101758于是此时栈顶地址变为 0x7bf8,而这个地址赋值给了 ebp 寄存器,我们就看到了 ebp 寄存器的内容是 0x7bf8 了。这个函数调用没有参数,后面的参数没有意义。

练习 6

完善中断初始化和处理。

  1. 中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

    查看 struct gatedesc 定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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
    };

    可以看到一个 struct gatedesc 占用 8 字节,其中低 2 字节和高 2 字节拼接成段偏移的低 16 位和高 16 位,2-3 字节则是段选择子。

  2. 请编程完善 kern/trap/trap.c 中对中断向量表进行初始化的函数 idt_init。在 idt_init 函数 中,依次对所有中断入口进行初始化。使用 mmu.h 中的 SETGATE 宏,填充 idt 数组内容。注意除了系统调用中断 (T_SYSCALL)以外,其它中断均使用中断门描述符,权限为内核态权限;而系统调用中断使用异常,权限为用户态权限。每个中断的入口由 tools/vectors.c 生成,使用 trap.c 中声明的 vectors 数组即可。

    代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void
    idt_init(void) {
    extern uintptr_t __vectors[];
    for (int i = 0; i < sizeof(idt) / sizeof(struct gatedesc); ++i) {
    SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
    }

    SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
    SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);

    lidt(&idt_pd);
    }

    使用 SETGATE 宏设置好中断描述表,调用 lidt 命令将其加载,以后遇到中断时就会进入我们的中断处理程序。

  3. 请编程完善 trap.c 中的中断处理函数 trap(),在对时钟中断进行处理的部分填写 trap() 函数中处理时钟中断的部分,使操作系统每遇到 100 次时钟中断后,调用 print_ticks() 子程序,向 屏幕上打印一行文字“100 ticks”。 可以知道定时器中断的中断号是 IRQ_TIMER ,所以在 switch 块中找到 IRQ_TIMER 填入相关代码。代码很简单:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static void
    trap_dispatch(struct trapframe *tf) {
    char c;
    switch (tf->tf_trapno) {
    case IRQ_OFFSET + IRQ_TIMER:
    if ((++ticks) % TICK_NUM == 0) print_ticks();
    break;
    // ...
    }

完成代码后,执行 make qemu ,可以看到大约每隔一段时间就会输出一行 “100 ticks”,同时屏幕上会回显键盘按键。Screenshot_20190325_110047

扩展练习

这一部分的代码注释比较少,需要自己理解的部分较多。

由于切换特权级别的操作需要通过更改 CS 段寄存器来实现,但是 CS 段寄存器不能直接修改。在中断发生时,硬件会自动将 CSEIPEFLAGSERRCODE 压栈,中断返回时从栈中恢复这些寄存器的值。因此,必须通过 iret 指令修改 CS 段寄存器中的值,也就是在中断处理程序中,必须更改 struct trapframe 中的 tf_cs 字段才能实现特权级的转换。对于 SS 段选择子寄存器而言也是一样的,要更改它的值必须通过修改 struct trapframe 来实现。

需要注意的是 iret 指令在不同情况下表现是不同的。查阅资料得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
if(OperandSize == 32) {
if(!IsWithinStackLimits(TopStackBytes(12)) Exception(SS); //top 12 bytes of stack not within stack limits
TemporaryEIP = Pop();
TemporaryCS = Pop();
TemporaryEFLAGS = Pop();
}
//Protected mode return
//PE == 1, VM == 0 in flags image
if(ReturnCode.SegmentSelector == 0) Exception(GP(0));
if(!IsWithinDescriptorTableLimits(ReturnCode.SegmentSelector.AddressesDescriptor)) Exception(GP(Selector));
ReturnCode.SegmentDescriptor = ReadSegmentDescriptor(ReturnCode.SegmentSelector);
if(!IsCodeSegment(ReturnCode.SegmentDescriptor)) Exception(GP(Selector));
if(ReturnCode.SegmentSelector.RPL < CPL) Exception(GP(Selector));
if(IsConforming(ReturnCode.SegmentDescriptor) && ReturnCode.Segment.DPL > ReturnCode.SegmentSelector.RPL) Exception(GP(Selector));
if(ReturnCode.SegmentSelector.RPL > CPL) {
//Return to outer privilege level
if(OperandSize == 32 && if(!IsWithinStackLimits(TopStackBytes(8)) Exception(SS(0)); //top 8 bytes of stack not within stack limits
else /*OperandSize == 16*/ if(!IsWithinStackLimits(TopStackBytes(4)) Exception(SS(0)); //top 4 bytes of stack not within stack limits
StackSegmentSelector = ReadReturnSegmentSelector();
if(StackSegmentSelector == 0) Exception(GP(0));
if(!IsWithinDescriptorTableLimits(ReturnStackSegmentSelector.Index)) Exception(GP(SSSelector));
SegmentDescriptor = ReadSegmenDescriptor(ReturnSegmentSelector);
if(StackSegmentSelector.RPL != ReturnCode.SegmentSelector.RPL) {
if(StackSegmentSelector.RPL != ReturnCode.SegmentSelector.RPL || !IndicatesWritableDataSegment(StackSegmentDescriptor) || StackSegment.DPL != ReturnCode.SegmentSelector.RPL) Exception(GP(SSSelector));
if(!IsPresent(StackSegment)) Exception(SS(SSSelector));
}
if(!IsWithinCodeSegmentLimit(TemporaryEIP)) Exception(GP(0));
EIP = TemporaryIP;
CS = TemporaryCS;
EFLAGS.CF = TemporaryEFLAGS.CF;
EFLAGS.PF = TemporaryEFLAGS.PF;
EFLAGS.AF = TemporaryEFLAGS.ZF;
EFLAGS.SF = TemporaryEFLAGS.SF;
EFLAGS.TF = TemporaryEFLAGS.DF;
EFLAGS.OF = TemporaryEFLAGS.OF;
EFLAGS.NT = TemporaryEFLAGS.NT;
if(OperandSize == 32) {
EFLAGS.RF = TemporaryEFLAGS.RF;
EFLAGS.AC = TemporaryEFLAGS.AC;
EFLAGS.ID = TemporaryEFLAGS.ID;
}
if(CPL <= IOPL) EFLAGS.IF = TemporaryEFLAGS.IF;
if(CPL == 0) {
EFLAGS.IOPL = TemporaryEFLAGS.IOPL;
if(OperandSize == 32) {
EFLAGS.VM = TemporaryEFLAGS.VM;
EFLAGS.VIF = TemporaryEFLAGS.VIF;
EFLAGS.VIP = TemporaryEFLAGS.VIP;
}
}
//perform operation for each of the segment registers
SegmentRegisters[] = {ES, FS, GS, DS};
while(SegmentRegister = SegmentRegisters.Next()) if((PointsToDate(SegmentRegister) || !IsConformingCodeSegment(SegmentRegister)) && CPL > SegmentDescriptor.DPL /*stored in hidden part of segment register*/) SegmentSelector = 0; //segment register invalid; null segment selector
//END
} else {
//Same privilege level
//PE=1, VM=0 in flags image, RPL=CPL
if(!IsWithinCodeSegmentLimits(EIP)) Exception(GP(0));
EIP = TemporaryEIP;
CS = TemporaryCS; //segment descriptor information also loaded
EFLAGS.CF = TemporaryEFLAGS.CF;
EFLAGS.PF = TemporaryEFLAGS.PF;
EFLAGS.AF = TemporaryEFLAGS.ZF;
EFLAGS.SF = TemporaryEFLAGS.SF;
EFLAGS.TF = TemporaryEFLAGS.DF;
EFLAGS.OF = TemporaryEFLAGS.OF;
EFLAGS.NT = TemporaryEFLAGS.NT;
if(OperandSize == 32) {
EFLAGS.RF = TemporaryEFLAGS.RF;
EFLAGS.AC = TemporaryEFLAGS.AC;
EFLAGS.ID = TemporaryEFLAGS.ID;
}
if(CPL <= IOPL) EFLAGS.IF = TemporaryEFLAGS.IF;
if(CPL == 0) {
EFLAGS.IOPL = TemporaryEFLAGS.IOPL;
if(OperandSize == 32) {
EFLAGS.VM = TemporaryEFLAGS.VM;
EFLAGS.VIF = TemporaryEFLAGS.VIF;
EFLAGS.VIP = TemporaryEFLAGS.VIP;
}
}
//END
}

从上面的伪代码可以看出,如果在中断返回时,代码段从特权级较高的一层转换到了特权级较低的一层,iret 指令还会将 SSESP 出栈覆盖原来的值,也就是说在从内核态切换到用户态的时候我们还要设置 tf_ss 到用户段空间中,确保栈段选择子的 RPL 与代码段选择子的 RPL 相同,否则会引发 General Protection 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct trapframe switchk2u, *switchu2k;
static void
trap_dispatch(struct trapframe *tf) {
char c;
switch (tf->tf_trapno) {
// ...
case T_SWITCH_TOU:
switchk2u = *tf;
switchk2u.tf_cs = USER_CS;
switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
switchk2u.tf_esp = (uintptr_t) &tf->tf_esp;
switchk2u.tf_eflags |= FL_IOPL_3;
// Will overwrite %esp
*((uint32_t *)tf - 1) = (uint32_t) &switchk2u;
break;
// ...
}

从以上代码可以看出,从内核态切换到用户态时要先修改各段寄存器,然后将 tf_esp 设置为原来的栈顶,最后更改 tf_eflags 的特权级。

1
2
3
4
5
6
7
8
9
+|    栈底方向     | 高位地址
| .... |
| .... |
| .... |
| .... |
| .... |
| .... | <-------- [tf](tf 即原来的 %esp)
| tf | <-------- [esp]
| .... | 低位地址

最后一行的

1
*((uint32_t *)tf - 1) = (uint32_t) &switchk2u;

比较巧妙,在这句执行完毕后,函数就返回到了

1
2
3
pushl %esp
call trap
popl %esp # <------ 返回到此处

而此时的栈变为了

1
2
3
4
5
6
7
8
9
+|    栈底方向     |  高位地址
| .... |
| .... |
| .... |
| .... |
| .... |
| .... | <-------- [tf](tf 即原来的 %esp)
| &switchk2u | <-------- [esp]
| .... | 低位地址

所以,ESP 马上就会被 switchk2u 的地址所覆盖,接下来寄存器的出栈,和段寄存器的修改,都会以我们刚刚修改的 struct trapframe switchk2u 的内容为准,因此也就完成了从内核态到用户态的切换。在 gdb 中也可以观察到:Screenshot_20190326_104346

TIM截图20190326191809

而在用户态切换到内核态的时候,中断处理例程的 CS 段选择子权限高于发起中断的程序,所以会发生栈的切换,struct trapframe 中的 tf_esp 就是原来的 ESP 寄存器,而 SS 段选择子已经在发生中断的时候切换到内核的段选择子了,无需我们手动设定。同时,iret 返回时,tf_cs 的特权级也已经变成了内核特权级,没有发生特权级的转换,所以 SSESP 都不会被栈的内容覆盖,也无需设定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct trapframe switchk2u, *switchu2k;
static void
trap_dispatch(struct trapframe *tf) {
char c;
switch (tf->tf_trapno) {
// ...
case T_SWITCH_TOK:
if (tf->tf_cs == KERNEL_CS) return;
tf->tf_cs = KERNEL_CS;
tf->tf_ds = tf->tf_es = KERNEL_DS;
tf->tf_eflags &= ~FL_IOPL_MASK;
switchu2k = (struct trapframe *) (tf->tf_esp - (sizeof(struct trapframe) - 8));
memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
*((uint32_t *)tf - 1) = (uint32_t) switchu2k;
break;
// ...
}

代码和切换到用户态类似,只不过少了 tf_sstf_esp 的拷贝。

完成代码后,执行 make grade ,可以看出我们的实现是正确的:

Screenshot_20190326_212545

从上面的分析可以看出,调用切换到用户态的函数时,会多 pop 两个 32 位数,所以在调用前要先保护栈,不过由于在中断处理程序中我们已经将栈指针改到别的地方去了,所以其实不太需要。

增加一个系统调用用来获取当前时钟计数值,则是通过 T_SYSCALL 中断来实现,系统调用采用 EAX 寄存器传递调用号,这里就取 1 作为获取时钟计数的调用号了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// kern/init/init.c
static int get_ticks(void) {
int ticks;

asm volatile (
"movl %2, %%eax \n"
"int %1 \n"
"movl %%eax, %0 \n"
"movl %%ebp, %%esp \n"
: "=r"(ticks)
: "i"(T_SYSCALL), "i"(1)
: "%eax"
);

return ticks;
}

// kern/trap/trap.c
struct trapframe switchk2u, *switchu2k;
static void
trap_dispatch(struct trapframe *tf) {
char c;
switch (tf->tf_trapno) {
// ...
case T_SYSCALL:
if (tf->tf_cs == KERNEL_CS) { return; }
if (tf->tf_regs.reg_eax == 1)
tf->tf_regs.reg_eax = ticks;
break;
// ...
}

最后通过键盘上的“0”和“3”切换用户态和内核态的代码就是在主循环中加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (1) {
int c = getchar();
cprintf("[kbd %c] ", c);
switch (c) {
case '0':
cprintf("Switch to kernel mode\n");
lab1_switch_to_kernel();
lab1_print_cur_status();
break;
case '3':
cprintf("Switch to user mode\n");
lab1_switch_to_user();
lab1_print_cur_status();
break;
case 't':
cprintf("get_ticks(): %d\n", get_ticks());
break;
}
}

Screenshot_20190327_082634

按下 “3” 后,切换到用户态,按下 “0” 后,切换到内核态,按下“t”后就获得当前时钟计数值并输出。

值得注意的是,在内核态中调用切换到内核态的函数不会有任何作用;但如果在用户态调用切换到用户态模式,则会导致权限不足引发 General ProctectionScreenshot_20190327_084425Screenshot_20190327_084409