通过分析汇编输出理解 C/C++ 代码行为






4.96/5 (15投票s)
一篇关于 Visual C/C++ 代码如何与位运算符和移位运算符以及循环优化一起工作的讨论。
目录
引言
C/C++ 标准定义了语言元素的规范,例如语法和文法,但它没有严格指出如何实现运算符或语句。每个软件制造商都可以自由使用任何实现,只要它符合 C/C++ 标准。然而,实现与程序在运行时执行的性能密切相关。我之前的文章你可能不知道的 C/C++ 中 Switch 语句提供了一个示例,通过汇编输出分析 C/C++ switch
语句的行为和效率。
本文将通过分析 Visual Studio 在示例中生成的汇编输出来讨论一些 C/C++ 运算符。首先,我将向您展示如何理解位运算符 &
和逻辑运算符 &&
之间的概念差异。其次,是一个关于左位移运算符 <<
和右位移运算符 >>
的具体示例,让您了解它们在调试和发布构建中是如何不同实现的。
我们将看到 Visual C/C++ 代码背后发生了什么。我们使用 Microsoft Visual Studio IDE,因为它可以在编译时生成相应的汇编列表。为了阅读本文,需要对 Intel x86 汇编指令和Microsoft MASM 规范有大致的了解。正如您很快会看到的,这里所有的讨论都基于一种“逆向工程”,因此,本文绝不是编译器实现的准确描述;但它可以作为学习汇编语言编程的额外学习材料。
要在 Visual Studio 中生成汇编程序输出,请打开 .CPP 文件属性对话框,选择“所有配置”,然后在左侧 C/C++ 下拉菜单下,选择“输出文件”类别。在右侧窗格中,选择“带源代码的汇编 (/FAs)”选项,如下所示用于ShiftAndTest.cpp
当您编译ShiftAndTest.cpp时,一个名为ShiftAndTest.asm的汇编文件将在Debug或Release文件夹中生成。使用此选项,汇编列表包含原始 C++ 代码行,并带有行号供您参考,这些行用分号注释,您很快就会看到。
我在 Windows 系统上使用 Visual Studio 和 MASM 汇编器,只是为了CSCI 241,汇编语言编程课程中的学术便利。由于汇编语言编程不可移植,您可以在其他平台上找到类似的工具。一个功能强大的在线编译器(和反汇编器)可以在gcc.godbolt.org找到,用于 GCC/Clang,具有许多有用的选项,适用于 ARM 或 x86。您绝对可以尝试生成类似的汇编列表。
位运算符和逻辑运算符
我们通常都知道运算符 &
和 &&
的规范。两者都是二元运算符,带有两个操作数来执行布尔 AND
逻辑,如Cplusplus.com或Wikipedia.org在线解释的那样。我们也理解它们之间的区别:运算符 &
的结果是通过对两个整数的每个位(0
或 1
)进行 AND
运算来计算的,而 &&
只将两个操作数作为由零或非零表示的 false
和 true
。
此外,运算符 &
或 &&
的操作数可以是必须事先求值的表达式。在这种情况下,让我们考虑以下用于 &
和 &&
且具有相同表达式操作数的代码片段
int i =5, j =3, k;
bool b;
k =7;
b = (i-5) & (k=i-j);
printf("Operator &, b=%d, k=%d\n", b, k);
k =7;
b = (i-5) && (k=i-j);
printf("Operator &&, b=%d, k=%d\n", b, k);
有什么不同?你可能会卡住一会儿。这是输出
它提醒你短路求值的概念。为了查看操作符实现中发生了什么,我生成了如下汇编程序输出
; 17 : int i =5, j =3, k;
mov DWORD PTR _i$[ebp], 5
mov DWORD PTR _j$[ebp], 3
; 18 : bool b;
; 19 : k =7;
mov DWORD PTR _k$[ebp], 7
这段代码创建并初始化了 i
、j
和 k
。这三个局部变量很容易识别,因为它们在堆栈帧上使用了相同的名称。
从输出中可以看出,&
运算符使 k
=2
,即 k
=i
-j
。这意味着 &
运算符对两个表达式执行完整求值,不使用短路,即使左侧的 i
-5
为零,右侧的 k
=i
-j
仍然执行。以下代码揭示了详细信息
; 20 : b = (i-5) & (k=i-j);
mov eax, DWORD PTR _i$[ebp]
sub eax, DWORD PTR _j$[ebp]
mov DWORD PTR _k$[ebp], eax
mov ecx, DWORD PTR _i$[ebp]
sub ecx, 5
and ecx, DWORD PTR _k$[ebp]
setne dl
mov BYTE PTR _b$[ebp], dl
该代码只是在 EAX
中执行 i
– j
并将其赋值给 k
。然后它执行第二个 SUB
进行 i
– 5
。AND
指令用于执行布尔位逻辑。由于我们只需要变量 b
的逻辑结果 true
或 false
,因此代码使用 SETNE
通过 DL
检查零标志。有趣的是,即使改变两个表达式的顺序,您仍然会得到相同的汇编代码
b = (k=i-j) & (i-5);
现在让我们看一下运算符 &&
的输出,其结果为 k
=7
。这意味着 k
未改变,没有执行 i
-j
。运算符 &&
执行短路评估,因为左侧 i
-5
为零,右侧 k
=i
-j
被跳过。以下是这样的实现
; 22 : k =7;
mov DWORD PTR _k$[ebp], 7
; 23 : b = (i-5) && (k=i-j);
mov eax, DWORD PTR _i$[ebp]
sub eax, 5
je SHORT $LN3@main
mov ecx, DWORD PTR _i$[ebp]
sub ecx, DWORD PTR _j$[ebp]
mov DWORD PTR _k$[ebp], ecx
je SHORT $LN3@main
mov DWORD PTR tv77[ebp], 1
jmp SHORT $LN4@main
$LN3@main:
mov DWORD PTR tv77[ebp], 0
$LN4@main:
mov dl, BYTE PTR tv77[ebp]
mov BYTE PTR _b$[ebp], dl
由于 &&
仅用于逻辑 true
或 false
,因此不需要位 AND
指令。代码首先执行 i
– 5
并使用 JE
检查零标志。如果找到零,它会跳转到标签 $LN3@main
返回零作为 false
。否则,它会继续评估右操作数 k
=i
-j
并检查第二个条件以获得变量 b
的最终结果。
另一个有趣的练习是改变条件的顺序,以验证汇编输出中两个表达式是否都得到了评估
b = (k=i-j) && (i-5);
现在,位运算符 |
和逻辑运算符 ||
之间也可以进行类似的比较。请看这个例子
k =7;
b = (i-j) | ++k;
printf("Operator |, b=%d, k=%d\n", b, k);
k =7;
b = (i-j) || ++k;
printf("Operator ||, b=%d, k=%d\n", b, k);
控制台输出为
至于位运算符 |
,代码首先在 EAX
中无条件执行 ++k
并将其保存回变量 k
。然后计算 i
-j
并将其放入 ECX
中以用于 OR
指令
; 26 : k =7;
mov DWORD PTR _k$[ebp], 7
; 27 : b = (i-j) | ++k;
mov eax, DWORD PTR _k$[ebp]
add eax, 1
mov DWORD PTR _k$[ebp], eax
mov ecx, DWORD PTR _i$[ebp]
sub ecx, DWORD PTR _j$[ebp]
or ecx, DWORD PTR _k$[ebp]
setne dl
mov BYTE PTR _b$[ebp], dl
对于逻辑 ||
,首先计算 i
-j
并检查其是否非零。只有当 JNE
不满足时,才会计算右侧的 ++k
。在我们的测试中,i
-j
非零,因此 ++k
被忽略,因为实现了短路
; 29 : k =7;
mov DWORD PTR _k$[ebp], 7
; 30 : b = (i-j) || ++k;
mov eax, DWORD PTR _i$[ebp]
sub eax, DWORD PTR _j$[ebp]
jne SHORT $LN5@main
mov ecx, DWORD PTR _k$[ebp]
add ecx, 1
mov DWORD PTR _k$[ebp], ecx
jne SHORT $LN5@main
mov DWORD PTR tv128[ebp], 0
jmp SHORT $LN6@main
$LN5@main:
mov DWORD PTR tv128[ebp], 1
$LN6@main:
mov dl, BYTE PTR tv128[ebp]
mov BYTE PTR _b$[ebp], dl
在此示例中,我在调试构建中生成了上述汇编程序输出。通常,在调试中生成的代码更易读和理解,但效率可能较低。从发布构建生成的代码经过优化,以最大限度地提高速度或减小大小。它们可能难以阅读但效率更高。
有时,生成的汇编列表并不能完全反映实现高级语言代码的整体逻辑,因为部分逻辑可能在预处理或其他地方实现。一个智能的编译器可能会将责任划分到汇编时而不是宝贵的运行时。例如,您可以阅读您可能不知道的 C/C++ 中 Switch 语句中的“使用二分查找”部分,我在其中讨论了 switch
/case
块的一种情况。
位移位运算符
在本节中,我将使用一个示例来反转 DWORD
(32 位无符号整数) 中的字节,通过调用一个名为 ReverseBytes()
的 C 函数,如下所示
printf("Reverse Bytes:\n");
int n = 0x12345678;
printf("1. 32-bit Hexadecimal: %Xh\n", n);
printf("2. Now Reverse Bytes: %Xh\n", n =ReverseBytes(n) );
printf("3. Again, Resume now: %Xh\n", ReverseBytes(n) );
由于一个字节占用两个十六进制数字,所以用十六进制格式化的结果可以是
一种解决方案是简单地使用位右移和左移运算符两次
unsigned int ReverseBytes(unsigned x)
{
x = (x & 0xffff0000) >> 16 | (x & 0x0000ffff) << 16;
x = (x & 0xff00ff00) >> 8 | (x & 0x00ff00ff) << 8;
return x;
}
对于我们的测试数据 12345678h
,第一个语句左移 16 位会得到这个结果
x
= 00001234h | 56780000h
= 56781234h
第二个语句移位 8 位会产生结果
x
= 00560012h | 78003400h
= 78563412h
x86 指令集提供了强大的移位和旋转指令来实现了 C 位移位逻辑。让我们首先看看从调试构建生成的汇编代码。为了集中讨论我们的主题,我只简单地截取了 C 语句实现的三行,这里不包括堆栈帧的序言和尾声
; 9 : x = ((x & 0xffff0000) >> 16) | ((x & 0x0000ffff) << 16);
mov eax, DWORD PTR _x$[ebp]
and eax, -65536 ; ffff0000H
shr eax, 16 ; 00000010H
mov ecx, DWORD PTR _x$[ebp]
and ecx, 65535 ; 0000ffffH
shl ecx, 16 ; 00000010H
or eax, ecx
mov DWORD PTR _x$[ebp], eax
; 10 : x = ((x & 0xff00ff00) >> 8) | ((x & 0x00ff00ff) << 8);
mov eax, DWORD PTR _x$[ebp]
and eax, -16711936 ; ff00ff00H
shr eax, 8
mov ecx, DWORD PTR _x$[ebp]
and ecx, 16711935 ; 00ff00ffH
shl ecx, 8
or eax, ecx
mov DWORD PTR _x$[ebp], eax
; 11 : return x;
mov eax, DWORD PTR _x$[ebp]
以上两个汇编块在逻辑上完美地映射了两个 C 语句。SHR
指令用于 >>
运算符的右移,而 SHL
用于 <<
的左移。只有由内存 _x$[ebp]
表示的变量 x
被移动到 EAX
和 ECX
中以执行不同的移位,然后将它们在 EAX
中进行 OR
运算,结果再保存回 _x$[ebp]
。两个块的区别仅仅是两个指定的移位掩码,分别为 16 或 8 位。
上述逻辑清晰易读,有助于理解汇编指令如何一步步工作,因为一个 C 语言语句对应着多条汇编指令的详细实现。然而,该代码可能效率不高。让我们看看从发布构建生成的汇编代码,如下所示
; _x$ = ecx
; 9 : x = (x & 0xffff0000) >> 16 | (x & 0x0000ffff) << 16;
rol ecx, 16 ; 00000010H
mov eax, ecx
; 10 : x = (x & 0xff00ff00) >> 8 | (x & 0x00ff00ff) << 8;
; 11 : return x;
mov edx, ecx
shr eax, 8
shl edx, 8
xor eax, edx
and eax, 16711935 ; 00ff00ffH
shl ecx, 8
xor eax, ecx
令人惊讶的是,首先,它没有像以前的内存参数 EBP
那样使用 x
。直接使用寄存器 ECX
来表示 x
会快得多。接下来,它利用旋转指令 ROL
,大大简化了 16 位交换以获得中间结果 56781234h
。
然而,在第二个块中,代码右移 8 位使 EAX
中为 00567812h
,左移 8 位使 EDX
中为 78123400h
。理解的难点是两个 XOR
指令。作为学习练习,您可以跟踪代码以验证结果。思考它们实际在做什么以及最终如何工作。当得到结果时,EAX
中的值 78563412h
正是您期望返回的值。
尽管第二个块的逻辑不易理解,但第一个块的 16 位移位策略确实很棒且具有启发性。我们能从这个简洁的 ROL
指令中获得一些提示,以实现简单的交换吗?当然,请看这个
12345678h => 12347856h => 78561234h =>78563412h
哇,这个想法更简单,更容易理解,甚至比发布构建的还要好。我创建了一个这样的解决方案,仅仅通过三个 ROL
指令。假设 EAX
初始化为 12345678h
,请尝试这个
rol ax, 8h
rol eax, 10h
rol ax, 8h
实际上,要在多字节整数中交换字节,x86-64 中只需一条指令 BSWAP,与任何高级语言相比都非常强大。
摘要
我讨论了与 C 运算符交互的汇编语言实现中的各种特性。要理解这些示例,实际上需要您对 x86 指令集和 MASM 规范有丰富的背景知识。通过仔细研究汇编代码列表,我揭示了一些有趣的 C/C++ 代码在运行时的行为。要在 Visual Studio 中分析 C/C++ 程序,我们可以使用编译器生成的静态汇编列表,也可以使用 VS 调试反汇编器进行动态执行。在本文中,我使用了汇编列表,因为我必须比较 C/C++ 代码在调试和发布汇编输出中的行为。可下载的 zip 文件包含项目ShiftAndTest,其中包括 VS 2010 中的 .CPP 源代码和 .ASM 列表。您可以使用任何最新的 VS IDE 自己生成相同的列表。
我们知道,理解高级语言与汇编语言之间的对应关系是我们在学习基于一对多关系实现的低级实现时的重要课题。这就是为什么我们必须深入研究汇编程序输出,以教育目的来窥探和检测 C/C++ 代码行为。汇编语言以其指令与其机器代码之间的一一对应关系而著称。通过汇编代码,您可以更接近机器的核心,研究指令执行和性能。汇编语言编程在学术研究和工业开发中都扮演着重要的角色。我希望本文能为学生学习汇编编程,甚至为开发人员提供一些示例。欢迎提出任何意见和建议。
历史
- 2021年3月28日 -- 删除第三节
- 2017年4月14日 -- 发布原始版本