ARM预取绕过解决方案

10

我有一个情况,其中一些地址空间非常敏感,如果你读取它,你会崩溃,因为那里没有人来响应那个地址。

pop {r3,pc}
bx r0

   0:   e8bd8008    pop {r3, pc}
   4:   e12fff10    bx  r0

   8:   bd08        pop {r3, pc}
   a:   4700        bx  r0

bx不是编译器创建的指令,而是由于一个32位常数无法在单个指令中立即适应,因此设置了一个PC相对载入。这基本上是文字池。它恰好有一些类似于bx的位。

可以轻松编写一个测试程序来生成该问题。

unsigned int more_fun ( unsigned int );
unsigned int fun ( void )
{
    return(more_fun(0x12344700)+1);
}

00000000 <fun>:
   0:   b510        push    {r4, lr}
   2:   4802        ldr r0, [pc, #8]    ; (c <fun+0xc>)
   4:   f7ff fffe   bl  0 <more_fun>
   8:   3001        adds    r0, #1
   a:   bd10        pop {r4, pc}
   c:   12344700    eorsne  r4, r4, #0, 14

看起来正在发生的是处理器在等待从pop(ldm)返回数据,然后继续执行下一条指令bx r0,并开始在r0中的地址处预取。这导致ARM挂起。

作为人类,我们将pop视为无条件分支,但处理器并不会停止,而是继续通过管道。

预取和分支预测并不新鲜(在这种情况下我们关闭了分支预测器),已经有数十年的历史,不限于ARM,但具有PC作为GPR并且在某种程度上视其为非特殊指令的指令集数量很少。

我正在寻找一个gcc命令行选项来防止这种情况发生。我无法想象我们是第一个遇到这个问题的人。

我当然可以这样做

-march=armv4t


00000000 <fun>:
   0:   b510        push    {r4, lr}
   2:   4803        ldr r0, [pc, #12]   ; (10 <fun+0x10>)
   4:   f7ff fffe   bl  0 <more_fun>
   8:   3001        adds    r0, #1
   a:   bc10        pop {r4}
   c:   bc02        pop {r1}
   e:   4708        bx  r1
  10:   12344700    eorsne  r4, r4, #0, 14

防止这个问题发生

请注意,不仅限于拇指模式,gcc也可以为像这样的情况生成ARM代码,只需在pop之后使用文字池即可。

unsigned int more_fun ( unsigned int );
unsigned int fun ( void )
{
    return(more_fun(0xe12fff10)+1);
}

00000000 <fun>:
   0:   e92d4010    push    {r4, lr}
   4:   e59f0008    ldr r0, [pc, #8]    ; 14 <fun+0x14>
   8:   ebfffffe    bl  0 <more_fun>
   c:   e2800001    add r0, r0, #1
  10:   e8bd8010    pop {r4, pc}
  14:   e12fff10    bx  r0

希望有人知道一个通用的或针对arm特定的选项,可以执行类似于armv4t返回(例如在arm模式下pop {r4,lr}; bx lr),而不需要额外的负担或在pop pc之后立即放置分支到本身(似乎解决了管道对b的误解作为无条件分支的问题)。

编辑:

ldr pc,[something]
bx rn

还会引起预取,这不会被视为-march=armv4t。gcc有意为switch语句生成ldrls pc,[]; b somewhere,并且这是可以接受的。没有检查后端以查看是否生成其他ldr pc,[]指令。

编辑

看起来ARM已经将此报告为勘误(勘误720247,“可以在内存映射中的任何位置进行推测性指令提取”),真希望我之前知道这一点就好了,我们花了一个月的时间解决它...


1
我想知道的是:ARM通常有不可缓存内存的概念,难道不是吗?如果SoC尝试预加载未连接的地址,那么告诉其哪些区域可以被缓存的表格肯定出了问题。 - fuz
1
@Ped7g重新编写了问题(再一次)。我尚未确定例如基于寄存器的ldr(bhd)指令是否会开始最终挂起的读取。在弹出后使用分支到自身(分支到与分支相同的地址)可能会解决问题,但宁愿不使用自定义gnu工具链。同样,在带有PC返回的armv4t上执行gcc已经执行的操作将正常工作,它不会混淆bx。 - old_timer
1
芯片设计师决定连接哪些AMBA/AXI总线以及它们如何响应,以及覆盖多少地址空间等问题。在我们的情况下,ARM是更大设计的一小部分,整个ARM的地址空间可编程,非常类似于PCIe,我们可以更改各种大小的空间以指向芯片的其余部分,但像AXI一样,芯片的其他部分使用一个总线,如果程序员命中没有目标响应的空间,该总线不会超时(按设计)。 - old_timer
这并不一定是处理器的错误,这只是这个处理器的工作方式。这是流水线处理器本质上的特点。对于大多数人来说解释这些东西已经很困难了,更不用说当有微妙之处时。它只是没有被适当地记录,也没有及时的勘误表来展示这种行为。同样,这在处理器中是比较典型的,这就是为什么编译器和处理器核心需要匹配起来的原因。在这种情况下,这并没有发生。我们不得不使用 -march=armv4t 来解决这个问题。 - old_timer
今天的新闻有点有趣,涉及到了“熔断”和“幽灵”漏洞,实际上这是一种技术上的推测执行问题,与随后出现的核心中的真正严重的推测执行相比,这只是微不足道的小问题。但事实就是这样,它开始在管道中预先执行指令,导致取指令并产生了副作用。 - old_timer
显示剩余10条评论
1个回答

5

https://gcc.gnu.org/onlinedocs/gcc/ARM-Options.html提供了一个-mpure-code选项,该选项不会将常量放入代码段中。"只有在使用MOVT指令为M-profile目标生成非PIC代码时才可以使用此选项",因此它可能会使用一对mov-immediate指令加载常量而不是从常量池中加载。

然而,这并不能完全解决你的问题,因为函数内部的条件分支后面的常规指令的乱码寄存器内容的推测执行仍然可能触发访问不可预测的地址。或者,另一个函数的第一条指令可能是读取指令,所以掉到另一个函数中也不总是安全的。


我可以试着说明为什么这个问题够难以理解,以至于编译器没有避免它。

通常情况下,指令的乱码执行不会有问题。CPU直到变得非乱码执行之前根本不会出错。错误(或不存在)的分支预测可能会导致CPU在找到正确路径之前执行一些缓慢的操作,但不应该存在正确性问题。

通常,在大多数CPU设计中,来自内存的乱码读取是允许的。但是,具有MMIO寄存器的内存区域显然必须受到保护。例如,在x86中,内存区域可以是WB(正常的写回可缓存,允许乱码加载)或UC(Uncacheable,不允许乱码加载)。更不用说写组合写通了...

为了解决你的正确性问题,你可能需要类似的东西,以防止乱码执行做出实际上会爆炸的事情。这包括由于乱码bx r0触发的推测指令获取。(抱歉我不懂ARM,所以无法建议你如何做到这一点。但这就是为什么对于大多数系统,即使它们有不能被乱码读取的MMIO寄存器,这只是一个轻微的性能问题。)

我认为,让CPU从会导致系统崩溃而不是只引发异常的地址进行乱码加载的设置非常不寻常。


在这种情况下,我们关闭分支预测器

这可能是你总是看到无条件分支(pop)之后的乱码执行,而不仅仅是非常少见的原因。

使用bx返回进行侦查工作很好,这表明你的CPU在解码时检测到了这种无条件分支,但没有检查pop中的pc位。 :/

通常情况下,分支预测必须在解码之前发生,以避免取指令气泡。给定一个取指块的地址,预测下一个块的取指地址。对于后续阶段的核心来说,预测也是在指令级别而不是取指块级别生成的(因为在一个块中可能有多个分支指令,你需要知道哪个被取)。

这是一般理论。分支预测并不是100%准确的,所以你不能指望它解决你的正确性问题。
x86 CPU可能会出现性能问题,其中间接jmp [mem]或jmp reg的默认预测为下一条指令。如果推测执行开始了一些慢速取消的事情(例如某些CPU上的div)或触发了缓慢的推测内存访问或TLB miss,它可能会延迟确定正确路径的执行。
因此,建议(通过优化手册)在jmp reg后放置ud2(非法指令)或int3(调试陷阱)或类似物品。或者更好的是,在跳转表目标之一中放置它,因此“穿过”有时是正确的预测。(如果BTB没有预测,则下一个指令是唯一合理的操作。)
但是,x86通常不会将代码与数据混合在一起,因此这对于文字池常见的体系结构更可能是问题。(但是,从间接分支和错误的正常分支加载仍然可能发生。)
例如,if(address_good){call table [address]();} 可能很容易错误预测并触发来自坏地址的推测式代码获取。但是如果最终的物理地址范围被标记为不可缓存,则负载请求将在记忆控制器中停止,直到知道它不是推测性的为止。
返回指令是间接分支的一种类型,但是下一个指令预测可能不太有用。因此,bx lr可能停滞,因为推测穿越不太可能有用? pop {pc}(又名从堆栈指针的LDMIA)在解码阶段可能没有被检测为分支(如果它没有特别检查pc位),或者被视为通用间接分支。当然,其他使用情况也可以将ld加载到pc作为非返回分支,因此检测它是否为概率返回需要检查源寄存器编码以及pc位。
也许有一个特殊的(内部隐藏的)返回地址预测堆栈,当与bl配对时可以每次正确地预测bx lr?x86会这样做,以预测call / ret指令。
您测试过pop {r4, pc}是否比pop {r4, lr} / bx lr更有效吗?如果bx lr在避免垃圾的推测执行方面不仅仅是处理特殊情况,那么最好让gcc这样做,而不是让它通过某些指令引导其文字池。

评论不适合进行长时间的讨论;此对话已被移至聊天室 - Andy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接