系列文章目录
操作系统入门系列-MIT6.S081(操作系统)学习笔记(一)---- 操作系统介绍与接口示例
操作系统入门系列-MIT6.828(操作系统工程)学习笔记(二)----课程实验环境搭建(wsl2+ubuntu+quem+xv6)
操作系统入门系列-MIT6.828(操作系统工程)学习笔记(三)---- xv6初探与实验一(Lab: Xv6 and Unix utilities)
操作系统入门系列-MIT6.828(操作系统工程)学习笔记(四)---- C语言与计算机架构(Programming xv6 in C)
操作系统入门系列-MIT6.828(操作系统工程)学习笔记(五)---- 操作系统的组织结构(OS design)
操作系统入门系列-MIT6.828(操作系统工程)学习笔记(六)---- 初窥操作系统启动流程(xv6启动)
文章目录
- 系列文章目录
- 前言
- 一、xv6操作系统的启动
- 1.使用GDB跟踪启动流程
- 2.程序入口——支持C语言(为执行C语言代码作准备)
- 2.start.c——模式切换与赋权(为启动作准备)
- 3.main——初始化系统功能
- 4.userinit——准备第一个用户进程启动
- 5.initcode.S——第一个用户进程
- 6.init.c——初始化用户空间
- 7.shell——各种应用程序运行
- 总结
前言
本节对应的是MIT 6.828课程第三节:OS design
有大佬讲视频课程的内容进行了中文记录,链接如下:MIT6.828 简介
按照课程官方的进度安排:课程进度计划表
一、课前预习:
1.读xv6实验指导手册第二章
2.精读xv6源码: kernel/proc.h, kernel/defs.h, kernel/entry.S, kernel/main.c, user/initcode.S, user/init.c
3.略读xv6源码: kernel/proc.c and kernel/exec.c
二、课后任务:
1.完成Lab: system calls
本文主要探究xv6操作系统的启动流程,进而初步窥视操作系统的启动。因为xv6是一个以教育为目的的精简操作系统,所以xv6的启动并不能完全与实际操作系统的启动一致,本文作为“抛砖引玉”之用。
一、xv6操作系统的启动
启动的大致流程图如下:
系统调用的前置只是查看:
操作系统入门系列-MIT6.S081(操作系统)学习笔记(一)---- 操作系统介绍与接口示例
1.使用GDB跟踪启动流程
gdb可以帮助我们跟踪代码的执行,老师就是使用gdb进行跟踪。但是本文以“上帝视角”直接进行代码讲解,后面的实验会展示gdb的使用过程。
(1)首先在实验目录下使用命令:
make qemu-gdb
(2)打开另一个窗口,在实验目录下输入:
gdb-multiarch
gdb就启动了:
2.程序入口——支持C语言(为执行C语言代码作准备)
在实验目录下,查看文件kernel/kernel.asm,这个文件中存储着被编译后链接起来的kernel的汇编代码。
可以看到,kernel程序的起始地址是0x8000_0000
地址0x80000000是一个被QEMU认可的地址,也就是说如果你想使用QEMU,那么第一个指令地址必须是它。所以,我们会让内核加载器从那个位置开始加载内核,这一规定写入kernel.ld文件中,指导链接器工作。
初始代码是来自汇编文件kernel/entry.S,如下:该程序的功能就是先为每一个CPU分配一个4096byte的栈空间,之后转到start.c文件执行。
# qemu -kernel loads the kernel at 0x80000000. qemu -kernel 命令在0x80000000处加载内核代码到
# and causes each hart (i.e. CPU) to jump there. 并且使每个核心(即CPU)跳转到那里
# kernel.ld causes the following code to.
# be placed at 0x80000000.
# 链接脚本 kernel.ld 使得下面的代码被放置在地址 0x80000000。
.section .text
.global _entry
_entry:
# set up a stack for C. 为C语言建立一个栈空间
# stack0 is declared in start.c, stack0在start.c文件中被申明
# with a 4096-byte stack per CPU. 每个CPU都分配有4096(4K)的栈空间
# 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 转到statr.c执行
call start
spin:
j spin
2.start.c——模式切换与赋权(为启动作准备)
从kernel.asm文件可以看到,调用start函数的指令为:
jal ra,8000589e <start>
即跳转到0x800589e处执行代码,我们再顺着查看kernel.asm文件0x800589e处的代码,以及kernel/start.c文件:
下面是start.c的代码,具体的代码讲解在注释中。大致上来讲,strat.c的作用是:当前内核代码还运行在机器权限下,仅仅只是初始化了栈空间来支持C语言。start的目的是将芯片的权限由机器模式( machine mode)转变为监管模式(Supervisor mode),也就是kernel mode。并且在转变之前,将内存管理、中断与异常、时钟的管理权限给监管模式。最后在切换到监管模式后,跳转到main函数执行。
// entry.S jumps here in machine mode on stack0. entry.S
// 程序跳转至该函数,在机器模式下在stack0栈空间执行start函数
void start()
{
// set M Previous Privilege mode to Supervisor, for mret.
/*
设置 M Previous Privilege mode 为 Supervisor,以便于 mret 指令的执行。
在RISC-V中,mret指令用于从机器模式(Machine Mode)返回到之前的特权模式。
为了确保mret指令能够正确返回到Supervisor模式(S模式)
需要在执行mret之前将M模式的Previous Privilege mode设置为Supervisor模式。
这样,当mret指令执行时,处理器会从机器模式返回到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
// 将M异常程序计数器设置为main函数的地址,以便于mret指令的执行。
// 需要使用gcc编译器,并指定-mcmodel=medany选项。
/*
在RISC-V架构中,为了使用mret指令从机器模式(M模式)返回到之前的特权模式(例如S模式或U模式)
需要将M模式的异常程序计数器(Exception Program Counter, EPC)设置为要返回的程序的入口点。
在这个例子中,EPC被设置为指向main函数的地址
这样当执行mret指令时,处理器会跳转到main函数的起始位置继续执行。
此外,需要使用gcc编译器,并且需要指定特定的编译选项-mcmodel=medany。
这个选项告诉编译器使用中等大小的代码模型(medium code model)
这允许代码和数据在更大的地址范围内进行访问。
这对于某些嵌入式系统或需要较大地址空间的应用程序来说是必要的。
*/
w_mepc((uint64)main);
// disable paging for now.
// 当前暂时禁用分页机制
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode.
// 将所有中断和异常委托给管理模式。
/*
在RISC-V架构中,操作系统或固件设置的意图是将所有的中断和异常处理委托给管理模式(Supervisor Mode)
在RISC-V中,管理模式是介于用户模式(User Mode)和机器模式(Machine Mode)之间的一种特权模式
它允许操作系统执行一些受保护的操作,比如处理中断和异常。
通过将中断和异常委托给管理模式
操作系统可以更有效地管理这些事件,执行必要的处理
比如调度任务、处理I/O请求等
这种委托机制是通过设置特定的控制和状态寄存器来实现的
确保当发生中断或异常时,处理器能够自动切换到管理模式,并执行相应的处理程序。
在RISC-V中,这种机制是通过设置机器模式下的控制寄存器(如mideleg和medeleg)来实现的
这些寄存器允许操作系统指定哪些中断和异常应该被委托给管理模式处理。
*/
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.
// 配置物理内存保护,使得管理模式能够访问所有物理内存。
/*
在RISC-V架构中,操作系统或固件正在配置物理内存保护(Physical Memory Protection, PMP)机制
以便于管理模式(Supervisor Mode)能够访问整个物理内存空间
PMP是RISC-V架构中用于控制对物理内存访问权限的一种机制
它允许操作系统或固件定义一系列的内存区域
并为每个区域指定访问权限,如读、写和执行。
通过配置PMP,可以实现对物理内存的精细控制
确保只有授权的代码和数据可以被访问,从而提高系统的安全性和稳定性
在statr函数中,操作系统或固件正在设置PMP,以允许管理模式访问所有物理内存
这通常是为了执行某些需要广泛内存访问权限的操作,比如初始化内存、管理内存映射等。
配置PMP通常涉及设置特定的控制寄存器
如PMP配置寄存器(PMPADDRn)和PMP配置控制寄存器(PMPCFGn)
这些寄存器定义了内存区域的边界和访问权限。
*/
w_pmpaddr0(0x3fffffffffffffull);
w_pmpcfg0(0xf);
// ask for clock interrupts.
// 请求时钟中断
timerinit();
// keep each CPU's hartid in its tp register, for cpuid().
// 将每个CPU的hart ID存储在其tp(thread pointer)寄存器中,以便于cpuid()函数使用。
/*
在RISC-V架构中,hart ID是一个标识符,用于唯一标识系统中的每个CPU核心
tp寄存器通常用于存储线程相关的数据,如线程局部存储(Thread-Local Storage, TLS)的基地址
通过将hart ID存储在tp寄存器中,操作系统或应用程序可以方便地通过访问tp寄存器来获取当前执行线程的CPU核心信息。
cpuid()被设计用来返回当前执行线程的CPU的hart ID
通过这种方式,操作系统或应用程序可以实现对不同CPU核心的负载均衡、资源分配和性能监控等功能。
*/
int id = r_mhartid();
w_tp(id);
// switch to supervisor mode and jump to main().
// 切换到管理模式并跳转到 main() 函数。
asm volatile("mret");
}
3.main——初始化系统功能
根据kernel.asm代码:执行完start函数后,将会到main函数(0x80000362)执行
asm volatile("csrw mepc, %0" : : "r" (x));
800058be: ffffb797 auipc a5,0xffffb
800058c2: aa478793 addi a5,a5,-1372 # 80000362 <main>
800058c6: 34179073 csrw mepc,a5
下面是对汇编代码的解释,来自于海螺AI,是国产的免费的大模式,个人觉得很好用,安利一下:
1.asm 关键字表示这是一个内联汇编语句
2.volatile 关键字表示这个内联汇编语句不应该被编译器优化掉,即使它看起来没有改变任何变量的值。这通常用于执行一些有副作用的操作,比如修改硬件寄存器
3.“csrw mepc, %0” 是内联汇编指令本身。csrw 是一个RISC-V指令,用于将一个值写入到一个控制和状态寄存器(CSR)。mepc 是机器模式异常程序计数器(Machine Exception Program Counter),它保存了发生异常时应该返回的地址。%0 是一个占位符,表示内联汇编指令的第一个操作数。
4.: : “r” (x) 是内联汇编的输出操作数部分。它表示内联汇编指令的输出操作数,“r” 表示这个操作数应该被分配到一个通用寄存器中,(x) 是传递给内联汇编的变量。
5.auipc 是 “Add Upper Immediate to PC” 的缩写,它将一个立即数加到程序计数器(PC)上,并将结果存储在寄存器 a5 中。这里,0xffffb 是一个立即数,它被加到当前的PC上,以计算出一个绝对地址。
6.addi 是 “Add Immediate” 的缩写,它将一个立即数加到寄存器 a5 的值上。这里,-1372 是要加的立即数,它将 a5 寄存器的值调整为 main 函数的地址。
csrw mepc, a5 指令将 a5 寄存器的值(即 main 函数的地址)写入到 mepc 寄存器中。
总的来说,这段代码的目的是将 main 函数的地址写入到 mepc 寄存器中,这通常在异常处理或中断处理的上下文中发生,以便于在异常处理完成后能够返回到 main 函数继续执行。下面是kernel.asm中main函数的部分:
接着是kernel/main.c文件:细节的讲解写到了注释中,大致上就是在kernel模式下初始化一系列操作系统的功能,初始化完成后去执行userinit()函数
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"
volatile static int started = 0;
// start() jumps here in supervisor mode on all CPUs.
// 在所有CPU上,start() 函数在管理模式下跳转到这里
void
main()
{
if(cpuid() == 0){
//将读取和写入系统调用连接到控制台读取和控制台写入
//初始化串口
/*
操作系统或系统软件中的一种配置,即把系统调用(system calls)中的读取(read)和写入(write)操作
映射或关联到特定的函数或服务上。在操作系统中,系统调用是应用程序请求操作系统内核提供服务的一种机制
例如,当应用程序需要从文件或设备读取数据时,它会调用系统提供的读取系统调用接口。
在注释中提到的“consoleread”和“consolewrite”很是两个函数或服务
它们分别用于处理控制台(即命令行界面)的读取和写入操作
将系统调用“read”和“write”连接到这两个函数上意味着
当应用程序执行读取或写入系统调用时
实际上是在调用“consoleread”和“consolewrite”函数来处理与控制台的交互。
这种配置通常在操作系统启动时进行,或者在系统初始化过程中设置
它确保了应用程序可以通过标准的系统调用来与控制台进行交互,而无需直接操作硬件或底层细节
这种抽象层使得应用程序的编写更加简单,同时允许操作系统在底层实现上进行优化或更改,而不会影响到应用程序的正常运行。
*/
consoleinit();
//初始化printf函数
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator 设置好页表分配器(page allocator)
kvminit(); // create kernel page table 设置好虚拟内存,这是下节课的内容
kvminithart(); // turn on paging 打开页表,也是下节课的内容
procinit(); // process table 设置好初始进程或者说设置好进程表单
trapinit(); // trap vectors 初始化陷阱向量,在计算机系统中,
//陷阱向量通常指的是操作系统或CPU架构中用于处理异常情况(如错误条件或中断)的机制
//当发生异常或中断时,CPU会根据陷阱向量跳转到相应的处理程序,以处理这些事件。
trapinithart(); // install kernel trap vector //加载内核的陷阱向量
plicinit(); // set up interrupt controller //启动中断控制器
plicinithart(); // ask PLIC for device interrupts 设置好中断控制器PLIC(Platform Level Interrupt Controller)
//我们后面在介绍中断的时候会详细的介绍这部分,这是我们用来与磁盘和console交互方式
binit(); // buffer cache 分配buffer cache
iinit(); // inode table 初始化inode缓存
fileinit(); // file table 初始化文件系统
virtio_disk_init(); // emulated hard disk 初始化磁盘
userinit(); // first user process 最后当所有的设置都完成了,操作系统也运行起来了,会通过userinit运行第一个进程
__sync_synchronize();
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()函数:它将执行一个进程,永不返回。这个进程就是一个用户进程,与userinit有关。
// 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.
// 每个CPU的进程调度器。
// 每个CPU在完成自身设置后调用scheduler()函数。
// 调度器永不返回。它循环执行以下操作:
// - 选择一个进程来运行。
// - 通过swtch切换到该进程开始执行。
// - 最终,该进程通过swtch将控制权交还给调度器。
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
for(;;){
// The most recent process to run may have had interrupts
// turned off; enable them to avoid a deadlock if all
// processes are waiting.
// 最近运行的进程可能已经关闭了中断;
// 为了防止所有进程都在等待时发生死锁,需要重新启用它们。
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);
}
}
}
4.userinit——准备第一个用户进程启动
在main()中,初始化完成后会进入到useinit函数,进行用户空间初始化,kenel.asm对应的部分代码如下:
userinit函数的C语言代码如下(proc.c文件中):
// Set up first user process.
// 启动第一个用户进程
void
userinit(void)
{
struct proc *p;
p = allocproc();//为第一个用户程序分配进程
initproc = p;
// allocate one user page and copy initcode's instructions
// and data into it.
// 分配一个用户页,并将初始化代码的指令和数据复制到该页(4K)中。
uvmfirst(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
p->trace_mask = 0;//xxxxxxx
// prepare for the very first "return" from kernel to user.
// 准备进行从内核到用户空间的第一次“返回”
p->trapframe->epc = 0; // user program counter
/*
将进程的陷阱帧中的epc(Exception Program Counter)字段设置为0
epc寄存器在RISC-V架构中用于存储发生异常或中断时的程序计数器(PC)值
即下一条将要执行的指令的地址
将epc设置为0意味着当进程从内核模式返回到用户模式时,它将从地址0开始执行
这通常是一个异常情况,因为正常的程序不会从地址0开始执行。
*/
p->trapframe->sp = PGSIZE; // user stack pointer
/*
将进程的陷阱帧中的 sp(Stack Pointer)字段设置为 PGSIZE
sp 寄存器在RISC-V架构中用于存储当前栈顶的地址
PGSIZE 通常定义为一个页面的大小,这表示进程的栈指针被设置为指向一个新页面的起始地址
这为用户程序提供了一个初始的栈空间,以便于执行函数调用、局部变量存储等操作。
*/
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}
userinit有点像是胶水代码/Glue code(胶水代码不实现具体的功能,只是为了适配不同的部分而存在),它利用了XV6的特性,并启动了第一个进程。我们总是需要有一个用户进程在运行,这样才能实现与操作系统的交互,所以这里需要一个小程序来初始化第一个用户进程。这个小程序定义在initcode中:
5.initcode.S——第一个用户进程
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.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <start>:
#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
0: 00000517 auipc a0,0x0
4: 00050513 mv a0,a0
la a1, argv
8: 00000597 auipc a1,0x0
c: 00058593 mv a1,a1
li a7, SYS_exec
10: 00700893 li a7,7
ecall
14: 00000073 ecall
0000000000000018 <exit>:
# for(;;) exit();
exit:
li a7, SYS_exit
18: 00200893 li a7,2
ecall
1c: 00000073 ecall
jal exit
20: ff9ff0ef jal ra,18 <exit>
0000000000000024 <init>:
24: 696e692f 0x696e692f
28: 0074 addi a3,sp,12
...
000000000000002b <argv>:
...
可以看到这个汇编程序的指令的初始地址就是0x00000000,结合userinit中的一个代码:
p->trapframe->epc = 0; // user program counter
/*
将进程的陷阱帧中的epc(Exception Program Counter)字段设置为0
epc寄存器在RISC-V架构中用于存储发生异常或中断时的程序计数器(PC)值
即下一条将要执行的指令的地址
将epc设置为0意味着当进程从内核模式返回到用户模式时,它将从地址0开始执行
这通常是一个异常情况,因为正常的程序不会从地址0开始执行。
*/
从内核转到用户空间,将从地址0开始执行,也就是执行上述的汇编代码,汇编代码翻译为C语言如下:即使用exec系统调用执行init.c代码。
#include "syscall.h"
char init[]="/init\0";
char *argv[]={init, 0}
exec("init", argv);
for(;;) exit();
6.init.c——初始化用户空间
init.c已经是用户空间中的程序了,代码如下:该程序很简单,就是使用fork+exec的系统调用组合启动shell
// 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.
}
}
}
}
7.shell——各种应用程序运行
总结
文章展示的是xv6启动的大致流程,有两个问题:(1)一些硬件和编译链接的细节没有体现(2)xv6是小型教学操作系统,只包含核心启动流程,并不是现实中诸如Linux系统的实际启动流程,但是对学习现实中操作系统的实际启动流程有帮助