启动过程简介
- RISC-V 加电之后,会运行保存在 ROM 中的BootLoader
- BootLoader 将 xv6 内核加载到内存物理地址为0x80000000的地方,因为0x0~0x80000000这一段地址将用来操作 I/O 设备
- CPU 开始运行
kernel/entry.S中的_entry函数,这个函数主要用于开辟栈空间,以便后续运行C代码 - 每一个CPU都会有自己的栈,将栈指针指向stack0 + 4096 * CPU_ID 位置
_entry函数调用kernel/start.c中start()函数start函数配置M-mode一些寄存器start函数配置 mepc 寄存器值为kernel/main.c中main函数地址,这样start函数返回时会执行main函数start函数关闭分页,初始化时钟中断,最后调用mret指令进入到S-mode,并开始执行main函数main函数初始化所有设备和子系统,初始化地址空间,创建内核页表,分配一个物理内存页给内核栈- main 函数调用 userinit 函数创建第一个用户进程
- 第一个用户进程执行的代码就是
user/initcode.S中代码,其实际上就是调用exec("\init", 0), 执行init程序 - init程序创建文件描述符0,1,2,并开启一个shell窗口
- init程序子进程是一个shell,其本身在无限循环处理孤儿进程
- 系统成功启动
RISC-V总共有三种模式:
- M-mode (Machine Mode)
- S-mode (Supervisor Mode)
- U-mode (User mode)
在系统加电之后,会处于M-mode
Makefile文件解析
在启动xv6时,我们会执行命令make qemu,我们就从这一条命令开始来解析Makefile,然后讲述xv6操作系统的整个操作过程。
在Makefile文件中找到 qemu 目标:
# 定义 qemu 具体命令
QEMU = qemu-system-riscv64
# ---- 定义 qemu 启动参数 ----
QEMUOPTS = -machine virt -bios none -kernel $K/kernel -m 128M -smp $(CPUS) -nographic
QEMUOPTS += -drive file=fs.img,if=none,format=raw,id=x0
QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
ifeq ($(LAB),net)
QEMUOPTS += -netdev user,id=net0,hostfwd=udp::$(FWDPORT)-:2000 -object filter-dump,id=net0,netdev=net0,file=packets.pcap
QEMUOPTS += -device e1000,netdev=net0,bus=pcie.0
endif
# ---------------------------
# qemu 目标
qemu: $K/kernel fs.img
$(QEMU) $(QEMUOPTS)
可以看到 qemu 目标依赖于 $K/kernel 和 fs.img 这两个目标
$K/kernel 目标
下面是$K/kernel目标相关makefile代码,主要作用是编译内核文件
K=kernel
U=user
# ---- 定义编译内核文件过程中依赖的文件 ----
OBJS = \
$K/entry.o \
$K/kalloc.o \
$K/string.o \
$K/main.o \
$K/vm.o \
$K/proc.o \
...
OBJS_KCSAN = \
$K/start.o \
$K/console.o \
$K/printf.o \
$K/uart.o \
$K/spinlock.o
# --------------------------------------
LD = $(TOOLPREFIX)ld
LDFLAGS = -z max-page-size=4096
# 可以看到 $K/kernel 目标还依赖于 $U/initcode 目标
$K/kernel: $(OBJS) $(OBJS_KCSAN) $K/kernel.ld $U/initcode
# 指定kernel/kernel.ld为链接脚本,将目标文件进行链接,输出可执行文件kernel/kernel
$(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS) $(OBJS_KCSAN)
# 将 kernel 可执行文件反汇编出来,并将其保存在kernel/kernel.asm中
$(OBJDUMP) -S $K/kernel > $K/kernel.asm
# 将kernel 符号表入口地址打印到 kernel/kernel.sym 中
$(OBJDUMP) -t $K/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $K/kernel.sym
sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d'在sed中
d表示删除命令,s表示替换命令,a表示新增操作,c表示取代操作,i表示插入操作 所以:
1,/SYMBOL TABLE/d;表示删除第一行到出现SYMBOL TABLE字符串第一次出现的行s/ .* / /;表示将被空格包围的字符串替换成一个空格/^$$/d表示将一行中只有一个$字符的行删除
objdump -s : 显示指定 section 完整内容,默认所有的非空 section 都会被显示。
objdump -S : 尽可能反汇编出源代码,尤其当编译时指定 -g 这种调试参数时,效果比较明显,隐含 -d 参数。
objdump -t : 显示文件的符号表入口。类似于
nm -s提供的信息。objdump -T : 显示文件的动态符号表入口,仅对动态目标文件有意义,比如某些共享库。它显示的信息类似于
nm -D | --dynamic显示的信息。
kernel.ld
kernel.ld文件是链接脚本,用于设置链接kernel可执行文件时的一些操作。
链接脚本最上方的两条脚本命令 OUTPUT_ARCH、ENTRY 实际并没有什么作用,也可以删除。ENTRY 的作用是指定ELF header中entry的值,并不能影响 .text 节内函数顺序。之前配置的GUN套件本身是用来适配RISC-V架构的,所以OUTPUT_ARCH默认为riscv。而在链接命令$(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS) $(OBJS_KCSAN),其中的$(OBJS)的第一位为entry.o,所以entry.o包含的符号会在链接合并过程中排在最前面。
OUTPUT_ARCH( "riscv" )
ENTRY( _entry )
下面开始对链接后的section进行设置,比较关键的是.text节的设置,剩下设置都是对文件的数据节进行合并。
SECTIONS
{
/*
* ensure that entry.S / _entry is at 0x80000000,
* where qemu's -kernel jumps.
*/
# 将定位器置于0x80000000,确保_entry函数在这个地址上
. = 0x80000000;
# 将 .text 节起点置于定位器所在地址,也就是0x80000000
.text : {
*(.text .text.*) # 合并所有文件.text节和任意以.text开头的节的内容,地址为当前定位器
. = ALIGN(0x1000); # 将定位器以0x1000(4096)为base进行地址对齐
_trampoline = .; # 将定位器现在位置复制给符号_trampoline,是用户进程陷入内核态的入口
*(trampsec) # 合并所有文件trampsec节(实际值定义在trampoline.S),地址为当前定位器
. = ALIGN(0x1000);
# 断言,确保trampoline不大于一个页
ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page");
# 定义一个新符号etext,值为定位器当前位置
PROVIDE(etext = .);
}
########### 合并所有文件的数据节,包括.rodata、.data、.bss节 ###########
.rodata : {
. = ALIGN(16);
*(.srodata .srodata.*) /* do not need to distinguish this from .rodata */
. = ALIGN(16);
*(.rodata .rodata.*)
}
.data : {
. = ALIGN(16);
*(.sdata .sdata.*) /* do not need to distinguish this from .data */
. = ALIGN(16);
*(.data .data.*)
}
.bss : {
. = ALIGN(16);
*(.sbss .sbss.*) /* do not need to distinguish this from .bss */
. = ALIGN(16);
*(.bss .bss.*)
}
#####################################################################
# 定义一个新符号end,位置为当前定位器位置
PROVIDE(end = .);
}
在Makefile中我们还看到有定义LDFLAGS = -z max-page-size=4096,这是用于定义合并后的section的最大长度,合并后的section被称为segment,也就是说这里设置一个segment最大长度为4096B,因为xv6载入新进程时会给每一个可以被载入内存的segment分配一个内存页,所以一个segment的长度不可以超过一个内存页大小。
$U/initcode 目标
$U/initcode: $U/initcode.S
$(CC) $(CFLAGS) -march=rv64g -nostdinc -I. -Ikernel -c $U/initcode.S -o $U/initcode.o
$(LD) $(LDFLAGS) -N -e start -Ttext 0 -o $U/initcode.out $U/initcode.o
$(OBJCOPY) -S -O binary $U/initcode.out $U/initcode
$(OBJDUMP) -S $U/initcode.o > $U/initcode.asm
ld 命令选项作用:
-N: 将 text section 和 data section 设置为可读写。
-e: 显示设置程序入口的符号名
-Ttext 0: 表示设置.text段的起始地址为0x0
$(LD) $(LDFLAGS) -N -e start -Ttext 0 -o $U/initcode.out $U/initcode.o命令作用就是将编译好的initcode.o文件链接成initcode.out文件,然后再通过$(OBJCOPY) -S -O binary $U/initcode.out $U/initcode命令将initcode.out可执行文件的二进制代码复制到initcode文件中编译出来的这个程序并不会实际在内核运行中用到,因为initcode这一段代码已经被硬编码到
kernel/proc.c文件中,具体流程后续会介绍。
做完上述这些操作,xv6内核和用户程序就已经编译完成。
fs.img 目标
接下来就会去执行fs.img目标,用于制作磁盘镜像,将上面编译好的用户程序都放置到磁盘镜像中
UEXTRA=
ifeq ($(LAB),util)
UEXTRA += user/xargstest.sh
endif
fs.img: mkfs/mkfs README $(UEXTRA) $(UPROGS)
mkfs/mkfs fs.img README $(UEXTRA) $(UPROGS)
可以看到fs.img目标还依赖mkfs/mkfs目标
mkfs/mkfs: mkfs/mkfs.c $K/fs.h $K/param.h
gcc $(XCFLAGS) -Werror -Wall -I. -o mkfs/mkfs mkfs/mkfs.c
这里将mkfs/mkfs.c文件编译成mkfs/mkfs可执行文件,然后再用这个可执行文件,将之前编译好的用户程序和README等文件制作成文件系统镜像fs.img,作为xv6操作系统启动的文件系统。
启动的具体流程
可以在qemu目标中可以看到,我们执行qemu命令时,会用 -kernel $K/kernel 参数指定内核文件,-file fs.img 参数指定文件系统镜像。qemu启动后,就可以理解为RISC-V机器上电了,它就会运行存储在ROM中的BootLoader程序,BootLoader程序就会去执行我们指定好的 kernel/kernel 内核。
我们在kernel/kernel.ld文件中可以得知kernel文件的入口为 _entry 函数,该函数定义在 kernel/entry.S 文件中。
kernel/entry.S
因为执行C程序需要栈,所以_entry函数中会给每个CPU都初始化一个栈。stack0 是定义在 kernel/start.c 中的一个符号,这里属于外部符号的引用了,在链接时就可以确定stack0的值。下面代码首先将sp指针设置为stack0,作为基址,然后将a0寄存器设置为4096,因为一个页为4KB,这里目的就是后续给每个CPU都分配一个页作为栈。
# kernel/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
mhartid是用于存放硬件线程号的寄存器,也就是存放的是CPU ID,将CPU ID放到a1寄存器中,因为CPU ID是从0开始的,这里要作为后续的乘数,所以需要先用add命令给a1寄存器加1,然后通过mul命令计算出当前CPU栈空间的和stack0的偏移量,用这个偏移量和sp指针相加就得到了当前CPU的栈空间的起始地址。最后调用 call start 指令执行定义在 kernel/start.c 中的start函数。
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
kernel/start.c
注意:start.c 的start函数代码中可能会涉及到很多RISC-V相关控制寄存器的知识,这部分知识对了解xv6操作系统影响并不大,所以可以根据自己兴趣决定是否去了解,能大致知道代码做了些什么操作既可。
start.c 中定义了一片4KB * CPU个数大小的空间,用于存放每个CPU的栈,stack0 符号在前文_entry 函数中使用到,它要求与16bit对齐。
// kernel/start.c
// entry.S needs one stack per CPU.
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];
下面开始start函数,开始先设置mstatus寄存器,使其在执行完mret指令之后,进入到supervisor mode。关于mstatus寄存器内容可以看:RISC-V特权级寄存器及指令文档_delegation register-CSDN博客。
// entry.S jumps here in machine mode on stack0.
void start() {
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus();
x &= ~MSTATUS_MPP_MASK;
x |= MSTATUS_MPP_S;
w_mstatus(x);
在xv6中总是以
r_<registername>()形式函数用于获取指定<registername>寄存器的值,例如上述代码中r_mstatus()就是获取mstatus寄存器值。 在xv6中总是以w_<registername>(value)形式函数用于设置指定<registername>寄存器值为value,例如上述代码中w_mstatus(x)就是将mstatus寄存器值设置为x变量存储的值。
设置mepc寄存器为main函数地址,这样执行完mret指令之后就会跳转到main函数
// set M Exception Program Counter to main, for mret.
// requires gcc -mcmodel=medany
w_mepc((uint64)main);
临时禁用分页功能,这样可以直接操作物理地址
// disable paging for now.
w_satp(0);
设置和中断异常相关的寄存器medeleg、mideleg、sie到supervisor mode下,关于这些寄存器的标志位的作用具体可以查看RISC-V文档。
// delegate all interrupts and exceptions to supervisor mode.
// 设置medeleg寄存器为0xffff,将所有异常(exception)委托给supervisor mode处理
w_medeleg(0xffff);
// 设置mideleg寄存器为0xffff,将所有中断(interrupt)委托给supervisor mode处理
w_mideleg(0xffff);
// 设置sie寄存器,使supervisor mode开启外部中断、时钟中断和软件中断
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
将pmpaddr0寄存器设置为0x3fffffffffffff,使得supervisor mode下可以访问所有物理地址。将pmcfg0寄存器设置为0xf,配置物理内存保护,使得supervisor mode下可以访问所有物理内存。
// configure Physical Memory Protection to give supervisor mode
// access to all of physical memory.
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);
初始化时钟中断,然后通过mhartid寄存器获取当前CPU ID,然后将CPU ID 保存到tp寄存器中。最后执行mret指令,该指令会返回到之前设置的mepc寄存器指向位置,也就是main函数。最后mret返回时,根据之前设置的一些相关寄存器的状态,机器模式会切换到supervisor mode下。
// ask for clock interrupts.
// 初始化时钟中断
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
kernel/main.c
main函数定义在 kernel/main.c 中,进行内核的一些初始化操作。
在第一个CPU执行main函数时,会进行一系列的初始化操作。关于初始化操作的详细解析会在后面文章逐一解析。
// kernel/main.c
// 作为CPU0是否初始化完成的标志
volatile static int started = 0;
// start() jumps here in supervisor mode on all CPUs.
void main() {
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
// 创建进程表
procinit(); // process table
// 设置系统中断向量
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
// 系统中断初始化
plicinit(); // set up interrupt controller
// 设备中断初始化
plicinithart(); // ask PLIC for device interrupts
// 磁盘缓冲初始化
binit(); // buffer cache
// 磁盘节点inode初始化
iinit(); // inode table
// 文件系统初始化
fileinit(); // file table
// 磁盘初始化
virtio_disk_init(); // emulated hard disk
// 创建第一个用户进程
userinit(); // first user process
// gcc提供的原子操作,保证内存访问的操作都是原子操作
__sync_synchronize();
// 表明CPU0已经完成了系统初始化
started = 1;
}
如果不是CPU0,则会循环等待CPU0进行系统初始化完成之后,才会进行下一步操作。
else {
// 循环等待CPU0初始化完成
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();
}
userinit函数
在 main 函数中调用的 userinit 函数用于启动内核后运行的第一个用户程序。
在 userinit 函数前有一个 initcode 数组,放置了第一个用户程序的二进制代码,这第一个用户程序执行的就是这段二进制代码。
// kernel/proc.c
// a user program that calls exec("/init")
// od -t xC initcode
uchar initcode[] = {
0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x45, 0x02,
0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x35, 0x02,
0x93, 0x08, 0x70, 0x00, 0x73, 0x00, 0x00, 0x00,
0x93, 0x08, 0x20, 0x00, 0x73, 0x00, 0x00, 0x00,
0xef, 0xf0, 0x9f, 0xff, 0x2f, 0x69, 0x6e, 0x69,
0x74, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
};
这段二进制代码实际上对应的就是user/initcode.S中的代码,可以通过od -t xC user/initcode 指令验证。
od -t xC指令的作用是将文件内容用十六进制形式展示user/initcode就是user/initcode.S编译后的文件,可以看makefile中$U/initcode目标得知
user/initcode.S 代码如下,start为其中第一个符号,编译后在二进制最前面。可以看出,start函数实际上执行的就是exec("/init", 0),也就是运行/init程序。
# user/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 # 将init符号地址存入到a0寄存器中
la a1, argv # 将argv符号地址存入到a1寄存器中
li a7, SYS_exec # 将exec系统调用号存入到a7寄存器中,具体可以看系统调用章节
ecall # 发起系统调用
# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit
# char init[] = "/init\0";
# 定义init符号,保存"/init\0"字符串
init:
.string "/init\0"
# char *argv[] = { init, 0 };
# 定义argv符号,保存的是一个long数组,第一个元素存init符号地址,第二个元素为0
.p2align 2
argv:
.long init
.long 0
下面是userinit函数代码,首先会通过allocproc 函数为第一个用户进程分配进程结构体proc,然后将initproc变量指向这个第一个用户进程的进程结构体。
// kernel/proc.c
// Set up first user process.
void userinit(void) {
struct proc *p;
p = allocproc();
initproc = p;
通过uvminit函数给进程分配一个页,且将initcode放置到这个页中,设置当前进程所占内存大小为PGSIZE。
// allocate one user page and copy init's instructions
// and data into it.
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
uvminit函数其实只服务于第一个进程的创建,主要是将initcode代码放到虚拟内存地址为 0x0 的位置上面。uvminit函数在kernel/vm.c文件中。
// kernel/vm.c
// Load the user initcode into address 0 of pagetable,
// for the very first process.
// sz must be less than a page.
void uvminit(pagetable_t pagetable, uchar *src, uint sz) {
char *mem;
if(sz >= PGSIZE)
panic("inituvm: more than a page");
// 申请一个物理内存页,并初始化内存页
mem = kalloc();
memset(mem, 0, PGSIZE);
// 将这个内存页进行虚拟地址映射,将其映射到进程虚拟地址0x0位置上
mappages(pagetable, 0, PGSIZE, (uint64)mem, PTE_W|PTE_R|PTE_X|PTE_U);
// 将传入的二进制代码复制到刚刚申请的内存中去,这样访问地址0x0,就可以访问到src指向的代码
memmove(mem, src, sz);
}
回到userinit函数,设置进程trapframe中保存的epc值为0,这样在返回用户空间时,pc寄存器就会恢复为epc保存的值,程序就会从地址0x0开始执行,也就是开始执行initcode中代码。将进程trapframe中保存的sp值为PGSIZE,也就是刚刚申请的内存页的顶端,sp是栈指针,因为栈是高地址向低地址扩展的,所以这里要将其设置为最顶部地址。
// kernel/proc.c
// prepare for the very first "return" from kernel to user.
p->trapframe->epc = 0; // user program counter
p->trapframe->sp = PGSIZE; // user stack pointer
将进程名称设置为initcode,将进程所在目录设置为/,将进程状态设置为RUNNABLE。当进程状态设置为RUNNABLE之后,main函数最后调用scheduler函数的时候,就会运行进程状态为RUNNABLE的进程,此时第一个用户进程initcode就开始运行了。从initcode.S代码中我们得知,第一个用户程序执行的是/init程序,其对应的是user/init.c中代码。
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
p->state = RUNNABLE;
// 调用allocproc函数时会加锁,所以最后需要释放锁
release(&p->lock);
}
user/init.c
init.c主要fork了一个子进程,然后打开shell窗口,方便用户进行交互操作。其主要做的就是fork();exec("sh", 0);
首先定义传给系统调用的参数,然后通过open函数打开console控制台,其文件描述符为0,作为stdin,然后通过dup函数将console控制台对应stdout和stderr,其文件描述符分别为 1 和 2。
// user/init.c
// init: The initial user-level program
char *argv[] = { "sh", 0 };
int main(void) {
int pid, wpid;
// 用可读可写方式打开控制台,此时是第一个打开文件,所以其文件描述符为0,作为stdin
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
fork一个子进程,然后使用exec执行shell程序。无限循环作用是用于保证前台一直会有shell开启,即使一个shell退出,也会立刻启动一个新的shell。第二层无限循环是由init进程执行,主要目的是用于不断回收孤儿进程的资源。
// 该层for用于保证前台一直有shell在运行
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
// 如果上面exec执行成功,则子进程后面语句都不会执行
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);
// wpid和pid(shell进程id)相等,证明shell进程退出,应跳出此层循环,重启shell
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.
// 孤儿进程资源已经被wait函数释放,所以这里无需做任何操作
}
}
}
}
这里
init程序可以回收孤儿进程资源是因为在程序退出时,会将其所有子进程的父进程通过reparent函数设置为init进程,可以看kernel/proc.c中exit函数代码,所以init进程中调用wait函数可以回收这些孤儿进程资源。
自此,xv6系统启动到我们看到shell界面的所有过程的解析就结束了。执行make qemu之后,控制台界面如下
xv6 kernel is booting这句打印在kernel/main.c的main函数中执行;hart %d starting这句打印也在kernel/main.c的main函数中执行;init: starting sh这句打印在user/init.c的main函数中执行。