JOS启动流程
首先做个总览,JOS的启动流程主要分三步:
- BIOS
- 检查内存、外设
- 将启动盘中的0号扇区的512字节读入到物理内存的0x7c00处,这段内存就是
bootloader
- 使用jmp指令将控制权交移至bootloader
- 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处
- 跳转到内核代码,将控制权交移给内核。
- boot.S: 进入32位保护模式
- 内核启动代码,包括
entry.S
文件、init.c文件中的i386_init()
函数- entry.S : 开启分页机制
- 创建临时页表,并将其物理地址载入
CR3
寄存器 - 将CR0寄存器的
PG
位置1。至此,操作系统的所有地址都是虚拟地址 - call i386_init() 执行操作系统的各种初始化操作
- 创建临时页表,并将其物理地址载入
- i386_init(): 操作系统的各种初始化,包括:
- 控制台、内存、进程、中断系统、调度系统、键盘控制器等初始化
- entry.S : 开启分页机制
- i386_init()中,还包括
多核CPU的启动流程
流程图如下所示
BIOS
BIOS程序是固化只读存储器ROM中的一段起始程序,它位于机器低1MB中的0xffff0处。
按照课程实验的指示,使用gdb将程序停止在BIOS程序的第一行:
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
第一条执行的指令便是在BIOS ROM中的一条指令,可以看到第一条指令的物理地址是f000:fff0,也即0xffff0
接着往下运行时,BIOS做以下事情:
- 检验各种硬件设置
- 对设备进行初始化
- 建立中断表
- 寻找启动盘(bootable disk),将它的第一个扇区的512字节读入到物理内存的0x7c00 处,然后使用jmp指令跳转到0000:7c00处执行。这一段512字节的程序,就是所谓的
boot loader
- 这样这样bios就把控制权转交给了
bootloader
- 这样这样bios就把控制权转交给了
BIOS除了加载bootloader,具体还做了写什么?---可以看看《操作系统真象》中的相关部分
bootloader
JOS的bootloader的逻辑主要由 boot.S 和 main.c的bootmain()函数组成,其中:
首先boot.S
的任务是将CPU从实模式(在这个模式下,cpu只能访问到内存的第1MB空间)转向32位保护模式(此时段寄存器中存放选择子,而不是基地址)
-
打开A20gate
A20的打开方式有许多,JOS的使用的方式与xv6相同,都是通过键盘控制器来打开的,具体的就不多说了(我不是很了解)
-
构建
临时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的大小:JOS的做法是手动定义一个gdtdesc数据结构存放上述信息,然后将gdtdesc载入GDTR中:
gdtdesc: .word 0x17 # sizeof(gdt) - 1 .long gdt # address gdt lgdt gdtdesc
-
将CR0的PE位置1,进入32位保护模式:
movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0
-
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;
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++) {
*((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;
};
我们可以使用readelf命令查看kernel的elf头和数据段和代码段:
readelf -l obj/kern/kernel # 查看程序头表
输出显示,有三个程序头,每个32字节。第一个程序头在本文件开头偏移的52字节处,bootmain.c程序正是利用了这两个信息,确定了各个文件头在磁盘上的位置。另外Entry point address 为 0x10000c,这是内核开始执行的第一条指令的位置。
接着用readelf查看程序头
readelf -l obj/kern/kernel # 查看所有程序头中的信息
每个程序头对应一个程序段,这里主要看 Type为LOAD的程序段,有两个,一个Flg = R E,表示可读可执行,一个 Flg = RW,表示可读写。明显,一个是代码段、一个是数据段,同时也给出了它们各自的磁盘位置(offset)和内存加载地址(PhysAddr)。bootmain.c程序从磁盘读出各个程序头后,就按着程序头中的内容将对应的程序段加载到对应的内存中。
bootmain()函数根据ELF文件的指示,建立了kernel的执行上下文:
注意Proghdr中有两个地址一个是 pa 一个 是va,它们两个的用途是不同的(在上面注释已标明),但课程的lab1只会涉及到pa,因为这时没涉及到分页呢,所以也不会有虚拟地址一说。
内核启动代码
bootloader最后会将控制权移交给内核,内核首先会开启分页,然后做一些列的初始化动作,最后正式开始运行。
内核启动主要由两个部分组成:包括entry.S
文件、init.c文件中的i386_init()
函数
首先entry.S
文件: 开启分页
-
首先创建一个临时的页表,注意这段代码在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)
建立的映射如下图所示,图源
-
加载页表位置到cr3,下面的代码在 entry.S中,即在 bootloader 将控制权转交给内核后,我们开启分页
movl $(RELOC(entry_pgdir)), %eax movl %eax, %cr3
-
设置cr0寄存器PG位,最终开启分页
movl %cr0, %eax orl $(CR0_PE|CR0_PG|CR0_WP), %eax movl %eax, %cr0
-
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分为两部分:IOAPIC
和LAPIC
。LAPIC集成在CPU内部,每个CPU都有一个LAPIC,IOPIC与外部设备相连。
总体机制是 : IOAPIC接受外设中断,将信号发给每一个LAPIC, 每个LAPIC再根据一些计算判断这个信号是否交由CPU处理。
此外LAPIC有一个ID寄存器,该寄存器的值用来唯一地标识一个LAPIC,因此也可以用LAPIC的ID唯一地标识CPU
,在具体代码的代码中我们正是利用这个特性区分不同的CPU。
mpconfig table搜寻
mpconfig table由floating pointer指出,因此为了找到mpconfig 首先应找到floating pointer
floating pointer可能在下面三个内存地址中的某一个:
- EBDA(Extended BIOS Data Area)最开始的 1KB
- 系统基本内存的最后1KB
- 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已经启动完毕,但是它使用的栈却是一直在变化的。
-
首先跟踪BSP的启动流程,它在BIOS后进入boot.S, 最后他将调用c函数的bootmain,在这之前它把0x7c00之下的内存当作栈
# Set up the stack pointer and call into C., 把0x7c00之下当作栈 movl $start, %esp call bootmain
-
在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-0x00110000108000
? 这不是内核数据段的加载地址吗?到BSP单核启动完成为止,内核的空间布局如下图所示: -
此后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);// 但是映射的时候却不会将额外的虚拟地址映射到实际的物理内存上 } }