在x86上观察自修改代码导致的指令获取陈旧问题

40

据Intel的手册所述,可以将指令写入内存,但指令预取队列已经获取了陈旧的指令,并将执行那些旧的指令。我一直没有成功地观察到这种行为。我的方法如下。

根据Intel软件开发手册第11.6节的说明:

 

对当前在处理器缓存中缓存的代码段中的内存位置进行写操作会导致相关的高速缓存行(或行)失效。此检查基于指令的物理地址。此外,P6系列和奔腾处理器还会检查对代码段的写操作是否可能修改已经预取以供执行的指令。如果写操作影响到预取的指令,则预取队列将失效。后一项检查基于指令的线性地址。

因此,看起来如果我想执行陈旧的指令,我需要有两个不同的线性地址引用相同的物理页面。因此,我将一个文件映射到两个不同的地址。

int fd = open("code_area", O_RDWR | O_CREAT, S_IRWXU | S_IRWXG | S_IRWXO);
assert(fd>=0);
write(fd, zeros, 0x1000);
uint8_t *a1 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
        MAP_FILE | MAP_SHARED, fd, 0);
uint8_t *a2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
        MAP_FILE | MAP_SHARED, fd, 0);
assert(a1 != a2);
我有一个汇编函数,它需要一个参数,即指向我想要更改的指令的指针。

我有一个汇编函数,它需要一个参数,即指向我想要更改的指令的指针。

fun:
    push %rbp
    mov %rsp, %rbp

    xorq %rax, %rax # Return value 0

# A far jump simulated with a far return
# Push the current code segment %cs, then the address we want to far jump to

    xorq %rsi, %rsi
    mov %cs, %rsi
    pushq %rsi
    leaq copy(%rip), %r15
    pushq %r15
    lretq

copy:
# Overwrite the two nops below with `inc %eax'. We will notice the change if the
# return value is 1, not zero. The passed in pointer at %rdi points to the same physical
# memory location of fun_ins, but the linear addresses will be different.
    movw $0xc0ff, (%rdi)

fun_ins:
    nop   # Two NOPs gives enough space for the inc %eax (opcode FF C0)
    nop
    pop %rbp
    ret
fun_end:
    nop

在C语言中,我将代码复制到内存映射文件中。我从线性地址a1调用函数,但我将指向a2的指针作为代码修改的目标传递。

#define DIFF(a, b) ((long)(b) - (long)(a))
long sz = DIFF(fun, fun_end);
memcpy(a1, fun, sz);
void *tochange = DIFF(fun, fun_ins);
int val = ((int (*)(void*))a1)(tochange);

如果CPU捕捉到了修改后的代码,则val==1。否则,如果执行了过时的指令(两个nop),val==0。

我在1.7GHz的Intel Core i5 (2011 macbook air)和Intel(R) Xeon(R) CPU X3460 @ 2.80GHz上运行了这个程序。但是每次都看到val==1,表明CPU总是注意到了新的指令。

有没有人遇到我想观察的行为?我的推理是否正确?我对手册中提到的P6和Pentium处理器以及没有提到我的Core i5处理器有点困惑。也许会发生其他导致CPU刷新其指令预取队列的情况吗?任何见解都将非常有帮助!


你使用的手册是什么(请检查第一页上的“订单号”并在此处写下)? - osgx
还要查看指南手册中的“8.1.3处理自修改和交叉修改代码”部分- http://download.intel.com/products/processor/manual/325462.pdf - osgx
嗯,尝试从a2中取消PROT_EXEC... 这可能会影响一些英特尔Atom处理器。 - osgx
"Pentium"或P5是一种特定的实现,于1993年至1996年销售。P6是另一种实现,并以"PentiumPro"的名义在1995年至1999年进行市场销售。如今要找到它们都会比较困难。 - undefined
4个回答

37

我认为你应该检查CPU的MACHINE_CLEARS.SMC性能计数器(它是MACHINE_CLEARS事件的一部分),它可用于你的Air powerbook中使用的Sandy Bridge 1,也可用于你的Xeon中使用的Nehalem 2-搜索“smc”。您可以使用oprofileperf或英特尔的Vtune来查找其值:

http://software.intel.com/sites/products/documentation/doclib/iss/2013/amplifier/lin/ug_docs/GUID-F0FD7660-58B5-4B5D-AA9A-E1AF21DDCA0E.htm

机器清除

描述

某些事件需要清除整个流水线并从上一个已退休指令后重新启动。该度量衡三种此类事件:内存排序违规、自修改代码和加载非法地址范围的某些内容。

可能出现的问题

执行时间的重要部分花费在处理机器清除上。检查MACHINE_CLEARS事件以确定具体原因。

SMC: http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/amplifierxe/win/win_reference/snb/events/machine_clears.html|机器清除

MACHINE_CLEARS事件代码:0xC3 SMC掩码:0x04

检测到自修改代码(SMC)。

检测到自修改代码机器清除的数量。

英特尔还提到了关于smc http://software.intel.com/en-us/forums/topic/345561(链接自Intel Performance Bottleneck Analyzer's taxonomy

当检测到自修改代码时,此事件将触发。这通常可以被二进制编辑人员用来强制程序走特定的路径(例如黑客)。该事件计算程序写入代码段的次数。自修改代码会在所有Intel 64和IA-32处理器中造成严重的惩罚。修改后的高速缓存行被写回L2和LLC高速缓存。此外,指令需要重新加载,从而导致性能下降。

我认为你将看到一些这样的事件。如果是这样的话,那么 CPU 就能够检测到代码自修改的动作,并触发“机器清除”——流水线的完全重启。第一个阶段是取指令,它们将会向 L2 缓存请求新的操作码。我非常想知道每次执行你的代码时 SMC 事件的确切数量——这将为我们提供有关延迟的估计。(SMC 在某些单位中计算,其中 1 个单位被假定为 1.5 个 CPU 周期,参见英特尔优化手册 B.6.2.6 节)
我们可以看到 Intel 表示“从最后一个退役指令之后重新启动”,所以我认为最后一个退役指令将是 mov;而你的 nops 已经在流水线中了。但是,在 mov 退役时,SMC 将被触发并杀死流水线中的所有内容,包括 nops。
这种由 SMC 引起的流水线重启不便宜,Agner 在 Optimizing_assembly.pdf 中进行了一些测量——“17.10 自修改代码(所有处理器)”(我认为任何 Core2/CoreiX 处理器在此方面都类似于 PM)。
执行修改后的代码会带来一定的惩罚,对于P1处理器约为19个时钟周期,PMMX约为31个时钟周期,而对于PPro、P2、P3和PM处理器则在150-300个时钟周期之间。自修改代码后,P4将清除整个跟踪缓存。在80486及更早版本的处理器中,必须在修改代码和被修改代码之间进行跳转,以清除代码缓存。自修改代码不被认为是良好的编程实践。只有当提速效果显著且修改后的代码被执行很多次以至于优势大于使用自修改代码的惩罚时,才应该使用自修改代码。

本文推荐了使用不同的线性地址来使SMC检测失败:https://dev59.com/3Wgu5IYBdhLWcg3w6bGo#10994728 - 我会试着找到实际的英特尔文献...现在无法回答你的真正问题。

这里可能有一些提示:优化手册,248966-026,2012年4月“3.6.9混合代码和数据”:

将可写数据放置在代码段中可能无法与自修改代码区分开来。在代码段中的可写数据可能会遭受与自修改代码相同的性能惩罚。

下一节内容:

软件应避免在正在执行的1-KB子页面中写入代码页或在同样正在被写入的2-KB子页面中获取代码。此外,与其他处理器共享包含直接或推测执行代码的页面作为数据页面可以触发SMC条件,导致整个机器的流水线和跟踪缓存被清除。这是由于自修改代码条件造成的。

因此,可能有一些原理图控制可写和可执行子页面的交叉点。

您可以尝试从其他线程进行修改(交叉修改代码)-但需要非常小心的线程同步和管道刷新(您可能希望在编写器线程中包含一些延迟的强制执行;在同步后进行CPUID是想要的)。但是你应该知道他们已经使用“核武器”来修复了这个问题-请检查US6857064专利。

我有点困惑手册提到了P6和奔腾处理器

如果您获取、解码并执行了英特尔指令手册的旧版本,这是可能的。您可以重置流水线并检查此版本:订单号码:325462-047US,2013年6月“11.6自修改代码”。这个版本仍然没有提到新的CPU,但是提醒当您使用不同的虚拟地址进行修改时,在微架构之间可能会出现不兼容的情况(在您的Nehalem / Sandy Bridge上可能有效,在Skymont上可能无效)。 11.6 自修改代码 在代码段中写入内存位置时,会导致处理器缓存的相关高速缓存行(或多个行)无效。此检查基于指令的物理地址。此外,P6系列和Pentium处理器会检查对代码段的写入是否可以修改已预取以执行的指令。如果写入影响了预取指令,则无效化预取队列。后一项检查是基于指令的线性地址。对于Pentium 4和Intel Xeon处理器,在代码段中的指令进行写入或嗅探时,目标指令已被解码并驻留在跟踪缓存中,将使整个跟踪缓存失效。这种行为意味着自修改代码的程序在Pentium 4和Intel Xeon处理器上运行时,可能会导致性能严重下降。
实际上,线性地址的检查不应该在IA-32处理器之间造成兼容性问题。包括自修改代码的应用程序使用相同的线性地址来修改和获取指令。
系统软件(例如调试器),可能使用与获取指令时不同的线性地址来修改指令,在修改指令执行之前会执行序列化操作,如CPUID指令,这将自动重新同步指令缓存和预取队列。(有关使用自修改代码的更多信息,请参见8.1.3节,“处理自修改和交叉修改代码”)。
对于Intel486处理器,在缓存中写入指令将在缓存和内存中修改它,但如果在写入之前预取指令,则可能执行旧版本的指令。为了防止执行旧指令,应编写跳转指令,并在任何修改指令后立即进行以刷新指令预取单元。

实时更新,在谷歌上搜索"SMC检测"(带引号),有一些关于现代Core2/Core iX如何检测SMC以及许多与Xeons和Pentiums挂钩的errata列表的详细信息:

  1. http://www.google.com/patents/US6237088 管道中跟踪飞行指令的系统和方法 @ 2001

  2. DOI 10.1535/itj.1203.03 (在citeseerx.ist.psu.edu上有免费版本,请在Google上搜索)- "INCLUSION FILTER"被添加到Penryn中以降低假SMC检测数量;图9显示了“现有的包含检测机制”

  3. http://www.google.com/patents/US6405307 - SMC检测逻辑的旧专利

根据专利US6237088(图5,摘要),存在“线路地址缓冲区”(具有许多线性地址,每个已获取的指令对应一个地址 - 或者换句话说,缓冲区充满了以高速缓存线为精度的获取IP)。每个存储器或更确切地说,每个存储器的“存储器地址”阶段将被馈入并行比较器中进行检查,检查存储器是否与当前正在执行的任何指令相交。

这两项专利都没有明确说明,在SMC逻辑中它们将使用物理还是逻辑地址... Sandy Bridge中的L1i是VIPT(虚拟索引,物理标记,索引使用虚拟地址,标记使用物理地址。)根据http://nick-black.com/dankwiki/index.php/Sandy_Bridge,因此当L1缓存返回数据时,我们拥有物理地址。 我认为英特尔可能在SMC检测逻辑中使用物理地址。

更进一步,http://www.google.com/patents/US6594734 @1999(2003年发表,只需要记住CPU设计周期大约为3-5年)在“摘要”部分中指出SMC现在位于TLB中并使用物理地址(或者换句话说 - 请不要试图欺骗SMC检测器):

自修改代码通过翻译后备缓存来检测..[其中]已存储有物理页面地址,可以使用存储到内存的物理内存地址进行监视。...为了提供比网页地址更好的粒度,每个高速缓存条目中都包括FINE HIT位,将缓存中的信息与内存中页面的部分相关联。

(页面部分,在专利US6594734中称为象限,听起来像是1K子页面,不是吗?)

然后他们说

因此,由存储指令到内存的触发器引起的窥视者可以通过将指令高速缓存中所有指令的物理地址与相应页面或内存页中存储的所有指令的地址进行比较,执行SMC检测。如果存在地址匹配,则表示某个内存位置已被修改。在地址匹配的情况下(表示SMC条件),退役单元会清除指令高速缓存和指令流水线,并从内存中获取新的指令以存储到指令高速缓存中。
由于SMC检测的窥视者是物理的,ITLB通常接受线性地址作为输入并将其转换为物理地址,因此ITLB还形成了一个基于物理地址的内容可寻址存储器,并包括一个额外的输入比较端口(称为窥视端口或反向转换端口)。
-- 因此,为了检测SMC,他们强制将存储器通过嗅探(其他内核/ CPU或DMA写入我们的高速缓存中也会传递类似的嗅探信号)将物理地址返回到指令缓冲区,如果嗅探的物理地址与存储在指令缓冲区中的缓存行发生冲突,则会通过从iTLB到退役单元传递的SMC信号重新启动流水线。可以想象,在这种从dTLB通过iTLB到退役单元的嗅探循环中将浪费多少CPU时钟(尽管早于mov并且没有副作用,但它无法退役下一个“nop”指令)。但是,ITLB具有物理地址输入和第二个CAM(大而热),只是为了支持和防御疯狂和欺骗性的自修改代码。

PS:如果我们使用大页面(4M或者可能是1G),会怎样呢?L1TLB具有大页面条目,并且对于4MB页面的1/4来说,可能会出现许多错误的SMC检测...

PPS:有一种变体,即不同线性地址的SMC的误处理仅存在于早期的P6 / Ppro / P2...


+1 因为在如此精确的英特尔细节讨论中提到了“空气PowerBook” :) - Potatoswatter
8
如果您再深入研究,就会看到与SMC相关的专利,而我是其中的发明人。据我所知,我发明了P6的I$和ITLB包含机制,以监视“正在执行的指令”。// 我认为这些是错误的。我认为更容易创建一个全相联的CAM,将所有指令块放在流水线中,物理上的。如果想要节省功耗,可以使用布隆过滤器。// 我认为它们是错误的(a)因为复杂且难以正确实现,即使它们节省了很多门电路,(b)在性能方面具有脆弱性。 - Krazy Glew
2
如果您修改的代码始终比当前执行地址提前1k字节,那么这样做是否可以避免问题? - tentimes

20
我被告知并从英特尔手册中了解到,可以将指令写入内存,但指令预取队列已经取回陈旧的指令,并且可能会执行那些旧的指令。我无法观察到这种行为。
是的,你会。
几乎所有现代英特尔处理器都比手册规定更加严格:
它们基于物理地址而不仅仅是线性地址监视流水线。
处理器实现允许比手册更加严格。
他们可能会做出选择,因为他们遇到了未遵守手册规则的代码,不想破坏该代码。
或者…因为遵守架构规范的最简单方法(在SMC的情况下,官方上至“直到下一个序列化指令”,但实际上对于旧代码而言是“直到下一个距离大于???字节的分支”)可能是更加严格。

5
另一个有趣的CPU超越x86 ISA手册规定要求的例子:一致的页面遍历用于只能被推测加载的TLB条目,以避免破坏Win9x。自从Bulldozer开始,AMD放弃了一致性,因为我猜他们认为依赖此功能的Win9x和其他软件已不再相关。 - Peter Cordes
2
我一直想详细阐述@PeterCordes有关TLB未命中的“连贯”页表遍历的评论,但我会很快:(1)英特尔开始通过缓存而不是绕过缓存运行页表遍历的主要原因是性能。在P6之前,页表遍历速度较慢,无法从缓存中受益,并且不具备推测性。足够慢以至于软件TLB未命中处理是性能优势。 P6通过使用缓存和缓存中间节点(如页面目录条目)来加速TLB未命中,从而进行了推测性地加速TLB未命中。 - Krazy Glew
4
最尴尬的错误之一与加法进位存储器有关。在早期的微码中,加载将进行,进位标志将被更新,然后可能会出现存储器故障-但是进位标志已经被更新,因此指令无法重新启动。通过简单的微码修复,可以在写入进位标志之前执行存储操作,但是再增加一个微操作就足以使该指令不适合于“中等速度”的微码系统。 - Krazy Glew
2
谢谢,安迪。这是一些很棒的历史!我觉得它应该出现在某个答案中,或者作为一个巨大的旁注,或者如果我们能想到一个好的“问题”,那么可以自问自答。天啊,所以即使在Core2和SnB系列上,在内存目标ADC中那个额外的ALU uop也是从那里来的?我从来没有想到过,但一直感到困惑。 - Peter Cordes
3
同样的道理也适用于自修改代码:我们不是想让自修改代码运行更快,而是因为尝试使用旧有机制进行自修改代码 - 如通过排空管道来序列化指令(比如CPUID) - 速度比直接监视Icache和pipeline更慢。但是,这又仅适用于高端机器:在低端机器上,旧有机制已经足够快且廉价。 - Krazy Glew
显示剩余16条评论

6
Sandybridge系列(至少Skylake)似乎仍然具有相同的行为,会查看物理地址。
虽然您的测试有点过于复杂,但是我不明白远跳的意义。如果将SMC函数汇编(必要时链接)成一个扁平二进制文件,您可以将其打开并映射两次。将a1和a2设为函数指针,然后main函数在映射后通过return a1(a2)来调用。
以下是一个简单的测试框架,以便任何人都可以在自己的机器上尝试:(open / assert / mmap块从问题中复制,感谢提供起点。)
缺点是,每次都必须重新构建SMC扁平二进制文件,因为使用MAP_SHARED映射它实际上会修改它。我不知道如何获得两个映射相同物理页的方法,而不会修改底层文件;写入MAP_PRIVATE将使它复制到不同的物理页。因此,现在意识到这一点,将机器代码写入文件再进行映射是有意义的。但是我的汇编代码仍然简单得多。
// smc-stale.c
#include <sys/mman.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>

typedef int (*intfunc_t)(void *);   // __attribute__((sysv_abi))  // in case you're on Windows.

int main() {
    int fd = open("smc-func", O_RDWR);

    assert(fd>=0);
    intfunc_t a1 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
                MAP_FILE | MAP_SHARED, fd, 0);
    intfunc_t a2 = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
                MAP_FILE | MAP_SHARED, fd, 0);
    assert(a1 != a2);
    return a1(a2);
}

测试函数的NASM源代码:

(请参见如何使用GNU GAS汇编器生成类似nasm -f bin的纯二进制文件?了解as+ld替代nasm -f的方法)

;;build with nasm smc-func.asm     -fbin is the default.
bits 64
entry:   ; rdi = another mapping of the same page that's executing
    mov  byte [rdi+dummy-entry], 0xcc       ; trigger any copy-on-write page fault now

    mov  r8, rbx    ; CPUID steps on call-preserved RBX
    cpuid               ; serialize for good measure
    mov  rbx, r8
;    mfence
;    lfence

    mov   dword [rdi + retmov+1 - entry],  0       ; return 0 for snooping
retmov:
    mov   eax, 1      ; opcode + imm32             ; return 1 for stale
    ret

dummy:  dd 0xcccccccc

在运行Linux 4.20.3-arch1-1-ARCH的i7-6700k上,我们没有观察到旧代码获取。覆盖立即数“1”为“0”的mov确实在运行之前修改了该指令。
peter@volta:~/src/experiments$ gcc -Og -g smc-stale.c
peter@volta:~/src/experiments$ nasm smc-func.asm && ./a.out; echo $?
0
# remember to rebuild smc-func every time, because MAP_SHARED modifies it

这一开始让我有点困惑,因为在原始问题和代码中,返回值为0表示执行了过期指令,但是你在代码中将其翻转,使得1表示过期指令,0表示指令在执行前被修改。然而,结论是相同的:没有观察到过期指令的执行。感谢您的贡献! - Erik Swan

3

2
有趣的事实:在为什么x86在80年代和90年代支持自修改代码?的一个回答中提到,陈旧的指令获取是检测8086与8088之间差异的一种方式。8086具有6字节的预取队列,而8088只有4字节。 - Peter Cordes

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