前置知识 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 + 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 ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/init.o obj/kern/libs/stdio.o obj/kern/libs/readline.o obj/kern/debug/panic.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/picirq.o obj/kern/driver/intr.o obj/kern/trap /trap.o obj/kern/trap /vectors.o obj/kern/trap /trapentry.o obj/kern/mm/pmm.o obj/libs/string.o obj/libs/printfmt.o + cc boot/bootasm.S gcc -Iboot/ -fno-builtin -fno-PIC -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o + 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 + 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: 500 bytesbuild 512 bytes boot sector: 'bin/bootblock' success! dd if =/dev/zero of=bin/ucore.img count=10000 10000+0 records in 10000+0 records out 5120000 bytes (5.1 MB, 4.9 MiB) copied, 0.0307689 s, 166 MB/s 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 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
。
ucore
的 MBR
十分简单,含 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 $(V)$(TERMINAL) -e "$(QEMU) -S -s -d in_asm -D $(BINDIR) /q.log -monitor stdio -hda $< -serial null" $(V)sleep 2 $(V)$(TERMINAL) -e "gdb -q -x tools/gdbinit"
其中 gdbinit
内容如下 (相较于源代码,此部分已经修改):
1 2 3 4 5 6 7 file bin/kernel set architecture i8086target remote :1234
在命令行目录 labcodes/lab1
中,输入命令 make debug
,开始进行调试。
查看 CS:IP 取值及此处指令:
1 2 3 4 5 6 7 8 9 10 (gdb) p/x $cs $2 = 0xf000(gdb) p/x $eip $3 = 0xfff0(gdb) x /i 0xffff0 0xffff0: ljmp $0x3630 ,$0xf000e05b
简要叙述 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.S
是 bootloader
的一部分,其主要完成三件事:1. 开启 A20;2. 初始化 GDT 表;3. 开启保护模式。
对于 intel x86 机器而言,其地址线 32 位 (物理寻址空间 2^32 = 4G),普通寄存器 32 位,段寄存器仍为 16 位 (兼容以往机器)。为访问全部的地址空间且基于分段提供保护功能,段寄存器此时存放 GPT 索引表位置及相关保护位,其中 GPT 表项存放段初始位置 (32 位)、段长、访问权限等信息,此时实际物理地址 = (段索引表示的段初始位置 + IP)。鉴于此种地址访问方式间接访问物理地址,因此其称为 保护模式 。(在我看来,分段机制基本没用,不如使用分页机制,而且现今 intel 采用扁平模式已经略过分段机制了)
bootasm.S
的具体实现,详见源代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 .set PROT_MODE_CSEG, 0x8 .set PROT_MODE_DSEG, 0x10 .set CR0_PE_ON, 0x1 .globl start start: .code16 cli cld xorw %ax, %ax movw %ax, %ds movw %ax, %es movw %ax, %ss seta20.1: inb $0x64 , %al testb $0x2 , %al jnz seta20.1 movb $0xd1 , %al outb %al, $0x64 seta20.2: inb $0x64 , %al testb $0x2 , %al jnz seta20.2 movb $0xdf , %al outb %al, $0x60 lgdt gdtdesc movl %cr0, %eax orl $CR0_PE_ON , %eax movl %eax, %cr0 ljmp $PROT_MODE_CSEG , $protcseg .code32 protcseg: 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 call bootmain spin: jmp spin .p2align 2 gdt: SEG_NULLASM SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) SEG_ASM(STA_W, 0x0, 0xffffffff) gdtdesc: .word 0x17 .long gdt
对于现有的操作系统而言,Bootloader
的实现代码往往很多,MBR 内部根本无法容纳全部的 Bootloader
。因此,其实现往往是这样的:MBR 内部的 Bootloader
代码用于定位、加载、运行另外一处位置的代码,而该部分代码为 Boot Loader 的实际实现代码。该部分代码通常包含一些通用的文件系统驱动程序,从而保证可以加载 OS。
练习四 此练习用于了解 bootmain.c
代码所做的工作。
bootmain.c
是 bootloader
的另一部分,其主要完成 OS 的加载。
bootmain.c
的具体实现,详见源代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 #include <defs.h> #include <x86.h> #include <elf.h> #define SECTSIZE 512 #define ELFHDR ((struct elfhdr *)0x10000) static void waitdisk (void ) { while ((inb(0x1F7 ) & 0xC0 ) != 0x40 ) ; } static void readsect (void *dst, uint32_t secno) { waitdisk(); outb(0x1F2 , 1 ); outb(0x1F3 , secno & 0xFF ); outb(0x1F4 , (secno >> 8 ) & 0xFF ); outb(0x1F5 , (secno >> 16 ) & 0xFF ); outb(0x1F6 , ((secno >> 24 ) & 0xF ) | 0xE0 ); outb(0x1F7 , 0x20 ); waitdisk(); insl(0x1F0 , dst, SECTSIZE / 4 ); } static void readseg (uintptr_t va, uint32_t count, uint32_t offset) { uintptr_t end_va = va + count; va -= offset % SECTSIZE; uint32_t secno = (offset / SECTSIZE) + 1 ; for (; va < end_va; va += SECTSIZE, secno ++) { readsect((void *)va, secno); } } void bootmain (void ) { readseg((uintptr_t )ELFHDR, SECTSIZE * 8 , 0 ); if (ELFHDR->e_magic != ELF_MAGIC) { goto bad; } struct proghdr *ph , *eph ; 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); } ((void (*)(void ))(ELFHDR->e_entry & 0xFFFFFF ))(); bad: outw(0x8A00 , 0x8A00 ); outw(0x8A00 , 0x8E00 ); while (1 ); }
练习五 此练习需要了解函数调用过程的底层原理,并基于此实现 print_stackframe()
函数。
函数调用过程时栈的组织结构可简化为下图:
1 2 3 4 5 6 7 8 9 +| 栈底方向 | 高位地址 | ... | | ... | | 参数3 | | 参数2 | | 参数1 | | 返回地址 | | 上一层[ebp] | <-------- [ebp] | 局部变量 | 低位地址
基于寄存器 ebp
取值,我们可以很容易地得到如下内容:当前函数的参数、父函数的返回地址、父函数的 ebp
取值。基于父函数的 ebp
取值,我们可以递归得到祖先函数的相关信息。
基于上述知识,可以很容易实现 print_stackframe()
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void print_stackframe (void ) { uint32_t ebp = read_ebp(); uint32_t eip = read_eip(); for (int i = 0 ; ebp != 0 && i < STACKFRAME_DEPTH; i++) { uint32_t * args = (uint32_t *)(ebp + 8 ); cprintf("ebp:0x%08x eip:0x%08x args:0x%08x 0x%08x 0x%08x 0x%08x" , ebp, eip, args[0 ], args[1 ], args[2 ], args[3 ]); cprintf("\n" ); print_debuginfo(eip - 1 ); eip = ((uint32_t *)ebp)[1 ]; ebp = ((uint32_t *)ebp)[0 ]; } }
练习六 此练习需要了解中断向量表及中断处理流程,从而实现中断初始化函数 idt_init()
及 trap_dispatch()
中的时钟中断处理。
首先简单介绍 labcodes/lab1/kern/init/init.c
文件内 kern_init()
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 int kern_init (void ) { extern char edata[], end[]; memset (edata, 0 , end - edata); cons_init(); const char *message = "(THU.CST) os is loading ..." ; cprintf("%s\n\n" , message); print_kerninfo(); grade_backtrace(); pmm_init(); pic_init(); idt_init(); clock_init(); intr_enable(); 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 };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, }; 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)) ; asm volatile ("ljmp %0, $1f\n 1:\n" :: "i" (KERNEL_CS)) ; } uint8_t stack0[1024 ];static void gdt_init (void ) { ts.ts_esp0 = (uint32_t )&stack0 + sizeof (stack0); ts.ts_ss0 = KERNEL_DS; gdt[SEG_TSS] = SEG16(STS_T32A, (uint32_t )&ts, sizeof (ts), DPL_KERNEL); gdt[SEG_TSS].sd_s = 0 ; lgdt(&gdt_pd); ltr(GD_TSS); } void pmm_init (void ) { 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 static struct gatedesc idt [256] = {{0 }};static struct pseudodesc idt_pd = { sizeof (idt) - 1 , (uintptr_t )idt }; void idt_init (void ) { extern uintptr_t __vectors[]; for (int i = 0 ; i < sizeof (idt) / sizeof (struct gatedesc); i++) { SETGATE(idt[i], 0 , GD_KTEXT, __vectors[i], DPL_KERNEL) } SETGATE(idt[T_SWITCH_TOK], 1 , GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER); lidt(&idt_pd); }
简单分析代码,我们可以得到中断处理流程:CPU 接收中断号 i –> 调用 idt[i] 进行处理 –> 调用 __alltraps()
进行处理 –> 调用 trap()
进行处理 –> 调用 trap_dispatch()
进行处理 –> 依据中断号,执行相应功能,并返回。
在 trap_dispatch()
内部,针对时钟中断进行如下处理:
1 2 3 4 5 6 7 8 case IRQ_OFFSET + IRQ_TIMER: ticks++; if (ticks % TICK_NUM == 0 ) { print_ticks(); } break ;
对于机器而言,部分硬件的中断号是固定的,部分硬件的中断号是动态分配的,此处的时钟中断号便是固定的。
扩展练习 该练习需要理解 intel 机器处理中断的具体流程,从而实现用户空间与内核空间的自主切换。
首先简要说明 intel 的硬件处理机制:
1 2 3 4 5 6 7 8 9 10 11 - 每执行完一条指令,CPU 都会判断是否存在待处理的中断,如果存在,则先行获取中断号。 - 如果中断号所示的中断描述符权限 DPL >= CPL,则表明当前权限高于该中断的允许使用权限,故而可执行此次中断,否则为非法。 - 如果中断号所示的段选择子的段描述符权限 DPL < CPL, 则表明当前权限低于对应中断处理程序所需的权限,因此存在特权级转换。 - 如果存在特权级转换,则 CPU 会获取 TSS 中的 ss0/esp0,并将其设置给相应的寄存器,同时压入当前进程的 ss/esp 至内核栈。 +++++ - CPU 保存 cs/eip/eflags 等寄存器至内核栈。 +++++ - CPU 保存设置该中断号对应的中断处理程序的 cs/eip 至相应寄存器,从而开始执行中断处理程序。 +++++ - 中断处理程序首先保存 ds/es/fs/gs/各种通用寄存器至内核栈 (如果中断处理程序不会使用它们,则可不保存),然后真正执行中断处理程序。 ----- - 中断处理程序即将完成时,其会自动恢复上述保存的各种寄存器。 ----- - CPU 执行 iret 指令,内核栈中弹出 cs/eip/eflags 等寄存器。 +++++ - 如果存在特权级转换,则 CPU 保存当前 ss/esp 至 TSS,然后从内核栈中弹出 ss/esp。 +++++
其次,我们简单看看源代码与上述机制间的联系:
接下来,我们准备实现该练习。
在 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 ) { asm volatile ( "sub $0x8, %%esp \n" "int %0 \n" "movl %%ebp, %%esp" : : "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_TOU
和 T_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; 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; break ;
特权级 因为 intel 的特权级概念十分重要 (借助于此,可实现部分的保护机制),因此在此简单介绍一番。
对于 intel 而言,特权级分为四个等级,使用 2 bit 进行表示,具体如图所示。实际之中,往往仅使用等级 0 (用于内核使用) 和等级 3 (用于用户使用)。
特权级具体表现在各种段寄存器、段描述符、门描述符之内,使用 CPL/RPL/DPL
进行表示,简要如图所示:
对于代码段/栈段选择子而言,其中 RPL 表示当前代码/进程所处的特权级,也称为 CPL;对于各种数据段选择子而言,其中 RPL 表示当前代码/进程访问该段内容时的请求权限,基于此可以提供更为细致的保护机制;对于各种描述符而言,其中 DPL 表示访问此部分内容所需的权限。
现在,基于上述知识,简单说明 intel 提供的保护机制。
如果当前进程欲访问某数据段,则硬件判断 Max(CPL,RPL) <= 数据段的 DPL
,如果满足此等式,则允许执行此指令,否则存在权限冲突,直接报错。
如果当前进程欲访问某系统服务,则硬件判断 CPL <= 门描述符的 DPL
,如果满足此等式,则允许访问系统服务,否则表示不允许。另外,如果满足 CPL > 门描述符的段选择子对应的段描述符的 DPL
,则表明存在特权级转换。