C++ 内联汇编(Inline Assembly):直接操作 CPU 指令以实现极致性能

哈喽,各位好!今天咱们聊聊C++里的“秘密武器”——内联汇编。这玩意儿听起来玄乎,但用好了,能让你的代码直接跟CPU“对话”,榨干硬件的最后一滴性能。

啥是内联汇编?

简单说,就是在C++代码里嵌入汇编语言。想象一下,你的C++代码是一支乐队,大部分时候大家演奏的是通用乐器(高级语言),但有时候,你需要一个唢呐(汇编)来吹奏一些特别复杂或者精密的乐段,才能达到最佳效果。

为啥要用?因为有些操作,C++编译器优化起来力不从心,或者根本就没提供相应的接口。这时候,直接写汇编,就能精准控制硬件,实现一些高级的骚操作,比如:

  • 极致性能优化: 针对特定CPU指令集进行优化,比如使用SIMD指令加速计算密集型任务。
  • 直接访问硬件资源: 操作特定的寄存器、端口,实现底层驱动程序或嵌入式系统控制。
  • 实现编译器无法完成的任务: 例如,某些原子操作或者平台相关的底层操作。

内联汇编的语法结构

不同的编译器,内联汇编的语法略有不同。咱们以GCC和Visual C++为例,看看它们的基本结构。

GCC (GNU Compiler Collection)

GCC的内联汇编语法是比较复杂的,但是功能也很强大。它的基本结构是:

asm (
    "汇编指令"
    : 输出操作数
    : 输入操作数
    : clobbered list
);

看起来像天书?别怕,咱们一点点拆解:

  • asm 关键字: 这是告诉编译器,后面跟着的是汇编代码。
  • "汇编指令" 这里放的就是汇编指令字符串,多条指令用换行符 n 分隔。注意,为了避免C++编译器把反斜杠当成转义字符,通常会用双反斜杠 \n
  • 输出操作数 指定汇编代码的输出结果,也就是汇编代码计算完之后,要把结果放到哪些C++变量里。
  • 输入操作数 指定汇编代码需要用到的输入数据,也就是从哪些C++变量里读取数据。
  • clobbered list 这个比较重要,它告诉编译器,汇编代码执行后,哪些寄存器的值被修改了。编译器需要知道这些信息,才能正确地保存和恢复寄存器的状态,避免破坏程序的其他部分。

Visual C++

Visual C++的内联汇编语法相对简单,直接使用__asm关键字:

__asm {
    ; 汇编指令
    ; 更多汇编指令
}

Visual C++的内联汇编不需要显式指定输入输出操作数,编译器会自动推断。但是,你需要注意寄存器的使用,避免冲突。

一个简单的例子:加法

咱们先来个最简单的例子,用汇编实现两个整数的加法。

GCC:

#include <iostream>

int main() {
    int a = 10;
    int b = 20;
    int sum;

    asm (
        "movl %1, %%eaxn"  // 将b的值移动到eax寄存器
        "addl %2, %%eaxn"  // 将a的值加到eax寄存器
        "movl %%eax, %0"   // 将eax寄存器的值移动到sum变量
        : "=r" (sum)       // 输出操作数:sum,使用寄存器
        : "r" (b), "r" (a)  // 输入操作数:b和a,使用寄存器
        : "%eax"           // 告诉编译器,eax寄存器被修改了
    );

    std::cout << "Sum: " << sum << std::endl;
    return 0;
}

Visual C++:

#include <iostream>

int main() {
    int a = 10;
    int b = 20;
    int sum;

    __asm {
        mov eax, b  ; 将b的值移动到eax寄存器
        add eax, a  ; 将a的值加到eax寄存器
        mov sum, eax ; 将eax寄存器的值移动到sum变量
    }

    std::cout << "Sum: " << sum << std::endl;
    return 0;
}

代码解释:

  • movl %1, %%eax (GCC) / mov eax, b (Visual C++): 这条指令将变量b的值移动到eax寄存器。在GCC中,%1表示第一个输入操作数(b),%%eax表示eax寄存器(需要用两个百分号转义)。在Visual C++中,直接使用变量名即可。
  • addl %2, %%eax (GCC) / add eax, a (Visual C++): 这条指令将变量a的值加到eax寄存器。在GCC中,%2表示第二个输入操作数(a)。
  • movl %%eax, %0 (GCC) / mov sum, eax (Visual C++): 这条指令将eax寄存器的值移动到变量sum。在GCC中,%0表示第一个输出操作数(sum)。
  • "=r" (sum) (GCC): 这部分是GCC的输出操作数声明,=r表示sum是一个输出变量,并且使用寄存器来存储它的值。=表示这是一个只写的变量。
  • "r" (b), "r" (a) (GCC): 这部分是GCC的输入操作数声明,r表示ba是输入变量,并且使用寄存器来存储它们的值。
  • "%eax" (GCC): 这部分是GCC的clobbered list,告诉编译器eax寄存器的值会被修改。

更复杂的例子:SIMD指令优化数组求和

现在咱们来个更实际的例子,使用SIMD指令(Single Instruction, Multiple Data)优化数组求和。SIMD指令可以一次处理多个数据,大大提高计算效率。

这里以SSE(Streaming SIMD Extensions)指令集为例。

C++ 版本 (不使用内联汇编):

#include <iostream>
#include <chrono>
#include <vector>

int main() {
    const int N = 1024 * 1024;
    std::vector<float> arr(N, 1.0f);
    float sum = 0.0f;

    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < N; ++i) {
        sum += arr[i];
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;

    std::cout << "Sum (C++): " << sum << std::endl;
    std::cout << "Time (C++): " << duration.count() << " s" << std::endl;

    return 0;
}

GCC 内联汇编 SSE 版本:

#include <iostream>
#include <chrono>
#include <vector>

int main() {
    const int N = 1024 * 1024;
    std::vector<float> arr(N, 1.0f);
    float sum = 0.0f;

    auto start = std::chrono::high_resolution_clock::now();

    __asm__ volatile (
        "xorps %%xmm0, %%xmm0n"       // 初始化 xmm0 为 0
        "xorps %%xmm1, %%xmm1n"       // 初始化 xmm1 为 0
        "xorps %%xmm2, %%xmm2n"       // 初始化 xmm2 为 0
        "xorps %%xmm3, %%xmm3n"       // 初始化 xmm3 为 0
        "movl %2, %%ecxn"            // 将 N 移动到 ecx (循环计数器)
        "movl %3, %%esin"            // 将 arr 的地址移动到 esi

        "loop_start:n"
        "movups (%%esi), %%xmm4n"   // 从 arr 加载 16 字节 (4 个 float) 到 xmm4
        "addps %%xmm4, %%xmm0n"       // 将 xmm4 加到 xmm0
        "movups 16(%%esi), %%xmm5n"  // 从 arr 加载 16 字节 (4 个 float) 到 xmm5
        "addps %%xmm5, %%xmm1n"       // 将 xmm5 加到 xmm1
        "movups 32(%%esi), %%xmm6n"   // 从 arr 加载 16 字节 (4 个 float) 到 xmm6
        "addps %%xmm6, %%xmm2n"       // 将 xmm6 加到 xmm2
        "movups 48(%%esi), %%xmm7n"  // 从 arr 加载 16 字节 (4 个 float) 到 xmm7
        "addps %%xmm7, %%xmm3n"       // 将 xmm7 加到 xmm3

        "addl $64, %%esin"           // esi 指向下一个 64 字节 (16 个 float)
        "subl $16, %%ecxn"           // ecx 减 16 (处理了16个float)
        "jnz loop_startn"           // 如果 ecx != 0, 跳转到 loop_start

        "addps %%xmm1, %%xmm0n"       // 将 xmm1 加到 xmm0
        "addps %%xmm2, %%xmm0n"       // 将 xmm2 加到 xmm0
        "addps %%xmm3, %%xmm0n"       // 将 xmm3 加到 xmm0

        "movhlps %%xmm0, %%xmm1n"    // 将 xmm0 的高位 64 位移动到 xmm1 的低位 64 位
        "addps %%xmm1, %%xmm0n"       // xmm0 += xmm1

        "pshufd $1, %%xmm0, %%xmm1n" // 交换 xmm0 的低位和高位 32 位
        "addps %%xmm1, %%xmm0n"       // xmm0 += xmm1

        "movss %%xmm0, %0n"          // 将 xmm0 的最低位 float 移动到 sum
        : "=m" (sum)                    // 输出操作数:sum (内存)
        : "m" (arr[0]), "r" (N), "r" (&arr[0])  // 输入操作数:arr[0], N, &arr[0]
        : "memory", "cc", "%xmm0", "%xmm1", "%xmm2", "%xmm3", "%xmm4", "%xmm5", "%xmm6", "%xmm7", "%esi", "%ecx"
    );

    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;

    std::cout << "Sum (SSE): " << sum << std::endl;
    std::cout << "Time (SSE): " << duration.count() << " s" << std::endl;

    return 0;
}

Visual C++ 内联汇编 SSE 版本:

#include <iostream>
#include <chrono>
#include <vector>

int main() {
    const int N = 1024 * 1024;
    std::vector<float> arr(N, 1.0f);
    float sum = 0.0f;

    auto start = std::chrono::high_resolution_clock::now();

    __asm {
        ; 初始化 SSE 寄存器
        xorps xmm0, xmm0
        xorps xmm1, xmm1
        xorps xmm2, xmm2
        xorps xmm3, xmm3

        ; 初始化循环计数器和数组指针
        mov ecx, N
        mov esi, arr

        loop_start:
        ; 加载数据并累加
        movups xmm4, [esi]
        addps xmm0, xmm4
        movups xmm5, [esi + 16]
        addps xmm1, xmm5
        movups xmm6, [esi + 32]
        addps xmm2, xmm6
        movups xmm7, [esi + 48]
        addps xmm3, xmm7

        ; 更新指针和计数器
        add esi, 64  ; 每次处理 16 个 float (64 字节)
        sub ecx, 16 ; 每次处理 16 个 float
        jnz loop_start

        ; 将所有 SSE 寄存器中的值累加到 xmm0
        addps xmm0, xmm1
        addps xmm0, xmm2
        addps xmm0, xmm3

        ; 将 xmm0 中的高位和低位累加
        movhlps xmm1, xmm0  ; 将 xmm0 的高位 64 位移动到 xmm1 的低位
        addps xmm0, xmm1

        ; 再次累加,处理剩下的两个 float
        pshufd xmm1, xmm0, 1 ; 交换 xmm0 的低位和高位 32 位
        addps xmm0, xmm1

        ; 将最终结果存储到 sum 变量
        movss sum, xmm0
    }

    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;

    std::cout << "Sum (SSE): " << sum << std::endl;
    std::cout << "Time (SSE): " << duration.count() << " s" << std::endl;

    return 0;
}

代码解释:

  • xorps xmm0, xmm0xmm0寄存器清零。xmm0是SSE指令集中的128位寄存器,可以同时存储4个float类型的数据。
  • movups xmm4, [esi] 从内存地址esi处加载16个字节(4个float)到xmm4寄存器。movups指令用于加载未对齐的数据。
  • addps xmm0, xmm4xmm4寄存器中的4个float值加到xmm0寄存器中。addps指令用于对4个float值进行并行加法。
  • add esi, 64 将指针esi增加64,指向下一个16个float数据的起始地址。
  • movhlps xmm1, xmm0 把xmm0的高64位移动到xmm1的低64位
  • pshufd xmm1, xmm0, 1 交换 xmm0 的低位和高位 32 位
  • movss sum, xmm0xmm0寄存器中的第一个float值存储到sum变量中。movss指令用于存储单个float值。

重要提示:

  • 对齐问题: 在使用SIMD指令时,数据对齐非常重要。如果数据没有对齐,可能会导致性能下降,甚至程序崩溃。可以使用alignas关键字来保证数据对齐。
  • 编译器优化: 编译器可能会对内联汇编代码进行优化,导致你的汇编代码被修改甚至删除。可以使用volatile关键字来告诉编译器,不要对内联汇编代码进行优化。
  • 可移植性: 内联汇编代码通常是平台相关的,这意味着你的代码可能无法在不同的CPU架构上运行。
  • 调试难度: 内联汇编代码的调试难度较高,需要熟悉汇编语言和调试工具。

GCC的输入输出操作数约束

GCC内联汇编的输入输出操作数约束是控制数据如何在C++变量和汇编代码之间传递的关键。它们决定了编译器如何分配寄存器,以及如何将数据从内存加载到寄存器,或者将寄存器中的结果写回内存。以下是一些常用的约束类型:

  • r (Register): 将变量分配到通用寄存器中。这是最常用的约束,编译器会选择合适的寄存器。例如:"r" (my_variable)
  • m (Memory): 操作数是一个内存地址。汇编代码直接访问内存中的变量。例如:"m" (my_variable)
  • i (Immediate): 操作数是一个立即数(常量)。例如:"i" (123)
  • g (General): 编译器可以选择任何可用的寄存器、内存位置或立即数。
  • S and D: 分别用于esiedi寄存器。
  • a, b, c, d: 分别用于eax, ebx, ecx, edx寄存器。
  • f: 浮点寄存器
  • t: 顶部浮点寄存器(top of stack floating point register)
  • o: 偏移量的内存地址
  • V: 间接内存操作数(indirect memory operand)

约束修饰符

除了基本约束之外,还有一些修饰符可以改变约束的行为:

  • = (Write-Only): 表示操作数是只写的。之前的任何值都将被丢弃。
  • + (Read-Write): 表示操作数是可读可写的。变量既作为输入,也作为输出。
  • & (Early-Clobber): 表示该操作数使用的寄存器,会在汇编代码执行的早期被修改。这告诉编译器,不要将任何输入操作数放在同一个寄存器中。

Clobber List详解

Clobber list用于告诉编译器,内联汇编代码修改了哪些寄存器或内存。这对于确保编译器正确地保存和恢复寄存器的状态至关重要。

  • 寄存器: 列出所有被修改的寄存器,例如"eax", "ebx", "xmm0"等。
  • "memory": 如果汇编代码修改了任何内存位置,但无法精确指出哪些变量被修改,就必须包含"memory"。这会强制编译器在汇编代码执行前后,将所有变量的值刷新到内存中,并从内存中重新加载。
  • "cc": 如果汇编代码修改了条件码寄存器(例如,EFLAGS寄存器),则必须包含"cc"

总结

内联汇编是一把双刃剑。用好了,可以大幅提升性能,实现一些高级功能。用不好,可能会引入难以调试的bug,降低代码的可移植性。因此,在使用内联汇编之前,一定要慎重考虑,权衡利弊。

什么时候用?

  • 性能瓶颈: 只有当你的代码遇到性能瓶颈,并且C++编译器无法优化时,才考虑使用内联汇编。
  • 底层操作: 当你需要直接访问硬件资源或者实现一些编译器无法完成的任务时,可以使用内联汇编。

什么时候避免用?

  • 代码可读性: 内联汇编代码通常难以阅读和理解,尽量避免在不必要的地方使用。
  • 代码可移植性: 内联汇编代码通常是平台相关的,尽量避免在需要跨平台运行的代码中使用。

记住,永远先尝试用C++解决问题,实在不行再考虑内联汇编。 祝大家编程愉快,早日成为汇编大师!(手动滑稽)

发表回复

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