1.1 前言
在开始阅读本系列之前,读者们不妨先问自己几个问题?
- 你是否对操作系统的运作原理感兴趣?
- 你是否阅读了很多资料,却仍然无法对操作系统形成系统性的认知?
- 你是否试图尝试动手实践,手写一个简单的操作系统,却又无从下手?
- 你是否感觉到,在研究实用的操作系统前,总是感到过于复杂,缺了一块前置的垫脚石?
如果你的回答都是否,那么你可能是一位操作系统专家,本系列并不适合你。如果上面的问题中,你有一个,甚至多个回答是“是”,那么不妨多了解一下这个系列。
这是笔者继《Lua解释器构建:从虚拟机到编译器》系列之后的新系列,《操作系统构建:从0实现xv6系统》,xv6系统是美国麻省工程学院开发的一款面向教学的Unix Like操作系统,它旨在向学生展示一个完整而简单的操作系统原型,帮助学生理解操作系统的基本概念。
然而,从笔者创建的技术讨论社区来看,xv6尽管已经非常精简,但是对一些新人来说,研究起来还是有一些小费劲。对此,笔者应一部分粉丝的要求,和上一个系列一样,着手拆解xv6操作系统,一步一步实现它,并在原有基础上拓展图形界面,并实现两个能在这个操作系统上跑的小游戏。这个项目笔者将其称之为dummyxv6。
dummyxv6是一个开源项目,它的地址位于这里。欢迎广大读者点击这个链接,并star这个项目。在开始我们的旅程之前,读者需要下载一些软件,整个工程是在运行在Virtual Box上的Ubuntu Server系统上开发,并且在Ubuntu Server上实用QEMU进行运行,验证和调试。因此读者需要下载并安装如下软件:
- Virtual Box
- Ubuntu Server 20或以上
- 在Ubuntu Server上安装riscv-gnu-toolchain
环境部署需要费一些周折,当然如果你不想进行这些折腾,可以下载笔者安装好的镜像,并将该镜像导入Virtual Box虚拟机即可。该镜像的下载链接在这里。读者在下载完ova文件之后,可以在Virtual Box中导入ova虚拟机镜像,如图1所示。读者在Virtual Box导入镜像后,登录账号为manistein,密码为123456。
图1
读者完成环境安装之后,可以进入阅读接下来的环节。
1.2 一个Hello World程序的运行
在开始了解操作系统之前,不妨先听听笔者粗浅地唠叨一下计算机系统是怎样运行一个程序的。本节要模拟运行的程序如下所示:
#include <stdio.h>
#define HELLO_WORLD "hello world"
int main()
{
print(HELLO_WORLD);
return 0;
} 以上展示了一段C语言输出“hello world”字符串的代码,它位于hello.c文件中,然而这样的文本信息,不能在机器上运行,因为它是供程序员阅读的源码,要使其能够在目标处理器上运行,需要通过预处理器、编译器、汇编器转成包含目标指令集架构的机器码文件,然后经由链接器链接该程序引用到的库,最后生成可执行文件后才能运行。其编译流程可以通过图2来展示:
图2
这个最终被编译生成的可执行文件里包含了目标处理器能够运行的机器码,现在它能够被执行了。
任何一个程序都不能够凭空被运行的,它们需要在实实在在的硬件上运行。硬件上包含了所有能够被调用执行的指令功能,软件则是告诉硬件,需要执行哪些指令?怎样执行?本文借助CSAPP中的一张图来抽象地描述一台计算机硬件系统,它如图3所示:
图3
正如图3所示,一台计算机需要包含CPU、IO桥、IO总线、内存、磁盘、显示器(display)和输入设备(如键盘、鼠标等)。此外,这台计算机还包含了拓展插槽,用来整合更多的硬件设备,比如显卡、网卡、额外内存条等。这里就不从硬件层面去描述更多的细节,而是针对这个抽象的计算机模型,结合本文内容进行阐述。
CPU是整个计算机硬件系统的核心,它包含了一个运算逻辑单元(ALU),这个单元负责算术逻辑运算,比如加减乘除,比如与、或、异或等逻辑操作。CPU通过寄存器来临时存放ALU需要用到的操作数据,以及ALU操作完的运算结果,在CPU中还有一个非常特殊的寄存器叫做PC寄存器,它包含了正在运行的程序中,下一条要运行的指令的地址。
CPU通过IO桥(I/O bridge)来连接内存和IO设备,所谓的IO就是Input、Ouput的意思,翻译成中文就是输入输出,计算机运算的流程,本质上就是从一个地方搬运数据到CPU寄存器上,然后再交给CPU的ALU运算得到结果后,再搬运到CPU中存放结果的寄存器中,接着再将这些寄存器中的数据搬运到另外一个地方。
IO桥连接了CPU、内存和慢速IO设备,它通过系统总线(System Bus)来和CPU连接,通过内存总线(Memory Bus)和内存连接,通过IO总线(I/O Bus)来和其他相对慢速的IO设备(磁盘、鼠标、键盘、显示器等)连接。
hello程序在经过编译之后,得到的可执行文件首先会存放到磁盘中,当需要运行这个程序时,首先需要将这个可执行文件加载到内存中,如图4所示:
图4
接着CPU的PC寄存器会保存hello程序的第一条指令的地址,并且开始执行。这个hello程序本质就是程序内存中,数据区的字符按顺序读取到寄存器中,然后在通过IO桥经过IO总线输送到显示器上,最后在屏幕上输出”hello world”正如图5所示。
图5
本节展示了hello程序的运行流程,在完成编译后的hello程序,为什么要先存放在磁盘上呢?众所周知,磁盘是可以持久化的设备,容量最大,但是运行速度也相对更慢。内存是在程序在运行时,临时存放程序指令和数据的空间,它是易失性的,只要断电就会失去所有数据,它的内容比磁盘小一个数量级,但是运行速度更快。运行速度更快的是位于CPU内的缓存空间,它在本节中没有展示出来,它的空间比内存再小一个数量级,但是速度也更快,很多内存的数据读取后,会放在CPU缓存里,这样CPU做运算的时候,就不用次次到内存中拿了,进而导致处理能力下降。CPU读写速度最快的就是寄存器了,它容量最小,距离CPU的ALU最近。图6展示了存储模型的梯度图。
图6
1.3 操作系统的概念和作用
上一节的例子,读者们看到了一个程序是怎么在计算机里运行的。众所周知,计算机资源是相对比较昂贵的,因此需要尽可能充分的利用它。而充分利用它的方式,就是让它尽可能地多跑程序。如果没有操作系统,那么想让计算机同时跑多个程序将会变得很困难,硬件资源的抢占协调,硬件访问,程序运行的安全问题,都需要应用程序自己处理,这会使程序非常难写,并且由于每个应用程序员的水平参差不齐,因此写出的程序直接让机器崩溃,或者引发严重bug影响到其他程序,将会使计算机的服务带来严重的降级。
复杂的问题就不说了,设想一下,如果hello程序内包含一段死循环的逻辑,那么它将一直独占整个计算机的资源,这样给计算机资源的管理,安全性等带来极大的挑战。
为了解决这样的问题,操作系统应运而生,操作系统本质上也是一种系统软件,它抽象了硬件层,并引入了进程的概念。一个应用程序是源代码编译后的结果,而一个进程则是将程序从磁盘加载到内存后,启动运行的实体,一个程序可以以多个进程的形式运行。在操作系统中,进程是独立运行,互不干扰的,它们之间的内存是相互隔离的,进程使用的内存空间是虚拟内存空间,负责给进程分配和协调进程之间物理内存使用的是操作系统的工作。此外操作系统还要保障一个应用进程除了自己,不能访问其他进程的内存空间。这是操作系统保障了应用进程的隔离性。
为了使运行在操作系统上的应用进程,看起来像是“同时”在运行,操作系统需要实现高效的进程调度机制,为每个进程分配合理的时间片去运行,即便是其中有一个进程陷入无限循环也是如此。
操作系统还要限制应用进程的指令执行权限,当应用进程尝试执行权限外的指令时,应当终止该进程,从而保障系统的安全性。
此外操作系统还整合了大量的硬件驱动,并为应用层提供了大量的访问和操作硬件设备的接口,与此同时它也协调不同进程之间的硬件资源使用,使得应用层能够更遍历地访问硬件资源。
总的来说,操作系统一般是计算机启动加载的第一个程序,它是应用软件和硬件的中间层,抽象了硬件实现,使得应用进程访问硬件更加方便。同时它也实现了大量的机制用于协助计算机资源更加有效安全地被使用。
1.4 dummyxv6项目介绍
为了能够通过实践梳理清楚操作系统的基本概念,笔者做了一个开源项目–dummyxv6。这是一个仿照美国麻省工程学院开源的教学级操作系统xv6而开发的项目,目的是将其拆解,一步一步从头开始构建,从而在构建的过程中,一点一点理解操作系统是怎么被建立起来的。由于所有的部分,都是根据笔者自己的理解重新实现,因此在一些细节上不会与官方的保持完全的一致。笔者最终的目的是希望读者能够跟随本系列的每一个章节,自己动手实现dummyxv6系统。项目的下载地址在这里。
dummyxv6工程目录如下所示:
+ kernel // 内核模块相关
+ user // 用户模块相关
.gdbinit // gdb调试相关
.gdbinit.tmpl-riscv // gdb调试相关
.gitignore // git版本管理器中,忽略管理的文件列表
LICENSE // MIT协议内容
README.md // 项目必读内容
fs.img // 虚拟文件镜像,后续会将user里编译好的可执行文件,
// 一起打包到这个镜像中,作为虚拟磁盘里的文件
makefile // 编译、启动dummyxv6的文件目录结构非常简单,可以分为几个部分看。
- kernel目录:kernel是操作系统内核,所有内核相关的模块,都将放置到这个目录下,包括操作系统的启动逻辑、任务调度、内存管理、锁、文件系统等。
- user目录:操作系统提供的供用户使用的程序,都将编译成可执行文件后,放置于user目录之下。
- .gdbinit和.gdbinit.tmpl-riscv文件:这两个文件是gdb调试相关的配置。
- fs.img文件:在后续章节里,会将user目录下的可执行文件一起打包到fs.img镜像中,并且被当做虚拟磁盘中的文件使用。
- makefile文件:包含工程编译规则,启动规则和调试规则的文件。
本章节在kernel目录下创建了4个文件,它们分别是entry.S、kernel.ld、param.h和start.c,读者可以找到章节对应的工程,去阅读这些代码。以下是当前kernel目录展开的文件布局。
- kernel
kernel.ld // kernel可执行文件的链接脚本,描述了它的内存布局等信息
param.h // OS参数定义,比如NCPU宏的具体值
entry.S // 执行kernel的入口,并对kernel初试栈空间进行初始化
start.c // 包含一个start函数,由entry.S调用过来,主要负责硬件层面的初始化工作操作系统内核最终要能够运行起来,首先它要完成编译,而dummyxv6的编译规则则位于工程目录下的makefile文件里,现在来看一下makefile文件是如何定义编译规则的。
// makefile
1 K=kernel
2 U=user
3 OBJS = \
4 $K/entry.o \
5 $K/start.o
6 # Try to find a riscv64 version of GCC/binutils
7 # Try to infer the correct TOOLPREFIX if not set
8 ifndef TOOLPREFIX
9 TOOLPREFIX := $(shell if riscv64-unknown-elf-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
10 then echo 'riscv64-unknown-elf-'; \
11 elif riscv64-linux-gnu-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
12 then echo 'riscv64-linux-gnu-'; \
13 elif riscv64-unknown-linux-gnu-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
14 then echo 'riscv64-unknown-linux-gnu-'; \
15 else echo "***" 1>&2; \
16 echo "*** Error: Couldn't find a riscv64 version of GCC/binutils." 1>&2; \
17 echo "*** To turn off this error, run 'gmake TOOLPREFIX= ...'." 1>&2; \
18 echo "***" 1>&2; exit 1; fi)
19 endif
20 QEMU=qemu-system-riscv64
21 CC=$(TOOLPREFIX)gcc
22 AS=$(TOOLPREFIX)gas
23 LD=$(TOOLPREFIX)ld
24 OBJCOPY=$(TOOLPREFIX)objcopy
25 OBJDUMP=$(TOOLPREFIX)objdump
26 CFLAGS = -Wall -Werror -O -fno-omit-frame-pointer -ggdb -gdwarf-2
27 CFLAGS += -MD
28 CFLAGS += -mcmodel=medany
29 # CFLAGS += -ffreestanding -fno-common -nostdlib -mno-relax
30 CFLAGS += -fno-common -nostdlib
31 CFLAGS += -fno-builtin-strncpy -fno-builtin-strncmp -fno-builtin-strlen -fno-builtin-memset
32 CFLAGS += -fno-builtin-memmove -fno-builtin-memcmp -fno-builtin-log -fno-builtin-bzero
33 CFLAGS += -fno-builtin-strchr -fno-builtin-exit -fno-builtin-malloc -fno-builtin-putc
34 CFLAGS += -fno-builtin-freekj
35 CFLAGS += -fno-builtin-memcpy -Wno-main
36 CFLAGS += -fno-builtin-printf -fno-builtin-fprintf -fno-builtin-vprintf
37 CFLAGS += -I.
38 CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
39 LDFLAGS = -z max-page-size=4096
40 $K/kernel: $(OBJS) $K/kernel.ld
41 $(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS)
42 $(OBJDUMP) -S $K/kernel > $K/kernel.asm
43 $(OBJDUMP) -t $K/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $K/kernel.sym
... 上述代码中的第1到第5行,定义了变量K、U和OBJS的值,这样在makefile文件中,取$K就得到”kernel”字符串,取$U就得到”user”字符串,取$OBJS就得到”entry.o start.o”这个字符串。
第6到第19行代码看上去有点复杂,实际上做的事情很简单,就是判断你所在的机器上是否安装了riscv的gnu工具链,如果你有成功安装riscv-gnu-toolchain,或者直接使用了笔者提供的虚拟机镜像,那么这段代码的结果就是将”riscv64-unknow-elf”字符串赋值给TOOLPREFIX变量,也就是说调用$TOOLPREFIX可以得到”riscv64-unknow-elf”。
接下来第20行代第25行执行后,调用以下变量,可以得到如下结果:
- $QEMU:获得qemu-system-riscv64的值,本质就是使用qemu的riscv64位模拟器。
- $CC:使用riscv64-unknow-elf-gcc来编译源码。
- $AS:使用riscv64-unknow-elf-gas汇编器。
- $LD:使用riscv64-unknow-elf-ld链接器。
- $OBJCOPY:使用riscv64-unknow-elf-objcopy来进行二进制拷贝。
- $OBJDUMP:使用riscv64-unknow-elf-objdump。
第26行到第38行则是对编译参数进行设置,主要是设置了调试属性,以及禁止一些gcc的优化指示。读者可以上网搜索一下这些flag的具体含义,这里限于篇幅就不搬字过纸了。
第39行设置了链接器的参数,只要是指定页最大大小为4KB。
接下来就是第40行到第43行,这几行的工作主要是对$OBJS里的目标文件,对应的源文件(比如entry.S和start.c)进行编译,生成目标文件.o(如entry.o和start.o),然后针对kernel.ld脚本的规则,对这些.o目标文件进行链接,生成最终的可执行文件kernel。随后用objdump工具,生成这个kernel文件的汇编代码和符号表,方便我们查阅研究。
有了上述的编译规则,后续新增模块,只需要在OBJS变量后面新增对应的.o名称即可。
阅读到这里,读者不妨动动手指,通过cd命令进入dummyxv6目录下,然后输入make命令,按下回车执行。接着可以得到如下的输出:
riscv64-unknown-elf-gcc -c -o kernel/entry.o kernel/entry.S
riscv64-unknown-elf-gcc -Wall -Werror -O -fno-omit-frame-pointer -ggdb -gdwarf-2
-MD
-mcmodel=medany
-fno-common -nostdlib
-fno-builtin-strncpy -fno-builtin-strncmp -fno-builtin-strlen -fno-builtin-memset
-fno-builtin-memmove -fno-builtin-memcmp -fno-builtin-log -fno-builtin-bzero
-fno-builtin-strchr -fno-builtin-exit -fno-builtin-malloc -fno-builtin-putc
-fno-builtin-freekj
-fno-builtin-memcpy -Wno-main
-fno-builtin-printf -fno-builtin-fprintf -fno-builtin-vprintf
-I.
-fno-stack-protector
-c -o kernel/start.o kernel/start.c
riscv64-unknown-elf-ld -z max-page-size=4096 -T kernel/kernel.ld -o kernel/kernel kernel/entry.o kernel/start.o
riscv64-unknown-elf-objdump -S kernel/kernel > kernel/kernel.asm
riscv64-unknown-elf-objdump -t kernel/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$/d' > kernel/kernel.sym 现在可以看到具体的编译结果了,整体编译的逻辑就是,先用riscv版本的gcc编译entry.S为entry.o,然后根据CFLAGS编译本章唯一的一个.c文件start.c,将其编译成start.o。接着将entry.o和start.o,根据kernel.ld中的规则,编译成kernel。最后生成kernel的汇编文件和符号表。编译过程如图7所示:
图7
接下来一起看一下编译脚本kernel.ld的编译规则,以下是其代码段:
/* kernel.ld */
1 OUTPUT_ARCH("riscv")
2 ENTRY(_entry)
3
4 SECTIONS {
5 /* ensure that _entry is starting at 0x80000000 */
6 . = 0x80000000;
7
8 .text : {
9 *(.text .text.*)
10 . = ALIGN(0x1000);
11
12 PROVIDE(etext = .);
13 }
14
15 .rodata : {
16 . = ALIGN(16);
17 *(.srodata .srodata.*)
18 . = ALIGN(16);
19 *(.rodata .rodata.*)
20 }
21
22 .data : {
23 . = ALIGN(16);
24 *(.sdata .sdata.*)
25 . = ALIGN(16);
26 *(.data .data.*)
27 }
28
29 .bss : {
30 . = ALIGN(16);
31 *(.sbss .sbss.*)
32 . = ALIGN(16);
33 *(.bss .bss.*)
34 }
35
36 PROVIDE(end = .);
37 } 第1行指明了链接后的可执行文件用的是riscv架构的ABI。第2行指明了可执行文件的入口是_entry,它在entry.S中定义,是个全局变量。一旦kernel被加载完毕,entry之后的第一行指令会马上被执行。接下来的SECTION则是定义不同段的布局。
这里简略给大家复习一下,ELF格式的目标文件和可执行文件段的概念。不论我们的entry.S文件也好,start.c文件也好,它们被编译成目标文件(.o文件)后,会有自己的布局。首先代码会被编译成指令,它们会被存放在.text段中,这个段本质是代码段,在运行期间它们是只读的。然后是代码中定义的静态或者全局变量,则可能会被存放入.data段或者.rodata段中,它们一个是数据段,一个是只读数据段。其中未赋值的静态和全局变量,则会被放入.bss段中。.bss段默认放入其中的变量都是在内存里赋值为0,为了节约磁盘空间,这里没有将0值占用文件空间。这里简要介绍了ELF格式段的基本概念,当然具体细节还有很多,这里省略了很多和本文关联不大的细节,感兴趣的读者可以自行去阅读《程序员的自我修养:链接、装载和库》这本书。不过本系列涉及链接相关的东西不多,读者也不必过分焦虑,只需要阅读完本章,编译链接相关的东西就已经足够了。
现在来回顾一下kernel.ld脚本,第6行的赋值语句表示起始地址是从0x80000000开始的。这里指代_entry的起始地址是0x80000000,因为_entry是内核的入口函数。第8到第13行表示,将所有目标文件(.o文件)的代码段(.text段)全部拷贝到kernel可执行文件中,并汇总在一起,生成kernel可执行文件自己的代码段,后续的代码也类似,就是将对应段全部汇聚成可执行文件的新段,最后的结果如图8所示。
图8
图中没有展示.data段和.bss段的箭头,但是它们也是一起被链接器合并到可执行文件kernel的.data段和.bss段了。这里需要注意的是,不论目标文件有多少个,它们对应的段都会被搜集并合并在可执行文件里。kernel文件里的“.”表示当前地址,ALIGN关键字表示地址末尾要与传入值对齐,比方说ALIGN(0x1000)就是地址要和4K对齐,ALIGN(16)就是地址要和16对齐。图8中灰色的部分表示代码段因为占不满4KB的空间,最后代码段结束的位置强制和4K对齐。橙色部分表示地址为了和16对齐而浪费掉的空间。当然,图中标灰色和橙色的空间,是表示可能会为了对齐而浪费掉的空间,不代表他们一定存在。
最后要提的就是PROVIDE关键字,它一般是用来定义一个符号,并且给这个符号赋一个值。这个符号可能不存在与任何目标文件,或者链接的库中。
这里最后还要提到的是.sdata、.srodata和.sbss段,它们本质上和.data、.rodata和.bss段没有什么本质的区别,只是一些值较小的数据(全局或静态变量)会放到这些段中,s指代small,这样做的目的是节约空间提升运行效率。
到这里,本章工程就完成编译了,读者可以使用readelf -S kernel命令来查看可执行文件的段信息,得到如下结果:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000080000000 00001000
0000000000001000 0000000000000000 AX 0 0 2
[ 2] .rodata PROGBITS 0000000080001000 00002000
0000000000000000 0000000000000000 WA 0 0 1
[ 3] .data PROGBITS 0000000080001000 00002000
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000080001000 00002000
0000000000008000 0000000000000000 WA 0 0 16
[ 5] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 00002000
000000000000004b 0000000000000000 0 0 1
[ 6] .comment PROGBITS 0000000000000000 0000204b
000000000000001b 0000000000000001 MS 0 0 1
[ 7] .debug_info PROGBITS 0000000000000000 00002066
0000000000000083 0000000000000000 0 0 1
[ 8] .debug_abbrev PROGBITS 0000000000000000 000020e9
000000000000005f 0000000000000000 0 0 1
[ 9] .debug_loc PROGBITS 0000000000000000 00002148
0000000000000074 0000000000000000 0 0 1
[10] .debug_aranges PROGBITS 0000000000000000 000021bc
0000000000000030 0000000000000000 0 0 1
[11] .debug_line PROGBITS 0000000000000000 000021ec
0000000000000048 0000000000000000 0 0 1
[12] .debug_str PROGBITS 0000000000000000 00002234
0000000000000261 0000000000000001 MS 0 0 1
[13] .debug_frame PROGBITS 0000000000000000 00002498
0000000000000040 0000000000000000 0 0 8
[14] .symtab SYMTAB 0000000000000000 000024d8
0000000000000210 0000000000000018 15 19 8
[15] .strtab STRTAB 0000000000000000 000026e8
0000000000000060 0000000000000000 0 0 1
[16] .shstrtab STRTAB 0000000000000000 00002748
00000000000000a7 0000000000000000 0 0 1大家可以观察到,.text代码段的起始地址是0x80000000,数据段包括.data、.rodata和.bss段同属于一个页,实际上.data段和.rodata段的大小是0,说明在本章对应的工程里,没有对应的全局或者静态变量需要放置到.data和.rodata段中,只有.bss段占据了0x8000个字节(也就是32KB),那么是什么占据了这些空间呢?观察一下start.c的代码,如下所示:
// kernel/start.c
1 #include "param.h"
2
3 // The stack space for each CPU to run start function in machine mode.
4 __attribute__((aligned(16))) char stack0[4096 * NCPU];
5
6 void start() {
7
8 }可执行文件中的.bss段放置的就是stack0数组,它刚好占据32KB的空间。在param.h文件中,NCPU的值定义为8,代表QEMU最多模拟8核心的RISCV架构的CPU。在内核的初始化阶段,每个核心需要独立运行entry.S里的_entry逻辑,因此需要为他们划分栈空间,以支持它们的初始化运行。它们的栈空间则是位于stack0数组内,每个核心划拨4KB。这时候细心的读者可能会发现,每个CPU核心是如何和自己的栈空间关联起来的?
在RISC-V架构中,CPU的核心被称为hart,是hardware thread的缩写,每个hart内部有一个常量寄存器,这个常量寄存器保存着该hart的id,这个寄存器的名称是mhartid。在一个RISC-V处理器中,每个hart的hartid都是唯一的。每个hart内有一个sp寄存器,在初始状态,它们会保存自己的栈底地址,示意如图9所示:
图9
在本章所描述的初始化流程中,每个核心都有自己的栈空间,栈的开辟方向是从高地址到低地址开辟的,每个hart中的sp寄存器指向栈的栈顶,但此时它保存的值是栈底地址,当栈顶地址和栈底地址相等时,既是空栈。当需要开辟新的栈空间时,sp要减去需要的字节,得到新的地址。
到这里,可执行文件kernel的布局就完成了。接下来要梳理的是可执行内核kernel的加载和运行流程。
1.5 配置调试环境
在完成内核的编译之后,接下来就是要启动和运行内核了。运行dummyxv6非常简单,只需要进入到makefile所在的目录,输入make qemu命令即可。但是单纯运行qemu模拟器来跑内核意义不大,读者们需要通过GDB工具来调试工程,这样能够更好地探索项目,这一节主要是讲述怎么配置GDB环境的。
dummyxv6使用QEMU来模拟RISC-V架构的CPU,并且还模拟了计算机的其他重要组件。本质上来说它是一个虚拟机,启动它的规则位于makefile文件的后半段:
// makefile
1 # try to generate a unique GDB port
2 GDBPORT = $(shell expr `id -u` % 5000 + 25000)
3 # QEMU's gdb stub command line changed in 0.11
4 QEMUGDB = $(shell if $(QEMU) -help | grep -q '^-gdb'; \
5 then echo "-gdb tcp::$(GDBPORT)"; \
6 else echo "-s -p $(GDBPORT)"; fi)
7 ifndef CPUS
8 CPUS := 1
9 endif
10
11 QEMUOPTS = -machine virt -bios none -kernel $K/kernel -m 128M -smp $(CPUS) -nographic
12 QEMUOPTS += -global virtio-mmio.force-legacy=false
13 QEMUOPTS += -drive file=fs.img,if=none,format=raw,id=x0
14 QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
15
16 qemu: $K/kernel fs.img
17 $(QEMU) $(QEMUOPTS)
18
19 .gdbinit: .gdbinit.tmpl-riscv
20 sed "s/:1234/:$(GDBPORT)/" < $^ > $@
21
22 qemu-gdb: $K/kernel .gdbinit fs.img
23 @echo "*** Now run 'gdb' in another window." 1>&2
24 $(QEMU) $(QEMUOPTS) -S $(QEMUGDB)使用QEMU进行GDB调试,需要用QEMU GDB Server启动目标可执行文件,然后再用GDB的客户端连接并调试它。对于GDB的server端,需要给他选择一个合适的GDB端口。以下是对GDB规则的解析:
- 第2行:shell关键字表示其后的内容,是通过shell来执行。expr后面一般跟一个表达式字符串,expr
id -u,的作用是获取当前用户的id,接着拿着用户id的值对5000取模,最后得到的值加上25000后,将它赋值给GDBPORT变量,得到GDB Server的端口。 - 第4行-第6行:选择GDB Server的启动命令,优先采用tcp连接的方式。
- 第7行-第9行:选择QEMU模拟的RISC-V CPU的核心有几个。
- 第11行-第14行:QEMU启动参数,核心内容则是,选择kernel可执行文件作为内核,虚拟设备内存大小为128MB,CPU有$(CPUS)个核心,不需要图形驱动和基本输入输出系统等。核心的内容就是这些,读者如果对每个选项都感兴趣的话,可以查阅一下官网,这里不再赘述。
- 第16行-第17行:表示输入make qemu指令会执行到的规则。QEMU启动时会加载内核文件kernel,并且将fs.img作为虚拟磁盘文件使用。
- 第19行-第20行:这里是根据.gdbinit.tmpl-riscv文件去生成.gdbinit文件,第20行的命令,会将.gdbinit.tmpl-riscv中的“1234”字符串替换成前面生成的GDBPORT的值,并写入.gdbinit文件中,这个文件是用于启动QEMU GDB Server的配置。
- 第22行-第24行:启动QEMU GDB Server的规则,这里将调试对象设置为kernel,虚拟磁盘是fs.img,GDB配置是.gdbinit。
如果你是第一次使用QEMU的GDB调试,需要在~/.config/gdb目录下创建一个名称为gdbinit的文件(注意文件不加前缀.),然后往文件里填入如下内容后退出,否则进行gdb调试时会报警告。
1 // ~/.config/gdb/gdbinit
2 set auto-load safe-path path_to_your_project/.gdbinitpath_to_your_project替换成你下载的dummyxv6工程所在的路径,在笔者的机器上则是/home/manistein/projects/dummyxv6/dummy-xv6。到这里就完成了启动QEMU GDB的准备工作了。接下来在dummyxv6目录下,输入如下命令,启动QEMU-GDB Server:
make qemu-gdb得到下面这样的输出时,说明QEMU GDB Server启动成功,此时需要在新的窗口使用riscv64-unknow-elf-gdb来进行连接。笔者使用SecureCRT连接在VirtualBox中的UbuntuServer,建议读者也使用这个工具,这样可以很方便得打开多个窗口,方便调试。当然读者喜欢使用其他工具也是没有问题的。
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -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 -S -gdb tcp::26000接下来在另一个窗口中输入riscv64-unknown-elf-gdb命令进行调试,得到如下的结果:
manistein@ubuntu-server:~/projects/dummyxv6/dummy-xv6$ riscv64-unknown-elf-gdb ./kernel/kernel
GNU gdb (GDB) 14.2
Copyright (C) 2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-pc-linux-gnu --target=riscv64-unknown-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./kernel/kernel...
The target architecture is set to "riscv:rv64".
0x0000000000001000 in ?? ()此时输入file ./kernel,得到一行新的日志输出:
Reading symbols from ./kernel...由于在.gdbinit配置里设置了等待用户命令的参数,所以此时kernel并没有立即运行,而是需要等待用户在GDB客户端键入命令,才会执行。此时先设置一个断点b _entry得到如下结果:
(gdb) b _entry
Breakpoint 1 at 0x8000000a
(gdb) 再键入命令c,使得程序继续执行。接下来键入命令info registers得到如下结果:
Breakpoint 1, 0x000000008000000a in _entry ()
=> 0x000000008000000a <_entry+10>: f14025f3 csrr a1,mhartid
(gdb) info registers
ra 0x0 0x0
sp 0x80001000 0x80001000 <stack0>
gp 0x0 0x0
tp 0x0 0x0
t0 0x80000000 2147483648
t1 0x0 0
t2 0x0 0
fp 0x0 0x0
s1 0x0 0
a0 0x1000 4096
a1 0x87e00000 2279604224
a2 0x1028 4136
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 0x8000000a 0x8000000a <_entry+10>此时寄已经显示了运行后寄存器的结果了。
1.6 操作系统入口程序entry.S运行流程
正如前文所示,完成dummyxv6的编译之后,得到可执行文件kernel,这个kernel就是dummyxv6操作系统的内核程序。第一章的kernel程序没有什么具体的内容,但是展示了它的编译规则。当计算机开机之后,首先会从磁盘里读取kernel文件并加载到内存中,紧接着找到kernel的入口函数,并且开始执行。前文也已经提到过,kernel的入口是_entry。这个入口位于kernel/entry.S文件中,其代码内容如下所示:
// kernel/entry.S
1 # qemu -kernel loads the kernel at 0x80000000
2 # and cause every core to jump here.
3 # The start address of _entry is also at 0x80000000 in corresponding elf file.
4
5 .section .text
6 .global _entry
7 _entry:
8 # load address of the stack0 array which is defined in
9 # kernel/start.c into the sp(stack pointer) register
10 la sp, stack0 #la指令指代读取stack0数组的地址值,并赋值到sp寄存器中。
11
12 # since our stack expands from higher address to lower address
13 # so the sp must points to the highest address of current stack space
14 li a0, 4096 #li指令读取立即数4096到a0寄存器中。4096即是4KB
15 csrr a1, mhartid #将hartid读取到a1寄存器中。
16 addi a1, a1, 1 #将a1寄存器的值加1。
17 mul a0, a0, a1 #算出偏移多少个4KB。
18 add sp, sp, a0 #根据偏移地址,找到自己hart所属的栈底地址。
19
20 # jump to start funcion in kernel/start.c
21 call start #跳转到start函数。
22 spin:
23 j spin 这段程序将运行在RISC-V架构的CPU上,要理解它做了什么,首先要理解RISC-V架构的最基本的内容。首先,读者们要了解RISC-V有哪些寄存器,这些寄存器分别做什么用,有什么特殊的规则。然后就是要了解RISC-V架构的函数调用约定。
RISC-V ISA由31个通用寄存器:x1-x31组成,这些寄存器保存整数值。寄存器x0硬连线到常数0。还有一个额外的用户可见程序计数器pc寄存器,它用于保存当前指令的地址。RISC-V没有定义特定的子程序返回地址链接寄存器,但建议标准软件调用约定应该使用寄存器x1来存储调用的返回地址。这些寄存器的宽度由所使用的RISC-V基本变体定义。也就是说,对于RV32,寄存器是32位宽,对于RV64,它们是64位,对于RV128,这些寄存器是128位宽[1]。不同位数的RISCV寄存器之间的关系,如图10所示,本系列使用的是64位的RISC-V架构。
图10
也就是说,除了通用寄存器x1-x31以外,就是x0和pc寄存器最重要了。下表展示了不同寄存器的别名以及描述:
| 5-bit Encoding (rx) | 3-bit Compressed Encoding (rx’) | Register | ABI Name | Description | Saved by Calle- |
|---|---|---|---|---|---|
| 0 | - | x0 | zero | hardwired zero | - |
| 1 | - | x1 | ra | return address | -R |
| 2 | - | x2 | sp | stack pointer | -E |
| 3 | - | x3 | gp | global pointer | - |
| 4 | - | x4 | tp | thread pointer | - |
| 5 | - | x5 | t0 | temporary register 0 | -R |
| 6 | - | x6 | t1 | temporary register 1 | -R |
| 7 | - | x7 | t2 | temporary register 2 | -R |
| 8 | 0 | x8 | s0/fp | saved register 0 / frame pointer | -E |
| 9 | 1 | x9 | s1 | saved register 1 | -E |
| 10 | 2 | x10 | a0 | function argument 0 / return value 0 | -R |
| 11 | 3 | x11 | a1 | function argument 1 / return value 1 | -R |
| 12 | 4 | x12 | a2 | function argument 2 | -R |
| 13 | 5 | x13 | a3 | function argument 3 | -R |
| 14 | 6 | x14 | a4 | function argument 4 | -R |
| 15 | 7 | x15 | a5 | function argument 5 | -R |
| 16 | - | x16 | a6 | function argument 6 | -R |
| 17 | - | x17 | a7 | function argument 7 | -R |
| 18 | - | x18 | s2 | saved register 2 | -E |
| 19 | - | x19 | s3 | saved register 3 | -E |
| 20 | - | x20 | s4 | saved register 4 | -E |
| 21 | - | x21 | s5 | saved register 5 | -E |
| 22 | - | x22 | s6 | saved register 6 | -E |
| 23 | - | x23 | s7 | saved register 7 | -E |
| 24 | - | x24 | s8 | saved register 8 | -E |
| 25 | - | x25 | s9 | saved register 9 | -E |
| 26 | - | x26 | s10 | saved register 10 | -E |
| 27 | - | x27 | s11 | saved register 11 | -E |
| 28 | - | x28 | t3 | temporary register 3 | -R |
| 29 | - | x29 | t4 | temporary register 4 | -R |
| 30 | - | x30 | t5 | temporary register 5 | -R |
| 31 | - | x31 | t6 | temporary register 6 | -R |
最重要的是第3到第6列,第3列列举了32个通用寄存器,而第4列则展示了他们对应的ABI名称。什么是ABI呢?这里引用了《Computer Organization and Design RISC-V Edition》的定义。
application binary interface (ABI)[2]
The user portion of the instruction set plus the operating system interfaces used by application programmers. It defines a standard for binary portability across computers.程序二进制接口(ABI)
ABI是指令集中供用户使用的部分,以及供程序员使用的操作系统接口的统称。它定义了一个可在多台设备上移植的二进制规范。
汇编指令是二进制指令的别名,这里符合书中对ABI的定义。第5列是对每个寄存器功能的描述,这里对这些寄存器做一下简要的概述:
- zero(x0)寄存器:永久输出0的寄存器。
- ra(x1)寄存器:返回到调用者下一条指令的地址。
- sp(x2)寄存器:保存栈顶地址的寄存器。
- gp(x3)寄存器:gp寄存器的用途比较广,在访问内存时,使用gp寄存器可以保存一个基地址,然后通过偏移量来计算目标的具体地址,这样就不需要读取完整的地址空间来进而去访问,这样可以提升运行效率。gp寄存器的第二个关键用途是,在PIC(position independent code)中,一些全局变量或函数的地址需要在运行时确定地址,在程序启动时可以将基地址保存到gp寄存器,然后通过偏移量来计算实际的地址,就可以不用具体地址来访问这些全局变量或函数。此外gp寄存器也在加载动态链接库的时候有着重要用途。
- tp(x4)寄存器:用来保存线程独占地址空间的寄存器。
- t0-t6(x5-x7,x28-x31)寄存器:临时寄存器,由函数调用者(Caller)负责保存。也就是说,这些值在调用一个函数前,要由函数调用者负责保存到调用者的栈空间,在函数调用结束后,从栈空间恢复到寄存器中。
- s0-s11(x8-x9,x18-x27)寄存器:保存寄存器,值由被调用者(Callee)负责保存。这些寄存器的值,在被调用者开始执行逻辑时,要保存到被调用者的栈空间中,被调用者函数执行完之后,将被调用者栈空间中,对应的值恢复到寄存器中。此外,s0寄存器还可以作为frame pointer使用。
- a0-a7(x10-x17)寄存器:参数寄存器,调用一个函数时,参数的存放位置,参数超过8个就要保存到栈中。参数寄存器也是Saved by Caller寄存器,由函数调用者负责保存。其中a0和a1通常作为被调用者的返回值。
现在读者对RISC-V架构的通用寄存器有了初步的了解了,除了通用寄存器,RISCV还有很多csr寄存器,这些内容和CPU的状态有关系,笔者将它们留在后续章节进行讨论。看了上述内容,读者们可能会对s寄存器和t寄存器有一些困惑,t寄存器又叫做调用者负责保存的寄存器,也就是说调用者要在调用函数后,让这些寄存器保持调用函数前的状态。s寄存器也是被调用者保存的寄存器,也就是说这些寄存器,在函数被调用结束前,需要恢复这些寄存器的原状态的寄存器。这里将给出一个例子来展示RISC-V的调用约定,让读者更容易理解。现在先以调用者的视角观察下面这个例子:
1 addi s0, x0, 5 # s0 contains 5
2 jal ra, func # call a function
3 addi s0, s0, 0 # s0 still contains 5 here!上述代码段中,addi指令负责将一个寄存器x0和一个立即数5相加,并将结果保存到目标寄存器s0中。尔后,调用jal(jump and link)指令,将PC寄存器所指的下一条指令的地址值存入ra寄存器中,同时PC寄存器赋值func函数第一条指令的地址,这相当于实现了指令跳转。在func函数执行完之后,返回ra保存的指令地址处,此时读者们可以发现,s0寄存器的值仍然是5。也就是说s0-s11作为callee save寄存器,调用者不需要对他们进行任何处理,它们在被调用函数执行结束后,也能恢复原来的状态。因为这些状态由被调用者(callee)负责保证它们的值不变。接下来来看另一个例子:
1 addi t0, x0, 5 # t0 contains 5
2 jal ra, func # call a function
3 addi t0, t0, 0 # t0 contains garbage!这次把s0寄存器替换成了t0寄存器,结果在调用func函数结束后,t0寄存器就(可能)包含了垃圾数据,这是什么原因呢?这是因为t寄存器是需要调用者保存的寄存器,所以调用者要负责将它们保存到栈空间中。
1 addi t0, x0, 5 # t0 contains 5
2 addi a0, t0, 10 # a0 (argument to func) contains 15
3
4 # Prologue
5 addi sp, sp, -8 # decrement stack
6 sw t0, 0(sp) # store t0 value on the stack
7 sw a0, 4(sp) # store a0 value on the stack
8
9 # Function call
10 jal ra, func # call a function
11 mv s0, a0 # save return value 1, before restoring a0's old value to avoid overwriting the return value
12 mv s1, a1 # save return value 2, before moving on, in case a1 is used in the future, to avoid potentially overwriting the return value
13
14 # Epilogue
15 lw t0, 0(sp) # restore t0 value from the stack
16 lw a0, 4(sp) # restore a0 value from stack
17 addi sp, sp, 8 # increment stack
18
19 addi t0, t0, 0 # t0 contains 5 here because you saved it before the function call!
20 xori t1, a0, t0 # t0^a0 safely here读者们可以看到,func函数的调用者在上述代码中开辟了栈空间,用来保存临时寄存器t0和a0(代码第5行到第7行),然后在func函数调用结束后,从栈中恢复t0和a0,并且归还栈空间(代码第15行到第17行),这样就能得到正确的结果了。
现在从被调用者的角度来看一个例子:
1 # Prologue
2 addi sp, sp, -12 # decrement stack
3 sw ra, 0(sp) # store ra value on the stack
4 sw s0, 4(sp) # store s0 value on the stack
5 sw s1, 8(sp) # store s1 value on the stack
6
7 # do stuff in the function
8
9 # Epilogue
10 lw ra, 0(sp) # restore ra value from the stack
11 lw s0, 4(sp) # restore s0 value from the stack
12 lw s1, 8(sp) # restore s1 value from the stack
13 addi sp, sp, 12 # increment stack
14
15 # finish up any loose ends
16
17 ret # return from function读者应该可以看到,在正式开始执行函数之前,ra、s0和s1寄存器被保存在栈空间中,在函数结束之后,又从栈空间中恢复值到这些寄存器中。现在读者们应该对t寄存器和s寄存器的作用有了比较清晰的认识了。
除了寄存器的类别以外,还需要注意的是RISC-V的调用约定,这里读者们观察一下图11[3]:
图11
这里是一个函数A调用函数B,函数B再调用函数C的例子。他们都要经历几个阶段:
- 在函数开始执行之前:
- 将ra寄存器的值保存到栈顶。
- 保存s0寄存器(frame pointer)到栈顶。
- 保存其他saved寄存器到栈顶。
- 执行函数本体:
- 如果需要调用其他函数:
- 保存参数寄存器a0-a7到栈顶。
- 保存临时寄存器t0-t6到栈顶。
- 保存其他临时变量到栈顶。
- 如果需要调用其他函数:
- 在函数退出之前:
- 恢复ra寄存器。
- 恢复s0寄存器。
- 恢复其他saved寄存器。
- 回收栈空间。
现在回过头来看看kernel/entry.S的逻辑,读者可以翻回前面阅读一下代码。其实逻辑很简单,操作系统被加载到内存之后,内核在内存中的布局和图9是一模一样的,此时只有物理地址空间。读者可能会疑惑,内核起始的物理地址怎么可能从0x80000000开始呢?这里其实是因为内核是在QEMU上跑,为了简化问题,所以将内核可执行文件加载到,从物理地址为0x80000000开始的地方,在真机上可能是从0x0这个地址开始。模拟器中有足够大的物理内存空间,所以不用担心这个问题。
回顾一下图9,结合上文中entry.S代码的注释观察,entry.S执行完之后,sp寄存器保存了hart专属栈空间的栈底地址,相当于对栈进行初始化之后,跳转到kernel/start.c的start函数中。start函数执行完之后,就会一直自选等待。到这里,关于entry.S的执行流程的描述就完成了。
1.7 结束语
作为操作系统构建的开篇之作,这篇文章确实有点长,至少比预期的要长很多。本章先是介绍了一个hello world程序在裸机上运行的流程,然后是引入了操作系统的概念,接着介绍了dummyxv6项目的目录结构、编译规则、调试方法,最后介绍了RISC-V的通用寄存器,以及调用约定和entry.S的运行流程。相信读者此时对dummyxv6的第一章有了一定的了解,后续讲推进更多的内容。
Reference
[1] Registers - RISC-V
[2] Computer Organization and Design RISC-V Edition - 1.4 Under the Cover
[3] New ABI for stack layout and frame pointer scheme #437