assembly 由于从越界内存跳过cmov,因此难以调试SEGV

zwghvu4y  于 2023-08-06  发布在  其他
关注(0)|答案(2)|浏览(81)

我正在尝试编写一些高性能的汇编函数作为练习,并遇到了一个奇怪的segfault,发生在运行程序时,但不是在valgrind或nemiver。
基本上,一个不应该运行的cmov,一个越界的地址,即使条件总是假,也会使我产生segfault
我有一个快的和一个慢的版本。慢的那个一直在工作。快速的一个工作,除非它收到一个非ascii字符,在这一点上,它崩溃可怕,除非我运行在adb或nemiver。
ascii_flags只是一个128字节的数组(末尾有一点空间),其中包含所有ASCII字符(字母、数字、可打印字符等)的标志
这是有效的:

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    jnz .error
    mov EAX, [rel ascii_flags + EDI]    ; load ascii table if input fits
    and EAX, 0b00001000         ; get specific bit
.error:
    ret

字符串
但这并不是:

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    cmovz EAX, [rel ascii_flags + EDI]  ; load ascii table if input fits
    and EAX, flag_print         ; get specific bit
    ret


Valgrind确实崩溃了,但是除了内存地址之外没有其他信息,因为我还没有设法获得更多的调试信息。
编辑:
我已经编写了三个版本的函数来考虑这些精彩的答案:

ft_isprint:
    mov RAX, 128                            ; load default index
    test RDI, ~127                          ; check for non-ascii (>127) input
    cmovz RAX, RDI                          ; if none are found, load correct index
    mov AL, byte [ascii_flags + RAX]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit (and zeros rest of RAX)
    ret

ft_isprint_branch:
    test RDI, ~127                          ; check for non-ascii (>127) input
    jnz .out_of_bounds                      ; if non-ascii, jump to error handling
    mov AL, byte [ascii_flags + RDI]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit (and zeros rest of RAX)
    ret
.out_of_bounds:
    xor RAX, RAX                            ; zeros return value
    ret

ft_isprint_compact:
    xor RAX, RAX                            ; zeros return value preemptively
    test RDI, ~127                          ; check for non-ascii (>127) input
    jnz .out_of_bounds                      ; if non-ascii was found, skip dereferenciation
    mov AL, byte [ascii_flags + RDI]        ; dereference index into least sig. byte
    and RAX, flag_print                     ; get specific bit
.out_of_bounds:
    ret


经过广泛的测试,在所有类型的数据上,分支函数肯定比cmov函数快大约5-15%。紧凑型和非紧凑型之间的差异正如预期的那样是最小的。在可预测的数据集上,压缩的速度会稍快一些,而在不可预测的数据上,非压缩的速度也会稍快一些。
我尝试了各种不同的方法来跳过'xor EAX,EAX'指令,但找不到任何有效的方法。
编辑:经过更多的测试,我已经将代码更新为三个新版本:

ft_isprint_compact:
    sub EDI, 32                             ; substract 32 from input, to overflow any value < ' '
    xor EAX, EAX                            ; set return value to 0
    cmp EDI, 94                             ; check if input <= '~' - 32
    setbe AL                                ; if so, set return value to 1
    ret

ft_isprint_branch:
    xor EAX, EAX                            ; set return value to 0
    cmp EDI, 127                            ; check for non-ascii (>127) input
    ja .out_of_bounds                       ; if non-ascii was found, skip dereferenciation
    mov AL, byte [rel ascii_flags + EDI]    ; dereference index into least sig. byte
.out_of_bounds:
    ret

ft_isprint:
    mov EAX, 128                            ; load default index
    cmp EDI, EAX                            ; check if ascii
    cmovae EDI, EAX                         ; replace with 128 if outside 0..127
                                            ; cmov also zero-extends EDI into RDI
;   movzx EAX, byte [ascii_flags + RDI]     ; alternative to two following instruction if masking is removed
    mov AL, byte [ascii_flags + RDI]        ; load table entry
    and EAX, flag_print                     ; apply mask to get correct bit and zero rest of EAX
    ret


性能如下所示,单位为微秒。图1-2-3显示了执行顺序,以避免缓存的优点:

-O3 a.out
1 cond 153185, 2 branch 238341 3 no_table 145436
1 cond 148928, 3 branch 248954 2 no_table 116629
2 cond 149599, 1 branch 226222 3 no_table 117428
2 cond 117258, 3 branch 241118 1 no_table 147053
3 cond 117635, 1 branch 228209 2 no_table 147263
3 cond 146212, 2 branch 220900 1 no_table 147377
-O3 main.c
1 cond 132964, 2 branch 157963 3 no_table 131826
1 cond 133697, 3 branch 159629 2 no_table 105961
2 cond 133825, 1 branch 139360 3 no_table 108185
2 cond 113039, 3 branch 162261 1 no_table 142454
3 cond 106407, 1 branch 133979 2 no_table 137602
3 cond 134306, 2 branch 148205 1 no_table 141934
-O0 a.out
1 cond 255904, 2 branch 320505 3 no_table 257241
1 cond 262288, 3 branch 325310 2 no_table 249576
2 cond 247948, 1 branch 340220 3 no_table 250163
2 cond 256020, 3 branch 415632 1 no_table 256492
3 cond 250690, 1 branch 316983 2 no_table 257726
3 cond 249331, 2 branch 325226 1 no_table 250227
-O0 main.c
1 cond 225019, 2 branch 224297 3 no_table 229554
1 cond 235607, 3 branch 199806 2 no_table 226286
2 cond 226739, 1 branch 210179 3 no_table 238690
2 cond 237532, 3 branch 223877 1 no_table 234103
3 cond 225485, 1 branch 201246 2 no_table 230591
3 cond 228824, 2 branch 202015 1 no_table 226788


no table版本和cmov一样快,但是不允许容易实现的局部变量。分支算法是更差的,除非在可预测的数据在零优化?我无法解释。
我将保留cmov版本,它是最优雅和易于更新的版本。谢谢你的帮助。

aydmsdu9

aydmsdu91#

cmov是一个ALU选择操作,它总是在 * 检查条件之前读取两个源 *。使用内存源不会改变这一点。它不像ARMAssert的指令,如果条件为假,则其行为就像NOP。cmovz eax, [mem]也无条件地 * 写入 * EAX,零扩展到RAX,而不管条件如何。
就大部分CPU而言(无序调度器等),cmovcc reg, [mem]的处理方式与adc reg, [mem]完全相同:3输入1输出ALU指令。(adc写标志,不像cmov,但不用在意。)微融合存储器源操作数是一个单独的uop,恰好是同一条x86指令的一部分。这也是伊萨规则的工作方式。

所以实际上,更适合cmovz的助记符是selectz。(或AArch 64的csel。)

英特尔APX(Advanced Performance Extensions将添加CFCMOVcc(有条件故障CMOVcc)-有条件加载/有条件存储指令,这些指令 * 确实 * 使用假 predicate (如AVX 512掩蔽)抑制内存故障。(使用cmovcc操作码的EVEX前缀进行编码。)
x86的唯一条件加载(不会在错误地址上出错,只是可能运行缓慢)是:
*有条件分支保护的正常加载。导致运行故障加载的分支误预测或其他误推测被相当有效地处理(可能开始页面行走,但是一旦误推测被识别,正确的指令流的执行不必等待由推测执行开始的任何存储器操作)。

如果在您无法读取的页面上有一个TLB命中,那么在故障加载达到退休之前不会发生太多事情(已知是非推测性的,因此实际上会发生#PF页面故障异常,这不可避免地会很慢)。在某些CPU上,这种快速处理会导致Meltdown攻击。>.<参见http://blog.stuffedcow.net/2018/05/meltdown-microarchitecture/

  • rep lodsd,RCX=0或1。(* 不 * 快速或高效,但微代码分支是特殊的,不能从英特尔CPU上的分支预测中受益。参见What setup does REP do?。Andy Glew提到了微代码分支错误预测,但我认为这些与正常的分支错误不同,因为似乎有一个固定的成本。
    *AVX2 vpmaskmovd/q / AVX1 vmaskmovps/pd。对于掩码为0的元素,将抑制故障。即使从法律的地址加载全0掩码,也需要大约200个周期的微码辅助,并采用基址+索引寻址模式。)参见英特尔优化手册中的第 12.9节“条件SIMD打包加载和存储” 和表C-8。(在Skylake上,使用全零面罩的非法地址的商店也需要帮助。

早期的MMX/SSE 2 maskmovdqu是只存储的(并且具有NT提示)。只有具有dword/qword(而不是byte)元素的类似AVX指令具有加载形式。

  • AVX 512掩蔽负载
  • AVX 2在清除部分/所有掩码元素的情况下进行聚集。
  • (未来的CPU)APX cfmovcc reg, reg/[mem]cfmovcc dst, src1, src2/[mem]。也是商店版本。据推测,它们使用AVX-512屏蔽标量加载/存储所使用的一些相同硬件支持。(APX还提供了大量的新内容,例如大多数传统标量整数指令的3操作数编码,32个GPR,最后是一个写入整个32/64位寄存器的setcc形式。

……也许还有一些我忘记了。TSX / RTM事务中的正常加载:错误会中止事务 * 而不是引发#PF。但是你不能指望一个坏的索引错误,而不是仅仅从附近的某个地方阅读伪造的数据,所以这不是一个真正的条件加载。它也不是超级快进入一个交易。
另一种方法可能是**cmov您无条件使用的地址**,选择从哪个地址加载。例如,如果你有一个0从其他地方加载,这将工作。但是这样你就必须在寄存器中计算表索引,而不是使用寻址模式,所以你可以cmov最终地址。
或者只需要CMOV索引并在表的末尾填充一些零字节,这样就可以从table + 128加载。
或者使用分支,它可能会很好地预测很多情况。但对于像法语这样的语言来说可能不是这样,因为在普通文本中,您会发现低128和更高的Unicode码位的混合。

代码审核

请注意,[rel]仅在寻址模式中不涉及寄存器(RIP除外)时才工作。RIP相对寻址取代了2种冗余方式之一(32位代码)来编码[disp32]。它使用较短的非SIB编码,而ModRM+SIB仍然可以在没有寄存器的情况下编码绝对值[disp32]。(对于像[fs: 16]这样的地址,相对于基于段的线程本地存储的小偏移量很有用。

如果您只想尽可能使用RIP相对寻址,请在文件顶部使用default rel[symbol]是RIP相关的,但[symbol + rax]不是。不幸的是,NASM和YASM默认为default abs

[reg + disp32]是在位置相关代码中索引静态数据的一种非常有效的方法,只是不要自欺欺人地认为它可以是RIP相关的。参见32-bit absolute addresses no longer allowed in x86-64 Linux?a question about indexing arrays(RIP相对莱亚静态地址,并使用2寄存器寻址模式。)
[rel ascii_flags + EDI]也很奇怪,因为您在x86-64代码中的寻址模式中使用了32位寄存器。通常没有理由花费地址大小前缀来将地址截断为32位。
然而,在这种情况下,如果您的表位于虚拟地址空间的低32位,并且您的函数arg仅指定为32位(因此允许调用者在RDI的高32位中留下垃圾),那么使用[disp32 + edi]而不是mov esi,edi或其他零扩展实际上是一种胜利。如果你是故意这样做的,一定要说明为什么你使用32位寻址模式。
但是在这种情况下,在索引上使用cmov将零扩展到64位。
从字节表中使用DWORD加载也很奇怪。您偶尔会跨越缓存行边界并遭受额外的延迟。如果你只需要一个字节,使用movzx eax, byte [mem]
@fuz显示了一个在索引上使用RIP相对莱亚 * 和 * CMOV的版本。

在位置相关代码中,32位绝对地址是可以的,无论如何都要使用它来保存指令[disp32]寻址模式比RIP-relative(长1个字节)差,但当位置相关代码和32位绝对地址正常时,[reg + disp32]寻址模式完全正常。(例如x86-64 Linux,但不是OS X,其中可执行文件总是Map到低32位之外。)请注意,它不是rel

; position-dependent version taking advantage of 32-bit absolute [reg + disp32] addressing
; not usable in shared libraries, only non-PIE executables.
ft_isprint:
    mov     eax, 128               ; offset of dummy entry for "not ASCII"
    cmp     edi, eax               ; check if ascii
    cmovae  edi, eax               ; replace with 128 if outside 0..127
              ; cmov also zero-extends EDI into RDI
    movzx   eax, byte [ascii_flags + rdi] ; load table entry
    and     al, flag_print         ; mask the desired flag
      ; if the caller is only going to read / test AL anyway, might as well save bytes here
    ret

字符串

如果表中的任何现有条目具有与高输入相同的标志,例如也许条目0在隐式长度的字符串中是看不到的,你仍然可以对EAX进行异或零运算,并将表保持在128字节,而不是129字节。
test r32, imm32占用的代码字节比您需要的多~127 = 0xFFFFFF80适合符号扩展字节,但不是TEST r/m32, sign-extended-imm8编码。cmp也有这样的编码,就像所有其他立即指令一样。

您可以使用cmp edi, 127/cmovbe eax, edicmova edi, eax检查127以上的无符号。这节省了3个字节的代码大小。或者我们可以通过使用cmp reg,reg来保存4个字节,使用我们用于表索引的128

数组索引前的范围检查对大多数人来说也比检查高位更直观。

and al, imm8只有2个字节,而and r/m32, sign-extended-imm8的3个字节。它在任何CPU上都不会慢,只要调用者只读AL。在Sandybridge之前的Intel CPU上,在AND到AL之后阅读EAX可能会导致部分寄存器停顿/减速。如果我没记错的话,Sandybridge不会重命名读-修改-写操作的部分寄存器,IvB和后来的版本根本不会重命名low 8部分寄存器。
您也可以使用mov al, [table]而不是movzx来保存另一个代码字节。早期的mov eax, 128已经打破了对EAX旧值的任何假依赖,因此它不应该有性能下降。但是movzx不是一个坏主意。
当所有其他条件相同时,较小的代码大小几乎总是更好(对于指令缓存占用空间,有时用于打包到uop缓存中)。但是,如果它花费了任何额外的uops或引入了任何错误的依赖关系,那么在优化速度时就不值得了。

laawzig2

laawzig22#

正如Peter Cordes所解释的,cmovCC无条件地从内存加载。为了缓解这个问题,你可以先在edi上做一个条件移动,如果字符超出范围,就清除edi,导致条件移动从ascii_flags[0]加载,避免你的问题。方便的是,eax在您这样做时已经清除了。
还请注意,您可能希望避免将32位寄存器作为基址和索引寄存器,因为它们需要额外的前缀来表示,并且在某些架构上可能会更慢。只需要使用64位的对应项即可。

ft_isprint:
    xor EAX, EAX                ; empty EAX
    test EDI, ~127              ; check for non-ascii (>127) input
    cmovnz EDI, EAX             ; clear EDI if not ascii
    cmovz EAX, [ascii_flags + RDI]  ; load ascii table if input fits
    and EAX, flag_print         ; get specific bit
    ret

字符串
为了解决Peter Cordes的其他问题,我实际上会使用这样的代码:

; PIC/PIE safe version, doing only a byte load
ft_isprint:
    lea   rsi, [rel ascii_flags] ; load address of ascii_flags
    mov   eax, 128               ; load offset of dummy entry for "not ASCII"
    test   edi, ~127             ; check if ascii
    cmovz  eax, edi              ; load proper entry if ascii
    movzx  eax, byte [rsi + rax] ; load table entry
    and    eax, flag_print       ; mask the desired flag
    ret

相关问题