3minimal-rt.rst.txt 12.9 KB
Newer Older
Y
Yifan Wu 已提交
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
重建最小化运行时
=================================

.. toctree::
   :hidden:
   :maxdepth: 5

本节开始我们将着手自己来实现之前被我们移除的 ``Hello, world!`` 程序中的功能。在这一小节,我们介绍如何进行 **执行环境初始化** 。

我们在上一小节提到过,一个应用程序的运行离不开下面多层执行环境栈的支撑。以 ``Hello, world!`` 程序为例,在目前广泛使用的操作系统上,
它就至少需要经历以下层层递进的初始化过程:

- 一段汇编代码对硬件进行初始化,让上层包括内核在内的软件得以运行;
- 要运行该程序的时候,内核分配相应资源,将程序代码和数据载入内存,并赋予 CPU 使用权,由此应用程序可以运行;
- 程序员编写的代码是应用程序的一部分,它需要标准库进行一些初始化工作后才能运行。

但在上一小节中,由于目标平台 ``riscv64gc-unknown-none-elf`` 没有任何操作系统支持,我们只能禁用标准库并移除默认的 main 函数
入口。但是最终我们还是要将 main 恢复回来并且在里面输出 ``Hello, world!`` 的。因此,我们需要知道具体需要做哪些初始化工作才能支持
main 的运行。

而这又需要明确两点:首先是系统在做这些初始化工作之前处于什么状态,在做完初始化工作也就是即将执行 main 函数之前又处于什么状态。比较二者
即可得出答案。

让我们从 CPU 加电后第一条指令开始讲起。对于裸机平台 ``riscv64gc-unknown-none-elf`` 而言,它的 pc 寄存器会被设置为 ``0x80000000`` ,
也就是说它会从这个 **物理地址** (Physical Address) 开始一条条取指并执行放置于 **物理内存** (Physical Memory) 中的指令。

.. note::

   **物理内存与物理地址**

   物理内存是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
   从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
   是,该下标通常不以 0 开头,而通常以 ``0x80000000`` 开头。总结一下的话就是, CPU 可以通过物理地址来 *逐字节* 访问物理内存中保存的
   数据。

   值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和
   地址对齐的问题。由于这并不是重点,我们在这里不展开说明。

Y
Yifan Wu 已提交
39 40 41
在该目标平台上,物理内存以物理地址 ``0x80000000`` 开头的部分放置着 **引导加载程序** (Bootloader) 的代码。它的任务是对硬件进行一些
初始化工作,并跳转到一个固定的物理地址 ``0x80020000`` 。在本书正文中我们无需关心它的实现,而是当成一个黑盒使用即可,它的预编译版本
可执行文件放在项目根目录的 ``bootloader`` 目录下。在这之后,控制权就会被移交到我们手中。因此,我们需要保证我们负责的初始化的代码
Y
Yifan Wu 已提交
42 43 44
出现在物理内存以物理地址 ``0x80020000`` 开头的地方。在我们的初始化任务完成之后,自然需要跳转到 main 函数进行执行里面的代码,这也是
初始化任务的一个重要部分。

Y
Yifan Wu 已提交
45
但实际上不止如此,我们还需要考虑栈的设置。
Y
Yifan Wu 已提交
46 47 48 49

函数调用与栈
----------------------------

Y
Yifan Wu 已提交
50
从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 :math:`\{a_n\}`,那么这个序列会符合怎样的模式呢?
Y
Yifan Wu 已提交
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72

其中最简单的无疑就是 CPU 一条条连续向下执行指令,也即满足递推式 :math:`a_{n+1}=a_n+L`,这里我们假设该平台的指令是定长的且均为 
:math:`L` 字节(常见情况为 2/4 字节)。但是执行序列并不总是符合这种模式,当位于物理地址 :math:`a_n` 的指令是一条跳转指令的时候,
该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 **控制流** (Control Flow) 的多种不同结构,比如分支结构(如 if/switch 语句)
和循环结构(如 for/while 语句)。用来实现上述两种结构的跳转指令,只需实现跳转功能,也就是将 pc 寄存器设置到一个指定的地址即可。

另一种控制流结构则显得更为复杂: **函数调用** (Function Call)。我们大概清楚调用函数整个过程中代码执行的顺序,如果是从源代码级的
视角来看,我们会去执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行。那么我们如何用汇编指令来实现
这一过程?首先在调用的时候,需要有一条指令跳转到被调用函数的位置,这个看起来和其他控制结构没什么不同;但是在被调用函数返回的时候,我们
却需要返回那条跳转过来的指令的下一条继续执行。这次用来返回的跳转究竟跳转到何处,在对应的函数调用发生之前是不知道的。比如,我们在两个不同的
地方调用同一个函数,显然函数返回之后会回到不同的地址。这是一个很大的不同:其他控制流都只需要跳转到一个 *编译期固定下来* 的地址,而函数调用
的返回跳转是跳转到一个 *运行时确定* (确切地说是在函数调用发生的时候)的地址。

对此,指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在 RISC-V 架构上,有两条指令即符合这样的特征:

.. list-table:: RISC-V 函数调用跳转指令
   :widths: 20 30
   :header-rows: 1
   :align: center

   * - 指令
     - 指令功能
Y
Yifan Wu 已提交
73
   * - :math:`\text{jal}\ \text{rd},\ \text{imm}[20:1]`
Y
Yifan Wu 已提交
74 75 76
     - :math:`\text{rd}\leftarrow\text{pc}+4`

       :math:`\text{pc}\leftarrow\text{pc}+\text{imm}`
Y
Yifan Wu 已提交
77
   * - :math:`\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}`
Y
Yifan Wu 已提交
78 79 80 81
     - :math:`\text{rd}\leftarrow\text{pc}+4`
       
       :math:`\text{pc}\leftarrow\text{rs}+\text{imm}`

Y
Yifan Wu 已提交
82 83 84 85 86 87 88 89 90 91 92 93 94
.. note::

   **RISC-V 指令各部分含义**

   在大多数只与通用寄存器打交道的指令中, rs 表示 **源寄存器** (Source Register), imm 表示 **立即数** (Immediate),
   是一个常数,二者构成了指令的输入部分;而 rd 表示 **目标寄存器** (Destination Register),它是指令的输出部分。rs 和 rd 
   可以在 32 个通用寄存器 x0~x31 中选取。但是这三个部分都不是必须的,某些指令只有一种输入类型,另一些指令则没有输出部分。


从中可以看出,这两条指令除了设置 pc 寄存器完成跳转功能之外,还将当前跳转指令的下一条指令地址保存在 rd 寄存器中。
(这里假设所有指令的长度均为 4 字节,在不使用 C 标准指令集拓展的情况下成立)
在 RISC-V 架构中,
通常使用 ra(x1) 寄存器作为其中的 rd ,因此在函数返回的时候,只需跳转回 ra 所保存的地址即可。事实上在函数返回的时候我们常常使用一条
Y
Yifan Wu 已提交
95
**伪指令** (Pseudo Instruction) 跳转回调用之前的位置: ``ret`` 。它会被汇编器翻译为 ``jalr x0, 0(x1)``,含义为跳转到寄存器 
Y
Yifan Wu 已提交
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中保存这一步被省略。总结一下,在进行函数调用的时候,我们通过 jalr 指令
保存返回地址并实现跳转;而在函数即将返回的时候,则通过 ret 指令跳转之前的下一条指令继续执行。这两条指令实现了函数调用流程的核心机制。

由于我们是在 ra 寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化,不然在 ret 之后就会跳转到错误的位置。事实上编译器
除了函数调用的相关指令之外确实基本上不使用 ra 寄存器。也就是说,如果在函数中没有调用其他函数,那 ra 的值不会变化,函数调用流程
能够正常工作。但遗憾的是,在实际编写代码的时候我们常常会遇到函数 **多层嵌套调用** 的情形。我们很容易想象,如果函数不支持嵌套调用,那么编程将会
变得多么复杂。如果我们试图在一个函数 :math:`f` 中调用一个子函数,在跳转到子函数 :math:`g` 的同时,ra 会被覆盖成这条跳转指令的
下一条的地址,而 ra 之前所保存的函数 :math:`f` 的返回地址将会 `永久丢失` 。 

因此,若想正确实现嵌套函数调用的控制流,我们必须通过某种方式保证:在一个函数调用子函数的前后,ra 寄存器的值不能发生变化。但实际上,
这并不仅仅局限于 ra 一个寄存器,而是作用于所有的通用寄存器。这是因为,编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的
子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。因此这是必要的。
我们将在控制流转移前后需要保持不变的寄存器集合称之为 **上下文** (Context) 或称 **活动记录** (Activation Record),利用这一概念
,则在函数调用前后需要保持不变的寄存器集合被称为函数调用上下文。

由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在
内存中的一个区域 **保存** (Save) 函数调用上下文中的寄存器;而之后我们会从内存中同样的区域读取并 **恢复** (Restore) 函数调用上下文
中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:

- **被调用者保存** (Callee-Saved) 寄存器,即被调用的函数保证调用它前后,这些寄存器保持不变;
- **调用者保存** (Caller-Saved) 寄存器,被调用的函数可能会覆盖这些寄存器。

从名字中可以看出,函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下:

- 调用者:首先保存不希望在函数调用过程中发生变化的调用者保存寄存器,然后通过 jal/jalr 指令调用子函数,返回回来之后恢复这些寄存器。
- 被调用者:在函数开头保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,在退出之前恢复这些寄存器。

我们发现无论是调用者还是被调用者,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 **开场白** (Prologue) 和 
**收场白** (Epilogue),它们会由编译器帮我们自动插入。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。对于
它而言,如果在执行的时候需要修改被调用者保存寄存器,而必须在函数开头的开场白和结尾的收场白处进行保存;对于调用者保存寄存器则可以没有任何
顾虑的随便使用,因为它在约定中本就不需要承担保证调用者保存寄存器保持不变的义务。

.. note::

   **寄存器保存与编译器优化**

   这里值得说明的是,调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器的一个子集。对于调用者而言,那些内容
   并不重要,即使在调用子函数的时候被覆盖也不影响函数执行的调用者保存寄存器不会被编译器保存;而对于被调用者而言,在其执行过程中没有
Y
Yifan Wu 已提交
134 135
   使用到的被调用者保存寄存器也无需保存。编译器作为寄存器的使用者自然知道在这两个场景中,分别有哪些值得保存的寄存器。
   从这一角度也可以理解为何要将函数调用上下文分成两类:可以在尽可能早的时候优化掉一些无用的寄存器保存与恢复。
Y
Yifan Wu 已提交
136 137 138 139 140 141 142 143 144 145 146 147 148

**调用规范** (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:

1. 函数的输入参数和返回值如何传递;
2. 函数调用上下文中调用者/被调用者保存寄存器的划分;
3. 其他的在函数调用流程中对于寄存器的使用方法。

调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数
接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。

.. note::

   **RISC-V 架构上的 C 语言调用规范**
Y
Yifan Wu 已提交
149

Y
Yifan Wu 已提交
150
   RISC-V 架构上的 C 语言调用规范可以在 `这里 <https://riscv.org/wp-content/uploads/2015/01/riscv-calling.pdf>`_ 找到。