返回介绍

13.1 x86

发布于 2025-02-22 14:00:44 字数 6474 浏览 0 评论 0 收藏 0

13.1.1 无优化的 MSVC

让我们编译一下:

#!bash
_eos$ = -4                  ; size = 4
_str$ = 8                   ; size = 4
_strlen PROC
    push    ebp
    mov     ebp, esp
    push    ecx
    mov     eax, DWORD PTR _str$[ebp]   ; place pointer to string from str
    mov     DWORD PTR _eos$[ebp], eax   ; place it to local varuable eos
$LN2@strlen_:
    mov     ecx, DWORD PTR _eos$[ebp]   ; ECX=eos

    ; take 8-bit byte from address in ECX and place it as 32-bit value to EDX with sign extension

    movsx   edx, BYTE PTR [ecx]
    mov     eax, DWORD PTR _eos$[ebp]   ; EAX=eos
    add     eax, 1 ; increment EAX
    mov     DWORD PTR _eos$[ebp], eax   ; place EAX back to eos
    test    edx, edx                    ; EDX is zero?
    je      SHORT $LN1@strlen_          ; yes, then finish loop
    jmp     SHORT $LN2@strlen_          ; continue loop
$LN1@strlen_:

    ; here we calculate the difference between two pointers

    mov     eax, DWORD PTR _eos$[ebp]
    sub     eax, DWORD PTR _str$[ebp]
    sub     eax, 1                      ; subtract 1 and return result
    mov     esp, ebp
    pop     ebp
    ret     0
_strlen_ ENDP

我们看到了两个新的指令:MOVSX(见 13.1.1 节)和 TEST。

关于第一个:MOVSX 用来从内存中取出字节然后把它放到一个 32 位寄存器中。MOVSX 意味着 MOV with Sign-Extent(带符号扩展的 MOV 操作)。MOVSX 操作下,如果复制源是负数,从第 8 到第 31 的位将被设为 1,否则将被设为 0。

现在解释一下为什么要这么做。

C/C++标准将 char(译注:1 字节)类型定义为有符号的。如果我们有 2 个值,一个是 char,另一个是 int(int 也是有符号的),而且它的初值是-2(被编码为 0xFE),我们将这个值拷贝到 int(译注:一般是 4 字节)中时,int 的值将是 0x000000FE,这时,int 的值将是 254 而不是-2。因为在有符号数中,-2 被编码为 0xFFFFFFFE。 所以,如果我们需要将 0xFE 从 char 类型转换为 int 类型,那么,我们就需要识别它的符号并扩展它。这就是 MOVSX 所做的事情。

请参见章节“有符号数表示方法”。(35 章)

我不太确定编译器是否需要将 char 变量存储在 EDX 中,它可以使用其中 8 位(我的意思是 DL 部分)。显然,编译器的寄存器分配器就是这么工作的。

然后我们可以看到 TEST EDX, EDX。关于 TEST 指令,你可以阅读一下位这一节(17 章)。但是现在我想说的是,这个 TEST 指令只是检查 EDX 的值是否等于 0。

13.1.2 无优化的 GCC

让我们在 GCC 4.4.1 下测试:

#!bash
        public strlen
strlen  proc near

eos     = dword ptr -4
arg_0   = dword ptr 8

        push    ebp
        mov     ebp, esp
        sub     esp, 10h
        mov     eax, [ebp+arg_0]
        mov     [ebp+eos], eax

loc_80483F0:
        mov     eax, [ebp+eos]
        movzx   eax, byte ptr [eax]
        test    al, al
        setnz   al
        add     [ebp+eos], 1
        test    al, al
        jnz     short loc_80483F0
        mov     edx, [ebp+eos]
        mov     eax, [ebp+arg_0]
        mov     ecx, edx
        sub     ecx, eax
        mov     eax, ecx
        sub     eax, 1
        leave
        retn
strlen  endp

可以看到它的结果和 MSVC 几乎相同,但是这儿我们可以看到它用 MOVZX 代替了 MOVSX。 MOVZX 代表着 MOV with Zero-Extend(0 位扩展 MOV)。这个指令将 8 位或者 16 位的值拷贝到 32 位寄存器,然后将剩余位设置为 0。事实上,这个指令比较方便的原因是它将两条指令组合到了一起:xor eax,eax / mov al, [...]。

另一方面来说,显然这里编译器可以产生如下代码: mov al, byte ptr [eax] / test al, al,这几乎是一样的,但是,EAX 高位将还是会有随机的数值存在。 但是我们想一想就知道了,这正是编译器的劣势所在——它不能产生更多能让人容易理解的代码。严格的说, 事实上编译器也并没有义务为人类产生易于理解的代码。

还有一个新指令,SETNZ。这里,如果 AL 包含非 0, test al, al 将设置 ZF 标记位为 0。 但是 SETNZ 中,如果 ZF == 0(NZ 的意思是非零,Not Zero),AL 将设置为 1。用自然语言描述一下,如果 AL 非 0,我们就跳转到 loc_80483F0。编译器生成了少量的冗余代码,不过不要忘了我们已经把优化给关了。

13.1.3 优化后的 MSVC

让我们在 MSVC 2012 下编译,打开优化选项/Ox:

清单 13.1: MSVC 2010 /Ox /Ob0

#!bash
_str$ = 8               ; size = 4
_strlen     PROC
            mov     edx, DWORD PTR _str$[esp-4] ; EDX -> 指向字符的指针
            mov     eax, edx                    ; 移动到 EAX
$LL2@strlen:
            mov     cl, BYTE PTR [eax]          ; CL = *EAX
            inc     eax                         ; EAX++
            test    cl, cl                      ; CL==0?
            jne     SHORT $LL2@strlen           ; 否,继续循环
            sub     eax, edx                    ; 计算指针差异
            dec     eax                         ; 递减 EAX
            ret     0
_strlen ENDP

现在看起来就更简单点了。但是没有必要去说编译器能在这么小的函数里面,如此有效率的使用如此少的本地变量,特殊情况而已。

INC / DEC 是递增 / 递减指令,或者换句话说,给变量加一或者减一。

13.1.4 优化后的 MSVC + OllyDbg

我们可以在 OllyDbg 中试试这个(优化过的)例子。这儿有一个简单的最初的初始化:图 13.1。 我们可以看到 OllyDbg

找到了一个循环,然后为了方便观看,OllyDbg 把它们环绕在一个方格区域中了。在 EAX 上右键点击,我们可以选择“Follow in Dump”,然后内存窗口的位置将会跳转到对应位置。我们可以在内存中看到这里有一个“hello!”的字符串。 在它之后至少有一个 0 字节,然后就是随机的数据。 如果 OllyDbg 发现了一个寄存器是一个指向字符串的指针,那么它会显示这个字符串。

让我们按下 F8(步过)多次,我们可以看到当前地址的游标将在循环体中回到开始的地方:图 13.2。我们可以看到 EAX 现在包含有字符串的第二个字符。

我们继续按 F8,然后执行完整个循环:图 13.3。我们可以看到 EAX 现在包含空字符()的地址,也就是字符串的末尾。同时,EDX 并没有改变,所以它还是指向字符串的最开始的地方。现在它就可以计算这两个寄存器的差值了。

然后 SUB 指令会被执行:图 13.4。 差值保存在 EAX 中,为 7。 但是,字符串“hello!”的长度是 6,这儿 7 是因为包含了末尾的。但是 strlen()函数必须返回非 0 部分字符串的长度,所以在最后还是要给 EAX 减去 1,然后将它作为返回值返回,退出函数。

enter image description here

图 13.1: 第一次循环迭代起始位置

enter image description here

图 13.2:第二次循环迭代开始位置

enter image description here

图 13.3: 现在要计算二者的差了

enter image description here

图 13.4: EAX 需要减一

13.1.5 优化过的 GCC

让我们打开 GCC 4.4.1 的编译优化选项(-O3):

#!bash
        public strlen
strlen  proc near

arg_0   = dword ptr 8

        push    ebp
        mov     ebp, esp
        mov     ecx, [ebp+arg_0]
        mov     eax, ecx

loc_8048418:
        movzx   edx, byte ptr [eax]
        add     eax, 1
        test    dl, dl
        jnz     short loc_8048418
        not     ecx
        add     eax, ecx
        pop     ebp
        retn
strlen endp

这儿 GCC 和 MSVC 的表现方式几乎一样,除了 MOVZX 的表达方式。

但是,这里的 MOVZX 可能被替换为 mov dl, byte ptr [eax]

可能是因为对 GCC 编译器来说,生成此种代码会让它更容易记住整个寄存器已经分配给 char 变量了,然后因此它就可以确认高位在任何时候都不会有任何干扰数据的存在了。

之后,我们可以看到新的操作符 NOT。这个操作符把操作数的所有位全部取反。可以说,它和 XOR ECX, 0fffffffh 效果是一样的。NOT 和接下来的 ADD 指令计算差值然后将结果减一。在最开始的 ECX 出存储了 str 的指针,翻转之后会将它的值减一。

请参考“有符号数的表达方式”。(第 35 章)

换句话说,在函数最后,也就是循环体后面其实是做了这样一个操作:

ecx=str;
eax=eos;
ecx=(-ecx)-1;
eax=eax+ecx
return eax

这样做其实几乎相等于:

ecx=str;
eax=eos;
eax=eax-ecx;
eax=eax-1;
return eax

为什么 GCC 会认为它更棒呢?我不能确定,但是我确定上下两种方式都应该有相同的效率。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文