xv6启动源码阅读


最近在学习MIT的6.828操作系统课程,课程地址:

https://pdos.csail.mit.edu/6.828/2016/ 。6.828的课程自带了一个简单的基于unix的操作系统。打算写几篇阅读这个操作系统源码的文章。这是第一篇,主要讲解启动时候的主要操作。


一 bootloader的汇编代码部分

       我们知道,在计算机启动的时候,首先执行的代码是主板上的BIOS(Basic Input Output System).BIOS所做的主要是一些硬件自检的工作。在这些工作做完之后,它会从启动盘里读取第一扇区的512字节数据到内存中,这512字节实际上就是我们熟知的bootloader.在导入完之后,BIOS就会把CPU的控制权给bootloader.BIOS会把bootloader导入到地址0x7c00开始的地方,然后把PC指针设成此地址,以完成跳转。下面让我们看看bootloader的汇编部分代码:

.code16 # Assemble for 16−bit mode
.globl start
start:
cli # BIOS enabled interrupts; disable

 # Zero data segment registers DS, ES, and SS.
xorw %ax,%ax # Set %ax to zero
movw %ax,%ds # −> Data Segment
movw %ax,%es # −> Extra Segment
movw %ax,%ss # −> Stack Segment

.code16的意思是此段代码是16位的代码。为了保证向后的兼容性,在刚启动的时候,执行的是16位的代码。32位的代码要等到32位被使能之后才可以执行。

        BIOS在执行的时候会打开中断,但这是BIOS已经不在执行了,所以它的中断向量表之类的也就不在起作用了,bootloader此时应当关闭中断,在合适的时机再将中断打开。

               下面几条语句的用意是清掉段寄存器。在xv6中,我们有虚拟地址,逻辑地址,线性地址和物理地址的概念。虚拟地址的概念相信熟悉分页机制的肯定都知道。在进程中,指令操作的往往都不是实际的物理地址,因为这样会带来很多问题:安全问题,共享问题等等。所以用分页机制来做下转换,程序操作的都是虚拟地址,而这些虚拟地址都是不变的,操作系统负责将虚拟地址映射到实际的物理地址上,同样的虚拟地址在不同的时刻可能对应不同的物理地址。

         在x86指令架构中,虚拟地址通常是用逻辑地址来表示的,虚拟地址通常保存成这样的形式segment:offset,每条地址处在一个段中,segment是该段的基地址,各个地址相对于段基址的偏移被存在offset里面。程序中出现的实际上只有offset,硬件自动通过通过segment和offset算出线性地址,线性地址再经过页表等一系列处理得到实际的物理地址。

       在刚进入bootloader的时候,硬件处于实模式,在此模式下,只能使用16位的寄存器,但是真正可寻址的范围是20位地址,也就是1M的地址空间,硬件通过把segment左移四位然后加上offset的方式来得到线性地址,因为这时分页模式还没有打开,所以得到的就是实际的物理地址。

实模式下物理地址计算方式:segment >> 4 + offset.

        %ds, %es,%ss实际上存放的就是各个段的基地址(segment),其中ds代表数据段,es代表扩展段,ss代表堆栈段。在BIOS的时候可能会用到这些寄存器,在这里把寄存器清0.

seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 0xd1 −> port 0x64
outb %al,$0x64

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

movb $0xdf,%al # 0xdf −> port 0x60
outb %al,$0x60

       在上述计算物理地址的过程中,用到了两个16位的寄存器,那么两个寄存器的最大值都是0xffff,所以得到的物理地址的最大值应该是是0xffff0+0xffff = 0x10ffef,这个地址实际上是有21位的,但是在实模式下,8086/8088寻址地址实际上只有20位,这也就意味着最高位是无效的,得到的地址实际上是0xffef.

     但是在intel更高架构的处理器上(如80286),地址线总数是多余20位的,所以这21位是有效的,这就导致了在8086和80286在实模式下行为不一致。为了使行为一致,IBM采用了这样一种方法:当键盘控制器的输入为低时,清掉A21这位,只有键盘控制器的输入为高时,这一位才有效。具体的情形可以参考这篇文章:http://www.techbulo.com/703.html

     当然,在这里我们需要离开实模式了,所以我们要确保键盘控制器输出为高,以保证在80286以上的机型中A2有效,上述代码的作用就是这个。发送0xdf可以使能键盘控制器gate.

lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

      前面我们提到即将离开实模式,马上我们就会进入保护模式。在保护模式情况下,寻址地址达到32位。而且线性地址的计算方式也和实模式下不同。

      在保护模式下,segment的寄存器实际上是段描述符表的索引。段描述符的格式如下:



      其它的部分我们可以暂且不去管它,只关注BASE和LIMIT即可,顾名思义,BASE指的是段基址所在的地址,LIMIT可以指明段的大小。记住,程序中用到的地址只有offset,硬件会自动根据地址所在段提取段描述符表中的索引,把逻辑地址转换成线性地址,转换方法如下:

                      

        所以在我们转换到保护模式之前,我们必须保证已经建好了段描述符表,lgdt命令把段描述符表的基址和限界存到GDTR这个寄存其中,硬件就可以找到这个段描述表了。我们再来看看段描述表的内容:

gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg

gdtdesc:
.word (gdtdesc − gdt − 1) # sizeof(gdt) − 1
.long gdt # address gdt


  在分析这段代码之前,先来看看SEG_ASM的代码:

#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)

    可以看到,这段代码实际上就是以段描述符的形式,建立了段描述符表中的一项。这里一共定义了三项:null,code段和数据段。我们发现,code段和数据段的基址和限界都是0x0,0xffffffff,这说明在xv6中,在boot这段时间里,逻辑地址和线性地址实际上是一样的。

     gdtdesc实际上就是分配了六个字节,前两个字节存放的是gdt的大小减一,后四个字节是gdt表的地址。lgdt负责把这段空间的基地址传到GDTR寄存器中,段描述符表就建立好啦。

      在建立好段描述表之后,可以切换到保护模式了,通过置位CR0的PE位打开保护模式。CR0是个控制寄存器,定义如下:

ljmp $(SEG_KCODE<<3), $start32

      这一段代码就是设置PC和cs寄存器了,注意,我们是不可以直接修改CS寄存器的值的,只能通过间接的方式,这段代码设置了CS的值是(SEG_KCODE * 8),乘8是因为一个段描述符的大小是八个字节,SEG_KCODE定义为1,这正和我们刚才建立的段描述表对应:code段的所以是1.然后我们跳掉start32处执行,让我们看看这段代码:

start32:
# Set up the protected-mode data segment registers
movw $(SEG_KDATA<<3), %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %ss # -> SS: Stack Segment
movw $0, %ax # Zero segments not ready for use
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS

     这段代码实际上是设置各个段的索引,可以看到ds,es,ss都索引到了数据段,fs和gs段值为0.

# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain

      这一段设置了一个栈顶指针,然后跳到C代码执行,bootloader的汇编部分至此结束。



二 bootloader的C代码部分

     下面我们看看bootmain这个函数:

9207 #include "types.h"
9208 #include "elf.h"
9209 #include "x86.h"
#include "memlayout.h"

#define SECTSIZE 512

void readseg(uchar*, uint, uint);

void
bootmain(void)
{
	struct elfhdr *elf;
	struct proghdr *ph, *eph;
	void (*entry)(void);
	uchar* pa;

	elf = (struct elfhdr*)0x10000; // scratch space

	// Read 1st page off disk
	readseg((uchar*)elf, 4096, 0);

	// Is this an ELF executable?
	if(elf−>magic != ELF_MAGIC)
	return; // let bootasm.S handle error

	// Load each program segment (ignores ph flags).
	ph = (struct proghdr*)((uchar*)elf + elf−>phoff);
	eph = ph + elf−>phnum;
	for(; ph < eph; ph++){
	pa = (uchar*)ph−>paddr;
	readseg(pa, ph−>filesz, ph−>off);
	if(ph−>memsz > ph−>filesz)
	stosb(pa + ph−>filesz, 0, ph−>memsz − ph−>filesz);
    }

	// Call the entry point from the ELF header.
	// Does not return!
	entry = (void(*)(void))(elf−>entry);
	entry();
}

       这一段的作用主要是从硬盘中读出kernel,然后跳到入口点开始执行kernel的代码。kernel是按照elf的格式存到硬盘中的,内核读出来是放到地址空间0x10000处。

void
waitdisk(void)
{
	// Wait for disk ready.
	while((inb(0x1F7) & 0xC0) != 0x40)
	;
}

// Read a single sector at offset into dst.
void
readsect(void *dst, uint offset)
{
	// Issue command.
	waitdisk();
	outb(0x1F2, 1); // count = 1
	outb(0x1F3, offset);
	outb(0x1F4, offset >> 8);
	outb(0x1F5, offset >> 16);
	outb(0x1F6, (offset >> 24) | 0xE0);
	outb(0x1F7, 0x20); // cmd 0x20 − read sectors

	// Read data.
	waitdisk();
	insl(0x1F0, dst, SECTSIZE/4);
}

// Read ’count’ bytes at ’offset’ from kernel into physical address ’pa’.
// Might copy more than asked.
void
readseg(uchar* pa, uint count, uint offset)
{
	uchar* epa;

	epa = pa + count;

	// Round down to sector boundary.
	pa −= offset % SECTSIZE;

	// Translate from bytes to sectors; kernel starts at sector 1.
	offset = (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(; pa < epa; pa += SECTSIZE, offset++)
		readsect(pa, offset);
}

    先看一下从硬盘中读取数据的函数,readseg读取从硬盘中读取数据到pa开始的内存地址,count是字节数目,offset是开始的字节数。因为在硬盘中的最小但单位是扇区(SECTSIZE,512字节),索引我们先把offset转换成sector对齐,注意内核是从sector1开始的,索引转换之后的结果要加1.下一步就是依次读取从指定sector开始的count定义的字节数目到内存中了。

    readsect这个函数就是往IO端口中发送命令读取一个sector的数据到内存中。0x20对应了read sector的命令,即读出一个sector的数据。

        在读出elf header后,判断是不是一个elf文件,如果是的话,依次把内核的各个段读到内存中。最后用stosb初始化没有数值的内存区域。
       

 static inline void
 stosb(void *addr, int data, int cnt)
 {
     asm volatile("cld; rep stosb" :
     "=D" (addr), "=c" (cnt) :
     "0" (addr), "1" (cnt), "a" (data) :
     "memory", "cc");
 }

    汇编cld清掉eflags中的DF flag位,即方向位,这表示用esi和edi给内存赋值的时候才用增加地址的方式,=D代表把addr赋到edi寄存器中,=c代表把cnt的值赋到cx寄存器中,a 代表把data 辅导ax 中,rep代表重复这个操作。

     bootmain的最后一个操作就是跳到内核执行了,entry的地址这里是0x10000c,下面开始的就是真正的内核代码了。

三 内核entry.S
   

      上面我们说过,bootloader把内核从硬盘导入到0x100000处,至于为什么不放在0x0开始的地方是因为从640kb到0x100000开始的地方是被用于IO device的映射,所以为了保持内核代码的连续性,就从1MB的内存区域开始存放。在xv6里面,内核对应的虚拟地址实际上是从0x800000处开始的,那么为什么不干脆把内核导入到物理地址的0x800000处呢?这个原因主要是有的小型PC是没有这么高的地址的,所以放在0x100000处显然是个更好的选择。

      

.globl _start
_start = V2P_WO(entry)

# Entering xv6 on boot processor, with paging off.
.globl entry
entry:
# Turn on page size extension for 4Mbyte pages
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4

# Set page directory
$(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0

# Set up the stack pointer.
movl $(stack + KSTACKSIZE), %esp

# Jump to main(), and switch to executing at
# high addresses. The indirect call is needed because
# the assembler produces a PC−relative instruction
# for a direct jump.
mov $main, %eax
jmp *%eax

.comm stack, KSTACKSIZE

       首先我们定义一个全局变量_start来保存entry的地址,由于我们还没有开始虚拟地址,所以这里保存的还是entry的实际物理地址。

       在entry中,我们首先打开CR4的PSE位,即页大小扩展位,如果PSE等于0,每页的大小只能是4KB,如果PSE等于1,每页的容量可以达到4MB。CR4是一个控制寄存器,定义如下:

       下面就要打开分页模式了,在这之前,我们必须要确保页目录表的地址已经存放到cr3中了。cr3也是一个控制寄存器,里面存放了全局页目录表的地址。记住,CR3里面必须存放物理地址。我们来看一下entrydir的定义:

__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
// Map VA’s [0, 4MB) to PA’s [0, 4MB)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// Map VA’s [KERNBASE, KERNBASE+4MB) to PA’s [0, 4MB)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};

        entrydir中只定义了两项,分别把虚拟地址0~4MB和KERNBASE(0x800000) ~ KERNBASE + 4MB的空间映射到了0~4MB的物理地址,映射0~4MB是因为我们我们在entry之前可能还会用到这段区域,0x800000则是分页之后内核认为的虚拟地址所在地,注意这两项都是指向同一个地方的,即内核所在地。

        下面我们就要打开分页模式了,从这里开始,我们就可以用到虚拟地址的概念了。打开分页模式主要是置位CR0的PG位,因为我们不希望有人可以更改内核代码,所以把WP位也置上。CR0寄存器的定义如下:


     在分页之后,我们设置一下栈指针,KSTACKSIZE的定义是4096,也就是我们4K.这意味着我们暂且用4KB开始向下的这一段区域当做内核的栈。

     最后,我们跳到main函数执行,在main函数中,我们将会初始化一些变量并且开始创立我们的第一个进程。


四 第一个进程的创立

        

int
main(void)
{
	kinit1(end, P2V(4*1024*1024)); // phys page allocator
	kvmalloc(); // kernel page table
	mpinit(); // detect other processors
	lapicinit(); // interrupt controller
	seginit(); // segment descriptors
	cprintf("\ncpu%d: starting xv6\n\n", cpunum());
	picinit(); // another interrupt controller
	ioapicinit(); // another interrupt controller
	consoleinit(); // console hardware
	uartinit(); // serial port
	pinit(); // process table
	tvinit(); // trap vectors
	binit(); // buffer cache
	fileinit(); // file table
	ideinit(); // disk
	if(!ismp)
	timerinit(); // uniprocessor timer
	startothers(); // start other processors
	kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
	userinit(); // first user process
	mpmain(); // finish this processor’s setup
}

          main函数调用了很多函数,在这里,我们先关注其中的几个,kvmalloc分配了内核的页表,我们先来看看这个函数。

     

void
kvmalloc(void)
{
    kpgdir = setupkvm();
    switchkvm();
}

     kvmalloc首先为kernel建立了一个页表,然后开始采用这个页表。我们来具体看看setupkvm的code:

// Set up kernel part of a page table.
pde_t*
setupkvm(void)
{
	pde_t *pgdir;
	struct kmap *k;

	if((pgdir = (pde_t*)kalloc()) == 0)
	return 0;
	memset(pgdir, 0, PGSIZE);
	if (P2V(PHYSTOP) > (void*)DEVSPACE)
	panic("PHYSTOP too high");
	for(k = kmap; k < &kmap[NELEM(kmap)]; k++)
	if(mappages(pgdir, k−>virt, k−>phys_end − k−>phys_start,
	(uint)k−>phys_start, k−>perm) < 0)
        return 0;
    return pgdir;
}

        函数首先分配了一个4KB的区域来存放全局页表,在xv6里面,我们采用了二级页表的形式,一个虚拟地址被分成三部分:

     

         虚拟地址最高十位被用来当做全局目录页表的索引,中间十位被用来当做二级页表的索引,最后十二位是页中的索引。

        

         虚拟地址到物理地址(我们提到过,在xv6中,虚拟地址实际上是等于线性地址的)的转换如上图。关于页表的具体信息我们以后再讲。在这里只需记住,CR3中存放了全局页目录表的地址,有了这个,建好页表,我们就可以一步一步地从虚拟地址得到物理地址了,当然,这些转换都是由硬件完成,我们的操作系统只需要建立好这些页表即可。

      现在让我们再来看看上述代码。在用0初始化这个将要被用作内核的全局虚拟页表之后,对于kmap中的每一项,我们建立对应的页表。kmap定义如下:

       

// This table defines the kernel’s mappings, which are present in
// every process’s page table.
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
	{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
	{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
	{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
	{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};

    kmap定义了四项,KERNBASE 即0x80000000开始的0x100000大小的区域,被映射到了0到EXTMEM(即0x100000)的物理内存处,即0~1M的区域,我们知道,这段区域被用来映射IO设备;KERNLINK(0x80100000)到data区域所在地被映射到了0x100000处开始的物理内存区域,从前面我们也知道,这段区域主要映射内核的代码和只读数据;在往上是data和其余memory的区域,最后一项我们暂且不关注。

    

// Return the address of the PTE in page table pgdir
// that corresponds to virtual address va. If alloc!=0,
// create any required page table pages.
static pte_t *
walkpgdir(pde_t *pgdir, const void *va, int alloc)
{
	pde_t *pde;
	pte_t *pgtab;

	pde = &pgdir[PDX(va)];
	if(*pde & PTE_P){
	pgtab = (pte_t*)P2V(PTE_ADDR(*pde));
	} else {
	if(!alloc || (pgtab = (pte_t*)kalloc()) == 0)
	return 0;
	// Make sure all those PTE_P bits are zero.
	memset(pgtab, 0, PGSIZE);
	// The permissions here are overly generous, but they can
	// be further restricted by the permissions in the page table
	// entries, if necessary.
	*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U;
	}
	return &pgtab[PTX(va)];
}

// Create PTEs for virtual addresses starting at va that refer to
// physical addresses starting at pa. va and size might not
// be page−aligned.
static int
mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
	char *a, *last;
	pte_t *pte;

	a = (char*)PGROUNDDOWN((uint)va);
	last = (char*)PGROUNDDOWN(((uint)va) + size − 1);
	for(;;){
	if((pte = walkpgdir(pgdir, a, 1)) == 0)
	return −1;
	if(*pte & PTE_P)
	panic("remap");
	*pte = pa | perm | PTE_P;
	if(a == last)
	break;
	a += PGSIZE;
	pa += PGSIZE;
	}
	return 0;
}


        从这几个函数可以看出,对kmap中的每一项,mappages对其中的每一个4K如果不存在对应调用walkpgdir函数,在这个函数里面,如果不存在对应的二级页表就分配一个,然后填写对应的二级页表,同时更新全局页目录表;如果存在,就直接更新对应的二级页表。

       至此,kernel对应的页表项已经建好,此页表项对于今后所有的用户进程都是通用的。

       接下来,用switchkvm项来更新cr3寄存器,开始采用此页表。

void
switchkvm(void)
{
    lcr3(V2P(kpgdir)); // switch to the kenel page table
}


       cr3中存放的应该是物理地址,这里我们把虚拟地址转换物理地址。


       待续。。。。。












阅读更多
换一批

没有更多推荐了,返回首页