MIT 6.828 操作系统课程系列0 基础和环境#

https://pdos.csail.mit.edu/6.828/2022/schedule.html

用尽全力强烈建议先完成CS143编译原理课程。因为会比较熟悉汇编和程序的底层整体架构。再来看os就是顺水推舟。
我是以前看过os这个课程,做了一半lab,但很多细节其实一知半解。
后来整完CS143。再回过来补全os这个课,认知就完全不一样了。
必须搞懂较为底层的细节才有能力自己做一个os。

课程目的#

  1. 理解操作系统的设计和实现

  2. 学习risc-v架构

  3. 能动手实现系统应用

  4. 能动手扩展os

  5. 能实现一个小型os

背景#

  • RISC(Reduced Instruction Set Computer)精简指令集计算机。

  • CISC(Complex Instruction Set Computer)复杂指令集计算机。

  • 先有复杂指令集。越来越庞大、暴露各种缺点,就有了精简指令集。 risc不断进化,到了第五代,就是RISC-V。 RISC-V是开源的。人们可以在它基础上设计开发自己的cpu。
    https://riscv.org/
    riscv/riscv-isa-manual
    riscv/riscv-isa-manual

  • xv6是mit开发的一个教学级别的操作系统,受Unix V6影响。作者都是业界大佬级别,质量有保证。

  • xv6运行在多核64位riscv cpu

  • 本课程就用这个操作系统,会深入其代码。一共就五十几个代码文件。

  • 因为linux目前代码量巨大,很多层面上无从下手,那么从一个小而精的系统入手也是个很好的选择。 完成后希望能掌握一个os核心的框架。

  • xv6手册 https://pdos.csail.mit.edu/6.828/2022/xv6/book-riscv-rev3.pdf

主要按lab顺序来看代码,如果碰到相关性强的内容会提前看。
lab0尽量研究系统的基本启动相关流程。代码编译->制作fs->启动机器->运行kernel->用户的第一个程序。
其他代码在后续lab里研究。
每个lab都完成所有任务,得到满分。

环境#

环境安装非常简单

实验代码
git clone git://g.csail.mit.edu/xv6-labs-2022
实验代码是放在不同的分支里。默认branch为第一个lab。

也可以直接看正式的xv6代码mit-pdos/xv6-riscv
其make流程和各实验会有少许不同,总体一样的。

实验工具
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu

直接make qemu编完会自动进入模拟器运行kernel并显示命令行,就和一般linux系统一样。
默认的kernel功能简陋,只有一些最基本的命令。


qemu是一个开源的模拟器,主要用于各种系统的模拟。

比如模拟riscv架构,你编一个riscv的操作系统,可以在不同架构的windows桌面或者linux命令行之类环境下启动qemu来加载这个操作系统并运行。
https://www.qemu.org/docs/master/system/target-riscv.html#risc-v-system-emulator


virtio
虚拟化接口的一套标准/解决方案/框架。庞大的课题。

https://wiki.osdev.org/Virtio
https://docs.oasis-open.org/virtio/virtio/v1.1/virtio-v1.1.html
https://ozlabs.org/~rusty/virtio-spec/virtio-paper.pdf

qemu跑xv6代码也是用的virtio machine。


makefile的qemu部分

qemu: $K/kernel fs.img
    $(QEMU) $(QEMUOPTS)

QEMU = qemu-system-riscv64 # qemu启动64位riscv系统

QEMUOPTS = -machine virt # machine类型
           -bios none    # 指定bios
           -kernel $K/kernel # kernel的bzImage
           -m 128M # ram
           -smp $(CPUS) # SMP system cpu数
           -nographic # 无图形化
QEMUOPTS += -global virtio-mmio.force-legacy=false
QEMUOPTS += -drive file=fs.img,if=none,format=raw,id=x0 # disk image
QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0

qemu参数
https://www.qemu.org/docs/master/system/invocation.html

列出machine类型

qemu-system-riscv64 -machine help
Supported machines are:
none                 empty machine
sifive_e             RISC-V Board compatible with SiFive E SDK
sifive_u             RISC-V Board compatible with SiFive U SDK
spike                RISC-V Spike Board (default)
spike_v1.10          RISC-V Spike Board (Privileged ISA v1.10)
spike_v1.9.1         RISC-V Spike Board (Privileged ISA v1.9.1)
virt                 RISC-V VirtIO board

qemu-system-riscv64 -device help列出茫茫多设备。

启动qemu模拟器依赖于$K/kernelfs.img。即操作系统程序和文件系统。

xv6的mkfs流程#

这个其实较为独立,大概从lab5才开始用到。可以暂时跳过。可以看个大概。

先看下手册第8章了解文件系统。

fs.img: mkfs/mkfs README $(UPROGS)
    mkfs/mkfs fs.img README $(UPROGS)

# fs.img靠mkfs生成,传入UPROGS,一系列user程序。

UPROGS=\
    $U/_cat\
    $U/_echo\
    $U/_forktest\
    $U/_grep\
    $U/_init\
    $U/_kill\
    $U/_ln\
    $U/_ls\
    $U/_mkdir\
    $U/_rm\
    $U/_sh\
    $U/_stressfs\
    $U/_usertests\
    $U/_grind\
    $U/_wc\
    $U/_zombie\

ULIB = $U/ulib.o $U/usys.o $U/printf.o $U/umalloc.o

_%: %.o $(ULIB)
    $(LD) $(LDFLAGS) -T $U/user.ld -o $@ $^
    $(OBJDUMP) -S $@ > $*.asm
    $(OBJDUMP) -t $@ | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $*.sym

# UPROGS中的_%程序依赖于%.o。
# 根据gcc的隐式规则,会找对应的.c来编。
#
# linker参数见https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_node/ld_3.html
# -T参数指定user.ld
# 
# $@ $^见https://www.gnu.org/software/make/manual/html_node/Automatic-Variables.html#Automatic-Variables
# $@即为目标,最前边那个。$^为后面所有的依赖。
#
# linker为riscv64-linux-gnu-ld
# https://manpages.debian.org/testing/binutils-riscv64-linux-gnu/riscv64-linux-gnu-ld.1.en.html
#
# LDFLAGS中max-page-size设置最大页大小
 
# https://manpages.debian.org/testing/binutils-riscv64-linux-gnu/riscv64-linux-gnu-objdump.1.en.html
# riscv64-linux-gnu-objdump -S
# dump每个user程序到各自的.asm

# riscv64-linux-gnu-objdump -t
# 输出symbol table

# https://manpages.debian.org/testing/binutils-riscv64-linux-gnu/riscv64-linux-gnu-objcopy.1.en.html

mkfs/mkfs: mkfs/mkfs.c $K/fs.h $K/param.h
    gcc -Werror -Wall -I. -o mkfs/mkfs mkfs/mkfs.c

linker script资料
https://home.cs.colorado.edu/~main/cs1300/doc/gnu/ld_toc.html
https://www.eecs.umich.edu/courses/eecs373/readings/Linker.pdf
https://wiki.osdev.org/Linker_Scripts

linker此时可忽略。后续会看。


需要编出所有UPROGS即用户程序,也就是一些常用命令比如cat/ls/rm。

用mkfs.c编出mkfs。
mkfs/mkfs fs.img README $(UPROGS)生成文件系统fs.img。并把常用命令放到根目录。

先看手册第8章文件系统的block和inode相关内容和代码。

//文件系统基本信息

#define BSIZE 1024 // 文件系统把存储资源看作一系列block。每个block为1k字节。
#define FSSIZE 2000 // 文件系统总block数2000。大约2m。

// xv6文件系统布局

//            | boot | superblock |  log   |    inodes    |   bitmap  |         data       |

// block编号      0        1         2 ...        ...           ...         ... FSSIZE-1

// 数量           1        1         nlog     ninodeblocks    nbitmap          nblocks

#define MAXOPBLOCKS 10 // 一个fs操作进行写动作的最多block数
#define LOGSIZE (MAXOPBLOCKS*3) // log的block数

int nbitmap = FSSIZE/(BSIZE*8) + 1; // 每一位表示一个block的占用状态。2000个block算下来只用1个block就够了。1个block有8kbit。FSSIZE只有2000。
int nlog = LOGSIZE; // log的block数

// incode包含一个文件的信息
#define NDIRECT 12
struct dinode {
    short type;             // File type
    short major;            // Major device number (T_DEVICE only)
    short minor;            // Minor device number (T_DEVICE only)
    short nlink;            // Number of links to inode in file system
    uint size;              // Size of file (bytes)
    uint addrs[NDIRECT+1];  // 见xv6手册8.10
};

#define IPB (BSIZE / sizeof(struct dinode)) // 一个block可以放几个dinode结构体
#define NINODES 200                         // 最多200个inode/文件
int ninodeblocks = NINODES / IPB + 1;       // 所有inode所需的block数
nmeta = 2 + nlog + ninodeblocks + nbitmap;  // 除data外的block总数。系统自用数据。
nblocks = FSSIZE - nmeta;                   // 可用的block总数

// superblock填入文件系统总信息
struct superblock sb;
sb.magic = FSMAGIC;             // # 0x10203040
sb.size = xint(FSSIZE);
sb.nblocks = xint(nblocks);     // 可用的block总数。
sb.ninodes = xint(NINODES);     // inode总数/文件总数=200
sb.nlog = xint(nlog);           // log信息的block数
sb.logstart = xint(2);          // log从block2开始
sb.inodestart = xint(2+nlog);   // inode开始位置
sb.bmapstart = xint(2+nlog+ninodeblocks); // bitmap开始位置

// 生成文件系统fs.img流程

char zeroes[BSIZE];
char buf[BSIZE];

main
    fsfd = open(argv[1], O_RDWR|O_CREAT|O_TRUNC, 0666); //打开/创建fs.img

    // superblock填入基本信息

    // 往文件里写入FSSIZE个block,都为0。
    for(i = 0; i < FSSIZE; i++)
        wsect(i, zeroes);
            lseek(fsfd, sec * BSIZE, 0) // seek到指定的block
            write(fsfd, buf, BSIZE)     // 写block到文件。linux的write

    // superblock写到第一个block。
    // buf清零。sb挪到buf。buf写入文件。
    memset(buf, 0, sizeof(buf));
    memmove(buf, &sb, sizeof(sb));
    wsect(1, buf);

    // inode类型
    #define T_DIR     1   // Directory
    #define T_FILE    2   // File
    #define T_DEVICE  3   // Device

    #define IBLOCK(i, sb) ((i) / IPB + sb.inodestart) // 计算编号为i的inode在第几个block

    // 分配一个新的inode。类型为文件夹。
    // 根目录的inode号特地做成1
    uint freeinode = 1;
    rootino = ialloc(T_DIR);
        uint inum = freeinode++; // freeinode不断增长
        struct dinode din; // 起一个din

        bzero(&din, sizeof(din)); // 清零。即memset
        din.type = xshort(type); // 需转成riscv字节顺序
        din.nlink = xshort(1);
        din.size = xint(0);
        winode(inum, &din); // 写文件。把din写到第inum个inode位置
            char buf[BSIZE];
            uint bn;
            struct dinode *dip;

            bn = IBLOCK(inum, sb);    // 算出inode所在的block
            rsect(bn, buf);           // 读block到buf
            dip = ((struct dinode*)buf) + (inum % IPB); // dip指向对应的incode数据
            *dip = *ip;               // 直接把新起的din复制到buf对应的inode位置。
            wsect(bn, buf);           // 最后把buf写回文件

        return inum;

    /*

    inode具体数据布局

    #define NDIRECT 12                        // 12个直接block
    #define NINDIRECT (BSIZE / sizeof(uint))  // INDIRECT数量。用一个block存地址,每个地址用uint存,一共能存几个地址。1024/4=256。
    #define MAXFILE (NDIRECT + NINDIRECT)     // 一个文件的最大block数。12+256=268

    struct dinode {
        short type;             // File type
        short major;            // Major device number (T_DEVICE only)
        short minor;            // Minor device number (T_DEVICE only)
        short nlink;            // Number of links to inode in file system
        uint size;              // Size of file (bytes)
        uint addrs[NDIRECT+1];  // 见xv6手册8.10
    };

    12个direct block的地址直接放在addrs里。addrs最后存一个block地址,包含sizeof(uint)个indirect block。
    这样一个文件的前12k数据可以直接从inode数据中拿到。后续的数据得从那个indirect block中找。

    */

    // dirent是目录中的一个个实体。可能是文件,可能是目录。
    // 一个目录类型的inode里,实际数据就是一串dirent。
    #define DIRSIZ 14 // dir名字最大长度
    struct dirent {
        ushort inum;         // inode编号
        char name[DIRSIZ];   // 名字
    };

    struct dirent de;

    //当前目录
    de.inum = xshort(rootino); // 文件夹的inum设为新生成的rootino=1
    strcpy(de.name, ".");      // 名字设为.

    freeblock = nmeta; // 可用的block编号从nmeta开始

    // ialloc生成新的inode。
    // iappend把任意数据append到一个inode。可以多次append。
    // 多次的效果是多个dirent顺序排在data block和inode数据里。
    // 它这里先把"."和".."两个路径append到rootino。

    // 复制de的数据到rootino
    iappend(rootino, &de, sizeof(de)); // iappend(uint inum, void *xp, int n)
        char *p = (char*)xp;      // p指向dirent
        uint fbn, off, n1;
        struct dinode din;
        char buf[BSIZE];
        uint indirect[NINDIRECT]; // indirect block的地址。所有地址占一个block。实际是存block的id(见freeblock),并不是一般意义的地址。
        uint x;

        rinode(inum, &din);   // 从文件读出inum编号的inode到din
        off = xint(din.size); // 初始为0。如果已经append过,则为append的总数居。

        while(n > 0){                  // n = sizeof(de) 为剩余要复制的数据大小
            fbn = off / BSIZE;         // off从0开始不断增加。每次计算当前off落在哪个block。
            assert(fbn < MAXFILE);
            if(fbn < NDIRECT){
                // 如果落在direct
                if(xint(din.addrs[fbn]) == 0){
                    din.addrs[fbn] = xint(freeblock++); // 如果此block编号为0。做新编号。
                }
                x = xint(din.addrs[fbn]);               // 记录block编号
            } else {
                // 如果落在indirect。已经处理完direct部分。
                if(xint(din.addrs[NDIRECT]) == 0){          // addrs的最后一项为indirect block的编号
                    din.addrs[NDIRECT] = xint(freeblock++); // 为其分配block编号
                }
                rsect(xint(din.addrs[NDIRECT]), (char*)indirect);       // 读出此block
                if(indirect[fbn - NDIRECT] == 0){                       // 减去NDIRECT,即从0开始。
                    indirect[fbn - NDIRECT] = xint(freeblock++);        // 新做block编号
                    wsect(xint(din.addrs[NDIRECT]), (char*)indirect);   // 把新的indirect数据存进去。
                }
                x = xint(indirect[fbn-NDIRECT]);        // 记录block编号
            }

            // 这个代码挺精妙的。它可实现多次append。

            // 如果当前offset不是1k对齐,就尝试先补齐。
            // 比如offset为345字节,就先复制679字节(如果剩余大于679字节)。
            // 然后就是每次复制1k,直到最后一次复制<=1k的数据。

            n1 = min(n, (fbn + 1) * BSIZE - off);
            rsect(x, buf);
            bcopy(p, buf + off - (fbn * BSIZE), n1);
            wsect(x, buf);
            n -= n1;
            off += n1;
            p += n1;
        }
        din.size = xint(off);  // 设置为最新的size
        winode(inum, &din);    // 更新inode到文件

    // append ..路径
    de.inum = xshort(rootino);
    strcpy(de.name, "..");
    iappend(rootino, &de, sizeof(de));

    // 运行mkfs的参数为mkfs/mkfs fs.img README $(UPROGS)
    // 对其余每个文件都起新的inode,并append到rootino。

    // 对于T_DIR类型的inode,append路径。
    // 对T_FILE类型的inode,直接append文件的原始数据。

    for(i = 2; i < argc; i++)
        fd = open(argv[i], 0)

        inum = ialloc(T_FILE); // 起inode。类型为file。

        // 新文件的inode append到rootino
        de.inum = xshort(inum);
        strncpy(de.name, shortname, DIRSIZ);
        iappend(rootino, &de, sizeof(de));

        // 直接append文件的原始数据
        while((cc = read(fd, buf, sizeof(buf))) > 0)
            iappend(inum, buf, cc);


    balloc(int used) // balloc(freeblock);
        uchar buf[BSIZE];
        int i;

        printf("balloc: first %d blocks have been allocated\n", used);
        assert(used < BSIZE*8);  // 一个block有BSIZE*8bit。只要小于就能用1block装下。
        bzero(buf, BSIZE);
        for(i = 0; i < used; i++){
            buf[i/8] = buf[i/8] | (0x1 << (i%8)); // 把所有已使用的block的对应bit置1
        }
        printf("balloc: write bitmap block at sector %d\n", sb.bmapstart);
        wsect(sb.bmapstart, buf);  // 更新文件系统的bitmap数据

看完这个代码就清楚了fs.img到底是个啥。
本质上就是先制定了一个文件系统的格式协议。包括各种尺寸/顺序/结构。
那么不管在任何机器上都能通用了。
然后在我们host上按这个格式做一个文件fs.img,用这个文件模拟整个目标机器的文件系统。
后续会把fs.img作为文件系统加载到模拟器。

xv6的启动流程#

$K/kernel: $(OBJS) $K/kernel.ld $U/initcode
    $(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS) 
    $(OBJDUMP) -S $K/kernel > $K/kernel.asm
    $(OBJDUMP) -t $K/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $K/kernel.sym

$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

OBJS = 各种.o

CFLAGS += -MD # MD选项生成.d文件。包含所有.o的依赖

# 它这个makefile应该是走了隐藏规则,自动找了.c文件去编译。
# https://www.gnu.org/software/make/manual/html_node/Using-Implicit.html

# 实验的makefile还和xv6-riscv本身有一定区别

看手册第2章了解系统整体信息。

总体启动流程

  1. 上电。bootloader加载kernel进内存。地址为0x80000000(kernel.ld中配置)。因为0x0到0x80000000包含io设备,所以放到0x80000000。

  2. machine模式。cpu执行entry.S里的_entry(kernel.ld中配置ENTRY)。此时硬件分页关闭,虚拟地址直接映射到物理地址。

  3. _entry起一个stack0让xv6能够跑c代码。sp指针指向stack0+4096。开始跑start.c里的start()函数。

  4. start()做一些machine模式特许的操作。

  5. 切换到supervisor模式。写main函数(kernel/main.c)的地址到mepc,直接返回到main函数。

  6. main()初始化各种设备和子系统。调用userinit()创建第一个进程。执行user/initcode.S。

  7. initcode.S执行exec(system call),启动init程序(user/init.c)。进入shell等待用户输入。

# 系统程序入口。每个cpu都会执行。

# entry.S

        # qemu -kernel loads the kernel at 0x80000000
        # and causes each hart (i.e. 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                     # 找到stack0的地址。让cpu用这个地址做栈。
        li a0, 1024*4                     # 做一个4096
        csrr a1, mhartid                  # 获取cpu线程id
        addi a1, a1, 1                    # +1
        mul a0, a0, a1                    # *=4096
        add sp, sp, a0                    # sp挪到对应cpu位置
        # jump to start() in start.c
        call start                        # 走start.c的start()
spin:
        j spin

讲了不少指令集/汇编相关基本概念。非常好。
https://riscv-programming.org/book/riscv-book.html

可结合生成的kernel.asm看流程。


先看看手册第4章熟悉一下trap/system call和riscv寄存器相关

Traps and system calls#

有三种事件会让cpu放下当前的指令,转到处理这些事件的特殊代码。

  1. system call
    user代码调用ecall指令请求kernel做某件事情。

  2. exception
    user或kernel干了某件非法的事情比如除0。

  3. interrupt
    硬件发出某种信号。比如硬盘完成了一次读/写请求。

这里用trap通指这些情况。一般来说trap完成后需要继续之前的流程,仿佛什么都没发生。
所谓transparent,做了事,但不会被察觉到。
一般流程是,trap强制进kernel模式,kernel保存各种寄存器等以便回到之前的状态。
kernel执行handler代码。然后恢复到trap之前的状态。

xv6的trap处理分阶段

  1. riscv cpu做硬件动作

  2. 一些汇编指令准备运行kernel的c代码

  3. c代码决定如何处理trap

  4. 实际处理trap的system call或者设备驱动函数

根据三种trap的共性,kernel可以用同样的代码路径去处理。但是分三种情况处理更方便。

  1. 来自user空间的trap

  2. 来自kernel空间的trap

  3. timer interrupts

处理kernel trap的代码称为handler。

RISC-V trap machinery#

这里需参考riscv文档

riscv cpu有一系列寄存器,kernel通过其配置trap的处理方式。也可以获取当前trap的信息。
riscv.h里定义了各种xv6所需的riscv相关操作。

  • stvec(Supervisor Trap Vector Base Address Register)
    kernel设置trap handler的地址,cpu跳转到此地址执行handler。

  • sepc(Supervisor Exception Program Counter)
    trap发生时cpu把当前的pc存这儿。pc设为stvec。sret是从trap返回的指令,当trap完成后,sret会把pc置为sepc,然后跳回初始状态。

  • scause(Supervisor Cause Register)
    cpu把trap原因存这儿

  • sscratch(Supervisor Scratch Register)
    用来保存某些context。具体不清楚。

  • sstatus(Supervisor Status Register)
    其中的SIE位表示硬件中断使能。
    SPP设置模式。0-user。1-supervisor。

上述寄存器只能在supervisor模式操作。同样有一套machine模式的寄存器,只用在一些特殊情况。
多核架构的每个cpu都有一套相同的寄存器。同一个时刻可能有多个cpu在处理trap。

发生trap时cpu的动作(除了timer中断)

  1. 如果trap是硬件中断,且SIE为0。啥都不干

  2. 清SIE关中断

  3. pc值保存到sepc

  4. 保存SPP

  5. 设置scause

  6. 转到supervisor模式

  7. pc值设为stvec

  8. 执行pc

注意cpu并不转到kernel页表和kernel栈。除了pc也不存其他寄存器。
这些工作由kernel来做,并不是cpu来做。
让cpu只做最少的工作,是为了灵活性,可对kernel逻辑进行修改,而不是把逻辑写死在cpu。


start.c的start进行靠近cpu层面的各种配置。最后走main.c的main。


// start.c

// erntry.S里call start到这。每个cpu都会执行。

// 此时所有cpu为默认的machine模式

start()
    // set M Previous Privilege mode to Supervisor, for mret.
    unsigned long x = r_mstatus(); // 获取mstatus寄存器值
    x &= ~MSTATUS_MPP_MASK;        // SPP的两位写0,其他都为1。
    x |= MSTATUS_MPP_S;            // SPP置为01也就是supervisor模式。
    w_mstatus(x);                  // 写寄存器。在mret时,会根据这个寄存器改变模式。

    // set M Exception Program Counter to main, for mret.
    // requires gcc -mcmodel=medany
    w_mepc((uint64)main);          // 设置pc寄存器。mret时会从pc开始执行指令。也就是mret时调用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);
    // riscv-privileged文档3.1.8
    // 默认所有trap都在machine模式下处理。可以设置代理让S模式处理。
    // 这样设置后,trap会先走默认的mtvec,再执行stvec。实际是个转发的效果。

    // 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();               // 时钟相关。见lab4/lab7。

    // keep each CPU's hartid in its tp register, for cpuid().
    int id = r_mhartid();      // 获取hartid
    w_tp(id);                  // 写到thread pointer寄存器

    // switch to supervisor mode and jump to main().
    asm volatile("mret");      // mepc设置为main。走main函数。同时模式改为之前设置的S。




最后走main.c的main。


// 此时cpu在S模式

main()
    if(cpuid() == 0){
        // 只有cpu0运行一次特殊的初始化流程

        consoleinit(); // 初始化uart。可暂忽略
        printfinit();  // 可暂忽略
        printf("\n");
        printf("xv6 kernel is booting\n");
        printf("\n");
        kinit();         // physical page allocator
            // 物理内存页初始化。见lab2和3

        kvminit();       // create kernel page table
            // 创建kernel页表。kernel页表全系统唯一。见lab3
            // 对各种kernel数据做映射

        kvminithart();   // turn on paging
            // 把kernel页表喂给cpu

        procinit();      // process table
            // 初始化进程array,都置为UNUSED。
            // 每个进程有独立的kstack,1k大小,排在内存空间的末尾。见手册图3.3

        trapinit();      // 忽略
        trapinithart();  // install kernel trap vector
            w_stvec((uint64)kernelvec);
            // 配置stvec为kernelvec,碰到trap会走kernelvec。 

        plicinit();      // set up interrupt controller
        plicinithart();  // ask PLIC for device interrupts
            // 设备和中断相关。见lab7。可暂忽略

        binit();         // buffer cache
        iinit();         // inode table
        fileinit();      // file table
        virtio_disk_init(); // emulated hard disk
            // 文件系统相关。见lab9。可暂时忽略

        userinit();      // first user process
            struct proc *p;

            p = allocproc();
                // 找一个可用的进程资源。状态置为USED。
                // 给p->trapframe分配一个页
                // proc_pagetable创建一个user页表

                // 设置context信息。后续运行进程时用到
                // 最初的ra设为forkret。那么第一次切换到该进程会先走一次forkret,回到user空间再跑epc。
                p->context.ra = (uint64)forkret;
                p->context.sp = p->kstack + PGSIZE;

            initproc = p;

            // allocate one user page and copy initcode's instructions
            // and data into it.

            // initcode是写死的initcode.S编译生成的数据
            // 内容是发起syscall。exec调用init。

                /* initcode.S
                
                # exec(init, argv)
                .globl start
                start:
                        la a0, init             # SYS_exec第一个参数为程序名。init
                        la a1, argv             # SYS_exec参数列表。就一个程序名,没有多余的参数。
                        li a7, SYS_exec
                        ecall                   # 走system call流程和exec流程。运行用户程序init。

                # 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
                */

            // uvmfirst从user页表分配一页,从虚拟地址0开始。把initcode数据复制进去,后续可直接运行。  
            uvmfirst(p->pagetable, initcode, sizeof(initcode));

            p->sz = PGSIZE;  // 进初始大小为一页

            // prepare for the very first "return" from kernel to user.
            p->trapframe->epc = 0;      // user program counter
            // user pc寄存器设为0。后续会根据这个值,运行上面0地址的指令。即运行initcode.S。
            // 为什么是0。
            // 其他的user程序是在user.ld指定ENTRY( _main )。然后exec时读elf里的这个entry给到epc。
            // 但是initcode.S没走user.ld。
            // 这里直接写0,我估计是因为initcode.S里把start写在最前面强制作为0,不十分确定。
            // 如果写在最前面无法强制作为0,那么就是编出initcode的elf之后读到里面的entry值为0,然后这里epc写为0。
            // 总之是人工进行了这里的0和initcode.S中start的对应。

            p->trapframe->sp = PGSIZE;  // user stack pointer
            // sp从第2页开始

            safestrcpy(p->name, "initcode", sizeof(p->name));
            p->cwd = namei("/");

            p->state = RUNNABLE;
            // 状态设为RUNNABLE。后续进程调度时可开始执行。

            release(&p->lock);


        __sync_synchronize();
            // memory barrier
            // https://gcc.gnu.org/onlinedocs/gcc-4.6.2/gcc/Atomic-Builtins.html
            // https://en.wikipedia.org/wiki/Memory_barrier

        started = 1;
    } else {
        while(started == 0)
          ;
        __sync_synchronize();
        printf("hart %d starting\n", cpuid());

        // 其他cpu开始自己的初始化。可忽略
        kvminithart();    // turn on paging
        trapinithart();   // install kernel trap vector
        plicinithart();   // ask PLIC for device interrupts
    }

    // 开始进程调度。每个cpu会跑一个scheduler,死循环不断寻找需要运行的进程并切换运行。
    // 此时只有之前userinit中创建的唯一一个进程。

    scheduler();
        struct proc *p;
        struct cpu *c = mycpu();

        c->proc = 0;
        for(;;)
            for(p = proc; p < &proc[NPROC]; p++)
                acquire(&p->lock);

                if(p->state == RUNNABLE) {
                    p->state = RUNNING;
                    c->proc = p;
                    swtch(&c->context, &p->context);
                        // 切换到userinit创建的进程

                        // swtch.S

                        // context包含ra/sp/s0-s11一共14个寄存器

                        // sd ra, 0(a0)
                        // sd sp, 8(a0)
                        // ...

                        // ld ra, 0(a1)
                        // ld sp, 8(a1)
                        // ...

                        // ret

                        // 根据riscv的calling convention。函数调用的前两个参数会存在a0和a1。
                        // sd把寄存器值存到内存地址
                        // ld把内存地址上的值存到寄存器
                        // 最后的效果就是把当前的context寄存器保存到c->context,
                        // 把p->context的寄存器值加载到cpu当前寄存器。

                        // 在swtch运行过程中把ra写为p->context中的ra。
                        // 那么swtch结束时会返回到之前设置的forkret。

                    forkret
                        usertrapret
                            // 此时cpu仍在S模式
                            // 开始为回到user模式做准备

                            struct proc *p = myproc();

                            // we're about to switch the destination of traps from
                            // kerneltrap() to usertrap(), so turn off interrupts until
                            // we're back in user space, where usertrap() is correct.
                            intr_off(); // 关中断

                            // send syscalls, interrupts, and exceptions to uservec in trampoline.S
                            uint64 trampoline_uservec = TRAMPOLINE + (uservec - trampoline);
                            w_stvec(trampoline_uservec);
                            // TRAMPOLINE = MAXVA - PGSIZE指向虚拟地址最后一页。已经在kvminit中映射到了trampoline.S中的trampoline。
                            // TRAMPOLINE是虚拟地址。uservec - trampoline是一个offset。
                            // trampoline_uservec得到uservec函数的地址
                            // 设置为stvec。后续user的trap会走uservec。
                            // 之前已经给cpu设置了kernel页表,所以这里都是传入虚拟地址。

                            // user空间的system call走ecall触发trap后就走uservec。然后走usertrap,调用syscall()。
                            // 完成后又走到这里,回到user模式,完成整个system call。
                            // 详见lab2

                            // set up trapframe values that uservec will need when
                            // the process next traps into the kernel.
                            p->trapframe->kernel_satp = r_satp();         // kernel page table
                            p->trapframe->kernel_sp = p->kstack + PGSIZE; // process's kernel stack
                            p->trapframe->kernel_trap = (uint64)usertrap;
                            p->trapframe->kernel_hartid = r_tp();         // hartid for cpuid()
                            // 保存kernel页表。atp
                            // 保存kernel的sp
                            // 设置处理user trap的函数
                            // 保存hartid

                            // set up the registers that trampoline.S's sret will use
                            // to get to user space.

                            // set S Previous Privilege mode to User.
                            unsigned long x = r_sstatus();
                            x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode     // 配置为user模式
                            x |= SSTATUS_SPIE; // enable interrupts in user mode   // 打开中断
                            w_sstatus(x);

                            // set S Exception Program Counter to the saved user pc.
                            w_sepc(p->trapframe->epc);
                            // userinit中设置了0。这里取出来写到pc。

                            // tell trampoline.S the user page table to switch to.
                            uint64 satp = MAKE_SATP(p->pagetable);
                            // 页表配置为进程的user页表

                            // jump to userret in 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 trampoline_userret = TRAMPOLINE + (userret - trampoline);
                            ((void (*)(uint64))trampoline_userret)(satp);
                            // 走userret函数。sret后进user模式。
                            // pc也设置到了0,走之前设置好的initcode。即运行exec init。

                            // 开始运行init.c的main。起sh命令行。
                            // 到此系统就跑起来了。sh等待用户输命令。

                    c->proc = 0;
                }

                release(&p->lock);




到此可以说大致了解了一个简易riscv操作系统的运行模式。
本质上无非是让cpu运行指令。在这之上进行抽象,做出各自独立的进程/程序。
每个进程绑定各自的内存等资源。多进程排队抢cpu来运行自己的指令。
操作cpu寄存器实现进程的切换/模式的切换。
配合cpu的各种模式/功能实现更高级的功能。