(undone) MIT6.S081 2023 一个月速通 (Day1: 了解 xv6 结构) (TODO: fs.img 生成方式不清楚)
一直有系统学习操作系统的打算,看了很多资料,像什么 pintos,xv6,还有一些杂七杂八的中文资料,也看了许多人对不同资料的评价和好坏判断。
个人感觉,对新手来说,最重要的还是找一个经典的、有名的、广泛认可的资料认真研读、读完,而不是纠结自己应该学哪个资料。
我们先来速通 MIT6.S081 吧。
任务1:启动 xv6 (完成)
首先是 xv6 实验官网:https://pdos.csail.mit.edu/6.S081/2023/tools.html (2023)
我们使用 WSL-ubuntu20.04 做实验。
运行如下命令安装大量依赖:
sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
运行下面命令看关键程序是否安装成功:
qemu-system-riscv64 --version
riscv64-linux-gnu-gcc --version
OK,现在让我们来到第一个 Lab : Utilities
git clone git://g.csail.mit.edu/xv6-labs-2023
cd xv6-labs-2023
make qemu
如下图,成功启动 xv6
接着使用 Ctrl + A +x 即可退出
任务2:了解 xv6 代码结构 之 搞清楚启动命令 (完成)
接下来让我们拆开 xv6,看看里面的代码是怎么写的
先看 Makefile,找到 qemu target,如下:
qemu: $K/kernel fs.img$(QEMU) $(QEMUOPTS)
它实际上是:
qemu: kernel/kernel fs.imgqemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -global virtio-mmio.force-legacy=false -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
K := kernel
QEMU := qemu-system-riscv64
QEMUOPTS := -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -global virtio-mmio.force-legacy=false -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
尝试运行这个命令:
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -global virtio-mmio.force-legacy=false -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
成功启动 xv6,说明最终启动 xv6 的就是这个命令,我们现在来了解这个命令的每一个项
根据 CHATGPT,如下:
qemu-system-riscv64:指定 QEMU 模拟的目标架构,这里是 RISC-V 64 位架构。-machine virt:指定使用的机器类型,这里是 "virt",适用于虚拟化的通用平台。-bios none:不加载任何 BIOS,通常用于直接使用内核启动,而不是通过 BIOS 启动。-kernel kernel/kernel:指定要加载的内核映像文件路径,这里是 kernel/kernel。-m 128M:设置虚拟机的内存大小,这里是 128MB。-smp 3:设置虚拟机的 CPU 核心数,这里是 3 个核心。-nographic:在没有图形界面的情况下运行虚拟机,所有输入输出通过终端进行。-global virtio-mmio.force-legacy=false:设置 virtio-mmio 设备的全局参数,强制不使用传统模式,通常用于启用新特性。-drive file=fs.img,if=none,format=raw,id=x0:定义一个虚拟磁盘驱动器,其中 file=fs.img 是映像文件名,if=none 表示不与任何具体接口关联,format=raw 指定映像文件格式为原始格式,id=x0 是该驱动器的标识。-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0:将一个 virtio 块设备添加到虚拟机,drive=x0 指定了之前定义的虚拟磁盘驱动器,bus=virtio-mmio-bus.0 指定了设备连接的总线。
这里除了 qemu,还使用了两个文件:
- kernel 内核文件
- fs.img 文件系统镜像 (TODO: here 还要了解文件系统镜像如何生成)
任务3:了解 xv6 代码结构 之 内核文件如何生成 (完成)
在 Makefile:122 可以看到 $K/kernel 的生成依赖和命令
kernel/kernel: kernel/entry.o kernel/kalloc.o kernel/string.o kernel/main.o kernel/vm.o kernel/proc.o kernel/swtch.o kernel/trampoline.o kernel/trap.o kernel/syscall.o kernel/sysproc.o kernel/bio.o kernel/fs.o kernel/log.o kernel/sleeplock.o kernel/file.o kernel/pipe.o kernel/exec.o kernel/sysfile.o kernel/kernelvec.o kernel/plic.o kernel/virtio_disk.o kernel/start.o kernel/console.o kernel/printf.o kernel/uart.o kernel/spinlock.o kernel/kernel.ld user/initcoderiscv64-linux-gnu-ld -z max-page-size=4096 -T kernel/kernel.ld -o kernel/kernel kernel/entry.o kernel/kalloc.o kernel/string.o kernel/main.o kernel/vm.o kernel/proc.o kernel/swtch.o kernel/trampoline.o kernel/trap.o kernel/syscall.o kernel/sysproc.o kernel/bio.o kernel/fs.o kernel/log.o kernel/sleeplock.o kernel/file.o kernel/pipe.o kernel/exec.o kernel/sysfile.o kernel/kernelvec.o kernel/plic.o kernel/virtio_disk.o kernel/start.o kernel/console.o kernel/printf.o kernel/uart.o kernel/spinlock.oriscv64-linux-gnu-objdump -S kernel/kernel > kernel/kernel.asmriscv64-linux-gnu-objdump -t kernel/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$/d' > kernel/kernel.sym
可以看到 kernel 的生成依赖很多 .o 文件,然后第一个命令是把这些 .o 文件给链接起来变成一个大的 binary。剩下两个 objdump 命令是用来生成汇编命令以及符号表,帮助调试的。
后续两个命令不是那么重要,我们来深入看看第一个命令
$(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS) $(OBJS_KCSAN)
其中
LD := riscv64-linux-gnu-ld
LDFLAGS := -z max-page-size=4096
OBJS := kernel/entry.o kernel/kalloc.o kernel/string.o kernel/main.o kernel/vm.o kernel/proc.o kernel/swtch.o kernel/trampoline.o kernel/trap.o kernel/syscall.o kernel/sysproc.o kernel/bio.o kernel/fs.o kernel/log.o kernel/sleeplock.o kernel/file.o kernel/pipe.o kernel/exec.o kernel/sysfile.o kernel/kernelvec.o kernel/plic.o kernel/virtio_disk.o
OBJS_KCSAN := kernel/start.o kernel/console.o kernel/printf.o kernel/uart.o kernel/spinlock.o
这个命令翻译成人话就是: riscv64-linux-gnu-ld,设置最大内存页大小为 4096 字节,随后根据 kernel/kernel.ld 链接脚本的指示,把一堆 .o 文件链接成一个大的 binary
任务4:找到 xv6 内核文件的程序入点(完成)
既然这是一个大的 binary,那么肯定有程序入点,也许是 main 函数,也许是链接脚本里定义的一个地址。
最靠谱的方式是,先找到 qemu-system-riscv64 的上电地址,再来找程序入点
使用 gdb-qemu 调试内核的方式如下,启动 qemu 的时候加上 -s -S 选项:
qemu-system-riscv64 -s -S -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -global virtio-mmio.force-legacy=false -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
随后换一个窗口,运行
gdb-multiarch kernel/kernel (这一步主要是为了读取 kernel 的符号表)
target remote localhost:1234
可以看到 qemu-system-riscv64 的上电位置在 0x1000,如下图
运行 continue 命令即可运行 xv6
0x1000 地址的指令应该是 qemu-system-riscv64 的内置 BIOS/bootloader,它的作用是把 kernel 加载到位置 0x80000000,然后把执行流交给 0x80000000 的指令。
为了确认 0x80000000 是内核入点,这里我们可以看看 kernel/kernel.ld 链接脚本,如下
/* 输出的目标架构是 riscv */
OUTPUT_ARCH( "riscv" )
/* 程序入口点为 _entry */
ENTRY( _entry )/* 下面的部分定义了各个段(section)在内存中的布局 */
SECTIONS
{/** ensure that entry.S / _entry is at 0x80000000,* where qemu's -kernel jumps.*//* 将入口点 _entry 设置在地址 0x80000000,这是 QEMU 模拟器启动内核时的跳转地址。 */. = 0x80000000;.text : {*(.text .text.*). = ALIGN(0x1000);_trampoline = .;*(trampsec). = ALIGN(0x1000);ASSERT(. - _trampoline == 0x1000, "error: trampoline larger than one page");PROVIDE(etext = .);}.rodata : {. = ALIGN(16);*(.srodata .srodata.*) /* do not need to distinguish this from .rodata */. = ALIGN(16);*(.rodata .rodata.*)}.data : {. = ALIGN(16);*(.sdata .sdata.*) /* do not need to distinguish this from .data */. = ALIGN(16);*(.data .data.*)}.bss : {. = ALIGN(16);*(.sbss .sbss.*) /* do not need to distinguish this from .bss */. = ALIGN(16);*(.bss .bss.*)}PROVIDE(end = .);
}
可见,在链接内核文件的时候,就已经把程序入口点设为 _entry,同时把内存地址设定为 0x80000000
在 kernel/entry.S 可以找到 _entry 的定义
# 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, stack0li a0, 1024*4csrr a1, mhartidaddi a1, a1, 1mul a0, a0, a1add sp, sp, a0# jump to start() in start.ccall start
spin:j spin
我们进入 gdb 里的汇编指令确认一下
使用 b *0x80000000
在这个内存地址打断点
随后用 layout asm
打开汇编窗口
对比汇编指令,可以看到汇编指令并不完全一致,这是因为 riscv64 中有伪指令。
大体上是一致的,说明 kernel/entry.S 的 _entry 确实是内核程序入点。
任务5:搞清楚 xv6 内核怎么从汇编跳转到 C (完成)
先来看一下 qemu 刚刚跳转到 0x8000_0000 时的寄存器状态,如下:
ra 0x0 0x0
sp 0x0 0x0
gp 0x0 0x0
tp 0x0 0x0
t0 0x80000000 2147483648
t1 0x0 0
t2 0x0 0
fp 0x0 0x0
s1 0x0 0
a0 0x1 1
a1 0x1020 4128
a2 0x0 0
a3 0x0 0
a4 0x0 0
a5 0x0 0
a6 0x0 0
a7 0x0 0
s2 0x0 0
s3 0x0 0
s4 0x0 0
s5 0x0 0
s6 0x0 0
s7 0x0 0
s8 0x0 0
s9 0x0 0
s10 0x0 0
s11 0x0 0
t3 0x0 0
t4 0x0 0
t5 0x0 0
t6 0x0 0
pc 0x80000000 0x80000000 <_entry>
可以看到除了部分寄存器,大部分寄存器都是 0x0。(部分寄存器应该是 qemu 内置 BIOS 设置的)
再来看 kernel/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, stack0li a0, 1024*4csrr a1, mhartidaddi a1, a1, 1mul a0, a0, a1add sp, sp, a0# jump to start() in start.ccall start
spin:j spin
从注释来看,qemu 的每个核心都跳转到了 _entry,看来此时有多个 core 在执行同一块代码
_entry 在跳转到 C 代码之前,给每一个 core 分配了一个栈。
这里有个疑问:为什么要分配这些栈?这些栈有什么用?
回答:设置栈指针,实际上是为了支持函数的调用和返回。在古早时代,人们为了在函数之间传递参数、从函数返回会使用各种各样的方法。最后大浪淘沙被认为最有效的方式就是 “设置栈指针,把参数压入栈和部分寄存器中”。今天的 gcc/clang 编译器也会在编译 C 语言的时候,在函数开头进行压栈,在函数返回时弹栈。为了让我们能顺利地从汇编语言跳转到 C 语言(或者说,由 gcc 编译出来的汇编代码),我们需要在进入 C 语言之前设置栈指针
汇编中涉及到的 stack0,在 kernel/start.c 中定义
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];
可以看到是一个对 16 字节对齐的数组(或者说一段内存),大小为 4096 * NCPU, NCPU=8,是 xv6 支持的最大 core 数量
另外,entry.S 中使用了一个特殊寄存器 mhartid,根据 CHATGPT:
在 RISC-V 架构中,mhartid 指的是机器级别的 hart(硬件线程)标识符。以下是一些详细信息:Hart:在 RISC-V 术语中,hart 是一个执行的硬件线程,类似于多核处理器中的逻辑核心。每个 hart 可以独立执行指令。mhartid 寄存器:mhartid 是一个特殊的寄存器,用于存储当前 hart 的 ID。这个 ID 通常是一个非负整数,唯一标识系统中的每个 hart。例如,如果一个系统有四个 harts,它们的 mhartid 值通常为 0、1、2 和 3。用途:mhartid 寄存器可以被操作系统和应用程序用来确定当前 hart 的身份。这在负载均衡、线程管理和其他多线程或多核功能中非常有用。
可见,mhartid 是用来识别 core 的编号的。
于是,整个 _entry 的代码逻辑就是:
1.给所有的 core 的栈指针设置为 stack0
2.a0 = 4096,也就是每个 core 能拥有的栈大小
3.a1 = mhartid 获取 core 编号
4.a1 += 1,a1 = 自己的 core 编号+1
5.a0 = a1 * a0 = (core编号+1) * (栈大小 4096字节)
6.sp = sp + a0,也就是 (stack0 起始地址 + (core编号+1) * (栈大小 4096字节))
7.跳转进入 C 语言中的 start 函数
总结一下,起始就是通过各个 cores 的编号,给各个 cores 分配它们的栈空间。这里给 sp 分配的是栈顶,因为 riscv64 中的栈通常是从上往下增长的。
这里有个疑问:为什么栈从上往下增长
回答:根据谷歌,部分架构栈从下往上增长,所以这大概率是历史原因
分配完栈后,就可以跳转到 C 语言中,和 gcc 编译出的汇编代码协作了。
TODO: here