- 第一章 CPU 简介
- 第二章 Hello,world!
- 第三章 函数开始和结束
- 第四章 栈
- Chapter 5 printf() 与参数处理
- Chapter 6 scanf()
- CHAPER7 访问传递参数
- Chapter 8 一个或者多个字的返回值
- Chapter 9 指针
- Chapter 10 条件跳转
- 第 11 章 选择结构 switch()/case/default
- 第 12 章 循环结构
- 第 13 章 strlen()
- Chapter 14 Division by 9
- chapter 15 用 FPU 工作
- Chapter 16 数组
- Chapter 17 位域
- 第 18 章 结构体
- 19 章 联合体
- 第二十章 函数指针
- 第 21 章 在 32 位环境中的 64 位值
- 第二十二章 SIMD
- 23 章 64 位化
- 24 章 使用 x64 下的 SIMD 来处理浮点数
- 25 章 温度转换
- 26 章 C99 的限制
- 27 章 内联函数
- 第 28 章 得到不正确反汇编结果
- 第 29 章 花指令
- 第 30 章 16 位 Windows
- 第 31 章 类
- 三十二 ostream
- 34.2.2 MSVC
- 34.2.3 C++ 11 std::forward_list
- 34.3 std::vector
- 34.4 std::map and std::set
13.1 x86
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,然后将它作为返回值返回,退出函数。
图 13.1: 第一次循环迭代起始位置
图 13.2:第二次循环迭代开始位置
图 13.3: 现在要计算二者的差了
图 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 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论