众所周知,ARM 架构到目前共有三种获得广泛应用的指令集:AArch32(简称 A32,原本称为 ARM 指令集)、AArch64(简称 A64,原本称为 ARM64 指令集)和 Thumb(简称 T32)。这三种指令集各自也有很多版本演进,基于不同微架构的 CPU 支持的指令集(以及版本)也不同,如:
- 2009 年发布的基于 ARMv6-M 的 Cortex-M0 只支持部分的 T32 指令集(不完整的 Thumb-1 和少数 Thumb-2)
- 2011 年发布的基于 ARMv7-A 的 Cortex-A15 支持 A32 和 T32 指令集
- 2016 年发布的基于 ARMv8-A 的 Cortex-A72 支持 A64、A32 和 T32 指令集
ARM 指令集切换
ARM CPU 允许在自身支持的指令集之间动态地互相切换。根据 ARMv8-A 文档,支持 AArch64 ISA 的 CPU 有 AArch64 和 AArch32 两种执行态(execution state),前者支持 A64 ISA,后者支持 A32 和 T32 ISA。这两种运行态只能通过异常边界切换(见下图),而 AArch32 运行态中能访问的地址空间和寄存器是 AArch64 运行态的子集。这样的设计使得操作系统和应用程序可以使用不同的指令集运行,比如 Windows AArch64 可以运行 AArch32 的程序。
在 AArch32 运行态中,CPU 可以使用通过 BX (Branch and eXchange)、BLX (Branch with Link and eXchange) 这两条非特权指令,在 A32 和 T32 指令集之间自由切换(其中 L 代表将当前地址存入 LR 寄存器,通常用于函数调用)。需要注意,为了二进制兼容性,即便是只支持 T32 ISA 的处理器,也支持这两条指令,但任何切换指令集的尝试都会导致异常。
A32 和 T32 指令集的 BX 指令的操作数都是单个目标地址寄存器,并且指令中没有空余的编码位置。因此,ARM 约定将地址中一定为 0 的最低位(LSB)视为指令集选择位(instruction set selection bit),是 0 表示目标地址使用 A32 指令集,是 1 则表示 T32 指令集。这样,处理器在执行时能够根据地址切换状态。
同样地,A32 和 T32 的 BLX 指令也都支持将寄存器作为操作数。额外地,ARMv5 以上的非 M 架构的 BL / BLX 在 A32 和 T32 模式下都支持使用立即数为操作数,跳转到相对 PC 的地址。此时,目标指令集的选择被直接编码在指令中,因此不再需要额外处理地址的最低位。
工具链处理与 Veneer / Thunk
本节中,将通过下面的代码测试工具链在不同的用法下,对于指令集切换的处理。main.c
和 foo.c
将分别使用 arm-none-eabi-gcc
和 clang --target=arm-none-eabi
使用不同的链接器(ld.bfd
和 ld.lld
)、不同微架构上以不同的指令集编译(使用 -mthumb
和 -marm
切换),并观察最终链接后生成的代码。代码中共测试了五种情况,其中 bx label
是编译器不支持的写法,其余编号从 1 到 5。
// main.c
extern int foo();
int main() {
foo(); // (1) 直接调用
__asm(
"blx foo\n" // (2) blx label
"bl foo\n" // (3) bl label
// "bx foo\n" // not supportted
"b foo\n" // (4) b label
"ldr r0, =foo\n" // (5) ldr + blx
"blx r0"
:::"r0"
);
}
// foo.c
int foo() { return 1; }
ld.bfd 行为
结果表明,在使用 ld.bfd
时,GCC 和 Clang 给出的结果几乎一致的,更换优化选项,启用或者关闭 PIC 也都不产生影响。在不同的微架构上,产生的结果稍有区别:
ARMv7-A 微架构:
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
A32 -> A32 | bl F |
bl F |
bl F |
b F |
LSB=0 |
A32 -> T32 | blx F |
blx F |
blx F |
b V |
LSB=1 |
T32 -> T32 | bl F |
bl F |
bl F |
b.w F |
LSB=1 |
T32 -> A32 | blx F |
blx F |
blx F |
b.w V |
LSB=0 |
ARMv6 微架构:
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
A32 -> A32 | bl F |
bl F |
bl F |
b F |
LSB=0 |
A32 -> T32 | bl V |
bl V |
bl V |
b V |
LSB=1 |
T32 -> T32 | bl F |
blx F |
bl F |
b.n F |
LSB=1 |
T32 -> A32 | bl V |
blx V |
blx V |
b.n F |
LSB=0 |
由于这两种架构在 A32 / T32 模式下都有支持立即数的 BLX 指令,因此指令集切换可以被编码在指令中,不影响函数 foo
(表中简写为 F
)编码在立即数中的偏移量。而只有在用 LDR 指令直接取符号的地址时,链接器才会进行相应的处理,即根据对应的指令集处理取到的地址的最后一位。如下面是 ARMv7-A 微架构下,GCC 产生的 A32 -> T32 的反汇编结果,对应第一张表的第二行:
00008180 <main>:
8180: e92d4800 push {fp, lr}
8184: e28db004 add fp, sp, #4
8188: fa000008 blx 81b0 <foo> ; (1)
818c: fa000007 blx 81b0 <foo> ; (2)
8190: fa000006 blx 81b0 <foo> ; (3)
8194: ea0000b3 b 8468 <__foo_from_arm> ; (4)
8198: e59f000c ldr r0, [pc, #12] ; 81ac <main+0x2c> ; (5)
819c: e12fff30 blx r0
81a0: e3a03000 mov r3, #0
81a4: e1a00003 mov r0, r3
81a8: e8bd8800 pop {fp, pc}
81ac: 000081b1 .word 0x000081b1 (5)
表中的 V
和上面的 __foo_from_arm
是值得关注的,它代表指令跳转到了一段叫做 veneer 的代码中,用于切换指令集。ld.bfd
产生的从 A32 切换到 T32 的 veneer 名为 __foo_from_arm
,反之则名为 __foo_from_thumb
。这是由于原本编译器预留的指令空间(4 字节)不足以放下切换所需的代码,因此额外生成了一小段代码来完成必要的工作。它们的反汇编如下:
00008468 <__foo_from_thumb>:
8468: 4778 bx pc
846a: e7fd b.n 8468 <__foo_from_thumb>
846c: eaffff4c b 81a4 <foo>
00008468 <__foo_from_arm>:
8468: e51ff004 ldr pc, [pc, #-4] ; 846c <__foo_from_arm+0x4>
846c: 000081b1 .word 0x000081b1
可以看到,ld.bfd
生成的切换到 T32 指令集的 veneer 直接将带有 T32 指令选择编码的地址写入了 PC(这是不使用 BLX / BX 进行切换的另一个办法),而切换到 A32 指令集的 veneer 利用了一个小技巧,即 PC 总是指向当前指令 + 4 的位置。因此在 0x8468
处执行 bx pc
,即可直接以 A32 指令集跳转到 0x846c
,然后跳回 foo
函数。
虽然跳转进入不同指令集的函数需要 veneer,但返回不需要任何特殊处理:ARM 处理器对于 BL / BLX
指令,在填写 LR 时,会根据当前指令集修改最低位。因此,只需要在函数最后 bx lr
(或等价地,pop {lr}
)即可。
可能是方法不正确,我并没有在 binutils 的代码中找到关于生成 veneer 的部分,只在 coff-arm.c
中找到了类似的名称(同时也意识到了链接脚本中 .glue_7
段的含义)。ARM 工具链中 armlink 文档 对 veneer 有更详细的说明,它还可以有其他用途,比如处理超出立即数范围的跳转(而避免产生常见的 relocation truncated to fit 的错误)。
但需要关注的是,在 T32->A32 使用 B 指令跳转时,产生了错误的结果(加粗的部分),反汇编如下:
00008034 <main>:
8034: e0c8 b.n 81c8 <foo>
8036: 2000 movs r0, #0
8038: 4770 bx lr
803a: 46c0 nop ; (mov r8, r8)
000081c8 <foo>:
81c8: e3a00001 mov r0, #1
81cc: e12fff1e bx lr
b.n
指令本身并不能切换指令集,直接跳转将导致异常。这或许是链接器的 bug,也可能是这种写法本身并不合法。具体原因尚待进一步调查。
根据与宋教授的交流和查阅 ARM ABI 文档 的 aaelf32
部分,ARMv6 指令集(Thumb 部分)的 B 指令只有 2 字节版本,能够产生的 relocation 是 R_ARM_THM_JUMP11
,而 ARMv7-A Thumb 部分的 B 指令有四字节版本(B.W),能够产生 R_ARM_THM_JUMP24
的 relocation。根据文档 Call and Jump relocations 一节的规定:
A linker may use a veneer (a sequence of instructions) to implement the relocated branch if the relocation is one of R_ARM_PC24, R_ARM_CALL, R_ARM_JUMP24, (or, in Thumb state, R_ARM_THM_CALL, R_ARM_THM_JUMP24, or R_ARM_THM_JUMP19) and:
- The target symbol has type STT_FUNC
- Or, the target symbol and relocated place are in separate sections input to the linker
In all other cases a linker shall diagnose an error if relocation cannot be effected without a veneer.
也就是说,对于 R_ARM_THM_JUMP11
,链接器可以不产生 veneer,但应该在有问题时报告错误。但事实上 ld.bfd
并没有这么做,或许有机会可以向上游报告一下。
ld.lld 行为
如果使用 ld.lld
链接,同样会产生代码片段(称为 Thunk,实现位于 Thunks.cpp
,包含了 ARM 在内的各种架构),而符号名称有所改变,会包含 Thunk 在源码中的子类名,如 __ARMv5ABSLongThunk_foo
和 __Thumbv7ABSLongThunk_foo
。
在 ARMv7-A 上,不同的跳转方法产生的指令类型与 ld.bfd
一致,而 thunk 的反汇编如下所示。这些实现比较简单粗暴,直接把对应编码后的绝对地址写入 IP 寄存器(因此名为 ABSLongThunk
),然后使用 BX 指令跳转并切换指令集。
00020114 <__ARMv7ABSLongThunk_foo>:
20114: e300c111 movw ip, #273 ; 0x111
20118: e340c002 movt ip, #2
2011c: e12fff1c bx ip
0002010c <__Thumbv7ABSLongThunk_foo>:
2010c: f240 1c04 movw ip, #260 ; 0x104
20110: f2c0 0c02 movt ip, #2
20114: 4760 bx ip
而在 ARMv6 上,ld.lld
的结果如下,其中斜体是与 lf.bfd
不同的部分。
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
A32 -> A32 | bl F |
bl F |
bl F |
b F |
LSB=0 |
A32 -> T32 | blx F |
blx F |
blx F |
b V |
LSB=1 |
T32 -> T32 | bl F |
bl F |
bl F |
b.n F |
LSB=1 |
T32 -> A32 | blx F |
blx F |
blx F |
b.n F |
LSB=0 |
可以看到,ld.lld
尽量避免了 veneer 的使用,也就避免了产生更多的代码和 relocation。
在上面 ld.bfd
生成了错误代码的地方,ld.lld
也采取了相同的做法。此外,根据宋教授的说法,LLVM 能够生成 veneer / thunk 代码的 relocation 总共包括:R_ARM_PC24 / R_ARM_PLT32 / R_ARM_JUMP24 / R_ARM_JUMP19 / R_ARM_CALL / R_ARM_THM_JUMP19 / R_ARM_THM_JUMP24 / R_ARM_THM_CALL。
ld.mold 行为
(感谢 victoryang00 的提醒)最近新出现的高性能链接器 mold
最新的 1.2.0 版本支持了 ARM32 架构,因此我也进行了测试。
ARMv7-A 微架构:
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
A32 -> A32 | bl F |
blx F |
bl F |
b F |
LSB=0 |
A32 -> T32 | bl F |
blx F |
blx F |
b F |
LSB=1 |
T32 -> T32 | bl F |
bl F |
bl F |
b.w F |
LSB=1 |
T32 -> A32 | blx F |
blx F |
blx F |
b.w V |
LSB=0 |
在 T32 -> A32 切换时,mold
会生成名为 .thumb_to_arm
的代码节,用于放置切换代码,称为 chunk(mold
的实现中称为 ThumbToArmSection
)。反过来则不使用 chunk,直接使用 BLX 指令。在 A32 -> T32 使用 B 跳转时,mold
直接使用了带立即数的 B 指令。
ARMv6 微架构:
1 | 2 | 3 | 4 | 5 | |
---|---|---|---|---|---|
A32 -> A32 | bl F |
blx F |
bl F |
b F |
LSB=0 |
A32 -> T32 | bl V |
bl V |
bl V |
b F |
LSB=1 |
T32 -> T32 | bl F |
bl F |
bl F |
b.n F |
LSB=1 |
T32 -> A32 | blx F |
blx F |
blx F |
ERR | LSB=0 |
在 T32 -> A32 使用 B 跳转时,依旧生成了不正确的代码。而 T32 -> A32 使用 B 跳转时,mold
报错如下:
ld.mold: elf/arch-arm32.cc:227: void mold::elf::InputSection<mold::elf::ARM32>::apply_reloc_alloc(Context<mold::elf::E> &, mold::elf::u8 *) [E = mold::elf::ARM32]: Assertion `T' failed.
报错位于 arch-arm32.cc#L227
,代码大意如下:
#define T (sym.get_addr(ctx) & 1)
case R_ARM_THM_JUMP11: {
assert(T);
u32 val = (S + A - P) >> 1;
*(u16 *)loc = (*(u16 *)loc & 0xf800) | (val & 0x07ff);
continue;
}
也就是说,mold
不允许 R_ARM_THM_JUMP11
跳转到非 T32 指令对应的地址,也就防止了生成类似 ld.lld
和 ld.bfd
的错误代码。不过这里的报错可以考虑设计得再友好一些,而不是直接挂掉,还需要用户看代码追踪错误。