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 spinQEMU/固件把内核放到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.unsignedlongxr_mstatus();//x~MSTATUS_MPP_MASK;x|MSTATUS_MPP_S;w_mstatus(x);// set M Exception Program Counter to main, for mret.// requires gcc -mcmodelmedanyw_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 CPUs hartid in its tp register, for cpuid().intidr_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.main0 号 hart 全局初始化其他 hart 等待位置kernel.asm:551if(cpuid()0){consoleinit();printfinit();...kinit();kvminit();kvminithart();procinit();trapinit();trapinithart();plicinit();plicinithart();binit();iinit();fileinit();virtio_disk_init();userinit();started1;}else{while(started0);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_mapstackskvminithart()此函数执行以后MAKE_SATP() 设置了 Sv39 模式分页机制打开MMU开始接管 CPU 访问的地址信息并且将它们全部认为虚拟地址然后刷新TLB由于RISCV使用的是SMP内存模型每个CPU都有一个独立的MMU和TLB所有CPU统一的物理内存因此后面每个CPU都必须调用一次这个函数。procinit()负责进程子系统的开机初始化给 64 个进程槽位各配一把锁并算好各自内核栈的虚拟地址trapinit()初始化保护 ticks 的锁uint ticks记录了系统启动以来的时钟中断次数trapinithart()设置每个 CPU 的stvec指向kernelvec保证在内核态执行时如果发生 trap有一整套保存→处理→恢复→返回的标准流程类似于用户态trapstvec也是 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*cmycpu();c-proc0;for(;;){// Avoid deadlock by ensuring that devices can interrupt.intr_on();for(pproc;pproc[NPROC];p){acquire(p-lock);if(p-stateRUNNABLE){// Switch to chosen process. It is the processs job// to release its lock and then reacquire it// before jumping back to us.p-stateRUNNING;c-procp;swtch(c-context,p-context);// Process is done running for now.// It should have changed its p-state before coming back.c-proc0;}release(p-lock);}}}注意几个要点这个函数无参数、无返回值每个 CPU 核心各自运行一个scheduler()实例核心操作——上下文切换保存当前调度器的 CPU 上下文到c-context恢复进程p的上下文从p-context执行流跳转到进程p上次被暂停的地方继续执行核心切换函数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.spp-kstackPGSIZE;整体流程是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-epc0;// user program counterp-trapframe-spPGSIZE;// 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 fnTRAMPOLINE(userret-trampoline);((void(*)(uint64,uint64))fn)(TRAPFRAME,satp);上述代码将会跳转到trampoline中的userret位置正式执行返回用户态的代码userret接下来会根据传入的satp参数切换到用户页表从TRAPFRAME指向的 trapframe 恢复所有 32 个用户寄存器执行sret硬件自动切换到用户态sret 之后就正式进入了第一个进程:┌──────────────────────────────────┐ │ pc0x0(U-mode)│ │ spPGSIZE(用户栈)│ │ 页表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.#includesyscall.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就正式启动起来了。