重建最小化运行时¶
本节开始我们将着手自己来实现之前被我们移除的 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) 中的指令。
注解
物理内存与物理地址
物理内存是计算机体系结构中一个重要的组成部分。在存储方面,CPU 唯一能够直接访问的只有物理内存中的数据,它可以通过访存指令来达到这一目的。
从 CPU 的视角看来,可以将物理内存看成一个大字节数组,而物理地址则对应于一个能够用来访问数组中某个元素的下标。与我们日常编程习惯不同的
是,该下标通常不以 0 开头,而通常以 0x80000000
开头。总结一下的话就是, CPU 可以通过物理地址来 逐字节 访问物理内存中保存的
数据。
值得一提的是,当 CPU 以多个字节(比如 2/4/8 或更多)为单位访问物理内存(事实上并不局限于物理内存)中的数据时,就有可能会引入端序和 地址对齐的问题。由于这并不是重点,我们在这里不展开说明。
在该目标平台上,物理内存以物理地址 0x80000000
开头的部分放置着 引导加载程序 (Bootloader) 的代码。它的任务是对硬件进行一些
初始化工作,并跳转到一个固定的物理地址 0x80020000
。在本书正文中我们无需关心它的实现,而是当成一个黑盒使用即可,它的预编译版本
可执行文件放在项目根目录的 bootloader
目录下。在这之后,控制权就会被移交到我们手中。因此,我们需要保证我们负责的初始化的代码
出现在物理内存以物理地址 0x80020000
开头的地方。在我们的初始化任务完成之后,自然需要跳转到 main 函数进行执行里面的代码,这也是
初始化任务的一个重要部分。
但实际上不止如此,我们还需要考虑栈的设置。
函数调用与栈¶
从汇编指令的级别看待一段程序的执行,假如 CPU 依次执行的指令的物理地址序列为 \(\{a_n\}\),那么这个序列会符合怎样的模式呢?
其中最简单的无疑就是 CPU 一条条连续向下执行指令,也即满足递推式 \(a_{n+1}=a_n+L\),这里我们假设该平台的指令是定长的且均为 \(L\) 字节(常见情况为 2/4 字节)。但是执行序列并不总是符合这种模式,当位于物理地址 \(a_n\) 的指令是一条跳转指令的时候, 该模式就有可能被破坏。跳转指令对应于我们在程序中构造的 控制流 (Control Flow) 的多种不同结构,比如分支结构(如 if/switch 语句) 和循环结构(如 for/while 语句)。用来实现上述两种结构的跳转指令,只需实现跳转功能,也就是将 pc 寄存器设置到一个指定的地址即可。
另一种控制流结构则显得更为复杂: 函数调用 (Function Call)。我们大概清楚调用函数整个过程中代码执行的顺序,如果是从源代码级的 视角来看,我们会去执行被调用函数的代码,等到它返回之后,我们会回到调用函数对应语句的下一行继续执行。那么我们如何用汇编指令来实现 这一过程?首先在调用的时候,需要有一条指令跳转到被调用函数的位置,这个看起来和其他控制结构没什么不同;但是在被调用函数返回的时候,我们 却需要返回那条跳转过来的指令的下一条继续执行。这次用来返回的跳转究竟跳转到何处,在对应的函数调用发生之前是不知道的。比如,我们在两个不同的 地方调用同一个函数,显然函数返回之后会回到不同的地址。这是一个很大的不同:其他控制流都只需要跳转到一个 编译期固定下来 的地址,而函数调用 的返回跳转是跳转到一个 运行时确定 (确切地说是在函数调用发生的时候)的地址。
对此,指令集必须给用于函数调用的跳转指令一些额外的能力,而不只是单纯的跳转。在 RISC-V 架构上,有两条指令即符合这样的特征:
指令 |
指令功能 |
---|---|
\(\text{jal}\ \text{rd},\ \text{imm}[20:1]\) |
\(\text{rd}\leftarrow\text{pc}+4\) \(\text{pc}\leftarrow\text{pc}+\text{imm}\) |
\(\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}\) |
\(\text{rd}\leftarrow\text{pc}+4\) \(\text{pc}\leftarrow\text{rs}+\text{imm}\) |
注解
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 所保存的地址即可。事实上在函数返回的时候我们常常使用一条
伪指令 (Pseudo Instruction) 跳转回调用之前的位置: ret
。它会被汇编器翻译为 jalr x0, 0(x1)
,含义为跳转到寄存器
ra 保存的物理地址,由于 x0 是一个恒为 0 的寄存器,在 rd 中保存这一步被省略。总结一下,在进行函数调用的时候,我们通过 jalr 指令
保存返回地址并实现跳转;而在函数即将返回的时候,则通过 ret 指令跳转之前的下一条指令继续执行。这两条指令实现了函数调用流程的核心机制。
由于我们是在 ra 寄存器中保存返回地址的,我们要保证它在函数执行的全程不发生变化,不然在 ret 之后就会跳转到错误的位置。事实上编译器 除了函数调用的相关指令之外确实基本上不使用 ra 寄存器。也就是说,如果在函数中没有调用其他函数,那 ra 的值不会变化,函数调用流程 能够正常工作。但遗憾的是,在实际编写代码的时候我们常常会遇到函数 多层嵌套调用 的情形。我们很容易想象,如果函数不支持嵌套调用,那么编程将会 变得多么复杂。如果我们试图在一个函数 \(f\) 中调用一个子函数,在跳转到子函数 \(g\) 的同时,ra 会被覆盖成这条跳转指令的 下一条的地址,而 ra 之前所保存的函数 \(f\) 的返回地址将会 永久丢失 。
因此,若想正确实现嵌套函数调用的控制流,我们必须通过某种方式保证:在一个函数调用子函数的前后,ra 寄存器的值不能发生变化。但实际上, 这并不仅仅局限于 ra 一个寄存器,而是作用于所有的通用寄存器。这是因为,编译器是独立编译每个函数的,因此一个函数并不能知道它所调用的 子函数修改了哪些寄存器。而站在一个函数的视角,在调用子函数的过程中某些寄存器的值被覆盖的确会对它接下来的执行产生影响。因此这是必要的。 我们将在控制流转移前后需要保持不变的寄存器集合称之为 上下文 (Context) 或称 活动记录 (Activation Record),利用这一概念 ,则在函数调用前后需要保持不变的寄存器集合被称为函数调用上下文。
由于每个 CPU 只有一套寄存器,我们若想在子函数调用前后保持函数调用上下文不变,需要物理内存的帮助。确切的说,在调用子函数之前,我们需要在 内存中的一个区域 保存 (Save) 函数调用上下文中的寄存器;而之后我们会从内存中同样的区域读取并 恢复 (Restore) 函数调用上下文 中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
被调用者保存 (Callee-Saved) 寄存器,即被调用的函数保证调用它前后,这些寄存器保持不变;
调用者保存 (Caller-Saved) 寄存器,被调用的函数可能会覆盖这些寄存器。
从名字中可以看出,函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下:
调用者:首先保存不希望在函数调用过程中发生变化的调用者保存寄存器,然后通过 jal/jalr 指令调用子函数,返回回来之后恢复这些寄存器。
被调用者:在函数开头保存函数执行过程中被用到的被调用者保存寄存器,然后执行函数,在退出之前恢复这些寄存器。
我们发现无论是调用者还是被调用者,都会因调用行为而需要两段匹配的保存和恢复寄存器的汇编代码,可以分别将其称为 开场白 (Prologue) 和 收场白 (Epilogue),它们会由编译器帮我们自动插入。一个函数既有可能作为调用者调用其他函数,也有可能作为被调用者被其他函数调用。对于 它而言,如果在执行的时候需要修改被调用者保存寄存器,而必须在函数开头的开场白和结尾的收场白处进行保存;对于调用者保存寄存器则可以没有任何 顾虑的随便使用,因为它在约定中本就不需要承担保证调用者保存寄存器保持不变的义务。
注解
寄存器保存与编译器优化
这里值得说明的是,调用者和被调用者实际上只需分别按需保存调用者保存寄存器和被调用者保存寄存器的一个子集。对于调用者而言,那些内容 并不重要,即使在调用子函数的时候被覆盖也不影响函数执行的调用者保存寄存器不会被编译器保存;而对于被调用者而言,在其执行过程中没有 使用到的被调用者保存寄存器也无需保存。编译器作为寄存器的使用者自然知道在这两个场景中,分别有哪些值得保存的寄存器。 从这一角度也可以理解为何要将函数调用上下文分成两类:可以在尽可能早的时候优化掉一些无用的寄存器保存与恢复。
调用规范 (Calling Convention) 约定在某个指令集架构上,某种编程语言的函数调用如何实现。它包括了以下内容:
函数的输入参数和返回值如何传递;
函数调用上下文中调用者/被调用者保存寄存器的划分;
其他的在函数调用流程中对于寄存器的使用方法。
调用规范是对于一种确定的编程语言来说的,因为一般意义上的函数调用只会在编程语言的内部进行。当一种语言想要调用用另一门编程语言编写的函数 接口时,编译器就需要同时清楚两门语言的调用规范,并对寄存器的使用做出调整。
注解
RISC-V 架构上的 C 语言调用规范
RISC-V 架构上的 C 语言调用规范可以在 这里 找到。 它对通用寄存器的使用做出了如下约定:
寄存器组 |
保存者 |
功能 |
---|---|---|
a0~a7 |
调用者保存 |
用来传递输入参数。特别的 a0 和 a1 用来保存返回值。 |
t0~t6 |
调用者保存 |
作为临时寄存器使用,在函数中可以随意使用无需保存。 |
s0~s11 |
被调用者保存 |
作为临时寄存器使用,保存后才能在函数中使用。 |
剩下的 5 个通用寄存器情况如下:
zero(x0) 之前提到过,它恒为零,函数调用不会对它产生影响;
ra(x1) 是调用者保存的,不过它并不会在每次调用子函数的时候都保存一次,而是在函数的开头和结尾保存/恢复即可,因为在执行期间即使被 覆盖也没有关系。看上去和被调用者保存寄存器保存的位置一样,但是它确实是调用者保存的。
sp(x2) 是被调用者保存的。这个之后就会提到。
gp(x3) 和 tp(x4) 在一个程序运行期间都不会变化,因此不必放在函数调用上下文中。它们的用途在后面的章节会提到。
更加详细的内容可以参考 Cornell 的 课件 。
之前我们讨论了函数调用上下文的保存/恢复时机以及寄存器的选择,但我们并没有详细说明这些寄存器保存在哪里,只是用“内存中的一块区域”草草带过。实际上, 它更确切的名字是 栈 (Stack) 。 sp(x2) 常用来保存 栈指针 (Stack Pointer),它是一个指向了内存中已经用过的位置的一个地址。在 RISC-V 架构中,栈是从高地址到低地址增长的。在一个函数中,作为起始的开场白负责分配一块新的栈空间,其实它只需要知道需要空间的大小,然后将 sp 的值减小相应的字节数即可,于是物理地址区间 \([\text{新sp},\text{旧sp})\) 对应的物理内存便可以被这个函数用来函数调用上下文的保存/恢复 以及其他工作,这块物理内存被称为这个函数的 栈帧 (Stackframe)。同理,函数中作为结尾的收场白负责将开场白分配的栈帧回收,这也仅仅需要 将 sp 的值增加相同的字节数回到分配之前的状态。这也可以解释为什么 sp 是一个被调用者保存寄存器。
![../_images/CallStack.png](../_images/CallStack.png)
函数调用与栈帧:如图所示,我们能够看到在程序依次调用 a、调用 b、调用 c、c 返回、b 返回整个过程中栈帧的分配/回收以及 sp 寄存器的变化。 图中标有 a/b/c 的块分别代表函数 a/b/c 的栈帧。¶
注解
数据结构中的栈与实现函数调用所需要的栈
从数据结构的角度来看,栈是一个 后入先出 (Last In First Out, LIFO) 的线性表,支持向栈顶压入一个元素以及从栈顶弹出一个元素 两种操作,分别被称为 push 和 pop。从它提供的接口来看,它只支持访问栈顶附近的元素。因此在实现的时候需要维护一个指向栈顶 的指针来表示栈当前的状态。
我们这里的栈与数据结构中的栈原理相同,在很多方面可以一一对应。栈指针 sp 可以对应到指向栈顶的指针,对于栈帧的分配/回收可以分别 对应到 push/pop 操作。如果将我们的栈看成一个内存分配器,它之所以可以这么简单,是因为它回收的内存一定是 最近一次分配 的内存, 从而只需要类似 push/pop 的两种操作即可。
在合适的编译选项设置之下,一个函数的栈帧内容可能如下图所示:
![../_images/StackFrame.png](../_images/StackFrame.png)
函数栈帧中的内容¶
它的开头和结尾分别在 sp(x2) 和 fp(s0) 所指向的地址。按照地址从高到低分别有以下内容,它们都是通过 sp 加上一个偏移量来访问的:
ra 寄存器保存其返回之后的跳转地址,是一个调用者保存寄存器;
父亲栈帧的结束地址 fp,是一个被调用者保存寄存器;
其他被调用者保存寄存器 s1~s11;
函数所使用到的局部变量。
因此,栈上实际上保存了一条完整的函数调用链,通过适当的方式我们可以实现对它的跟踪。
至此,我们基本上说明了函数调用是如何基于栈来实现的。不过我们可以暂时先忽略掉这些细节,因为我们现在只是需要在初始化阶段完成栈的设置,也就是
设置好栈指针 sp 寄存器,后面的函数调用相关机制编译器会帮我们自动完成。麻烦的是, sp 的值也不能随便设置。至少我们需要保证它仍在物理内存上,
而且不能与程序的其他代码、数据段相交,因为在函数调用的过程中,栈区域里面的内容会被修改。如何保证这一点呢?此外,之前我们还提到我们编写的
初始化代码必须放在物理地址 0x80020000
开头的内存上,这又如何做到呢?事实上,这两点都需要我们接下来讲到的程序内存布局的知识。
程序内存布局¶
在我们将源代码编译为可执行文件之后,它就会变成一个看似充满了杂乱无章的字节的一个文件。但我们知道这些字节至少可以分成代码和数据两部分,在 程序运行起来的时候它们的功能并不相同:代码部分由一条条可以被 CPU 解码并执行的指令组成,而数据部分只是被 CPU 视作可用的存储空间。事实上 我们还可以根据其功能进一步把两个部分划分为更小的单位: 段 (Section) 。不同的段会被编译器放置在内存不同的位置上,这构成了程序的 内存布局 (Memory Layout)。一种典型的程序相对内存布局如下:
![../_images/MemoryLayout.png](../_images/MemoryLayout.png)
一种典型的程序相对内存布局¶
代码部分只有代码段 .text
一个段,存放程序的所有汇编代码。
数据部分则还可以继续细化:
已初始化数据段保存程序中那些已初始化的全局数据,分为
.rodata
和.data
两部分。前者存放只读的全局数据,通常是一些常数或者是 常量字符串等;而后者存放可修改的全局数据。未初始化数据段
.bss
保存程序中那些未初始化的全局数据,通常由程序的加载者代为进行零初始化,也即将这块区域逐字节清零;堆 (heap) 区域用来存放程序运行时动态分配的数据,如 C/C++ 中的 malloc/new 分配到的数据本体就放在堆区域,它向高地址增长;
栈区域 stack 不仅用作函数调用上下文的保存与恢复,每个函数作用域内的局部变量也被编译器放在它的栈帧内。它向低地址增长。
注解
局部变量与全局变量
在一个函数的视角中,它能够访问的变量包括以下几种:
函数的输入参数和局部变量:保存在一些寄存器或是该函数的栈帧里面,如果是在栈帧里面的话是基于当前 sp 加上一个偏移量来访问的;
全局变量:保存在数据段
.data
和.bss
中,某些情况下 gp(x3) 寄存器保存两个数据段中间的一个位置,于是全局变量是基于 gp 加上一个偏移量来访问的。堆上的动态变量:本体被保存在堆上,大小在运行时才能确定。而我们只能 直接 访问栈上或者全局数据段中的 编译期确定大小 的变量。 因此我们需要通过一个运行时分配内存得到的一个指向堆上数据的指针来访问它,指针的位宽确实在编译期就能够确定。该指针即可以作为局部变量 放在栈帧里面,也可以作为全局变量放在全局数据段中。
我们可以将常说的编译流程细化为多个阶段(虽然输入一条命令便可将它们全部完成):
编译器 (Compiler) 将每个源文件从某门高级编程语言转化为汇编语言,注意此时源文件仍然是一个 ASCII 或其他编码的文本文件;
汇编器 (Assembler) 将上一步的每个源文件中的文本格式的指令转化为机器码,得到一个二进制的 目标文件 (Object File);
链接器 (Linker) 将上一步得到的所有目标文件以及一些可能的外部目标文件链接在一起形成一个完整的可执行文件。
每个目标文件都有着自己局部的内存布局,里面含有若干个段。在链接的时候,链接器会将这些内存布局合并起来形成一个整体的内存布局。此外,每个目标文件 都有一个符号表,里面记录着它需要从其他文件中寻找的外部符号和能够提供给其他文件的符号,通常是一些函数和全局变量等。在链接的时候汇编器会将 外部符号替换为实际的地址。
我们可以通过 链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合我们的预期。
实现¶
我们自己编写运行时初始化的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 | # os/src/entry.asm
.section .text.entry
.globl _start
_start:
la sp, boot_stack_top
call rust_main
.section .bss.stack
.globl boot_stack
boot_stack:
.space 4096 * 16
.globl boot_stack_top
boot_stack_top:
|
在这段汇编代码中,我们从第 8 行开始预留了一块大小为 4096 * 16 字节也就是 \(64\text{KiB}\) 的空间用作接下来要运行的程序的栈空间,
这块栈空间的栈顶地址被全局符号 boot_stack_top
标识,栈底则被全局符号 boot_stack
标识。同时,这块栈空间单独作为一个名为
.bss.stack
的段,之后我们会通过链接脚本来安排它的位置。
从第 2 行开始,我们通过汇编代码实现执行环境的初始化,它其实只有两条指令:第一条指令将 sp 设置为我们预留的栈空间的栈顶位置,于是之后在函数
调用的时候,栈就可以从这里开始向低地址增长了。简单起见,我们目前暂时不考虑 sp 越过了栈底 boot_stack
,也就是栈溢出的情形,虽然这有
可能导致严重的错误。第二条指令则是通过伪指令 call
函数调用 rust_main
,这里的 rust_main
是一个我们稍后自己编写的应用
入口。因此初始化任务非常简单:正如上面所说的一样,只需要设置栈指针 sp,随后跳转到应用入口即可。这两条指令单独作为一个名为
.text.entry
的段,且全局符号 _start
给出了段内第一条指令的地址。
接着,我们在 main.rs
中嵌入这些汇编代码并声明应用入口 rust_main
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | // os/src/main.rs
#![no_std]
#![no_main]
#![feature(global_asm)]
mod lang_items;
global_asm!(include_str!("entry.asm"));
#[no_mangle]
pub fn rust_main() -> ! {
loop {}
}
|
背景高亮指出了 main.rs
中新增的代码。
第 4 行中,我们手动设置 global_asm
特性来支持在 Rust 代码中嵌入全局汇编代码。第 8 行,我们首先通过
include_str!
宏将同目录下的汇编代码 entry.asm
转化为字符串并通过 global_asm!
宏嵌入到代码中。
从第 10 行开始,
我们声明了应用的入口点 rust_main
,这里需要注意的是需要通过宏将 rust_main
标记为 #[no_mangle]
以避免编译器对它的
名字进行混淆,不然的话在链接的时候, entry.asm
将找不到 main.rs
提供的外部符号 rust_main
从而导致链接失败。
我们修改 Cargo 的配置文件来使用我们自己的链接脚本 os/src/linker.ld
而非使用默认的内存布局:
1 2 3 4 5 6 7 8 | // os/.cargo/config
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
|
具体的链接脚本 os/src/linker.ld
如下:
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 | OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80020000;
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
stext = .;
.text : {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
*(.bss.stack)
sbss = .;
*(.bss .bss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
|
第 1 行我们设置了目标平台为 riscv ;第 2 行我们设置了整个程序的入口点为之前定义的全局符号 _start
;
第 3 行定义了一个常量 BASE_ADDRESS
为 0x80020000
,也就是我们之前提到的期望我们自己实现的初始化代码被放在的地址;
从第 5 行开始体现了链接过程中对输入的目标文件的段的合并。其中 .
表示当前地址,也就是链接器会从它指向的位置开始往下放置从输入的目标文件
中收集来的段。我们可以对 .
进行赋值来调整接下来的段放在哪里,也可以创建一些全局符号赋值为 .
从而记录这一时刻的位置。我们还能够
看到这样的格式:
.rodata : {
*(.rodata)
}
冒号前面表示最终生成的可执行文件的一个段的名字,花括号内按照放置顺序描述将所有输入目标文件的哪些段放在这个段中,每一行格式为
<ObjectFile>(SectionName)
,表示目标文件 ObjectFile
的名为 SectionName
的段需要被放进去。我们也可以
使用通配符来书写 <ObjectFile>
和 <SectionName>
分别表示可能的输入目标文件和段名。因此,最终的合并结果是,在最终可执行文件
中各个常见的段 .text, .rodata .data, .bss
从低地址到高地址按顺序放置,每个段里面都包括了所有输入目标文件的同名段,
且每个段都有两个全局符号给出了它的开始和结束地址(比如 .text
段的开始和结束地址分别是 stext
和 etext
)。
为了说明当前实现的正确性,我们需要讨论这样两个问题:
如何做到执行环境的初始化代码被放在内存上以
0x80020000
开头的区域上?在链接脚本第 7 行,我们将当前地址设置为
BASE_ADDRESS
也即0x80020000
,然后从这里开始往高地址放置各个段。第一个被放置的 是.text
,而里面第一个被放置的又是来自entry.asm
中的段.text.entry
,这个段恰恰是含有两条指令的执行环境初始化代码, 它在所有段中最早被放置在我们期望的0x80020000
处。应用函数调用所需的栈放在哪里?
从链接脚本第 32 行开始,我们可以看出
entry.asm
中分配的栈空间对应的段.bss.stack
被放入到可执行文件中的.bss
段中的低地址中。在后面虽然有一个通配符.bss.*
,但是由于链接脚本的优先匹配规则它并不会被匹配到后面去。 这里需要注意的是地址区间 \([\text{sbss},\text{ebss})\) 并不包括栈空间,其原因后面再进行说明。
这样一来,我们就将运行时重建完毕了。在 os
目录下 cargo build --release
或者直接 make build
就能够看到
最终生成的可执行文件 target/riscv64gc-unknown-none-elf/release/os
。