MIT6.828_JOS启动流程

JOS启动流程

首先做个总览,JOS的启动流程主要分三步:

  1. BIOS
    • 检查内存、外设、构建临时IDT等
    • 将启动盘中的0号扇区的512字节读入到物理内存的0x7c00处,这段内存就是bootloader
    • 使用jmp指令将控制权交移至bootloader
  2. bootloader,包括 boot.S文件、 boot.c中的bootmain()函数
    • boot.S: 进入32位保护模式
      • 打开A20gate,以使用32根地址线
      • 构建临时GDT,并使用lgdt命令将GDT地址加载至GDTR寄存器中
      • CR0寄存器的PE位置1,进入保护模式。至此我们可以开始使用32位CPU指令了。
      • call bootmain()进行下一步
    • bootmian(): 加载内核
      • 从启动盘的1号扇区将内核的elf文件加载到物理地址的0x10000c处
      • 跳转到内核代码,将控制权交移给内核。
  3. 内核启动代码,包括entry.S文件、init.c文件中的i386_init()函数
    • entry.S : 开启分页机制
      • 创建临时页表,并将其物理地址载入CR3寄存器
      • 将CR0寄存器的PG位置1。至此,操作系统的所有地址都是虚拟地址
      • call i386_init() 执行操作系统的各种初始化操作
    • i386_init(): 操作系统的各种初始化,包括:
      • 控制台、内存、进程、中断系统、调度系统、键盘控制器等初始化
  4. i386_init()中,还包括多核CPU的启动流程

流程图如下所示

image-20230211163008915

BIOS

BIOS程序是固化只读存储器ROM中的一段起始程序,它位于机器低1MB中的0xffff0处。

按照课程实验的指示,使用gdb将程序停止在BIOS程序的第一行:

[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b

第一条执行的指令便是在BIOS ROM中的一条指令,可以看到第一条指令的物理地址是f000:fff0,也即0xffff0

接着往下运行时,BIOS做以下事情:

  • 检验各种硬件设置
  • 对设备进行初始化
  • 建立中断表
  • 寻找启动盘(bootable disk),将它的0号扇区的512字节读入到物理内存的0x7c00 处,然后使用jmp指令跳转到0000:7c00处执行。这一段512字节的程序,就是所谓的 boot loader
    • 这样bios就把控制权转交给了bootloader

想知道BIOS除了加载bootloader,具体还做了写什么?---可以看看《操作系统真象》中的相关部分

bootloader

JOS的bootloader的逻辑主要由 boot.S 和 main.c的bootmain()函数组成,其中:

首先boot.S的任务是将CPU从实模式(在这个模式下,cpu只能访问到内存的第1MB空间)转向32位保护模式(此时段寄存器中存放选择子,而不是基地址)

  1. 打开A20gate

    A20的打开方式有许多,JOS的使用的方式与xv6相同,都是通过键盘控制器来打开的,具体的就不多说了(我不是很了解)

  2. 构建临时GDT,并使用lgdt命令将GDT地址加载至GDTR寄存器中

    gdt的定义如下:

    gdt:
      SEG_NULL							 # null seg, 全局描述符表的第一项不使用 
      SEG(STA_X|STA_R, 0x0, 0xffffffff)	   # code seg , 可执行、可读
      SEG(STA_W, 0x0, 0xffffffff)	       # data seg , 可写
    

    注意GDT的第一项不使用。其余的两个描述符分别代表代码段和数据段,注意它们的基地址为0,段界限为2^32,因此JOS的内存寻址也是“平坦模式”

    然后使用lgdt指令,将上面的gdt的线性地址加载到GDTR寄存器中,GDTR寄存器由两部分构成,第一部分是32位地址,指向GDT的物理地址,第二部分则是gdt的大小:

    image-20230210204509581

    JOS的做法是手动定义一个gdtdesc数据结构存放上述信息,然后将gdtdesc载入GDTR中:

    gdtdesc:
      .word   0x17                            # sizeof(gdt) - 1
      .long   gdt                             # address gdt
      
    lgdt  gdtdesc
    
  3. 将CR0的PE位置1,进入32位保护模式:

    movl    %cr0, %eax
    orl     $CR0_PE_ON, %eax
    movl    %eax, %cr0
    
  4. call bootmain继续执行,其中关于栈的变化会在最后小节中总结

    # 把0x7c00之下的内存当作栈
    movl    $start, %esp # 设置栈
    call bootmain  # 转到main.c的bootmain()函数
    

接着main.c的bootmain()将从启动盘的1号扇区(第二个扇区)将内核加载到内存中,部分代码如下,它最后会跳转至正真的内核代码中执行!

void
bootmain(void)
{
	struct Proghdr *ph, *eph;
	int i;
	// 读4094个字节到 0x10000 之上, 这4096个字节包括elf文件的elf头表52字节,和3个程序头表,每个32字节。 使用这些信息将内核加载到物理内存中来
	readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

	// 根据elf文件格式加载各个程序段
	ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
	eph = ph + ELFHDR->e_phnum; // elf头表中一共由多少个项
	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);
		for (i = 0; i < ph->p_memsz - ph->p_filesz; i++) {
            // bss段清零
			*((char *) ph->p_pa + ph->p_filesz + i) = 0;
		}
	}

	// call the entry point from the ELF header
    // 跳入内核代码的第一个指令
	((void (*)(void)) (ELFHDR->e_entry))();

}

下面将插述elf文件相关内容

关于elf文件

https://pdos.csail.mit.edu/6.828/2018/readings/elf.pdf

JOS用C语言处理ELF文件,首先定义了3个与之相关的结构体,我们只是用了elf头和程序头表,因此先来看elf头结构体和proghdr头结构:

  • elf头主要有三个信息
    • 程序入口地址
    • 程序头表偏移地址
    • 程序头个数
  • 程序头主要有两个信息
    • 说明这个段的类型,如果是LOAD,那么就表示这个段会被加载到内存
    • 指明了对应的程序段在磁盘中的位置,
    • 指明了该段在内存中的位置
struct Elf { // elf 文件头结构体, 共52字节
	uint32_t e_magic;	// 魔数字 ,must equal ELF_MAGIC
	uint8_t e_elf[12];
	uint16_t e_type;
	uint16_t e_machine;
	uint32_t e_version;
	uint32_t e_entry;	// 程序的入口地址
	uint32_t e_phoff;  // 程序头表偏移地址
	uint32_t e_shoff;  // 节头表偏移地址
	uint32_t e_flags;
	uint16_t e_ehsize; // elf 文件头大小
	uint16_t e_phentsize; // 程序头表大小
	uint16_t e_phnum;		//程序头个数
	uint16_t e_shentsize;  // 节头表大小
	uint16_t e_shnum;      // 节头个数
	uint16_t e_shstrndx;	//节区字符串表在节头表中的下标
};

struct Proghdr { // 程序头结构
	uint32_t p_type; // 该段的类型,比如说可装载段,可装载段会被加载到内存
	uint32_t p_offset; // 该程序段在磁盘上相对于 文件起始的偏移地址
	uint32_t p_va; // 该段加载到内存时的虚拟地址,exec函数中用到
	uint32_t p_pa; // 该段加载到内存时的物理地址,启动时加载内核 elf 文件时用到
	uint32_t p_filesz;// 该段在磁盘上的大小
	uint32_t p_memsz; // 该段在内存上的大小
	uint32_t p_flags;
	uint32_t p_align;
};

注意,如果某个段在内存上的大小比它在磁盘上的大小要小,这表示这个段包括了未初始化为0的变量,这部分变量被放入BSS段中。

我们可以使用readelf命令查看内核elf文件的elf头和数据段和代码段:

readelf -h  obj/kern/kernel # 查看程序头表

image-20221016190650577

输出显示,有三个程序头,每个32字节。第一个程序头在本文件开头偏移的52字节处,bootmain.c程序正是利用了这两个信息,确定了各个文件头在磁盘上的位置。另外Entry point address 为 0x10000c,这是内核开始执行的第一条指令的位置。

接着用readelf查看程序头

readelf -l  obj/kern/kernel # 查看所有程序头中的信息

image-20221016185811042

每个程序头对应一个程序段,这里主要看 Type为LOAD的程序段,有两个,一个Flg = R E,表示可读可执行,一个 Flg = RW,表示可读写。明显,一个是代码段、一个是数据段,同时也给出了它们各自的磁盘位置(offset)和内存加载地址(PhysAddr)。bootmain.c程序从磁盘读出各个程序头后,就按着程序头中的内容将对应的程序段加载到对应的内存中。

bootmain()函数根据ELF文件的指示,建立了kernel的执行上下文:

image-20221016195028212

注意Proghdr中有两个地址一个是 pa(物理地址) 一个 是va(虚拟地址),它们两个的用途是不同的(在上面注释已标明),但课程的lab1只会涉及到pa,因为这时没涉及到分页呢,所以也不会有虚拟地址一说。

内核启动代码

bootloader最后会将控制权移交给内核,内核首先会开启分页,然后做一些列的初始化动作,最后正式开始运行。

内核启动主要由两个部分组成:包括entry.S文件、init.c文件中的i386_init()函数

首先entry.S文件: 开启分页

  1. 首先创建一个临时的页表,注意这段代码在entrypgdir.c而不是entry.S中

    pde_t entry_pgdir[NPDENTRIES] = {
        //KERNBASE = 0xf0000000 
    	// Map VA's [0, 4MB) to PA's [0, 4MB)
    	[0]
    		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
    	// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
    	[KERNBASE>>PDXSHIFT]
    		= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
    };
    pte_t entry_pgtable[NPTENTRIES] = {
    	0x000000 | PTE_P | PTE_W,
    	0x001000 | PTE_P | PTE_W,
    	0x002000 | PTE_P | PTE_W,
    	0x003000 | PTE_P | PTE_W,
    	0x004000 | PTE_P | PTE_W,
    	0x005000 | PTE_P | PTE_W,
    	0x006000 | PTE_P | PTE_W,
        ...
        0x3fd000 | PTE_P | PTE_W,
    	0x3fe000 | PTE_P | PTE_W,
    	0x3ff000 | PTE_P | PTE_W, 
    }
    

    页表只有两个项且内容相同,将虚拟地址的[0, 4MB) 和[KERNBASE, KERNBASE+4MB) 都映射到物理地址的[0, 4MB)

    建立的映射如下图所示,图源

    image-20221016204654760

  2. 加载页表位置到cr3,下面的代码在 entry.S中,即在 bootloader 将控制权转交给内核后,我们开启分页

    movl	$(RELOC(entry_pgdir)), %eax
    movl	%eax, %cr3
    
  3. 设置cr0寄存器PG位,最终开启分页

    movl	%cr0, %eax
    orl	$(CR0_PE|CR0_PG|CR0_WP), %eax
    movl	%eax, %cr0
    
  4. call i386_init()

    call	i386_init
    

接着,控制流转到init.c文件中的i386_init()函数,它会做很多初始化工作:

void
i386_init(void)
{
    // 这两个数据是由链接器帮我们定义的,分别指向elf文件的data段在内存中的末尾,以及bss段在内存中的末尾
	extern char edata[], end[];
	// 将内核未初始化的全局变量置0!
	memset(edata, 0, end - edata);
	// 初始化console, 包括cga显示器、键盘、串口
	cons_init();
	// 内存初始化
	mem_init();
	// 用户进程初始化
	env_init();
	// 中断向量表初始化
	trap_init();
	// 多核启动准备工作
	mp_init();
	lapic_init();
	pic_init();
    // 获取内核大锁,防止多个cpu在内核中执行
	lock_kernel(); 
	// 启动其他CPU
	boot_aps();
	// 文件服务进程启动
	ENV_CREATE(fs_fs, ENV_TYPE_FS);
	// 开启线程调度!
	sched_yield();
}

接着我将阐述其中的多核启动流程,主要涉及上述代码中的

mp_init();

boot_aps();

多核启动流程

最初启动的CPU称作BSP(BootStrao Processor,到此位置上面讲述的流程都由BSP执行完毕了),BSP可以向其他CPU发送启动信号,这些被BSP唤醒的CPU则被成为AP(ApplicationProcessor)

首先为了从单核转向多核,硬件也需要相应的升级,从PIC到APIC

其次,内存的某处有个mpconfig table结构,里面存储了各cpu的信息以及APIC的IO地址,BSP需要找到这个结构才能知道其余CPU的信息

最后,BSP发送信号给其余AP,让它们执行启动流程,AP的启动流程与BSP类似,包括开启保护模式、设置GDT、开启分页、设置中断向量表等。

APIC

单核CPU结构中,可以用PIC芯片处理外部设备中断,PIC芯片收到设备中断信号后将和CPU通信完成中断服务流程。

但是在多核CPU架构中,每个CPU都能处理外部设备中断,假设还是使用PIC芯片,当有一个设备发出中断信号时,PIC应该向哪个CPU请求处理呢?

因此多核架构中,我们需要一个更高级的中断处理器,这就是APIC(Advanced PIC)。

APIC分为两部分:IOAPICLAPIC。LAPIC集成在CPU内部,每个CPU都有一个LAPIC,IOPIC与外部设备相连。

图源

image-20221103120434595

总体机制是 : IOAPIC接受外设中断,将信号发给每一个LAPIC, 每个LAPIC再根据一些计算判断这个信号是否交由CPU处理。

此外LAPIC有一个ID寄存器,该寄存器的值用来唯一地标识一个LAPIC,因此也可以用LAPIC的ID唯一地标识CPU,在具体代码的代码中我们正是利用这个特性区分不同的CPU。

mpconfig table搜寻

mpconfig table由floating pointer指出,因此为了找到mpconfig 首先应找到floating pointer

floating pointer可能在下面三个内存地址中的某一个:

  1. EBDA(Extended BIOS Data Area)最开始的 1KB
  2. 系统基本内存的最后1KB
  3. BIOS的ROM区域,在 0xf0000 到 0xfffff 之间

mpconfig.c中的mpsearch()函数就从这三个地址中查找floating pointer。

然后回到mp_init()函数中,依次将CPU信息填入struct CpuInfo cups[]这个数组中,主要信息是lapic的id,它唯一地标识了每一个CPU。

void
mp_init(void)
{
	...

	bootcpu = &cpus[0];
	if ((conf = mpconfig(&mp)) == 0) // 搜索mpconfig
		return;
	ismp = 1;
	lapicaddr = conf->lapicaddr;

	for (p = conf->entries, i = 0; i < conf->entry; i++) {
		switch (*p) {
		case MPPROC:
			proc = (struct mpproc *)p;
			if (proc->flags & MPPROC_BOOT)
				bootcpu = &cpus[ncpu]; // 记录BSP
			if (ncpu < NCPU) {
				cpus[ncpu].cpu_id = ncpu; // 记录其他CPU的id
				ncpu++;
			} 
			...
		}
	}

	...
}

BSP唤醒其他AP

收集到了其余AP信息后,回到i386_init()中开始执行boot_aps,顾名思义,这要正式启动AP了。

static void
boot_aps(void)
{
	extern unsigned char mpentry_start[], mpentry_end[];
	void *code;
	struct CpuInfo *c;

	// 链接器将ap的启动代码链接到了 mpentry_start ~ mpentry_end这段内存
	code = KADDR(MPENTRY_PADDR); // #define MPENTRY_PADDR	0x7000
   	// 把这段启动内存拷贝到0x7000上面
	memmove(code, mpentry_start, mpentry_end - mpentry_start);

	// 依次启动cpu
	for (c = cpus; c < cpus + ncpu; c++) {
		if (c == cpus + cpunum())  // BSP已经启动了,跳过
			continue;
		// 指定每个cpu要使用的内核栈
		mpentry_kstack = percpu_kstacks[c - cpus] + KSTKSIZE;
		// 启动cpu,起始代码0x7000处
		lapic_startap(c->cpu_id, PADDR(code));
		// Wait for the CPU to finish some basic setup in mp_main()
		while(c->cpu_status != CPU_STARTED)
			;
	}
}

关于内核栈,我会在下一节进行总结。

其中最主要的函数为lapic_startap(c->cpu_id, PADDR(code)),但是在这里不想过多深入细节,简单说就是使用lapic的通信机制,向指定cup_id的AP的lapic发送init信号,该AP将从0x7000处执行第一行代码。

0x7000之上的代码是所有AP的启动代码,它的主要逻辑包括mentry.S文件以及init.c中的mp_main()函数,与BSP的启动流程相似,同样有开启保护模式(但是不需要再打开A20gate)、设置GDT、开启分页、设置中断向量表、设置TSS段描述符等。

内核栈的变化

有了栈汇编代码才能调用c函数,到此为止,cpu已经启动完毕,但是它使用的栈却是一直在变化的。

  1. 首先跟踪BSP的启动流程,它在BIOS后进入boot.S, 最后他将调用c函数的bootmain,在这之前它把0x7c00之下的内存当作栈

    # Set up the stack pointer and call into C., 把0x7c00之下当作栈
    movl    $start, %esp
    call bootmain
    

    image-20230211153245173

  2. 在bootloader转交控制权给kernel后来到了entry.S

    movl	$0x0,%ebp			# ebp置零,这是第一个栈帧!
    # 改变内核栈
    movl	$(bootstacktop),%esp
    call	i386_init
    
    	# Should never get here, but in case we do, just spin.
    spin:	jmp	spin
    
    
    .data
    ###################################################################
    # 分配内核栈 stack
    ###################################################################
    	.p2align	PGSHIFT		# force page alignment
    	.globl		bootstack
    bootstack:
    	.space		KSTKSIZE     # 分配一个8 * 4096的栈
    	.globl		bootstacktop   
    bootstacktop:
    

    查看反汇编文件kernel.asm ,搜索 bootsatck:

    movl	$(bootstacktop),%esp
    f0100034:	bc 00 00 11 f0       	mov    $0xf0110000,%esp
    

    可以看到esp 栈顶被设置为0xf0110000,栈大小为32KB,此时已经开启了分页,因此内核的栈在实际内存的位置应该是0x00108000-0x00110000

    108000? 这不是内核数据段的加载地址吗?到BSP单核启动完成为止,内核的空间布局如下图所示:

    image-20230211154052995

  3. 此后BSP一直执行到i386_init()的mem_init(),在mem_init中,有一段代码,又给所有的cpu重新分配了内核栈。内核栈的物理空间在代码编译、链接后已经开辟了的,JOS的做法是直接定义一个二维数组:

    unsigned char percpu_kstacks[NCPU][KSTKSIZE]
    __attribute__ ((aligned(PGSIZE)));
    

    其中NCPU为为JOS做多能够支持的CPU数,为8, KSTKSIZE就是每个内核栈的大小,为 8 * PGSIZE,PGSIZE则是每个页的大小,一般为4KB。

    mem_init()中的mem_init_mp()函数,则将上述的物理空间映射到虚拟地址上,值得注意的是每个stack之后都有KSTKGAP大小的虚拟地址没有建立映射,这就是所谓的"guard page",有了它便能方便检测栈溢出了:

    static void
    mem_init_mp(void)
    {
    	for(int i=0; i<NCPU; i++){
    		uint32_t kstacktop_i = KSTACKTOP - i*(KSTKSIZE + KSTKGAP); // 额外空出KSTKGAP的虚拟地址大小,
    		boot_map_region(kern_pgdir,kstacktop_i-KSTKSIZE,KSTKSIZE,PADDR(&percpu_kstacks[i]),PTE_W);// 但是映射的时候却不会将额外的虚拟地址映射到实际的物理内存上
    	}
    }
    

    image-20230211160513206

posted @ 2023-02-11 16:34  别杀那头猪  阅读(234)  评论(0编辑  收藏  举报