内核初始化
bootloader将内核载入物理地址0x100000,通过跳转命令正式将控制权交给内核
# Entering xv6 on boot processor, with paging off.
.globl entry
entry:
这里的entry便是内核最开始运行的代码,前面说过,此时虽然已经开始了保护模式但是分页机制并没有开启,这个时候线性地址等于物理地址,但是内核中所有的符号地址都是位于高内存处的虚拟地址,所以最开始先设置页表并开启分页机制
# Turn on page size extension for 4Mbyte pages
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4
# Set page directory
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0
在这里可以看到内核将entrypgdir的物理地址载入cr3寄存器并开启分页,由于内核中
虚拟地址(线性地址)-KERNBASE(0x80000000) = 物理地址
所以即便未开启分页机制的情况下仍然能够正确寻址到entrypgdir,entrypgdir内容为:
__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,
};
从代码中可看出来,页表只是暂时将内核虚拟地址的4Mb内存映射到物理地址的低内存,但是需要注意的一个问题是,eip依然指向的物理地址的低地址处,因为虽然分页机制开启了,但是由于未发生跳转,所以eip仍然在低地址处增加指令计数,此时的eip是虚拟地址,需要经过页表转换,所以必须在页表中将虚拟地址的低地址处映射到物理地址的低地址处,这里直接将虚拟地址前4Mb映射到物理地址前4Mb。
接下来便调用c函数main,但是现在的栈是bootloader设置的不处在内核中,所以首先得把栈设为内核栈,然后通过跳转使eip指向高地址处
# 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
进入main函数后,便进行一系列的初始化,每个函数的初始化作用注释已经很清楚了,具体实现会在后面的博客中解释
// doing some setup required for memory allocator to work.
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
}
多核启动
多核启动主要是按照Intel的手册进行各种配置,具体配置由在main函数中调用的mpinit函数负责
Intel系列多核CPU在启动时首先会有一个CPU先运行内核代码,然后通过具体的配置使其他CPU开始运行,xv6通过一个结构体将每个CPU的信息保存起来,具体的cpu结构体如下:
// Per-CPU state
struct cpu {
uchar apicid; // Local APIC ID
struct context *scheduler; // swtch() here to enter scheduler
struct taskstate ts; // Used by x86 to find stack for interrupt
struct segdesc gdt[NSEGS]; // x86 global descriptor table
volatile uint started; // Has the CPU started?
int ncli; // Depth of pushcli nesting.
int intena; // Were interrupts enabled before pushcli?
// Cpu-local storage variables; see below
struct cpu *cpu;
struct proc *proc; // The currently-running process.
};
可以看出,每个CPU都有独立的scheduler内核线程句柄,ts任务栈,gdt描述符表和唯一的标识apicid,Intel的每个CPU都有独立的lapic,每个lapic有一个ID,apicid是区分CPU的重要标识。
xv6使用一个数组来保存这样的结构体,并用一个全局变量表示CPU数量:
extern struct cpu cpus[NCPU];
extern int ncpu;
这里有个迷惑了我很久的地方:
extern struct cpu *cpu asm("%gs:0"); // &cpus[cpunum()]
extern struct proc *proc asm("%gs:4"); // cpus[cpunum()].proc
通过指针cpu和proc,能够准确引用当前所在CPU的cpu结构体(每个cpu有一个这样的结构体,当然每个运行代码的CPU都应该引用自己的cpu结构,通过这种做法可以直接使用cpu->,proc-> 的方式自动引用独立的cpu结构体),这里的做法是在访问这两个变量的时候通过段寄存器gs访问,得到的线性地址便是:
gs选择子对应的描述符基址 + 0或4 === 线性地址
因为每个CPU都有独立的gdt,所以在设置gdt的时候可以这样设置:
// Map cpu and proc -- these are private per cpu.
c->gdt[SEG_KCPU] = SEG(STA_W, &c->cpu, 8, 0);
lgdt(c->gdt, sizeof(c->gdt));
loadgs(SEG_KCPU << 3);
看到这里便一目了然了,通过提前设置gs寄存器和gdt的描述符,便能直接通过cpu和proc变量访问当前CPU的结构体和运行进程
cpu结构体在内核中是重要的数据结构,也是xv6中区分不同CPU的唯一地方,它的初始化是由mpinit函数负责的,前面说过,mpinit由main函数调用
mpinit初始化了cpus结构体数组,并确定了lapic地址,ioapicid
uchar ioapicid;
volatile uint *lapic; // Initialized in mp.c
volatile struct ioapic *ioapic; // ioapic在地址空间中有固定地址,这里写出来只是为了对比
具体的初始过程如下:
lapic = (uint*)conf->lapicaddr;
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
switch(*p){
case MPPROC:
proc = (struct mpproc*)p;
if(ncpu < NCPU) {
cpus[ncpu].apicid = proc->apicid; // apicid may differ from ncpu
ncpu++;
}
p += sizeof(struct mpproc);
continue;
case MPIOAPIC:
ioapic = (struct mpioapic*)p;
ioapicid = ioapic->apicno;
p += sizeof(struct mpioapic);
continue;
case MPBUS:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
ismp = 0;
break;
}
}
通过在地址空间中找到mp的数据结构后,得到每个CPU的apicid和CPU数量。
通过mpinit函数,有关的cpus结构体也初始完成了,接下来在main函数中调用startothers函数启动其他CPU。这段代码我没有深入研究,因为网上的资料太少,Intel手册很详细但是是英文的需要花很多时间,由于只是通过阅读xv6深入理解操作系统原理,所以没有过多深入研究Intel体系,但是通过代码还是能够猜出做的事情,如有错误希望能够指出来
startothers让其他CPU执行名为entryother.S对应的代码
entryother.S是作为独立的二进制文件与内核二进制文件一起组成整体的ELF文件,通过在LD链接器中-b参数来整合一个独立的二进制文件,在内核中通过_binary_entryother_start和_binary_entryother_size来引用,具体的makefile如下:
$(LD) $(LDFLAGS) -T kernel.ld -o kernelmemfs entry.o $(MEMFSOBJS) -b binary initcode entryother fs.img
entryothers在生成二进制文件时指定入口点为start以及加载地址和链接地址都为0x7000
entryother: entryother.S
$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c entryother.S
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7000 -o bootblockother.o entryother.o
$(OBJCOPY) -S -O binary -j .text bootblockother.o entryother
$(OBJDUMP) -S bootblockother.o > entryother.asm
在startothers中,首先将entryothers移动到物理地址0x7000处使其能正常运行,在这里需要注意的是此时entryothers相当于CPU刚上电的情形,因为这是其他CPU最初运行的内核代码,所以没有开启保护模式和分页机制,entryothers将页表设置为entrypgdir,在设置页表前,虚拟地址等于物理地址
// Write entry code to unused memory at 0x7000.
// The linker has placed the image of entryother.S in
// _binary_entryother_start.
code = P2V(0x7000);
memmove(code, _binary_entryother_start, (uint)_binary_entryother_size);
然后循环逐个开启每个CPU让每个CPU从entryothers中start标号开始运行:
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;
// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);
lapicstartap(c->apicid, V2P(code));
// wait for cpu to finish mpmain()
while(c->started == 0)
;
}
在这里同时还设置了每个CPU特有的内核栈以及共有的页表,并设置mpenter为entryothers最后跳转回内核的地址,entryothers主要进行CPU必要的初始化:从实模式切换到保护模式,开启分页机制,最后跳转到mpenter处,跳回内核
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;
// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);
lapicstartap(c->apicid, V2P(code));
// wait for cpu to finish mpmain()
while(c->started == 0)
;
}
mpter设置新的内核页表和进行段初始化,最后调用mpmain开始调度进程
switchkvm();
seginit();
lapicinit();
mpmain();
至此,所有的CPU都已经正常工作,这里只是简要说明了初始化流程,详细细节需要参考Intel手册,在这里放出手册地址吧:
http://www.intel.com/design/pentium/datashts/24201606.pdf
Intel编程开发手册第三卷也有一章是介绍多核处理器:
https://software.intel.com/en-us/articles/intel-sdm
其实多核心操作系统中最难处理的并不是初始化过程,而是在系统中如何处理好多个CPU的竞争和同步问题,以及在多CPU间的进程调度问题,多个CPU的竞争和同步问题困扰了我很久,在内核代码中需要非常小心细致地使用锁来避免竞争和同步,但是使用锁机制大大加大了编码难度,锁的不恰当使用会直接导致意想不到的死锁和宕机,后面会专门详细讨论xv6中锁机制的使用。