C++ Compiler Explorer (Godbolt):在线分析汇编代码与优化

好的,各位观众老爷,欢迎来到今天的“扒光编译器内裤”特别节目!我是你们的老朋友,码农界的段子手,Bug界的克星,今天我们要聊的是一个神器,一个能让你和编译器“坦诚相见”的神器,它就是——C++ Compiler Explorer,俗称 Godbolt!

一、Godbolt是啥?为啥要用它?

Godbolt,听起来是不是像雷神的名字?但它跟雷神没啥关系,它是一个在线工具,能让你实时看到你的C++代码被编译器编译成汇编代码的样子。这玩意儿有啥用呢?想象一下:

  • 你想知道编译器到底是怎么优化你的代码的。 比如,你想看看循环展开、内联函数这些优化,编译器是不是真的做了?
  • 你想学习汇编语言。 没有比直接看C++代码对应的汇编代码更好的学习方式了。
  • 你想搞清楚一些C++特性的底层实现。 比如,虚函数是怎么实现的?Lambda表达式是怎么实现的?
  • 你想优化你的代码,让它跑得更快。 通过分析汇编代码,你可以找到性能瓶颈,然后对症下药。
  • 你想装逼。 对着汇编代码指点江山,那感觉,倍儿爽!

简单来说,Godbolt就是你的代码的“X光机”,让你看穿代码的本质,了解编译器的“小心思”。

二、Godbolt的基本用法:Hello, Assembly!

废话不多说,咱们先来个最简单的例子,体验一下Godbolt的魅力。

  1. 打开Godbolt: 在浏览器里输入 godbolt.org,你就进入了编译器探险乐园。

  2. 输入C++代码: 在左边的代码框里输入以下代码:

    #include <iostream>
    
    int main() {
        std::cout << "Hello, Assembly!" << std::endl;
        return 0;
    }
  3. 看汇编代码: 右边就会实时显示这段代码对应的汇编代码。是不是感觉像打开了新世界的大门?

    .LC0:
            .string "Hello, Assembly!"
    main:
            push    rbp
            mov     rbp, rsp
            sub     rsp, 16
            mov     esi, OFFSET FLAT:.LC0
            mov     edi, OFFSET FLAT:_ZSt4cout
            call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
            mov     edi, OFFSET FLAT:_ZSt4cout
            call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
            mov     eax, 0
            leave
            ret
  4. 高亮对应: 点击左边C++代码的某一行,右边对应的汇编代码就会高亮显示。这个功能非常有用,可以让你清楚地看到每一行C++代码对应哪些汇编指令。

三、Godbolt的进阶用法:编译选项、编译器选择、多窗口对比

Godbolt的功能远不止于此,它还提供了很多高级选项,让你更深入地探索编译器的世界。

  1. 选择编译器: Godbolt支持多种编译器,包括GCC、Clang、MSVC等等。你可以在界面左上角的编译器下拉菜单里选择你想要的编译器。不同的编译器,编译出来的汇编代码可能会有所不同。

  2. 编译选项: 你可以通过在编译器下拉菜单旁边的文本框里输入编译选项来控制编译器的行为。例如,-O3 表示开启最高级别的优化,-g 表示生成调试信息,-std=c++17 表示使用C++17标准。

    编译选项 含义
    -O0 关闭优化。这是默认选项,生成的汇编代码最容易理解,但性能最差。
    -O1 开启基本优化。编译器会进行一些简单的优化,例如常量折叠、死代码消除等等。
    -O2 开启更高级别的优化。编译器会进行更复杂的优化,例如循环展开、内联函数等等。
    -O3 开启最高级别的优化。编译器会尽其所能地优化代码,但可能会增加编译时间和代码大小。
    -Os 优化代码大小。编译器会尽量生成体积更小的代码,但可能会牺牲一些性能。
    -g 生成调试信息。这会增加编译后的文件大小,但可以让你在调试时更容易地找到问题。
    -std=c++11, -std=c++14, -std=c++17, -std=c++20 指定C++标准。你可以选择使用不同的C++标准来编译你的代码。
    -Wall 开启所有警告。这可以帮助你发现代码中潜在的问题。
    -Werror 将警告视为错误。如果你的代码有任何警告,编译将会失败。

    举个例子,我们可以用 -O3 看看编译器在最高优化级别下会怎么处理我们的代码:

    #include <iostream>
    
    int main() {
        int sum = 0;
        for (int i = 0; i < 10; ++i) {
            sum += i;
        }
        std::cout << "Sum: " << sum << std::endl;
        return 0;
    }

    加上 -O3 之后,汇编代码会变得非常简洁,因为编译器直接把循环展开,算出了结果:

    .LC0:
            .string "Sum: "
    main:
            push    rbp
            mov     rbp, rsp
            mov     esi, OFFSET FLAT:.LC0
            mov     edi, OFFSET FLAT:_ZSt4cout
            call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
            mov     esi, 45
            mov     edi, OFFSET FLAT:_ZSt4cout
            call    std::basic_ostream<char, std::char_traits<char> >& std::operator<< <int, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, int)
            mov     edi, OFFSET FLAT:_ZSt4cout
            call    std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
            mov     eax, 0
            pop     rbp
            ret

    看到没?编译器直接把 sum 的值算出来了,根本没有循环!这就是优化的力量!

  3. 添加汇编指令窗口: Godbolt允许你添加汇编指令窗口,直接在代码中插入汇编指令。这个功能对于理解底层原理非常有用。

    例如,我们可以用汇编指令来手动计算 sum 的值:

    #include <iostream>
    
    int main() {
        int sum = 0;
        // 手动计算 sum 的值
        asm (
            "mov $0, %eaxn"  // 初始化 sum = 0
            "mov $0, %ecxn"  // 初始化 i = 0
            "loop:n"
            "add %ecx, %eaxn" // sum += i
            "inc %ecxn"      // i++
            "cmp $10, %ecxn" // i < 10?
            "jl loopn"        // 如果 i < 10,跳转到 loop
            "mov %eax, %0n"  // 将 sum 的值保存到 sum 变量
            : "=r" (sum)      // 输出操作数:sum 变量
            :                  // 输入操作数:无
            : "%eax", "%ecx"   // clobber list:eax 和 ecx 寄存器被修改
        );
        std::cout << "Sum: " << sum << std::endl;
        return 0;
    }

    这段代码的汇编部分可能会让你觉得有点晕,但它可以让你更深入地了解汇编指令的用法。

  4. 多窗口对比: Godbolt允许你创建多个代码窗口,并排显示。这对于比较不同编译器、不同编译选项下的汇编代码非常方便。

    你可以点击界面右上角的“Add new…”按钮来创建新的代码窗口。

四、实战演练:分析C++特性

光说不练假把式,咱们来几个实战例子,用Godbolt分析一些C++特性。

  1. 虚函数:

    #include <iostream>
    
    class Base {
    public:
        virtual void foo() {
            std::cout << "Base::foo()" << std::endl;
        }
    };
    
    class Derived : public Base {
    public:
        void foo() override {
            std::cout << "Derived::foo()" << std::endl;
        }
    };
    
    int main() {
        Base* obj = new Derived();
        obj->foo(); // 调用哪个 foo()?
        delete obj;
        return 0;
    }

    这段代码的关键在于 obj->foo() 到底调用的是 Base::foo() 还是 Derived::foo()?答案是 Derived::foo(),因为 foo() 是虚函数。

    通过Godbolt,我们可以看到,编译器会为包含虚函数的类创建一个虚函数表(vtable),vtable中存储了虚函数的地址。当调用虚函数时,编译器会通过vtable来找到实际要调用的函数。

    在汇编代码中,你可以找到类似这样的代码:

    mov     rax, QWORD PTR [rdi]  ; 获取对象的 vtable 指针
    mov     rax, QWORD PTR [rax]  ; 获取 vtable 中第一个函数的地址(foo() 的地址)
    call    rax                  ; 调用 foo()

    这段代码先获取对象的 vtable 指针,然后从 vtable 中找到 foo() 函数的地址,最后调用 foo() 函数。

  2. Lambda表达式:

    #include <iostream>
    #include <algorithm>
    #include <vector>
    
    int main() {
        std::vector<int> numbers = {1, 2, 3, 4, 5};
        int threshold = 3;
    
        // 使用 Lambda 表达式过滤 numbers
        std::vector<int> filteredNumbers;
        std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(filteredNumbers),
                     [threshold](int n) { return n > threshold; });
    
        for (int num : filteredNumbers) {
            std::cout << num << " ";
        }
        std::cout << std::endl;
    
        return 0;
    }

    Lambda表达式本质上是一个匿名函数对象。编译器会根据Lambda表达式生成一个类,这个类重载了 operator(),实现了Lambda表达式的功能。

    通过Godbolt,我们可以看到编译器生成的这个类的代码。你会发现,Lambda表达式捕获的变量会被作为类的成员变量存储起来。

  3. 内联函数:

    #include <iostream>
    
    inline int add(int a, int b) {
        return a + b;
    }
    
    int main() {
        int x = 10;
        int y = 20;
        int sum = add(x, y);
        std::cout << "Sum: " << sum << std::endl;
        return 0;
    }

    内联函数的作用是告诉编译器,尽量把函数调用替换成函数体本身,从而减少函数调用的开销。

    通过Godbolt,我们可以看到,如果编译器决定内联 add() 函数,那么汇编代码中就不会有 call add 这样的指令,而是直接把 add() 函数的代码插入到 main() 函数中。

五、Godbolt的局限性

虽然Godbolt很强大,但它也有一些局限性:

  • 只能看汇编代码,不能调试。 Godbolt只能让你看到代码被编译成什么样子,但不能让你像在调试器里一样单步执行代码。
  • 编译环境可能与你的实际环境不同。 Godbolt使用的编译器和编译选项可能与你的实际环境不同,因此编译结果可能会有所差异。
  • 汇编代码比较难懂。 如果你不熟悉汇编语言,那么看汇编代码可能会让你感到头疼。

六、总结:Godbolt,你的编译器“老中医”

Godbolt是一个非常强大的工具,它可以让你深入了解编译器的行为,学习汇编语言,优化你的代码。虽然它有一些局限性,但它仍然是每个C++程序员必备的“老中医”,帮你诊断代码的“疑难杂症”。

希望今天的节目能让你对Godbolt有一个更深入的了解。记住,下次你想知道编译器在搞什么鬼的时候,就打开Godbolt,让它帮你“扒光编译器的内裤”!

感谢各位的观看,咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注