C 汇编循环

Posted

技术标签:

【中文标题】C 汇编循环【英文标题】:C Assembly Loop 【发布时间】:2017-05-15 05:44:22 【问题描述】:

我对这个程序集中发生的事情有点困惑。我可以看到,如果不输入六个数字,炸弹就会爆炸并结束程序。逐行检查输入,如果六个数字非负则进入循环。我在这里迷路了0x0000000000400f29 <+29>: add -0x4(%rbp),%eax

看起来很简单,但我真的不明白这里添加了什么。它是添加-4然后将其与0进行比较吗?然后如果相等就跳?

我基本上是在寻找关于循环的具体说明,以及预期在循环中继续的输入模式。

转储

   0x0000000000400f0c <+0>:     push   %rbp
   0x0000000000400f0d <+1>:     push   %rbx
   0x0000000000400f0e <+2>:     sub    $0x28,%rsp
   0x0000000000400f12 <+6>:     mov    %rsp,%rsi
   0x0000000000400f15 <+9>:     callq  0x40165a <read_six_numbers>
   0x0000000000400f1a <+14>:    cmpl   $0x0,(%rsp)
   0x0000000000400f1e <+18>:    jns    0x400f44 <phase_2+56>
   0x0000000000400f20 <+20>:    callq  0x401624 <explode_bomb>
   0x0000000000400f25 <+25>:    jmp    0x400f44 <phase_2+56>
   0x0000000000400f27 <+27>:    mov    %ebx,%eax
=> 0x0000000000400f29 <+29>:    add    -0x4(%rbp),%eax
   0x0000000000400f2c <+32>:    cmp    %eax,0x0(%rbp)
   0x0000000000400f2f <+35>:    je     0x400f36 <phase_2+42>
   0x0000000000400f31 <+37>:    callq  0x401624 <explode_bomb>
   0x0000000000400f36 <+42>:    add    $0x1,%ebx
   0x0000000000400f39 <+45>:    add    $0x4,%rbp
   0x0000000000400f3d <+49>:    cmp    $0x6,%ebx
   0x0000000000400f40 <+52>:    jne    0x400f27 <phase_2+27>
   0x0000000000400f42 <+54>:    jmp    0x400f50 <phase_2+68>
   0x0000000000400f44 <+56>:    lea    0x4(%rsp),%rbp
   0x0000000000400f49 <+61>:    mov    $0x1,%ebx
   0x0000000000400f4e <+66>:    jmp    0x400f27 <phase_2+27>
   0x0000000000400f50 <+68>:    add    $0x28,%rsp
   0x0000000000400f54 <+72>:    pop    %rbx
   0x0000000000400f55 <+73>:    pop    %rbp
   0x0000000000400f56 <+74>:    retq
End of assembler dump.

read_six_numbers

Dump of assembler code for function read_six_numbers:
=> 0x000000000040165a <+0>:     sub    $0x18,%rsp
   0x000000000040165e <+4>:     mov    %rsi,%rdx
   0x0000000000401661 <+7>:     lea    0x4(%rsi),%rcx
   0x0000000000401665 <+11>:    lea    0x14(%rsi),%rax
   0x0000000000401669 <+15>:    mov    %rax,0x8(%rsp)
   0x000000000040166e <+20>:    lea    0x10(%rsi),%rax
   0x0000000000401672 <+24>:    mov    %rax,(%rsp)
   0x0000000000401676 <+28>:    lea    0xc(%rsi),%r9
   0x000000000040167a <+32>:    lea    0x8(%rsi),%r8
   0x000000000040167e <+36>:    mov    $0x402871,%esi
   0x0000000000401683 <+41>:    mov    $0x0,%eax
   0x0000000000401688 <+46>:    callq  0x400c30 <__isoc99_sscanf@plt>
   0x000000000040168d <+51>:    cmp    $0x5,%eax
   0x0000000000401690 <+54>:    jg     0x401697 <read_six_numbers+61>
   0x0000000000401692 <+56>:    callq  0x401624 <explode_bomb>
   0x0000000000401697 <+61>:    add    $0x18,%rsp
   0x000000000040169b <+65>:    retq
End of assembler dump.

【问题讨论】:

哪一部分是C 它将rbp-4指向的内存位置的值添加到eax,然后将eax与内存位置rbp的值进行比较 如果说这是响应C 代码而生成的程序集,难道不正确吗? 我在问题中没有看到 C 语言的语法中的任何内容,也没有提到 C。为什么 C 标记在那里?您想显示作为该程序集源的 C 代码吗?如果是这样的话。 愚蠢的 AT&T 语法一如既往地令人困惑。 add -0x4(%rbp),%eax 在 Intel 语法中是 add eax, [rbp-4],这意味着“将存储在 rbp-4 中的值添加到 eax”(在伪 C 中,它类似于 eax+=*((uint32_t *)rbp-1) - -1 而不是 -4,因为指针算术是如何工作的在 C)。长话短说,它只是添加一个局部变量的值(记住:根据经验,rbp+something 是堆栈上的参数,rbp-something 是本地变量)。 【参考方案1】:

粗略阅读,整个事情归结为:

void read_six_numbers(const char *sz, int numbers[6]) 
    // the format string is inferred from the context,
    // to see its actual value you should look at 0x402871
    if(sscanf(sz, "%d %d %d %d %d %d", &numbers[0], &numbers[1], &numbers[2], &numbers[3], &numbers[4], &numbers[5])<6) explode_bomb();


void phase_2(const char *sz) 
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0] < 0) explode_bomb();
    for(int i=1; i!=6; ++i) 
        int a = i + numbers[i-1];
        if(numbers[i]!=a) explode_bomb();
    

可惜我现在在火车上,没有电脑,时间有限,稍后我会补充详细说明。来了!

注意:通过这篇文章,我将使用 Intel 表示法进行组装;它与您发布的内容不同,但是,至少在 IMO 中,它更具可读性和可理解性 - 除了 AT&T 符号中对符号的可怕品味之外,反转的操作数在许多指令中都是零意义的,尤其是算术指令和 cmp;此外,内存寻址的语法完全不可读。


read_six_numbers

让我们从简单的开始 - read_six_numbers;从phase_2中的代码可以看出

<+6>:     mov    rsi,rsp
<+9>:     call   0x40165a <read_six_numbers>

它在rsi 中接收一个参数,它是一个指向调用者堆栈中某物的指针。常规 SystemV 调用约定使用 rsi 作为 second 参数,read_six_numbers 读取为 rdi(隐含,我们稍后会看到)。所以我们可以假设phase_2 确实在rdi 中接收到一个参数并将其留在那里,直接将其传递给read_six_numbers

在它为本地人保留堆栈的“经典”序幕之后

<+0>:     sub    rsp,0x18

它继续将“调整后的”指针值加载到各种寄存器和堆栈中

<+4>:     mov    rdx,rsi
<+7>:     lea    rcx,[rsi+0x4]
<+11>:    lea    rax,[rsi+0x14]
<+15>:    mov    [rsp+0x8],rax
<+20>:    lea    rax,[rsi+0x10]
<+24>:    mov    [rsp],rax
<+28>:    lea    r9,[rsi+0xc]
<+32>:    lea    r8,[rsi+0x8]

如果你按照代码,你会看到最终结果是

rdx     <- rsi
rcx     <- rsi+4
r8      <- rsi+8
r9      <- rsi+12
[rsp]   <- rsi+16
[rsp+8] <- rsi+20

(不要让lea 欺骗你——尽管[] 语法会让你认为它正在访问括号内地址的内存,它实际上只是将该地址复制到左侧的操作数)

然后

<+36>:    mov    esi,0x402871
<+41>:    mov    eax,0x0
<+46>:    call   0x400c30 <__isoc99_sscanf@plt>

rsi 现在设置为某个固定地址1,考虑到它的地址以及它在rsi 中的事实(所以它会成为下面sscanf 的第二个参数)。

所以,这是 a regular x86_64 System V ABI variadic call 到 sscanf2 - 这需要将参数(按顺序)传递到 rdirsirdxrcx、@ 987654350@、r9 和堆栈中的其余部分,以及 ax 设置为存储在 XMM 寄存器中的浮点参数的数量(这里为零,因此 mov3)。

如果我们将迄今为止收集到的部分放在一起,我们可以推断:

rdi 是一个const char *,是要读取的六个数字的源字符串; rsi 包含一个指向至少六个 int 的数组的指针(看到它加载了偏移量为 4 的寄存器 - 又名 sizeof(int)?),它们是从 rdi 中的字符串中读取的; 很可能,如果我们查看0x402871,我们会看到类似"%d %d %d %d %d %d" 的内容。

所以,我们可以开始编写这个函数的暂定定义:

void read_six_numbers(const char *sz, int numbers[6]) 
    int eax = sscanf(sz, "%d %d %d %d %d %d",
            &numbers[0], &numbers[1], &numbers[2],
            &numbers[3], &numbers[4], &numbers[5]);
    ...

请注意,我在这里写numbers[6] 只是为了提醒我,对于C 语言,数组参数中的大小被忽略-它只是一个常规指针;另外,我将void 写为返回类型,因为我看到在调用代码中,调用此函数后似乎没有人对raxeax 感兴趣。

然后:

<+51>:    cmp    eax,0x5
<+54>:    jg     0x401697 <read_six_numbers+61>
<+56>:    call   0x401624 <explode_bomb>
<+61>:    add    rsp,0x18
<+65>:    ret

这里只是检查 sscanf 返回的值是否大于 5 - 即它是否设法读取所有必填字段;如果是,它会跳过对explode_bomb 的调用。我们可以用更人性化的方式重写它,比如

if(eax<6) explode_bomb();

然后,在 +61 和 +65 处有标准函数尾声(修复堆栈并返回)。

所以,总而言之,我们可以把整个事情写成

void read_six_numbers(const char *sz, int numbers[6]) 
    if(sscanf(sz, "%d %d %d %d %d %d",
            &numbers[0], &numbers[1], &numbers[2],
            &numbers[3], &numbers[4], &numbers[5] < 6) 
        explode_bomb();
    

收工。


phase_2

<+0>:     push   rbp
<+1>:     push   rbx
<+2>:     sub    rsp,0x28

通常的序幕; 40字节的局部变量,保存rbprbx,因为它们将被使用(并且都是被调用者保存的寄存器);请注意,这里的rbp 用作堆栈帧指针,而是用作“常规”寄存器。

<+6>:     mov    rsi,rsp
<+9>:     call   0x40165a <read_six_numbers>

调用read_six_numbers,隐式转发pase_2的第一个参数作为第一个参数或rdi中的read_six_numbers(我们收集的是必须解析的字符串),并将堆栈顶部传递为numbers 参数。

请记住,堆栈向下(=>向更小的地址)增长,而数组元素向向上(=>向更大的地址)增长,因此将rsp 传递为指向第一个数组元素的指针意味着后面的元素正确地位于刚刚使用上面的sub 分配的堆栈部分中。

从现在开始,请记住rsp 指向numbers 数组的第一个元素。

<+14>:    cmp    [rsp],0
<+18>:    jns    0x400f44 <phase_2+56>
<+20>:    call  0x401624 <explode_bomb>
<+25>:    jmp    0x400f44 <phase_2+56>

检查第一个数字([rsp] *numbers numbers[0])是否为负数;如果是这样,请跳过对explode_bomb 的调用。

(要了解它是如何工作的,请记住cmp 执行减法而不保存结果,但只保存与其对应的标志,因此[rsp]-0 是普通[rsp],而jns 表示j ump if not sign 位,所以如果cmp 的结果为非负数则跳转)

让我们试着推测一下到目前为止我们所拥有的:

ret_type? phase_2(const char *sz) 
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    ...

让我们暂时跳过 +27 和 +56 之间的部分,继续常规控制流程 - 直接到 +56:

<+56>:    lea    rbp,[rsp+4]
<+61>:    mov    ebx,1
<+66>:    jmp    0x400f27 <phase_2+27>

这里它用&amp;numbers[1] 加载rbp(记住numbers 的每个元素都是4 个字节大)和ebx1,然后,它跳回到+27。

<+27>:    mov    eax,ebx
<+29>:    add    eax,[rbp-4]
<+32>:    cmp    [rbp],eax
<+35>:    je     0x400f36 <phase_2+42>
<+37>:    call   0x401624 <explode_bomb>
<+42>:    add    ebx,1
<+45>:    add    rbp,4
<+49>:    cmp    ebx,6
<+52>:    jne    0x400f27 <phase_2+27>
<+54>:    jmp    0x400f50 <phase_2+68>

如果您快速浏览一下跳跃,您会发现:

+56 处的小块不再执行; [+27, +56) 之间的大块,我们在这个小块之后跳转的地方,有条件地重复(参见jne+52

这是一个很好的提示,这类似于for 循环,上面的小块是初始化,跳转前的部分是条件检查,而那些add 是增量。 ebx,经过初始化 (+61)、递增 (+42) 和检查 (+49),肯定看起来像一个计数器变量。

我们会回到这个;现在,让我们继续循环体:

<+27>:    mov    eax,ebx
<+29>:    add    eax,[rbp-4]
<+32>:    cmp    [rbp],eax
<+35>:    je     0x400f36 <phase_2+42>
<+37>:    call   0x401624 <explode_bomb>

将循环计数器复制到eax,然后将rbp 指向的数组元素之前 (-4) 中的值添加到其中。然后,将其与rbp当前指向的元素进行比较,如果不匹配则炸弹爆炸。

<+42>:    add    ebx,1
<+45>:    add    rbp,4

循环计数器 (ebx) 递增,rbp 移动到下一个 numbers 元素(递增阶段)

<+49>:    cmp    ebx,6
<+52>:    jne    0x400f27 <phase_2+27>
<+54>:    jmp    0x400f50 <phase_2+68>

如果循环计数器还没有达到 6(numbers 数组中的元素数),则冲洗并重复,否则跳转到函数的末尾。

<+68>:    add    rsp,0x28
<+72>:    pop    rbx
<+73>:    pop    rbp
<+74>:    ret

通常的清理:释放局部变量,恢复被破坏的寄存器,返回。

让我们试着总结一下:

void phase_2(const char *sz) 
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    int ebx = 1;
    int *rbp = &numbers[1];
    do 
        int eax = ebx + rbp[-1];
        if(eax != rbp[0]) explode_bomb();
        ++ebx;
        ++rbp;
     while(ebx!=6);

这并不是 C 程序员真正的写法。 do...while,虽然是汇编的直接翻译,但对于 C 程序员来说是很不自然的(尽管在循环之前检查条件在汇编中编写反而更自然)。

此外,所有带有rbp 的游戏都只是优化器的产物,它通过“继续”ebxrbp 来避免从索引中重新计算目标地址。总而言之,它可能写得更像:

void phase_2(const char *sz) 
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    for(int i=1; i<6; ++i) 
        int n = i + numbers[i-1];
        if(n != numbers[i]) explode_bomb();
    

...我们到了。

作为最后的检查,我们可以重新运行编译器,如果它是一个众所周知的编译器,它很可能会生成相同的程序集。

...事实上,这是由 gcc 4.9(和其他版本)在-O1 生成的the exact assembly。

有趣的是,将优化级别移动到 -O2,它将注释 3 中注释的 mov eax,0 更改为 xor eax,eax,因此这可能只是优化器在较低优化级别上草率工作的结果。


注意事项

    实际上它使用esi,但结果如前所述 - 该地址的前 32 位无论如何都是零,并且设置 64 位寄存器的 32 位部分会将寄存器的前 32 位清零。 可以做一个mov r64,imm64(事实上,这是少数几个你实际上可以有一个imm64值的情况之一),但这是一个巨大的instrution在这里没有任何收获。 __isoc99 部分可能是为了消除用于支持各种 C 标准修订的各种版本的 sscanf 之间的歧义,或者避免与由旧的、非 C99 兼容版本的 @987654438 导出的 sscanf 冲突/混淆@; plt 是一个中间蹦床,用于解析从共享库导入的函数。 通过一个巨大的 5 字节 moveax 寄存器清零仍然有点不寻常 - 通常,它是通过更紧凑的 2 字节 xor eax,eax 完成的;我不知道编译器选择这种编码是否有某些特殊原因 - 也许它更喜欢使用 5 字节 move 并保持对齐 call 而不是使用 2 字节 xor 然后是 @ 987654446@ 将call 对齐到4个字节,也许只是这是一个优化器没有触及的固定序列。 编辑:考虑到启用更高的优化器级别,它变成了xor eax,eax,我会说它只是一个优化器漏洞(或者可能在更高的优化级别,即使是那些罐头序列通过窥视孔优化器)。

【讨论】:

我会在早上阅读你的详细解释。感谢您的帮助。 没有。第一个不能是负数,其他的必须等于前一个 + 它们的索引 - 例如,element3(=第四个数字)必须等于 element2 + 3。顺便说一下,如您所见,我写了一步一步的解释。 哇。没想到会有这样的解释。伟大的工作,我确实弄清楚了序列 - 0 1 3 6 10 15。只是误读了循环。期待阅读您的解释。

以上是关于C 汇编循环的主要内容,如果未能解决你的问题,请参考以下文章

汇编 for循环

for 循环的反汇编浅析

汇编学习-分支与循环

使用 AND、OR、SHR 和 SHL 指令以及数组将循环从 x86 汇编转换为 C 语言

汇编代码 - 循环中的异或如何工作? [复制]

JVM优化之循环展开(附有详细的汇编代码)