lab1 — 系统软件启动过程

前置知识

make/gdb 使用、磁盘 MBR 格式规范、BIOS 执行流程、bootloader 执行流程、ELF 文件格式、函数调用底层过程、中断处理流程。

练习一

此练习用于了解编译 ucore 源代码为镜像文件 ucore.img 的整体流程。

因为我不是很懂 Makefile,因此我会尽可能地避免介绍 Makefile 内部相关指令含义。

在命令行目录 labcodes_answer/lab1_result 中,依次输入命令 make clean,make "V=",即可得到详细的编译过程 (已删除若干冗余输出结果):

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
# 编译 .c 得到 .o 文件。着重说明几个重要参数:-ggdb 用于生成 gdb 调试信息、-nostdinc 不使用标准头文件、-Ixxx 使用指定位置 xxx 处的头文件。
+ 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
+ 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
...
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
# 链接上述 .o 文件得到 kernel 可执行文件。着重说明几个重要参数:-m elf_i386 仿真 elf_i386 机器的链接器功能、-nostdlib 不使用标准库、-T xxx 使用特定链接脚本 xxx (其中主要指定各段的起始地址,设定代码段应当位于 0x100000 处) 进行链接。
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
# 编译 .S 或 .c 得到 .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
+ 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
# 编译得到 sign 可执行文件,其用于判断并规格化主引导扇区(如果满足容量限制的话)。
+ cc tools/sign.c
gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
# 链接 bootblock.o 和 bootasm 得到 bootblock 可执行文件。着重说明几个重要参数:-m elf_i386 仿真 elf_i386 机器的链接器功能、-nostdlib 不使用标准库、-N 指定代码段和数据段可读写、-e 指定入口点、-Ttext 指定代码段的起始地址
+ 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
# 此两句输出结果表明,bootblock.out 所占容量为 500 bytes,并以此为基础,成功构建规格化的主引导扇区 (就是将 512 字节的最后两个字节设为 0x55AA,此两个字节的存在表明当前扇区为规格化的主引导扇区)。
'obj/bootblock.out' size: 500 bytes
build 512 bytes boot sector: 'bin/bootblock' success!

# 构建空的 ucore.img 镜像文件,并将 bootblock 和 kernel 放置其中。
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.0307689 s, 166 MB/s
# 放置 bootblock 至 ucore.img 的第一个扇区
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes copied, 0.000149858 s, 3.4 MB/s
# 放置 kernel 至 ucore.img 的第二个扇区。
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
146+1 records in
146+1 records out
74868 bytes (75 kB, 73 KiB) copied, 0.000415369 s, 180 MB/s

可详见 os_kernel_lab/labcodes_answer/lab1_result/tools/sign.c 以查看其功能。

ucore 源代码中的 bootblock 也是常说的 bootloader

ucoreMBR 十分简单,含 bootloader 而不含磁盘分区表信息。

练习二

此练习用于了解计算机启动至 bootblock 开始执行期间的指令执行顺序。

对于 intel 以往机器而言,地址线 20 位 (物理寻址空间 2^20 = 1M),而寄存器仅 16 位。为实现访问全部的地址空间,另设若干段寄存器 (例如 CS 表示代码段寄存器),其中存放段基址。此时实际物理地址 = (段基址 << 4 + IP)。鉴于此种地址访问方式直接访问物理地址,因此其称为 实模式

为兼容以往机器,intel x86 机器启动后,首先进入 实模式,并寻址第一条指令 CS:IP = 0xf000:0xfff0 => 0xffff0 以执行 BIOS 指令。

为了解此执行顺序,需要进行单步调试。

首先查看 labcodes/lab1/Makefile 中的 debug 部分指令:

1
2
3
4
5
# $(QEMU) 执行 QEMU 模拟器、-S 启动 QEMU 而不启动 CPU,等待 monitor (其用于与 QEMU 通信,以执行暂停、运行模拟器等工作) 输入 'c' 后才启动 CPU 以进行模拟、-s 等待 gdb 远程连接、-D 指定日志存放位置、-monitor 重定向 monitor 至 stdio、 -hda 指定硬盘镜像文件
$(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR)/q.log -monitor stdio -hda $< -serial null"
$(V)sleep 2
# 启动 gdb 调试,并使用 lab1init 进行初始化。
$(V)$(TERMINAL) -e "gdb -q -x tools/gdbinit"

其中 gdbinit 内容如下 (相较于源代码,此部分已经修改):

1
2
3
4
5
6
7
# 加载 kernel 的调试信息(暂时没用)
file bin/kernel
# 设置当前所模拟机器的指令架构
set architecture i8086
# 远程连接 QEMU
target remote :1234
# 根据上述的 debug 部分指令可知,此时仅启动 QEMU 而尚未启动 CPU,因此其位于 BIOS 尚未执行的状态。

在命令行目录 labcodes/lab1 中,输入命令 make debug,开始进行调试。

查看 CS:IP 取值及此处指令:

1
2
3
4
5
6
7
8
9
10
# CS:IP 取值与上述相同。
(gdb) p/x $cs
$2 = 0xf000
(gdb) p/x $eip
$3 = 0xfff0
# 此处指令为一跳转指令,用于跳转至实际的 BIOS 指令 (如此设计,仍是兼容以往机器的缘故)。
(gdb) x /i 0xffff0
0xffff0: ljmp $0x3630,$0xf000e05b
# 大佬解释说,$0x3630 为 QEMU 版本问题,可以忽略。
# ljmp $0xf000e05b => 设置 CS:IP=0xf000:0xe05b,并跳转至此位置执行 BIOS 代码。

简要叙述 BIOS 功能:

  • 提供基本的输入输出功能 (例如,机器启动进入 BIOS 界面后,允许输入键盘信息并输出信息至显示屏)。
  • 硬件自检 (检查与机器启动密切相关的硬件是否正常,如果不正常则直接启动失败,否则才允许继续启动)
  • 加载 bootloader (BIOS 允许从指定磁盘启动,那么它便会加载指定磁盘的 0 号扇区,即 MBR ,至 0x0:0x7C00 处,并设置 CS:IP 为此值,从而执行 bootloader 指令)。

输入如下指令,以设置断点并运行机器至断点指令处。

1
2
(gdb) b *0x7c00
(gdb) continue

查看断点处指令,可得 (其与 bootasm.S 文件中 start 处指令相同,即执行的是此处指令,相关原因可见上述的 make "V=" 输出结果):

1
2
3
4
5
6
7
8
(gdb) p/x $pc
$4 = 0x7c00
(gdb) x /5i $pc
=> 0x7c00: cli
0x7c01: cld
0x7c02: xor %eax,%eax
0x7c04: mov %eax,%ds
0x7c06: mov %eax,%es

练习三

此练习用于了解 bootasm.S 代码所做的工作。

bootasm.Sbootloader 的一部分,其主要完成三件事:1. 开启 A20;2. 初始化 GDT 表;3. 开启保护模式。

对于 intel x86 机器而言,其地址线 32 位 (物理寻址空间 2^32 = 4G),普通寄存器 32 位,段寄存器仍为 16 位 (兼容以往机器)。为访问全部的地址空间且基于分段提供保护功能,段寄存器此时存放 GPT 索引表位置及相关保护位,其中 GPT 表项存放段初始位置 (32 位)、段长、访问权限等信息,此时实际物理地址 = (段索引表示的段初始位置 + IP)。鉴于此种地址访问方式间接访问物理地址,因此其称为 保护模式。(在我看来,分段机制基本没用,不如使用分页机制,而且现今 intel 采用扁平模式已经略过分段机制了)

bootasm.S 的具体实现,详见源代码:

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
87
88
89
90
91
92
93
94
#include <asm.h>

# 注:Intel x86 中的段选择器,也称为段选择子。
# 段选择子仍存在一定规则,高 13 位表示其在 gdt 中的索引位,低 3 位为保护信息。
.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:

# bootloader 所做的第一件事:使能 A20 地址线,如此便可访问 4G 物理地址空间 (仍是兼容以往机器的缘故)。
# 使能 A20 地址线比较复杂,涉及较多底层硬件,简单来说:禁中断、等待 Input Buffer 为空、输入写命令、等待 Input Buffer 为空、输入置位命令。
.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:
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
# 至此,完成使能 A20 操作。

# 加载 gdtdesc 所表示的值至 gdtr 寄存器。
lgdt gdtdesc
# cr0 控制寄存器的部分作用在于控制处理器的工作模式,通过设置相关位,以开启保护模式。
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

# 跳转至 protcseg,执行相关指令 (因为目前基于分段机制,因此汇编码需要设置段选择子和段内偏移,注意:实模式和保护模式的段寄存器含义不同)。
ljmp $PROT_MODE_CSEG, $protcseg

.code32 # Assemble for 32-bit mode
protcseg:
# 设置 cs 以外的段寄存器取值为 $PROT_MODE_DSEG,即均设置为数据段选择子的取值。
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

# 如此设置各个段寄存器的值后 (这些值应当都不会再发生变化了),编译器中的逻辑地址 => 隐式转换为同等的线性地址 => 再做它处。

# 设置栈顶指针 esp 和 栈顶指针所示栈帧底部的指针 ebp (esp 指向栈空间底部,此时并不存在栈帧,因此 ebp 没有含义,将其设为 0,以此作为到达栈底的条件判断),并调用 bootmain 函数 (因此栈空间为 0 ~ start/0x7c00)。
# 注意:栈空间的使用是不会覆盖即将执行的其他指令的,原因有二:1. 上述已执行了众多指令,这部分指令空间是可以被直接覆盖的;2. bootmain 中函数调用所需的栈空间并不多,并且加载完 OS 后,便会跳转至 0x100000 处执行。
movl $0x0, %ebp
movl $start, %esp
# 调用此函数,其为 bootloader 的另一部分,主要完成加载 OS 工作。
call bootmain

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

# Bootstrap GDT
.p2align 2 # force 4 byte alignment
# 对于 gdt 中的段描述符而言,其含有 64 位,不同部位表示特定的含义,此处使用定义于 <asm.h> 中的宏填充表项
# 对于 gdt 的段描述符组织结构而言,第一项应当为空,其余随便,在此处第二三项分别与代码段和数据段相关。
gdt:
# 全 0。
SEG_NULLASM # null seg
# 代码段具有读、执行权限,段基址 = 0,段界限 = 4G,即扁平化处理,以隐藏分段机制。
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
# 数据段具有读、写执行权限,段基址 = 0,段界限 = 4G,即扁平化处理,以隐藏分段机制。
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel

# 对于 gptr 寄存器而言,其高 32 位为 gdt 所在地址 (对应 .long gdt),低 16 位为段界限 (对应 .word 0x17)
gdtdesc:
# 根据 gdt 具体结构可知,其含有 3 个段,因此段界限为 3 * 8 - 1 = 23 = 0x17
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt

对于现有的操作系统而言,Bootloader 的实现代码往往很多,MBR 内部根本无法容纳全部的 Bootloader。因此,其实现往往是这样的:MBR 内部的 Bootloader 代码用于定位、加载、运行另外一处位置的代码,而该部分代码为 Boot Loader 的实际实现代码。该部分代码通常包含一些通用的文件系统驱动程序,从而保证可以加载 OS。

练习四

此练习用于了解 bootmain.c 代码所做的工作。

bootmain.cbootloader 的另一部分,其主要完成 OS 的加载。

bootmain.c 的具体实现,详见源代码:

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
#include <defs.h>
#include <x86.h>
#include <elf.h>

// 设定扇区大小、ELFHDR 加载位置
#define SECTSIZE 512
#define ELFHDR ((struct elfhdr *)0x10000) // scratch space

// 如果当前磁盘尚未准备好,则一直循环等待。
static void waitdisk(void) {
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}


// 此处采用最常见的磁盘读取方式(等待磁盘准备好、发送读命令、等待磁盘准备好,读取数据)。
// 如何直接操纵磁盘以读取扇区的方式,属于硬件相关,简单了解即可。
static void readsect(void *dst, uint32_t secno) {

waitdisk();

// 每次读取一个扇区
outb(0x1F2, 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

waitdisk();

// 读取扇区内容至 dst
insl(0x1F0, dst, SECTSIZE / 4);
}


// 从 kernel offset 偏移处读取 count 字节至 va 处。
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;

// 磁盘数据以扇区为单位,如果 offset 位于扇区内部,我们便需要重新设置 va,以保证 offset 偏移处的数据位于原先 va 处。
va -= offset % SECTSIZE;

// 因为 kernel 开始于扇区 1,因此此处需要加一,以计算所读数据的开始扇区。
uint32_t secno = (offset / SECTSIZE) + 1;

// 依次循环,以读取扇区数据。
for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);
}
}

void bootmain(void) {
// 读取磁盘指定位置的数据(可以看到,总共需要读取 8 个扇区,可能是为确保 ELF 头部全部被读至内存)至 (uintptr_t)ELFHDR (0x10000)。
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

// 判断该文件是否为 ELF 文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}

struct proghdr *ph, *eph;

// 获取 ELF 文件中的 program header 表 (其中存放程序执行直接相关的目标文件结构信息,用于定位各段),随后加载各段至指定位置。
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph ++) {
// 此处会将各段加载至相应的虚拟地址中,该虚拟地址由 kernel.ld 链接脚本设定。查看该文件,可知:代码段位于 0x100000 处。
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}

// 调用入口函数,无需返回
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

// bad 这部分没什么用。
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);

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

练习五

此练习需要了解函数调用过程的底层原理,并基于此实现 print_stackframe() 函数。

函数调用过程时栈的组织结构可简化为下图:

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

基于寄存器 ebp 取值,我们可以很容易地得到如下内容:当前函数的参数、父函数的返回地址、父函数的 ebp 取值。基于父函数的 ebp 取值,我们可以递归得到祖先函数的相关信息。

基于上述知识,可以很容易实现 print_stackframe() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void print_stackframe(void) {
// 此二者函数借助于汇编代码以获取寄存器 ebp/eip 取值。
uint32_t ebp = read_ebp();
uint32_t eip = read_eip();
// 正如 bootasm.S 中看到的,最初 ebp 取值为 0,因此可借于此判断是否到达栈底。
for (int i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i++)
{
uint32_t* args = (uint32_t*)(ebp + 8);
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 和 ebp。
eip = ((uint32_t*)ebp)[1];
ebp = ((uint32_t*)ebp)[0];
}
}

练习六

此练习需要了解中断向量表及中断处理流程,从而实现中断初始化函数 idt_init()trap_dispatch() 中的时钟中断处理。

首先简单介绍 labcodes/lab1/kern/init/init.c 文件内 kern_init() 函数:

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
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);

// 打印 OS 相关信息。
print_kerninfo();

// 最终会调用 print_stackframe() 函数,可以忽略。
grade_backtrace();

// 初始化物理内存(重点关注)
pmm_init();

// 初始化中断控制器(涉及硬件底层,可以忽略)
pic_init();
// 初始化中断描述符表(重点关注)
idt_init();

// 初始化时钟 (硬件相关,暂时忽略)
clock_init();
// 使能中断
intr_enable();

// lab1 challenge
// lab1_switch_test();

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

labcodes/lab1/kern/mm/pmm.c 中查看 pmm_init() 的具体实现:

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
#include <defs.h>
#include <x86.h>
#include <mmu.h>
#include <memlayout.h>
#include <pmm.h>

static struct taskstate ts = {0};

/* *
* global segment number
* #define SEG_KTEXT 1
* #define SEG_KDATA 2
* #define SEG_UTEXT 3
* #define SEG_UDATA 4
* #define SEG_TSS 5

* global descrptor numbers
* #define GD_KTEXT ((SEG_KTEXT) << 3) // kernel text
* #define GD_KDATA ((SEG_KDATA) << 3) // kernel data
* #define GD_UTEXT ((SEG_UTEXT) << 3) // user text
* #define GD_UDATA ((SEG_UDATA) << 3) // user data
* #define GD_TSS ((SEG_TSS) << 3) // task segment selector

* #define DPL_KERNEL (0)
* #define DPL_USER (3)

* #define KERNEL_CS ((GD_KTEXT) | DPL_KERNEL)
* #define KERNEL_DS ((GD_KDATA) | DPL_KERNEL)
* #define USER_CS ((GD_UTEXT) | DPL_USER)
* #define USER_DS ((GD_UDATA) | DPL_USER)
* */

// GDT 中,内核段和用户段基本相同,均细分为代码段和数据段,均采用扁平模式,唯一不同点在于权限不同。
// TSS 是一种比较特殊的段,只要记住:其中的 SS0/ESP0 分别用于存放内核栈的栈段和 ESP 值。
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,
};

// gdtr 寄存器的具体结构。
static struct pseudodesc gdt_pd = {
sizeof(gdt) - 1, (uint32_t)gdt
};


static inline void lgdt(struct pseudodesc *pd) {
asm volatile ("lgdt (%0)" :: "r" (pd));
asm volatile ("movw %%ax, %%gs" :: "a" (USER_DS));
asm volatile ("movw %%ax, %%fs" :: "a" (USER_DS));
asm volatile ("movw %%ax, %%es" :: "a" (KERNEL_DS));
asm volatile ("movw %%ax, %%ds" :: "a" (KERNEL_DS));
asm volatile ("movw %%ax, %%ss" :: "a" (KERNEL_DS));
// reload cs
asm volatile ("ljmp %0, $1f\n 1:\n" :: "i" (KERNEL_CS));
}

/* temporary kernel stack */
uint8_t stack0[1024];

static void gdt_init(void) {
// 初始化 TSS 内部的 ss0/esp0。
ts.ts_esp0 = (uint32_t)&stack0 + sizeof(stack0);
ts.ts_ss0 = KERNEL_DS;

// 初始化 GPT 中的 TSS 段描述符。
gdt[SEG_TSS] = SEG16(STS_T32A, (uint32_t)&ts, sizeof(ts), DPL_KERNEL);
gdt[SEG_TSS].sd_s = 0;

// 加载 gdtr 和 tr 寄存器,并重新初始化各种段寄存器。
lgdt(&gdt_pd);
ltr(GD_TSS);
}

void pmm_init(void) {
// 重新规划 GDT。
gdt_init();
}

labcodes/lab1/kern/trap/trap.c 中查看 idt_init() 的具体实现 (相较于源代码,此部分已经修改):

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
// idt 数组,即中断描述符表。
static struct gatedesc idt[256] = {{0}};

// idtr 寄存器的具体结构。
static struct pseudodesc idt_pd = {
sizeof(idt) - 1, (uintptr_t)idt
};


void idt_init(void) {
// 该变量存放 vector.S 中各 vectori 函数的地址,这些函数为相应中断号对应的中断处理程序。
extern uintptr_t __vectors[];
// 循环填充 idt 表。
for (int i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i++)
{
// 对于 SETGATE 而言,其参数含义分别为:某个中断描述符、当前中断是否为 trap、对应中断处理程序的段选择子、所对应中断处理程序的段内偏移、使用该中断所需的权限等级
// 就目前而言,对于大多数终端而言,其非 trap、段选择子为 GD_KTEXT/KERNEL_CS,段内偏移为 __vectors[i]、所需权限为 DPL_KERNEL。
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL)
}

// T_SWITCH_TOK 比较特殊,它供用户使用,以切换至内核。
SETGATE(idt[T_SWITCH_TOK], 1, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);

// 加载 idt 寄存器。
lidt(&idt_pd);
}

简单分析代码,我们可以得到中断处理流程:CPU 接收中断号 i –> 调用 idt[i] 进行处理 –> 调用 __alltraps() 进行处理 –> 调用 trap() 进行处理 –> 调用 trap_dispatch() 进行处理 –> 依据中断号,执行相应功能,并返回。

trap_dispatch() 内部,针对时钟中断进行如下处理:

1
2
3
4
5
6
7
8
case IRQ_OFFSET + IRQ_TIMER:
ticks++;
if (ticks % TICK_NUM == 0)
{
print_ticks();
}

break;

对于机器而言,部分硬件的中断号是固定的,部分硬件的中断号是动态分配的,此处的时钟中断号便是固定的。

扩展练习

该练习需要理解 intel 机器处理中断的具体流程,从而实现用户空间与内核空间的自主切换。

首先简要说明 intel 的硬件处理机制:

1
2
3
4
5
6
7
8
9
10
11
- 每执行完一条指令,CPU 都会判断是否存在待处理的中断,如果存在,则先行获取中断号。
- 如果中断号所示的中断描述符权限 DPL >= CPL,则表明当前权限高于该中断的允许使用权限,故而可执行此次中断,否则为非法。
- 如果中断号所示的段选择子的段描述符权限 DPL < CPL, 则表明当前权限低于对应中断处理程序所需的权限,因此存在特权级转换。
- 如果存在特权级转换,则 CPU 会获取 TSS 中的 ss0/esp0,并将其设置给相应的寄存器,同时压入当前进程的 ss/esp 至内核栈。 +++++
- CPU 保存 cs/eip/eflags 等寄存器至内核栈。 +++++
- CPU 保存设置该中断号对应的中断处理程序的 cs/eip 至相应寄存器,从而开始执行中断处理程序。 +++++
- 中断处理程序首先保存 ds/es/fs/gs/各种通用寄存器至内核栈 (如果中断处理程序不会使用它们,则可不保存),然后真正执行中断处理程序。 -----
- 中断处理程序即将完成时,其会自动恢复上述保存的各种寄存器。 -----
- CPU 执行 iret 指令,内核栈中弹出 cs/eip/eflags 等寄存器。 +++++
- 如果存在特权级转换,则 CPU 保存当前 ss/esp 至 TSS,然后从内核栈中弹出 ss/esp。 +++++
# +++++ 表示由硬件自动完成,---- 表示由软件完成。

其次,我们简单看看源代码与上述机制间的联系:

  • __alltraps 的具体实现

    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
    .text
    .globl __alltraps
    __alltraps:
    # 压入相关段寄存器和通用寄存器值(实际上,这些寄存器也并非会用到,只是为保证能够在栈上构建 struct trapframe)
    pushl %ds
    pushl %es
    pushl %fs
    pushl %gs
    pushal

    # 根据上述硬件处理机制描述,硬件已经自动设置 ss/cs 等段寄存器了。
    # 设置相应数据段寄存器。
    movl $GD_KDATA, %eax
    movw %ax, %ds
    movw %ax, %es

    # 压入 esp 值,等价于向 trap 传递参数。
    pushl %esp

    # call trap(tf), where tf=%esp
    call trap

    # pop the pushed stack pointer
    popl %esp

    # 相关操作处理完成后的恢复操作
    .globl __trapret
    __trapret:
    popal
    popl %gs
    popl %fs
    popl %es
    popl %ds

    # get rid of the trap number and error code
    addl $0x8, %esp
    iret
  • trap() 的具体实现

    1
    2
    3
    void trap(struct trapframe *tf) {
    trap_dispatch(tf);
    }
  • struct trapframe/pushregs 的具体结构

    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
    // 观察 __alltraps 的压栈顺序,可以发现:此结构与其顺序相同。
    struct trapframe {
    struct pushregs tf_regs;
    uint16_t tf_gs;
    uint16_t tf_padding0;
    uint16_t tf_fs;
    uint16_t tf_padding1;
    uint16_t tf_es;
    uint16_t tf_padding2;
    uint16_t tf_ds;
    uint16_t tf_padding3;
    uint32_t tf_trapno;
    /* below here defined by x86 hardware */
    uint32_t tf_err;
    uintptr_t tf_eip;
    uint16_t tf_cs;
    uint16_t tf_padding4;
    uint32_t tf_eflags;
    /* below here only when crossing rings, such as from user to kernel */
    uintptr_t tf_esp;
    uint16_t tf_ss;
    uint16_t tf_padding5;
    } __attribute__((packed));

    struct pushregs {
    uint32_t reg_edi;
    uint32_t reg_esi;
    uint32_t reg_ebp;
    uint32_t reg_oesp; /* Useless */
    uint32_t reg_ebx;
    uint32_t reg_edx;
    uint32_t reg_ecx;
    uint32_t reg_eax;
    };

接下来,我们准备实现该练习。

labcodes/lab1/kern/init/init.c 中,主函数调用 lab1_switch_test() 以测试功能实现。

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
static void lab1_switch_to_user(void) {
// 因为当前便是内核空间,因此调用中断不会引发特权级转换,而切换到用户空间需要弹出 ss/esp,故而需要预留 8 字节空间。
// 因为需要切换至用户空间,直接调用相关中断即可。
asm volatile (
"sub $0x8, %%esp \n"
"int %0 \n"
"movl %%ebp, %%esp"
// 之所以需要这条代码,原因在于:由于当前函数均为汇编代码,编译得到的汇编代码少了 `movl %%ebp, %%esp` 这么一句,如果不显式补上这句,可能出问题。
// 具体详见:https://piazza.com/class/i5j09fnsl7k5x0?cid=1468
:
: "i"(T_SWITCH_TOU)
);
}

static void lab1_switch_to_kernel(void) {
// 因为需要切换至内核空间,直接调用相关中断即可。
asm volatile (
"int %0 \n"
"movl %%ebp, %%esp \n"
// 之所以需要这条代码,原因同上。
:
: "i"(T_SWITCH_TOK)
);
}

static void lab1_switch_test(void) {
// 基于汇编获取相关寄存器值,并将其显示于屏幕上。
lab1_print_cur_status();
cprintf("+++ switch to user mode +++\n");
lab1_switch_to_user();
lab1_print_cur_status();
cprintf("+++ switch to kernel mode +++\n");
lab1_switch_to_kernel();
lab1_print_cur_status();
}

labcodes/lab1/kern/trap/trap.c 中,实现 T_SWITCH_TOUT_SWITCH_TOK 的中断处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
case T_SWITCH_TOU:
// 切换为用户空间,只需修改相关段寄存器即可。
tf->tf_cs = USER_CS;
tf->tf_ds = USER_DS;
tf->tf_es = USER_DS;
tf->tf_ss = USER_DS;
// 需要开启 IO 权限,才能实现输出。
tf->tf_eflags |= FL_IOPL_MASK;
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;
// 根据上述硬件处理机制,ss 设置由硬件自动实现。
break;

特权级

因为 intel 的特权级概念十分重要 (借助于此,可实现部分的保护机制),因此在此简单介绍一番。

对于 intel 而言,特权级分为四个等级,使用 2 bit 进行表示,具体如图所示。实际之中,往往仅使用等级 0 (用于内核使用) 和等级 3 (用于用户使用)。

特权级具体表现在各种段寄存器、段描述符、门描述符之内,使用 CPL/RPL/DPL 进行表示,简要如图所示:

对于代码段/栈段选择子而言,其中 RPL 表示当前代码/进程所处的特权级,也称为 CPL;对于各种数据段选择子而言,其中 RPL 表示当前代码/进程访问该段内容时的请求权限,基于此可以提供更为细致的保护机制;对于各种描述符而言,其中 DPL 表示访问此部分内容所需的权限。

现在,基于上述知识,简单说明 intel 提供的保护机制。

  • 如果当前进程欲访问某数据段,则硬件判断 Max(CPL,RPL) <= 数据段的 DPL,如果满足此等式,则允许执行此指令,否则存在权限冲突,直接报错。
  • 如果当前进程欲访问某系统服务,则硬件判断 CPL <= 门描述符的 DPL,如果满足此等式,则允许访问系统服务,否则表示不允许。另外,如果满足 CPL > 门描述符的段选择子对应的段描述符的 DPL ,则表明存在特权级转换。