如果 Rust 产生一个常数值,它会贪婪地求值 Lazy 吗?

Jer*_*ows 5 lazy-evaluation compiler-optimization rust

我正在通过编写一个属性宏来混淆源代码中的字符串,它在调试模式下工作得很好,但在发布模式下,它似乎没有任何效果。

我的猜测是,编译器认为将SyncLazy始终产生相同的值,并且在优化期间,它会继续并在编译时进行评估,尽管我不希望出现这种行为。

我尝试将评估包装在 a 中,black_box但编译器似乎没有接受提示。我也尝试过使用该optimize_attribute功能,尽管看起来还没有optimize(none)可用。

基于以下结果的示例cargo expand

#![feature(once_cell)]
#![feature(bench_black_box)]

use std::hint;
use std::lazy::SyncLazy;

// everything in the lazy was generated by the macro to get the original string back at run-time
static VAL: SyncLazy<String> = SyncLazy::new(|| {
    hint::black_box(String::from_utf8(
        [
            140, 155, 158, 142, 158, 142, 166, 156, 150, 130, 148, 171, 128, 134, 132, 150, 137,
        ]
        .iter()
        .enumerate()
        .map(|(i, e)| i as u8 ^ !e as u8)
        .collect::<Vec<u8>>(),
    ))
    .unwrap()
});

fn main() {
    println!("{}", *VAL);
}
Run Code Online (Sandbox Code Playgroud)
# expected result:
$ rustc -C opt-level=1 main.rs && if grep 'secret' main; then echo '"secret" is still readable'; else echo "obfuscation worked"; fi
obfuscation worked

# does not hide data:
$ rustc -C opt-level=2 main.rs && if grep 'secret' main; then echo '"secret" is still readable'; else echo "obfuscation worked"; fi
grep: main: binary file matches
"secret" is still readable
Run Code Online (Sandbox Code Playgroud)

我的解决方法是在我的 中设置较低的发布优化级别Cargo.toml,但我仍然对发生的情况感到好奇,或者是否有一个好的方法可以将评估推迟到运行时。贪婪地评估惰性类型似乎违背了它的目的,因此如果对实际发生的情况有更好的解释,那也可能有助于产生更好的解决方案。

use*_*968 4

此行为并非特定于SyncLazy. 编译器可以看到数组上的迭代和后续计算是静态的,并且将在编译时尽可能“预计算”结果,作为其常量折叠过程的一部分。不过,确切的行为并未指定,因为程序无法观察编译器是否进行了此优化。一般来说,如果计算太大或太复杂,编译器就会放弃并推迟到运行时。同样,由于这不是可观察的程序行为的一部分,因此您无法预测编译器是否完全、部分或完全执行此操作。根据经验,如果您不依赖编译器,您应该预期编译器会常量折叠,如果您希望它不会常量折叠,则应预期它不会常量折叠......

例如,如果您在启用优化的情况下编译以下内容...

pub fn foo() -> Vec<u8> {
    let cypher = [47u8; 15];  // The `encrypted` bytes
    cypher
        .iter()
        .enumerate()
        .map(|(idx, p)| 1 + *p ^ (idx as u8))  // The `decryption`
        .collect()  // The `decrypted` Vec
}
Run Code Online (Sandbox Code Playgroud)

...您将在生成的程序集中看到编译器已完全删除计算并用单个分配替换函数,并Vec通过寄存器将“解密”结果直接移动到新分配的中:

playground::foo:
    pushq   %rbx
    movq    %rdi, %rbx
    movl    $15, %edi
    movl    $1, %esi
    callq   *__rust_alloc@GOTPCREL(%rip)
    testq   %rax, %rax
    je  .LBB0_1
    movq    %rax, (%rbx)
    movabsq $3978425819141910832, %rcx // This is the `decrypted` result
    movq    %rcx, (%rax)               // as part of the instruction-stream
    movl    $993671480, 8(%rax)  
    movw    $15676, 12(%rax)
    movb    $62, 14(%rax)
    movaps  .LCPI0_0(%rip), %xmm0
    movups  %xmm0, 8(%rbx)
    movq    %rbx, %rax
    popq    %rbx
    retq // No computation in this function at all
Run Code Online (Sandbox Code Playgroud)

如果我们将数组的长度从 更改为1516编译器将无法使用寄存器,而是将“解密”字节烘焙到可执行文件中;结果被Vec一举移入新分配的区域:

playground::foo:
    pushq   %rbx
    movq    %rdi, %rbx
    movl    $16, %edi
    movl    $1, %esi
    callq   *__rust_alloc@GOTPCREL(%rip)
    testq   %rax, %rax
    je  .LBB0_1
    movq    %rax, (%rbx)
    movaps  .LCPI0_0(%rip), %xmm0 
    movups  %xmm0, (%rax)
    movaps  .LCPI0_1(%rip), %xmm0
    movups  %xmm0, 8(%rbx)
    movq    %rbx, %rax
    popq    %rbx
    retq  // Again, no computation
Run Code Online (Sandbox Code Playgroud)

如果我们让数组足够大,编译器就会放弃:它分配一块内存,memset将其分配给我们在代码中设置的值(47在上面的示例中),并执行整个计算;每次foo调用该函数时都会执行此操作。编译器放弃的点似乎正好是 609 字节,这应该被认为是完全任意的且特定于版本的。

由于篇幅原因,我不会在这里粘贴代码......

正如评论中指出的那样,它obfstr实现了您想要的行为。为了说服 LLVM不要将反混淆移至编译时,它必须经历巨大的痛苦。

作为对你的问题的更一般的回答:你永远不应该将秘密交给编译器,如果你这样做,期望编译器将这些秘密泄露给用户。在您的情况下,混淆算法是秘密,编译器很乐意通过将去混淆的字节放入可执行文件中来泄露它。如果您必须这样做,您将需要花费额外的工作(就像obfstr板条箱所做的那样)来“欺骗”编译器,并大量监视结果。