Loading

MIT6.828-LAB1 : PC启动

Lab1

1. 先熟悉PC的物理地址空间

image-20210620120250345

这里其实有很多可以说的,不过先简单描述一下吧。从0x00000000到0x00100000这1mb的地址空间时机器处于16位的实模式。也就是说这个时候机器的汇编都是16位汇编。这是为了兼容之前的8086处理器。在这1mb里面。有我们常见的bios,这里要做的就是进行一些开机前的检查,随后把内核读取进来,就算开机完成了

2. 追踪ROM BIOS

这里要求我们利用断点跟随一下bios的过程,看一下bios干了什么

这里的调试要利用到两个终端,一个执行make qemu-gdb 另一个执行make gdb

image-20210620123141845

你会看到gdb会停在这个界面。这里的停在的地址是oxfff0这是通过

oxfooo << 4 + oxfff0 得到的
$cs = oxf000
$pc = 0xfff0

image-20210620124623032

这样计算地址的方法是还是因为当前在是模式。所以寻址方式是 $cs << 4 + $pc

接下来就到了bios的执行时间。它大概会做下面的事情

首先bios会初始化一些中断向量表,然后会初始化一些重要设备比如vga等等,然后开机提示信息就回现实(如windows常见的loading图)在初始化PCI总线和一些重要设备之后,它搜索可引导设备,如软盘,硬盘驱动器或CD-ROM。 最终,当它找到可启动磁盘时,BIOS将引导加载程序从磁盘读取。随后转移到引导启动程序上去

3. The Boot Loader

于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区。一个扇区是一次磁盘操作的最小粒度。每一次读取或者写入操作都必须是一个或多个扇区。如果一个磁盘是可以被用来启动操作系统的,就把这个磁盘的第一个扇区叫做启动扇区。当BIOS找到一个可以启动的软盘或硬盘后,它就会把这512字节的启动扇区加载到内存地址0x7c00~0x7dff这个区域内。

 对于6.828,我们将采用传统的硬盘启动机制,这就意味着我们的boot loader程序的大小必须小于512字节。整个boot loader是由一个汇编文件,boot/boot.S,以及一个C语言文件,boot/main.c组成。Boot loader必须完成两个主要的功能。

  1. 首先,boot loader要把处理器从实模式转换为32bit的保护模式,因为只有在这种模式下软件可以访问超过1MB空间的内容。
  2. 然后,boot loader可以通过使用x86特定的IO指令,直接访问IDE磁盘设备寄存器,从磁盘中读取内核。

对于boot loader来说,有一个文件很重要,obj/boot/boot.asm。这个文件是我们真实运行的boot loader程序的反汇编版本。所以我们可以把它和它的源代码即boot.S以及main.c比较一下。

好下面就去0x7c00这个地址看一下这个启动扇区都做了什么

我们依次来分析一下boot.S的汇编代码

 /boot/boot.S:

1 .globl start
2 start:
3   .code16                # Assemble for 16-bit mode
4   cli                    # Disable interrupts

  这几条指令就是boot.S最开始的几句,其中cli是boot.S,也是boot loader的第一条指令。这条指令用于把所有的中断都关闭。因为在BIOS运行期间有可能打开了中断。此时CPU工作在实模式下。

5  cld                         # String operations increment

  这条指令用于指定之后发生的串处理操作的指针移动方向。在这里现在对它大致了解就够了。

6  # Set up the important data segment registers (DS, ES, SS).
7  xorw    %ax,%ax             # Segment number zero
8  movw    %ax,%ds             # -> Data Segment
9  movw    %ax,%es             # -> Extra Segment
10 movw    %ax,%ss             # -> Stack Segment

  这几条命令主要是在把三个段寄存器,ds,es,ss全部清零,因为经历了BIOS,操作系统不能保证这三个寄存器中存放的是什么数。所以这也是为后面进入保护模式做准备。

11  # Enable A20:
12  #   For backwards compatibility with the earliest PCs, physical
13  #   address line 20 is tied low, so that addresses higher than
14  #   1MB wrap around to zero by default.  This code undoes this.
15 seta20.1:
16  inb     $0x64,%al               # Wait for not busy
17  testb   $0x2,%al
18  jnz     seta20.1

19  movb    $0xd1,%al               # 0xd1 -> port 0x64
20  outb    %al,$0x64

21 seta20.2:
22  inb     $0x64,%al               # Wait for not busy
23  testb   $0x2,%al
24  jnz     seta20.2

25  movb    $0xdf,%al               # 0xdf -> port 0x60
26  outb    %al,$0x60

这部分指令就是在准备把CPU的工作模式从实模式转换为保护模式。我们可以看到其中的指令包括inb,outb这样的IO端口命令。所以这些指令都是在对外部设备进行操作。

接下来还是会做一些在进入保护模式之前的准备

27   # Switch from real to protected mode, using a bootstrap GDT
28   # and segment translation that makes virtual addresses 
29   # identical to their physical addresses, so that the 
30   # effective memory map does not change during the switch.
31   lgdt    gdtdesc # 把关于GDT表的一些信息存放到CPU的GDTR寄存器中(包括起始地址+长度
32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

这部分把gdtdesc送入全局映射描述符表寄存器GDTR中。GDT表是处理器工作于实模式下一个非常重要的表。这里的gdtdesc表示了一个标识符,标识这一个内存地址。从这个内存地址开始之后的6个字节分别存放着GDT表的长度和起始地址。

 1 # Bootstrap GDT
 2 .p2align 2                               # force 4 byte alignment
 3 gdt:
 4   SEG_NULL                               # null seg
 5   SEG(STA_X|STA_R, 0x0, 0xffffffff)      # code seg
 6   SEG(STA_W, 0x0, 0xffffffff)            # data seg
 7 
 8 gdtdesc:
 9   .word   0x17                           # sizeof(gdt) - 1
10   .long   gdt                            # address gdt

其中第3行的gdt是一个标识符,标识从这里开始就是GDT表了。可见这个GDT表中包括三个表项(4,5,6行),分别代表三个段,null seg,code seg,data seg。由于xv6其实并没有使用分段机制,也就是说数据和代码都是写在一起的,所以数据段和代码段的起始地址都是0x0,大小都是0xffffffff=4GB

在第4~6行是调用SEG()子程序来构造GDT表项的。这个子函数定义mmu.h中,形式如下:  

 #define SEG(type,base,lim)                    \
                    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);    \
                    .byte (((base) >> 16) & 0xff), (0x90 | (type)),        \
                    (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

gdb表中的每一个表项的结构如下所示

struct gdt_entry_struct {
	limit_low: resb 2
	base_low: resb 2
	base_middle: resb 1
	access: resb 1
	granularity: resb 1
	base_high: resb1
} endstruc

这个表项一共8字节,其中limit_low就是limit的低16位。base_low就是base的低16位,依次类推。

在gdtdesc处就要存放这个GDT表的信息了,其中0x17是这个表的大小-1 = 0x17 = 23,至于为什么不直接存表的大小24,根据查询是官方规定的。紧接着就是这个表的起始地址gdt。

在load完gdt表之后下面的操作就是进入保护模式之前的最后操作了

32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

这里就是在修改CRO寄存器的值,其中CRO寄存器的bit0是保护模式启动位,把这一位设置成1代表保护模式启动。

35  ljmp    $PROT_MODE_CSEG, $protcseg

这里的跳转就表示跳转到保护模式。在保护模式就变成了32位地址模式

protcseg:
  # Set up the protected-mode data segment registers
36  movw    $PROT_MODE_DSEG, %ax    # Our data segment selector
37  movw    %ax, %ds                # -> DS: Data Segment
38  movw    %ax, %es                # -> ES: Extra Segment
39  movw    %ax, %fs                # -> FS
40  movw    %ax, %gs                # -> GS
41  movw    %ax, %ss                # -> SS: Stack Segment

因为规定我们在加载完GDTR寄存器之后必须要重新加载所有的段寄存器。因此下面这些代码就是在加载段寄存器

随后我们就要为跳转到main.c文件中的bootmain函数做准备(因为boot.S的最后一条指令就是call bootmain)

跳转到main.c文件

在main.c文件做的第一件事就是把内核的第一个页读取到内存地址0x10000处。其实第一个页就是操作系统映射文件到elf。读取完内核的elf文件。关于elf文件的解释首先会通过魔数来判断一下这个内核是否合理。对应下面的代码

	// read 1st page off disk
	readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

	// is this a valid ELF?
	if (ELFHDR->e_magic != ELF_MAGIC)
		goto bad;

在elf文件中包含Program Header Table。这个表格存放着程序中所有段的信息。通过这个表我们才能找到要执行的代码段,数据段等等。所以我们要先获得这个表。

这条指令就可以完成这一点,首先elf表示elf表的起址,而phoff字段代表Program Header Table距离表头的偏移量。所以ph可以被指定为Program Header Table表头。

ph = (struct *Proghdr* *) ((*uint8_t* *) ELFHDR + ELFHDR->e_phoff);

下面的代码非常重要

	eph = ph + ELFHDR->e_phnum;
	for (; ph < eph; ph++)
		// p_pa is the load address of this segment (as well
		// as the physical address)
		readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

这里的eph表示一共有多少段。这段代码就是逐段把操作系统内核从硬盘中读到内存中

而后同样通过ELFHEADER的

 ((void (*)(void)) (ELFHDR->e_entry))();

  e_entry字段指向的是这个文件的执行入口地址。所以这里相当于开始运行这个文件。也就是内核文件。 自此就把控制权从boot loader转交给了操作系统的内核。

4. The Kernel

对实验指导内容的一些翻译

  在运行boot loader时,boot loader中的链接地址(虚拟地址)和加载地址(物理地址)是一样的。但是当进入到内核程序后,这两种地址就不再相同了。

  操作系统内核程序在虚拟地址空间通常会被链接到一个非常高的虚拟地址空间处,比如0xf0100000,目的就是能够让处理器的虚拟地址空间的低地址部分能够被用户利用来进行编程。

  但是许多的机器其实并没有能够支持0xf0100000这种地址那么大的物理内存,所以我们不能把内核的0xf0100000虚拟地址映射到物理地址0xf0100000的存储单元处。

  这就造成了一个问题,在我们编程时,我们应该把操作系统放在高地址处,但是在实际的计算机内存中却没有那么高的地址,这该怎么办?

  解决方案就是在虚拟地址空间中,我们还是把操作系统放在高地址处0xf0100000,但是在实际的内存中我们把操作系统存放在一个低的物理地址空间处,如0x00100000。那么当用户程序想访问一个操作系统内核的指令时,首先给出的是一个高的虚拟地址,然后计算机中通过某个机构把这个虚拟地址映射为真实的物理地址,这样就解决了上述的问题。那么这种机构通常是通过分段管理,分页管理来实现的。

  在这个实验中,首先是采用分页管理的方法来实现上面所讲述的地址映射。但是设计者实现映射的方式并不是通常计算机所采用的分页管理机构,而是自己手写了一个程序lab\kern\entrygdir.c用于进行映射。既然是手写的,所以它的功能就很有限了,只能够把虚拟地址空间的地址范围:0xf0000000 - 0xf0400000,映射到物理地址范围:0x00000000 - 0x00400000上面。也可以把虚拟地址范围:0x00000000 - 0x00400000,同样映射到物理地址范围:0x00000000~0x00400000上面。任何不再这两个虚拟地址范围内的地址都会引起一个硬件异常。虽然只能映射这两块很小的空间,但是已经足够刚启动程序的时候来使用了。

4.1 Exercise 7

问题1:使用Qemu和GDB去追踪JOS内核文件,并且停止在movl %eax, %cr0指令前。此时看一下内存地址0x00100000以及0xf0100000处分别存放着什么。然后使用stepi命令执行完这条命令,再次检查这两个地址处的内容。确保你真的理解了发生了什么。

问题2: 如果这条指令movl %eax, %cr0并没有执行,而是被跳过,那么第一个会出现问题的指令是什么?我们可以通过把entry.S的这条语句加上注释来验证一下。

对于第一个问题。其实只要在0x100000C这个地方打一个断点(我们前面其实知道这个地址就是内核的入口地址

image-20210620161032107

然后通过断点看一下就可以。发现这个时候还是不一样的

image-20210620161133093

我们这个时候发现就变成了一样的。说明这个时候已经完成了从物理地址到虚拟地址到映射

第二个问题的答案显然就是会出现段错误。因为这一行代码注释之后,就没有办法开启虚拟地址了。不得不说这样的实验设计蛮棒的

4.2 Exercise 8

这里要在/lib/printfmt.c这个下做一些改动

搞明白print.c的调用链cprintf -> vcprintf -> vprintfmt -> putch -> cputchar

// (unsigned) octal
		case 'o':
			// Replace this with your code.
			// putch('X', putdat);
			// putch('X', putdat);
			// putch('X', putdat);
			num = getuint(&ap,lflag);
			base = 8;
			goto number;
			break;

4.3 Exercise9

判断一下操作系统内核是从哪条指令开始初始化它的堆栈空间的,以及这个堆栈坐落在内存的哪个地方?内核是如何给它的堆栈保留一块内存空间的?堆栈指针又是指向这块被保留的区域的哪一端的呢?

前面有分析到main.c的最后一行代码是要进入Entry.S.所以直接进入entry.s中

从注释里面可以看到堆栈指针应该是在这两行设置的

# Clear the frame pointer register (EBP)
	# so that once we get into debugging C code,
	# stack backtraces will be terminated properly.
	movl	$0x0,%ebp			# nuke frame pointer

	# Set the stack pointer
	movl	$(bootstacktop),%esp

image-20210620164140395

这里通过断点找到这两行到底在干什么。这里的esp寄存器就是栈指针寄存器。而ebp寄存器是栈帧的基地址指针

这里把0xf0110000赋给esp这个寄存器。就表示我们的栈是从这个地址开始。那么他的大小为多少那

bootstack:
	.space		KSTKSIZE
	.globl		bootstacktop   

这几行代码就为他制定了大小大小为32kb。因此整个栈的地址区间就为 0xf0108000-0xf0110000的范围。

4.4 Exercise 10

  为了能够更好的了解在x86上的C程序调用过程的细节,我们首先找到在obj/kern/kern.asm中test_backtrace子程序的地址,设置断点,并且探讨一下在内核启动后,这个程序被调用时发生了什么。对于这个循环嵌套调用的程序test_backtrace,它一共压入了多少信息到堆栈之中。并且它们都代表什么含义?

好下面就开始看源码和打断点

先看c语言的代码然后再分析汇编代码

voidtest_backtrace(int x){    cprintf("entering test_backtrace %d\n", x);    if (x > 0)        test_backtrace(x-1);    else        mon_backtrace(0, 0, 0);    cprintf("leaving test_backtrace %d\n", x);}

可以发现这里是一个递归调用的过程,输入x的表示调用次数

好下面切换到汇编语言。先看一下在函数执行之前的栈指针的一些信息。可以发现这里两个寄存器的值和我们在调用i386_init之前一摸一样。

image-20210620171256439

当第一次进入这个函数的时候x=5.这表示我们要执行这个代码五次

而这个代码也就是一个简单的调用。由于下一个问题就是要实现对于调用的trace函数。所以我们在下面进行讲解

4.5 Exercise 11

    实现backtrace子程序。来进行堆栈的回溯

  这个函数应该能够展示出下面这种格式的信息:

  Stack backtrace:

 ebp f0109358 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031

​ ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061

  这个子程序的功能就是要显示当前正在执行的程序的栈帧信息。包括当前的ebp寄存器的值,这个寄存器的值代表该子程序的栈帧的最高地址。eip则指的是这个子程序执行完成之后要返回调用它的子程序时,下一个要执行的指令地址。后面的值就是这个子程序接受的来自调用它的子程序传递给它的输入参数。下面这张图对栈帧做了很好的解释

  img

根据上图我们可以很轻松的获取我们想要的参数。

返回地址 ebp+ 4

参数1 ebp + 8

...........

所以综上所述,只要我们知道当前运行程序的ebp寄存器的值就可以,之后至于其他的我们都可以根据ebp寄存器的值推导出来。

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	cprintf("Start backtrace\n");
	uint32_t ebp = read_ebp();
	while(ebp){
		uint32_t *stack_frame = (uint32_t *)(ebp);
		cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
				ebp,		 /*ebp*/
				stack_frame[1],   /*eip*/
				stack_frame[2],   /*arg1*/
				stack_frame[3],  /*arg2*/
				stack_frame[4],  /*arg3*/
				stack_frame[5],  /*arg4*/
				stack_frame[6]); /*arg5*/
		ebp = stack_frame[0];
	}
	return 0;
}

这里的代码看起来非常简单。但还是需要有理解的。首先这里的ebp获取到的是一个指针。因此我们想要获得到ebp的值的话。需要通过数组访问,或者直接取值操作。同时需要理解一个非常重要的点。就是下面这几行汇编代码

f0100044:	55                   	push   %ebp
f0100045:	89 e5                	mov    %esp,%ebp
f0100047:	56                   	push   %esi
f0100048:	53                   	push   %ebx

这里是test_backtrace递归的开始。这里每次都先把ebp入栈。然后再把esp的值赋给ebp。也就是说下一次调用的时候。它入栈的ebp寄存器里就存储了上一次的esp指针。通过这个就可以找到每一次调用的栈帧的起始地址分别在哪里。这里如果看过csapp第三章的话,这个应该非常好理解

4.6 Exercise 12

这次我们需要修改stack backtrace函数,让它显示每一个eip, func_name, source_file_name, line_number。为了帮助实现这些功能,在kern/kdebug.c中已经实现了一个函数debuginfo_eip(),这个函数能够查找eip的符号表然后返回关于该地址的debug信息。

这里要修改我们之前的mon_backtrace函数。其实改动也不大只需要把debuginfo_eip加进去就好了

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
	// Your code here.
	cprintf("Start backtrace\n");
	uint32_t ebp = read_ebp();
	struct Eipdebuginfo info;
	while(ebp){
		uint32_t *stack_frame = (uint32_t *)(ebp);
		cprintf("ebp %08x  eip %08x  args %08x %08x %08x %08x %08x\n",
				ebp,		 /*ebp*/
				stack_frame[1],   /*eip*/
				stack_frame[2],   /*arg1*/
				stack_frame[3],  /*arg2*/
				stack_frame[4],  /*arg3*/
				stack_frame[5],  /*arg4*/
				stack_frame[6]); /*arg5*/
		uint32_t eip = stack_frame[1];
		debuginfo_eip(eip,&info);
		cprintf("     %s:%d: %.*s+%d\n", info.eip_file, info.eip_line,
				info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);
	
		ebp = stack_frame[0];
	}
	return 0;
}

这样就可以过掉所有的test了

image-20210620205514098

就可以拿到满分了over

5. 总结

总的来说lab1对于os的初学者应该还是蛮难吧。我个人感觉还可以,因为真的有太多的参考资料了,英文资料懒得看直接去看别人的翻译还有博客等等,当然代码还都是自己写的了(不过一共也没几行代码的说)因此这里的不过有一些回答我没写到博客上,因为网上资料还是超多的。希望lab2好运嘿嘿

posted @ 2021-06-20 21:13  周小伦  阅读(2671)  评论(1编辑  收藏  举报