在上一篇中我们介绍了如何使用 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 的位置。

file

如果我们想要知道各个段的大小并在代码里使用,或者想要自己定义最终的布局,就可以使用链接器脚本。

链接器脚本

在 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 等段和其他符号。以我们定义的 sbssebss 为例,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 的输出。

打印出 sbssebss 的地址:

sbss: 2149658624, ebss: 2149662720
ARR pointer: 2149658624

sbssebss 的差正好是 4096,数组 ARR 正好在这个两个地址里面。

这里有几个知识点:

  1. .bss 段是放置可变数据,所以上面定义的 APP 数组需要 mut 标记为可变的,否则不会出现在 .bss
  2. 局部变量不会出现在 .bss 段中,而是在堆栈里
  3. Rust 会自动优化未使用的变量,所以需要使用 ARR 才会被放置在 .bss

Con

类似的,你可以在链接器脚本中定义其他符号,比如在开头和末尾分别定义 .skernel.ekernel 来获取整个程序的大小和在内存里的位置。