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"
);
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内联汇编的核心知识点串起来了。你可以把它当作写代码时的「速查地图」。
4.6 常见陷阱与调试建议
写内联汇编,调试起来确实比纯C麻烦。我分享几个自己踩过的坑:
- 陷阱一:忘记加
volatile——编译器可能把内联汇编优化掉,尤其是当它认为输出没有被使用时。 - 陷阱二:破坏列表不完整——如果你在内联汇编里用了v8,但破坏列表没写,编译器可能在其他地方也用了v8,数据就乱了。
- 陷阱三:内存操作数没加
"memory"——如果你通过指针修改了内存,一定要在破坏列表里加上"memory",否则编译器可能使用缓存里的旧值。
-S 选项生成汇编文件,看看编译器生成的代码里,内联汇编周围有没有额外的寄存器保存恢复。如果发现编译器在每次内联汇编前后都插入了 vsave/vrestore,说明你的内联汇编块太小了,考虑合并。
好了,这一节的内容就到这里。内联汇编的语法其实不复杂,关键在于你愿不愿意动手去写、去试。我建议你从最简单的向量加法开始,逐步增加复杂度。写多了,你自然就能找到感觉。