使用函数指针的 LUT 会强制编译器使用该策略。它可以在理论上将 switch 版本编译为与 LUT 版本基本相同的代码(现在您已经向两者添加了越界检查)。实际上,这不是 gcc 或 clang 选择做的事情,因此值得查看 asm 输出以了解发生了什么。
(更新:gcc -fpie(在大多数现代 Linux 发行版上默认启用)喜欢制作相对偏移量表,而不是绝对函数指针,因此rodata 也是与位置无关的。GCC Jump Table initialization code generating movsxd and add?。这可以是一个错过的优化,请参阅我的答案以获取 gcc 错误报告的链接。手动创建函数指针数组可以解决这个问题。)
我将代码 on the Godbolt compiler explorer 与这两个函数放在一个编译单元(带有 gcc 和 clang 输出)中,以查看它是如何编译的。我稍微扩展了功能,所以不只是两种情况。
void fsm_switch(int state) {
switch(state) {
case FUNC0: func0(); break;
case FUNC1: func1(); break;
case FUNC2: func2(); break;
case FUNC3: func3(); break;
default: ;// Error handling
}
//prevent_tailcall();
}
void fsm_lut(state_e state) {
if (likely(state < FUNC_COUNT)) // without likely(), gcc puts the LUT on the taken side of this branch
lookUpTable[state]();
else
;// Error handling
//prevent_tailcall();
}
另请参阅
How do the likely() and unlikely() macros in the Linux kernel work and what is their benefit?
x86
在 x86 上,clang 为开关制作自己的 LUT,但条目是指向函数内部的指针,而不是最终的函数指针。所以对于 clang-3.7,switch 恰好编译成比手动实现的 LUT 更差的代码。无论哪种方式,x86 CPU 都倾向于具有可以处理间接调用/跳转的分支预测,至少在它们易于预测的情况下是这样。
GCC 使用一系列条件分支(but unfortunately doesn't tail-call directly with conditional branches, which AFAICT is safe on x86。它会依次检查 1、
他们为 LUT 编写了基本相同的代码:边界检查,使用 mov 将 arg 寄存器的高 32 位归零,然后使用索引寻址模式进行内存间接跳转。
手臂:
gcc 4.8.2 和 -mcpu=cortex-m4 -O2 生成有趣的代码。
正如 Olaf 所说,它制作了一个包含 1B 条目的内联表。它不会直接跳转到目标函数,而是跳转到正常的跳转指令(如b func3)。这是一个正常的无条件跳转,因为它是一个尾调用。
如果fsm_switch 在调用后执行任何操作(如在本例中,如果声明了void prevent_tailcall(void); 但未定义),则每个表目标条目都需要significantly more code (Godbolt),或者如果这被内联到更大的功能。
@@ With void prevent_tailcall(void){} defined so it can inline:
@@ Unlike in the godbolt link, this is doing tailcalls.
fsm_switch:
cmp r0, #3 @ state,
bhi .L5 @
tbb [pc, r0] @ state
@@ There's no section .rodata directive here: the table is in-line with the code, so there's no need for base pointer to be loaded into a reg. And apparently it's even loaded from I-cache, not D-cache
.byte (.L7-.L8)/2
.byte (.L9-.L8)/2
.byte (.L10-.L8)/2
.byte (.L11-.L8)/2
.L11:
b func3 @ optimized tail-call
.L10:
b func2
.L9:
b func1
.L7:
b func0
.L5:
bx lr @ This is ARM's equivalent of an x86 ret insn
IDK 如果在轻量级 ARM 内核上,tbb 与完全间接跳转或调用 (blx) 的分支预测效果有很大差异。加载表的数据访问可能比通过switch 获得的两步跳转到分支指令更重要。
我了解到间接分支在 ARM 上的预测很差。如果间接分支每次都有相同的目标,我希望这还不错。但如果不是,我会假设大多数 ARM 内核都不会像大型 x86 内核那样找到短模式。
指令获取/解码在 x86 上需要更长的时间,因此避免指令流中的气泡更为重要。这就是 x86 CPU 具有如此出色的分支预测的原因之一。现代分支预测器甚至可以很好地处理间接分支的模式,基于该分支和/或其他分支的历史。
LUT 函数必须花费几条指令将 LUT 的基地址加载到寄存器中,但除此之外很像 x86:
fsm_lut:
cmp r0, #3 @ state,
bhi .L13 @,
movw r3, #:lower16:.LANCHOR0 @ tmp112,
movt r3, #:upper16:.LANCHOR0 @ tmp112,
ldr r3, [r3, r0, lsl #2] @ tmp113, lookUpTable
bx r3 @ indirect register sibling call @ tmp113
.L13:
bx lr @
@ in the .rodata section
lookUpTable:
.word func0
.word func1
.word func2
.word func3
有关 Microchip dsPIC 的类似分析,请参阅 Mike of SST's answer。