XV6操作系统入门系列
03-栈与函数调用
前情提要
1. RiscV引导程序
RiscV在引导程序时,初始化了几个寄存器:a0,a1,a2,t0,pc,整个过程发生在硬件电路中。我汇总一下这个寄存器的状态。在引导程序的最后,程序计数器调整到 0x8000_0000 的内核代码继续执行。
2. XV6代码在硬件中的布局
RiscV上电后的第一行代码在ROM只读寄存器中,对应起始地址是 0x1000,由硬件控制。操作系统被装载在内存中,是一系列精心设计的代码,它们背后包含着很多巧妙的抽象概念,我们这个系列就是搞懂这些抽象概念。
第一个概念是:抽象代码占据着硬件内存的一段实体空间。 抽象的代码依次罗列在内存中,按照代码文件顺序,依次是:entry.S,start.c 等等。
第二个概念是:每个文件包含一系列符号(symbol),它们是对自己内存空间的某些位置起的名字。名字仅仅是为了人类方便阅读,硬件Risc-V并不知晓这些名字。
entry.S详解
entry.S 文件只做了两件事情:
- 初始化每个CPU计算核心的栈空间;
- 跳转到
start函数继续执行。
第一节事情:栈的初始化
在xv6启动了8个计算核心。在 entry.S 中,申请了一段连续的内存空间,并且将这段连续的内存空间平均分成8份,分配给这8个核心。
由于这段内存空间在后续有特别的用途,我们将它起名叫做栈。每个核心的分配到一个4096字节(byte)的栈,换算下来是4096x8=32,768位(bit)。
栈是一种抽象的约束规则,RiscV不会限制它的访问,这种约束规则是由软件代码来实现的。
第二件事情:函数调用指令
第二件事情对应 entry.S 的最后执行的代码:
call start
表面看起来很简单,就是调用函数 start。但实际上,这不仅仅是一行简单的代码。
调用一个函数存在一个的约定惯例,这个约定惯例是由RiscV指令集和C语言共同规定的。为了实现这个约定惯例,RiscV硬件电路和编译器默默做了一些事情,并且对于操作系统而言是透明的。也正是这个函数调用惯例需要用到栈,所以在entry.S中需要初始化栈。
第一视角,从纯软件层面看,entry.S 调用 call 命令跳转到 start.c。
第二视角,从编译器编译的汇编层面看,start.c开头几行代码并不是原来的C语言代码,由编译器根据被调用者的惯例添加。我这里简单解释一下:
add sp,sp,-16:栈指针减去16。因为栈由高位地址向低位地址,等价于向栈申请16个字节。sd ra,8(sp):存入返回地址寄存器 ra,因为是64位处理器,所以需要8个字节。sd s0,0(sp):存入调用前的函数所用的栈顶地址s0,s0也叫做帧指针寄存器。add s0,sp,16:将当前函数的栈顶地址存入帧指针寄存器s0。
第三视角,从硬件电路层面看,跳转指令call编译后的jal指令做了两件事情。将当前jal的下一条指令存入返回地址寄存器 ra。由于jal被放在0x8000_0016,所以返回地址 ra=0x8000_0016+4=0x8000_001a。
RiscV通用寄存器表
目前,我们接触到了很多CPU内部的寄存器:程序计数器pc、栈指针sp、帧指针s0,返回地址寄存器ra等等。我们干脆汇总一下RiscV里的常规寄存器。
除了程序计数器pc是特殊寄存器外,其他寄存器都是通用寄存器。习惯上,我们选择将一些通用寄存器用作专门用途。虽然纯粹硬件电路上看,这些寄存器和其他通用寄存器没有特殊的地方,但是在编程的时候,我们会根据它们的用途,起一个别名,方便记忆。
RiscV一共有32个通用寄存器x0到x31,按照使用惯例,它们被赋予对应的别名。
比如前面的栈指针sp实际上也是x2寄存器,帧指针s0实际上是x8寄存器,返回地址寄存器ra实际上是x1寄存器。
| 寄存器 | 别名 | 描述 |
|---|---|---|
| x0 | zero | 硬连线为0 |
| x1 | ra | 返回地址 |
| x2 | sp | 栈指针 |
| x3 | gp | 全局指针 |
| x4 | tp | 线程指针 |
| x5 | t0 | 临时/备用链接寄存器 |
| x6-x7 | t1-t2 | 临时寄存器 |
| x8 | s0/fp | 保存寄存器/帧指针 |
| x9 | s1 | 保存寄存器 |
| x10-x11 | a0-a1 | 函数参数/返回值 |
| x12-x17 | a2-a7 | 函数参数 |
| x18-x27 | s2-s11 | 保存寄存器 |
| x28-x31 | t3-t6 | 临时寄存器 |
RiscV除了一些通用寄存器,还有一系列专门用途的寄存器,硬件电路上有专门的设计,和通用寄存器也不一样。在操作系统中会碰到的任务调度,内存管理, 内核态等等操作系统的概念,底层依赖都这些寄存器。在学习到相关概念时,我们同时也需要了解硬件层面的概念。
网上很多文章都说过,他们在做到内存管理部分的实验时,感觉非常困难。我认为,本质的原因就是他们硬件概念的不熟悉,概念只停留在软件层面,在靠直觉和猜测做题目。在应试教育中,我们经常会用到直觉和猜测,效果还会不错。
但是,硬件中很多东西都是人为的规定,我们当然无法凭空猜测多年以前千里之外的人做出了如何的设计了RiscV处理器和C编程语言,所以直觉在这里是失效的,从而导致了我们难以理解软件层面的代码。
总结
我们通过分析entry.S文件具体作用,理解了操作系统中栈就是一段特殊用途的内存空间。同时通过对entry.S中函数调用功能的解析,我们引入了一个新的概念,函数调用惯例。函数调用惯例涉及到RiscV处理器、C语言编译器以及操作系统共同的配合。在调用一个函数的过程中,C语言程序隐式地用到了栈。因此,在调用C语言代码前,需要初始化栈。