xv6 OS的启动主线可以压缩为:
QEMU/固件 -> 0x80000000 _entry -> start -> mret 进入 S 模式 main -> 0 号 hart 初始化内核子系统 -> userinit 创建首个用户进程 -> scheduler 调度 initcode -> initcode exec /init1._entry:准备每个 hart 的内核栈
0000000080000000 <_entry>: 80000000: 0001e117 auipc sp,0x1e 80000004: 14010113 addi sp,sp,320 # 8001e140 <stack0> 80000008: 6505 lui a0,0x1 8000000a: f14025f3 csrr a1,mhartid 8000000e: 0585 addi a1,a1,1 80000010: 02b50533 mul a0,a0,a1 80000014: 912a add sp,sp,a0 80000016: 0a9050ef jal 800058be <start> 000000008000001a <spin>: 8000001a: a001 j 8000001a <spin>QEMU/固件把内核放到0x80000000后跳到_entry。
这里主要做三件事:读取mhartid,按 hart 编号在.bss区域获得为本cpu分配的 4096 字节内核栈,并且让sp指针指向这个内核栈的栈顶,然后跳到start。
2.start:从 M 模式切到 S 模式
位置:kernel.asm:11671
// entry.S jumps here in machine mode on stack0.voidstart(){// set M Previous Privilege mode to Supervisor, for mret.unsignedlongx=r_mstatus();//x&=~MSTATUS_MPP_MASK;x|=MSTATUS_MPP_S;w_mstatus(x);// set M Exception Program Counter to main, for mret.// requires gcc -mcmodel=medanyw_mepc((uint64)main);// disable paging for now.w_satp(0);// delegate all interrupts and exceptions to supervisor mode.w_medeleg(0xffff);w_mideleg(0xffff);w_sie(r_sie()|SIE_SEIE|SIE_STIE|SIE_SSIE);// configure Physical Memory Protection to give supervisor mode// access to all of physical memory.w_pmpaddr0(0x3fffffffffffffull);w_pmpcfg0(0xf);// ask for clock interrupts.timerinit();// keep each CPU's hartid in its tp register, for cpuid().intid=r_mhartid();w_tp(id);// switch to supervisor mode and jump to main().asmvolatile("mret");}start完成 RISC-V 特权级和中断的基础设置:
- 修改
mstatus.MPP,让mret后进入supervisor mode。 - 设置
mepc = main,即跳转目标是 main。 - 清空
satp,早期暂不启用分页。 - 设置
medeleg/mideleg,把异常和中断委托给 S 模式。 - 开启 S 模式可见的外部中断、定时器中断、软件中断。
- 配置 PMP,让 S 模式可访问物理内存。
- 调用 timerinit 初始化每个 hart 的定时器中断。
- 保存
mhartid到tp,供之后cpuid()使用。 - 执行
mret,正式进入 S 模式的main。
3.main:0 号 hart 全局初始化,其他 hart 等待
位置:kernel.asm:551
if(cpuid()==0){consoleinit();printfinit();...kinit();kvminit();kvminithart();procinit();trapinit();trapinithart();plicinit();plicinithart();binit();iinit();fileinit();virtio_disk_init();userinit();started=1;}else{while(started==0);kvminithart();trapinithart();plicinithart();}scheduler();0 号 hart 负责全局初始化:
consoleinit():初始化 UART 硬件,然后把控制台注册为设备驱动,让read()/write()系统调用能找到它。printfinit():初始化内核printfkinit():物理分配器初始化,将[end, PHYSTOP]范围内的物理页面,挂载到空闲链表上kvminit():初始化内核页表,并且将一部分物理地址直接映射到相同的虚拟地址上- UART 串口寄存器 — 可读写,不可执行:
VA: UART0 ──────── PA: UART0 (0x10000000),大小为1 页 (4KB) - VirtIO 磁盘 MMIO — 可读写,不可执行:
VA: VIRTIO0 ─────── PA: VIRTIO0 (0x10001000),大小为1 页 (4KB) - PLIC 中断控制器 — 可读写:VA:
PLIC ────────── PA: PLIC (0x0C000000),大小为4MB (0x400000) - 内核代码段 — 可读可执行,不可写:
VA: KERNBASE ────── PA: KERNBASE (0x80000000),大小 : etext - KERNBASE,也就是[0x80000000,代码段末尾] - 内核数据 + 剩余物理内存 — 可读写:
VA: etext ───────── PA: etext,大小:PHYSTOP - etext[代码末尾 , 0x88000000] - 跳板页(TRAMPOLINE)— 可读可执行:
VA: TRAMPOLINE ──── PA: trampoline (放在 .text 段的 trampsec 里),这里需要注意,trampoline会从一个物理地址映射到两个虚拟地址,这里是映射到第二个物理地址TRAMPOLINE,第一次映射出现在上面的内核代码段映射。 - 各 CPU 的内核栈 — 每个进程 1 页(通过 proc_mapstacks)
- UART 串口寄存器 — 可读写,不可执行:
kvminithart():此函数执行以后,MAKE_SATP() 设置了 Sv39 模式,分页机制打开,MMU开始接管 CPU 访问的地址信息,并且将它们全部认为虚拟地址,然后刷新TLB,由于RISCV使用的是SMP内存模型,每个CPU都有一个独立的MMU和TLB,所有CPU统一的物理内存,因此后面每个CPU都必须调用一次这个函数。procinit():负责进程子系统的开机初始化:给 64 个进程槽位各配一把锁,并算好各自内核栈的虚拟地址trapinit():初始化保护 ticks 的锁,uint ticks记录了系统启动以来的时钟中断次数trapinithart():设置每个 CPU 的stvec指向kernelvec,保证在内核态执行时如果发生 trap,有一整套"保存→处理→恢复→返回"的标准流程,类似于用户态trap,stvec也是 per-CPU 的,各核心需要各自的中断入口地址。plicinit():将所需的中断请求优先级设置为非零值(否则将被禁用),这个函数只在主 CPU 上被调用一次,因为它设置的是 PLIC 的全局配置(所有 CPU 共享)plicinithart():每个 CPU 还要设置一下本CPU对应的中断使能位和优先级阈值,和plicinit()配合,前者设设备侧,后者设 CPU 侧,两边都配好 PLIC 才会把设备中断路由到指定核。binit():磁盘块缓存(buffer cache)初始化iinit():对文件系统里inode 缓存层(inode table)进行初始化,初始化的itable是内存中的「活动 inode 表」,缓存了正在被使用的 inode(被打开的文件、当前目录等)fileinit():初始化一把保护ftable的自旋锁,ftable是全系统共享的打开文件表,每个 struct file 代表一次打开(持有偏移量 offset、可读/可写标志、指向底层 inode 或 pipe 的指针、引用计数 ref),最多 NFILE 个。virtio_disk_init():virtio 磁盘驱动的硬件初始化,用于和 QEMU 模拟出来的虚拟磁盘设备走一整套virtio 协议握手,并把驱动和设备之间用来通信的内存结构搭建好,并不负责具体数据传输。userinit():创建第一个进程,其会执行exec(“/init”),
其他 hart 等started == 1后只做本 hart 相关初始化:开启分页、设置 trap vector、设置 PLIC,然后一起进入scheduler()。
4.scheduler:进入调度循环
// Per-CPU process scheduler.// Each CPU calls scheduler() after setting itself up.// Scheduler never returns. It loops, doing:// - choose a process to run.// - swtch to start running that process.// - eventually that process transfers control// via swtch back to the scheduler.voidscheduler(void){structproc*p;structcpu*c=mycpu();c->proc=0;for(;;){// Avoid deadlock by ensuring that devices can interrupt.intr_on();for(p=proc;p<&proc[NPROC];p++){acquire(&p->lock);if(p->state==RUNNABLE){// Switch to chosen process. It is the process's job// to release its lock and then reacquire it// before jumping back to us.p->state=RUNNING;c->proc=p;swtch(&c->context,&p->context);// Process is done running for now.// It should have changed its p->state before coming back.c->proc=0;}release(&p->lock);}}}注意几个要点
这个函数无参数、无返回值,每个 CPU 核心各自运行一个
scheduler()实例核心操作——上下文切换:
- 保存当前(调度器)的 CPU 上下文到
c->context - 恢复进程
p的上下文从p->context - 执行流跳转到进程
p上次被暂停的地方继续执行
- 保存当前(调度器)的 CPU 上下文到
核心切换函数
void swtch(struct context *old, struct context *new);这个函数由汇编写就,详细信息如下#Contextswitch#voidswtch(structcontext*old,structcontext*new);#Save current registers in old.Load from new..globl swtch swtch:sd ra,0(a0)sd sp,8(a0)sd s0,16(a0)sd s1,24(a0)sd s2,32(a0)sd s3,40(a0)sd s4,48(a0)sd s5,56(a0)sd s6,64(a0)sd s7,72(a0)sd s8,80(a0)sd s9,88(a0)sd s10,96(a0)sd s11,104(a0)ld ra,0(a1)ld sp,8(a1)ld s0,16(a1)ld s1,24(a1)ld s2,32(a1)ld s3,40(a1)ld s4,48(a1)ld s5,56(a1)ld s6,64(a1)ld s7,72(a1)ld s8,80(a1)ld s9,88(a1)ld s10,96(a1)ld s11,104(a1)ret由于进程目前还处在内核态,使用的是内核页表,所以直接根据传入寄存器a0和a1中的地址,进行相关寄存器的保存与切换,其中,initcode这个进程的ra在此处设置
//allocproc()memset(&p->context,0,sizeof(p->context));p->context.ra=(uint64)forkret;p->context.sp=p->kstack+PGSIZE;整体流程是
scheduler -> swtch(&c->context, &p->context) -> swtch.S 里的 ret -> forkret() -> usertrapret() -> sret 到用户态之后,会根据 trapframe->ra,也就是用户态的 ra 寄存器,进行跳转,在
userinit(void)中,明确设置了:// prepare for the very first "return" from kernel to userp->trapframe->epc=0;// user program counterp->trapframe->sp=PGSIZE;// user stack pointer因此,在执行
swtch(&c->context, &p->context);的过程中,PC就跳转到了forkret函数所在的位置,暂时不会回来了。
之后,如果进程 p 因为yield/sleep/exit等原因进入sched(),通过swtch(&p->context, &c->context)把 CPU 还给当前 CPU 的 scheduler 上下文;scheduler 从之前的swtch(&c->context, &p->context)后面继续执行,清空c->proc,释放锁,然后继续在无限循环里寻找下一个可运行进程。scheduler有点像“永远运行的调度循环”,但它不是守护进程。它没有struct proc,没有 PID,没有用户地址空间。它只是每个 CPU 在内核里运行的一段调度代码。
5.进入第一个进程
scheduler()在执行swtch(&c->context, &p->context)之后,首先会跳转到forkret()函数中,在这里会将文件系统初始化(只需要初始化一次),然后再进入到usertrapret(void)中。
这个函数会把进程从内核态安全地送回用户态,并且由于当前stvec指向kerneltrap(处理内核态的 trap),马上要回用户态了,需要把stvec切换为uservec,用于处理系统调用、中断和异常导致的trap,以及设置一些陷阱帧值,这些值将在进程下次重新进入内核时供用户向量使用,比如内核页表,进程的内核栈顶地址以及用户态trap的处理函数usertrap(),都将其保存在trapframe中。
在获得用户页表之后,会执行以下代码
// jump to trampoline.S at the top of memory, which// switches to the user page table, restores user registers,// and switches to user mode with sret.uint64 fn=TRAMPOLINE+(userret-trampoline);((void(*)(uint64,uint64))fn)(TRAPFRAME,satp);上述代码将会跳转到trampoline中的userret位置,正式执行返回用户态的代码,userret接下来会:
- 根据传入的
satp参数,切换到用户页表 - 从
TRAPFRAME指向的 trapframe 恢复所有 32 个用户寄存器 - 执行
sret,硬件自动切换到用户态
sret 之后,就正式进入了第一个进程:
┌──────────────────────────────────┐ │ pc=0x0(U-mode)│ │ sp=PGSIZE(用户栈)│ │ 页表=initcode 的用户页表 │ │ │ │ 执行地址0x0的指令:│ │ la a0,init # a0="/init"│ │ la a1,argv # a1=参数 │ │ li a7,7# SYS_exec │ │ ecall # → 内核执行exec│ └──────────────────────────────────┘这是initcode.S,也就是0x0处的汇编
#Initial process that execs/init.#This code runs in user space.#include"syscall.h"#exec(init,argv).globl start start:la a0,init la a1,argv li a7,SYS_exec ecall#for(;;)exit();exit:li a7,SYS_exit ecall jal exit#charinit[]="/init\0";init:.string"/init\0"#char*argv[]={init,0};.p2align2argv:.longinit.long0之后就是通过exec开始运行/init程序,/init程序再fork 出 shell,整个OS就正式启动起来了。