65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (15投票s)

2017年4月15日

CPOL

9分钟阅读

viewsIcon

26719

downloadIcon

234

一篇关于 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

   Image 1: the Assembly With Source Code option

当您编译ShiftAndTest.cpp时,一个名为ShiftAndTest.asm的汇编文件将在DebugRelease文件夹中生成。使用此选项,汇编列表包含原始 C++ 代码行,并带有行号供您参考,这些行用分号注释,您很快就会看到。

我在 Windows 系统上使用 Visual Studio 和 MASM 汇编器,只是为了CSCI 241,汇编语言编程课程中的学术便利。由于汇编语言编程不可移植,您可以在其他平台上找到类似的工具。一个功能强大的在线编译器(和反汇编器)可以在gcc.godbolt.org找到,用于 GCC/Clang,具有许多有用的选项,适用于 ARM 或 x86。您绝对可以尝试生成类似的汇编列表。

位运算符和逻辑运算符

我们通常都知道运算符 &&& 的规范。两者都是二元运算符,带有两个操作数来执行布尔 AND 逻辑,如Cplusplus.comWikipedia.org在线解释的那样。我们也理解它们之间的区别:运算符 & 的结果是通过对两个整数的每个位(01)进行 AND 运算来计算的,而 && 只将两个操作数作为由零或非零表示的 falsetrue

此外,运算符 &&& 的操作数可以是必须事先求值的表达式。在这种情况下,让我们考虑以下用于 &&& 且具有相同表达式操作数的代码片段

   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);

有什么不同?你可能会卡住一会儿。这是输出

   Image 2: the console output for operator AND

它提醒你短路求值的概念。为了查看操作符实现中发生了什么,我生成了如下汇编程序输出

; 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

这段代码创建并初始化了 ijk。这三个局部变量很容易识别,因为它们在堆栈帧上使用了相同的名称。

从输出中可以看出,& 运算符使 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 中执行 ij 并将其赋值给 k。然后它执行第二个 SUB 进行 i5AND 指令用于执行布尔位逻辑。由于我们只需要变量 b 的逻辑结果 truefalse,因此代码使用 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

由于 && 仅用于逻辑 truefalse,因此不需要位 AND 指令。代码首先执行 i5 并使用 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);

控制台输出为

   Image 3: the console output for operator OR

至于位运算符 |,代码首先在 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) );

由于一个字节占用两个十六进制数字,所以用十六进制格式化的结果可以是

   Image 4: the console output for Reverse Bytes

一种解决方案是简单地使用位右移和左移运算符两次

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 被移动到 EAXECX 中以执行不同的移位,然后将它们在 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日 -- 发布原始版本
© . All rights reserved.