在上一篇中我们介绍了如何使用 Rust 编写 Risc-V 平台的裸机代码,里面出现了 _start
函数作为我们的入口,但这不是必须的。既然是裸机代码,那么不可避免的需要编写少许汇编来操作 CPU——比如一开始需要初始化一段内存等。
如果我们需要用到一些代表内存位置的符号,那么可能需要手写链接器脚本了。
链接器
先简单介绍链接器。在我们编写代码并编译的过程中一般会经过这几个步骤:预处理宏展开->编译成汇编代码->汇编成目标代码->链接器链接成可执行文件(简化的过程)。
以我们在上一篇中的 Risc-V 下的 ELF 文件为例子,ELF 就是编译后的最终产物。ELF 文件中有几个重要的段:
- .text 机器代码
- .rodata 只读数据
- .data 已初始化的全局变量
- .bss 未初始化的变量,可变数据将会放在这个段里
- .symtab 符号表
除此之外还有其他段,在这里先不讲。
链接器的功能就是把代码和数据安排进这几个段里。比如说我的代码里引用到了其他库里的东西:
use foo; // 其他库
fn main() {
let f = foo::Foo;
}
变量 f 指向的是一个其他库里的符号,但是在最后编译成一个可执行文件的时候,变量 f 指向的位置是会发生变化的。
变量 A 指向其他库 B,在编译后 B 被整合进了 A 里,那么 A 就需要指向新的 B 的位置。
如果我们想要知道各个段的大小并在代码里使用,或者想要自己定义最终的布局,就可以使用链接器脚本。
链接器脚本
在 Rust 里如何告诉编译器使用我们自己的链接器脚本在上一篇文章中有提及:使用 Rust 编写 Risc-V 裸机代码-链接器。
我们的链接器脚本是 src/linker.ld
然后就可以编写我们的脚本,脚本语言不用多复杂,够用就行。
# src/linker.ld
OUTPUT_ARCH(riscv)
ENTRY(_start)
SECTION {
.text: {
*(.text.entry)
*(.text .text.*)
}
}
以上面这一段简单的链接器脚本为例,开头 OUTPUT_ARCH(riscv)
表示目标结构为 riscv,ENTRY(_start)
表示程序的入口符号是 _start
。这两句不加也没有关系,如果我们没有指定程序入口,那么链接器默认入口是 _start
,如果我们的程序中没有 _start
符号,那么链接器默认入口是 .text
段的第一句。
在上一篇中我们是编写了 _start
函数作为入口的。
随后是 SECTION {}
表示定义段,在这里面我们指定 .text
段如何构建。.text
段里的 *()
表示所有文件,星号 是通配符,里面的 .text.entry
表示 代码里的 .text.entry
段。随后是 *(.text .text.*)
,表示所有文件的 .text
段和 以 .text.
开头的所有段。那么这一段脚本的意思就是:.text
段先由所有文件里的 .text.entry
段组成,再由 .text
和以 .text.
开头的段组成。
如果你不理解 .text.entry
是什么是哪来的,那么你需要一点点的汇编语言的知识。
一点点的汇编语言
在上一篇文章 使用 Rust 编写 Risc-V 裸机代码 中我们编写了 _start
函数作为入口,实际上是会被编译成类似这么一段汇编代码:
# assembly Risc-V 64
.section .text
.globl _start
_start:
# bala bala bala bala
_start
函数被编译成符号 _start
,并被 .globl
暴露出去,并将这些代码放在 .text
段下面。但我们可以给我们的段起其他名字,比如 .text.entry
,使用这个段名也可以更好的说明这里是程序的入口(entry)。
# assembly Risc-V 64
.section .text.entry # 这里
.globl _start
_start:
# bala bala bala bala
那么在链接器脚本中,*(.text.entry)
就会将所有的 .text.entry
段先放置进来。
继续链接器脚本
继续仿照 .text
段编写链接器脚本完成剩下的段的构建。
# src/linker.ld
OUTPUT_ARCH(riscv)
ENTRY(_start)
SECTION {
skernel = .;
stext = .;
.text: {
*(.text.entry)
*(.text .text.*)
}
. = ALIGN(4K);
etext = .;
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}
. = ALIGN(4K);
edata = .;
.bss : {
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
}
. = ALIGN(4K);
ebss = .;
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
}
}
继续来看这段更长的脚本。在 .text
段后有这么一句:. = ALIGN(4K);
,点号表示当前位置,ALIGN(4K) 表示 4KB 对齐,这句话的意思是当前位置需要 4kb 对齐,那么 .text
段就会扩充到 4KB 对齐。
然后编写了这一句:etext = .;
。这其实是定义了一个符号 etext
,它的值是当前位置(星号),这个符号会出现在符号表中,这个符号我们可以在 Rust 代码中使用。
随后我们以同样的方式构建了 .rodata
等段和其他符号。以我们定义的 sbss
和 ebss
为例,sbss
符号定义在 .bss
段的开头,ebss
定义在了 .bss
段的末尾,这两个符号正好代表了 .bss
段的开头和结尾,它们的差应会是 4096 的倍数,因为我们让 .bss
段 4kb 对齐。
而在最后的 /DISCARD/
中定义的东西会被丢弃掉,比如里面的 *(.eh_frame)
会被丢弃而不出现在 ELF 文件中。
这些符号可以在 Rust 代码中使用。
在 Rust 中使用符号
在我们的 Rust 代码中使用 extern "C"
定义外部符号:
# src/main.rs
static mut ARR: [u8; 10] = [0; 10];
pub extern "C" _start() -> ! {
extern "C" {
fn sbss();
fn ebss();
}
println!("sbss: {}, ebss: {}", sbss as usize, ebss as usize);
unsafe {
let p = ARR.as_ref() as *const _ as *const () as usize;
println!("ARR pointer: {}", p);
}
}
这里我用到了打印函数,并不是标准库的实现,是我自己的实现,使用 Risc-V 的 SBI 的输出。
打印出 sbss
和 ebss
的地址:
sbss: 2149658624, ebss: 2149662720
ARR pointer: 2149658624
sbss
和 ebss
的差正好是 4096,数组 ARR 正好在这个两个地址里面。
这里有几个知识点:
.bss
段是放置可变数据,所以上面定义的 APP 数组需要 mut 标记为可变的,否则不会出现在.bss
中- 局部变量不会出现在
.bss
段中,而是在堆栈里 - Rust 会自动优化未使用的变量,所以需要使用 ARR 才会被放置在
.bss
中
Con
类似的,你可以在链接器脚本中定义其他符号,比如在开头和末尾分别定义 .skernel
和 .ekernel
来获取整个程序的大小和在内存里的位置。
nb,,,
这篇博客介绍了如何使用链接器脚本来编写裸机代码。首先,博文解释了链接器的作用,即将代码和数据安排进不同的段中。然后,博文详细介绍了如何编写链接器脚本,并给出了一个示例脚本。脚本中使用了各种段和符号来定义程序的布局和大小。博文还提到了在Rust代码中使用这些符号的方法。
这篇博客的闪光点在于它提供了一个清晰的解释和示例,帮助读者理解链接器脚本的作用和用法。它将复杂的概念和代码解释得很清楚,使读者能够迅速上手编写自己的链接器脚本。
然而,博文也有一些可以改进的地方。首先,博文中提到的链接器脚本示例比较简单,只涉及了几个段和符号。读者可能希望看到更复杂的示例,涉及更多的段和符号。其次,博文中提到了一些汇编语言的知识,但没有详细解释。读者可能需要更多的背景知识才能理解和应用这些概念。
总的来说,这篇博客对于想要了解和使用链接器脚本的读者来说是一个很好的起点。它提供了清晰的解释和示例,帮助读者理解链接器脚本的基本原理和用法。然而,博文可以通过提供更复杂的示例和更详细的解释来进一步改进。