2023-02-11 16:34阅读: 277评论: 0推荐: 0

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程序的第一行:

copy
  • 1
[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的定义如下:

    copy
    • 1
    • 2
    • 3
    • 4
    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中:

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

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

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

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

copy
  • 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
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,那么就表示这个段会被加载到内存
    • 指明了对应的程序段在磁盘中的位置,
    • 指明了该段在内存中的位置
copy
  • 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
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头和数据段和代码段:

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

image-20221016190650577

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

接着用readelf查看程序头

copy
  • 1
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中

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    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 将控制权转交给内核后,我们开启分页

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

    copy
    • 1
    • 2
    • 3
    movl %cr0, %eax orl $(CR0_PE|CR0_PG|CR0_WP), %eax movl %eax, %cr0
  4. call i386_init()

    copy
    • 1
    call i386_init

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

copy
  • 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
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(); }

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

copy
  • 1
  • 2
  • 3
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。

copy
  • 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
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了。

copy
  • 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
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之下的内存当作栈

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

    image-20230211153245173

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

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    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:

    copy
    • 1
    • 2
    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的做法是直接定义一个二维数组:

    copy
    • 1
    • 2
    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",有了它便能方便检测栈溢出了:

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    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

本文作者:别杀那头猪

本文链接:https://www.cnblogs.com/HeyLUMouMou/p/17111969.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   别杀那头猪  阅读(277)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起