RISC-V 代码模型
翻译自:The RISC-V Code Models.
RISC-V ISA的设计既简单又模块化。为了实现这些设计目标,RISC-V最大限度地降低了实现复杂ISA的最大成本之一:寻址模式。寻址模式在小型设计(由于解码成本)和大型设计(由于隐式依赖关系)中都很昂贵。
RISC-V只有三种寻址模式:
- PC-relative, via the auipc, jal and br* instructions.(PC指针)
- Register-offset, via the jalr, addi and all memory instructions.(寄存器偏移)
- Absolute, via the lui instruction (though arguably this is just x0-offset).(绝对地址)
这些寻址模式经过精心选择,以便以最小的硬件复杂性高效地生成代码。我们通过依靠工具链来优化软件中的寻址以简化复杂的操作——这与传统的ISA形成了鲜明对比,传统的ISA在硬件中实现了大量的寻址模式。研究表明,RISC-V方法是合理的:我们能够在基准测试中实现类似的代码大小,同时具有更简单的解码规则和大量的空闲编码空间。
所有这些硬件复杂性的降低都是以增加软件复杂性为代价的。这篇博客文章介绍了RISC-V中的另一个软件复杂性:代码模型的概念。就像重新定位和放宽一样,代码模型并不特定于RISC-V——事实上,RISC-V工具链的代码模型比大多数流行的ISA少,这主要是因为我们依赖软件优化而不是古怪的寻址模式,这使得我们的寻址模式显著更灵活。
什么是代码模型
大多数程序不会用符号填充它们整个可用的地址空间(大多数程序根本不会填充它,但那些填充的程序倾向于用堆填充它们的地址空间)。ISA倾向于利用这种局部性,在硬件中实现更短的寻址模式,并依靠软件提供更大的寻址模式。代码模型决定了使用哪种软件寻址模式,从而决定了对链接程序实施哪些约束。软件寻址模式决定了程序员如何看到地址,而硬件寻址模式则决定了如何处理指令中的地址位。
由于编译器和链接器之间的分离,代码模型是必要的: 当生成一个未链接的对象时,编译器不知道任何符号的绝对地址,但它仍然必须知道使用什么寻址模式,因为一些寻址模式可能需要从头寄存器来操作。由于编译器不能生成实际的寻址代码,因此它生成寻址模板(称为 重定位) ,链接器在知道每个符号的实际地址后可以丰富这些模板。简而言之代码模型确定这些寻址模板的框架,从而决定发出哪些重定位。
这可能最好用一个例子来解释:
long global_symbol[2];
int main() {
return global_symbol[0] != 0;
}
尽管单个 GCC 调用可以为这个简单的例子生成二进制文件,但在幕后,GCC驱动程序脚本实际上正在运行预处理器,然后是编译器,然后是汇编器,最后是链接器。GCC的 - -save - temps参数允许用户查看所有这些中间文件,并且是在工具链内部进行探索的有用参数。
运行 GCC 包装器脚本的每一步都会生成一个文件:
- cmodel.i: 预处理源代码,它展开任何预处理器指令(例如 # include 或 # ifdef)。
- cmodel.s: 实际编译器的输出,它是一个汇编文件(RISC-V 汇编格式的文本文件)。
- cmodel.o: 汇编程序的输出,它是一个未链接的对象文件(一个 ELF 文件,但不是一个可执行的 ELF)。
- cmodel: 链接器的输出,它是一个链接的可执行文件(一个可执行的 ELF 文件)。
为了理解代码模型存在的原因,我们必须首先更详细地检查这个工具链流。由于这是一个没有预处理器宏的简单源文件,预处理器运行非常无聊:它所做的只是发出一些指令,以便在以后生成调试信息时使用:
$ cat cmodel.i
# 1 "cmodel.c"
# 1 "built-in"
# 1 "command-line"
# 31 "command-line"
# 1 "/scratch/palmer/work/upstream/riscv-gnu-toolchain/build/install/sysroot/usr/include/stdc-predef.h" 1 3 4
# 32 "command-line" 2
# 1 "cmodel.c"
long global_symbol;
int main() {
return global_symbol != 0;
}
然后,预处理的输出通过编译器提供,编译器生成一个程序集文件。这个文件是纯文本,包含 RISC-V 汇编代码,因此很容易阅读:
$ cat cmodel.s
main:
lui a5,%hi(global_symbol)
ld a0,%lo(global_symbol)(a5)
snez a0,a0
ret
生成的程序集包含一对指令,用于处理global_symbol:lui和ld。这对global_symbol可以接受的地址施加了一个约束:它必须是可由32位有符号绝对常数寻址的(不是来自某些寄存器或PC的32位偏移,而是是一个32位实际物理地址)。请注意,对符号地址的限制与此架构上指针的大小无关:具体来说,这里的指针可能仍然是64位,但所有global symbols都必须可以通过32位绝对地址寻址。
编译器生成程序集后,GCC包装器脚本调用汇编程序生成目标文件。此文件是一个ELF二进制文件,可以使用Binutils提供的各种工具读取。如果我们将使用objdump显示符号表,请分解文本部分并显示汇编程序生成的重定位:
$ riscv64-unknown-linux-gnu-objdump -d -t -r cmodel.o
cmodel.o: file format elf64-littleriscv
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 cmodel.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .text.startup 0000000000000000 .text.startup
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text.startup 000000000000000e main
0000000000000010 O *COM* 0000000000000008 global_symbol
Disassembly of section .text.startup:
0000000000000000 main:
0: 000007b7 lui a5,0x0
0: R_RISCV_HI20 global_symbol
0: R_RISCV_RELAX *ABS*
4: 0007b503 ld a0,0(a5) # 0 main
4: R_RISCV_LO12_I global_symbol
4: R_RISCV_RELAX *ABS*
8: 00a03533 snez a0,a0
c: 8082 ret
此时我们有一个对象文件,但我们仍然不知道任何global symbols的实际地址。这就是工具链中每个组件的角色有点重叠的地方:将文本指令转换为位是汇编程序的工作,但在这些位取决于global symbols的地址(例如上面代码中的lui)的情况下,汇编程序无法知道这些位实际上应该是什么。为了允许链接器在最终的可执行目标文件中填写这些位,汇编程序在重定位表中为链接器预期填写的每个位范围生成条目。重定位定义了链接器在将代码链接在一起时要填写的位范围。文本部分中存在的任何重新定位类型的具体定义都是ISA特定的,RISC-V定义可以在ELF psABI文档中找到。
得到汇编程序后,GCC包装器脚本运行链接器以生成可执行文件。这是另一个ELF文件,但这次是一个完整的可执行文件。由于这包含了大量的C库代码,我将在这里只显示它的相关片段:
$ riscv64-unknown-linux-gnu-objdump -d -t -r cmodel
cmodel: file format elf64-littleriscv
SYMBOL TABLE:
0000000000012038 g O .bss 0000000000000010 global_symbol
...
Disassembly of section .text:
0000000000010330 main:
10330: 67c9 lui a5,0x12
10332: 0387b503 ld a0,56(a5) # 12038 global_symbol
10336: 00a03533 snez a0,a0
1033a: 8082 ret
注意:
- 符号表包含具有实际绝对值的符号。这是链接器的全部指针索引。
- 文本部分包含实际引用global symbols的正确位,而不仅仅是一堆0。
- 对全局符号的重新定位已被删除,因为它们不再是必要的。可执行文件中可能仍然存在一些重定位以允许动态链接之类的东西,但是在这个简单的例子中没有重定位。
到目前为止,这个示例一直在使用 RISC-V 的默认代码模型 medlow。为了更具体地演示什么是代码模型,最好将其与我们的其他代码模型 medany 进行对比。这种差异可以用一个示例输出来总结:
0000000000000000 main:
0: 00000797 auipc a5,0x0
0: R_RISCV_PCREL_HI20 global_symbol
0: R_RISCV_RELAX *ABS*
4: 0007b503 ld a0,0(a5) # 0 main
4: R_RISCV_PCREL_LO12_I .LA0
4: R_RISCV_RELAX *ABS*
8: 00a03533 snez a0,a0
c: 8082 ret
具体来说,medany 代码模型生成 auipc/ld 对来引用global symbols,这允许代码在任何地址进行链接; 而 medlow 生成 lui/ld 对来引用global symbols,这限制了代码在地址零周围进行链接。它们都为引用符号生成32位有符号偏移量,因此它们都将生成的代码限制为在2GiB 窗口内链接。
-mcmodel=medlow的含义
这选择了medium-low代码模型,这意味着程序及其静态定义的符号必须位于单个2GiB 地址范围内,并且必须位于绝对地址 -2GiB 和 + 2GiB 之间。寻址global symbols使用 lui/addi 指令对,它们发出 R _ RISCV _ HI20/R _ RISCV _ LO12 _ I序列。下面是一个使用 medlow 代码模型生成代码的例子:
$ cat cmodel.c
long global_symbol[2];
int main() {
return global_symbol[0] != 0;
}
$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps -mcmodel=medlow
$ cat cmodel.s
main:
lui a5,%hi(global_symbol)
ld a0,%lo(global_symbol)(a5)
snez a0,a0
ret
$ riscv64-unknown-linux-gnu-objdump -d -r cmodel.o
cmodel.o: file format elf64-littleriscv
Disassembly of section .text.startup:
0000000000000000 main:
0: 000007b7 lui a5,0x0
0: R_RISCV_HI20 global_symbol
0: R_RISCV_RELAX *ABS*
4: 0007b503 ld a0,0(a5) # 0 main
4: R_RISCV_LO12_I global_symbol
4: R_RISCV_RELAX *ABS*
8: 00a03533 snez a0,a0
c: 8082 ret
$ riscv64-unknown-linux-gnu-objdump -d -r cmodel
Disassembly of section .text:
0000000000010330 main:
10330: 67c9 lui a5,0x12
10332: 0387b503 ld a0,56(a5) # 12038 global_symbol
10336: 00a03533 snez a0,a0
1033a: 8082 ret
-mcmodel=medany的含义
当选择medium-any代码模型,这意味着程序及其静态定义的符号必须位于任意一个2GiB 地址范围内。寻址global symbols使用 lui/addi 指令对,它们发出 R _ RISCV _ PCREL _ HI20/R _ RISCV _ PCREL _ LO12 _ I 序列。下面是使用 medany 代码模型生成的一些代码的示例(使用**-mexplicit-relocs**,以便更清楚地匹配**-mcmodel = medlow** 示例) :
$ cat cmodel.c
long global_symbol[2];
int main() {
return global_symbol[0] != 0;
}
$ riscv64-unknown-linux-gnu-gcc cmodel.c -o cmodel -O3 --save-temps -mcmodel=medany -mexplicit-relocs
$ cat cmodel.s
main:
.LA0: auipc a5,%pcrel_hi(global_symbol)
ld a0,%pcrel_lo(.LA0)(a5)
snez a0,a0
ret
$ riscv64-unknown-linux-gnu-objdump -d -r cmodel.o
cmodel.o: file format elf64-littleriscv
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 cmodel.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .text.startup 0000000000000000 .text.startup
0000000000000000 l .text.startup 0000000000000000 .LA0
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text.startup 000000000000000e main
0000000000000010 O *COM* 0000000000000008 global_symbol
Disassembly of section .text.startup:
0000000000000000 main:
0: 00000797 auipc a5,0x0
0: R_RISCV_PCREL_HI20 global_symbol
0: R_RISCV_RELAX *ABS*
4: 0007b503 ld a0,0(a5) # 0 main
4: R_RISCV_PCREL_LO12_I .LA0
4: R_RISCV_RELAX *ABS*
8: 00a03533 snez a0,a0
c: 8082 ret
$ riscv64-unknown-linux-gnu-objdump -d -r cmodel.o
Disassembly of section .text:
0000000000010330 main:
10330: 00002797 auipc a5,0x2
10334: d087b503 ld a0,-760(a5) # 12038 global_symbol
10338: 00a03533 snez a0,a0
1033c: 8082 ret
...
请注意,-mcmodel = medany 目前默认为**-mno-explicit-relocs**,这可以产生可观的性能效果。这种性能效果有一些细微差别,所以我们将在以后的博客中讨论。
代码模型与 ABI 的区别
一个常常被误解的区别是代码模型和 ABI 之间的区别。ABI 决定函数之间的接口,而代码模型决定如何在函数中生成代码。具体来说: 两种 RISC-V 代码模型都将寻址符号的代码限制为32位偏移量,但在基于 RV64I 的系统上,它们仍将指针编码为64位。
具体来说,这意味着用**-mcmodel = medany** 编译的函数可以被用**-mcmodel = medlow** 编译的函数调用,反之亦然。需要同时满足这两个函数对符号寻址所施加的限制,以允许链接可执行文件,但这种限制通常是正确的。由于代码模型不影响内存中结构的布局,也不影响函数之间的参数传递,所以对于程序来说,它基本上是透明的。
与此相反,两个不同的ABI生成的链接代码是无效的。想象一个包含双参数的函数。为lp64d编译的函数将在寄存器中接收此参数。当被为lp64编译的函数调用时,该函数将参数放置在X寄存器中,程序将无法正常工作。
代码模型与Linker Relaxation
到目前为止,我们还没有讨论代码模型如何与linker relaxation交互,很大程度上是答案现在相当明显了:假设你使用各种工具链组件的RISC-V分支,因为还有一些补丁还没有上传,但这些都不影响其正常工作。
linker relaxation实际上是一个足够重要的优化,它对RISC-V ISA产生了重大影响:为了在许多代码库上获得合理的性能,linker relaxation需要允许RISC-V放弃寻址模式。
对于RISC-V,有以下寻址模式可供选择:
- Symbols within a 7-bit offset from 0 (or from __global_pointer$): 2 bytes.
- Symbols within a 12-bit offset from 0 (or from __global_pointer$): 4 bytes.
- Symbols within a 17-bit offset from 0: 6 bytes.
- Symbols within a 32-bit offset from 0: 8 bytes. On RV32I this is the entire address space.
这一切都可以通过单个代码模型来实现,同时通过八种指令格式(U、I、S、CI、CR、CIW、CL和CS)使用单个硬件寻址模式(register+offset),而不需要任何模式位。您可以将其视为一种可变长度地址编码,在硬件中是可选的。由于链接器实现指令压缩,我们只需要一个代码模型。将此行为与ARMv8 GCC端口进行对比,后者需要根据他发出每个地址的生成序列而选择选择不同的代码模型。
实现可变长度的寻址序列通常是为 CISC 处理器保留的,它们通过在硬件中实现过多的寻址模式和在可能的情况下在装配时机会性地缩小寻址序列来实现这一点。使用易熔多指令寻址序列和链路器松弛的 RISC-V 方法具有允许简单实现和导致相似代码大小的优点。