课程主页:https://www.xuetangx.com/course/THU08091000267/5883104?channel=i.area.learn_title
实验指导书:https://objectkuan.gitbooks.io/ucore-docs/content/
github:https://github.com/chyyuu/ucore_os_lab (master 分支)

前置知识

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
6
7
# $(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"

# 根据上述的 debug 部分指令可知,此时仅启动 QEMU 而尚未启动 CPU,因此其位于 BIOS 尚未执行的状态。

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

1
2
3
4
5
6
7
8
9
10
11
# 加载 kernel 的调试信息(暂时没用)
file bin/kernel
# 设置当前所模拟机器的指令架构
set architecture i8086
# 远程连接 QEMU
target remote :1234

# gdb无法正确获取当前qemu执行的汇编指令,通过如下配置可以在每次gdb命令行前强制反汇编当前的指令
define hook-stop
x/i $pc
end

在命令行目录 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
#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 # 从64端口读取数据 # Wait for not busy(8042 input buffer empty).
testb $0x2, %al # 读到的数据是2则input buffer 为空
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
83
84
85
86
#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 */;
}


// 此处采用最常见的磁盘读取方式(等待磁盘准备好、发送读命令、等待磁盘准备好,读取数据)。
//等待磁盘直到其不忙;
//往0x1F2到0X1F6中设置读取扇区需要的参数,包括读取扇区的数量以及LBA参数;
//往0x1F7端口发送读命令0X20;
//等待磁盘完成读取操作;
//从数据端口0X1F0读取出数据到指定内存中;
//注:如何直接操纵磁盘以读取扇区,属于硬件相关,简单了解即可。
static void readsect(void *dst, uint32_t secno) {

waitdisk(); // 等待磁盘到不忙为止

outb(0x1F2, 1); // 往0X1F2地址中写入要读取的扇区数,由于此处需要读一个扇区,因此参数为1
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); // 向磁盘发出读命令0x20

waitdisk(); // 等待磁盘直到不忙

insl(0x1F0, dst, SECTSIZE / 4); // 从数据端口0x1F0读取数据至dst,除以4是因为此处是以4个字节为单位的,这个从指令是以l(long)结尾这点可以推测出来;
}


// 是对readsect函数的进一步封装(从 kernel offset 偏移处读取 count 字节至 va 处)。
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;

// 磁盘数据以扇区为单位,如果 offset 位于扇区内部,那么在读到offset偏移处的数居前还读取了长度为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);
}

// 调用入口函数,无需返回(从ELF header中查询到OS kernel的入口地址,然后使用函数调用的方式跳转到该地址上去)
((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
18
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++)
{
// 此时的ebp是当前栈帧的ebp寄存器中的值,此时的eip是当前函数中所调用函数的调用指令的下一条指令的地址(即所调用函数的返回地址)
cprintf("ebp:0x%08x eip:0x%08x ", ebp, ra);
uint32_t* args = (uint32_t *) (ebp + 8);
cprintf("args:0x%08x 0x%08x 0x%08x 0x%08x\n", args[0], args[1], args[2], args[3]);
// 借助于调试信息以获取其所在函数的位置信息。参数为eip-1的目的是指向这个call指令(eip-1表示的地址落在call指令占的地址范围)
print_debuginfo(eip - 1);
// 更新 eip 和 ebp。
eip = ((uint32_t*)ebp)[1];
ebp = ((uint32_t*)ebp)[0];
}
}

接下来分析最后一行输出各个数值的意义:

1
2
ebp:0x00007bf8 eip:0x00007d6e args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
<unknow>: -- 0x00007d6d --

根据上述打印栈帧信息的过程,可以推测出打印出的ebp是第一个被调用函数的栈帧的base pointer,eip是在该栈帧对应函数中调用下一个栈帧对应函数的指令的下一条指令的地址,而args是传递给这第一个被调用的函数的参数

为了验证这个想法,不妨在反汇编出来的kernel.asm和bootblock.asm中寻找0x7d6e这个地址,可以发现这个地址上的指令恰好是bootmain函数中调用OS kernel入口函数的指令的下一条,也就是说最后一行打印出来的是bootmain这个函数对应的栈帧信息,其中ebp表示该栈帧的base pointer,eip表示在该函数内调用栈上的下一个函数指令的返回地址,而后面的args则表示传递给bootmain函数的参数,但是由于bootmain函数不需要任何参数,因此这些打印出来的数值并没有太大的意义,后面的unkonw之后的0x00007d6d则是bootmain函数内调用OS kernel入口函数的该指令的地址;

关于其他每行输出中各个数值的意义为:ebp, eip等这一行数值意义与上述一致,下一行的输出调试信息,在*.c之后的数字表示当前所在函数进一步调用其他函数的语句在源代码文件中的行号,而后面的+22一类数值表示从该函数汇编代码的入口处到进一步调用其他函数的call指令的最后一个字节的偏移量,以字节为单位;

1
2
ebp:0x00007b38 eip:0x00100a28 args:0x00010094 0x00010094 0x00007b68 0x0010007f
kern/debug/kdebug.c:306: print_stackframe+22

练习六

此练习需要了解中断向量表及中断处理流程,从而实现中断初始化函数 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;

// 初始化 GDT 中的 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
};

// 依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个 中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
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] 进行处理,跳转到相应的中断中断处理入口,并在桟中压入相应的 error_code(是否存在与异常号相关) 以及 trap_no

–> 调用 __alltraps() 进行处理(在栈中保存当前被打断程序的 trapframe 结构,设置相应的寄存器)

–> 调用 trap() 进行处理

–> 调用 trap_dispatch() 进行处理

–> 依据中断号,执行相应功能,并返回。

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

1
2
3
4
5
6
7
8
9
// 使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向 屏幕上打印一行文字”100 ticks”。
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
    38
    .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(即取出之前压入的esp值)
    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
    4
    // 处理中断
    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 (用于用户使用)。

img

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

img

对于代码段/栈段选择子而言,其中 CPL (存在于CS,SS寄存器)表示当前代码/进程所处的特权级;而 RPL(存在于DS,ES,FS,GS寄存器)说明的是进程对段访问的请求权限,是对于段选择子而言的,基于此可以提供更为细致的保护机制(RPL对每个段来说是不固定的,两次访问同一段时的RPL可以不同,RPL会削弱CPL的作用,例如当前CPL=0的进程要访问i一个数据段,段选择子的RPL设为3,这样它对该段就只有特权级3的访问权限);对于各种描述符而言,其中 DPL(存在于段描述符,门描述符) 表示访问此部分内容所需的权限。

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

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