练习1:理解通过make生成执行文件的过程
1.操作系统镜像文件ucore.img是如何一步一步生成的?
首先我们在 lab1 代码下输入 make
看看会发生什么
可以看到输出了一些信息,根据对这些信息的解读,并且观察文件目录我们可以发现,输入 make
生成了一系列的目标文件:
- bin 文件夹下的 ucore.img:被 qemu 访问的虚拟硬盘文件
- bin 文件夹下的 kernel: ELF 格式的 toy ucore kernel 执行文件,被嵌入到了 ucore.img 中
- bin 文件夹下的 bootblock: 虚拟的硬盘主引导扇区(512字节),包含了 bootloader 执行代码,被嵌入到了 ucore.img 中
- bin 文件夹下的 sign.c:外部执行程序,用来生成虚拟的硬盘主引导扇区
- 还有 obj 文件夹下的许多文件,比较多,就不一一列举了
输入 make
看不到具体做了什么,先输入 make clean
后输入 make "V="
来观察 make
具体执行了哪些命令:
1
2
3
4
5
6
7
8
9
10
11
tjb@tjb-VirtualBox:~/study/os/os_kernel_lab/labcodes/lab1$ make "V="
+ cc kern/init/init.c
gcc -Ikern/init/ -march=i686 -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/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
...
ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
...
我们可以发现在 gcc
和 ld
时,带了许多的参数,我们首先对这些参数进行解释 :
- -ggdb 生成可供gdb使用的调试信息
- -m32 生成适用于32位环境的代码
- -gstabs 生成stabs格式的调试信息
- -nostdinc 不使用标准库
- -fno-stack-protector 不生成用于检测缓冲区溢出的代码
- -Os 为减小代码大小而进行优化
- -I添加搜索头文件的路径
- -fno-builtin 不进行builtin函数的优化
- -m 模拟为i386上的连接器
- -N 设置代码段和数据段均可读写
- -e 指定入口
- -Ttext 指定代码段开始位置
在 Makefile
文件里找到生成镜像文件 ucore.img
的相关代码
1
2
3
4
5
6
7
8
9
# create ucore.img
UCOREIMG := $(call totarget,ucore.img)
$(UCOREIMG): $(kernel) $(bootblock)
$(V)dd if=/dev/zero of=$@ count=10000
$(V)dd if=$(bootblock) of=$@ conv=notrunc
$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc
$(call create_target,ucore.img)
可以看到,为了生成 ucore.img
,首先需要生成 bootblock
和 kernel
首先创建一个大小为 10000 字节的块儿,然后在第一个块加载已经生成的 bootblock
,在第二个块加载已经生成的 kernel
再在 Makefile
文件里找到生成 bootblock
的相关代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# create bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
bootblock = $(call totarget,bootblock)
$(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)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
$(call create_target,bootblock)
执行的命令具体为:
1
2
3
4
5
6
7
8
9
10
11
+ cc boot/bootasm.S
gcc -Iboot/ -march=i686 -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/ -march=i686 -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
+ cc tools/sign.c
gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
+ ld 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: 496 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
这里 gcc
和 ld
带的参数在上面都已经说明,可以看到生成 bootblock
的过程是,先编译 bootasm.S
和 bootmain.c
生成目标文件 bootasm.o
和 bootmain.o
,然后链接这两个目标文件生成 bootblock
(同时指定代码段开始地址为 0x7c00
)
再在 Makefile
文件里找到生成 kernel
的相关代码
1
2
3
4
5
6
7
8
9
10
11
12
# create kernel target
kernel = $(call totarget,kernel)
$(kernel): tools/kernel.ld
$(kernel): $(KOBJS)
@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)
$(call create_target,kernel)
执行的命令具体为:
1
2
3
4
5
+ cc kern/init/init.c
gcc -Ikern/init/ -march=i686 -fno-builtin -fno-PIC
...
+ 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
这里 gcc
和 ld
带的参数在上面都已经说明,可以看到生成 kernel
的过程是,先编译生成 kernel
所需要的文件(有很多,就不列举了)生成对应的目标文件,然后链接这些目标文件生成 kernel
综合上面的分析我们可以知道,先通过 gcc
将需要的源文件编译为对应的目标文件,通过链接对应的目标文件生成 bootblock
和 kernel
,最后初始化 ucore.img
,生成大小为 10000 字节的空间,加载 bootblock
到第一个扇区,再加载 kernel
到下一个扇区
2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
我们可以发现在 make
后,在 bin 文件夹下生成了一个 sign
文件,这个文件就是用来检测是否符合规范的硬盘主引导扇区的
我们在 Makefile
文件里找到生成 sign
的相关代码:
1
2
3
# create 'sign' tools
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
我们可以发现, sign
文件是由 tools 文件夹里的 sign.c
生成的,仔细观察 sign.c
的代码,我们可以发现这么几段
1
2
3
4
5
6
7
8
9
10
11
12
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
buf[510] = 0x55;
buf[511] = 0xAA;
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
根据这里我们可以知道,硬盘主引导扇区里的程序不能超过 510 个字节,硬盘主引导扇区大小必须为 512 个字节,并且最后两个字节一定要分别是 0x55 和0xAA
练习2:使用qemu执行并调试lab1中的软件
1.从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
首先将 tools 文件夹中的 gdbinit
修改为如下
1
2
set architecture i8086
target remote :1234
我们在 Makefile
文件中可以发现如下代码:
1
2
3
4
debug: $(UCOREIMG)
$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
$(V)sleep 2
$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
查看之后发现 debug 目标使用 qemu 追踪记录, debug 使用 gdb 加载了初始化文件 gdbinit
所以我们在终端输入 make debug
进入调试,BIOS 位于实模式下,是 20 位地址,由段寄存器 CS 和寄存器 EIP 共同组成,所以我们首先查看初始时这两个寄存器的值
1
2
3
4
(gdb) p/x $cs
$2 = 0xf000
(gdb) p/x $eip
$3 = 0xfff0
可以看到 CS 寄存器的值为 0xf000,EIP 寄存器的值为 0xfff0,所以 BIOS 运行的初始地址为 0xffff0,接着查看此地址的汇编代码
1
2
(gdb) x/i 0xffff0
0xffff0: ljmp $0x3630,$0xf000e05b
可以看到运行地址将会跳到 0xfe05b 处,查看后续 BIOS 代码
1
2
3
4
5
6
7
8
9
10
11
(gdb) x/10i 0xfe05b
0xfe05b: cmpw $0xffc8,%cs:(%esi)
0xfe060: bound %eax,(%eax)
0xfe062: jne 0xd241d0b2
0xfe068: mov %edx,%ss
0xfe06a: mov $0x7000,%sp
0xfe06e: add %al,(%eax)
0xfe070: mov $0x7c4,%dx
0xfe074: verw %cx
0xfe077: stos %eax,%es:(%edi)
0xfe078: out %al,(%dx)
弄清楚这些以后,就可以不停地按下 si
跟踪 BIOS 的执行了
2.在初始化位置0x7c00设置实地址断点,测试断点正常。
首先将 tools 文件夹中的 gdbinit
修改为如下
1
2
3
4
set architecture i8086
target remote :1234
b *0x7c00
c
在终端运行 make debug
得到
1
2
3
4
5
6
7
The target architecture is assumed to be i8086
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
0x0000fff0 in ?? ()
Breakpoint 1 at 0x7c00
Breakpoint 1, 0x00007c00 in ?? ()
查看接下来的几条汇编
1
2
3
4
5
6
7
8
9
10
11
(gdb) x /10i $pc
=> 0x7c00: cli
0x7c01: cld
0x7c02: xor %eax,%eax
0x7c04: mov %eax,%ds
0x7c06: mov %eax,%es
0x7c08: mov %eax,%ss
0x7c0a: in $0x64,%al
0x7c0c: test $0x2,%al
0x7c0e: jne 0x7c0a
0x7c10: mov $0xd1,%al
所以可以知道断点正常
3.从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
首先将 tools 文件夹中的 gdbinit
修改为如下
1
2
3
4
5
6
file bin/kernel
set architecture i8086
target remote :1234
b *0x7c00
c
break kern_init
改写 makefile 文件,在调用 qemu 时增加 -d in_asm -D q.log
参数,便可以将运行的汇编指令保存在 q.log 中
1
2
3
4
debug: $(UCOREIMG)
$(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR)/q.log -parallel stdio -hda $< -serial null"
$(V)sleep 2
$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
在终端输入 make debug
就可以生成 q.log 文件
将生成的 q.log 对应的区域和 bootasm.S 和 bootblock.asm 进行对比
----------------
IN:
0x00007c00: cli
----------------
IN:
0x00007c01: cld
0x00007c02: xor %ax,%ax
0x00007c04: mov %ax,%ds
0x00007c06: mov %ax,%es
0x00007c08: mov %ax,%ss
----------------
IN:
0x00007c0a: in $0x64,%al
----------------
IN:
0x00007c0c: test $0x2,%al
0x00007c0e: jne 0x7c0a
----------------
IN:
0x00007c10: mov $0xd1,%al
0x00007c12: out %al,$0x64
0x00007c14: in $0x64,%al
0x00007c16: test $0x2,%al
0x00007c18: jne 0x7c14
----------------
IN:
0x00007c1a: mov $0xdf,%al
0x00007c1c: out %al,$0x60
0x00007c1e: lgdtw 0x7c6c
0x00007c23: mov %cr0,%eax
0x00007c26: or $0x1,%eax
0x00007c2a: mov %eax,%cr0
----------------
IN:
0x00007c2d: ljmp $0x8,$0x7c32
----------------
IN:
0x00007c32: mov $0x10,%ax
0x00007c36: mov %eax,%ds
----------------
IN:
0x00007c38: mov %eax,%es
----------------
IN:
0x00007c3a: mov %eax,%fs
0x00007c3c: mov %eax,%gs
0x00007c3e: mov %eax,%ss
----------------
IN:
0x00007c40: mov $0x0,%ebp
----------------
IN:
0x00007c45: mov $0x7c00,%esp
0x00007c4a: call 0x7d0d
----------------
IN:
0x00007d0d: push %ebp
对比可以发现汇编代码是一致的
4.自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
在 0x7c35 设置断点(bootmain函数)。修改gdbinit:
1
2
3
set architecture i8086
target remote :1234
break *0x7c35
然后在终端输入 make debug
使用 gdb
根据以上进行调试
练习3:分析bootloader进入保护模式的过程
首先查看lab1/boot/bootasm.S源码:
#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
可以看到从 cs=0 eip=0x7c00
进入后
首先清理环境,将 flag 置 0 和段寄存器全都置为 0
.code16
cli # 关闭中断
cld
xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
从 0x64 端口读入一个字节的数据到 al 中,如果 al 第二位不为 0 ,则跳转到 seta20.1 接着执行检查 a1 的第二位是不是 0 ,是 0 说明 8042 键盘控制器不忙,发送写 8042 P2 端口的指令,再通过同样的方式检测 8042 键盘控制器是不是不忙,不忙将 P2 端口设置为 1 ,即开启 A20
开启 A20:通过将键盘控制器上的 A20 线置于高电位,全部 32 条地址线可用,可以访问 4G 的内存空间。
seta20.1: # 等待8042键盘控制器不忙
inb $0x64, %al #
testb $0x2, %al #
jnz seta20.1 #
movb $0xd1, %al # 发送写8042输出端口的指令
outb %al, $0x64 #
seta20.2: # 等待8042键盘控制器不忙
inb $0x64, %al #
testb $0x2, %al #
jnz seta20.2 #
movb $0xdf, %al # 打开A20
outb %al, $0x60 #
设置 GDT :
# 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(executable and read-only)
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel(writable)
gdtdesc:
.long gdt # address gdt
```
其中的的宏定义在asm.h中,asm.h比较短,直接摘抄出来。
```
/* Normal segment */
#define SEG_NULLASM \
.word 0, 0; \
.byte 0, 0, 0, 0
#define SEG_ASM(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
/* Application segment type bits */
#define STA_X 0x8 // Executable segment
#define STA_E 0x4 // Expand down (non-executable segments)
#define STA_C 0x4 // Conforming code segment (executable only)
#define STA_W 0x2 // Writeable (non-executable segments)
#define STA_R 0x2 // Readable (executable segments)
#define STA_A 0x1 // Accessed
分析以上两段代码可以发现:
- GDT[0] 设置为空,未使用;
- GDT[1],GDT[2] 分别作为内核代码段、数据段;
- 内核代码段、数据段都被设置为最长4G,且基地址均为0x00;
内核代码段、数据段被设置成这样是有意削弱(避免)X86分段内存模型的影响,在32位的CPU上实现类似64位上的平坦内存模型,方便页机制的实现。
初始化GDT表:一个简单的GDT表和其描述符已经静态储存在引导区中,载入即可
lgdt gdtdesc
开启保护模式仅需要打开控制寄存器CR0中相应的标志位,通过异或之前定义的掩码CRO_PE_ON实现
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
打开保护模式后,CPU 真正进入了 32 位模式,默认使用分段内存模型,段寄存器中必须存放相应的选择子。首先设置 cs 和 eip 的值,通过 ljmp 实现。ljmp 仅仅是跳转到了下一条指令( procseg 处)
ljmp $PROT_MODE_CSEG, $protcseg
.code32
protcseg:
设置段寄存器,并建立堆栈,将所有的寄存器设置为PROT_MODE_DSEG(指向内核数据段)。将栈设置为为0x00~0x7c00(bootloader之下都是栈的空间)
movw $PROT_MODE_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
movl $0x0, %ebp
movl $start, %esp
栈和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
+--------------------+
+ +
+ +
+ +
+ +
+ not in use +
+ +
+ +
+ +
+ +
+ +
+--------------------+
+ +
+ +
+ +
+ bootloader +
+ +
+ +
+ +
+ +
+--------------------+ <-- 0x7c00 beginning of bootloader
+ | +
+ | +
+ stack | +
+ | +
+ | +
+ V +
+--------------------+ <-- 0x00
转到保护模式完成,进入 bootmain 加载 kernel
call bootmain
练习4:分析bootloader加载ELF格式的OS的过程
首先我们必须要知道 ELF 格式的结构。在这里只涉及到了 ELF32 格式中的已链接的可执行文件,所以忽略共享目标文件和可重定位目标文件,对应的格式如下:
图中 ELF 头和段头部对应的 ucore 中的数据类型为 struct elfhdr 和 struct proghdr,均定义在 libs/elf.h 中
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
/* file header */
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
};
/* program section header */
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 格式的 kernel 的机制如下:
- 通过判断 ELF_MAGIC 是否等于 ELF 头中的 e_magic 确定 kernel 是否是合法的 ELF32 格式
- 段头部表是 struct proghdr 的数组,数组元素个数为 e_phnum(在 ELF 头中)
- 内核各段应该加载到对应段头部中记录的 p_va 处,大小为 p_memsz,位于于 kernel 文件的 p_offset 处。通过 readseg 实现。
首先看 readsect 函数,readsect
从设备的第 secno 扇区读取数据到 dst 位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* readsect - read a single sector at @secno into @dst */
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);
}
readseg 简单包装了 readsect,可以从设备读取任意长度的内容。
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);
}
}
bootmain 逻辑流:
- 读取 kernel 的 ELF 头和段头部表加载到 ELFHDR(0x10000)处
- 判断 kernel 是否是合法的 ELF 格式,如果不是则死循环
- 如果格式合法就根据 kernel 中的 ELF 头和段头部表中的信息将 kernel 各段加载到适当的位置
- 加载完成后,通过函数调用跳转到 entry point
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
/* bootmain - the entry of bootloader */
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);
}
练习5:实现函数调用堆栈跟踪函数
这个练习要求我们 kern/debug/kdebug.c 中的 print_stackframe() 函数。在 print_stackframe() 函数中,注释已经给出了完整的步骤,难度不大,只要理解 x86 函数调用过程就可以做出来。 栈的设置是在启动阶段(跳转到 bootmain 函数之前完成的),所以初始时栈结构如下:
1
2
3
4
5
6
7
8
9
+--------------------+ <-- 0x7c00
+ bootmain frame + |g
+--------------------+ |r
+ + |o
+ + |w
+ + V
+ +
+ +
+--------------------+ <-- 0x00
bootmain 函数是不会返回的,所有的函数栈帧都在 bootmain 函数下面。在调用 bootmain() 之前,%ebp 被设置了 0,这个值被压入了函数栈中,这个特殊的 %ebp 是跟踪函数栈帧的结束标志。
x86 函数调用的具体过程(同特权级):
- 把函数参数压入栈中
- 把函数返回地址压入栈中
- 把旧的 %ebp 压入栈中,并把 %ebp 的值改为当前 %esp
跟踪函数堆栈就是利用了x86函数调用后堆栈的结构。
堆栈的地址示意图:
1
2
3
4
5
6
7
8
9
+| 栈底方向 | 高位地址
| ... |
| ... |
| 参数3 |
| 参数2 |
| 参数1 |
| 返回地址 |
| 上一层[ebp] | <-------- [ebp]
| 局部变量 | 低位地址
每次函数调用,%ebp 都被压入栈中,并修改为当时栈顶指针 %esp 的值。栈中保存的 %ebp 总是指向上一次保存的 %ebp 处,而且栈中保存的 %ebp 上面4字节处就是调用函数的返回地址,返回地址之上就是被调用函数的参数。
利用当前的 %ebp 值和栈中保存的 %ebp 值就可以实现跟踪堆栈的功能,补全代码如下:
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
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;
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];
}
}
接下来进行验证,再终端输入 make qemu
得到如下结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ebp:0x00007b28 eip:0x00100ab3 args:0x00010094 0x00010094 0x00007b58 0x00100096
kern/debug/kdebug.c:305: print_stackframe+25
ebp:0x00007b38 eip:0x00100db5 args:0x00000000 0x00000000 0x00000000 0x00007ba8
kern/debug/kmonitor.c:125: mon_backtrace+14
ebp:0x00007b58 eip:0x00100096 args:0x00000000 0x00007b80 0xffff0000 0x00007b84
kern/init/init.c:48: grade_backtrace2+37
ebp:0x00007b78 eip:0x001000c4 args:0x00000000 0xffff0000 0x00007ba4 0x00000029
kern/init/init.c:53: grade_backtrace1+42
ebp:0x00007b98 eip:0x001000e7 args:0x00000000 0x00100000 0xffff0000 0x0000001d
kern/init/init.c:58: grade_backtrace0+27
ebp:0x00007bb8 eip:0x00100111 args:0x0010343c 0x00103420 0x0000130a 0x00000000
kern/init/init.c:63: grade_backtrace+38
ebp:0x00007be8 eip:0x00100055 args:0x00000000 0x00000000 0x00000000 0x00007c4f
kern/init/init.c:28: kern_init+84
ebp:0x00007bf8 eip:0x00007d74 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8
<unknow>: -- 0x00007d73 --
++ setup timer interrupts
可以看到检验正确
练习6:完善中断初始化和处理
1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
操作系统是由中断驱动的,用于当某事件发生时,可以主动通知cpu及os进行处理,主要的中断类型有外部中断、内部中断(异常)、软中断(陷阱、系统调用)。
- 外部中断:用于cpu与外设进行通信,当外设需要输入或输出时主动向cpu发出中断请求;
- 内部中断:cpu执行期间检测到不正常或非法条件(如除零错、地址访问越界)时会引起内部中断;
- 系统调用:用于程序使用系统调用服务。
当中断发生时,cpu会得到一个中断向量号,作为IDT(中断描述符表)的索引,IDT表起始地址由IDTR寄存器存储,cpu会从IDT表中找到该中断向量号相应的中断服务程序入口地址,跳转到中断处理程序处执行,并保存当前现场;当中断程序执行完毕,恢复现场,跳转到原中断点处继续执行。
IDT的表项为中断描述符,主要类型有中断门、陷阱门、调用门,IDT描述符结构(摘自intel开发手册)如下:
中断门与陷阱门作为IDT的表项,每个表项占据8字节,其中段选择子和偏移地址用来代表中断处理程序入口地址,具体先通过选择子查找GDT对应段描述符,得到该代码段的基址,基址加上偏移地址为中断处理程序入口地址。其中2-3字节是段选择子,0-1字节和6-7字节拼成位移,两者联合便是中断处理程序的入口地址
2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
中断处理过程的调用链如下:
发生中断 N –> vectors[N] –> alltraps –> trap –> trap_dispatch
IDT 中设置中断处理程序仅仅是将中断错误码 /0 和中断号压入栈中,然后跳转到 __alltraps 将其他寄存器压栈,之后调用 trap( trap_dispatch 的包装函数)将指向栈帧的指针传递给 trap_dispatch ,trap_dispatch 根据栈帧中保存的中断号对相应中断进行处理。
_vectors[N]:
- 对于有错误码的中断,压入中断号(错误码由处理器压入),跳转到 __alltraps。
- 对于无错误码的中断,压入 0 和中断号,跳转到 __alltraps。
__vectors[N]的这种处理方式同一了带错误码的中断和不带中断码的终端的堆栈布局,为统一两种终端的处理过程提供了可能。
__alltraps:
_alltraps 对堆栈的布局进行进一步处理:
- 中断发生时,将所有段寄存器、通用寄存器压入栈中。
- 中断处理完成后将传递给 trap 的指针出栈。
在这里先全部设为中断门,中断处理程序均在内核态执行,因此代码段为内核的代码段,DPL 为内核态的 0,但也有可以从用户态到内核的中断,中断号 T_SWITCH_TOK 就是,所以还要设置从用户态转为内核态的中断的特权级为 DPL_USER
补全后的 idt_init 函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
extern uintptr_t __vectors[];
for (int i = 0; i < 256; i++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); //DPL_KERNEL是内核态优先级
}
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
lidt(&idt_pd);
}
3.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
trap 作为 trap_dispatch 的包装函数,功能仅仅是将指向栈帧的指针传递给 trap_dispatch,trap_dispatch 完成具体处理过程。
trap 定义如下:
1
2
3
4
5
void
trap(struct trapframe *tf) {
// dispatch based on what type of trap occurred
trap_dispatch(tf);
}
trap_distrap 接收到指向栈帧的指针后,使用 switch 语句根据栈帧中保存的中断号对中断进行针对性的处理。在 ucore lab1 中,trap_dispatch 的的功能还很简单,只有对少数几种硬件中断的处理能力。
接下来编写代码就很简单了,每次时钟中断就将 ticks 加一,当到达 TICK_NUM 即 100 的时候就调用 print_ticks 函数
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
case IRQ_OFFSET + IRQ_TIMER:
/* LAB1 YOUR CODE : STEP 3 */
/* handle the timer interrupt */
/* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
* (3) Too Simple? Yes, I think so!
*/
ticks++;
if (ticks % TICK_NUM == 0) {
print_ticks();
}
break;
编写完成后在终端输入 make qemu
进行验证
可以看到验证成功
扩展练习 Challenge 1
ucore 的内核代码段在 boot/bootmain.c 中被设置为可执行、可读、非一致的,只有当 CPL 等于代码段 DPL 且 RPL 小于等于 RPL 时才能够成功访问,所以不能够通过直接修改 CS 的特权位实现特权级的切换。数据段也存在类似问题。
可行的方法是通过额外设置与内核代码段指向相同、DPL 不同的用户态代码段,与内核数据段指向相同、DPL 不同的用户态数据段来实现特权级切换。
当从用户态切换到内核态时,程序的段寄存器指向对应的内核态段,偏移地址不变;当从内核态切换到用户态时,程序的段寄存器指向对应的应用态段,偏移地址不变。
特权级切换时会发生堆栈切换,还必须设置好 ring 3 和 ring 0 的堆栈并记录在 TSS 中。幸运的是,ucore 在初始化过程中已经替我们完成了这些工作。
从内核态到用户态
中断处理例程处于 ring 0 ,所以内核态发生的中断不发生堆栈切换,因此 SS、ESP 不会自动压栈;。
但是是否弹出 SS、ESP 确实由堆栈上的 CS 中的特权位决定的。当我们将堆栈中的 CS 的特权位设置为 ring 3 时,IRET 会误认为中断是从 ring 3 发生的,执行时会按照发生特权级切换的情况弹出 SS、ESP。
利用这个特性,只需要手动地将内核堆栈布局设置为发生了特权级转换时的布局,将所有的特权位修改为 DPL_USER ,保持 EIP、ESP 不变,IRET 执行后就可以切换为应用态。
因为从内核态发生中断不压入 SS、ESP,所以在中断前手动压入 SS、ESP。中断处理过程中会修改 tf->tf_esp 的值,中断发生前压入的 SS 实际不会被使用,所以代码中仅仅是压入了 %eax 占位。
为了在切换为应用态后,保存原有堆栈结构不变,确保程序正确运行,栈顶的位置应该被恢复到中断发生前的位置。SS、ESP 是通过 push 指令压栈的,压入 SS 后,ESP 的值已经上移了 4 个字节,所以在 trap_dispatch 将 ESP 下移 4 字节。为了保证在用户态下也能使用 I/O,将 IOPL 降低到了 ring 3。
1
2
3
4
5
6
7
8
9
10
11
static void
lab1_switch_to_user(void) {
//LAB1 CHALLENGE 1 : TODO
asm volatile (
"sub $0x8, %%esp \n"
"int %0 \n"
"movl %%ebp, %%esp"
:
: "i"(T_SWITCH_TOU)
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
case T_SWITCH_TOU:
if (tf->tf_cs != USER_CS) {
switchk2u = *tf;
switchk2u.tf_cs = USER_CS;
switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
// set eflags, make sure ucore can use io under user mode.
// if CPL > IOPL, then cpu will generate a general protection.
switchk2u.tf_eflags |= FL_IOPL_MASK;
// set temporary stack
// then iret will jump to the right stack
*((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
}
break;
从用户态到内核态
在用户态发生中断时堆栈会从用户栈切换到内核栈,并压入SS、ESP等寄存器。在篡改内核堆栈后IRET返回时会误认为没有特权级转换发生,不会把SS、ESP弹出,因此从用户态切换到内核态时需要手动弹出SS、ESP
1
2
3
4
5
6
7
8
9
10
static void
lab1_switch_to_kernel(void) {
//LAB1 CHALLENGE 1 : TODO
asm volatile (
"int %0 \n"
"movl %%ebp, %%esp \n"
:
: "i"(T_SWITCH_TOK)
);
}
1
2
3
4
5
6
7
8
9
10
case T_SWITCH_TOK:
if (tf->tf_cs != KERNEL_CS) {
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;
接下来在终端输入 make grade
进行检验
可以看到得分为满分,说明正确
扩展练习 Challenge 2
切换内核的代码直接照搬 Challenge 1
的代码即可,修改 case IRQ_OFFSET + IRQ_KBD:
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
// in `trap_dispatch` of `trap.c`
case IRQ_OFFSET + IRQ_KBD:
c = cons_getc();
cprintf("kbd [%03d] %c\n", c, c);
// 切换特权级的代码直接照抄之前编写的代码
if(c == '0')
{
if (tf->tf_cs != KERNEL_CS) {
cprintf("+++ switch to kernel mode +++\n");
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;
}
}
else if(c == '3')
{
if (tf->tf_cs != USER_CS) {
cprintf("+++ switch to user mode +++\n");
switchk2u = *tf;
switchk2u.tf_cs = USER_CS;
switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
switchk2u.tf_eflags |= FL_IOPL_MASK;
*((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
}
}
break;
接下来在终端输入 make qemu
,并且在键盘上输入 0
或者 3
进行验证
可以看到验证成功
重要知识点
将在实验中很重要的知识点都列出在这里了
Intel 80386
ucore目前支持的硬件环境是基于Intel 80386以上的计算机系统。
Intel 80386是80x86系列中的第一种32位微处理器。80386的内部和外部数据总线都是32位,地址总线也是32位,可寻址高达4GB内存。
工作方式包括实模式、保护模式以及虚拟86模式。
BIOS中断、DOS中断、Linux中断的区别
- BIOS 和 DOS 都存在于实模式下,由它们建立的中断调用都是建立在中断向量表(Interrupt Vector Table,IVT)中的,都是通过软中断指令 int 中断号来调用。
- BIOS 中断调用的主要功能是提供了硬件访问的方法,该方法使对硬件的操作变得简单易行。
- DOS 是运行在实模式下的,故其建立的中断调用也建立在中断向量表中,只不过其中断向量号和 BIOS 的不能冲突。
- Linux 内核是在进入保护模式后才建立中断例程的,不过在保护模式下,中断向量表已经不存在了,取而代之的是中断描述符表(Interrupt Descriptor Table,IDT)。Linux 的系统调用和 DOS 中断调用类似,不过 Linux 是通过
int 0x80
指令进入一个中断程序后再根据 eax 寄存器的值来调用不同的子功能函数的。
BIOS启动过程
当计算机加电后,一般不直接执行操作系统,而是执行系统初始化软件完成基本IO初始化和引导加载功能。简单地说,系统初始化软件就是在操作系统内核运行之前运行的一段小软件。通过这段小软件,我们可以初始化硬件设备、建立系统的内存空间映射图,从而将系统的软硬件环境带到一个合适的状态,以便为最终调用操作系统内核准备好正确的环境。最终引导加载程序把操作系统内核映像加载到RAM中,并将系统控制权传递给它。
对于绝大多数计算机系统而言,操作系统和应用软件是存放在磁盘(硬盘/软盘)、光盘、EPROM、ROM、Flash等可在掉电后继续保存数据的存储介质上。计算机启动后,CPU一开始会到一个特定的地址开始执行指令,这个特定的地址存放了系统初始化软件,负责完成计算机基本的IO初始化,这是系统加电后运行的第一段软件代码。对于Intel 80386的体系结构而言,PC机中的系统初始化软件由BIOS (Basic Input Output System,即基本输入/输出系统,其本质是一个固化在主板Flash/CMOS上的软件)和位于软盘/硬盘引导扇区中的OS Boot Loader(在ucore中的bootasm.S和bootmain.c)一起组成。BIOS实际上是被固化在计算机ROM(只读存储器)芯片上的一个特殊的软件,为上层软件提供最底层的、最直接的硬件控制与支持。更形象地说,BIOS就是PC计算机硬件与上层软件程序之间的一个”桥梁”,负责访问和控制硬件。
以Intel 80386为例,计算机加电后,CPU从物理地址0xFFFFFFF0(由初始化的CS:EIP确定,此时CS和IP的值分别是0xF000和0xFFF0))开始执行。在0xFFFFFFF0这里只是存放了一条跳转指令,通过跳转指令跳到BIOS例行程序起始点。BIOS做完计算机硬件自检和初始化后,会选择一个启动设备(例如软盘、硬盘、光盘等),并且读取该设备的第一扇区(即主引导扇区或启动扇区)到内存一个特定的地址0x7c00处,然后CPU控制权会转移到那个地址继续执行。至此BIOS的初始化工作做完了,进一步的工作交给了ucore的bootloader。
Intel的CPU具有很好的向后兼容性。在16位的8086 CPU时代,内存限制在1MB范围内,且BIOS的代码固化在EPROM中。在基于Intel的8086 CPU的PC机中的EPROM被编址在1MB内存地址空间的最高64KB中。PC加电后,CS寄存器初始化为0xF000,IP寄存器初始化为0xFFF0,所以CPU要执行的第一条指令的地址为CS:IP=0xF000:0XFFF0(Segment:Offset 表示)=0xFFFF0(Linear表示)。这个地址位于被固化EPROM中,指令是一个长跳转指令JMP F000:E05B
。这样就开启了BIOS的执行过程。
到了32位的80386 CPU时代,内存空间扩大到了4G,多了段机制和页机制,但Intel依然很好地保证了80386向后兼容8086。地址空间的变化导致无法直接采用8086的启动约定。如果把BIOS启动固件编址在0xF000起始的64KB内存地址空间内,就会把整个物理内存地址空间隔离成不连续的两段,一段是0xF000以前的地址,一段是1MB以后的地址,这很不协调。为此,intel采用了一个折中的方案:默认将执行BIOS ROM编址在32位内存地址空间的最高端,即位于4GB地址的最后一个64KB内。在PC系统开机复位时,CPU进入实模式,并将CS寄存器设置成0xF000,将它的shadow register的Base值初始化设置为0xFFFF0000,EIP寄存器初始化设置为0x0000FFF0。所以机器执行的第一条指令的物理地址是0xFFFFFFF0。80386的BIOS代码也要和以前8086的BIOS代码兼容,故地址0xFFFFFFF0处的指令还是一条长跳转指令jmp F000:E05B
。注意,这个长跳转指令会触发更新CS寄存器和它的shadow register,即执行jmp F000 : E05B
后,CS将被更新成0xF000。表面上看CS其实没有变化,但CS的shadow register被更新为另外一个值了,它的Base域被更新成0x000F0000,此时形成的物理地址为Base+EIP=0x000FE05B,这就是CPU执行的第二条指令的地址。此时这条指令的地址已经是1M以内了,且此地址不再位于BIOS ROM中,而是位于RAM空间中。由于Intel设计了一种映射机制,将内存高端的BIOS ROM映射到1MB以内的RAM空间里,并且可以使这一段被映射的RAM空间具有与ROM类似的只读属性。所以PC机启动时将开启这种映射机制,让4GB地址空间的最高一个64KB的内容等同于1MB地址空间的最高一个64K的内容,从而使得执行了长跳转指令后,其实是回到了早期的8086 CPU初始化控制流,保证了向下兼容。
bootloader启动过程
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。bootloader完成的工作包括:
- 切换到保护模式,启用分段机制
- 读磁盘中ELF执行文件格式的ucore操作系统到内存
- 显示字符串信息
- 把控制权交给ucore操作系统
实模式
我们知道Intel x86系列CPU有实模式和保护模式,实模式从8086开始就有,保护模式从80386开始引入。为了兼容,Intel x86系列CPU都支持实模式。现代操作系统都是运行在保护模式下(Intel x86系列CPU)。计算机启动时,默认的工作模式是实模式,为了让内核能运行在保护模式下,Bootloader需要从实模式切换到保护模式,切换步骤如下:
- 准备好GDT(Global Descriptor Table)
- 关中断
- 加载GDT到GDTR寄存器
- 开启A20,让CPU寻址大于1M
- 开启CPU的保护模式,即把cr0寄存器第一个bit置1
- 跳转到保护模式代码
GDT是Intel CPU保护模式运行的核心数据结构,所有保护模式操作的数据都从GDT表开始查找,这里有GDT的详细介绍。
GDT中的每一个表项由8字节表示,如下图:
-
CPU大体上可分为控制单元、运算单元、存储单元
- 控制单元是CPU的控制中心,大致由指令寄存器(IR, Instruction Register)、指令译码器(ID, Instruction Decoder)和操作控制器(OC, Operation Controller)组成。以下是一般的指令格式
- 运算单元根据控制单元的信号,进行运算。
- 存储单元CPU内部的L1、L2缓存及寄存器。这部分缓存采用SRAM存储器。SRAM不需要刷新电路即可保存内部存储的数据,但因为体积较大,集成度较低。
-
CPU中的寄存器分为两大类:程序可见寄存器(例如通用寄存器、段寄存器)和程序不可见寄存器(例如中断描述符寄存器IDTR)。
-
实模式的主要特性是:程序用到的地址都是真实的物理地址。同时,实模式下的地址寻址空间只有1MB(20bit)
从intel 80386开始的CPU,只要进入实模式,地址寻址空间就限制在1MB。
-
实模式下的地址计算方式为16*段寄存器值+段内偏移地址,其CPU寻址方式为
- 寄存器寻址
- 立即数寻址
- 内存寻址
- 直接寻址。例如
mov ax, [0x1234]
- 基址寻址
- 变址寻址
- 基址变址寻址
- 直接寻址。例如
-
CPU初始状态为16位实模式,在实模式下只能访问1MB(20bits)内存。而硬件工程师将1MB的内存空间分成多个部分。
-
其中地址
0-0x9ffff
的640KB内存是DRAM,即插在主板上的内存条。 顶部0xf0000-0xfffff
的64KB内存是ROM,存放BIOS代码。BIOS检测并初始化硬件,同时建立中断向量表,并保证能运行一些基本硬件的IO操作
-
CPU中,插在主板上的物理内存并不是眼中“全部的内存”。地址总线宽度决定可以访问的内存空间大小。 并不是只有插在主板上的内存条需要通过地址总线访问,还有一些外设同样是需要通过地址总线来访问。 故地址总线上会提前预留出来一些地址空间给这些外设,其余的可用地址再指向DRAM。
分段机制
只有在保护模式下才能使用分段存储管理机制。分段机制将内存划分成以起始地址和长度限制这两个二维参数表示的内存块,这些内存块就称之为段(Segment)。编译器把源程序编译成执行程序时用到的代码段、数据段、堆和栈等概念在这里可以与段联系起来,二者在含义上是一致的。
分段机涉及4个关键内容:逻辑地址、段描述符(描述段的属性)、段描述符表(包含多个段描述符的“数组”)、段选择子(段寄存器,用于定位段描述符表中表项的索引)。转换逻辑地址(Logical Address,应用程序员看到的地址)到物理地址(Physical Address, 实际的物理内存地址)分以下两步:
-
分段地址转换:CPU把逻辑地址(由段选择子selector和段偏移offset组成)中的段选择子的内容作为段描述符表的索引,找到表中对应的段描述符,然后把段描述符中保存的段基址加上段偏移值,形成线性地址(Linear Address)。如果不启动分页存储管理机制,则线性地址等于物理地址。
-
分页地址转换,这一步中把线性地址转换为物理地址。(注意:这一步是可选的,由操作系统决定是否需要。在后续试验中会涉及。
上述转换过程对于应用程序员来说是不可见的。线性地址空间由一维的线性地址构成,线性地址空间和物理地址空间对等。线性地址32位长,线性地址空间容量为4G字节。分段地址转换的基本过程如下图所示。
图1 分段地址转换基本过程
分段存储管理机制需要在启动保护模式的前提下建立。从上图可以看出,为了使得分段存储管理机制正常运行,需要建立好段描述符和段描述符表(参看bootasm.S,mmu.h,pmm.c)。
段描述符
在分段存储管理机制的保护模式下,每个段由如下三个参数进行定义:段基地址(Base Address)、段界限(Limit)和段属性(Attributes)。在ucore中的kern/mm/mmu.h中的struct segdesc 数据结构中有具体的定义。
- 段基地址:规定线性地址空间中段的起始地址。在80386保护模式下,段基地址长32位。因为基地址长度与寻址地址的长度相同,所以任何一个段都可以从32位线性地址空间中的任何一个字节开始,而不象实方式下规定的边界必须被16整除。
- 段界限:规定段的大小。在80386保护模式下,段界限用20位表示,而且段界限可以是以字节为单位或以4K字节为单位。
- 段属性:确定段的各种性质。
- 段属性中的粒度位(Granularity),用符号G标记。G=0表示段界限以字节位位单位,20位的界限可表示的范围是1字节至1M字节,增量为1字节;G=1表示段界限以4K字节为单位,于是20位的界限可表示的范围是4K字节至4G字节,增量为4K字节。
- 类型(TYPE):用于区别不同类型的描述符。可表示所描述的段是代码段还是数据段,所描述的段是否可读/写/执行,段的扩展方向等。
- 描述符特权级(Descriptor Privilege Level)(DPL):用来实现保护机制。
- 段存在位(Segment-Present bit):如果这一位为0,则此描述符为非法的,不能被用来实现地址转换。如果一个非法描述符被加载进一个段寄存器,处理器会立即产生异常。图5-4显示了当存在位为0时,描述符的格式。操作系统可以任意的使用被标识为可用(AVAILABLE)的位。
- 已访问位(Accessed bit):当处理器访问该段(当一个指向该段描述符的选择子被加载进一个段寄存器)时,将自动设置访问位。操作系统可清除该位。
上述参数通过段描述符来表示,段描述符的结构如下图所示:
图2 段描述符结构
全局描述符表
全局描述符表的是一个保存多个段描述符的“数组”,其起始地址保存在全局描述符表寄存器GDTR中。GDTR长48位,其中高32位为基地址,低16位为段界限。由于GDT 不能有GDT本身之内的描述符进行描述定义,所以处理器采用GDTR为GDT这一特殊的系统段。注意,全局描述符表中第一个段描述符设定为空段描述符。GDTR中的段界限以字节为单位。对于含有N个描述符的描述符表的段界限通常可设为8*N-1。在ucore中的boot/bootasm.S中的gdt地址处和kern/mm/pmm.c中的全局变量数组gdt[]分别有基于汇编语言和C语言的全局描述符表的具体实现。
选择子
线性地址部分的选择子是用来选择哪个描述符表和在该表中索引一个描述符的。选择子可以做为指针变量的一部分,从而对应用程序员是可见的,但是一般是由连接加载器来设置的。选择子的格式如下图所示:
图3 段选择子结构
- 索引(Index):在描述符表中从8192个描述符中选择一个描述符。处理器自动将这个索引值乘以8(描述符的长度),再加上描述符表的基址来索引描述符表,从而选出一个合适的描述符。
- 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0代表应该访问全局描述符表(GDT),1代表应该访问局部描述符表(LDT)。
- 请求特权级(Requested Privilege Level,RPL):保护机制,在后续试验中会进一步讲解。
全局描述符表的第一项是不能被CPU使用,所以当一个段选择子的索引(Index)部分和表指示位(Table Indicator)都为0的时(即段选择子指向全局描述符表的第一项时),可以当做一个空的选择子(见mmu.h中的SEG_NULL)。当一个段寄存器被加载一个空选择子时,处理器并不会产生一个异常。但是,当用一个空选择子去访问内存时,则会产生异常。
保护模式下的特权级
在保护模式下,特权级总共有4个,编号从0(最高特权)到3(最低特权)。有3种主要的资源受到保护:内存,I/O端口以及执行特殊机器指令的能力。在任一时刻,x86 CPU都是在一个特定的特权级下运行的,从而决定了代码可以做什么,不可以做什么。这些特权级经常被称为为保护环(protection ring),最内的环(ring 0)对应于最高特权0,最外面的环(ring 3)一般给应用程序使用,对应最低特权3。在ucore中,CPU只用到其中的2个特权级:0(内核态)和3(用户态)。
有大约15条机器指令被CPU限制只能在内核态执行,这些机器指令如果被用户模式的程序所使用,就会颠覆保护模式的保护机制并引起混乱,所以它们被保留给操作系统内核使用。如果企图在ring 0以外运行这些指令,就会导致一个一般保护异常(general-protection exception)。对内存和I/O端口的访问也受类似的特权级限制。
数据段选择子的整个内容可由程序直接加载到各个段寄存器(如SS或DS等)当中。这些内容里包含了请求特权级(Requested Privilege Level,简称RPL)字段。然而,代码段寄存器(CS)的内容不能由装载指令(如MOV)直接设置,而只能被那些会改变程序执行顺序的指令(如JMP、INT、CALL)间接地设置。而且CS拥有一个由CPU维护的当前特权级字段(Current Privilege Level,简称CPL)。二者结构如下图所示:
图4 DS和CS的结构图
代码段寄存器中的CPL字段(2位)的值总是等于CPU的当前特权级,所以只要看一眼CS中的CPL,你就可以知道此刻的特权级了。
CPU会在两个关键点上保护内存:当一个段选择符被加载时,以及,当通过线性地址访问一个内存页时。因此,保护也反映在内存地址转换的过程之中,既包括分段又包括分页。当一个数据段选择符被加载时,就会发生下述的检测过程:
图5 内存访问特权级检查过程
因为越高的数值代表越低的特权,上图中的MAX()用于选择CPL和RPL中特权最低的一个,并与描述符特权级(Descriptor Privilege Level,简称DPL)比较。如果DPL的值大于等于它,那么这个访问可正常进行了。RPL背后的设计思想是:允许内核代码加载特权较低的段。比如,你可以使用RPL=3的段描述符来确保给定的操作所使用的段可以在用户模式中访问。但堆栈段寄存器是个例外,它要求CPL,RPL和DPL这3个值必须完全一致,才可以被加载。下面再总结一下CPL、RPL和DPL:
- CPL:当前特权级(Current Privilege Level) 保存在CS段寄存器(选择子)的最低两位,CPL就是当前活动代码段的特权级,并且它定义了当前所执行程序的特权级别)
- DPL:描述符特权(Descriptor Privilege Level) 存储在段描述符中的权限位,用于描述对应段所属的特权等级,也就是段本身能被访问的真正特权级。
- RPL:请求特权级RPL(Request Privilege Level) RPL保存在选择子的最低两位。RPL说明的是进程对段访问的请求权限,意思是当前进程想要的请求权限。RPL的值可自由设置,并不一定要求RPL>=CPL,但是当RPL < CPL时,实际起作用的就是CPL了,因为访问时的特权级保护检查要判断:max(RPL,CPL)<=DPL是否成立。所以RPL可以看成是每次访问时的附加限制,RPL=0时附加限制最小,RPL=3时附加限制最大。
MBR与磁盘分区
在目前x86的系统架构中,系统硬盘位于第0号磁道:0到511KB的区块为MBR(硬盘中的每一个磁道容量为512KB),开机管理程序使用这块区域来储存第一阶段开机引导程序(stage1)。接着位于1到62号磁道作为第1.5阶段的开机引导程序(stage1.5),从第63号磁道开始才是操作系统的分区。
主引导记录(MBR,Master Boot Record)是位于磁盘最前边的一段引导(Loader)代码。它负责磁盘操作系统(DOS)对磁盘进行读写时分区合法性的判别、分区引导信息的定位,它由磁盘操作系统(DOS)在对硬盘进行初始化时产生。
MBR的内容分为三部分:第一部分是0到445KB,是计算机的基础导引程序,也称为第一阶段的导引程序;接着446KB到509KB为磁盘分区表,由四个分区表项构成(每个16个字节)。负责说明磁盘上的分区情况。内容包括分区标记、分区的起始位置、分区的容量以及分区的类型。最后一部分为结束标志只占2KB,其值为AA55,存储时低位在前,高位在后。
从百度百科借了张图:
MBR中紧跟在主引导程序后的主分区表这64字节(01BE~01FD)中包含了许多磁盘分区描述信息,尤其是01BE~01CD这16字节,包含了分区引导标志bootid、分区起始源头beghead、分区起始扇区relsect、分区起始柱面begcy1、操作系统类型systid、分区结尾磁头endhead、分区结尾扇区begsect、分区结尾柱面begcy1、分区扇区起始位置relsect、分区扇区总数numsect。
其中分区引导标志bootid表示当前分区是否可以引导,若为0x0,则表示该分区为非活动区;若为0x80,则为可开机启动区。若有多个开机启动区,则由用户开机时的选择而定(如GRUB的菜单)。
分区扇区起始位置relsect表示分区中第一个扇区相对于磁盘起始点的偏移位置。
硬盘访问概述
bootloader让CPU进入保护模式后,下一步的工作就是从硬盘上加载并运行OS。考虑到实现的简单性,bootloader的访问硬盘都是LBA模式的PIO(Program IO)方式,即所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。
一般主板有2个IDE通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7实现的,具体参数见下表。一般第一个IDE通道通过访问IO地址0x1f0-0x1f7来实现,第二个IDE通道通过访问0x170-0x17f实现。每个通道的主从盘的选择通过第6个IO偏移地址寄存器来设置。
表一 磁盘IO地址和对应功能
第6位:为1=LBA模式;0 = CHS模式 第7位和第5位必须为1
IO地址 | 功能 |
---|---|
0x1f0 | 读数据,当0x1f7不为忙状态时,可以读。 |
0x1f2 | 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区 |
0x1f3 | 如果是LBA模式,就是LBA参数的0-7位 |
0x1f4 | 如果是LBA模式,就是LBA参数的8-15位 |
0x1f5 | 如果是LBA模式,就是LBA参数的16-23位 |
0x1f6 | 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘 |
0x1f7 | 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据 |
当前 硬盘数据是储存到硬盘扇区中,一个扇区大小为512字节。读一个扇区的流程(可参看boot/bootmain.c中的readsect函数实现)大致如下:
- 等待磁盘准备好
- 发出读取扇区的命令
- 等待磁盘准备好
- 把磁盘扇区数据读到指定内存
ELF文件格式概述
ELF(Executable and linking format)文件格式是Linux系统下的一种常用目标文件(object file)格式,有三种主要类型:
- 用于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。 这也是本实验的OS文件类型。
- 用于连接的可重定位文件(relocatable file),可与其它目标文件一起创建可执行文件和共享目标文件。
- 共享目标文件(shared object file),连接器可将它与其它可重定位文件和共享目标文件连接成其它的目标文件,动态连接器又可将它与可执行文件和其它共享目标文件结合起来创建一个进程映像。
这里只分析与本实验相关的ELF可执行文件类型。ELF header在文件开始处描述了整个文件的组织。ELF的文件头包含整个执行文件的控制结构,其定义在elf.h中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct elfhdr {
uint magic; // must equal ELF_MAGIC
uchar elf[12];
ushort type;
ushort machine;
uint version;
uint entry; // 程序入口的虚拟地址
uint phoff; // program header 表的位置偏移
uint shoff;
uint flags;
ushort ehsize;
ushort phentsize;
ushort phnum; //program header表中的入口数目
ushort shentsize;
ushort shnum;
ushort shstrndx;
};
program header描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。可执行文件的程序头部是一个program header结构的数组, 每个结构描述了一个段或者系统准备程序执行所必需的其它信息。目标文件的 “段” 包含一个或者多个 “节区”(section) ,也就是“段内容(Segment Contents)” 。程序头部仅对于可执行文件和共享目标文件有意义。可执行目标文件在ELF头部的e_phentsize和e_phnum成员中给出其自身程序头部的大小。程序头部的数据结构如下表所示:
1
2
3
4
5
6
7
8
9
10
struct proghdr {
uint type; // 段类型
uint offset; // 段相对文件头的偏移值
uint va; // 段的第一个字节将被放到内存中的虚拟地址
uint pa;
uint filesz;
uint memsz; // 段在内存映像中占用的字节数
uint flags;
uint align;
};
根据elfhdr和proghdr的结构描述,bootloader就可以完成对ELF格式的ucore操作系统的加载过程(参见boot/bootmain.c中的bootmain函数)。
[补充材料]
Link addr& Load addr
Link Address是指编译器指定代码和数据所需要放置的内存地址,由链接器配置。Load Address是指程序被实际加载到内存的位置(由程序加载器ld配置)。一般由可执行文件结构信息和加载器可保证这两个地址相同。Link Addr和LoadAddr不同会导致:
- 直接跳转位置错误
- 直接内存访问(只读数据区或bss等直接地址访问)错误
- 堆和栈等的使用不受影响,但是可能会覆盖程序、数据区域 注意:也存在Link地址和Load地址不一样的情况(例如:动态链接库)。
中断与异常
- 在操作系统中,有三种特殊的中断事件:
- 异步中断(asynchronous interrupt)。这是由CPU外部设备引起的外部事件中断,例如I/O中断、时钟中断、控制台中断等。
- 同步中断(synchronous interrupt)。这是CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件。
- 陷入中断(trap interrupt)。这是在程序中使用请求系统服务的系统调用而引发的事件。
- 当CPU收到中断或者异常的事件时,它会暂停执行当前的程序或任务,通过一定的机制跳转到负责处理这个信号的相关处理例程中,在完成对这个事件的处理后再跳回到刚才被打断的程序或任务中。
- 其中,中断向量和中断服务例程的对应关系主要是由IDT(中断描述符表)负责。操作系统在IDT中设置好各种中断向量对应的中断描述符,留待CPU在产生中断后查询对应中断服务例程的起始地址。而IDT本身的起始地址保存在
idtr
寄存器中。 - 当CPU进入中断处理例程时,
eflags
寄存器上的IF
标志位将会自动被CPU置为0,待中断处理例程结束后才恢复IF
标志。
a. 中断描述符表
- 中断描述符表(Interrupt Descriptor Table, IDT)把每个中断或异常编号和一个指向中断服务例程的描述符联系起来。同GDT一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符。
- IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。
b. IDT gate descriptors
-
中断/异常应该使用
Interrupt Gate
或Trap Gate
。其中的唯一区别就是:当调用Interrupt Gate
时,Interrupt会被CPU自动禁止;而调用Trap Gate
时,CPU则不会去禁止或打开中断,而是保留原样。这其中的原理是当CPU跳转至
Interrupt Gate
时,其eflag上的IF位会被清除。而Trap Gate
则不改变。 -
IDT中包含了3种类型的Descriptor
- Task-gate descriptor
- Interrupt-gate descriptor (中断方式用到)
- Trap-gate descriptor(系统调用用到) 下图图显示了80386的中断门描述符、陷阱门描述符的格式:
c. 中断处理过程
1) 起始阶段
- CPU执行完每条指令后,判断中断控制器中是否产生中断。如果存在中断,则取出对应的中断变量。
- CPU根据中断变量,到IDT中找到对应的中断描述符。
- 通过获取到的中断描述符中的段选择子,从GDT中取出对应的段描述符。此时便获取到了中断服务例程的段基址与属性信息,跳转至该地址。
- CPU会根据CPL和中断服务例程的段描述符的DPL信息确认是否发生了特权级的转换。若发生了特权级的转换,这时CPU会从当前程序的TSS信息(该信息在内存中的起始地址存在TR寄存器中)里取得该程序的内核栈地址,即包括内核态的ss和esp的值,并立即将系统当前使用的栈切换成新的内核栈。这个栈就是即将运行的中断服务程序要使用的栈。紧接着就将当前程序使用的用户态的ss和esp压到新的内核栈中保存起来;
- CPU需要开始保存当前被打断的程序的现场(即一些寄存器的值),以便于将来恢复被打断的程序继续执行。这需要利用内核栈来保存相关现场信息,即依次压入当前被打断程序使用的eflags,cs,eip,errorCode(如果是有错误码的异常)信息;
- CPU利用中断服务例程的段描述符将其第一条指令的地址加载到cs和eip寄存器中,开始执行中断服务例程。这意味着先前的程序被暂停执行,中断服务程序正式开始工作。
2) 终止阶段
-
每个中断服务例程在有中断处理工作完成后需要通过
iret
(或iretd
)指令恢复被打断的程序的执行。CPU执行IRET指令的具体过程如下:- 程序执行这条iret指令时,首先会从内核栈里弹出先前保存的被打断的程序的现场信息,即eflags,cs,eip重新开始执行;
- 如果存在特权级转换(从内核态转换到用户态),则还需要从内核栈中弹出用户态栈的ss和esp,即栈也被切换回原先使用的用户栈。
- 如果此次处理的是带有错误码(errorCode)的异常,CPU在恢复先前程序的现场时,并不会弹出errorCode,需要要求相关的中断服务例程在调用iret返回之前添加出栈代码主动弹出errorCode。
特权级
- 特权级共分为四档,分别为0-3,其中
Kernel
为第0特权级(ring 0),用户程序为第3特权级(ring 3),操作系统保护分别为第1和第2特权级。 - 特权级的区别
- 一些指令(例如特权指令
lgdt
)只能运行在ring 0下。 - CPU在如下时刻会检查特权级
- 访问数据段
- 访问页
- 进入中断服务例程(ISRs)
- …
- 如果检查失败,则会产生保护异常(General Protection Fault).
- 一些指令(例如特权指令
1. CPL、DPL、RPL与IOPL
-
DPL存储于段描述符中,规定访问该段的权限级别(Descriptor Privilege Level),每个段的DPL固定。 当进程访问一个段时,需要进程特权级检查。
-
CPL存在于CS寄存器的低两位,即CPL是CS段描述符的DPL,是当前代码的权限级别(Current Privilege Level)。
-
RPL存在于段选择子中,说明的是进程对段访问的请求权限(Request Privilege Level),是对于段选择子而言的,每个段选择子有自己的RPL。而且RPL对每个段来说不是固定的,两次访问同一段时的RPL可以不同。RPL可能会削弱CPL的作用,例如当前CPL=0的进程要访问一个数据段,它把段选择符中的RPL设为3,这样虽然它对该段仍然只有特权为3的访问权限。
-
IOPL(I/O Privilege Level)即I/O特权标志,位于eflag寄存器中,用两位二进制位来表示,也称为I/O特权级字段。该字段指定了要求执行I/O指令的特权级。如 果当前的特权级别在数值上小于等于IOPL的值,那么,该I/O指令可执行,否则将发生一个保护异常。
只有当CPL=0时,可以改变IOPL的值,当CPL<=IOPL时,可以改变IF标志位。
2. 特权级检查
在下述的特权级比较中,需要注意特权级越低,其ring值越大。
-
访问门时(中断、陷入、异常),要求DPL[段] <= CPL <= DPL[门]
访问门的代码权限比门的特权级要高,因为这样才能访问门。
但访问门的代码权限比被访问的段的权限要低,因为通过门的目的是访问特权级更高的段,这样就可以达到低权限应用程序使用高权限内核服务的目的。
-
访问段时,要求DPL[段] >= max {CPL, RPL}
只能使用最低的权限来访问段数据。
3. 通过中断切换特权级
1) TSS
-
TSS(Task State Segment) 是操作系统在进行进程切换时保存进程现场信息的段,其结构如下
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
COPY/* task state segment format (as described by the Pentium architecture book) */ struct taskstate { uint32_t ts_link; // old ts selector uintptr_t ts_esp0; // stack pointers and segment selectors uint16_t ts_ss0; // after an increase in privilege level uint16_t ts_padding1; uintptr_t ts_esp1; uint16_t ts_ss1; uint16_t ts_padding2; uintptr_t ts_esp2; uint16_t ts_ss2; uint16_t ts_padding3; uintptr_t ts_cr3; // page directory base uintptr_t ts_eip; // saved state from last task switch uint32_t ts_eflags; uint32_t ts_eax; // more saved state (registers) uint32_t ts_ecx; uint32_t ts_edx; uint32_t ts_ebx; uintptr_t ts_esp; uintptr_t ts_ebp; uint32_t ts_esi; uint32_t ts_edi; uint16_t ts_es; // even more saved state (segment selectors) uint16_t ts_padding4; uint16_t ts_cs; uint16_t ts_padding5; uint16_t ts_ss; uint16_t ts_padding6; uint16_t ts_ds; uint16_t ts_padding7; uint16_t ts_fs; uint16_t ts_padding8; uint16_t ts_gs; uint16_t ts_padding9; uint16_t ts_ldt; uint16_t ts_padding10; uint16_t ts_t; // trap on task switch uint16_t ts_iomb; // i/o map base address };
-
这里暂时只说明特权级切换相关的项。其中,TSS中分别保留了ring0、ring1、ring2的栈(
ss
、esp
寄存器值)。当用户程序从ring3跳至ring0时(例如执行中断),此时的栈就会从用户栈切换到内核栈。切换栈的操作从开始中断的那一瞬间(例如:从int 0x78
到中断处理例程之间)就已完成。切换栈的操作为修改
esp
和ss
寄存器。 -
TSS段的段描述符保存在GDT中,其
ring0
的栈会在初始化GDT时被一起设置。TR
寄存器会保存当前TSS的段描述符,以提高索引速度。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
COPYstatic 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, }; static struct pseudodesc gdt_pd = { sizeof(gdt) - 1, (uintptr_t)gdt }; /* gdt_init - initialize the default GDT and TSS */ static void gdt_init(void) { // 设置TSS的ring0栈地址,包括esp寄存器和SS段寄存器 load_esp0((uintptr_t)bootstacktop); ts.ts_ss0 = KERNEL_DS; // 将TSS写入GDT中 gdt[SEG_TSS] = SEGTSS(STS_T32A, (uintptr_t)&ts, sizeof(ts), DPL_KERNEL); // 加载GDT至GDTR寄存器 lgdt(&gdt_pd); // 加载TSS至TR寄存器 ltr(GD_TSS); }
-
2) trapFrame
-
trapframe
结构是进入中断门所必须的结构,其结构如下1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
COPYstruct trapframe { // tf_regs保存了基本寄存器的值,包括eax,ebx,esi,edi寄存器等等 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; // 以下这些信息会被CPU硬件自动压入切换后的栈。包括下面切换特权级所使用的esp、ss等数据 uint32_t tf_err; uintptr_t tf_eip; uint16_t tf_cs; uint16_t tf_padding4; uint32_t tf_eflags; // 以下这些信息会在切换特权级时被使用 uintptr_t tf_esp; uint16_t tf_ss; uint16_t tf_padding5; } __attribute__((packed));
3) 中断处理例程的入口代码
-
中断处理例程的入口代码用于保存上下文并构建一个
trapframe
,其源代码如下:COPY #include <memlayout.h> # vectors.S sends all traps here. .text .globl __alltraps __alltraps: # push registers to build a trap frame # therefore make the stack look like a struct trapframe pushl %ds pushl %es pushl %fs pushl %gs pushal # load GD_KDATA into %ds and %es to set up data segments for kernel movl $GD_KDATA, %eax movw %ax, %ds movw %ax, %es # push %esp to pass a pointer to the trapframe as an argument to trap() pushl %esp # call trap(tf), where tf=%esp call trap # pop the pushed stack pointer popl %esp # return falls through to trapret... .globl __trapret __trapret: # restore registers from stack popal # restore %ds, %es, %fs and %gs popl %gs popl %fs popl %es popl %ds # get rid of the trap number and error code addl $0x8, %esp iret
4) 切换特权级的过程
a. 特权级提升
当通过陷入门从ring3切换至ring0(特权提升) 时
-
在陷入的一瞬间,CPU会因为特权级的改变,索引TSS,切换
ss
和esp
为内核栈,并按顺序自动压入user_ss
、user_esp
、user_eflags
、user_cs
、old_eip
以及err
。需要注意的是,CPU先切换到内核栈,此时的
esp
与ss
不再指向用户栈。但此时CPU却可以再将用户栈地址存入内核栈。这种操作可能是依赖硬件来完成的。如果没有err,则CPU会自动压入0。
-
之后CPU会在中断处理例程入口处,先将剩余的段寄存器以及所有的通用寄存器压栈,构成一个
trapframe
。然后将该trapframe
传入给真正的中断处理例程并执行。 -
该处理例程会判断传入的中断数(
trapno
)并执行特定的代码。在提升特权级的代码中,程序会处理传入的trapframe
信息中的CS、DS、eflags
寄存器,修改上面的DPL、CPL与IOPL以达到提升特权的目的。 -
将修改后的
trapframe
压入用户栈(这一步没有修改user_esp
寄存器),并设置中断处理例程结束后将要弹出esp
寄存器的值为用户栈的新地址(与刚刚不同,这一步修改了将要恢复的user_esp
寄存器)。注意此时的用户栈地址指向的是修改后的
trapframe
。这样在退出中断处理程序,准备恢复上下文的时候,首先弹出的栈寄存器值是修改后的用户栈地址,其次弹出的通用寄存器、段寄存器等等都是存储于用户栈中的
trapframe
。为什么要做这么奇怪的操作呢? 因为恢复
esp
寄存器的指令只有一条pop %esp
(当前环境下的
iret
指令不会弹出栈地址)。正常情况下,中断处理例程结束,恢复
esp
寄存器后,esp
指向的还是内核栈。但我们的目的是切换回用户栈,则此时只能修改原先要恢复的
esp
值,通过该指令切换到用户栈。思考一下,进入中断处理程序前,上下文保存在内核栈。但将要恢复回上下文的数据却存储于用户栈。
-
在内核中,
将修改后的trapframe压入用户栈
这一步,需要舍弃trapframe
中末尾两个旧的ss
和esp
寄存器数据,因为iret
指令的特殊性:-
iret
指令的功能如下iret
指令会按顺序依次弹出eip
、cs
以及eflag
的值到特定寄存器中,然后从新的cs:ip
处开始执行。如果特权级发生改变,则还会在弹出eflag
后再依次弹出esp
与ss
寄存器值。 -
由于
iret
前后特权级不发生改变([中断中]ring0 -> ring0 [中断后]),故iret
指令不会弹出esp
和ss
寄存器值。如果这两个寄存器也被复制进用户栈,则相比于进入中断前的用户栈地址,esp
最终会抬高8个字节,可能造成很严重的错误。
-
b. 特权级降低
通过陷入门从ring0切换至ring3(特权降低) 的过程与特权提升的操作基本一样,不过有几个不同点需要注意一下
-
与ring3调用中断不同,当ring0调用中断时,进入中断前和进入中断后的这个过程,栈不发生改变。
因为在调用中断前的权限已经处于ring0了,而中断处理程序里的权限也是ring0,所以这一步陷入操作的特权级没有发生改变,故不需要访问TSS并重新设置
ss
、esp
寄存器。 -
修改后的
trapFrame
不需要像上面那样保存至将要使用的栈,因为当前环境下iret
前后特权级会发生改变,执行该命令会弹出ss
和esp
,所以可以通过iret
来设置返回时的栈地址。
A20
Intel早期的8086 CPU提供了20根地址线,可寻址空间范围即0~2^20(00000H~FFFFFH)的 1MB内存空间。但8086的数据处理位宽位16位,无法直接寻址1MB内存空间,所以8086提供了段地址加偏移地址的地址转换机制。PC机的寻址结构是segment:offset,segment和offset都是16位的寄存器,最大值是0ffffh,换算成物理地址的计算方法是把segment左移4位,再加上offset,所以segment:offset所能表达的寻址空间最大应为0ffff0h + 0ffffh = 10ffefh(前面的0ffffh是segment=0ffffh并向左移动4位的结果,后面的0ffffh是可能的最大offset),这个计算出的10ffefh是多大呢?大约是1088KB,就是说,segment:offset的地址表示能力,超过了20位地址线的物理寻址能力。所以当寻址到超过1MB的内存时,会发生“回卷”(不会发生异常)。但下一代的基于Intel 80286 CPU的PC AT计算机系统提供了24根地址线,这样CPU的寻址范围变为 2^24=16M,同时也提供了保护模式,可以访问到1MB以上的内存了,此时如果遇到“寻址超过1MB”的情况,系统不会再“回卷”了,这就造成了向下不兼容。为了保持完全的向下兼容性,IBM决定在PC AT计算机系统上加个硬件逻辑,来模仿以上的回绕特征,于是出现了A20 Gate。他们的方法就是把A20地址线控制和键盘控制器的一个输出进行AND操作,这样来控制A20地址线的打开(使能)和关闭(屏蔽\禁止)。一开始时A20地址线控制是被屏蔽的(总为0),直到系统软件通过一定的IO操作去打开它(参看bootasm.S)。很显然,在实模式下要访问高端内存区,这个开关必须打开,在保护模式下,由于使用32位地址线,如果A20恒等于0,那么系统只能访问奇数兆的内存,即只能访问0–1M、2-3M、4-5M……,这样无法有效访问所有可用内存。所以在保护模式下,这个开关也必须打开。
在保护模式下,为了使能所有地址位的寻址能力,需要打开A20地址线控制,即需要通过向键盘控制器8042发送一个命令来完成。键盘控制器8042将会将它的的某个输出引脚的输出置高电平,作为 A20 地址线控制的输入。一旦设置成功之后,内存将不会再被绕回(memory wrapping),这样我们就可以寻址整个 286 的 16M 内存,或者是寻址 80386级别机器的所有 4G 内存了。
键盘控制器8042的逻辑结构图如下所示。从软件的角度来看,如何控制8042呢?早期的PC机,控制键盘有一个单独的单片机8042,现如今这个芯片已经给集成到了其它大片子中,但其功能和使用方法还是一样,当PC机刚刚出现A20 Gate的时候,估计为节省硬件设计成本,工程师使用这个8042键盘控制器来控制A20 Gate,但A20 Gate与键盘管理没有一点关系。下面先从软件的角度简单介绍一下8042这个芯片。
图13 键盘控制器8042的逻辑结构图
8042键盘控制器的IO端口是0x60~0x6f,实际上IBM PC/AT使用的只有0x60和0x64两个端口(0x61、0x62和0x63用于与XT兼容目的)。8042通过这些端口给键盘控制器或键盘发送命令或读取状态。输出端口P2用于特定目的。位0(P20引脚)用于实现CPU复位操作,位1(P21引脚)用户控制A20信号线的开启与否。系统向输入缓冲(端口0x64)写入一个字节,即发送一个键盘控制器命令。可以带一个参数。参数是通过0x60端口发送的。 命令的返回值也从端口 0x60去读。8042有4个寄存器:
- 1个8-bit长的Input buffer;Write-Only;
- 1个8-bit长的Output buffer; Read-Only;
- 1个8-bit长的Status Register;Read-Only;
- 1个8-bit长的Control Register;Read/Write。
有两个端口地址:60h和64h,有关对它们的读写操作描述如下:
- 读60h端口,读output buffer
- 写60h端口,写input buffer
- 读64h端口,读Status Register
- 操作Control Register,首先要向64h端口写一个命令(20h为读命令,60h为写命令),然后根据命令从60h端口读出Control Register的数据或者向60h端口写入Control Register的数据(64h端口还可以接受许多其它的命令)。
Status Register的定义(要用bit 0和bit 1):
bit | meaning |
---|---|
0 | output register (60h) 中有数据 |
1 | input register (60h/64h) 有数据 |
2 | 系统标志(上电复位后被置为0) |
3 | data in input register is command (1) or data (0) |
4 | 1=keyboard enabled, 0=keyboard disabled (via switch) |
5 | 1=transmit timeout (data transmit not complete) |
6 | 1=receive timeout (data transmit not complete) |
7 | 1=even parity rec’d, 0=odd parity rec’d (should be odd) |
除了这些资源外,8042还有3个内部端口:Input Port、Outport Port和Test Port,这三个端口的操作都是通过向64h发送命令,然后在60h进行读写的方式完成,其中本文要操作的A20 Gate被定义在Output Port的bit 1上,所以有必要对Outport Port的操作及端口定义做一个说明。
- 读Output Port:向64h发送0d0h命令,然后从60h读取Output Port的内容
- 写Output Port:向64h发送0d1h命令,然后向60h写入Output Port的数据
- 禁止键盘操作命令:向64h发送0adh
- 打开键盘操作命令:向64h发送0aeh
有了这些命令和知识,就可以实现操作A20 Gate来从实模式切换到保护模式了。 理论上讲,我们只要操作8042芯片的输出端口(64h)的bit 1,就可以控制A20 Gate,但实际上,当你准备向8042的输入缓冲区里写数据时,可能里面还有其它数据没有处理,所以,我们要首先禁止键盘操作,同时等待数据缓冲区中没有数据以后,才能真正地去操作8042打开或者关闭A20 Gate。打开A20 Gate的具体步骤大致如下(参考bootasm.S):
- 等待8042 Input buffer为空;
- 发送Write 8042 Output Port (P2)命令到8042 Input buffer;
- 等待8042 Input buffer为空;
- 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer;
对比参考答案
练习1-4的参考答案与本实验报告中的解答基本一致,为数不多的几点区别如下所示:
- 在练习1中本实验报告中较为详细的分析了Makefile中每一条与生成ucore.img的指令,包括tools/function.mk中使用宏来生成编译规则的内容,而参考答案中仅按照Makefile单个文件中按照每一个阶段对编译过程进行了分析,而没有涉及到生成编译规则的内容,该内容也恰是该Makefile文件中最不容易看懂的部分;
- 在练习2的调试中,参考答案将gdb调试结果输出到log文件中,而本实验中的解答也是将调试结果输出到log文件中,但是调试的方法有点不同;
练习5的参考解答与本实验报告中的解答一致,因为在注释中的信息已经给的十分详细了,均是利用栈上保存的动态链信息(ebp)来查找栈上所有栈帧的内容;
练习6的参考答案解法与本实验报告解法一致;
challenge 1的实现方法也基本和参考答案一致
challenge 2,所提供参考答案没有关于challenge 2的解答,因此无法进行分析对比,但我相信思维过程都是差不多的
对应到的OS原理中的知识点分别有:
- 物理内存的管理;
- 外存的访问;
- OS的启动过程;
- OS中对中断机制的支持;
- OS使用保护机制对关键代码和数据的保护;
上面已经详细描述具体过程了,这里就不再赘述
两者的关系为,前者硬件中的机制为OS中相应功能的实现提供了底层支持
OS原理中很重要,实验中没有的
注意,如果仅谈操作系统的启动过程,实验真的已经很详细了,书上根本就没有仔细说操作系统是怎么启动的(至少我们用的教材是这样的)
- 操作系统的线程、进程管理、调度,以及进程间的共享互斥;
- 虚拟内存的管理,页式内存管理机制;
- 文件系统;
这里就不一一列举具体是怎么样的了,在后面的实验都会涉及到的