4. 手写向量化内联汇编:RVV内联汇编语法、约束修饰符、寄存器分配技巧

说实话,很多做RVV优化的朋友,一开始都卡在内联汇编这道坎上。我自己刚接触RISC-V向量扩展时,看着那些vsetvli、vadd.vv的指令格式,也觉得头大。但后来我发现,只要把内联汇编的语法骨架摸清楚,写起来其实挺顺手的。

这一节,我就带你手把手拆解RVV内联汇编。咱们不讲虚的,直接上干货。

4.1 RVV内联汇编的基本语法骨架

GCC/LLVM的内联汇编,基本格式长这样:

__asm__ volatile(
    "指令模板\n\t"
    : 输出操作数列表
    : 输入操作数列表
    : 可能被破坏的寄存器列表
);

嗯,这里要注意:volatile关键字我建议每次都加上。为什么?因为编译器优化有时会自作主张,把内联汇编挪位置甚至删掉。你想想看,向量化操作往往涉及内存读写,顺序乱了,结果就全错了。

举个最简单的例子——两个向量相加:

#include <riscv_vector.h>

void vec_add_asm(float *a, float *b, float *c, int n) {
    int vl;
    __asm__ volatile(
        "vsetvli %0, %1, e32, m1\n\t"
        "vle32.v v8, (%2)\n\t"
        "vle32.v v12, (%3)\n\t"
        "vfadd.vv v16, v8, v12\n\t"
        "vse32.v v16, (%4)\n\t"
        : "=r"(vl)
        : "r"(n), "r"(a), "r"(b), "r"(c)
        : "v8", "v12", "v16", "memory"
    );
}

这段代码里,%0到%4就是操作数占位符。它们按顺序对应冒号后面的列表。我个人习惯把输出操作数放在最前面,这样读起来逻辑更顺。

4.2 约束修饰符——告诉编译器怎么传参

约束修饰符是内联汇编的「通信协议」。你告诉编译器:这个参数我要放在哪个类型的寄存器里,或者直接给个内存地址就行。

修饰符 含义 适用场景
"r" 通用寄存器 标量整数、地址指针
"f" 浮点寄存器 标量浮点运算
"vr" 向量寄存器 RVV向量操作数
"vm" 向量掩码寄存器 掩码操作(v0..v7)
"m" 内存地址 直接内存访问
"=r" 输出(写后读) 输出操作数
"+r" 读写(读后写) 既输入又输出的操作数
我的小技巧: 对于向量寄存器,我强烈建议显式使用 "vr" 约束,而不是靠编译器自动分配。我在一个项目中遇到过,编译器把向量寄存器分配到了v0,结果和掩码寄存器冲突,调试了一整天才发现。

4.3 寄存器分配技巧——别让编译器「帮倒忙」

寄存器分配,说白了就是你和编译器之间的「抢椅子」游戏。你写死了某个向量寄存器,编译器就不能用它做其他事。但如果你什么都不说,编译器可能把你的关键数据给覆盖了。

我总结了几条实战经验:

  • 显式声明破坏列表:所有你在内联汇编里用到的向量寄存器,都要写在破坏列表里。比如 : "v8", "v12", "v16"。我曾经漏掉一个,结果函数返回后,调用方的数据全乱了。
  • 优先用高编号寄存器:v0-v7是掩码寄存器,尽量留给条件操作。我一般从v8开始用,v24-v31留给临时变量。
  • 避免跨内联汇编的寄存器依赖:如果你在两个内联汇编块里用了同一个向量寄存器,编译器可能在这之间插入其他代码,把寄存器内容冲掉。要么合并成一个内联汇编块,要么用 "+vr" 约束把寄存器「绑定」住。
// 错误示范:两个内联汇编块之间,v8可能被覆盖
__asm__ volatile("vle32.v v8, (%0)" : : "r"(ptr) : "v8");
// ... 编译器可能在这里插入其他向量操作 ...
__asm__ volatile("vfadd.vv v16, v8, v12" : : : "v8", "v12", "v16");

// 正确做法:合并成一个块
__asm__ volatile(
    "vle32.v v8, (%0)\n\t"
    "vfadd.vv v16, v8, v12\n\t"
    :
    : "r"(ptr), "vr"(v12)
    : "v8", "v16"
);
避坑指南: 我曾经在循环里用内联汇编做向量累加,每次迭代都重新加载数据。结果发现性能还不如纯C代码。后来一查,原来是每次内联汇编都触发了寄存器保存恢复的开销。解决方案是:把循环体整个包在一个大的内联汇编块里,手动管理循环计数和地址偏移。

4.4 实战:手写一个向量点积内联汇编

咱们来写一个完整的例子——向量点积。这个操作在矩阵乘法、卷积里经常出现。

float vec_dot_asm(float *a, float *b, int n) {
    float result = 0.0f;
    int vl;

    __asm__ volatile(
        "vsetvli %0, %1, e32, m1\n\t"
        "vle32.v v8, (%2)\n\t"
        "vle32.v v12, (%3)\n\t"
        "vfmul.vv v16, v8, v12\n\t"
        "vfredusum.vs v0, v16, v0\n\t"
        "vfmv.f.s %4, v0\n\t"
        : "=r"(vl), "=f"(result)
        : "r"(n), "r"(a), "r"(b)
        : "v8", "v12", "v16", "v0"
    );

    return result;
}

这里有个关键点:vfredusum.vs 是向量归约指令,它把v16里的所有元素累加到v0的标量单元里。最后用 vfmv.f.s 把标量结果取出来。嗯,这里要注意,v0既是掩码寄存器又是目标寄存器,所以破坏列表里一定要写上它。

4.5 知识结构总览

下面这张图,把RVV内联汇编的核心知识点串起来了。你可以把它当作写代码时的「速查地图」。

RVV内联汇编知识结构 内联汇编语法骨架 指令模板 + 占位符 输出/输入/破坏列表 约束修饰符 vsetvli / vle32 / vfadd %0..%N 顺序对应 "r" / "vr" / "vm" / "m" 寄存器分配技巧 显式声明破坏列表 优先用高编号寄存器 避免跨块寄存器依赖 核心原则:让编译器知道你在用什么,别让它「猜」

4.6 常见陷阱与调试建议

写内联汇编,调试起来确实比纯C麻烦。我分享几个自己踩过的坑:

  • 陷阱一:忘记加 volatile——编译器可能把内联汇编优化掉,尤其是当它认为输出没有被使用时。
  • 陷阱二:破坏列表不完整——如果你在内联汇编里用了v8,但破坏列表没写,编译器可能在其他地方也用了v8,数据就乱了。
  • 陷阱三:内存操作数没加 "memory"——如果你通过指针修改了内存,一定要在破坏列表里加上 "memory",否则编译器可能使用缓存里的旧值。
调试小工具: 我习惯在调试时先用 -S 选项生成汇编文件,看看编译器生成的代码里,内联汇编周围有没有额外的寄存器保存恢复。如果发现编译器在每次内联汇编前后都插入了 vsave/vrestore,说明你的内联汇编块太小了,考虑合并。

好了,这一节的内容就到这里。内联汇编的语法其实不复杂,关键在于你愿不愿意动手去写、去试。我建议你从最简单的向量加法开始,逐步增加复杂度。写多了,你自然就能找到感觉。