lagrange's blog

what's dead may never die

0%

Lab1 实验报告

Lab1 实验报告

/labcodes_answer/lab1_result/ 目录下的代码,在用较新版本(好像是 GCC 5.x 开始)就会出现生成的 bootloader 二进制文件过大无法塞入第一个扇区的问题,这种情况只需要将 /labcodes_answer/lab1_result/boot/bootmain.c中的全局变量改为用宏定义即可编译通过。

1
2
3
unsigned int SECTSIZE  = 512 ;
改为
#define SECTSIZE 512

make 生成执行文件的过程

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

Makefile 的终极目标在第 207 行被显式指定为 205 行的 TARGETS ,而 TARGETS 的依赖为 $(TARGETS) ,这个变量在 Makefile 只是空的,但是会在 tools/function.mk 中的 do_create_target 宏中被修改, do_create_target 被函数 create_target 直接调用。因此在 Makefile 中只要调用了 create_target 就会为 $(TARGETS) 增添新的一项。

经过一系列的 create_target$(TARGETS) 最终值为 bin/kernel bin/bootblock bin/sign bin/ucore.img

首先看bin/kernel的文件依赖

1
2
3
4
5
6
$(kernel): $(KOBJS) tools/kernel.ld
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
#最终的内核文件应该去除符号表等信息,并输出符号表信息,汇编文件信息,和输出信息
@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

显示这段代码执行的输出可以看到

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

ld 命令参数:

  • m <emulation> 模拟为 i386 上的连接器
  • nostdlib 不要在标准系统目录中寻找头文件.只搜索`-I’选项指定的目录(以及当前目录,如果合适).
  • T <scriptfile> 让连接器使用指定的脚本

所谓的KOBJS就是那串跟在-o 后面的,在\lib,\kern文件夹中所有.c .S 文件生成.o 二进制文件。通过ld的链接指令完成了 bin/kernel 文件的生成。 至于下面的的使用@隐藏输出的OBJDUMP,应该是删符号表等信息。

至于在ld命令前的.o 文件生成指令

1
2
+ cc kern/init/init.c
gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o

gcc 参数:

  • -fno-builtin 除非用 __builtin__ 前缀,否则不进行 builtin 函数的优化
  • -Wall 选项意思是编译后显示所有警告。
  • -ggdb 生成可供 gdb 使用的调试信息。这样才能用 qemu+gdb 来调试 bootloader or ucore。
  • -m32 生成适用于 32 位环境的代码。我们用的模拟硬件是 32bit 的 80386,所以 ucore 也要是 32 位的软件。
  • -gstabs 生成 stabs 格式的调试信息。这样要 ucore 的 monitor 可以显示出便于开发者阅读的函数调用栈信息
  • -nostdinc 不使用标准库。标准库是给应用程序用的,我们是编译 ucore 内核,OS 内核是提供服务的,所以所有的服务要自给自足。
  • -fno-stack-protector 不生成用于检测缓冲区溢出的代码。这是 for 应用程序的,我们是编译内核,ucore 内核好像还用不到此功能。
  • -I<dir> 添加搜索头文件的路径
  • -c Compile and assemble, but do not link.

是在第 126 行左右执行的$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))

该命令对所有 kern 和 lib 下的 .c .S 文件执行了编译操作生成.o 文件。

紧接上面,之后的输出

1
2
3
4
+ cc boot/bootasm.S
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
+ cc boot/bootmain.c
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o

gcc 参数:

  • -Os 为减小代码大小而进行优化。根据硬件 spec,主引导扇区只有 512 字节,我们写的简单 bootloader 的最终大小不能大于 510 字节。

来自于下面两条指令

1
2
3
# create bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

第一条语句找出\boot文件夹下的所有以.S .c 结尾的文件bootasm.S,bootmain.c,第二句话对上面找出的两个文件执行编译。 其中bootasm.S依赖于\boot文件夹下的的asm.h头文件。引入该头文件的方法是在 gcc 编译指令中加上-Iboot/这个参数。-I<dir> 添加搜索头文件的路径,根据这个参数可以找到位于\boot文件夹下的的asm.h头文件

下面的输出是对 sign 工具的编译。虽然在 makefile 文件下紧接着的是对 bootblock 的编译,但是因为 bootblock 依赖于 sign 工具,故首先执行 sign 工具的编译操作。

1
2
3
+ 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
1
2
3
# create 'sign' tools
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)

该生成由以上两句语句生成并输出

解决了 sign 工具的依赖后开始生成 bootblock,输出如下:

1
2
3
4
+ 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: 488 bytes
build 512 bytes boot sector: 'bin/bootblock' success!

新出现的 ld 命令参数:

  • e <entry> 指定入口
  • N 设置代码段和数据段均可读写
  • Ttext 制定代码段开始位置,0x7C00 就是 bios 执行完后程序开始执行的位置

对应 makefile

1
2
3
4
5
6
7
$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
@$(OBJDUMP) -t $(call objfile,bootblock) | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,bootblock)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

objcopy 拷贝二进制代码 bootblock.o 到 bootblock.out:

  • -S 移除所有符号和重定位信息
  • -O 指定输出格式

调用 sign 进行签名(这里只显示了 sign 程序执行时的输出,并没有显示调用 sign 程序的指令):

sign bootblock.out bootblock(大概是这个意思)

sign 函数执行的逻辑就是在文件小于 510 个字节的情况下将最后两个字节置为 0x55AA 标志该启动扇区是合法的。

执行以上代码后完成 bootblock 的构造,最后执行 ucore.img 的构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dd if=/dev/zero of=bin/ucore.img count=10000

10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0213187 s, 240 MB/s

dd if=bin/bootblock of=bin/ucore.img conv=notrunc

1+0 records in
1+0 records out
512 bytes (512 B) copied, 8.997e-05 s, 5.7 MB/s

dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

146+1 records in
146+1 records out
74923 bytes (75 kB) copied, 0.000318176 s, 235 MB/s
1
2
3
4
5
6
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
  1. 生成一个有 10000 个块的文件,每个块默认 512 字节,用 0 填充
    dd if=/dev/zero of=bin/ucore.img count=10000
  2. 把 bootblock 中的内容写到第一个块
    dd if=bin/bootblock of=bin/ucore.img conv=notrunc
  3. 从第二个块开始写 kernel 中的内容
    dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

/dev/zero : 在类 UNIX 操作系统中, /dev/zero 是一个特殊的文件,当你读它的时候,它会提供无限的空字符(NULL, ASCII NUL, 0x00)。其中的一个典型用法是用它提供的字符流来覆盖信息,另一个常见用法是产生一个特定大小的空白文件。BSD 就是通过 mmap 把/dev/zero 映射到虚地址空间实现共享内存的。可以使用 mmap 将/dev/zero 映射到一个虚拟的内存空间,这个操作的效果等同于使用一段匿名的内存(没有和任何文件相关)。

if=文件名:输入文件名,默认为标准输入。即指定源文件。
of=文件名:输出文件名,默认为标准输出。即指定目的文件。
count=blocks:仅拷贝 blocks 个块,块大小等于 ibs 指定的字节数。 notrunc:不截短输出文件

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

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
#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);
if (st.st_size > 510) //文件大于510个字节报错,虽然启动扇区能够装512个字节,但最后两个字节必须是0x55AA来标志该扇区为合法的启动扇区,所以最多装510个
{
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
char buf[512];
memset(buf, 0, sizeof(buf));
FILE *ifp = fopen(argv[1], "rb"); //argv[0]是程序全名,argv[1]是输入文件,即以读二进制文件的方法打开输入文件
int size = fread(buf, 1, st.st_size, ifp); //从ifp读st.st_size个对象,每个对象读一次到buf中
if (size != st.st_size)
{
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return -1;
}
fclose(ifp);
buf[510] = 0x55; //把最后两个字节写成0x55AA
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;
}

符合规范的硬盘主引导扇区 size=512bytes,并且第 511 个 byte 值为 0x55,第 512 个 byte 的值为 0xAA

操作系统启动过程

  1. x86 PC 刚开机时 CPU 处于实模式
  2. 开机时,CS=0xFFFF; IP=0x0000
  3. 寻址 0xFFFF0(ROM BIOS 映射区)
  4. 检查 RAM,键盘,显示器,软硬磁盘
  5. 将磁盘 0 磁道 0 扇区读入 0x7c00 处
  6. 设置 cs=0x07c0,ip=0x0000

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

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

在 i8086 时代,CPU 的数据总线是 16bit,地址总线是 20bit,寄存器是 16bit,因此 CPU 只能访问 1MB 以内的空间。因为数据总线和寄存器只有 16bit,如果需要获取 20bit 的数据, 需要 segment(每个 segment 大小恒定为 64K)左移 4 位再加上 offset 组成一个 20bit 的地址。理论上,20bit 的地址可以访问 1MB 的内存空间(0x00000 - (2^20 - 1 = 0xFFFFF))。但在实模式下, 这 20bit 的地址理论上能访问从 0x00000 - (0xFFFF0 + 0xFFFF = 0x10FFEF)的内存空间。也就是说,理论上我们可以访问超过 1MB 的内存空间,但越过 0xFFFFF 后,地址又会回到 0x00000。上面这个特征在 i8086 中是没有任何问题的(因为它最多只能访问 1MB 的内存空间),但到了 i80286/i80386 后,CPU 有了更宽的地址总线,数据总线和寄存器后,这就会出现一个问题: 在实模式下, 我们可以访问超过 1MB 的空间,但我们只希望访问 1MB 以内的内存空间。为了解决这个问题, CPU 中添加了一个可控制 A20 地址线的模块,通过这个模块,我们在实模式下将第 20bit 的地址线限制为 0,这样 CPU 就不能访问超过 1MB 的空间了。进入保护模式后,我们再通过这个模块解除对 A20 地址线的限制,这样我们就能访问超过 1MB 的内存空间了。

现在使用的 CPU 都是通过键盘控制器 8042 (端口 0x64 和 0x60 连着键盘控制器) 来控制 A20 地址线。默认情况下,A20 地址线是关闭的(限制只能访问 1M 内存),因此在进入保护模式(需要访问超过 1MB 的内存空间)前,我们需要开启 A20 地址线(第 20bit 的地址线可为 0 或者 1)。开启代码在 bootasm.S 文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

cpu 可以直接读写以下三个地方的数据,读写三个地方的指令都是不同的,他们的空间也是分开的。

  1. 端口
  2. 内存
  3. 寄存器

对外设的控制都是通过读写对应外设的端口来完成的。对端口的读写汇编指令只有 in 和 out。

由于当时的 8042 键盘控制器上恰好有空闲的端口引脚(输出端口 P2,引脚 P21),于是便使用了该引脚来作为与门控制这个地址比特位。该信号即被称为 A20。如果它为零,则比特 20 及以上地址都被清除。从而实现了兼容性。

当 A20 地址线控制禁止时,程序就像运行在 8086 上,1MB 以上的地址是不可访问的,只能访问奇数 MB 的不连续的地址。为了使能所有地址位的寻址能力,必须向键盘控制器 8082 发送一个命令,键盘控制器 8042 会将 A20 线置于高电位,使全部 32 条地址线可用,实现访问 4GB 内存。

控制 A20 gate 的方法有 3 种:

  1. 804x 键盘控制器法
  2. Fast A20 法
  3. BIOS 中断法

ucore 实验中用了第一种 804x 键盘控制器法,这也是最古老且效率最慢的一种。由于在机器启动时,默认条件下,A20 地址线是禁止的,所以操作系统必须使用适当的方法来开启它。

等待 8042 Input buffer 为空;
发送 Write 8042 Output Port (P2)命令到 8042 Input buffer;
等待 8042 Input buffer 为空;
将 8042 Output Port(P2)得到字节的第 2 位置 1,然后写入 8042 Input buffer

如何初始化 GDT 表

在保护模式下,x86 CPU 通过 GDT 表访问内存,我们根据 CPU 给的逻辑地址分离出段选择子。利用这个段选择子选择一个段描述符。将段描述符里的 Base Address 和段选择子的偏移量相加而得到线性地址。这个地址就是我们需要的地址。

GDT

在实模式下,通过 segment + offset 的方式一个程序可以访问内存中的任意一个地址,但是开启了保护模式之后,段选择子和段描述符中都有了特权级的概念,程序不能随意访问高特权级的段内容。段表有固定的格式被放到内存中,CPU 使用全局描述符表寄存器 GDTR 保存段表起始地址。GDTR 长 48 位,其中高 32 位为基地址,低 16 位为段界限。这里只需要载入已经静态存储在引导区的 GDT 表和其描述符到 GDTR 寄存器。理论上 GDT 可以存在内存中任何位置,但这里我们是在实模式下初始化 GDT 的,因此 GDT 应该是存在最低的这 1MB 内存空间中。CPU 通过 lgdt 指令读入 GDT 的地址,之后我们就可以使用 GDT 了。

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#注释:#include <asm.h>
asm.h头文件中包含了一些宏定义,用于定义gdt,gdt是保护模式使用的全局段描述符表,其中存储着段描述符。
# 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.
此段注释说明了要完成的目的:启动保护模式,转入C函数。
这里正好说了一下bootasm.S文件的作用。计算机加电后,由BIOS将bootasm.S生成的可执行代码从硬盘的第一个扇区复制到内存中的物理地址0x7c00处,并开始执行。
此时系统处于实模式。可用内存不多于1M。

.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
这两个段选择子的作用其实是提供了gdt中代码段和数据段的索引
.set CR0_PE_ON, 0x1 # protected mode enable flag
这个变量是开启A20地址线的标志,为1是开启保护模式

# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
这两行代码相当于定义了C语言中的main函数,start就相当于main,BIOS调用程序时,从这里开始执行
.code16 # Assemble for 16-bit mode
因为以下代码是在实模式下执行,所以要告诉编译器使用16位模式编译。
cli # Disable interrupts
cld # String operations increment
关中断,设置字符串操作是递增方向。cld的作用是将direct flag标志位清零,这意味着自动增加源索引和目标索引的指令(如MOVS)将同时增加它们。

# Set up the important data segment registers (DS, ES, SS).
xorw %ax, %ax # Segment number zero
ax寄存器就是eax寄存器的低十六位,使用xorw清零ax,效果相当于movw $0, %ax。 但是好像xorw性能好一些,google了一下没有得到好答案
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.
准备工作就绪,下面开始动真格的了,激活A20地址位。先翻译注释:由于需要兼容早期pc,物理地址的第20位绑定为0,所以高于1MB的地址又回到了0x00000.
好了,激活A20后,就可以访问所有4G内存了,就可以使用保护模式了。

怎么激活呢,由于历史原因A20地址位由键盘控制器芯片8042管理。所以要给8042发命令激活A20
8042有两个IO端口:0x60和0x64, 激活流程位: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60,done!
# seta20.1这些破东西叫标号。标号有唯一的名字加冒号组成。它可以出现在汇编程序的任何地方,并与紧跟其后的哪行代码具有相同的地址。概括的说 ,当程序中要跳转到另一位置时,需要有一个标识来指示新的位置,这就是标号,通过在目标地址的前面放上一个标号,可以在指令中使用标号来代替直接使用地址。
seta20.1:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.1
#发送命令之前,要等待键盘输入缓冲区为空,这通过8042的状态寄存器的第2bit来观察,而状态寄存器的值可以读0x64端口得到。
#上面的指令的意思就是,如果状态寄存器的第2位为1,就跳到seta20.1符号处执行,知道第2位为0,代表缓冲区为空

movb $0xd1, %al # 0xd1 -> port 0x64
outb %al, $0x64 # 0xd1 means: write data to 8042's P2 port
发送0xd1到0x64端口

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激活完成。
# 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.
转入保护模式,这里需要指定一个临时的GDT,来翻译逻辑地址。这里使用的GDT通过gdtdesc段定义。它翻译得到的物理地址和虚拟地址相同,所以转换过程中内存映射不会改变
lgdt gdtdesc
载入gdt
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
打开保护模式标志位,相当于按下了保护模式的开关。cr0寄存器的第0位就是这个开关,通过CR0_PE_ON或cr0寄存器,将第0位置1

# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
由于上面的代码已经打开了保护模式了,所以这里要使用逻辑地址,而不是之前实模式的地址了。
这里用到了PROT_MODE_CSEG, 他的值是0x8。根据段选择子的格式定义,0x8就翻译成:
        INDEX     TI CPL
0000 0000 1 0 00
INDEX代表GDT中的索引,TI代表使用GDTR中的GDT, CPL代表处于特权级。

PROT_MODE_CSEG选择子选择了GDT中的第1个段描述符。这里使用的gdt就是变量gdt。下面可以看到gdt的第1个段描述符的基地址是0x0000,所以经过映射后和转换前的内存映射的物理地址一样。
.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
栈顶设定在start处,也就是地址0x7c00处,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

CPU 使用全局描述符表寄存器 GDTR 保存段表起始地址。GDTR 长 48 位,其中高 32 位为基地址,低 16 位为段界限。lgdt指令将源操作数中的值加载到全局描述符表格寄存器 (GDTR) 中(plus : lgdt 是间接寻址的)。载入 gdt 表,就是通过lgdt gdtdesc指令设置 GDTR 寄存器中的内容。

0x17 换成 10 进制就是 23,总共就有 24 个字节。一个 GDT 表项有 64 个 bit 占 8byte。总共 3 个表项一共就有 24 个字节。基地址 32 位是 gdt 这个标号所代表数据段的地址。GDT 的第一项总是为 0, 这就确保空段选择符的逻辑地址会被认为是无效的, 因此引起一个处理器异常.

上面的地址一共分了两段,第一段可执行可写,第二段可读,地址空间都覆盖整个 32 位 4GB 的保护模式空间。也正如之前 uCore 介绍的没有特别采用段机制

如何使能和进入保护模式

因为我们无法直接操作 CR0,所以我们首先要用一个通用寄存器来保存当前 CR0 寄存器的值。

1
2
3
4
5
6
7
movl %cr0, %eax
orl $CR0_PE_ON, %eax #CR0_PE_ON的值就是0x1
movl %eax, %cr0 #保护模式打开

# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg

由于一些现代 CPU 特性 (乱序执行和分支预测等),在转到保护模式之后 CPU 可能仍然在跑着实模式下的代码,这显然会造成一些问题。因此必须强迫 CPU 清空一次缓冲。对此,最有效的方法就是进行一次 long jump

ljmp <imm1>, <imm2> # %cs ← imm1 # %ip ← imm2

由于上面的代码已经打开了保护模式了,所以这里要使用逻辑地址,而不是之前实模式的地址了。
这里用到了 PROT_MODE_CSEG, 他的值是 0x8。根据段选择子的格式定义,0x8 就翻译成:
| INDEX | TI | CPL |
| —– | — | — |
| 0000 0000 1 | 0 | 00 |
INDEX 代表 GDT 中的索引,TI 代表使用 GDTR 中的 GDT, CPL 代表处于特权级。

PROT_MODE_CSEG 选择子选择了 GDT 中的第 1 个段描述符。这里使用的 gdt 就是变量 gdt。下面可以看到 gdt 的第 1 个段描述符的基地址是 0x0000,所以经过映射后和转换前的内存映射的物理地址一样。

进入保护模式后,需要重新设置所有段寄存器的内容,现在 这些寄存器里面都需要保存段选择子,因为刚才的段表只把内存空间分成 2 段但这两段地址空间完全重合,实际上只有一段。所以这个时候所有的代码、数据、堆栈段都是在一个段选择子里,所以值都是 0x8,完成设置之后跳转到函数 bootmain

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.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

总结

Bootload 的启动过程可以概括如下:

首先,BIOS 将第一块扇区(存着 bootloader)读到内存中物理地址为 0x7c00 的位置,同时段寄存器 CS 值为 0x0000,IP 值为 0x7c00,之后开始执行 bootloader 程序。CLI 屏蔽中断(屏蔽所有的中断:为中断提供服务通常是操作系统设备驱动程序的责任,因此在 bootloader 的执行全过程中可以不必响应任何中断,中断屏蔽是通过写 CPU 提供的中断屏蔽寄存器来完成的);CLD 使 DF 复位,即 DF=0,通过执行 cld 指令可以控制方向标志 DF,决定内存地址是增大(DF=0,向高地址增加)还是减小(DF=1,向地地址减小)。设置寄存器 ax,ds,es,ss 寄存器值为 0;A20 门被关闭,高于 1MB 的地址都默认回卷到 0,所以要激活 A20,给 8042 发命令激活 A20,8042 有两个 IO 端口:0x60 和 0x64, 激活流程: 发送 0xd1 命令到 0x64 端口 –> 发送 0xdf 到 0x60,打开 A20 门。从实模式转换到保护模式(实模式将整个物理内存看成一块区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址,地址就是 IP 值。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的),所以就初始化全局描述符表使得虚拟地址和物理地址匹配可以相互转换;lgdt 汇编指令把通过 gdt 处理后的(asm.h 头文件中处理函数)描述符表的起始位置和大小存入 gdtr 寄存器中;将 CR0 的第 0 号位设置为 1,进入保护模式;指令跳转由代码段跳到 protcseg 的起始位置。设置保护模式下数据段寄存器;设置堆栈寄存器并调用 bootmain 函数

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

通过执行readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0)将 os 代码的 ELF 头读到内存中来。SECTSIZE=512 是扇区大小,ELFHDR 是存储 ELF 头格式的 OS 的地址。从磁盘 0 地址处(里面不算启动扇区,实际上是从第 1 个扇区开始读)读 SECTSIZE * 8 (8 个扇区) 读到 ELFHDR 这个地址上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void readseg(uintptr_t va, uint32_t count, uint32_t offset)
{
uintptr_t end_va = va + count; //终止地址

//要读只能一个扇区一起读,将地址va与扇区开始处对齐,这样如果从对齐(减小过的va开始读取)的话,读完后没减过的实际传入没修改过的va就是我们想要的中间内容(注意va是传值调用进来的)
va -= offset % SECTSIZE;

uint32_t secno = (offset / SECTSIZE) + 1; //算一算从哪个扇区开始

for (; va < end_va; va += SECTSIZE, secno++)
{
readsect((void *)va, secno);
}
}

每一个扇区读取的代码dst指示该扇区要读到的内存地址,secno指示读取磁盘的哪一个扇区。

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); // 读几个扇区
outb(0x1F3, secno & 0xFF); //以下几个寄存器放扇区编号
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // 操作内容,要求读扇区

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

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

读取硬盘扇区的步骤:

  1. 等待硬盘空闲。waitdisk 的函数实现只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不断查询读 0x1F7 寄存器的最高两位,直到最高位为 0、次高位为 1(这个状态应该意味着磁盘空闲)才返回。

  2. 硬盘空闲后,发出读取扇区的命令。对应的命令字为 0x20,放在 0x1F7 寄存器中;读取的扇区数为 1,放在 0x1F2 寄存器中;读取的扇区起始编号共 28 位,分成 4 部分依次放在 0x1F3~0x1F6 寄存器中。

  3. 发出命令后,再次等待硬盘空闲。

  4. 硬盘再次空闲后,开始从 0x1F0 寄存器中读数据。注意 insl 的作用是”That function will read cnt dwords from the input port specified by port into the supplied output array addr.”,是以 dword 即 4 字节为单位的,因此这里 SECTIZE 需要除以 4.

1
2
3
4
5
6
7
8
9
static inline void
insl(uint32_t port, void *addr, int cnt) {
asm volatile (
"cld;"
"repne; insl;"
: "=D" (addr), "=c" (cnt)
: "d" (port), "0" (addr), "1" (cnt)
: "memory", "cc");
}

读取完 8 个扇区的操作系统后开始解析:

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
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
{
goto bad;
}

//elf文件中的program header table
struct proghdr *ph, *eph;

// phoff: program header 表的起始位置偏移
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);

// phnum: program header 表中的入口数目
// program header 表是一个program header结构的数组, 每个结构描述了一个段或者系统准备程序执行所必需的其它信息
// eph 就是该表的终止位置
eph = ph + ELFHDR->e_phnum;

for (; ph < eph; ph++)
{
//p_va virtual address to map segment
//p_memsz size of segment in memory (bigger if contains bss)
//p_offset file offset of segment由于开始的地址是0,该偏移就是在磁盘中的实际地址
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))();

用工具读 kern 可以看到地址前面两位为空& 0xFFFFFF的作用就是截取后面的 24 位地址,还有注意ELFHDR->e_entry的值为0x100000ELFHDR的地址是0x10000,少一个 0,不是一个地址。

kern

实现函数调用堆栈跟踪函数

几乎所有本地编译器都会在每个函数体之前插入类似如下的汇编指令:

1
2
pushl   %ebp
movl %esp , %ebp

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

stack

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
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]
*/
uint32_t ebp = read_ebp(), eip = read_eip();

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

首先定义两个局部变量 ebp、esp 分别存放 ebp、esp 寄存器的值。这里将 ebp 定义为指针,是为了方便后面取 ebp 寄存器的值。

调用 read_ebp 函数来获取执行 print_stackframe 函数时 ebp 寄存器的值,这里 read_ebp 必须定义为 inline 函数,否则获取的是执行 read_ebp 函数时的 ebp 寄存器的值。

调用 read_eip 函数来获取当前指令的位置,也就是此时 eip 寄存器的值。这里 read_eip 必须定义为常规函数而不是 inline 函数,因为这样的话在调用 read_eip 时会把当前 ebp 压栈,把 ebp 设置为 eip,故只要读调用函数后的 ebp 就可得到当前 eip 的值。

由于变量 eip 存放的是下一条指令的地址,因此将变量 eip 的值减去 1,得到的指令地址就属于当前指令的范围了。由于只要输入的地址属于当前指令的起始和结束位置之间,print_debuginfo 都能搜索到当前指令,因此这里减去 1 即可。

以后变量 eip 的值就不能再调用 read_eip 来获取了(每次调用获取的值都是相同的),而应该从 ebp 寄存器指向栈中的位置再往上一个单位中获取。这个地址指向上一个栈帧的最后入栈的元素。

由于 ebp 寄存器指向栈中的位置存放的是调用者的 ebp 寄存器的值,把现在的地址更新为这个地址里面存储的内容,据此可以继续顺藤摸瓜,不断回溯,直到 ebp 寄存器的值变为 0

int 0x80系统调用实现

CS 作为段基址寄存器储存着段选择子,里面包含 GDT 表的偏移以及当前的特权级 CPL。对于用户态程序来说 CPL 一般为 3,内核段的 DPL 都是 0,无法直接访问内核的数据与代码,若需要访问则需要通过中断实现。

1
2
3
4
5
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
// set for switch from user to kernel
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);

在设置中断向量表 IDT 的时候,故意将T_SWITCH_TOK=0x79(实际是第 0x80 项)的 DPL 设置为用户态权限 3,其余都设置为内核态 0.
IDT

每个 IDT 表项如上图所示,当一个程序引发 0x80 中断时,CPU 通过硬件检查 IDT 中对应的 DPL 特权级,发现为 3 可以访问,然后将对应的段选择符和入口偏移装入 CS:IP,该段选择符的 CPL 为 0,即进入内核,可以通过系统调用操作一些内存数据。