13.1. 基础指令类
【例1】 32位数的加法运算
add.w $r5, $r2, $r1
该指令把源寄存器 r2 和 r1 的值相加,并将结果保存到目的寄存器 r5。add.w 同时适用于 LA32 和 LA64。LA32 中,寄存器宽度(GLEN)与操作数位宽一致,可直接完成加法并写回。LA64 中,add.w 只取 r2、r1 的低32位参与计算,再把低32位结果符号扩展到64位后写入 r5。计算过程如下:
add.w $r5, $r2, $r1 # LA32: r5 = r2 + r1
add.w $r5, $r2, $r1 # LA64: r5[63:0] = SignExtend(r2[31:0] + r1[31:0])
LA64 中其他32位运算指令,如 sub.w、addi.w、mul.w、div.w 等,也采用类似规则:先得到32位结果,再符号扩展后写入目的寄存器。
【例2】 64位数的加法运算
add.d $r5, $r2, $r1 # r5[63:0] = r2[63:0] + r1[63:0]
add.d 只用于 LA64 架构。执行时,r2 和 r1 的完整64位数据参与加法,结果直接写入 r5。
【例3】 有进位的加法运算
add.w $r5, $r1, $r1 # 假设r1为2147483647
该指令把两个相同的正整数 2147483647(0x7FFFFFFF)相加。结果在 LA32 中为 0xFFFF FFFE,在 LA64 中为 0xFFFF FFFF FFFF FFFE;按有符号数解释时,它们对应十进制 -2,而不是数学期望值 4294967294。原因是寄存器最高位表示符号位:LA32 为第31位,LA64 为第63位;该位为0表示正数,为1表示负数,其余位表示数值。两个32位正整数相加得到 4294967294 时,第31位变为1,结果便被解释为负数。同符号数相加或异符号数相减时,进位可能改变符号位,这种情况称为溢出。
溢出判断可以通过符号位完成。若两个操作数符号位相同,而加法结果的符号位与它们不同,就说明发生溢出。例如两个正数相加可能得到负数,两个负数相加可能得到正数;减法中,正数减负数可能变为负数,负数减正数也可能变为正数。编写汇编程序时应关注这类情况。ARM 等 RISC 架构提供程序状态寄存器(Current Program Status Register,CPSR)保存进位、正负、溢出等标志。LoongArch 没有类似 CPSR 的寄存器;若程序需要判断进位或溢出,可比较源寄存器与目的寄存器的符号位关系。
【例4】 带立即数的32位加法运算
addi.w $r5, $r2, 100
该指令把32位源寄存器 r2 与十进制立即数100相加,并把结果写入 r5。表3-1规定 addi.w 的立即数字段为 si12,因此可表示范围为 [-2048, 2047]。若立即数超出该范围仍强行使用此指令,结果会出错。此时应先把立即数装入寄存器,再使用不带立即数的 add.w 完成运算。
【例5】 实现与十进制数4098(十六进制表示为0x1002)的32位加法运算
lu12i.w $r1, 0x1
ori $r1, $r1, 0x2
add.w $r5, $r2, $r1
立即数4098已超出 addi.w 的可表示范围,因此先用 lu12i.w 和 ori 组合把4098装入 r1,再通过 add.w 参与加法。lu12i.w r1, 0x1 将4098中高于低12位的部分(0x1)写入 r1,得到 0x1000;ori r1, r1, 0x2 再把低12位 0x002 合入,最终在 r1 中形成 0x1002,即十进制4098。
除 lu12i.w 外,LoongArch 还可配合 lu32i.d、lu52i.d 构造52位或64位立即数。常见情况如下。
(1)小于12位的立即数加载:
addi.d rd, r0, imm
待加载立即数小于12位时,可借助恒为0的 r0,通过加法指令直接写入目标寄存器。
(2)大于12位但小于32位的立即数加载:
lu12i.w rd, imm[31:12]
ori rd, rd, imm[11:0]
大于12位但小于32位的立即数可由上述两条指令生成。lu12i.w 负责加载 imm 的高20位,ori 将低12位与已加载部分合并,从而得到完整32位值。
(3)大于32位但小于52位的立即数加载:
lu12i.w rd, imm[31:12]
ori rd, rd, imm[11:0]
lu32i.d rd, imm[51:32]
大于32位但小于52位的立即数按52位值处理,需要3条指令。前两条生成低32位,lu32i.d 再装入高20位并与低32位拼接。
(4)大于52位的立即数加载:
lu12i.w rd, imm[31:12]
ori rd, rd, imm[11:0]
lu32i.d rd, imm[51:32]
lu52i.d rd, rd, imm[63:52]
超过52位的立即数按64位值处理,通常需要4条指令。前三条构造低52位,lu52i.d 加载最高12位并与低52位组合,最终形成64位立即数。
由此可见,手工加载大立即数比较繁琐。为减轻编码负担,汇编器提供 li.w rd, imm32 和 li.d rd, imm64 两个立即数加载伪指令,分别用于32位和64位范围内的立即数。程序员无需关心立即数属于12位、32位、52位还是64位范围,汇编器会自动选择合适的展开序列。例如,要把4098(0x1002)装入 r4,可直接写为:
li.w $r4, 0x1002
【例6】 带移位的64位数加法运算
alsl.d $r5, $r4, 3 # r5[63:0] = (r4[63:0] << (3+1)) + r5[63:0]
该指令可实现类似 C 语言语句 b = (a << 4) + b; 的计算。这里假设 r4 保存变量 a,r5 保存变量 b,实际左移量为4(3+1),结果仍写回 r5。
表3-1列出了3条移位加法指令:alsl.w、alsl.d、alsl.wu,它们也是龙芯基础指令集中少见的四操作数指令。这些指令都用于“左移后相加”,差异主要在操作数位宽和扩展方式。alsl.w 与 alsl.wu 处理32位操作数;前者对结果做符号扩展,后者做零扩展。二者的位移量字段为 sa2,实际范围为 [1, 4]。若位移量超出该范围,例如为5,则需要额外使用移位指令和加法指令组合实现。
【例7】 乘法/除法运算
mul.w $r5, $r2, $r1
div.w $r5, $r2, $r1
这两条指令分别计算 r2 与 r1 的乘积和商,并将结果写入 r5。与 add.w 类似,mul.w、div.w 同时适用于 LA32 和 LA64。LA32 中,寄存器宽度与操作数位宽一致,结果可直接写回;LA64 中,只取两个源操作数低32位参与运算,再把低32位结果符号扩展到64位后写入目的寄存器。
整数乘法需要关注溢出,整数除法则需要关注余数。对 mul.w 而言,乘积超过31位可表示范围时会产生溢出;对 mul.d 而言,乘积超过63位可表示范围时同样如此。除法中的余数,是指 div.w、div.d 的两个源操作数不能整除时剩余的部分。
假设 r1 为 0x7FFF FFFF,r2 为 0x4,数学乘积应为 8589934588(0x1FFFFFFFC)。但使用 mul.w 时,只执行低32位乘法,并把结果低32位符号扩展后写入 r5,因此 r5 的值会变为 -4(0xFFFF FFFF FFFFFFFC)。若要得到期望的完整结果,可改用 mul.d 或 mulw.d.w:
mul.d $r5, $r1, $r2
mulw.d.w $r5, $r1, $r2
使用 mul.d 时,r1 和 r2 按64位数参与计算,r5 得到完整64位乘积,因此结果为 8589934588(0x0000 0001 FFFF FFFC)。mulw.d.w 则将 r1、r2 的低32位相乘,并把64位乘积写入 r5,也能得到相同结果。
如果程序需要乘积高位,可使用 mulh.w 或 mulh.d 获取。
除法运算中,可分别使用 div 和 mod 获取商与余数。示例如下:
div.d $r5, $r1, $r2
mod.d $r6, $r1, $r2
若 r1 为5、r2 为2,执行后 r5 保存商2,r6 保存余数1。
【例8】 取两个数的最大值。
slt $r12, $r4, $r5
maskeqz $r4, $r4, $r12
masknez $r12, $r5, $r12
or $r6, $r4, $r12
这4条指令实现类似 C 语言 a = (b > c) ? b : c; 的功能,即把 b、c 中较大的值赋给 a。假设 b、c、a 分别位于 r4、r5、r6。slt r12, r4, r5 计算 b < c 并把结果写入 r12;随后 maskeqz 与 masknez 根据该结果选择保留 r4 或 r5。若 b < c 成立,r4 被清零,r12 取 r5,最终 or r6, r4, r12 得到 c;若条件不成立,r4 保持 b,r12 为0,最终结果为 b。
【例9】 带立即数的逻辑与运算
andi $r5, $r2, 3
该指令把 r2 与立即数3进行按位与运算,并将结果写入 r5。若 r2 为 0x7F0,则 r5 的结果为 0x0。
andi 的立即数字段为 ui12,即无符号12位数。若参与运算的立即数超过12位,应先用 li 装入寄存器,再使用 and 指令完成逻辑与。
实际工程中,andi 常用于高效判断地址对齐。例如,判断地址是否8字节对齐,只需检查该地址与 0x7 按位与的结果是否为0;判断16字节对齐时,则检查与 0xF 的按位与结果。
【例10】 空指令nop的使用
nop # andi $r0, $r0, 0
nop 是 andi r0, r0, 0 的别名,它把 r0 与0按位与后写回 r0。由于龙芯架构规定 r0 恒为0,因此该指令不产生有效计算结果,只占用4字节指令空间并使 PC 前进4字节,所以称为空指令。nop 仍有实际用途,例如编译器可用它填充代码以满足对齐要求。龙芯平台通常要求函数入口16字节对齐,以提高执行效率。当编译器发现某个函数末尾指令地址会导致下一个函数入口不满足16字节对齐时,可能在末尾补入1~3条 nop。
【例11】 逻辑左移运算
sll.w $r5, $r1, $r2 # LA32,r5 = r1 << r2[4:0]
# LA64, r5[63:0] = SignExtend(r1[31:0] << r2[4:0])
该指令可在 LA32 和 LA64 上执行,用于把 r1 中的32位数据逻辑左移 N 位,并将结果写入 r5;在 LA64 中,结果还需符号扩展。这里 N 取自 r2 的低5位。若 r1 为205(0xCD),r2 为0x4,则执行后 r5 应为3280(0xCD0)。
由于 sll.w 只使用 r2 的低5位作为移位量,C 语言中的 int a = b << 34 在对应实现中会等价于 a = b << 2,因为34的低5位为2。这相当于对移位量按32取模。类似地,对64位数据(LA64 中通常对应 C 语言 long 类型)左移65位,效果等价于左移1位。
【例12】 带立即数的算术右移运算
srai.d $r5, $r1, 4
在 LA64 中,该指令把 r1 的数据算术右移4位,并将结果写入 r5。若 r1 为205(0xCD),右移后 r5 为12(0xC)。
处理器内部通常由不同功能部件执行移位和乘法,且移位指令一般更快。因此,当乘数是较小的2的幂时,可用逻辑左移替代乘法:乘2等价于左移1位,乘4等价于左移2位,乘8等价于左移3位。若乘法不能用固定移位等价表示,仍应使用乘法指令。有符号整除且余数为0时,也可用算术右移优化,例如除以2可改为算术右移1位,除以4可改为算术右移2位。无符号整除则可根据情况使用逻辑右移。
【例13】 带立即数的循环右移运算
rotri.d $r5, $r1, 32
在 LA64 中,该指令将 r1 的数据循环右移32位,并把结果写入 r5。若 r1 为十六进制 0x11111111EEEEEEEE,执行后 r5 应为 0xEEEEEEEE11111111。
【例14】 符号扩展运算
ext.w.h $r5, $r4
该指令把 r4 的低16位 [15:0] 做符号扩展后写入 r5。例如在 LA64 中,若 r4 为 0x000000000000FFFA,执行后 r5 为 0xFFFFFFFFFFFFFFFA。
符号扩展指令常用于实现高级语言中 byte、short 向 int 的类型转换。
【例15】 统计二进制数中连续1的数量
cto.d $r5, $r4
该指令从 r4 的第0位开始,向第63位方向统计连续1的个数,并将计数结果写入 r5。若 r4 为 0x30F,执行后 r5 的值为4。
【例16】 以字为组按字节逆序
revb.2w $r5, $r1
该指令以字为单位分组,并在每组内按字节逆序。若 r1 为 0xEE00FF11 CC22DD33,执行后 r5 为 0x11FF00EE33DD22CC。
字节逆序指令适合用于端序转换,例如大端转小端,或小端转大端。对一个32位整数进行端序转换时,C 语言可写为:
int reverse_dst = (src << 24) | ((src & 0xff00) << 8) | ((src >> 8) & 0xff00) | (src >> 24);
若 src 为 0x11223344,则 reverse_dst 最终为 0x44332211。对应的 LoongArch 汇编指令如下:
revb.2w $r7, $r4
slli.w $r7, $r7, 0
这里假设变量 src 位于 r4,reverse_dst 位于 r7。revb.2w 会分别对寄存器高32位和低32位按字节逆序,并把结果写入 r7。由于示例只处理32位整数端序转换,所以再使用 slli.w 完成32位结果到64位寄存器的符号扩展。
【例17】 从内存读数据
ld.w $r12, $r13, 0x7f
该指令从指定内存地址读取一个32位数据到 r12。LA64 中,读取结果需要符号扩展后再写入 r12。目标内存地址由 r13 与立即数 0x7f 相加得到。若 r13 为 0x12000000,则访问地址为 0x12000007f。
编译器通常把数组下标0的地址、类对象起始地址、字符串首字符地址或栈指针 SP 等作为基址,再通过偏移量访问其他数据。例如对数组 int a[20],基址为 &a[0]。访问某个元素时,偏移量等于元素类型宽度乘以下标;加载 a[3] 时,地址为 base(&a[0]) + sizeof(int) * 3。
当地址偏移量超出 si12 的表示范围时,应先把偏移量装入寄存器,再使用“基址 + 寄存器偏移”的访存指令。例如偏移量 0x7ff4 超过12位,可写为:
li.w $r14, 0x7ff4 # 加载立即数0x7ff4到寄存器r14
ldx.w $r12, $r13, $r14 # 从内存地址(r13+r14)加载一个字(32位)到寄存器r12
【例18】 写数据到内存
st.b $r12, $r13, 0x7f
该指令把 r12 的低8位 [7:0] 写入指定内存地址。这里的地址由 r13 + 0x7f 计算得到。
【例19】 满足内存地址自然对齐的32位/64位数据访存
ldptr.w $r12, $r13, 0x7ff4
该指令同样从指定内存地址读取32位数据到 r12。LA64 中,读取结果仍需符号扩展后写入 r12。区别在于地址计算方式为 r13 + (0x7ff4 << 2),即立即数偏移需要先逻辑左移2位。
表3-5中,名称带 ptr 的访存指令(如 ldptr.w、ldptr.d)也属于基址加立即数偏移(base + offset)类型,但可表示的偏移范围更大。当访存地址低两位为0,即满足自然对齐要求时,相比普通 ld/st 指令,可用这类指令访问16位偏移范围 [-32768, 32767] 内的地址。例如偏移 0x7ff4 超过12位,但仍在16位范围内且低两位为0,因此可用 ldptr.w 加载。与【例17】相比,这种写法省去了一条加载立即数的指令,性能更好。
【例20】 预取数据
preld 8, $r6, 0
该指令从 r6 + 0 指向的内存位置预取一个 Cache 行到缓存中。龙芯3A5000系列芯片的一个 Cache 行为64字节。hint = 8 表示被预取的数据随后可能用于写(Store)操作。
合理使用预取指令可以减少 Cache Miss(缓存未命中)造成的等待,从而提升程序效率。程序运行时,处理器通过访存指令与内存交换数据。当待访问数据不在缓存中时,访存部件需要从内存取回数据,处理器可能等待上百个时钟周期。数据预取的作用是在真正访问之前提前把数据调入 Cache,尽量避免后续访存因 Cache Miss 而停顿。
【例21】 带边界检查的存储操作
addi.w $r13, $r0, 1(0x1) # 加载立即数1到寄存器r13
stle.w $r13, $r12, $r14 # 如果(r12 <= r14)成立,将寄存器r13的值写入r12所指内存
# 否则写失败,触发边界检查例外
执行 stle.w r13, r12, r14 时,处理器先判断 r12 中的地址是否小于或等于 r14 中的地址。程序可将 r14 设为数组边界地址(如 &array[99])。如果 r12 保存的是 &array[101],条件不成立,stle.w 会触发边界检查例外,写操作不会发生。这样可以阻止错误继续传播,降低后续调试难度。开发者也可以创建专门线程接收这类例外(SIGSEGV),并进行处理或输出易读提示。一些高级语言运行时(如 Java 虚拟机)中的动态防御和异常处理机制也采用类似思路。
【例22】 数据栅障的使用方法
# 写进程
st.d val, data # 写数据到共享区域data
st.d 1, tag # 写数据到共享区域tag
写进程通过两条写内存指令更新共享存储区域,该区域分为数据区 data 和标识区 tag。第一条指令把数据写入 data,第二条指令把1写入 tag,用于通知其他进程数据已经更新。对应地,读进程应先读取 tag,确认其值为1后再读取 data。
# 读进程
ld.d reg, tag # 读标识区tag
beqz reg, L # 标识如果为0,则跳转到L执行nop
ld.d val, data # 否则证明标识区为1,执行读数据区data
L: nop
这段程序在弱一致性内存模型上存在风险。由于处理器可能乱序执行,写进程中两条没有数据依赖的写指令 st.d val, data 和 st.d 1, tag 可能调换完成顺序,即 tag 先被写为1,而 data 尚未更新。读进程此时可能先读到 tag 为1,并继续读取 data,但得到的仍是旧数据。
【例23】 使用LL-SC实现a=a+1的原子操作
1: # label 1
ll.w r4, &a # 加载内存数据(a)到寄存器r4
addi.w r6, r4, 1 # 加1
sc.w r6, &a # 结果写回内存
beqz r6, 1b # 如果写回失败(r6为0)则跳转到label 1,重复读-修改-写
这4条指令组成一个重试循环。若 sc.w 写回失败,程序会跳回标签1,重新执行 ll.w、addi.w、sc.w 序列,直到原子更新成功。
【例24】 使用amadd指令实现一个整型a=a+1的原子操作
li.w $r2, 1 # 加载立即数1
li.w $r4, &a # 加载变量a的地址
amadd.w $r0, $r2, $r4 # a = a + 1并写回a的地址。旧值不做保存故使用寄存器r0
这里 r4 保存变量 a 的地址,r2 保存立即数1。由于不需要保留旧值,目的寄存器 rd 选择恒为0的 r0。执行后,处理器把 r2 的值与 r4 指向地址中的旧值相加,并把结果写回该地址,即完成 a = a + 1。
需要注意,AMO 只支持32位和64位数据上的简单算术、逻辑原子操作。若要对8位、16位数据执行原子运算,或实现更复杂的原子逻辑,应使用 LL-SC 原子访存指令对完成。
【例25】 对数据栅障的使用方法进行优化,以实现一定的程序性能提升。原始指令序列为:
# 写进程
st.d val,data
dbar 0 # 确保其前后两条访存指令的顺序执行
st.d 1, tag
优化后的指令如下:
st.d val,data
amswap_db_d r0, 1, tag
【例26】 用汇编指令实现下面C语言中的程序流控制
if(a == 0) b++; // 如果整型变量a等于0,则整型变量b加1
else b--; // 否则b减1
此功能对应的LoongArch汇编指令如下:
beqz $r4, 8
addi.w $r5, $r5, -1 # b--
b 4
addi.w $r5, $r5, 1 # b++
nop
这里假设变量 a、b 分别存放在 r4、r5。第一条 beqz r4, 8 判断 a 是否为0。若 a 为0,程序跳转到 addi.w r5, r5, 1 执行 b++;否则顺序执行 addi.w r5, r5, -1 完成 b–,再通过 b 4 跳过 b++ 分支。
【例27】 用汇编指令实现C语言中的函数调用和函数返回
int add(int a,int b){ // 实现两个数的加法运算
return a + b;
}
int main(){
add(1,2); // 调用函数add,实现1+2
}
此功能对应的LoongArch汇编指令如下:
add:
...
add.w $r4, $r4, $r5 # a+b
jirl $r0, $r1, 0 # 函数返回,寄存器r1存放函数的返回地址
main:
...
bl add # 调用函数add
...
jirl $r0, $r1, 0 # main函数返回,寄存器r1存放函数的返回地址
main 中的 bl 在跳转到 add 之前,会把下一条指令地址(PC + 4)写入 r1。add 末尾的 jirl 以 r1 + 0 为跳转目标,因此会返回到 main 中 bl 后面的指令。jirl 跳转时还会把自身下一条指令地址写入 rd;这里 rd 使用恒为0的 r0,表示该 jirl 只是普通的非调用间接跳转。
Tip
这里的 bl add 也是宏指令,由编译器识别后翻译为真实汇编指令。add 是标签(Label),会在链接阶段替换为具体目标地址。
【例28】 通过系统调用指令实现用户进程退出功能
内核提供的进程退出接口为 sys_exit(int error_code)。按照 LoongArch ABI,该系统调用号为93,系统调用号通过 r11 传递,参数通过 r4~r10 传递。实现 sys_exit 的指令如下:
li.w $r11, 93 # 加载系统调用号93到寄存器r11
li.w $r4, 0 # 将错误码值0作为第一个参数,加载到寄存器r4
syscall 0 # 系统调用,程序陷入内核态
执行 syscall 0 后,处理器陷入内核态,并调用内核中的 sys_exit() 实现,从而退出当前用户进程。从用户程序角度看,其效果等同于调用 libc 中的 exit(0)。
【例29】 在源代码中插入一个 code 值为5的中断指令
break 5
程序运行到该指令时,会收到 SIGTRAP 信号,提示信息为“Trace/breakpoint trap”,并停在当前指令处。GDB 的软件断点功能通常就是通过 break 指令实现的。
【例30】 在龙芯3A5000上读取当前程序所在核的恒定频率
rdtime.d $r7, $r8
龙芯3A5000 属于 LA64 架构,因此可使用 rdtime.d。若执行该指令的进程运行在处理器核1上,执行后 r8 的值为1,r7 的值为核1计时器的当前计数。
【例31】 获取当前处理器是否支持非对齐访问
li.w $r4, 1 # 加载配置字号1到寄存器r4
cpucfg $r5, $r4 # 读取配置字号1对应的配置信息到寄存器r5
根据表3-11,非对齐访存支持信息位于配置字号1的第20位。程序先读取字号1对应的32位配置信息并写入 r5,再检查 r5 的第20位:若该位为1,表示当前架构支持非对齐访存;若为0,则表示不支持。
【例32】 使用 IEEE 802.3 多项式对一个 byte 数组内的所有数据进行 CRC
loop:
ld.d tmp, buf, 0 # 从内存地址(buf+0)加载1字节到寄存器tmp
crc.w.b.w crc, tmp, crc # 对寄存器crc与寄存器tmp进行CRC,将结果写回寄存器crc
addi.d buf, buf, 1 # 地址加1
addi.d len, len, -1 # 数组长度减1
blt r0, len, loop # 如果数组没有完成,跳到loop继续执行
该示例使用循环逐字节累积 CRC。tmp、buf、crc、len 是 r1~r31 中任意寄存器的别名。buf 初始保存数组起始地址,len 保存数组长度,crc 保存累积 CRC 校验和,初值可设为0。循环对 buf 中所有字节计算32位 CRC。指令 crc.w.b.w crc, tmp, crc 使用 IEEE 802.3 多项式,将 crc 中的32位累积值与 tmp[7:0] 按 CRC32 算法生成新的校验和,并符号扩展后写回 crc。