【跟我学RISC-V】(二)RISC-V的基础知识学习与汇编练习

发布于:2024-05-10 ⋅ 阅读:(25) ⋅ 点赞:(0)

写在前面:

这篇文章是跟我学RISC-V的第二期,是第一期的延续,第一期主要是带大家了解一下什么是RISC-V,是比较大体、宽泛的概念。这一期主要是讲一些基础知识,然后进行RISC-V汇编语言与c语言的编程。在第一期里我们搭建了好几个环境,你可以任意选一个你喜欢的RISC-V环境(能够执行RV机器码的平台),然后进行代码编写、编译、汇编、链接、运行、观察现象的这一过程。同样地,在这一篇里我也会拿x86的知识与RISC-V进行对比,这样也可以促进对两种指令集的学习。我会在介绍完指令之后使用c语言内嵌汇编来展示指令执行后的效果,大家可以跟着做。c内嵌汇编主要是效果更加明显,等到基础指令都学习完了之后我再使用gdb来调试和查看寄存器的信息。

一、RISC-V指令集的基础信息

1、RISC-V的通用寄存器

在第一期里我讲过,无论是RV32还是RV64,它的通用寄存器的数量都是32个。32真是一个好数字,刚好是2的5次方,实际上伯克利大学的研究员在设计RV的时候就非常讲究,这么做的好处是颇多的,也体现了RISC-V指令集的特色,这个我们在学习之后再讨论这个问题。

这32个寄存器分别是x0 x1 x2 ... x31这样去编号,但是就单纯的这样去写汇编的话,是非常不方便的,因此每一个寄存器又有自己的别名,这个别名就代表了这个寄存器的含义,以及函数调用时候的规则。也就是说,你在实际汇编编程的时候,既可以使用编号名,也可以使用别名,实际上使用别名更好,这样能够把寄存器的含义和在这里的作用绑定起来,别人看你的代码就知道你要做什么了。

寄存器名 别名 作用 在函数调用过程中的维护
x0 zero 零寄存器,永远是0 不需要维护
x1 ra return address在函数调用时存放返回地址 caller
x2 sp stack pointer栈指针寄存器 callee
x3 gp global pointer全局寄存器(用于联接器松弛优化)经常使用基于gp的寻址模式来访问全局变量和静态数据,从而提高访问速度和效率 caller/不需要保存
x4 tp thread pointer线程寄存器(保存pcb的地址) 与线程相关
x5 t0 temporaries临时寄存器,相当于c语言的临时用一下变量,callee可能会改变他们的值,caller根据实际情况看是否要保存 caller
x6 t1
x7 t2
x8 s0 saved保存寄存器,在函数调用过程中必须保存的寄存器 callee
x9 s1
x10 a0

argument参数寄存器,在函数调用过程中传递参数和返回值。同时,a0和a1又会在函数返回时的传递返回值。

caller
x11 a1
x12 a2
x13 a3
x14 a4
x15 a5
x16 a6
x17 s7
x18

s2

saved保存寄存器,在函数调用过程中必须保存的寄存器 callee
x19 s3
x20 s4
x21 s5
x22 s6
x23 s7
x24 s8
x25 s9
x26 s10
x27 s11
x28 t3 临时寄存器,加上前面的3个,总共有7个临时寄存器 caller
x29 t4
x30 t5
x31 t6

上图描述了32个通用寄存器在编程中作用的约定,特别是c编程时候的默认调用约定。其实这些寄存器本身来说想咋用,但是如果这样的话,你写一套使用寄存器的风格,他也有一套自己的风格,这样的话我写的函数你就没法调用了,因为寄存器安排不同,这样就非常麻烦,根本不利于开发。于是RISC-V指令集在设计之处,就把这些寄存器的作用和安排都规定好了,别名也取好了。你不需要自己想一套函数调用的法则,你只需要遵守约定就好。这样,我写的函数,你也可以直接调用,而不需要考虑参数保存在哪个寄存器,因为这都已经规定好了。

比如说A函数调用的B函数,那么caller就是A函数,callee就是B函数。

这个寄存器的别名是很有用的,你不需要去记忆x寄存器名到别名的映射,你只需要记住别名中前缀的含义,你在汇编语言编程的时候就知道该使用哪一个寄存器来保存什么信息了。

如果你是第一次看见这个表格,你可能会感觉很抽象,不过只要编程练习一下,那么也就不抽象了。不过想进行RISC-V汇编语言的编程,光是知道通用寄存器还是不够的,你还得知道一些指令,所以我在这里先分析一下RV的寄存器和x86的不同之处。

我们都知道,x86是CISC,而RISC-V在RISC,这二者在寄存器的安排上就有非常大的不同。在x86架构中,有一种说法是“寄存器较弱的体系结构”,意思就是x86架构的通用寄存器的数量是非常少的。在实模式下,也就ax,bx,cx,dx,bp,sp,si,di, 就是搞来搞去就这么几个寄存器,并且比如bx,bp还要拿来作为offset偏移量寻址、cx还要拿来作为循环次数的保存、sp还是指向栈顶。总结来说就是能够程序员使用的通用寄存器的数量是在是太少太少了,我在大一的时候学习8086汇编就比较难受,寄存器满打满算就这么几个,一下子就用掉了,总感觉不太够用(你可以看看我之前的blog)。进入IA-32e的长模式感觉就好多了,通用寄存器又加上了r8 ~ r15 ,Intel终于是不挤牙膏了。而对于RISC-V而言,有整整32个通用寄存器,其中临时寄存器的数量就有7个,相比x86真是太爽了,随便拿一个就能临时保存一下我计算过程中的数据(你可以认为是打草稿)。对程序员来说,这太方便、舒服了。

还有一些区别是,在x86中(保护模式),函数调用时候参数非常依赖于内存。也就是参数都是保存在栈里的,保存在栈里问题倒是不大,就是读写内存的速度相比读写寄存器的数据差距太大了。在RISC-V有中专门的a系列的寄存器可以用户保存函数调用时候的参数,a0 ~ a7 整整8个寄存器呢!基本上来说,你一个函数的参数也很少会超过8个,当然如果超过了那还是要保存在栈里。总的来说,参数保存在寄存器里那速度是快了好几倍。(当然在Intel IA-32e中也是使用寄存器保存参数了,Intel在多年的迭代过程中算是学聪明了,而RISC-V是一开始就这么聪明,这就是后发的优势)

还有一点就是在RISC-V体系结构中,专门可以拿出一个寄存器tp, 来存放指向当前进程task_struct的指针,用于加快访问速度。而像x86这样的体系结构(通用寄存器数量不多)就只能在内存栈顶创建thread_info结构,通过计算偏移量间接的查找task_struct(也就是pcb)。也就是x86体系中每一次调用current去查找当前进程的pcb都需要访问多次内存,还要通过偏移量去找到task_struct的地址,这转来转去速度就会变慢。而RISC-V则直接通过tp寄存器直接就能找到pcb ,那访问寄存器的速度快很多,并且也不需要通过偏移量去寻址。这又是一大优势。

还有就是在x86中通用寄存器是可拆解的,比如IA-32e的rax寄存器是64位的,你可以拆解它。rax、eax、ax、ah、al, 从8位到16位到32位到64位,是可以拆分的。这都是Intel为了兼容性而设计的这么一套东西,因为早期的8088是8位的CPU,8086是16位的,老奔腾是32位的,酷睿又是64位的,它为了兼容就用这种方法,你即便进入了长模式,仍然可以使用al寄存器。但是在RISC-V中,RV32寄存器的大小就是32位的,你不可能说拆成hx和hl,没有这样的用法。所以说RISC-V指令集里面,你在使用load系列指令的时候,把一个不到32位的数值放置到32位的寄存器,会进行符号扩展或者零扩展,而不是直接把这个数直接放到寄存器里,这样会损失符号的。

在上面表格中有一个非常特殊的寄存器x0 zero寄存器,它类似于LInux里的/dev/null这个设备,你往里面写入任何数据都没用,再怎么写都是0,写入任何数都会被丢弃掉;如果你把这个寄存器的值给读出来,也还是0。你可能觉得这个寄存器好像没啥用啊,难道我就不能用立即数0去替代这个x0寄存器吗?实际上,这个x0 寄存器是非常有用的,有很多地方都会用到它。特别是伪指令在转换成汇编指令的时候,会经常用到zero寄存器,这个我会在后面讲到。


2、RISC-V的指令格式与特点

(一)RISC-V指令的特点

RISC-V的每条指令宽度都是32位(固定的4B),如果有c扩展使用指令压缩后会变成2B ,这个我们先不提。RV的指令格式如图所示分为6种。

  1. R-type:寄存器与寄存器算术指令,这里的R就是register寄存器的意思;
  2. I-type:寄存器与立即数算术指令或者加载指令;
  3. S-type:存储指令(和上面的加载指令刚好是反义词);
  4. B-type:条件跳转指令;
  5. U-type:长立即数操作指令;
  6. J-type:无条件跳转指令。

大家从图里可以清晰地看到:无论是什么类型的指令,确实都是4B的,并且共同点就是opcode操作码都在低7位。操作码这个概念相信学过计算机组成原理的都知道。

  • 足够的编码空间:使用7位操作码可以提供128种不同的可能值,这允许定义多种不同的基本操作和指令格式。对于一个旨在可扩展和支持多种扩展模块(如整数、浮点、原子操作等)的现代处理器架构来说,这一点非常重要。

  • 简化解码:RISC-V的指令长度固定为32位,这使得硬件能够更加简单和高效地解码指令。opcode位于指令的最低7位,硬件可以快速地读取这7位并确定如何进一步解析整个指令,这对提高指令解码速度和处理器整体性能至关重要。

  • 支持指令格式多样性:RISC-V使用不同的指令格式(如R、I、S、B、U、J格式)来支持不同类型的操作。这些格式有不同的字段组合和长度,opcode的7位设计帮助区分这些格式,并指导如何解析随后的字段。

  • 扩展性:RISC-V架构被设计为可扩展的,以支持新的功能和指令集扩展。7位opcode为未来可能的指令集扩展留出了空间,使得可以轻松加入新的操作码而不会干扰现有的指令解码逻辑。

在图中的rd就是目的寄存器,rs就是源寄存器。这个概念类似于x86中的rdi和rsi。大家可以总结出来,rd要么是1个要么是0个(有的指令是不用把值输出到目的寄存器的),rs最多支持两个,就是最多放两个源寄存器进来。无论是rd还是rs,它所占用的位数都是5位。这个事情我们之前提到过,因为寄存器总共就32个,2的5次方等于32,那么设置成5位这样,是非常巧妙的。

图片中还经常出现imme,这个就是立即数的意思,immediately.

还有就是占用3位的funct3与占用7位的funct7,就是说单纯的opcode还不足以确定这条指令究竟是哪一条指令。而是要opcode和funct功能码,这二者一起才能共同决定这条指令对应的具体的汇编指令手动反汇编的时候要用到。

实际上对照这张表格,你就很容易做到反汇编了。拿到一个4B的16进制数,你先把他转换成32位二进制数,然后对照opcode先确定是什么类型,确定好之后再根据具体的funct(如果存在)就能确定是哪一条指令了。确定指令之后,再通过rs,rd推出对应的寄存器号,有立即数的话把立即数也带进去。这样,一整条汇编指令就出现了。


(二)RISC-V每条指令详解

接下来,我要对每一条指令进行说明,大家耐心看一看吧。为了让现象更加明显,我使用c语言内联汇编的方法,把指令执行后的现象给展示出来,方便大家查看,那么大家如果能够跟着实践一遍这样更好。这里我还没有讲到c语言内联汇编的东西,不过有编程基础的应该能够看懂asm语句,我会在c代码后面讲述这么做的目的。如果你先前没有c语言内嵌汇编的基础,你可以先看第二节编程理论。

①加载指令

加载指令load就是把数据从内存加载到寄存器的这一过程。

指令格式 数据位宽 说明
lb rd,offset(rs) 8 把rs寄存器里的值指向的地址作为基地址,在偏移offset的地址处,加载1B的数据经过符号扩展之后放入到rd寄存器里面
lbu rd,offset(rs) 8 作为无符号加载,经过零扩展放入到寄存器rd
lh rd,offset(rs) 16 符号扩展加载2B
lhu rd,offset(rs) 16 零扩展加载2B
lw rd,offset(rs) 32 符号扩展加载4B
lwu rd,offset(rs) 32 零扩展加载4B
ld rd,offset(rs) 64 直接加载到rd寄存器里,不用扩展了
lui rd,imme 64 把立即数imme左移12位,然后符号扩展,再把结果写入到rd寄存器(这里的u是upper的意思,不是unsigned的意思)

RISC-V的指令都挺有规律的,l就是load加载的意思,代表数据从内存加载到寄存器;b是byte的意思,表示1个字节;h是halfword的意思,表示半字,2个字节;w表示word,一个字,4个字节;d表示double word表示双字,就是8个字节。跟在b/h/w/d后面的u是unsigned的意思,表示这是无符号数,不存在符号扩展;直接跟在l后面的是u ,表示这是upper,需要左移。记忆是比较容易的。

我们先进行一些区分:

#include <stdio.h>

int main(void)
{
    long rd = 0;

    char rs[3];
    rs[0] = 'a';
    rs[1] = 'b';
    rs[2] = 'c';

    asm volatile(
        "lb %0,1(%1)    \n\t"
        :"=r"(rd)
        :"r"(rs)
    );
    printf("%c\n",rd);
        return 0;
}

这是一段非常简单的c语言内联汇编的代码,意思就是把rs作为地址传入到寄存器里,再通过lb指令把rs指向的地址作为基地址,偏移了1B的地址里面取出来1B,把这个数据经过符号扩展放入寄存器里,在输出到rd变量。我们打印rd变量,确实是字符b.由于字符b是一个正数,因此符号扩展之后值就是本身。


lb.c

#include <stdio.h>


int main(void)
{
    long rd = 0;
    char rs = -20;

    asm volatile(
        "lb %0,0(%1)    \n\t"
        :"=r"(rd)
        :"r"(&rs)
    );
    printf("%d\n",rd);
        return 0;
}

lbu.c

#include <stdio.h>

int main(void)
{
    long rd = 0;
    char rs = -20;

    asm volatile(
        "lbu %0,0(%1)    \n\t"
        :"=r"(rd)
        :"r"(&rs)
    );
    printf("%d\n",rd);
        return 0;
}

可以看见,即便rs变量的值是-20,如果你使用的是lbu指令,那么就会进行零扩展,符号位就无效了。

从以上这个例子我们不难看出:符号扩展是计算机系统中把小字节转换成大字节的规则之一,它会将符号扩展到所需要的位数。

比如一个1字节的数0x8A,它的最高位也就是第7位是1,那么就需要进行符号扩展,高字节使用1来填充。如果扩展到64位,那么它的值就是0xffff ffff ffff ff8a

而零扩展的就是当成无符号来处理,既然是无符号数,高字节部分使用0来填充。

还有一点要注意的是,符号扩展是小字节往大字节扩展的时候进行的,而ld这一条指令,它本身就是从内存加载一个64位的数到寄存器,没有从小字节到大字节的过程,因此是不需要符号扩展的。


我再测试一下lui指令:

lui.c

#include <stdio.h>

int main(void)
{
    long rd = 0;

    asm volatile(
        "lui %0,0xff    \n\t"
        :"+r"(rd)
    );

    printf("rd = %lx\n",rd);
    return 0;
}

确实是左移了12位,1个16进制的0代表2进制的4位。你也许会很困惑,为什么要左移12位?为什么不是左移13位?为什么不是干脆不左移?

  1. 寻址能力的扩展

lui 指令将 20 位立即数置于寄存器的高 20 位。这样做的目的是允许程序能够引用位于较高地址范围内的内存地址或数据。考虑到 RISC-V 的寄存器是 32 位的,这种设计使得使用 lui 加上一个后续的加法或其他指令(比如 addi),可以访问整个 32 位地址空间。

  1. 高效的常数加载

通过将立即数左移 12 位,lui 指令可以快速地设置寄存器中的高位,这对设置大的常数值非常有效。如果需要加载的立即数不仅仅是高位,可以通过随后的 addi(Add Immediate)等指令来设置剩余的低 12 位。

  1. 指令编码的简化

在 RISC-V 的指令格式中,立即数字段(imm字段)经常被复用以适应不同类型的指令。lui 指令的设计使得指令的立即数字段直接对应于寄存器的高 20 位,从而简化了指令的解码和执行过程。

  1. 支持编译器优化

这种左移 12 位的设计也有助于编译器生成更优化的代码,尤其是在进行全局地址或大范围数据定位的时候。编译器可以更容易地生成用于初始化大数组或访问静态变量的代码。

总之就是,在RISC-V中一条指令总共就4B,能够分配给立即数imme的部分是很有限的,为了能够寻址到“高地址的地方”,于是很多指令都是具有upper的性质,即把其中的立即数左移12位,然后低于12位的部分你可以使用add系列的指令加上来,这样你的寻址能力大大提升,不用再受限于4B指令有限的imme位数能够表示的最大值了。此时你可能会觉得这也太麻烦了,我寻址一下难道还要把一个完成的地址给拆分成高位和低12位,这样组合成地址吗?实际上,你可以手动这样去组合、去拼凑,因为精简指令集本身就是多条指令的组合才能完成一个功能的,而不像x86那样,一条MOV指令打天下。当然,RISC-V的设计者为了程序员方便,它提供了大量的“伪指令”,你使用伪指令之后,伪指令会再拆分成真正的RISC-V汇编指令。有了这些伪指令,编程是不会太麻烦了。

在这个例子你,你可以使用一条伪指令叫做li,这个li就可以把一个立即数放进寄存器里。

li.c

#include <stdio.h>

int main(void)
{
    long rd = 0;

    asm volatile(
        "li %0,0xff    \n\t"
        :"+r"(rd)
    );

    printf("rd = %lx\n",rd);
    return 0;
}

不过你的记得,这是一条伪指令,它不是真正的RISC-V汇编指令,它是多条指令的组合。


②存储指令

存储指令就是加载指令的反义词 --把数据从寄存器移动到内存里。只是它更加简单了,没有符号扩展,直接移动数据即可。

指令 位宽 说明
sb rs2,offset(rs1) 8 把rs2寄存器的低8位的值存储到以rs1寄存器的值为基地址,offset为偏移量的地址处。
sh rs2,offset(rs1) 16 低16位
sw rs2,offset(rs1) 32 低32位
sd rs2,offset(rs1) 64 整个rs2寄存器的值

这个存储指令就是store,把寄存器的值往内存里存,对应的指令类型是S-type.

大家其实也发现了,这个store指令系列对于load来说,简单太多了,没有什么又是u啊又是i的,就是非常单纯的把寄存器值的一部分或者整个寄存器的值,放置到指定的内存地址里面去。这里不需要什么符号扩展、零扩展的。

#include <stdio.h>

int main(void)
{
    char rs[3] = {0};

    asm volatile(
        "li t0,'b'  \n\t"
        "sb t0,1(%0)    \n\t"
        :
        :"r"(rs)
        :"t0","memory"
    );
    printf("rs[1] = %c\n",rs[1]);
    return 0;
}

注意这里我们在扩展内联汇编里直接使用到了寄存器t0,因此在损坏部分要把它写进去,这样在asm嵌入的代码块执行结束的时候会把t0原先的值给恢复回去。


③算术指令

算术指令相对来说是比较重要、用到的场景也是比较多的。

指令 指令格式 说明
add add rd,rs1,rs2 把rs1寄存器的值和rs2寄存器的值相加,并把加法的结果放到rd寄存器里
addi add rd,rs,imme 把rs寄存器的值和立即数imme相加,把结果放到rd寄存器里
addw addw rd,rs1,rs2 截取rs1和rs2寄存器的低32位,相加后把结果进行符号扩展并放到rd寄存器里
addiw addiw rd,rs,imme 截取rs寄存器的低23位并与imme立即数相加,把结果进行符号扩展并放到rd寄存器里
sub sub rd,rs1,rs2 把rs1寄存器里的值减去rs2寄存器里的值,把结果放到rd寄存器里
subw subw rd,rs1,rs2 把rs1寄存器的低32位减去rs2寄存器的低32位,把结果放到rd寄存器里

这个看起来比较简单,实践起来也不复杂。

add.c

#include <stdio.h>

int main(void)
{
    long rs1 = 20;
    long rs2 = 30;
    long rd = 0;

    asm volatile(
        "add %0,%1,%2   \n\t"
        :"=r"(rd)
        :"r"(rs1),"r"(rs2)
    );

    printf("rd = %d\n",rd);
    return 0;
}

sub.c

#include <stdio.h>

int main(void)
{
    long rs1 = 20;
    long rs2 = 30;
    long rd = 0;

    asm volatile(
        "sub %0,%1,%2   \n\t"
        :"=r"(rd)
        :"r"(rs1),"r"(rs2)
    );

    printf("rd = %d\n",rd);
    return 0;
}

怎么样,这样的汇编风格写起来,相比x86来说是不是简单太多了。

到目前为止,我们已经能够使用load系列指令把数据从内存读取到寄存器,也可以使用store系列指令把数据从寄存器存储到内存;那么大家是否好奇应该如何才能把数据从寄存器转移到另一个寄存器?RISC-V没有x86那样的MOV指令,实际上它就是利用了add系列指令配合zero寄存器,进行数据r2r的转移。

我们就使用add指令和zero寄存器来模拟实现x86的mov指令

mov.c

#include <stdio.h>

int main(void)
{
    long reg = 0;

    asm volatile(
        "li t0,0x11223344   \n\t"
        "add %0,t0,zero"
        :"=r"(reg)
    );

    printf("reg = %lx\n",reg);
    return 0;
}

可以看见,我们已经成功地把t0寄存器里的值给转移到了另一个寄存器里面,然后把这个值输出到reg变量里。

实际上RISC-V提供了一个伪指令mv来实现这一过程:

mv.c

#include <stdio.h>

int main(void)
{
    long reg = 0;

    asm volatile(
        "li t0,0x11223344   \n\t"
        "mv %0,t0"
        :"=r"(reg)
    );

    printf("reg = %lx\n",reg);
    return 0;
}

这个mv指令你可以想象成Linux的mv命令,只不过它是从右往左移动,你要知道这个mv伪指令它的本质是add指令,因此它也是把右边寄存器的值赋值到左边。

学到这里你就可以总结出来的,凡是load系列的指令就是把右侧操作数的值赋值到最左侧那个寄存器里,而store系列的指令刚好反过来,把最左侧寄存器的值赋值到右边的内存地址里。在x86架构里,指令通常是2个操作数,而RISC-V里指令通常有3个操作数,就是把运算的结果单独拿出来放置到rd寄存器里,而不是结果存储在原寄存器里。假如你把rd设置成原寄存器,那么效果就和x86一样了,比如add t0,t0,t1.这里面t0既是原寄存器又是目的寄存器。

还有一点不知道大家是否注意到:在x86中有一个自增的指令INC ,也就是说x86架构在已经存在add指令的情况下,还是创造了这么一个+1的指令,而在RISC-V架构里就完全不需要这么做。x86是CISC,它的指令长度不固定的,add指令长度更长、执行时间更久,因此它需要单独创造这么一个INC指令。而RISC-V它的每条指令长度是固定的,这完全没必要说单独创造一个INC自增指令了。


到目前为止,我们已经学习了load加载指令和store存储指令,这些都是真汇编指令,但有的时候,这些指令用起来会不太方便,毕竟不像x86那样一个MOV就能够达到目的。因此,对伪指令的学习也是非常重要的。

程序计数器(Program Counter,PC)是用来指示下一条指令的地址。为了保证CPU能够正确地执行程序的指令代码。就会使用一套PC寄存器来存储这个地址,那么硬件上就只需要把PC指针指向的地址里面的数据当作是代码,然后由指令领取单元IFU把指令送入预译码器并进行预译码。在这里面我们可以看到这个PC寄存器的重要作用,不同指令集给出的PC实现方式也不太一样。比如在x86架构中是使用CS:IP这一对寄存器来指定代码段的位置。而在RISC-V中简化了这一过程,它单纯使用PC寄存器来指定下一条指令的地址。这个PC寄存器,我们不能去读它的位置,但是可以用别的指令去相对PC寄存器进行寻址。

auipc rd,imme

auipc指令就是这么一条,通过PC寄存器进行相对寻址的指令。它的英文名是Add upper immediate to PC.

其中有upper,也就是说这里面的imme立即数也是要左移12位的,这里和上面是一样的。因此它只能寻址到与4KB对齐的地址,如果一个地址是在4KB内存块的内部,则auipc寻址不到它。不过我们也有相应的伪指令可以很方便地去寻址。这个auipc指令,我们用到的其实不太多,程序员用到的更多的是基于它的伪指令,当然这些基于它的伪指令展开还是auipc.

伪指令 指令组合 说明
la rd,symbol

auipc rd,delta[31:12]+delta[11]

addi rd,rd,delta[11:0]

加载符号的绝对地址
la rd,symbol

auipc rd,delta[31:12]+delta[11]

l{b,h,w,d} rd,rd,delta[11:0]

加载符号的绝对地址
lla rd ,symbol

auipc rd,delta[31:12]+delta[11]

addi rd,rd,delta[11:0]

记载符号的本地地址
l{b,h,w,d} rd,symbol

auipc rd,delta[31:12]+delta[11]

l{b,h,w,d} rd,rd,delta[11:0](rd)

把符号内容加载到寄存器里
s{b,h,w,d} rd,symbol,rt

auipc rd,delta[31:12]+delta[11]

s{b,h,w,d} rd,rd,delta[11:0](rt)

存储内容到符号中,其中rt为临时寄存器register tmp
li rd,imne 根据实际情况展开为不同的汇编指令 加载立即数imme到指定的寄存器

指令里面的a就代表了auipc指令。由于这个偏移的特性,你可以在这些伪指令展开成的汇编指令里经常看到delta这样的字眼。如果你自己去计算这些偏移量的话,实在是不方便,因此才提供这些个伪指令,来帮助程序员进行编程。大家在RISC-V汇编语言编程的时候要把这些伪指令给利用起来。

la.c

#include <stdio.h>

void print_hello(){
    printf("Hello World!\n");
}

int main(void)
{
    unsigned long address;
    
    asm volatile(
        "la %0,print_hello  \n\t"
        :"=r"(address)
    );
    printf("the function of print_hello is %p\n",address);
    return 0;
}


④位操作指令

对于计算机底层来说,位运算的速度可以说是最快的了,CPU就喜欢二进制,你能使用位操作可以尽量使用位操作。

移位操作

指令 格式 说明
sll all rd,rs1,rs2 逻辑左移.把rs1寄存器里的值左移rs2位,结果写入到rd寄存器里.
slli slli rd,rs,imme 立即数逻辑左移。左移imme位
slliw slliw rd,rs,imme 把rs寄存器的低32位作为源操作数,左移imme位,把结果进行符号扩展然后放到rd寄存器里。
sra sra rd,rs1,rs2 算术右移,把rs1寄存器的值右移rs2位,根据rs1寄存器的旧值进行符号扩展,然后写入到rd寄存器
srai srai rd,rs1,imme 算术右移imee位
sraiw sraiw rd,rs1,imme

截取rs1寄存器的低32位为源操作数,算术右移imme位,根据源操作数的符号进行符号扩展然后写入到rd寄存器

sraw sraw rd,rs1,rs2 截取rs1寄存器的低32位为源操作数,右移rs2位(取低5位),根据源操作数的符号进行符号扩展,然后写入到rd寄存器。
srl srl rd,rs1,rs2 把rs1寄存器的值逻辑右移rs2位,然后写入到rd寄存器中
srli srli rd,rs,imme 把rs寄存器的值逻辑右移imme位然后写入到rd寄存器中
srliw srliw rd,rs,imme 把rs寄存器的值的低32位作为源操作数,逻辑右移imme位
srlw srlw rd,rs1,rs2 把rs1寄存器的值逻辑右移rs2位(只取rs2的低5位)符号扩展后写入到rd寄存器里。

大家可以总结出来:这些指令都是s开头的,这里的s不是前面的存储指令store的意思,而是位移这个单词shift ,每个人的键盘上都有这个按键。sll就是shift left logical逻辑左移的意思。

a是arithmetic算术的意思,sra是算术右移。

逻辑左移的最高位会丢弃,最低位用0来补充;

逻辑右移的最高位用0补充,最低位丢弃;

逻辑位移不关心符号,就把最高位的符号位当成是一个普通的bit位,同其他bit位的地位是一样的,不认为这个最高位是符号了。

算术右移的最低位会丢弃,最高位会按照符号进行扩展,也就是算术右移是会保留符号的,原来是负数的,右移后仍然是负数。RISC-V中没有算术左移。

不知道大家是否已经观察出来了,凡是带有w的,并且没有i的,都有一个共同点:那就是rs2寄存器只取低5位。因为w是word是32位,它最多左移或者右移32位,因此rs2寄存器只需要低5位即可,2的5次方就是32.

sll.c

#include <stdio.h>

int main(void)
{
    long rd = 2;
    int shift = 3;

    asm volatile(
        "sll %0,%1,%2   \n\t"
        :"=r"(rd)
        :"0"(rd),"r"(shift)
    );

    printf("rd = %d\n",rd);
    return 0;
}

大家可以挨个尝试过去。


⑤比较指令

我们前面学习的汇编指令都是非常基础的,加减法的运算。但是一个稍微复杂一点的程序就需要用到分支、循环、递归等操作,这时候就非常需要比较指令。而比较之后会跳转到不同的执行流,这个时候又需要跳转指令。

基本比较指令:

指令 格式 说明
slt slt rd,rs1,rs2 有符号数比较。如果rs1的值小于rs2,则往rd写入1;否则写入0
sltu sltu rd,rs1,rs2 无符号数比较。
slti slti rd,rs,imme 如果rs1的值小于立即数imme则往rd写入1;否则写入0
sltiu sltiu rd ,rs1,imme 无符号数的比较

slt指令的英文名是Set Less Than

就是说如果rs1小于rs2,那么值位(把rd写入1)

这样是不是就很好理解了?

slt.c

#include <stdio.h>

int main(void)
{
    long rs1 = 3,rs2 = 5;
    long rd = 0;

    asm volatile(
        "slt %0,%1,%2   \n\t"
        :"=r"(rd)
        :"r"(rs1),"r"(rs2)
    );

    printf("rd = %d\n",rd);
    return 0;
}

这个用法还是比较简单的。

sltu.c

#include <stdio.h>

int main(void)
{
    long rd = 0;
    long rs1 = 20,rs2 = -1;

    asm volatile(
        "sltu %0,%1,%2  \n\t"
        :"=r"(rd)
        :"r"(rs1),"r"(rs2)
        :"memory"
    );

    printf("rd = %d\n",rd);
    return 0;
}

由于sltu是无符号的比较,虽然-1是负数,但是它作为一个无符号数是比20大的,因此输出1.

比较伪指令:

指令 格式 说明
sltz sltz rd,rs rs小于0则值位rd
snez snez rd,rs rs不等于0则值位rd
seqz seqz rd,rs rs等于值位rd
sgtz sgtz rd,rs 大于0则值位rd

这里面的s是set值位的意思 ,q是equal的意思 ,n是not equal , z是zero ,t是then


⑥跳转指令
指令 格式 说明
jal jal rd,offset 跳转到PC+offset的这个地址,然后把PC+4(这是返回地址)放置到rd寄存器里,跳转范围是[PC-1MB,PC+1MB]
jalr jalr rd,offset(rs) 跳转到以rs寄存器的值为基地址且偏移offset的地址,然后把PC+4放置到rd寄存器里,offset的范围是-2048~2047

跳转指令是非常重要的,但是上面指令用起来非常非常麻烦,还要自己去计算偏移量,那么在这种情况下,RV肯定会设计一套伪指令方便程序员使用。

伪指令 格式 说明
j j label 跳转到label处,不带返回地址
jal jal label 跳转到label处,返回地址保存在ra寄存器中
jr jr rs 跳转到rs寄存器中保存的地址,不带返回地址
jalr jalr rs 跳转到rs寄存器中保存的地址,返回地址保存在ra寄存器中
ret ret 从ra寄存器中获取返回地址,并返回。常用于函数返回
call call func 调用函数func ,返回地址保存在ra寄存器中
tail tail func 调用函数func ,不保存返回地址

j.c

#include <stdio.h>

int main(void)
{
    long result = 0;

    asm volatile(
        "addi %0,%0,1   \n\t"
        "j 1f           \n\t"
        "addi %0,%0,1   \n\t"
        "1:"
        :"+r"(result)
        :
        :"memory"
    );

    printf("result = %d\n",result);
    return 0;
}

我初始化了一个变量result为0 ,如果是正常执行下来的话,就会执行两次addi指令变成1 ,然是有使用j指令跳转到了1这个标签的位置,于是结果就是只会执行一次addi

jal_ret.c

#include <stdio.h>

int main(void)
{
    long result = 0;

    asm volatile(
        "addi %0,%0,1           \n\t"
        "mv t0,ra               \n\t"
        "jal next_label         \n\t"
        "addi %0,%0,1           \n\t"

        "mv ra,t0               \n\t"
        "j over                 \n\t"

        "next_label:            \n\t"
        "ret                    \n\t"
        "over:                  \n\t"
        :"+r"(result)
        :
        :"memory","t0"
    );

    printf("result = %d\n",result);
    return 0;
}

在这一段代码就展示了jal会保存ra的跳转,和ret通过ra寄存器的值返回去。你需要注意的时候,在使用jal之前你要保存当前ra的值,否则的话你的main函数就无法返回了,会出现段错误的。因此在jal执行之前保存ra到t0寄存器,然后跳转出去之后再把main函数的返回值从t0寄存器写回到ra中。

跳转指令在往ra寄存器保存返回地址的时候是PC+4 ,我们知道RISC-V每一条指令的长度是固定的4字节,因此这个返回地址其实就是跳转指令的下一条指令。

上面是无条件跳转指令,就是执行到就跳转到,不需要做任何判断的,c语言的goto大家可以理解成一个无条件的跳转。而下面的是有条件跳转指令,就是说会根据实际情况选择性的是否要跳转。实际上无条件的跳转指令还是在内核开发的时候用到的比较多,而对用户程序而言,更多的是在分支或者循环的时候做判断,根据判断去执行不同分支的代码。

指令 格式 说明

beq

beq rs1,rs2,label 如果rs1和rs2的值相等,则跳转到label
bne bne rs1,rs2,label 如果rs1和rs2的值不相等,则跳转到label
blt blt rs1,rs2,label 如果rs1 < rs2 ,则跳转到label
bltu bltu rs1,rs2,label 视为无符号数
bgt bgt rs1,rs2,label 如果rs1 > rs2 ,则跳转到label
bgtu bgtu rs1,rs2,label 视为无符号数
bge bge rs1,rs2,label 如果rs1 >= rs2 ,则跳转到label
bgeu bgeu rs1,rs2,label 视rs1和rs2寄存器里的数为无符号数

 这里面的b是branch的意思,大家应该对git branch都不陌生,它就是分支的意思。如果满足条件则进入一个分支,如果不满足则进入另一个分支。就是这个意思。

beq.c

#include <stdio.h>

int main(void)
{
    long rs1 = 114514,rs2 = 114514;
    long result = 0;

    asm volatile(
        "beq %1,%2,add_result_1       \n\t"
        "addi %0,%0,20                \n\t"
        "add_result_1:                \n\t"
        "addi %0,%0,1"
        :"+r"(result)
        :"r"(rs1),"r"(rs2)
    );

    printf("result = %d\n",result);
    return 0;
}

在x86中,如果要进行分支判断,首先还得要进行cmp比较,比较完了之后才使用jz等指令进行跳转。而在RISC-V中,不需要做两步这么麻烦,一条指令就足以完成比较和跳转的过程。

伪指令 本质 说明
beqz rs,label beq x0,label rs == 0 跳转到label
bnez rs,label bne rs,x0,label rs != 0 跳转到label
blez rs,label bge x0,rs,label rs <= 0 跳转到label
bgez rs,label bge rs,x0,label rs >= 0 跳转到label
bltz rs,label blt rs,,x0,label rs < 0 跳转到label
bgtz rs,label btg x0,rs,label rs > 0 跳转到label
bgt rs,rt,label blt rt,ra,kabel rs > rt 跳转到label
ble rs,rt,label bge rt,rs,label rs == rt
bgtu rs,rt,label bltu rt,rs,label rs > rt 无符号数
bleu rs,rt,label bleu rs,rt,label rs <= rt 无符号数

现在大家应该明白了:这个x0寄存器有多么重要,并不是说我们指令里不常用到,它就不重要了,而是伪指令展开之后很多时候就有这个x0寄存器的身影。当你看见这个带有z的伪指令,那么大概率它就是和zero寄存器有关了。


二、RISC-V指令集的编程理论

我们到目前为止已经学习和体验了单条的RISC-V汇编指令的作用,但是实际编程是复杂且困难的。因此我想先从c语言内嵌汇编语言讲起,这个c语言是简单、直接的,大家学起来也很方便。很多时候学好c嵌入汇编比写汇编本身更加重要,因为手写汇编的地方通常都是OS的启动部分,而一旦启动了,那基本上就是在c语言里调用汇编写的函数或者是c语言里嵌入汇编代码。

1、c语言内嵌汇编

c语言内嵌汇编有两种形式:基础内嵌汇编扩展内嵌汇编。在x86架构上这两种内嵌汇编的形式语法还不太一样,而在RISC-V中,这两种形式的语法是几乎一样的,节省了学习成本。

基础内嵌汇编就是在c语言汇编成汇编语言的时候单纯的把asm语句里的RISC-V汇编给插入进去,或者说是嵌入进去。

扩展内嵌汇编是在基础内嵌汇编的基础上,允许带上输入输出参数,也就是把c语言的变量给输入到汇编语言里,把汇编语言里输出的结果返回给c语言,让这两种在语法完全不同的编程语言进行交互,这样既能享受到c高级语言编程的便利、又能得到底层汇编语言对寄存器和设备的控制。当然,c语言汇编之后它的本质还是汇编语言,你可以把c语言当作是对汇编语言的封装。这二者的目的都是最终生成机器码。

基础内嵌汇编:

asm ("汇编指令")

基本内嵌汇编提供了一种简单的方法来嵌入裸汇编代码。在这种模式下,编译器对嵌入的汇编代码本身不做优化,因为它没有足够的信息来理解这些汇编指令的具体作用。编译器仅将这些汇编代码作为黑盒插入到生成的机器代码中。


asm 修饰词(
    "汇编指令    \n\t"
    "汇编指令    \n\t"
    :输出部分
    :输入部分
    :损坏部分
);

扩展内嵌汇编允许你详细说明汇编指令与C程序中的变量之间的关系,包括输入输出约束和副作用。这种详细的信息使得编译器能够更好地理解汇编代码的意图,因此在保持语义正确的前提下,编译器可以对这些汇编代码进行优化,如重排指令、删除冗余代码等。

asm这个关键字是GNU的一个扩展。汇编指令就是我们前面讲到过的一条条的汇编指令。

  • GCC会把汇编代码块当成一个字符串
  • GCC不会解析和分析汇编代码块
  • 如果你在asm语句里要写多条汇编代码,你得像我这样用\n\t隔开来

修饰词主要有如下几个:

  • volatile:确保这部分代码在编译时不会被优化掉,从而保证程序的正确执行(实际上大多数情况都是使用volatile) 特别是涉及到底层控制寄存器的时候。
  • inline:告诉GCC,把汇编代码编译成尽可能短的代码
  • goto:复杂的控制流,在内嵌汇编代码里跳转到c语言的标签处。

每条指令加上双引号,在指令末尾来几个空格或者Tab然后加上\n\t.最好是把指令给对齐,这样能够美观一点,不会看起来太乱。如果你没有输入或输出部分可以在分号后面什么都不写。

这个扩展内联汇编的用法的话我觉得,光是用文字去描述它是比较困难的,你只需要看几个例子,就可以举一反三去写了。

输出部分常用的修饰符

修饰符 说明 例子
= 被修饰的操作数具有只读属性 :"=r"(var)
+ 被修饰的操作数具有可读写的属性 :"+r"(var)
& 表明该操作数在输入参数的指令执行完成之后才能写入 :"=&r"(var)

例子里的r是寄存器的意思,表明要输出的操作数是在寄存器里的。它除了可以是寄存器还可以被放在别的地方:

操作数约束符 说明
p 内存地址
m 内存变量
r 通用寄存器(具体放在哪一个寄存器是由GCC来分配)
o 内存地址,使用基地址寻址
i 立即数
V 内存地址,不允许偏移的内存操作数
n 立即数

这里面实际上用到最多的就只有m和r.

RISC-V中特有的操作数的约束符:

约束符 说明
f 表示浮点数寄存器
I 表示12位有符号的立即数
J 表示值为0的整数
A 表示存储到通用寄存器中的一个地址(用于原子操作里)
K 表示5位无符号的立即数,用于CSR访问指令

输入部分相比输出部分更加简单了,只需要用上一个约束符,后面跟上c语言里的变量即可。约束符和输出部分一样。

修补部分:

  • "memory" 告诉GCC,如果内嵌汇编代码中改变了内存中的值,那么在执行完内嵌汇编代码之后重新加载该值,防止编译乱序。实际上你可以一直带着这个参数。
  • "cc" 表示内嵌代码修改了状态寄存器的相关标志位,在执行完之后恢复PSW
  • 还可以跟上寄存器的名字,比如"t0" ,就是说你在汇编代码里使用到了t0.如果GCC在编译的时候也使用t0寄存器,那不是乱套了吗?就是告诉GCC让他暂时别用t0寄存器,t0寄存器现在归我所有。

当你已经写好输入输出部分之后,你需要使用到这些变量,可以使用索引法和命名法。我会用具体的例子来描述这两种方法的不同风格。

索引法就是%0 %1 %2 %3这样去索引,输入部分和输出部分是共用使用这个索引的。

index.c

#include <stdio.h>

int main(void)
{
    unsigned char a = 0x55,b = 0xaa;
    unsigned short int x = 0;

    asm volatile(
        "slli t0,%1,8   \n\t"
        "add %0,%2,t0   \n\t"
        :"=r"(x)
        :"r"(a),"r"(b)
        :"memory","t0"
    );

    printf("x = %#x",x);
    return 0;
}

从输出部分开始计算,从0开始索引,按照顺序一次延续下去。

这个程序就是通过左移与加法合成了那个著名的魔数0x55aa,相信写过MBR的同学都不陌生。

命名法就是给你传入或者传出的变量取一个(通俗易懂)名字,这样编程起来看起来比较清楚,特别是当传入传入的变量数量太大的时候,你用索引法一个一个去找很容易看错,眼花缭乱的,而且也容易忘记。

我们稍微修改一下index.c

name.c

#include <stdio.h>

int main(void)
{
    unsigned char a = 0x55,b = 0xaa;
    unsigned short int x = 0;

    asm volatile(
        "slli t0,%[a],8   \n\t"
        "add %[x],%[b],t0   \n\t"
        :[x]"=r"(x)
        :[a]"r"(a),[b]"r"(b)
        :"memory","t0"
    );

    printf("x = %#x\n",x);
    return 0;
}

可以看见,就是在""的前面加了[]然后在[]里写上你想给它取的名字;名字取号之后,你想在汇编部分使用它就只需要%[name]这样就好了,用命名法你就不用从0开始一个一个数过去这么麻烦了。实际上命名法和索引法是通用的,也就是你即便给它取了个名字,它仍然占据一个索引,你还是可以用%0 %1 %2去索引它。

虽然从语法上来看,这个c语言内联汇编(英文名就是inline,只是为了和inline函数区分,我这里通常叫做内嵌汇编)好像不是太难,不需要记忆并且很好理解,语法也算美观。但是实际写起来你要考虑的东西有很多。因为输入输出部分怎么安排寄存器这都是GCC帮你完成的,GCC它只是个程序,它不是AI也不是真人,它给你分配的寄存器不一定就是你想要的,你要考虑的东西有很多。

我们现在来看一段代码:

test.c

请大家猜想一下运行之后是什么结果。

我相信大多数新手都会觉得应该输出的是a = 20,b = 20 。你也许会认为这不就是输入的a加上10,b也加上10,再输出回来吗?这完全符合前面原理部分的例子。

然而事实是:

然而事与愿违,a确实是20 ,但是b是30.

你也许会很疑惑,但是我们只要从汇编语言的角度去看就好了。

riscv64-unknown-linux-gnu-objdump -d test

这个问题的本质就是出现了寄存器的重用

   1051c:       fe843783                ld      a5,-24(s0)
   10520:       fe043703                ld      a4,-32(s0)
   10524:       00a78713                addi    a4,a5,10
   10528:       00a70793                addi    a5,a4,10

根据反汇编出来的结果,GCC先是把两个变量a和b分别加载到a4和a5寄存器 。然后把a5加上10的结果保存到a4寄存器,当前a4的值已经是20了,然后在执行a4加10,那可不就是30了嘛。它重用了这个寄存器,按照我们的意图,应该是两个变量分别进行addi才对,怎么能拿寄存着对方的值的寄存器进行运算呢?

因此我们在c内嵌汇编的时候一定要注意这个问题,在这里面输入和输出都是变量a和b,那么我们就不需要输入和输出都写进去了。在修饰符里有一个符号是+,你只需要把输出部分的=换成+那么问题就可以迎刃而解了。

修改后的test.c

也就是说你不需要输入部分也写,输出部分也去写。你只需要在输出部分用+这个符号,它是可读写的,这个变量放置到寄存器里,相当于既是输入,也是输出。这种做法也更加简洁,因为两个索引总是比四个索引看起来更加清楚。

除了上述方法之外,你还可以这样:

大家可以看到,最大的不同就是它没有重用这个寄存器了,而是两个变量两个加法分别使用不同的寄存器。变量a使用到a4和a3寄存器;而变量b使用到a5和a2寄存器。这样也可以解决问题。

除此之外,还有一种做法:

就是把输入和输出部分绑定到一起,也就是告诉GCC,这个输入的a和输出的a放到同一个寄存器里面去,b也一样。

大家可以看到,这种做法汇编出来的结果,似乎又比上一种更胜一筹,它直接在原来的寄存器里进行运算,不要引入更多寄存器进来,因为根本也没有这个必要去引入,在原来的寄存器里可以完成运算了。这样的做法本质上是和+是一样的,只是用+符号更加简洁、清晰。我的话肯定用+符号的。


2、GNU as汇编器

也叫做gas ,虽然它叫gas ,但是它的程序名就是as ,你在终端得敲as而不是gas.在第一期的最后我们已经完成了RISC-V的第一个汇编程序,就是不断地执行死循环,这个程序是无法自己退出的。

相信大家通过上面的c语言内嵌的汇编程序都已经有能力使用单独的RISC-V汇编指令了,但是把这些指令排列组合起来写成汇编程序可能还是会感到有点困难。

GNU的汇编程序一般是.S或者.s为扩展名,其中.S通常表示的是未宏展开的原始汇编,也就是带有.include之类的伪指令的程序,而.s通常是已经预处理过了的程序。


本文篇幅也比较长了,RISC-V汇编语言的练习我会放到下一篇文章里。


网站公告

今日签到

点亮在社区的每一天
去签到