ARM 指令集切换与 Veneer / Thunk

 

众所周知,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 的程序。

ARM switch ISA

在 AArch32 运行态中,CPU 可以使用通过 BX (Branch and eXchange)、BLX (Branch with Link and eXchange) 这两条非特权指令,在 A32 和 T32 指令集之间自由切换(其中 L 代表将当前地址存入 LR 寄存器,通常用于函数调用)。需要注意,为了二进制兼容性,即便是只支持 T32 ISA 的处理器,也支持这两条指令,但任何切换指令集的尝试都会导致异常。

A32T32 指令集的 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.cfoo.c 将分别使用 arm-none-eabi-gccclang --target=arm-none-eabi 使用不同的链接器(ld.bfdld.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.lldld.bfd 的错误代码。不过这里的报错可以考虑设计得再友好一些,而不是直接挂掉,还需要用户看代码追踪错误。