entry,S -> start.c -> main.c -> proc.c中的 userinit 函数 -> initcode.S -> init.c
entry.S
// entry.S
# qemu -kernel loads the kernel at 0x80000000
# and causes each CPU to jump there.
# kernel.ld causes the following code to
# be placed at 0x80000000.
.section .text
.global _entry
_entry:
# set up a stack for C.
# stack0 is declared in start.c,
# with a 4096-byte stack per CPU.
# sp = stack0 + (hartid * 4096)
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
spin:
j spin
entry.S 用于在 QEMU 模拟器上启动 xv6 操作系统。它定义了启动过程中的入口点和初始化堆栈的过程。以下是对这段代码的逐行解释:
-
.section .text
:指定以下代码应该放置在文本段(.text
)中,这是用于存储程序代码的标准段。 -
.global _entry
:声明_entry
是一个全局符号,这意味着它可以被外部引用,例如链接器或其他汇编文件。 -
_entry:
:定义了一个标签_entry
,这是当前代码段的入口点。la sp, stack0
:使用la
指令加载stack0
的地址到sp
寄存器。stack0
是在start.c
中声明的一个数组,为每个 CPU 核心提供了一个 4096 字节大小的堆栈。li a0, 1024*4
:将立即数1024*4
(即 4096)加载到a0
寄存器。这个值是每个 CPU 核心堆栈的大小。csrr a1, mhartid
:使用csrr
指令读取mhartid
寄存器的值到a1
寄存器。mhartid
寄存器包含当前硬件线程(hart)的 ID。addi a1, a1, 1
:将a1
寄存器的值加 1。这是因为mhartid
寄存器的值从 0 开始,而stack0
数组的索引从 1 开始。mul a0, a0, a1
:将a0
寄存器(堆栈大小)与a1
寄存器(hart ID 索引)相乘,计算出当前 hart 的堆栈在stack0
数组中的偏移量。add sp, sp, a0
:将计算出的偏移量加到sp
寄存器(当前 hart 的堆栈顶部)上,设置正确的堆栈指针。call start
:跳转到start
函数。start
函数是start.c
中定义的,它初始化操作系统并进入调度器的主循环。
-
spin:
:定义了一个标签spin
。j spin
:无限跳转到spin
标签,形成 spinlock(自旋锁)。如果start
函数返回,控制权会回到这里,但由于start
函数不应该返回,这个自旋锁实际上会阻止 CPU 执行任何其他代码。
这段代码确保每个 CPU 核心都有一个独立的堆栈(sp = stack0 + (hartid + 1) * 4KB
),并且它们都跳转到 start
函数开始执行。这是操作系统启动过程的一部分,它在物理硬件或模拟器上启动操作系统之前设置必要的环境。
为什么必须首先设置栈指针?
- 时间顺序上的必要性:
_entry
是内核的第一条指令,紧接着需要调用start
函数。如果栈指针未设置,call
指令会尝试将返回地址压栈,但由于sp
无效,可能会写入非法地址,导致崩溃。 - 硬件约束:RISC-V 的启动规范不保证
sp
的初始值,QEMU 也不会自动初始化栈指针。因此,操作系统必须显式设置。 - 后续依赖:
start.c
中的代码会进一步调用其他函数(如 timerinit),这些函数依赖栈来存储局部变量和调用上下文。如果没有栈,整个初始化过程无法继续。
start.c
start 函数负责初始化 CPU 的特权模式、内存管理和中断处理,并最终跳转到用户空间的 main 函数执行。
- 设置 M 模式的返回特权级别:
通过读取和修改mstatus
寄存器,将 Previous Privilege (PP) 模式设置为 Supervisor (S) 模式。当后续执行 mret 时,CPU 将从 M 模式切换到 S 模式。 - 设置 M Exception Program Counter:
将mepc
寄存器设置为main
函数的地址。这意味着当 CPU 从中断或异常返回时,它将跳转到main
函数继续执行。 - 禁用分页机制:
通过将satp
寄存器设置为 0,暂时禁用分页机制。此时内核还未初始化页表(这在 main() 中的 kvminit() 完成),因此暂时禁用分页。这是在操作系统完全启动并设置好内存管理单元 (MMU) 之前的必要步骤。 - 设置中断和异常代理到 S 模式:
通过写入medeleg
和mideleg
寄存器,将所有中断和异常代理给 Supervisor 模式处理。同时,通过设置sie
寄存器启用 Supervisor 模式的中断和异常。 - 配置物理内存保护:
通过设置pmpaddr
和pmpcfg
寄存器,配置物理内存保护,允许 Supervisor 模式访问所有物理内存地址。 - 初始化时钟中断:
调用timerinit
函数,初始化时钟中断,使 CPU 能够在需要时请求中断。 - 保存硬件线程 ID:
将当前硬件线程 (hart) 的 ID 保存到tp
寄存器中,以便后续可以通过cpuid
函数获取。 - 切换到 Supervisor 模式并跳转到 main 函数:
通过执行mret
指令,切换到 Supervisor 模式,并跳转到main
函数开始执行。这标志着操作系统启动流程的完成,控制权转移到用户空间的入口点。
这段代码是操作系统启动过程中的关键部分,它确保了 CPU 正确配置并准备好执行用户空间的程序。通过这些初始化步骤,操作系统能够管理内存、处理中断和异常,并提供一个安全的环境来运行用户程序。
// entry.S jumps here in machine mode on stack0.
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
// 这些行设置了 RISC-V 机器状态寄存器(mstatus)的 Previous Privilege (PP) 模式为 Supervisor(S)模式。
// 这是为了确保在执行 mret 指令(从中断或异常返回)时,CPU 能够返回到 Supervisor 模式。
unsigned long x = 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=medany
// 这里将RISC-V机器异常程序计数器(mepc)设置为main()函数的地址。
// 这是中断或异常处理完成后,CPU应该返回执行的地址。
w_mepc((uint64)main);
// disable paging for now.
// 这行代码禁用了分页机制,将 RISC-V 机器的页表根地址寄存器(satp)设置为 0。
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
// 这些行设置了中断和异常的代理(delegation),允许Supervisor模式处理所有的中断和异常。
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.
// 这里配置了物理内存保护(PMP),允许Supervisor模式访问所有物理内存。
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);
// ask for clock interrupts.
// 这行代码初始化了时钟中断,以便 CPU 可以在需要时请求中断。
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
// 这里将当前硬件线程(hart)的ID保存到RISC-V的线程指针(tp)寄存器中,
// 以便后续可以通过cpuid()函数获取
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
// 这行代码通过执行 mret 指令(从中断或异常返回)来切换到 Supervisor 模式,并跳转到 main() 函数开始执行。
asm volatile("mret");
}
main.c
void
main()
{
// 核0执行的代码
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator 初始化物理页分配器,这是操作系统管理内存的关键部分。
kvminit(); // create kernel page table 初始化内核页表,这是虚拟内存管理的基础。
kvminithart(); // turn on paging 在当前 hart 上启用分页机制。
procinit(); // process table 初始化进程表,这是操作系统管理进程的关键数据结构。
trapinit(); // trap vectors 初始化中断和异常处理向量,这是操作系统响应硬件事件的关键部分。
trapinithart(); // install kernel trap vector 在当前 hart 上安装内核陷阱向量。
plicinit(); // set up interrupt controller 初始化 PLIC(可编程中断控制器),这是处理设备中断的关键组件。
plicinithart(); // ask PLIC for device interrupts在当前 hart 上启用 PLIC。
binit(); // buffer cache 初始化缓冲区缓存,这是文件系统的一部分,用于提高磁盘 I/O 性能。
iinit(); // inode table 初始化 inode 表,这是文件系统管理文件和目录的关键数据结构。
fileinit(); // file table 初始化文件表,这也是文件系统的一部分。
virtio_disk_init(); // emulated hard disk 初始化虚拟 I/O 磁盘,这是模拟的硬盘设备,用于提供文件系统存储。
userinit(); // first user process 初始化用户进程,这是创建第一个用户级进程的步骤。
__sync_synchronize();
started = 1; // 确保所有初始化操作都完成后,设置 started 标志为 1,表示系统已经启动。
} else { // 其他核执行
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}
scheduler(); // 最后,调用 scheduler() 函数启动操作系统的调度器,这是操作系统多任务处理的核心部分。一旦调度器启动,它将负责在多个进程之间进行上下文切换,确保系统高效运行。
}
main() 的初始化顺序遵循“从底层到高层”、“从基础到应用”的原则。每个步骤都为后续步骤铺路,避免未定义行为。
初始化顺序的逻辑
- 基础设施优先:
- 控制台和打印(consoleinit、printfinit)→ 调试支持。
- 内存管理(kinit、kvminit、kvminithart)→ 分配和寻址基础。
- 核心功能:
- 进程(procinit)和陷阱(trapinit、trapinithart)→ 运行和管理任务。
- 中断(plicinit、plicinithart)→ 处理外部事件。
- 文件系统和设备:
- 缓冲区(binit)、inode(iinit)、文件表(fileinit)、磁盘(virtio_disk_init)→ 支持用户态文件操作。
- 用户态准备:
- userinit() → 启动用户进程。
必须先初始化什么?
- kinit() 必须在任何需要内存分配的步骤之前(如 kvminit、procinit)。
- kvminit() 和 kvminithart() 必须在访问虚拟地址的步骤之前(如陷阱处理、设备驱动)。
- trapinit() 和 trapinithart() 必须在可能触发中断的步骤之前(如 plicinit、virtio_disk_init)。
- 文件系统组件(binit、iinit、fileinit)必须按顺序初始化,因为它们有层次依赖。
- userinit() 必须最后,因为它依赖所有前置组件。
userinit
// Set up first user process.
void
userinit(void)
{
struct proc *p;
p = allocproc();
initproc = p;
// allocate one user page and copy init's instructions
// and data into it.
// 初始化新进程的页表,并为它分配一页用户内存。
// initcode 是一个包含启动代码的数组,它的大小是一页(PGSIZE)。这页内存将包含init进程的代码和数据。
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
// prepare for the very first "return" from kernel to user.
// 设置新进程的初始用户程序计数器(EPC)和用户栈指针(SP)。EPC 设置为 0,这通常是用户程序入口点的地址。
// SP 设置为一页的大小,这是用户栈的起始地址。
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer
// 使用 safestrcpy 函数复制字符串 "initcode" 到新进程的名字字段。这个字段用于调试和识别进程。
safestrcpy(p->name, "initcode", sizeof(p->name));
// 获取根目录的 inode 并将其赋给新进程的当前工作目录(cwd)
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}
在 allocproc()
中会通过 p->context.ra = (uint64)forkret;
设置 ra
寄存器,ra
寄存器会存储新创建的进程进行第一个 switch
调用会返回的位置,即 forkret
函数。
// A fork child's very first scheduling by scheduler()
// will swtch to forkret.
void
forkret(void)
{
static int first = 1;
// Still holding p->lock from scheduler.
release(&myproc()->lock);
if (first) {
// File system initialization must be run in the context of a
// regular process (e.g., because it calls sleep), and thus cannot
// be run from main().
first = 0;
fsinit(ROOTDEV);
}
// 这是重点!!!从内核空间 -> 用户空间
usertrapret();
}
执行完 userinit
后,main
函数运行 scheduler
进行第一次调度,调度现在仅有的 RUNNABLE 状态的进程 init
。
initcode.S
# 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
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
start
部分调用 SYS_exec
系统调用执行 init
函数。
// init.c
// init: The initial user-level program
#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/spinlock.h"
#include "kernel/sleeplock.h"
#include "kernel/fs.h"
#include "kernel/file.h"
#include "user/user.h"
#include "kernel/fcntl.h"
char *argv[] = { "sh", 0 };
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
for(;;){
// this call to wait() returns if the shell exits,
// or if a parentless process exits.
wpid = wait((int *) 0);
if(wpid == pid){
// the shell exited; restart it.
break;
} else if(wpid < 0){
printf("init: wait returned an error\n");
exit(1);
} else {
// it was a parentless process; do nothing.
}
}
}
}
init
进程是系统启动后的第一个进程,它的主要任务是启动 shell(通常是一个命令行解释器),并且如果 shell 进程退出,则不断地重启它。这样可以确保系统始终有一个可用的 shell 来接受用户命令。
通过这种机制,init
进程确保了系统中始终有一个可用的 shell,即使用户退出了当前的 shell 会话。这种设计使得 xv6 操作系统具有更好的健壮性和可用性。