Featured image of post 基于RISC-V架构的操作系统学习

基于RISC-V架构的操作系统学习

反复横跳

前言

这次还是课程实验。第二次吐槽,这么有意思的事情居然被框在平台的题目评测上,认真写的欲望一下子就降低了,不过选题依旧有价值,还是自己好好研究一下吧

以上是我曾经说出来的话。但当我经历明明实验完成了,评测程序一遍遍不通过,搞得我只好再来一次这种事,最终在配环境和运行这两个最简单的地方卡了整整两天,一共重来了6次。现在我只会说去你的应试教育

对,上面的话是我在两小时前说出来的,但现在已经结束了配环境,开始第一个正式实验了。确实很有意思啊,我又反悔,决定进行记录了

环境

我使用的是Ubuntu 24.04.4 LTS 64位 的虚拟机

  • CPU:4 核
  • 内存:8GB
  • 硬盘:50GB
  • 网络:NAT模式

img

安装

安装依赖

1
2
sudo apt update
sudo apt install -y git build-essential cmake ninja-build python3 python3-pip libglib2.0-dev libpixman-1-dev gcc-riscv64-unknown-elf gdb-multiarch mtools dosfstools

Ubuntu的架构是 x86_64,采用复杂指令集,一般用于普通PC程序开发,而实验中使用的操作系统是 RISC-V,采用精简指令集,一般用于嵌入式开发和操作系统内核构建。x86 CPU 不能直接执行 RISC-V 的机器码,所以不能用系统默认的 gcc 编译。这里我们需要用到交叉编译,也就是在 x86_64 架构的机器上,生成 RISC-V 的 ELF 程序,需要安装专门的 RISC-V 工具链

  • gcc :GNU 编译器

  • riscv64 :目标架构是 64 位 RISC-V

  • unknown-elf :生成的是裸机 ELF 程序,而不是 Linux 用户态程序

1
sudo apt install gcc-riscv64-unknown-elf

编译完成后,虽然已经得到了 RISC-V 程序,但 CPU 还是无法直接执行,这时候就需要 QEMU。可以把它理解成一个硬件模拟器,通过软件仿真在 x86_64 电脑上模拟出来整套 RISC-V 或者其他架构的硬件环境,这样哪怕没有真实的 RISC-V 开发板也能运行系统,还能查看串口输出、连接 GDB 调试、保存镜像状态,比真实硬件调试更方便

1
sudo apt install qemu-system-misc

检查实验工具链,如果输出版本信息说明环境正常

1
2
riscv64-unknown-elf-gcc -v
qemu-system-riscv64 --version

容器

解压容器文件

1
unzip kairos-lab-os-c-eh@v0.2.1.zip

导入 Docker 镜像

1
docker load -i kairos-lab-os-c-eh@v0.2.1.zip

查看镜像,看到刚刚的文件说明成功

1
docker images

创建代码映射目录

1
2
mkdir -p ~/oslab
cd ~/oslab

启动容器,这里相当于将本地路径/root/oslab映射到容器路径/root/oslab

1
2
3
docker run -it --privileged --net=host \
-v ~/oslab:/root/oslab \
kairos-lab-os-c-eh

进入映射目录并克隆实验仓库

1
2
cd /root/oslab
git clone 仓库URL

源码结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
(root)
├── bootloader       # bootloader(SBI)
├── changelog        # 项目的更新日志
├── .editorconfig    # 代码格式化规范(https://editorconfig.org)
├── entry   
   └── syscall.tbl  # 系统调用表
├── include          # 头文件目录
├── Makefile         # 构建脚本
├── README.md        # 实验指导说明
├── run-gdb.sh       # GDB调试脚本
├── run-k210.sh      # K210板子上的启动脚本
├── run-qemu.sh      # 正常启动的脚本
├── script           # 用来存放构建用的一些自动化脚本文件
├── src              # 内核源文件目录
├── tools            # 存放一些常用的工具
└── user             # 用户程序目录
    ├── CMakeLists.txt
    ├── include
    ├── lib
    ├── Makefile
    ├── raw          # 文件夹内的所有文件将被直接拷贝到文件镜像中
    └── src          # 源文件目录(每一个源文件对应一个二进制可执行文件,在构建后被拷贝到文件镜像中)

基础与调试

QEMU

QEMU 是一个开源的模拟器与虚拟化工具,可以在一种架构的主机上模拟另一种架构的运行环境。既能模拟完整计算机硬件来运行操作系统,也能单独运行异构架构的用户程序

系统级模拟

类似真机模拟,它模拟的是整台计算机的硬件环境。操作系统会认为自己正在真实硬件上运行,这会让开发与调试更方便

第一次运行需要添加执行权限

1
chmod +x run-qemu.sh

启动QEMU,这里实际调用的是qemu-system-riscv64system表示系统级模拟,riscv64表示模拟的是 64 位 RISC-V 架构

1
./run-qemu.sh

打开源代码根目录下的Makefile文件,找到QEMU的配置,可以看到这里写的是 QEMU 的执行参数

1
2
3
4
5
QEMU = qemu-system-riscv64
QEMUOPTS += -machine virt -bios bootloader/sbi-qemu -kernel $(BUILD_ROOT)/kernel -m 128M -smp $(CPUS) -nographic
QEMUOPTS += -drive file=$(fs.img),if=none,format=raw,id=x0
QEMUOPTS += -global virtio-mmio.force-legacy=false
QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
  • -machine virt:指定 QEMU 模拟的硬件平台
  • -bios bootloader/sbi-qemu:加载 rustSBI 作为 bootloader
  • -kernel $(BUILD_ROOT)/kernel:指定加载的内核镜像
  • -m 128M:分配 128MB 内存
  • -smp $(CPUS):指定 CPU 核心数量
  • -drive file=$(fs.img):挂载文件系统镜像

用户级模拟

用户级模拟不会模拟完整硬件,只模拟用户程序运行所需的 CPU 执行环境。QEMU 会翻译不同架构的指令,然后将程序的系统调用转发给宿主机。这会让快速验证程序行为更方便

进入用户程序目录,创建hello.c,之后要进行模拟的就是这个程序

1
2
cd user/src
vim hello.c

这个程序实现了对test.txt的创建、打开、写入、读出指定的字符串

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "stdio.h"
#include "string.h"
#include "unistd.h"
#include "stdlib.h"
int main() {
    // The string to be written
    char txt[] = "Hello World!\n";
    // Read Buffer
    char buffer[20];
    // File descriptor & tmp
    int fd, n;
    // Create & Open file
    fd = open("test.txt", O_CREATE|O_RDWR);
    assert(fd >= 0);
    puts("1) Open test.txt\n");
    // Write file
    n = write(fd, txt, strlen(txt));
    assert(n >= 0);
    puts("2) Write Done\n");
    // Close file
    close(fd);
    // re-open file
    fd = open("test.txt", O_RDONLY);
    assert(fd >= 0);
    puts("3) Open test.txt\n");
    // Read file
    n = read(fd, buffer, sizeof(buffer));
    assert(n >= 0);
    puts("4) Read content: ");
    write(stdout, buffer, n);
    // Close file
    close(fd);
    return 0;
}

返回项目根目录,用make user交叉编译这个程序。编译的所有结果在build/下,构建的可执行二进制格式文件在build/user_prog

1
2
cd /root/oslab/项目目录
make user

调用 QEMU 用户级模拟

1
qemu-riscv64 build/user_prog/hello

可以看到如下输出,也能在项目根目录下找到对应的test.txt

给 QEMU 传递-strace参数来输出程序运行过程中产生的系统调用日志

1
qemu-riscv64 -strace build/user_prog/hello

对比

这里需要对比 QEMU 用户模拟的日志和系统模拟的日志,这是为了之后调试可以通过对比两份日志的差异,方便的看出来哪里出现问题

使用make clean来清除构建,然后启动系统的debug模式

1
2
chmod +x run-qemu.sh
./run-qemu.sh debug=on

进入 shell 后运行程序

1
./hello

可以看到非常多的日志输出,这些日志是内核输出的 syscall 调试信息。为了解决日志过多,找不到需要的日志这个问题,可以修改src/kernel/syscall.c这个文件

Ctrl + ax退出QEMU,找到syscall函数,修改debugdebug_if,并指定过滤条件,作用是只打印 hello 进程的 syscall

1
2
3
4
5
6
debug_if(strncmp(p->name, "hello", 5) == 0,
    "PID %d syscall %s = %ld from %#lx",
    p->pid,
    syscall_name(num),
    ret,
    epc);

重新清除构建并启动系统,可以看到下面的日志

1
2
3
4
make clean
chmod +x run-qemu.sh
./run-qemu.sh debug=on
./hello

可以注意到这里的日志和上一步用户级模拟输出的日志是一样的。在之后的开发中,因为 QEMU 用户级模拟最终还是依赖宿主 Linux 来处理 syscall,因此可以作为基准,和自己开发的系统中生成的日志进行对比来找到bug

注意:每次修改构建参数之前都要执行make clean,否则可能出现旧缓存导致的错误

GDB 调试

GDB是重要的调试程序,对于开发中的排错非常实用

启动

启动调试版 QEMU,执行后系统会暂停等待 GDB 连接

1
2
chmod +x run-gdb.sh
./run-gdb.sh

新开一个终端,进入容器中的实验目录,然后启动GDB

1
gdb-multiarch

进行连接,成功后 GDB 就接管了 QEMU 里的 CPU

1
2
target remote localhost:26000
file build/kernel

常用命令

断点:b syscall

继续运行:c

执行下一行:n

进入函数:s

查看寄存器:info registers

查看调用栈:bt

查看当前源码位置:list

查看变量:p 变量名

查看内存:x/16x 地址

退出 GDB:q

实战

启动系统后看到下面的输出,意思是系统最初的用户进程init0退出了,所以内核报错后停止

因此关键是找到最初的用户进程,根据提示找到src/kernel/proc.c,打开后搜索init0,可以看到

1
2
3
4
5
char *argv[] = {USER0, NULL};

if (exec(USER0, argv, envp) != 0) {
    panic(USER0 " load");
}

说明USER0决定第一个用户进程

在根目录查找USER0的定义

1
grep -R "define USER0" .

可以找到#define USER0 "masquerade",这就是问题所在

正常情况下,系统启动时第一个运行的用户程序应该是#define USER0 "init0",但现在被改成了masquerade,所以系统启动后实际执行了这个程序。它就是实验故意放进去的假程序,不会正常启动系统,而是输出提示然后退出

问题在于第一个用户进程是不能随便退出的。对于操作系统来说,kernel 虽然启动完成,但用户态环境还没有真正建立起来。第一个用户进程就像整个用户空间的根节点,一旦它退出,后面所有东西都没法继续运行。所以系统看到它退出就会直接停止运行,报错panic: init exiting

在 Linux 中也是一样,启动后会有一个最初的用户进程,一般是 init 或 systemd, PID 永远是 1。如果它异常退出,Linux 就会认为系统发生错误

第一个用户进程不能退出,否则系统将失去整个用户态环境

修复方法是编辑include/param.h这个文件,找到#define USER0 "masquerade"masquerade改成init0

保存后重新编译,可以看到系统恢复正常

1
2
make clean
./run-qemu.sh

Backtrace

原理

函数调用是用栈来实现的,栈可以保存过往的函数调用上下文,让程序有类似记忆的功能。而backtrace就是通过栈进行回溯,利用fp遍历栈帧,然后读取每层的ra找到函数调用的来源链条

在根目录下创建hello.c,写入下面的代码,这个代码构造了一条函数调用链:main() > foo() > bar()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>
void bar() {
    printf("bar\n");
}
void foo() {
    printf("foo\n");
    bar();
}
int main() {
    foo();
    return 0; 
}

构建并调试

1
2
3
4
5
6
gcc hello.c -o hello # 编译产生hello可执行程序
gdb hello            # 调试该程序

break bar # 在bar函数入口打断点
run # 开始运行
backtrace # 打印当前函数调用栈

可以看到函数的调用栈被打印出来,前面的一串十六进制是地址,后面的是函数名,按照barfoomain的顺序,刚好对应出栈的顺序

程序运行时,每调用一次函数,CPU就会创建一个栈帧。栈帧和栈的区别是:栈是程序运行中存储所有函数调用信息的总的数据结构,占用一整块连续内存,而栈帧是其中单次调用函数产生的数据,相当于栈的一个单元

栈帧里面保存了:

  • 返回地址Return Address,RA:当前函数执行结束后,程序应该返回继续执行的位置
  • 帧指针Frame Pointer,FP:当前栈帧的基准位置,用来形成函数调用链,方便回溯
  • 局部变量Local Variables:当前函数内部的临时变量
  • 寄存器值Saved Registers:函数调用前需要暂时保存的寄存器值,用于之后恢复 CPU 状态
  • 函数参数Function Arguments:传递给当前函数的输入数据

调用结束后,这个栈帧就会弹出。backtrace这个操作本质就是通过当前函数保存的fp,来向上一级级的遍历,最终找到完整的调用链条,并输出链条中每个函数的返回地址,也就是ra(bar)ra(foo)ra(main)

注意:这里输出的地址不是当前函数或者上一级函数的入口地址,而是当前函数执行完成后,上一个函数应该继续从哪句开始执行,ra就是这一句的地址。和fp的区别是fp找栈帧位置,ra找具体执行

上面看到的来源函数是GDB自动翻译的。为了便于定位,可以在编译时加上编译选项-g,也就是在ELF文件中加入DWARF调试信息表,记录地址对应函数和代码行号。GDB用ra地址来查表,找到这个地址的函数信息并输出。因此最终看到的输出效果是函数名加上这个函数执行完成后的下一行代码位置

1
gcc hello.c -g -o hello

函数实现

src/utils.c中加入 backtrace 函数,这个文件存放的是内核调试工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void backtrace() {
    uint64_t fp;

    // 获取当前fp
    asm volatile("mv %0, s0" : "=r"(fp));

    // 获取当前进程的栈范围,设置寻找边界
    uint64_t bottom = (uint64_t)current->kstack;
    uint64_t top = bottom + KSTACK_SZ;

    kprintf("--- Backtrace Start ---\n");
    int depth = 0;

    // 顺着栈帧往上走
    while (fp >= bottom && fp < top) {
        // 防止栈损坏死循环
        if (depth++ > 32) break;

        // 
        uint64_t ra = *(uint64_t *)(fp - 8);
        uint64_t next_fp = *(uint64_t *)(fp - 16);

        kprintf("  0x%lx\n", ra);

        // 异常保护
        if (next_fp <= fp || next_fp == 0) {
            break;
        }

        fp = next_fp;
    }
    kprintf("--- Backtrace End ---\n");
}

RISC-V 栈帧布局:

在RISC-V中,s0寄存器用于存储fp,因此首先通过asm volatile("mv %0, s0" : "=r"(fp));读取s0寄存器来获取当前函数栈帧的地址。一个栈帧中存储着两个fp,分别是自己的存储在s0,和上一个函数的存储在fp-16。fp 在栈帧中是一个固定的锚点,ra和调用者的fp都是以fp为位置基准存储,所以能通过地址偏移来访问到

为了防止函数一直向上寻找,超过当前进程的栈的地址范围导致崩溃,需要获取栈顶和栈底地址,其中:

  • current :当前进程的PCB表

  • kstack:这个进程的栈底

  • current->kstack:从当前进程的PCB表中获取栈底地址

  • KSTACK_SZ:Kernel Stack Size内核栈大小

在栈的范围里进行回溯:

  • *(uint64_t *)(fp - 8) :当前函数的返回地址,在调用完成继续执行上级函数时会作为起点使用,这就是调用链的内容
  • *(uint64_t *)(fp - 16) :上一级调用者的栈帧指针,用来作为调用链的结构

最终将上级调用者的栈帧地址赋值给fp,就完成了一次回溯

测试

用户态发起的系统调用最终都会汇聚到syscall()。为了看到内核的函数调用链,可以将backtrace()插入src/kernel/syscall.csyscall()函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void syscall(void) {
    struct proc *p = current;
    int num = proc_get_tf(p)->a7;
    uint64_t epc = read_csr(sepc);
    
    // 插入的部分
    static int trace_count = 0;
    if (trace_count < 1) { 
        trace_count++;
        backtrace();
    }

    // ...
}

重新编译并运行,进入系统后就可以看到打印出的地址了,这就是内核内部的调用链

另开一个终端,运行下面的命令,然后将地址复制过来进行转换

1
riscv64-unknown-elf-addr2line -e build/kernel

可以看到syscall.ctrap.c,说明syscall的源函数是trap,也证明了syscall的执行前提是CPU陷入内核态

踩坑记录

脚本无法执行,报错Permission denied

原因:在 Linux 中当前脚本没有执行权,从 Windows 解压复制或者 git 拉取的脚本经常出现这个问题

解决方法chmod +x增加权限

1
2
chmod +x run-qemu.sh
chmod +x run-gdb.sh

GDB 找不到文件,报错build/kernel无法找到目标文件

原因:GDB运行在家目录,而文件在实验目录中

解决方法:进入实验目录再执行 GDB

内核编译完成后无限循环输出 --- Backtrace Start ------ Backtrace End ---,中间不规律夹杂地址

原因:把 backtrace() 放到了 proc.cmyproc() 中。myproc() 一般用于获取当前正在运行的进程PCB,会被内核路高频甚至嵌套调用,而且不同调用路径进入 myproc 的栈深度不同,才会出现这种情况

解决方法:将 backtrace() 放到调用可控的syscall函数中

编译完成后,进入系统前报错 panic: kerneltrap

原因:把 backtrace() 放到了 proc.cuser0_init() 中,导致第一个用户进程还没有创建完成,栈信息还不稳定的时候就被调用。而backtrace依赖完整的栈,调用过程中可能会非法访问,从而触发了异常直接退出

解决方法:将 backtrace() 放到栈稳定后才被调用的syscall函数中

编译成功但不打印地址,没有任何输出

原因:为了防止打印次数过多,设置了过滤条件 if (p && p->pid > 2 && num == 1) { backtrace(); }),但条件太严格导致所有输出都被过滤掉了

解决方法:修改过滤规则,采用计数打印,打印一次就结束

内核编译成功,输出正常,但输出次数过多

原因:虽然把 backtrace() 放到了正确的位置,但syscall()本身会被多次调用,比如欢迎语句的输出,导致输出一个字符就执行一次backtrace

解决方法:添加过滤规则,计数打印,打印一次就结束

系统调用

用户程序没有权限直接访问内核,只能通过 syscall 对内核执行操作。系统调用也就是用户态通过ecall陷入内核,内核根据syscall号调用对应函数,然后返回结果

基础调用

调用表

首先分配系统调用号,打开entry/syscall.tbl文件,在尾部添加三列数据

1
666 echo sys_echo
  • 666 是系统调用号,必须是唯一的
  • echo 是 syscall 名称
  • sys_echo 是最终调用的内核函数

之后构建脚本会自动生成 syscall 分发表,也就是一个函数指针数组

获取字符串并打印

构建系统时会自动生成系统调用的声明,所以直接实现系统调用即可。在内核源码目录编辑src/sys_echo.c,写入代码来进行实现

其中有关地址与内存的部分需要用到argintargaddrcopy_from_user 函数,因此先查找它们的定义,可以看到:

  • argint(int n, int *ip) :从 syscall 参数中取出第 n 个参数,并按整数保存到 ip 指向的位置

  • argaddr(int n, uint64_t *ip) :从 syscall 参数中取出第 n 个参数,并按用户态虚拟地址保存到 ip 指向的位置

  • copy_from_user(void *to, uint64_t from, size_t len): 在内核态无法直接访问用户态的指针,因为用户页表和内核页表不一样,用户传入的是虚拟地址,这时候就要用到copy_from_user()来拷贝内存。先用 argaddr 取出用户地址,再用 copy_from_user 把用户态虚拟地址从 from 开始的 len 字节数据复制到内核缓冲区 to 中,代码中对应的是buf

完整代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include "common.h"
#include "mm/vm.h"
#include "kernel/syscall.h"
#include "printf.h"

uint64_t sys_echo() {
    // 用户态传入的字符串地址
    uint64_t user_ptr;
    int len;
    char buf[128];
    
	// 从 syscall 参数里取参数
    if (argaddr(0, &user_ptr) < 0 || argint(1, &len) < 0) {
        return -1;
    }
    
	// 防止溢出
    if (len < 0 || len > 127)
        len = 127;
    
	// 从用户虚拟地址复制数据到内核
    if (copy_from_user(buf, user_ptr, len) < 0) {
        return -1;
    }
    
	// 手动添加字符串结束符
    buf[len] = '\0';
    
	// 内核打印字符串,这里不是用户内存,是内核自己的 buf
    printf("%s\n", buf);
    
	// 返回用户态
    return 0;
}

可以看到这里的函数uint64_t sys_echo()并没有参数,这是因为系统调用和普通的用户态程序不一样

  • 用户态程序实现函数的过程是先把参数的值存进寄存器,跳转到函数执行,这时候再从寄存器取出值
  • 系统调用是在用户执行syscall后,参数被放入寄存器。调用号会被放进 a7 寄存器,后面的参数会依次放进 a0 a1 等寄存器。然后用户态执行 ecall ,CPU 触发异常进入内核。在CPU 陷入内核时,这些寄存器内容,也就是上下文会被保存到trapframe里用于之后恢复进程,argaddr(0, &user_ptr)就是在从这里取出第0个参数解析成地址,argint(1, &len)就是取出第一个参数解析成int。在系统调用完成后,返回值被存入寄存器,用户态寄存器恢复,CPU回到用户态

完成后修改src/Makefile,添加下面的代码来将函数链接到内核,否则虽然代码存在但内核无法找到

1
obj-y += sys_echo.o

测试

在是用户态程序源码目录下user/src/test_echo.c中写入测试程序

1
2
3
4
5
6
7
8
#include "syscall.h"
#include "string.h"

int main() {
    char *msg = "Hello Navi";
    syscall(666, msg, strlen(msg));
    return 0;
}

重新编译

1
2
make clean
./run-qemu.sh

进入系统后执行

1
./test_echo

这里不能输入test_echo.c,因为.c 是源码文件,内核只能执行 ELF 可执行文件,如果输入源码文件,内核尝试把文本文件当 ELF 加载,会出现execvp failure

如果正常,会输出Hello Navi,也就是刚刚代码中定义的值,说明系统调用成功

trace系统调用

trace 的作用是监听指定的 syscall 的执行,比如用户执行trace(syscall_num),内核会监控当前进程之后的系统调用,如果执行了syscall_num对应的系统调用,就输出相关信息。这个进程的子进程也会继承监听项

PCB增加字段

PCB就是操作系统用来管理一个进程状态的数据结构,里面会放入这个进程的一系列信息,每个进程都有自己独立的 PCB。系统调用的监听状态需要保存在 PCB 里,PCB在源码中的位置是proc

打开 include/kernel/proc.h,找到 struct proc 结构体,在最后一行添加系统调用号的字段。这里相当于每个进程的PCB里面新增了一个字段,让保存的状态里多一个监听号

1
2
3
4
5
struct proc {
    // ...

    int traced_syscall;    //存储需要监控的系统调用号
};

内核实现

主要实现

打开 src/kernel/syscall.c,进行两处修改

文件末尾添加 sys_trace函数,当用户态执行trace(num)时,num就会被写入当前进程PCB的traced_syscall部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
uint64_t sys_trace(void) {
    int syscall_num;
    
    // 获取用户态传入的第0个参数
    if (argint(0, &syscall_num) < 0) {
        return -1;
    }
    
    // 写入当前进程的 PCB 中
    struct proc *p = current; 
    p->traced_syscall = syscall_num;
    
    return 0;
}

在执行完系统调用,并将返回值放进a0寄存器后,对系统调用进行判断,如果和traced_syscall中存储的调用号相符就进行打印

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void syscall(void) {
    struct proc *p = current;
    int num = proc_get_tf(p)->a7; 
    uint64_t epc = read_csr(sepc);

    if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
        uint64_t ret = syscalls[num]();
        proc_get_tf(p)->a0 = ret; 
        
        //插入的部分
        if (p->traced_syscall == num) {
            kprintf("trace: PID %d calls %s\n", p->pid, syscall_name(num));
        }
        
	// ...
}

调用表注册

系统调用需要一个唯一的 syscall number,用户态会通过这个编号告诉内核要调用哪个系统调用

entry/syscall.tbl中添加调用表项,来注册524号系统调用

1
524 trace sys_trace

子进程继承

trace 有一个性质是子进程会继承监听状态,实现函数在src/kernel/clone.c 中。找到把父进程p的属性复制给子进程np的代码np->parent = p;,在这句之后,函数返回之前加上这一行:

1
np->traced_syscall = myproc()->traced_syscall;

现在这样只能监听一个系统调用,因为 PCB 里只保存了一个 int traced_syscall。如果想同时监听多个系统调用,可以把它改成数组来存储监听号

用户态调用

在 Linux 中,一般会在用户态头文件user/include/unistd.h末尾添加声明

1
2
#define SYS_trace 524
int trace(int syscall_num);
  • SYS_trace :定义系统调用号
  • trace() :用户态声明trace函数

这个环境没有 libc 提供标准的封装,所以用户态不能直接调用普通的 trace(),需要自己封装一个trace函数发起syscall。也就是手动把 524 和监听目标分别放进a7和a0寄存器,然后触发 ecall 进入内核,通过a0寄存器返回系统调用的返回值

编辑 user/src/init0.c ,在开头void __run(char *argv[]);后面添加下面的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static inline int trace(int syscall_num) {
    register long a7 __asm__("a7") = 524;         // 系统调用号524
    register long a0_reg __asm__("a0") = syscall_num; // 要监听的调用号
    __asm__ volatile(
        "ecall"
        : "+r"(a0_reg)
        : "r"(a7)
        : "memory"
    );
    return a0_reg;
}

在main函数中新增下面的程序,先让trace监控172号系统调用getpid,然后执行调用,再创建一个子进程并调用,看内核是否会分别输出getpid和它的子进程信息。最后验证取消监听后还有没有输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void main() {
    run("welcome");
    
    // 新增
    printf("\n--- Start Trace Test ---\n");
    trace(172);  
    getpid(); 
    // 创建子进程
    int pid = fork();
    // 如果是子进程就验证是否继承状态
    if(pid == 0){
        getpid(); 
        exit(0);
    } else {
        // 父进程需要等待子进程结束调用
        int s;
        wait(&s);
        // 传入 -1 取消监听
        trace(-1);
        getpid(); 
    }
    printf("--- End Trace Test ---\n\n");
    
    run("sh");
    halt();
}

重新编译,可以看到成功输出了监控信息

1
2
make clean
./run-qemu.sh

用户态上下文

用户态上下文就是用户程序运行时的寄存器状态。用户程序进入内核态时,CPU 需要保存这些状态,否则内核处理完成后就无法回到原来的用户程序继续执行

每个进程的 PCB 中都有一个 trapframe 指针。用户态陷入内核时,会先把寄存器信息保存到这里,之后内核可以通过 trapframe 读取或修改用户程序的上下文

这里要做的是让用户程序故意触发一次非法内存访问,然后在内核中查看它触发异常时的上下文。之后再修改 trapframe 中保存的返回地址,让程序跳过异常指令继续执行

触发异常

user/src/context_test.c 中写入测试程序。地址 0 没有映射到合法内存,所以会触发page fault异常,从而使用户程序陷入内核态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include "stdio.h"

int main() {
    printf("=== Context Test Start ===\n");

    // 向地址0写入数据
    *((char *)0) = 0xff;

    printf("=== Context Test End ===\n");
    return 0;
}

user/src/init0.c 中运行它

1
run("context_test");

可以看到程序在非法访问处崩溃,输出epc 0x100d8 va 0

  • epc :触发异常时用户程序正在执行的指令地址
  • va :导致异常的虚拟地址

说明用户程序在 0x100d8 处执行指令时,访问了虚拟地址 0

打印用户态上下文

打开 src/kernel/trap.c,在 usertrap()when_pagefault 中加入打印:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
when_pagefault {
    // 只打印 context_test 的用户态上下文
    if (strncmp(p->name, "context_test", 12) == 0) {
        tf_print(p->trapframe);
    }

    // 交给页面异常处理函数
    if (p == NULL || handle_pagefault(scause) != 0) {
        goto kill;
    }
    break;
}

重新运行后可以看到下面的输出

这些值是程序触发异常时保存下来的寄存器内容:

  • sp: 7fffff78:栈指针,指向用户程序当前使用的栈位置
  • fp: 7fffff88:栈帧指针,用来定位当前函数栈帧中的数据
  • pc: 100d8:发生异常时正在执行的指令地址
  • ra: 100d0:函数返回地址,表示函数调用返回后应该继续执行的位置
  • a0-a5:参数寄存器

这里最重要的是 pc,和前面异常信息里的 epc 0x100d8一样,都记录异常指令的地址

查看异常指令

一般使用反汇编来debug。反汇编就是把编译后的机器指令重新翻译成汇编代码,同时显示每条指令所在的地址。用户程序编译后会放在 build/user_prog 目录中,可以用 objdump 进行反汇编,来查看 0x100d8 对应的指令

1
riscv64-unknown-elf-objdump -S build/user_prog/context_test > disasm.S

搜索刚刚定位到的异常地址,同时显示它前后各 10 行

1
grep -n -A10 -B10 "100d8" disasm.S

找到主要的几行,前两行准备数据,第三行执行非法写入,第四行是跳过异常后的位置:

1
2
3
4
100d0: 00000713    li  a4,0
100d4: fff00793    li  a5,-1
100d8: 00f70023    sb  a5,0(a4)
100dc: 00004517    auipc a0,0x4
  • li a4,0:把地址 0 保存到 a4
  • li a5,-1:把 0xff 保存到 a5 的低 8 位。64 位下 -1 表示为 0xffffffffffffffff,它的低 8 位就是 0xff
  • sb a5,0(a4):把 a5 的低 8 位写入 a4 指向的地址,也就是向地址 0 写入 0xff。地址 0不能访问,所以在这里触发异常。
  • auipc a0,0x4:异常指令的下一条指令

整体来看,a4保存地址,a5保存数据,异常指令进行写入,然后触发page fault。异常信息中的epc 0x100d8 va 0也正好和反汇编结果对应

跳过异常指令

发生异常后如果不做任何改动,程序返回用户态时, CPU根据 trapframe 中保存的 pc/epc 继续运行,0x100d8会被再次执行,触发相同异常

解决这个问题的方法是对epc进行修改。非法指令在 0x100d8,下一条指令在 0x100dc,两个地址相差 4 字节。所以只要把 trapframe 中保存的 epc 加 4,程序就会跳过这条非法指令

src/kernel/pagefault.chandle_pagefault() 中加入对当前异常的处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int handle_pagefault(uint64_t scause) {
    int ktrap = read_csr(sstatus) & SSTATUS_SPP;
    proc_t *p = current;
    uint64_t epc = read_csr(sepc);
    uint64_t stval = read_csr(stval);
    uint64_t rva = PGROUNDDOWN(stval);
    vma_t *vma = NULL;

    pagefault_t fault = get_pagefault(scause);

    if (fault == PF_UNKNOWN)
        return -1;
	// 只针对这个异常
    if (!ktrap &&
        p != NULL &&
        strncmp(p->name, "context_test", 12) == 0 &&
        stval == 0) {
        //epc + 4
        p->trapframe->epc += 4;
        return 0;
    }

    if (ktrap) {
        ...
    } else {
        ...
    }

    return 0;
}

验证

重新编译运行,输出结果如下:

可以看到,程序依然触发了异常,内核打印出了当时的 trapframe 信息。此时pc: 100d8,对比之前无变化

但之后的输出产生了变化:程序没有报错page fault,而是继续执行直到最终以退出码 0 正常结束,这说明刚才的修改已经生效,异常被跳过

踩坑记录

printf报错implicit declaration of function 'printf'stdio.h: No such file or directory

分别是最开始没有添加头文件,和第二次加入#include <stdio.h>产生的报错

原因:内核不是普通 Linux 用户程序,没有标准 C 库环境,不能直接使用 libc

解决方法:用内核自己的头文件#include "printf.h"

sys_echo报错undefined reference to 'syscall'

原因:用户态代码里调用了 syscall()作为trace函数,但代码里根本没有这个函数的实现,所以直接报错找不到

解决方法:直接在 init0.c 里自己写一个内联函数来实现 trace 系统调用,这样就不再依赖写好的函数

copy_from_user 参数类型错误,报错passing argument 2 of 'copy_from_user' makes integer from pointer without a cast

原因:把用户态的 char * 指针直接传给了 copy_from_user,但正常情况下传的应该是用户地址数值形式。用户态指针在内核里是用户虚拟地址,不能直接当普通指针用

解决方法:先用 argaddr 把参数取成 uint64_t 地址

1
2
uint64_t addr;
argaddr(0, &addr);

然后再调用copy_from_user(buf, addr, len);,内核通过传入的用户虚拟地址和长度,把数据从用户空间复制到内核缓冲区

编译完成后报错panic: init exiting

原因init0.c写错导致第一个用户进程编译错误退出,之后的用户进程无法启动,最终系统崩掉

解决方法:排查init0.c,保证逻辑完整

内存与程序

内存布局

这部分是要在进程执行程序的时候,把这个进程当前用的内存区域列表打印出来

要打印内存区域,首先就要找到保存这个区域的数据结构vma,vma就是描述一段虚拟内存的结构体。每一个进程会被分配到连续地址的虚拟内存空间,其中划分出来多块不同作用的区域,最后用页表来将虚拟地址映射到物理地址,不同进程的物理页可以独立或者共享,虚拟内存解决了物理内存不连续和需要隔离的问题

一个进程的虚拟内存并非一整块,是分成很多作用不同的段,每一段就是一个vma,比如程序代码、全局变量、堆、栈这一类。vma由链表存储,链表入口是vma_head,上面的一个段也就是一个节点,所有链表都存储在mm_t结构体中

打印进程内存区域

在根目录运行 grep 命令查看 vma这个结构体的定义

1
grep -n -A 20 "struct vma {" include/mm/mmap.h

我的目的是找到vma中用于把节点挂在链表上的变量名称,从输出中可以发现,这个变量叫 head

1
2
@brief vma list head
list_head_t head;
  • addr:按页对齐后这段虚拟内存的起点
  • raddr:实际映射的起点物理地址
  • len:按页对齐后这段虚拟内存的总大小
  • rlen:实际使用的长度,一般小于或等于 len
  • offset:文件映射时,从文件哪个位置开始
  • flags:这段内存的类型
  • prot:访问权限,标记区域是否可以读写执行
  • page_spec:页相关的额外规则
  • head:用于链表的连接
  • map_file:映射的文件指针,没有文件就是空

在用于管理内存的代码 mm/mmap.c写入 mmap_print 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void mmap_print(mm_t *mm) {
    vma_t *vma;
    int id = 1;
	// 防止空指针
    if (mm == NULL) {
        return;
    }

    list_for_each_entry(vma, &mm->vma_head, head) {
        kprintf("%d. ", id++);
        vma_print(vma);
    }
}

逻辑就是传入某个进程的mm_t,然后从head开始遍历并输出里面所有的vma

list_for_each_entry(vma, &mm->vma_head, head)中的参数含义:

  • &mm->vma_head:链表入口
  • vma:当前遍历到的 vma_t
  • head:将节点挂入链表的变量,用来找下一个节点

验证

src/kernel/exec.c中这个位置插入函数调用

exec的作用是将一个进程中的程序换成新的程序。这个位置是在某个进程执行exec切换程序,初始化好新程序的内存但还没有释放旧内存的时候。放在这里是因为可以完整观察新程序的虚拟内存布局,还不会受到执行程序对内存产生的影响

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 初始化新程序的内存
ustack -= sizeof(uint64_t) * argc;
copy_to_user(ustack, argcv, sizeof(uint64_t) * argc);

//新增项
mmap_print(newmm);

// 释放旧程序的内存
tf_reset(proc_get_tf(p), elfentry, ustack);
mmap_free(&oldmm);

重新编译,可以看到成功输出了监控信息。这里看到多段信息,这说明在内核编译完成,进入系统的过程中有多个进程都调用了exec

程序加载

程序编译后获得的文件就是ELF,比起源代码来说,ELF 文件还记录了程序应该怎么放进内存,包含地址、长度、权限这些内容

一个 ELF 里面有一个 Program Header Table,里面有很多条 Program Header。每一条 Program Header 描述一个段,也就是一个 Segment。一个 Segment 由多个 Section 组成

ELF详解

编译出来的内核本质上是一个ELF可执行文件,放在build/kernel中。可以用 readelf查看它的基本信息和它应该怎么被加载到内存。-h 就是查看ELF 文件头ELF Header-l 也就是查看程序加载表Program Headers

1
2
make kernel # 构建内核
readelf -lh build/kernel

ELF Header

ELF Header的作用是存储程序基本参数,主要的如下:

  • Class: ELF64:文件分类:这是 64 位 ELF 文件
  • Data: little endian:数据存储规律:数据按小端序存储
  • OS/ABI: UNIX - System V:指定了二进制程序使用的ABI标准,规定了这个可执行程序的运行环境,比如初始参数argc、argv,栈结构,环境变量传递等
  • Type: EXEC:文件类型:这是可执行文件
  • Machine: RISC-V:CPU架构:这个程序是给 RISC-V CPU 跑的
  • Entry point address: 0x80020000:CPU执行程序的起始地址:一个虚拟地址号
  • Number of program headers: 1:program headers数量:这个 ELF 里面只有一个LOAD段

Program Header

ELF文件分为很多段Segment,而Program Header的作用就是存储这个程序的所有段参数,也就是和程序加载到内存有关的参数。当程序被加载执行时,这些段会被操作系统从文件系统加载进内存中

  • Type: LOAD:表示这个段需要被加载进内存
  • Offset: 0x1000:在 ELF 文件里的起始位置
  • VirtAddr: 0x80020000:放到虚拟地址 0x80020000
  • PhysAddr: 0x80020000:对应的物理地址是 0x80020000
  • FileSiz: 0x54a40:在ELF文件中真实大小
  • MemSiz: 0x54a40:加载到内存后所占空间
  • Flags: RWE:这一段可读写执行
  • Align: 0x1000:按页大小对齐

图中这段的意思是在 ELF 文件从 0x1000 开始的一段内容,要被加载到内存地址 0x80020000,大小是 0x54a40,权限是可读写执行。按列来看是ELF文件,虚拟内存,物理内存三块,按行来看是地址,大小与参数

内核的段比较特殊,只有一个,其他类型的段可能有好几个,比如数据段、代码段,还可以分别指定不同的读写执行权限

  • PHDR:记录 Program Header 自己在内存中的位置
  • INTERP:记录动态链接器路径,比如 /lib/ld-linux-riscv64-lp64d.so.1
  • DYNAMIC:保存动态链接需要的信息,比如依赖库、重定位表、符号表
  • NOTE:保存一些额外说明信息
  • TLS:线程局部存储相关信息
  • GNU_STACK:说明栈是否可执行
  • GNU_RELRO:运行初期可写,重定位完成后改成只读的区域

Section 和 Segment

Section 是编译器使用,Segment 是加载器和操作系统执行程序时使用。.text.rodata.data.bss 这些 section 被打包进了同一个 LOAD Segment 里,加载器加载这个 LOAD 段的时候,它们都会一起进内存

  • .text:代码
  • .rodata:只读数据,比如字符串常量
  • .data:已经初始化的全局变量
  • .bss:未初始化的全局变量

用户与内核对比

上面查看的是内核 ELF,它比较特殊,一般由 bootloader 加载到固定的内核地址,比如这里的 0x80020000。在操作系统真正运行以后,exec 加载的一般是用户程序 ELF

为了进行对比,可以自己写一个 helloworld 程序,然后用 gcc 编译,再用 readelf 查看它的 ELF Header 和 Program Header

1
2
3
4
5
6
#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

编译并查看

1
2
gcc helloworld.c -o helloworld
readelf -lh helloworld

也可以加上 --static 编译成静态链接程序:

1
2
gcc --static helloworld.c -o helloworld_static
readelf -lh helloworld_static

可以发现内核 ELF 比普通 Linux 用户程序 ELF 简单很多。内核 ELF 是 RISC-V 架构,固定入口地址,而且只有一个 LOAD 段,helloworld程序虽然很短但运行在 Linux 用户态,运行时涉及的东西更多,相应的也有更多的 Program Header 段

实现加载

这里需要遍历所有Program Header,将LOAD部分加载进内存

程序加载的核心是 exec,内核执行 exec 时会丢弃当前进程原本的vma,然后按照ELF的规则建立新程序的vma,然后继续从ELF header记录的入口地址执行程序

程序需要用到MAP_FIXED,首先用 grep 确认一下它的定义

ELF 的 Program Header 已经写好了段应该放到哪个虚拟地址,MAP_FIXED 的作用就是要求内核不能随便找一块地址映射,而是必须按 ph.vaddr 指定的位置创建 VMA

1
grep -R "#define MAP_" include

查找 struct proghdr 的定义,从而确认Program Header中的字段名

1
grep -n "struct proghdr" -A 30 include/elf.h

查找 mmap_maploadseg的定义

1
2
grep -R "mmap_map(" -n .
grep -R "loadseg(" -n .

可以看到这两个函数的原型

1
2
3
4
5
6
7
vma_t *mmap_map(mm_t *mm,
                struct file *fp,
                off_t off,
                uint64_t addr,
                uint64_t len,
                int flags,
                int prot);
1
2
3
4
5
static int loadseg(mm_t *mm,
                   uint64_t va,
                   entry_t *ip,
                   uint offset,
                   uint sz);

打开 src/kernel/exec.c,找到 exec 函数中处理 Program Header 的位置,添加下面的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 遍历 Program Headers
for (i = 0, off = elf.phoff; i < elf.phnum; i++, off += sizeof(ph)) {
    // 提取其中一个ph,它描述了一个段
    if (reade(ep, 0, (uint64_t)&ph, off, sizeof(ph)) != sizeof(ph)) {
        eunlock(ep);
        goto bad;
    }
    // 判断是不是LOAD
    if (ph.type != PT_LOAD)
        continue;
	// 检查内存大小是否合法
    if (ph.memsz < ph.filesz) {
        eunlock(ep);
        goto bad;
    }
    //计算这个LOAD段按页对齐后的地址与大小
    uint64_t start = PGROUNDDOWN(ph.vaddr);
    uint64_t end = PGROUNDUP(ph.vaddr + ph.memsz);
    uint64_t sz = end - start;
	// 创建vma
    if (mmap_map(newmm,
                 NULL,
                 0,
                 start,
                 sz,
                 MAP_FIXED,
                 elf_map_prot(ph.flags)) == NULL) {
        eunlock(ep);
        goto bad;
    }
	//将文件内容加载到新创建的内存
    if (loadseg(newmm,
                ph.vaddr,
                ep,
                ph.off,
                ph.filesz) < 0) {
        eunlock(ep);
        goto bad;
    }
}
// 打印程序vma,调试用
mmap_print(newmm);

加载一个 LOAD 段时,内核先根据 ph.vaddrph.memsz 算出这个段在虚拟内存里要占的范围,并按页对齐创建 VMA;然后用 ph.off 作为 ELF 文件中的读取起点,读取 ph.filesz 大小的内容,放到 ph.vaddr 对应的虚拟地址中

mmap_map 参数:

  • newmm:新程序的地址空间
  • NULL:这里不直接绑定文件
  • 0:文件偏移参数,这里不使用
  • start:按页对齐后的虚拟地址起点
  • sz:按页对齐后的映射长度
  • MAP_FIXED:要求映射到指定地址
  • elf_map_prot(ph.flags):根据 ELF 段权限设置 VMA 权限

loadseg 参数:

  • newmm:新程序的地址空间
  • ph.vaddr:段内容要放到的虚拟地址
  • ep:当前正在加载的 ELF 文件
  • ph.off:在 ELF 文件中读取起点
  • ph.filesz:需要从 ELF 文件中读取的大小

重新编译后可以看到打印出来的多组vma,说明成功创建新程序的vma并加载LOAD段

踩坑记录

mmap报错 'vma_t' {aka 'struct vma'} has no member named 'link'

原因:将链表遍历中用于连接链表的变量名写成了link,但struct vma 里面找不到这个变量,所以编译失败

解决方法:用 grep 查找 struct vma 的定义,可以看到用于连接链表的变量名实际上是 head,改正即可

程序加载代码编译时报错 'struct proghdr' has no member named 'offset'

原因:将loadseg中的字段名写成了offset,但struct proghdr 里面找不到这个变量,所以编译失败

解决方法:用 grep 查找 struct proghdr 的定义,可以看到用于确定在ELF文件中起始位置的字段名实际上是 off,改正即可

内核启动时报错 panic: init0 load

原因:创建 VMA 时直接用了 ph.vaddrph.memsz,没有页对齐。VMA 是按页管理的,如果直接映射原始范围,段的起始地址或结束地址没有刚好在页边界,之后 loadseg 写入段内容时,可能因为部分页没有正确创建导致失败

解决方法:创建 VMA 前先对段范围做页对齐。用 PGROUNDDOWN(ph.vaddr) 得到按页向下对齐后的起始地址,用 PGROUNDUP(ph.vaddr + ph.memsz) 得到按页向上对齐后的结束地址,再用对齐后的范围创建 VMA

1
2
3
uint64_t start = PGROUNDDOWN(ph.vaddr);
uint64_t end = PGROUNDUP(ph.vaddr + ph.memsz);
uint64_t sz = end - start;

然后使用 startsz 创建映射即可。修改后 VMA 的范围会覆盖当前段涉及到的所有页,loadseg 写入时对应的虚拟地址已经被 VMA 完整覆盖,init0 可以正常加载

进程与线程

内核线程

内核线程就是运行在内核里的特殊进程,从创建开始就在内核态运行。虽然它叫线程,但和普通进程一样会被调度器调度,也有自己的 PCB 和内核栈。区别在于普通用户进程运行的是用户程序,而内核线程运行的是内核里的函数。类似内核自己创建出来的后台任务,用来完成一些持续的维护工作

读取密码并打印

这部分的目标是创建一个内核线程,让它在文件系统初始化完成后,读取文件镜像里的 /password 文件并打印

这里需要使用 kthread_create 创建线程,使用 namee 查找文件目录项,使用 reade 读取文件内容。首先在源码里查找这些函数的定义

1
2
3
grep -R "kthread_create" -n include src
grep -R "namee(" -n include src
grep -R "reade(" -n include src
  • kthread_create(char *name, kthread_callback_t callback):创建一个内核线程。name 是线程名称,callback 是线程启动后执行的函数
  • namee(entry_t *from, char *path):根据路径查找文件系统中的目录项。from 表示查找的起始目录,path 表示要查找的路径
  • reade(entry_t *entry, int user, uint64_t buff, off_t off, int n):从指定文件目录项中读取内容。entry 是要读取的文件,user 表示目标地址是否是用户态地址,buff 是保存读取结果的缓冲区地址,off 是文件内读取偏移,n 是读取字节数

src/mythread.c中写入下面的代码,实现mythread_fn函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "common.h"
#include "fs/fs.h"

extern fat32_t *fat;

void mythread_fn(proc_t *me) {
    // 在文件系统初始化完成之前一直让出CPU
    while (!fat)
        yield();

    __sync_synchronize();
	
    // 创建并初始化保存密码的数组
    char buf[128];
    memset(buf, 0, sizeof(buf));
	
    // 查找密码文件
    entry_t *ep = namee(NULL, "/password");
    if (ep == NULL) {
        kprintf("mythread: /password not found\n");
        while (1);
    }
	
    // 读取文件内容
    int n = reade(ep, 0, (uint64_t)buf, 0, sizeof(buf) - 1);
    if (n < 0) {
        kprintf("mythread: read /password failed\n");
        while (1);
    }

    buf[n] = '\0';
    kprintf("password: %s\n", buf);

    // Loop to avoid exit which is not supported now :(
    while (1);
}

创建线程

工作函数写完后,还需要在 mythread_init() 中真正创建线程。在 src/mythread.c增加这个函数

1
2
3
void mythread_init() {
    kthread_create("mythread", mythread_fn);
}

这个函数创建了一个叫 mythread 的内核线程。系统启动过程中调用 mythread_init() 后,这个线程就会进入调度,调度器选中它时,就会开始执行 mythread_fn函数

验证

src/main.c 中,调用创建线程函数

1
2
3
4
// 放在函数声明区域
extern void mythread_init();
// 放在内核启动流程中
mythread_init();

重新编译运行,可以看到启动过程中就输出了密码。这是因为这条输出是内核线程自己在内核态中用 kprintf 打印出来的,不依赖用户态程序触发。系统启动后,只要线程被创建,而且文件系统已经初始化完成,它就能自己执行读取文件的任务

yield系统调用

原理

yield 的作用是让当前进程主动让出CPU。它将当前进程的状态从RUNNING改成RUNNABLE,然后调度器重新选择可运行的进程。而当前进程会重新参与调度,下一次被选中后再运行

yield的实现在src/kernel/sched.c

1
2
3
4
5
6
7
void yield() {
    struct proc *p = myproc();
    acquire(&p->lock);
    pstate_migrate(p, RUNNABLE);
    sched();
    release(&p->lock);
}
  • myproc():获取当前进程
  • acquire(&p->lock):给当前进程加锁,防止状态修改时出问题
  • pstate_migrate(p, RUNNABLE):把当前进程状态改成 RUNNABLE
  • sched():进入调度器,重新选择可运行的进程
  • release(&p->lock):当前进程之后被调度回来后,释放进程锁

实现调用

系统调用需要先分配一个没有被使用过的 syscall number,打开 entry/syscall.tbl,在尾部添加

1
124 sched_yield sys_sched_yield
  • 124 是系统调用号
  • sched_yield 是系统调用名称
  • sys_sched_yield 是内核中真正执行的函数

编辑 src/sys_sched_yield.c,实现 sys_sched_yield。这部分很简单,用户调用sched_yield后,内核直接执行yield即可

1
2
3
4
5
6
7
#include "common.h"
#include "kernel/sched.h"

uint64_t sys_sched_yield(void) {
    yield();
    return 0;
}

最后修改src/Makefile,添加下面的代码来将函数链接到内核,否则虽然代码存在但内核无法找到

1
obj-y += sys_sched_yield.o

测试

user/src/init0.c 开头部分写入这个测试函数,这个测试会创建 3 个子进程,每个子进程循环 5 次。每轮循环里,子进程都会先调用 sched_yield() 主动让出 CPU,等下次被调度器选中再打印自己的 PID 和当前循环次数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include "syscall.h"

void test_yield() {
    for (int i = 0; i < 3; ++i) {
    	// 创建子进程,只有子进程fork返回0
        if (fork() == 0) {
            for (int j = 0; j < 5; ++j) {
                sched_yield();
                printf("Child %d. Iteration %d.\n", getpid(), j);
            }
            exit(0);
        }
    }
	// 父进程等待子进程结束
    wait(NULL);
    wait(NULL);
    wait(NULL);
}

在main函数中,进入系统打印欢迎消息之后加入调用

1
2
3
4
5
6
7
8
void main() {
    run("welcome");

    test_yield();

    run("sh");
    halt();
}

重新编译并运行,可以看到如图输出

输出三个不同PID的Child说明三个子进程都成功输出了,但输出顺序没有规律,并且有些内容被嵌套了,这是因为多个子进程是平行关系,无法预测调度器下一个选择的进程,类似于抢CPU

如果想让输出有序,就不能让子进程自由竞争 CPU。可以创建一个子进程后立刻 wait,等这个子进程全部输出结束后,再创建下一个子进程,这样输出顺序就会固定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void test_yield() {
    for (int i = 0; i < 3; ++i) {
        if (fork() == 0) {
            for (int j = 0; j < 5; ++j) {
                sched_yield();
                printf("Child %d. Iteration %d.\n", getpid(), j);
            }
            exit(0);
        }
        wait(NULL);
    }
}

进程时间片

原本OS的进程调度是基于时间片轮转来调度的。时钟中断后内核会让当前进程立刻yield让出 CPU,然后调度器选择下一个进程,一个进程每次只能执行一个时间片

而这里要做的是让一个进程可以连续执行多个时间片,只有剩余的时间片用完时才触发调度

PCB 增加字段

时间片属于进程运行状态的一部分,所以需要保存在 PCB 中。打开 include/kernel/proc.h,找到 struct proc 结构体,最后一行添加time_slice字段,用来记录当前进程还剩多少时间片

1
2
3
4
5
struct proc {
    // ...

    int time_slice;
};

再定义3作为默认时间片数量,也就是一个进程被调度器选中后,最多可以连续经历 3 次时钟中断

1
#define TIME_SLICE_DEFAULT 3

时钟中断

时间片是在时钟中断里消耗的,所以需要修改 src/kernel/irq.c

原来的逻辑是每次时钟中断都直接让出 CPU

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
when_clock {
            if (cpuid() == 0) {
                // debug("clock...\n");
                clockintr();
            }
            reset_timer();
            if (p && p->state == RUNNING && !p->killed) {
                // give up the CPU if this is a timer interrupt.
                yield();
            }
            break;
        }

改成这样的程序,这样每次时钟中断时当前进程不会立刻调用 yield(),而是剩余时间片减一,只有减少到0后才会让出CPU

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
when_clock {
    if (cpuid() == 0) {
        clockintr();
    }
    reset_timer();
    if (p && p->state == RUNNING && !p->killed) {
        p->time_slice--;

        info("\npid %d time_slice left %d\n", p->pid, p->time_slice);

        if (p->time_slice <= 0) {
            info("\npid %d time_slice used up, yield\n", p->pid);
            yield();
        }
    }
    break;
}

初始化时间片

时间片用完后,进程会通过 yield() 回到调度器。调度器的实现在 src/kernel/sched.cscheduler 中,可以在进程被选中后,真正被切换之前初始化时间片,这样在它被调度时,又可以继续运行多个时钟中断周期

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
p->__state = RUNNING;

c->proc = p;
set_current(p);

// 初始化时间片
if (p->time_slice <= 0) {
  p->time_slice = TIME_SLICE_DEFAULT;
}

switchuvm(p->mm);
swtch(&c->scheduler, &p->context);
switchkvm();

重新编译运行后可以看到输出了结果,time_slice是一直在减少的,说明每一次时钟中断,时间片就减少1。减少到0后就输出了yield,说明这个进程在时间片用完后让出了 CPU

时间片主动让出

进程让出 CPU 有两种情况,一种是时间片用完,在时钟中断中调用 yield(),另一种是进程主动调用 yield()比如 sched_yield() 。为了区分这两种情况,可以在 PCB 中增加一个字段记录让出原因

1
int yield_reason;

为了更容易观察到时间片耗尽的情况,这里将默认时间片改成2来增加切换次数。还需要定义两个值用来标记是哪一种让出情况

1
2
3
#define TIME_SLICE_DEFAULT 2
#define YIELD_VOLUNTARY 0
#define YIELD_TIMESLICE 1

src/kernel/irq.c 中,时间片用完时,把原因标记为时间片耗尽

1
2
3
4
if (p->time_slice <= 0) {
    p->yield_reason = YIELD_TIMESLICE;
    yield();
}

然后在 src/kernel/sched.cscheduler 中, swtch 返回后判断原因。打印完成后再把 yield_reason 初始化成 YIELD_VOLUNTARY,避免影响下一次判断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
switchuvm(p->mm);
swtch(&c->scheduler, &p->context);
switchkvm();

if (p->yield_reason == YIELD_TIMESLICE) {
    info("\npid %d time_slice used up, yield\n", p->pid);
} else {
    info("\npid %d yield voluntarily\n", p->pid);
}

p->yield_reason = YIELD_VOLUNTARY;

c->proc = NULL;

这里的 p 是调度器刚刚切换过去运行的进程。swtch 前的 p 是将要运行的进程,swtch 返回后的 p 是刚刚运行完并让出 CPU 的同一个进程。swtch 返回后控制权回到了调度器,所以此时可以通过 p->yield_reason 判断它刚才为什么让出 CPU

这样如果进程因为 time_slice 减到 0 被切走,就会打印时间片耗尽,如果没有被标记为 YIELD_TIMESLICE,就说明是其他情况导致的让出

内核main中有一个叫做scavenger的内核线程,它会做出主动让出调度器的动作。运行它之后,输出中会更容易看到主动让出的提示,因为它会主动调用 yield()

src/main.c 中,把这一行注释去掉

1
scavenger_init();

重新编译运行,发现输出中不停出现yield voluntarily,说明这个线程会循环调用yield

时钟中断

时钟中断就是硬件定时器周期性打断 CPU,让内核重新获得控制权,从而实现进程切换和时间片消耗。如果没有时钟中断,一个进程只要不主动 yield,就可能一直占用 CPU

中断入口

中断和异常的入口在 src/kernel/trap.c,有两个入口函数:

  • usertrap():处理来自用户态的 trap
  • kerneltrap():处理来自内核态的 trap

这两个函数的判断逻辑如下:

1
2
3
4
5
6
when_irq {
    if (handle_irq(scause) == -1) {
        goto kill;
    }
    break;
}

最主要的一句是handle_irq(scause) == -1

scause 是记录陷入原因的寄存器。最高位为 1 表示发生了中断,剩余部分保存中断编号。时钟中断进入这里后,会继续交给 handle_irq() 判断类型。返回 -1 表示中断没有被正常处理,跳转到 kill

识别时钟中断

include/riscv.h 中定义对应的宏:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define INTR_SOFT   (INTERRUPT  + 1)
//添加
#define INTR_TIMER  (INTERRUPT  + 5)

#define INTR_EXT    (INTERRUPT  + 9)

#define when_softirq \
    case INTR_SOFT:

#define when_hardirq \
    case INTR_EXT:
//添加
#define when_clock \
    case INTR_TIMER:

时钟中断在 scause 中的值为 INTERRUPT + 5

  • INTERRUPT: 表示最高位的中断标记

  • 5:时钟中断的编号

这一段表示原代码when_clock,在编译前会变成case (INTERRUPT + 5)

中断处理

handle_irq() 中已经写好了时钟中断的处理过程。这里只需要在 include/riscv.h 中实现when_clock,让它能在时钟中断时调用yield()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
when_clock {
    // 识别到时钟中断
    if (cpuid() == 0) {
        // 更新系统时间
        clockintr();
    }
    // 设置下一次时钟中断
    reset_timer();
    // 当前进程正在运行并且没有被杀死
    if (p && p->state == RUNNING && !p->killed) {
    	
    	kprintf("[clock] pid=%d ticks=%d\n", p->pid, ticks);
    	// 让出CPU
        yield();
    }
    break;
}

验证

重新编译运行,可以看到一直循环输出clock,说明时钟中断是周期性无限发生的

踩坑记录

编译时报错 implicit declaration of function 'test_yield'

原因:在 user/src/init0.cmain() 中调用了 test_yield(),但在调用前没有声明或定义这个函数

解决方法:将 test_yield() 函数在 main() 之前定义

编译时报错 TIME_SLICE_DEFAULT undeclared

原因:在 sched.c 中使用了 TIME_SLICE_DEFAULT,但没有在任何地方定义它。编译器找不到 TIME_SLICE_DEFAULT 所以直接报错

解决方法:在 include/kernel/proc.h 中定义默认时间片数量。这个文件是公共头文件,irq.csched.c都包含了它,因此可以使用其中定义的宏

1
#define TIME_SLICE_DEFAULT 3

文件系统

磁盘块缓存

磁盘访问速度比内存慢很多,所以文件系统会在内存中维护一组块缓存,读取磁盘块时,先在缓存里查找。如果目标块已经存在,就直接返回缓存,否则再选择一个空闲缓存块回收,用它去缓存新的磁盘块

这里使用 LRU 链表管理缓存块,LRU的淘汰特性是Least Recently Used,也就是最近最少使用。链表头表示最近使用过的缓存,链表尾表示最久没有使用的缓存。释放一个缓存块时,如果它已经没有外部引用,就把它移动到链表头。缓存未命中时,从链表尾部向前找 refcnt == 0 的块进行回收

LRU链表初始化

具体实现在 src/fs/bio.cbcache_init函数中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 创建空链表
INIT_LIST_HEAD(&bcache.lru_head);
// 循环依次初始化每个缓存
for (b = bcache.buf; b < bcache.buf + NBUF; b++) {
    // 没被引用
    b->refcnt = 0;
    // 初始化块号
    b->blockno = 0;
    // 没绑定磁盘设备
    b->dev = -1;
    // 初始化这个缓存自己的锁
    initsleeplock(&b->lock, "buffer");
    // 把缓存加入 LRU 链表
    list_add(&b->lru, &bcache.lru_head);
}

这段就是初始化一个缓存池。bcache.buf 是包含 NBUF 个缓存对象的数组,LRU 链表负责排列这些缓存的使用顺序。初始化结束后,所有缓存都处于可使用状态,此时没有缓存真正读入磁盘数据,LRU 顺序也没有实际意义,后续使用和释放缓存时才会调整顺序

LRU缓存获取

src/fs/bio.c中写入bget。传入磁盘设备号与想读取的磁盘块号,实现命中与回收逻辑。一个磁盘块由设备号和块号共同确定,所以两个值都相同才表示命中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
static blk_buf_t *bget(uint dev, uint blockno) {
    blk_buf_t *b;
	// 防止多个进程同时修改链表,因此获取全局锁
    acquire(&bcache.lock);

    // 查找目标磁盘块是否已经在缓存中
    list_for_each_entry(b, &bcache.lru_head, lru) {
        // 当前块命中缓存,不需要重新回收缓存块
        if (b->dev == dev && b->blockno == blockno) {
            // 当前调用者正在使用这个缓存块
            b->refcnt++;

            kprintf("bcache hit: block=%u\n", blockno);
			// 缓存查找和引用计数修改完成,因此释放全局锁
            release(&bcache.lock);
            // 之后这个块需要被读取数据,获取当前缓存块自己的睡眠锁
            acquiresleep(&b->lock);
            return b;
        }
    }

    // 缓存未命中,从 LRU 链表尾部开始寻找可以回收的缓存块
    list_for_each_entry_reverse(b, &bcache.lru_head, lru) {
        // 只有引用计数为 0 的缓存块才能被重新使用
        if (b->refcnt == 0) {
            kprintf("bcache recycle: old=%u new=%u\n", b->blockno, blockno);
			//进行替代,更新设备号、块号、有效标记、引用计数
            b->dev = dev;
            b->blockno = blockno;
            // 表示这个缓存块虽然已经绑定到了新的磁盘块号,但内容还没有真正从磁盘读入。之后读取时会再触发磁盘读入
            b->valid = 0;
            // 当前调用已经占用这个缓存
            b->refcnt = 1;
            
			// 之后 bread 会把磁盘数据读入这个缓存,因此释放全局锁获取睡眠锁
            release(&bcache.lock);
            acquiresleep(&b->lock);
            return b;
        }
    }

    release(&bcache.lock);
    panic("bget: no buffers");
}

refcnt统计当前缓存块调用者的数量。只要大于0,这个缓存块就不能被改成保存其他磁盘块

锁分为全局锁与睡眠锁:

  • bcache.lock 是全局锁,负责保护缓存链表和引用计数。只在查找缓存和修改 refcnt 时使用,防止多个进程同时修改链表。找到目标缓存并增加 refcnt 后,就可以释放它,让其他进程继续访问缓存链表

  • b->lock 是当前缓存自己的锁,用来保护当前缓存块中的具体内容 b->data,保证同一时间只有一个调用者修改它。获取睡眠锁可能需要等待,所以必须先释放全局锁,避免等待期间锁住整个缓存系统

LRU缓存释放

当上层访问完块缓存后,用 brelse来释放这个块缓存。函数会先同步这个块缓存与磁盘的内容,然后归还块缓存的引用,当引用为0时,块缓存就成为了可被淘汰的状态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void brelse(blk_buf_t *b) {
    // 说明调用者确实正在使用这个缓存块
    if (!holdingsleep(&b->lock))
        panic("brelse");
	// 将缓存中被修改的内容同步回磁盘
    bsync(b);
    // 调用者已经使用完,释放缓存块自己的锁,获取全局锁
    releasesleep(&b->lock);
    acquire(&bcache.lock);
	// 当前调用者归还缓存块,引用计数减一
    b->refcnt--;
    
	// 这个缓存块已经没有外部引用
    if (b->refcnt == 0) {
       
        kprintf("bcache release: block=%u move to head\n", b->blockno);
        
		// 将这个缓存块移动到 LRU 链表头部
        list_del(&b->lru);
        list_add(&b->lru, &bcache.lru_head);
    }
	// refcnt 和 LRU 链表修改完成,释放全局锁
    release(&bcache.lock);
}

refcnt 减到 0 时,说明这个缓存块当前已经没有外部引用。它刚刚被使用完,所以属于最近使用过的块,需要从原来的位置删除,再移动到 LRU 链表头部。这样链表尾部就会逐渐保留最久没有被使用的缓存块。之后缓存未命中时,从尾部反向遍历,就能优先回收最近最少使用的块

1
2
list_del(&b->lru);
list_add(&b->lru, &bcache.lru_head);

思考:缓存未命中时,回收最尾部当前未被引用的块,那么替换后新的缓存块就在链表最尾部。假如连续两次发生未命中,第二次未命中时,就会替换第一次未命中替换来的缓存块,可它并不是最久未使用的块

原因:发生替换就说明这个新缓存块要被使用了。使用完成后,brelse会把它移动到链表头,而不是我最开始理解的保留在链表尾部。因此刚替换的缓存不会立即再次被回收。我忽略了使用的过程

替换的具体流程:更新块信息、 refcnt=1bread 从磁盘读入数据、使用缓存refcnt=0、移到头部

验证

系统启动和读取文件时会触发块缓存的读写,这里能看到缓存块的命中与移动,说明实现成功

FAT32簇链

FAT32 是一种显式链式分配文件系统。它会把分区空间划分为多个簇。一个簇由多个扇区组成,是 FAT32 分配存储空间的基本单位。文件内容不一定连续存放在磁盘上,而是按簇存储,一个文件可能占用多个簇

簇之间的连接关系保存在 FAT 表中。每个簇在 FAT 表中都有一个对应表项,表项里保存的是下一个簇号。只要从文件起始簇开始,不断读取 FAT 表中的后继簇号,就可以沿着簇链找到整个文件的数据

函数实现

src/fs/fat32.c中实现fat_next_cluster,它的作用是给定当前簇号 cclus,返回它在 FAT 表中记录的下一个簇号

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
static uint32_t fat_next_cluster(fat32_t *fat, uint32_t cclus) {
    // 不能继续查询空闲标记或簇链结束标记
    if(IS_FAT_CLUS_END(cclus) || cclus == FAT_CLUS_FREE)
        panic("try to follow a FREE/EOC cluster");
    uint32_t answer;
    // 读取当前簇号所在的 FAT 表扇区,并读入缓存块
    blk_buf_t *buf = bread(fat->dev, clus2fatsec(fat, cclus));
    // 读取后继簇号
    answer = *(uint32_t *)(buf->data + clus2offset(fat, cclus));
    answer &= 0x0FFFFFFF;
    
    kprintf("fat_next: %u -> %u\n", cclus, answer);
    
	// 释放缓冲区并返回结果
    brelse(buf);
    return answer;
}

FAT 表很大,被分开放在多个扇区中。clus2fatsec(fat, cclus) 用来找到记录当前簇号的那部分 FAT 表所属的扇区。bread 把这个扇区读入内存,buf 指向读出的内容

1
blk_buf_t *buf = bread(fat->dev, clus2fatsec(fat, cclus));

buf->data 指向刚刚读入内存的整个 FAT 扇区开头,clus2offset(fat, cclus)计算出FAT表项位于该扇区的第几个字节。FAT32 中每个表项占 4 字节,所以找到偏移后,把这个位置的数据按 uint32_t 读取出来,就得到了下一个簇号

1
answer = *(uint32_t *)(buf->data + clus2offset(fat, cclus));

FAT32 的表项虽然占 32 位,但簇号只使用低 28 位。因此通过answer &= 0x0FFFFFFF;,把高 4 位清除

验证

重新编译运行后,可以看到系统在读取文件时不断打印当前簇号和后继簇号,后面启动 shell 时也能看到类似输出。这说明系统在加载用户程序时已经正常使用了簇链追踪功能

FAT32下的文件检索

平时打开文件时,会传入一个文件名,比如 test.txt。文件系统会根据这个名字在目录中找到对应的目录项,之后再读取内容。FAT32 的目录项比较特殊,会涉及短文件名和长文件名,这里的框架已经把这些细节封装到函数里,所以只需要处理文件查找的逻辑即可

具体方法是遍历当前目录中的每一个逻辑目录项,拿到它的文件名,然后和目标文件名比较。如果名字相同,就把这个目录项保存下来,同时记录它在目录中的偏移位置

逻辑

首先在src/fs/fat32.c中查看入口函数,作用是在指定目录簇下寻找名为name的目录项

1
2
3
4
FR_t fat_dirlookup(fat32_t *fat, uint32_t dir_clus, const char *cname, dir_item_t *ret, uint32_t *offset) {
    DEFINE_LOOKUP_HELPER(helper, cname, 0, ret, offset);
    return fat_travs_logical_dir(fat, dir_clus, 0, lookup_handler, &helper);
}

这个函数需要输出两份信息,同时还要用返回值表示成功或失败。C 函数只能直接返回一个值,所以这里采用指针输出

  • fat: FAT32 文件系统对象,里面保存簇大小和 FAT 表位置等
  • dir_clus:要遍历的目录簇起始簇号
  • cname:需要查找的目标文件名
  • ret:目录项的输出地址
  • offset:目录项位置的输出地址

fat_travs_logical_dir 负责遍历目录,同时屏蔽 FAT32 长短目录项的细节。它每遍历到一个逻辑目录项,就会调用一次 lookup_handler,所以需要在 lookup_handler 中判断当前文件名是不是目标

1
2
3
4
5
6
7
FR_t fat_travs_logical_dir(
    fat32_t *fat,
    uint32_t dir_clus,
    uint32_t dir_offset,
    travs_handler_t handler,
    void *state
);
  • fat :FAT32 文件系统对象
  • dir_clus :要遍历目录的起始簇号
  • dir_offset :遍历开始的位置
  • handler :判断每个目录项的函数
  • state :传递给 handler 的状态信息

DEFINE_LOOKUP_HELPER 负责创建一个结构体,用来保存handler所需的状态信息

1
2
3
4
5
6
typedef struct lookup_helper {
    const char *name;
    uint8_t checksum;
    dir_item_t *item;
    uint32_t *offset;
} lookup_helper_t;
  • name: 要查找的文件名
  • item: 目标目录项
  • offset: 目标目录项在目录中的偏移位置

后两个参数是用于输出的,也就是在handler中匹配到了对应的目录项后,需要将相关信息赋值给这些字段中指针所指向的位置

函数实现

接下来,fat_dirlookup会将helper状态信息与lookup_handler等信息传递给fat_travs_logical_dir用于遍历。在 src/fs/fat32.c中,写入lookup_handler的逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//接受一个目录项item,以及该目录项的名字name,目录项偏移offset,还有传入的helper
static FR_t lookup_handler(dir_item_t *item, const char *name, off_t offset, void *__helper) {
    // 把传入的 `__helper` 转回 `lookup_helper_t *`
    lookup_helper_t *helper = (lookup_helper_t *)__helper;
	//临时打印,正式实现可以删除
    kprintf("lookup: target=[%s], current=[%s], offset=%ld\n",
            helper->name, name, offset);
	// 文件名匹配
    if (strncmp(name, helper->name, strlen(helper->name) + 1) == 0) {
        //临时打印
        kprintf("lookup: found [%s]\n", name);
		// 把当前目录项复制到输出位置
        if (helper->item)
            *helper->item = *item;
		// 保存当前目录项的偏移
        if (helper->offset)
            *helper->offset = offset;
		// 查找成功
        return FR_OK;
    }
	// 不匹配,继续遍历后面的目录项
    return FR_CONTINUE;
}

文件名比较用的是strncmp(name, helper->name, strlen(helper->name) + 1)

  • name :当前遍历到的文件名
  • helper->name :目标文件名

比较长度用了 strlen(helper->name) + 1,是为了把字符串结尾的 \0 也比较进去。如果只比较目标文件名长度,可能会出现前缀误判。比如目标是 abc,当前文件名是 abcd,前三个字符相同,但这两个文件名并不一样。把 \0 一起比较后,abc 的第四个字符是字符串结束符,abcd 的第四个字符是 d,就不会被误判成同一个文件

验证

重新编译运行,可以看到,lookup_handler 会从目录开头逐项比较当前文件名和目标文件名。找到 sh 后返回 FR_OK。最后系统正常进入 shell

进程间通信

管道通信

普通进程之间的地址空间是隔离的,一个进程不能直接访问另一个进程的变量。而管道就是内核提供的一块缓冲区,用来让两个进程之间传递数据。管道是单向传输,有读端和写端,进程可以通过 write 往写端写入数据,通过 read 从读端读取数据

这里要做的是父进程从输入读取一个字符串,通过管道发给子进程。子进程把字符串转成全小写后,再通过另一个管道传回父进程,最后父进程进行输出

原理

首先在src/fs/pipe.c中查看创建管道的函数pipe的实现

  • pipealloc:创建管道,分配读写端。用户态中的 fd[0] 对应读端,fd[1] 对应写端
  • piperead:从管道读取数据。管道有数据时直接读,管道为空但写端还开着时阻塞等待,管道为空且写端关闭时返回 0
  • pipewrite:向管道写入数据。缓冲区没满时正常写入,缓冲区满但读端还在时阻塞等待,读端关闭后继续写入会返回错误
  • pipeclose:关闭管道的一端。关闭写端会唤醒等待读取的进程,关闭读端会唤醒等待写入的进程。读端和写端都关闭后,管道占用的内存会被释放
  • __pipe_empty:判断管道是否为空,条件是 nread == nwrite
  • __pipe_full:判断管道是否已满,条件是 nwrite == nread + PIPESIZE
  • pipe_emptypipe_full:带加锁保护的空满管道判断函数,避免并发访问时状态不一致

管道内部可以理解成一个环形缓冲区。nread 记录已经读出的字节数,nwrite 记录已经写入的字节数,实际访问数组时通过 % PIPESIZE 回到对应位置

函数实现

管道是单向的,为了实现双向通信,这里创建了两个管道。管道的输入与输出句柄都会返回给当前的进程,为了让另一个进程可以访问,这里使用了fork()

user/src 下新建 pipe_test.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include "unistd.h"
#include "stdio.h"
#include "stdlib.h"

#define LEN 16

int main() {
    // 数组下标0是读端,下标1是写端
    int p2c[2];
    int c2p[2];
    char buf[LEN + 1];

    // 创建两个管道
    pipe(p2c);
    pipe(c2p);
	
    // 创建子进程,fork会让子进程也拿到这两个管道的读写端
    int pid = fork();
    
    // pid == 0代表子进程,只需要从p2c读数据,再往c2p写数据,所以关闭不需要的端
    if (pid == 0) {
        close(p2c[1]);
        close(c2p[0]);
        
		// 子进程读取数据并进行处理
        read(p2c[0], buf, LEN);
        for (int i = 0; i < LEN; i++) {
            if (buf[i] >= 'A' && buf[i] <= 'Z') {
                buf[i] = buf[i] - 'A' + 'a';
            }
        }
		// 写回数据
        write(c2p[1], buf, LEN);

        close(p2c[0]);
        close(c2p[1]);

        exit(0);
    } else {
        // 父进程部分
        close(p2c[0]);
        close(c2p[1]);
		// 从输入读数据,这里的0是标准输入
        read(0, buf, LEN);
        // 父进程的管道部分
        write(p2c[1], buf, LEN);
        read(c2p[0], buf, LEN);
        buf[LEN] = '\0';

        printf("%s\n", buf);

        close(p2c[1]);
        close(c2p[0]);
        
		// 父进程等待子进程结束,回收子进程资源
        int status;
        wait(&status);
    }

    return 0;
}

验证

重新编译并运行,进入系统后运行测试程序

1
./pipe_test

输入一串16个字母的序列ABCDefghIJKLmnop,如果传输成功,可以看到下面的输出

这里的转换结果是由子进程通过管道传回父进程的。父进程只负责读取输入、发送接收数据、打印结果

shell 里的 | 也是用管道实现的。比如ls | grep test,这个命令的意思是ls 列出当前目录下的文件和目录,然后把输出结果交给 grep test 继续处理,grep test 从这些内容中筛选出包含 test 的行

shell 会先创建一个管道,再创建两个子进程。一个子进程执行 ls,另一个子进程执行 grep test。通过 dupls 的输出改到管道写端,把 grep 的输入改到管道读端。这样 ls 输出的内容通过管道传给grep ,就实现了前一个程序的输出作为后一个程序的输入

知识结构

名词解释

进程调度

  • 进程:一个运行中的程序就是一个进程
  • 线程:进程中实际执行代码的单位,同一个进程可以包含多个线程
  • 用户进程:在用户态运行的程序,使用的硬件资源由内核来分配,权限低
  • 内核线程:只在内核态运行的线程,用来执行内核中的任务
  • PCB:进程控制块,内核用它保存进程编号和运行状态等信息
  • PID:进程编号,内核用它区分不同进程
  • 上下文:进程在某个时刻的完整状态,包含了寄存器栈指针等信息
  • 父进程:创建其他进程的进程
  • 子进程:由父进程创建出来的新进程
  • fork:复制当前进程并创建一个子进程,子进程从fork返回的位置继续运行
  • clone:创建一个新的执行任务,可以通过参数决定它和原任务共享哪些资源
  • 就绪态:进程已经可以运行,正在等待CPU
  • 运行态:进程当前正在使用CPU
  • 阻塞态:进程正在等待某个事件,暂时无法继续运行
  • 互斥:同一时间只允许一个执行任务访问共享数据,防止内容被同时修改
  • 调度队列:保存等待运行的进程,调度器会从中选择下一个进程
  • 调度:内核决定接下来让哪个进程使用 CPU。虽然CPU一个核心同一时刻只能运行一个进程,但由于切换的足够快,从而看起来是同时运行
  • 调度器:内核中负责选择下一个运行进程的代码
  • 时间片:一个进程每次可以连续使用CPU的时间,时间片用完后会重新进行调度
  • yield:进程主动让出CPU,让调度器选择其他进程运行
  • 上下文切换:保存当前进程的上下文,再恢复另一个进程的上下文,让CPU去运行另一个进程
  • 进程切换:CPU从一个进程切换到另一个进程,切换时需要保存和恢复上下文

内存管理

  • 物理内存:计算机中真实存在的内存,程序的数据最终保存在这里
  • 物理地址:数据在物理内存中的真实地址
  • 页框:物理内存被划分成的固定大小区域,一个页框可以保存一页数据
  • 虚拟内存:操作系统管理物理内存,为了防止信息泄露和安全问题,会给每个进程分配一块单独的专属内存,分配是通过虚拟地址来交给进程,这就是虚拟内存,而虚拟地址再通过页表映射到物理内存
  • 虚拟地址:进程使用的内存地址,需要通过页表转换成物理地址
  • :虚拟内存被划分成的固定大小区域
  • 页表:虚拟内存对应物理内存的映射表,每个进程的页表都是独立的
  • 按页对齐:让地址位于一页的开头,也就是地址是页大小的整数倍
  • VMA:一段连续的虚拟内存区域,记录这段区域的地址范围和访问权限
  • :存放函数调用相关的数据
  • :程序运行过程中动态申请的内存区域
  • 用户栈:用户程序在用户态运行时使用的栈
  • 内核栈:进程进入内核态后使用的栈,与用户栈分开
  • mmap:在进程的虚拟地址空间中建立一段新的内存映射
  • copy_from_user:把用户空间中的数据复制到内核空间
  • copy_to_user:把内核空间中的数据复制到用户空间
  • page fault:访问虚拟地址时找不到有效映射或没有访问权限,CPU会触发异常并进入内核

CPU与硬件

  • 架构:CPU指令集和运行模式的整体设计结构,不同架构运行模式不同,指令不互通
  • 用户态:CPU运行普通程序的状态,很多权限受限,而且没有办法直接控制硬件
  • 内核态:CPU运行操作系统内核的状态,权限最高但没有办法直接由用户态控制
  • SBI:内核向更底层固件请求服务的接口,例如设置定时器
  • 寄存器:CPU内部临时保存数据和地址的位置
  • pc:程序计数器,保存CPU当前执行的指令地址
  • sp:栈指针,保存当前栈顶的地址
  • fp:栈帧指针,保存当前函数栈帧的位置
  • ra:返回地址寄存器,保存函数执行结束后要返回的位置
  • trap:陷入,在遇到系统调用、中断、异常等操作时CPU的处理流程。用户态发生的trap会进入内核态处理
  • ecall:用户程序主动进入内核的指令,通常用来发起系统调用
  • 异常:执行当前指令时出现问题,使CPU进入内核进行处理
  • 中断:硬件发出的信号,会暂时打断CPU当前正在执行的程序
  • timer interrupt:硬件定时器周期性产生的中断,内核可以借此更新时间并进行进程调度
  • scause:保存本次陷入原因的寄存器,用来判断发生的是哪种中断或异常
  • sepc:保存发生陷入时指令地址的寄存器,处理完成后可以根据它返回原程序
  • stval:保存异常附加信息的寄存器,发生页面异常时通常保存出错的虚拟地址
  • sstatus:保存CPU运行状态的寄存器,其中包含中断开关和陷入前的权限状态
  • trapframe:CPU状态切换到内核态时,需要保存当前进程的上下文,这就是用来保存的结构体,切换回用户态时会将上下文取出用来恢复状态

系统调用

  • syscall:系统调用,用户态如果需要执行内核态权限的操作就需要它,由用户态陷入内核态进行
  • 用户态封装函数:用户程序直接调用的函数,负责准备系统调用号和参数,再执行ecall进入内核
  • 系统调用号:每种系统调用对应的编号,内核通过它判断用户程序请求了什么操作
  • syscall 参数:用户程序传给系统调用的数据,通常通过寄存器传入内核
  • syscall 分发表:保存系统调用号和内核处理函数对应关系的表
  • sys_xxx:内核中真正执行系统调用功能的函数,例如sys_read
  • 返回值寄存器:保存系统调用执行结果的寄存器,RISC-V中通常使用a0
  • trace:跟踪系统调用,在进程发起系统调用时输出对应的调用信息
  • argaddr(0, &user_ptr):取出寄存器中第0个参数解析成地址
  • argint(1, &len):取出寄存器中第一个参数解析成int

程序构建

  • 字段:结构体中用来保存某一项数据的成员
  • uint64_t:64位无符号整数,在 64 位系统里,经常用来存地址、寄存器值、指针内存数据,因为 64 位地址本身就是一个 64 位数字
    • uint = unsigned int
    • 64 = 64 位
    • _t = type
  • 交叉编译:在一种架构机器上将程序源文件编译成另一种架构的可执行文件
  • 静态链接:编译时把需要的库代码直接放入可执行文件,生成的文件可以独立运行
  • 动态链接:程序运行时再加载需要的共享库,生成的可执行文件更小
  • ELF:一种可执行文件格式,其中保存了程序代码和加载程序需要的信息
  • ELF Header:ELF文件的开头,保存文件类型和入口地址等基本信息
  • Program Header:描述程序运行时需要把哪些内容加载到内存
  • PT_LOAD:表示这一段内容需要被加载到内存的类型标记
  • Segment:程序运行时加载到内存中的一段内容
  • Section:ELF文件内部按照用途划分的区域,主要供编译器和链接器使用
  • .text:保存程序编译后的指令
  • .rodata:保存只读数据,例如字符串常量
  • .data:保存已经初始化的全局变量和静态变量
  • .bss:保存没有初始化或初始化为0的全局变量和静态变量
  • readelf:查看ELF文件头和内部结构的工具
  • objdump:查看目标文件内容并进行反汇编的工具
  • addr2line:根据指令地址查找对应源文件和代码行的工具

文件系统

  • inode:内核中用来表示一个具体文件的结构,保存文件类型和大小等信息
  • file:文件被打开后在内核中的记录,保存对应的inode和当前读写位置
  • fd:文件描述符,用户程序用一个整数表示已经打开的文件
  • FAT:文件分配表,记录每个簇的状态以及它连接的下一个簇
  • :FAT文件系统分配存储空间的基本单位,一个簇由若干扇区组成
  • 簇链:多个簇通过FAT表连接形成的链,用来保存一个文件的完整内容
  • block buffer:磁盘块在内存中的缓存,保存从磁盘读取出来的数据
  • bread:读取指定磁盘块并返回对应的块缓存,缓存中没有数据时会从磁盘读入
  • bwrite:把块缓存中的数据写回磁盘
  • brelse:使用完块缓存后归还它,使它可以继续被命中或被回收

流程描述

  • 系统启动流程:bootloader > kernel > 初始化内存与文件系统 > 创建第一个用户进程 > exec(init0) > 进入shell

  • 内核线程创建流程:调用内核线程创建函数 > 分配PCB和内核栈 > 设置入口函数 > 设置为就绪态 > 加入调度队列 > 等待调度器运行

  • 系统调用流程:用户程序调用 syscall > 参数与 syscall 编号写入寄存器 > trap > CPU 陷入内核态 > 保存上下文到 trapframe > 内核根据 syscall 编号找到对应处理函数 > 解析参数并执行系统调用 > 返回值写入寄存器 > 恢复上下文 > 返回用户态继续执行

  • 程序加载流程:调用 exec > 读取 ELF > 遍历 Program Header > 找到 PT_LOAD 段 > mmap_map 创建 VMA > loadseg 加载段内容 > 设置入口地址 > 进入新程序执行

  • FAT32 路径查找流程:从根目录或当前目录开始 > 取出一段文件名 > 遍历目录项 > 比较文件名 > 找到后进入下一级目录 > 重复直到找到目标文件

  • FAT32 簇链读取流程:得到文件起始簇 > 读取当前簇的数据 > 查询FAT表 > 得到下一个簇号 > 继续读取 > 遇到簇链结束标记后停止

  • 文件读取流程:用户程序调用read > 根据fd找到file > 根据file找到inode > 文件系统读取对应数据 > bread取得块缓存 > copy_to_user复制到用户空间 > brelse归还块缓存 > 返回读取长度

  • 异常处理流程:CPU检测到异常 > 记录scause和sepc等信息 > trap进入内核 > 保存上下文 > 根据scause判断异常类型 > 调用对应处理函数 > 恢复上下文并返回

  • page fault 处理流程:程序访问未映射地址 > CPU记录异常地址 > trap进入内核 > 调用handle_pagefault > 根据VMA判断访问是否合法 > 分配物理页并建立映射 > 返回后重新执行异常指令

  • 时钟中断调度流程:硬件定时器产生中断 > trap进入内核 > handle_irq识别时钟中断 > 更新系统时间 > 设置下一次中断 > 当前进程调用yield > 调度器选择其他进程

  • 上下文切换流程:当前进程进入调度器 > 保存当前进程的上下文 > 选择一个就绪进程 > 恢复目标进程的上下文 > CPU继续运行目标进程

  • backtrace 回溯流程:读取当前fp > 找到栈帧中保存的ra和上一个fp > 打印ra > 切换到上一个栈帧 > 重复直到到达栈边界

代码入口与实现思路

  • 用户程序
    • 常见位置:user/src
    • 常见方法:编写用户程序 > 构建生成 ELF > 在 init0 或 shell 中运行 > 需要内核功能时通过系统调用进入内核
  • 系统调用
    • 常见位置:entry/syscall.tblsrc/kernel/syscall.c、具体的 src/sys_xxx.csrc/Makefile
    • 常见方法:分配 syscall number > 在 syscall.tbl 注册 > 实现 sys_xxx > 加入内核构建 > 用户态准备参数并执行 ecall > 内核根据调用号找到对应函数
  • 进程
    • 常见位置:include/kernel/proc.hsrc/kernel/proc.csrc/kernel/clone.c
    • 常见方法:PCB 保存进程状态 > 创建进程时初始化或继承字段 > 调度器根据状态选择进程 > 进程退出后回收相关资源
  • 内核线程
    • 常见位置:src/mythread.csrc/main.c
    • 常见方法:编写内核线程执行函数 > 使用 kthread_create 创建线程 > 设置为可调度状态 > 调度器选中后在内核态执行
  • 进程调度
    • 常见位置:src/kernel/sched.csrc/kernel/irq.cinclude/kernel/proc.h
    • 常见方法:PCB 保存调度状态 > 时钟中断更新剩余时间片 > 时间片耗尽或进程主动 yield > 回到调度器 > 选择新的可运行进程并切换上下文
  • 异常与中断
    • 常见位置:src/kernel/trap.csrc/kernel/irq.csrc/kernel/pagefault.cinclude/riscv.h
    • 常见方法:CPU 发生 trap > 读取 scause 判断原因 > 中断交给 handle_irq > page fault 交给 handle_pagefault > 处理完成后恢复上下文继续执行
  • 虚拟内存
    • 常见位置:include/mm/mmap.hmm/mmap.cmm/vm.h
    • 常见方法:VMA 描述虚拟内存区域 > 链表管理一个进程的多个 VMA > 建立虚拟地址到物理内存的映射 > 使用 copy_from_usercopy_to_user 在用户空间与内核空间传递数据
  • 程序加载
    • 常见位置:src/kernel/exec.cinclude/elf.hmm/mmap.c
    • 常见方法:exec 读取 ELF Header > 遍历 Program Header > 找到 PT_LOAD > 根据 vaddrmemsz 创建 VMA > 根据 offfilesz 读取段内容 > 设置程序入口和用户栈 > 进入新程序执行
  • 磁盘块缓存
    • 常见位置:src/fs/bio.c
    • 常见方法:bget 查找缓存 > 命中时增加 refcnt > 未命中时回收 LRU 尾部缓存 > bread 读取数据 > 使用完成后 brelse 归还缓存并更新 LRU
  • FAT32 文件系统
    • 常见位置:src/fs/fat32.csrc/fs/bio.c
    • 常见方法:根据簇号定位 FAT 表项 > 得到下一簇组成簇链 > 遍历目录中的逻辑目录项 > 比较文件名找到目标 > 沿簇链读取文件内容
  • 管道
    • 常见位置:src/fs/pipe.c
    • 常见方法:创建管道和读写端 > write 写入数据 > read 读取数据 > 空或满时根据另一端状态决定阻塞或返回 > 两端关闭后释放管道
  • 调试工具
    • 常见工具:kprintf、GDB、objdumpreadelfaddr2line
    • 常见方法:kprintf 打印状态 > GDB 查看寄存器和调用栈 > objdump 反汇编 > readelf 查看 ELF > addr2line 根据地址定位源码
Like 0
本站已不稳定运行 小时 分钟
共发表文章 33 篇 ,总计 162.49 k 字
本站总访问量: