这只是一个错过的优化,可能与编译器的非目标特定区域的优化有关。这不仅限于 cortex-m(存在于全尺寸 ARM 和 risc-v 中,毫无疑问还有其他(固定长度的指令集))
00000000 <getA>:
0: 00251793 slli x15,x10,0x2
4: 00000537 lui x10,0x0
8: 00050513 addi x10,x10,0 # 0 <getA>
c: 953e c.add x10,x15
e: 4108 c.lw x10,0(x10)
10: 8082 c.jr x1
00000012 <getB>:
12: 0541 c.addi x10,16
14: 00251793 slli x15,x10,0x2
18: 00000537 lui x10,0x0
1c: 00050513 addi x10,x10,0 # 0 <getA>
20: 953e c.add x10,x15
22: 4108 c.lw x10,0(x10)
24: 8082 c.jr x1
是的 gcc 和 clang 做同样的事情(我在这里使用 gcc 10.x)
getA:
ldr r3, .L3
ldr r0, [r3, r0, lsl #2]
bx lr
.L4:
.align 2
.L3:
.word .LANCHOR0
.size getA, .-getA
getB:
ldr r3, .L6
add r0, r0, #16
ldr r0, [r3, r0, lsl #2]
bx lr
.L7:
.align 2
.L6:
.word .LANCHOR0
所以这里有两件事很突出。现在假定这些功能是由它们自己构建和优化的。因此,如果结构的基地址是链接器正在填写的内容,并且在这种情况下,两者都使用相同的地址,并且它们足够小,以至于两个函数都可以访问相同的池数据,但这对于工具来说是有风险的由于传递次数的原因进行这样的优化,而且gcc至少会生成汇编语言并且不知道最终函数会有多大,所以不能简单地假设两者都可以达到。
对于 x86,它是可变指令长度,因此它可以将调整后的偏移量嵌入到指令中:
00000000004000f0 <getA>:
4000f0: 89 ff mov %edi,%edi
4000f2: 48 8b 04 fd 60 01 60 mov 0x600160(,%rdi,8),%rax
4000f9: 00
4000fa: c3 retq
4000fb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
0000000000400100 <getB>:
400100: 89 ff mov %edi,%edi
400102: 48 8b 04 fd e0 01 60 mov 0x6001e0(,%rdi,8),%rax
400109: 00
40010a: c3
(根据定义,long 的大小允许从一个编译器/目标到另一个编译器/目标有所不同)
回到手臂和气,我们可以试试这个:
getB:
ldr r3, .L6
ldr r0, [r3, r0, lsl #2]
bx lr
.L7:
.align 2
.L6:
.word .LANCHOR0+16
给予
00008000 <getA>:
8000: e59f3004 ldr r3, [pc, #4] ; 800c <getA+0xc>
8004: e7930100 ldr r0, [r3, r0, lsl #2]
8008: e12fff1e bx lr
800c: 00001000 .word 0x00001000
00008010 <getB>:
8010: e59f3004 ldr r3, [pc, #4] ; 801c <getB+0xc>
8014: e7930100 ldr r0, [r3, r0, lsl #2]
8018: e12fff1e bx lr
801c: 00001010 .word 0x00001010
所以该指令可以被删除是的。
这只是一个错过的优化。原因可能比您想象的要深,可能是(编译器)代码通常编写为基于结构的基地址执行所有操作。如果您想生成与位置无关的代码,这很有用。
getA:
ldr r3, .L3
ldr r2, .L3+4
.LPIC0:
add r3, pc, r3
ldr r3, [r3, r2]
ldr r0, [r3, r0, lsl #2]
bx lr
.L4:
.align 2
.L3:
.word _GLOBAL_OFFSET_TABLE_-(.LPIC0+8)
.word myStruct(GOT)
getB:
ldr r3, .L6
ldr r2, .L6+4
.LPIC1:
add r3, pc, r3
ldr r3, [r3, r2]
add r0, r0, #16
ldr r0, [r3, r0, lsl #2]
bx lr
.L7:
.align 2
.L6:
.word _GLOBAL_OFFSET_TABLE_-(.LPIC1+8)
.word myStruct(GOT)
所以现在你不仅会得到二进制文件中每个函数的池值,而且你还需要为结构中每个可能的偏移量提供 GOT 值
代码已生成。
00008000 <getA>:
8000: e59f3010 ldr r3, [pc, #16] ; 8018 <getA+0x18>
8004: e59f2010 ldr r2, [pc, #16] ; 801c <getA+0x1c>
8008: e08f3003 add r3, pc, r3
800c: e7933002 ldr r3, [r3, r2]
8010: e7930100 ldr r0, [r3, r0, lsl #2]
8014: e12fff1e bx lr
8018: 00010034 .word 0x00010034
801c: 0000000c .word 0x0000000c
00008020 <getB>:
8020: e59f3014 ldr r3, [pc, #20] ; 803c <getB+0x1c>
8024: e59f2014 ldr r2, [pc, #20] ; 8040 <getB+0x20>
8028: e08f3003 add r3, pc, r3
802c: e7933002 ldr r3, [r3, r2]
8030: e2800010 add r0, r0, #16
8034: e7930100 ldr r0, [r3, r0, lsl #2]
8038: e12fff1e bx lr
803c: 00010014 .word 0x00010014
8040: 0000000c .word 0x0000000c
所以你没有一个干净的地方可以在编译时添加 16,你必须在 GOT 中添加另一个条目,这是一个已经存在的条目加上一个偏移量。就二进制大小而言,这很快就会变得非常糟糕。
用 x86 看 PIC
0000000000400120 <getA>:
400120: 48 8d 05 d9 0e c0 ff lea -0x3ff127(%rip),%rax # 1000 <myStruct>
400127: 89 ff mov %edi,%edi
400129: 48 8b 04 f8 mov (%rax,%rdi,8),%rax
40012d: c3 retq
40012e: 66 90 xchg %ax,%ax
0000000000400130 <getB>:
400130: 48 8d 05 c9 0e c0 ff lea -0x3ff137(%rip),%rax # 1000 <myStruct>
400137: 89 ff mov %edi,%edi
400139: 48 8b 84 f8 80 00 00 mov 0x80(%rax,%rdi,8),%rax
400140: 00
400141: c3 retq
使用这样的可变长度指令集,您可以看到它们不需要获取 GOT 的基地址,然后获取偏移量并添加它。
还要记住,这些编译器是由人类编写的,并且所有提到的编译器都被很多人使用。还要记住,其中一些工具具有比其他工具更流行或某些指令集,这些指令集现在或过去可能占主导地位(并推动了该工具的大部分工作)。以 x86 为例,它具有可变长度特性,并且能够像伪代码一样采用通用 icode
get struct base address
add 16
read from that address
并将其整合到一条指令中,而不是三条或四条指令。所以有多少人在抱怨,特别是总体上。考虑一下添加这些优化与增加的价值相比会有多复杂以及风险有多大。例如,大部分代码可能是由 x86 人员生成的,而 ARM 人员只是简单地使后端功能化。
要考虑的另一件事是,很容易在编译的代码中找到似乎遗漏了优化的东西。有些人幻想编译器比人类更好,并且您永远不需要学习汇编语言(阅读或编写)。虽然很少需要编写它,或者您可能想采用已编译的代码并进行手动优化的情况很少,但您还需要考虑它是否真的,真的,影响我的性能?或者我现在是否在我用 C 编写的项目中添加了一些外国编程语言,例如只是为了优化一条指令,这会增加项目的可读性和维护成本?在您采取优化路径之前,您可能会问一个更大的问题,即为什么要创建这些函数并消耗更多的指令和性能,而不是手动内联代码,或者做一些事情来鼓励编译器内联。
如果您进行一个正常大小的项目,您可能会发现一些“遗漏的优化”。如果你和我自己和其他人一样花时间盯着这些东西,你也会看到像 gcc 这样的事情在这方面变得更糟,而不是随着时间的推移变得更好,最后的少数主要版本往往会根据某种定义制作更大/更差的代码更糟糕的是(对于手臂,不一定对于其他目标)。除了这样的事情之外,通常在编译器输出中经常发生这种情况。这是意料之中的事,很少会出人意料。
您还可能会发现,在 ARM 后端为 gcc 和 clang 工作的人可能与在 ARM 或 Kiel 在他们的工具等方面工作的人相同,因此您可能会看到相同的习惯或限制。
我认为在这种情况下,是的,这是一个错过的优化,这里不涉及链接器,编译器可以删除该指令并将其添加到池值中。同时,如果您考虑编写编译器需要什么,然后添加优化,并保留一定程度的可靠性,特别是编译器作者的调试能力,编译器可能是最好的根据结构的基地址进行尽可能多的操作。我永远不会知道,但有人很可能已经看到并尝试过,然后 PIC 案例爆炸了二进制文件的大小(或功能严重损坏),他们就放弃了......
很好,请留意更多,因为这些事情在编译输出中经常发生。
编辑
另外请注意,如果您在整个函数中对结构进行多次访问,那么您的测试函数有点过于简单,那么在每个项目的池地址和一条指令之间需要进行权衡。如果是拇指,那么该指令对于某些偏移量可能会更便宜,或者相同。
在我使用全尺寸的 arm 指令时,我们遇到了结构中第 16 个长字的问题,在这种情况下,长字为 4 个字节,因此作为参数传入的偏移量乘以 4,所以尝试了这个:
typedef struct {
unsigned char a[16];
unsigned char b[16];
} myStruct_t;
myStruct_t myStruct;
unsigned char getA(unsigned int index)
{
return myStruct.a[index];
}
unsigned char getB(unsigned int index)
{
return myStruct.b[index];
}
00000000 <getA>:
0: e59f3004 ldr r3, [pc, #4] ; c <getA+0xc>
4: e7d30000 ldrb r0, [r3, r0]
8: e12fff1e bx lr
c: 00000000 .word 0x00000000
00000010 <getB>:
10: e59f3008 ldr r3, [pc, #8] ; 20 <getB+0x10>
14: e0833000 add r3, r3, r0
18: e5d30010 ldrb r0, [r3, #16]
1c: e12fff1e bx lr
20: 00000000 .word 0x00000000
所以这里再次在所选指令集中没有可用的指令来执行所有这些操作。 +16 移到负载上,但要到达那里,需要添加基础加偏移量。但是
unsigned char getB(unsigned int index)
{
return (myStruct.b[index]+myStruct.b[3]);
}
00000010 <getB>:
10: e59f3014 ldr r3, [pc, #20] ; 2c <getB+0x1c>
14: e5d32013 ldrb r2, [r3, #19]
18: e0833000 add r3, r3, r0
1c: e5d30010 ldrb r0, [r3, #16]
20: e0800002 add r0, r0, r2
24: e20000ff and r0, r0, #255 ; 0xff
28: e12fff1e bx lr
2c: 00000000 .word 0x00000000
在这种情况下没有额外的指令,在这种情况下
unsigned char getB(unsigned int index)
{
return (myStruct.b[index]+myStruct.b[index+1]);
}
00000010 <getB>:
10: e59f3014 ldr r3, [pc, #20] ; 2c <getB+0x1c>
14: e0830000 add r0, r3, r0
18: e5d03011 ldrb r3, [r0, #17]
1c: e5d00010 ldrb r0, [r0, #16]
20: e0830000 add r0, r3, r0
24: e20000ff and r0, r0, #255 ; 0xff
28: e12fff1e bx lr
2c: 00000000 .word 0x00000000
如果添加了 16 位,我想这仍然可以工作,但这种情况:
unsigned long getB(unsigned int index)
{
return (myStruct.b[index]+myStruct.b[index+1]);
}
00000010 <getB>:
10: e59f3014 ldr r3, [pc, #20] ; 2c <getB+0x1c>
14: e2802011 add r2, r0, #17
18: e2800010 add r0, r0, #16
1c: e7932102 ldr r2, [r3, r2, lsl #2]
20: e7930100 ldr r0, [r3, r0, lsl #2]
24: e0820000 add r0, r2, r0
28: e12fff1e bx lr
2c: 00000000 .word 0x00000000
这演示了对结构和池数据的多次访问,这将保存一条指令,但不会保存两条……您需要两个池项,一个是 16,一个是添加 16
ldr r3, [pc, #20]
add r2,r0,#1
ldr r2, [r3, r2, lsl #2]
ldr r0, [r3, r0, lsl #2]
或类似的东西......
这种错过的优化虽然是真实的,但实际上是一个更小的极端情况,人们不得不质疑是否值得为编译器添加大量代码和风险?或者让通用机制做它的事情,当你有很多访问权限或图片或两者兼而有之时,通用解决方案就可以工作。一些指令集具有更“丰富”的指令,允许使用更少但更大的指令。
使用结构而不是两个独立的数组开始,使用这些访问函数,如果单个指令很重要,那么这两个决定首先对性能来说是不明智的。同样,这种差异在最终应用程序中实际上是可以测量的,还是只是看起来令人反感。当您遇到严重到需要以这种方式解决的实际性能问题时,最好从更高效的高级代码开始,然后获取编译器的输出(为什么要做所有的工作),然后手动用汇编语言调整它。并继续评估差异是否实际上是可测量的和相关的,以及是否值得进行这种手动调整代码的工作和风险。大多数时候性能是可以忍受的。首先使用高级语言意味着您会接受性能损失,因此需要管理您的敏感度和容忍度。
所以新的底线并没有太大的不同:
是的,这是一个错过的优化,但这是一个极端情况,不值得编译器和用户 IMO 付出努力或冒险。这个标签上的一个/一些常客和其他人会指出这一点(小功能并不总是能很好地优化,也不预期)