目录
写在前面:
这篇文章是跟我学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种。
- R-type:寄存器与寄存器算术指令,这里的R就是register寄存器的意思;
- I-type:寄存器与立即数算术指令或者加载指令;
- S-type:存储指令(和上面的加载指令刚好是反义词);
- B-type:条件跳转指令;
- U-type:长立即数操作指令;
- 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,需要左移。记忆是比较容易的。
还有一点我必须要强调一下:凡是涉及到寄存器寻址的,就是通过寄存器的值去访问内存地址的,你必须加上(),比如lb t0,(t1),你要访问的是t1寄存器的值作为地址访问内存的这个地方。千万记住不要漏了,这一点和x86是一样的。
我们先进行一些区分:
#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是一个正数,因此符号扩展之后值就是本身。
指令的用法其实非常简单,如果想把RISC-V吃透,还是得学会手动反汇编,或者说知道如何把RISC-V汇编语言指令与机器码对应起来。
我们可以看见:lb t0,(t1) 对应的机器码是0x00030283. 确实是4个字节。(如果使用了c压缩指令集,那么指令的长度固定为2字节,这个以后再探讨)
可以使用进制转换,把它变成二进制数方便手动反汇编。
我们对二进制进行如图所示的拆分:
- opcode:0000011 这和lb的opcode相符合
- rd:00101,转换成十进制就是5,x5寄存器的别名就是t0
- func:000,相符合
- rs1:110,转换成十进制就是6,x6寄存器的别名就是t1
- 高位imme用不到,全是0
从这个例子我们就能清晰地观察到为什么rs和rd寄存器的占用 的位数都是5个二进制位,这样正好可以表示从x0到x31总共32个寄存器。
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位的数到寄存器,没有从小字节到大字节的过程,因此是不需要符号扩展的。
我们开始反汇编lbu指令:
机器码和前面的lb是非常非常相似,就是func这个地方,从000变成了100,从这个例子我们不难看出:opcode只能决定这条指令是什么类别,比如都是load系列的指令,但是仅凭opcode还无法确定就是是哪一条汇编指令。必须配合func才能知道具体的指令名。
我再测试一下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位?为什么不是干脆不左移?
寻址能力的扩展:
lui
指令将 20 位立即数置于寄存器的高 20 位。这样做的目的是允许程序能够引用位于较高地址范围内的内存地址或数据。考虑到 RISC-V 的寄存器是 32 位的,这种设计使得使用lui
加上一个后续的加法或其他指令(比如addi
),可以访问整个 32 位地址空间。
高效的常数加载:
通过将立即数左移 12 位,
lui
指令可以快速地设置寄存器中的高位,这对设置大的常数值非常有效。如果需要加载的立即数不仅仅是高位,可以通过随后的addi
(Add Immediate)等指令来设置剩余的低 12 位。
指令编码的简化:
在 RISC-V 的指令格式中,立即数字段(imm字段)经常被复用以适应不同类型的指令。
lui
指令的设计使得指令的立即数字段直接对应于寄存器的高 20 位,从而简化了指令的解码和执行过程。
支持编译器优化:
这种左移 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汇编指令,它是多条指令的组合。并且这个组合不是固定的(你不能去背),而是要根据实际情况去“load”这个数据。
“纸上得来终觉浅”,你只有实际地去反汇编过才会得到这样的编程体验。li指令在加载小立即数和大立即数的时候展开成的汇编是不一样的。它的本质就是把一个数拆成高位和低位,高位使用带有"upper"的指令(因为upper指令会自动左移12位),在把低位给加上去;
如果立即数再变大也只是重复这一过程。slli这条指令是左移12位,也就是说每一次处理12位的数据,那么加载一个64位的数总共需要处理5次(16 + 12 + 12 + 12+ 12).最终把这个数给合成出来,这个过程只需要使用一个寄存器(这个例子里是t0)就好,不需要拿其他寄存器当“草稿纸”。现在大家能够体会到这个RISC精简指令集设计的特点了吗?一个功能是需要多条简单指令的排列组合,最终合作一起完成。而li仅仅是为了程序员方便而设计的伪指令,这样的设计对编译器、汇编器要求比较高,所以整个底层(硬件、编译器、操作系统)它们的发展是相辅相成的。
虽然li在加载大立即数的时候需要展开成这么多的指令,但实际上gas编译器是非常聪明的:
如图所示:如果我要加载0xffffffffffffffff,它在寄存器里的值本质上就是-1.
学到了这里,我们可以再一次进行总结。当你加载一个很大的数的时候,不需要扩展指令的长度,也就是说RISC-V汇编的每一条指令还是4B,不是说RV64是位的就要把指令扩展成8B,不需要这样。而是说,当你需要加载一个很大的数,它会展开成更多的指令,用指令的数量来达成加载大数的目的。因此,RISC-V的指令的长度是固定的(不考虑压缩指令),不需要随着你的操作数的位数变大而变大。那么操作数变大会带来什么影响呢?就是展开之后的指令长度会变大,指令数量更多了。复杂指令可以通过简单指令的排列组合来实现,这本来就是RISC的特点和优势。那么劣势就是展开后的指令数量增多,那么整个程序的体积就增大。但是我们现在的计算机内存都16GB起步了,这么一点体积增大真的算不得什么,但是换来的是底层硬件的简洁、直接,冗余的部分就减少,那么相同体积下就可以塞下更多的晶体管、而且少了冗余电路,芯片的发热量容易控制。这样就实现了减少冗余,把晶体管提供的算力都花在刀刃上的这一目标。
②存储指令
存储指令就是加载指令的反义词 --把数据从寄存器移动到内存里。只是它更加简单了,没有符号扩展,直接移动数据即可。
指令 | 位宽 | 说明 |
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;
}
blt.c
#include <stdio.h>
int main(void)
{
long rs1 = 1,rs2 = 2;
long result = 0;
asm volatile(
"blt %1,%2,over \n\t"
"addi %0,%0,1 \n\t"
"addi %0,%0,1 \n\t"
"over: \n\t"
:"+r"(result)
:"r"(rs1),"r"(rs2)
:"memory"
);
printf("result = %d\n",result);
return 0;
}
可以看见,当rs1小于rs2的时候直接跳转到了over地址退出去,于是中间的两行addi都没有执行。
bgt.c
#include <stdio.h>
int main(void)
{
long rs1 = 1,rs2 = 2;
long result = 0;
asm volatile(
"bgt %1,%2,over \n\t"
"addi %0,%0,1 \n\t"
"addi %0,%0,1 \n\t"
"over: \n\t"
:"+r"(result)
:"r"(rs1),"r"(rs2)
:"memory"
);
printf("result = %d\n",result);
return 0;
}
其结果一目了然,不符合跳转的条件,没有进行跳转,于是执行了这两条addi使result变成了2.
在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寄存器有关了。
带目前为止,我们已经学习了RISC0-V I标准的指令了,我们已经总结出了很多规律,是时候拿RISC和CISC进行对比了。
类别 | CISC | RISC |
指令设计 | 复杂、庞大 | 精简、简单 |
数量数目 | 容易膨胀、数量大 | 数量数量增长速度慢 |
指令字长 | 不固定 | 定长 |
可访存指令 | 不加限制 | 只有load/store指令 |
不同指令的执行时间 | 相差较大 | 大多数指令在一个时钟周期完成 |
不同指令使用的频率 | 少数指令占比很高 | 都比较常用 |
通用寄存器的数量 | 较少 | 较多 |
目标代码 | 编译优化效果相对不明显 | 编译优化效果相对明显 |
控制方式 | 微程序控制 | 组合逻辑控制 |
指令流水线 | 通过一定方式实现 | 必须实现 |
CISC:由于指令复杂,流水线实现较难,但可以通过复杂的控制逻辑实现。
RISC:RISC设计的简洁指令更适合流水线操作,流水线是RISC提高性能的关键机制
但总来来说,学习RISC-V比起学习x86要简单得多,不需要实模式一种编程方式、保护模式又一种编程方式。对于CPU指令设计的本质也更加得以体现,我建议《计算机组成原理》这门课完全可以替换成RISC-V进行教学讲解了,毕竟RISC-V本身就出自伯克利大学,具有学术的设计思想。
二、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、Rust内嵌汇编
随着Rust进入Linux内核,Rust编程语言进行底层编程,特别是驱动开发变为了一种潮流。那么作为一门底层语言,它和c语言一样,是可以内嵌汇编语言代码的,也就是说你完全可以使用Rust来写一个内核。
Rust 内嵌汇编https://doc.rust-lang.org/reference/inline-assembly.html
目前支持了上面几种指令集,其中就有RISC-V. 那么我们就有必要来学习一下Rust编程语言去嵌入RISC-V汇编编程。
use std::arch::asm;
fn main() {
let a: i32 = 20;
let mut b: i32 = 0;
unsafe {
asm!(
"lb {0}, ({1})",
out(reg) b,
in(reg) &a
);
}
println!("b: {}", b);
}
这是我写的一段Rust内嵌汇编。就是lb指令的使用。
在使用Rust内联汇编之前必须导入这个库,这算是固定的用法了,大家也不用专门去记。
由于内嵌汇编是不安全的用法,是不会受到Rust编译器的检查的,因此asm语句必须被放在unsafe代码块里。
内嵌汇编代码需要写在asm宏里,因此asm后面有一个! ,然后先写汇编语句,汇编语句用双引号引起来,每一行语句后面都需要带一个,
然后{} 符号内写索引,这里的索引不再需要像c内嵌汇编那样用%了,你直接写数字进去就好。寻址仍然使用()来表示。
Rust内嵌汇编也是有输入和输出部分,只是它具有Rust特色,和c语言完全不同。并且用法非常丰富,你需要一段时间的练习才能掌握它。
-
in(<reg>) <expr>
:<reg>
可以是寄存器类或一个明确的寄存器。分配的寄存器名称会替换到汇编模板字符串中。- 分配的寄存器在汇编代码开始时包含
<expr>
的值。 - 在汇编代码结束时,分配的寄存器必须包含相同的值(除非一个
lateout
操作数分配到相同的寄存器)。
-
out(<reg>) <expr>
:<reg>
可以是寄存器类或一个明确的寄存器。分配的寄存器名称会替换到汇编模板字符串中。- 分配的寄存器在汇编代码开始时包含一个未定义的值。
<expr>
必须是一个(可能未初始化的)位置表达式,汇编代码结束时,分配的寄存器内容会被写入到该表达式。- 可以用下划线(
_
)代替表达式,这样寄存器的内容在汇编代码结束时会被丢弃(实际上起到clobber的作用)。
-
lateout(<reg>) <expr>
:- 类似于
out
,但寄存器分配器可以重用分配给in
的寄存器。 - 你应该在读取所有输入后才写入寄存器,否则可能会破坏一个输入值。
- 类似于
-
inout(<reg>) <expr>
:<reg>
可以是寄存器类或一个明确的寄存器。分配的寄存器名称会替换到汇编模板字符串中。- 分配的寄存器在汇编代码开始时包含
<expr>
的值。 <expr>
必须是一个可变的初始化位置表达式,汇编代码结束时,分配的寄存器内容会被写入到该表达式。
-
inout(<reg>) <in expr> => <out expr>
:- 类似于
inout
,但寄存器的初始值取自<in expr>
的值。 <out expr>
必须是一个(可能未初始化的)位置表达式,汇编代码结束时,分配的寄存器内容会被写入到该表达式。- 可以用下划线(
_
)代替<out expr>
,这样寄存器的内容在汇编代码结束时会被丢弃(实际上起到clobber的作用)。 <in expr>
和<out expr>
可以具有不同的类型。
- 类似于
-
inlateout(<reg>) <expr>
/inlateout(<reg>) <in expr> => <out expr>
:- 类似于
inout
,但寄存器分配器可以重用分配给in
的寄存器(如果编译器知道in
具有与inlateout
相同的初始值)。 - 你应该在读取所有输入后才写入寄存器,否则可能会破坏一个输入值。
- 类似于
-
sym <path>
:<path>
必须指向一个函数或静态变量。- 一个指向该项的符号名称会替换到汇编模板字符串中。
- 替换的字符串不包含任何修饰符(例如GOT、PLT、重定位等)。
<path>
可以指向一个带有#[thread_local]
标记的静态变量,在这种情况下,汇编代码可以结合符号与重定位(例如@plt,@TPOFF)来读取线程局部数据。
3、GNU as汇编器
也叫做gas ,虽然它叫gas ,但是它的程序名就是as ,你在终端得敲as而不是gas.在第一期的最后我们已经完成了RISC-V的第一个汇编程序,就是不断地执行死循环,这个程序是无法自己退出的。
相信大家通过上面的c语言内嵌的汇编程序都已经有能力使用单独的RISC-V汇编指令了,但是把这些指令排列组合起来写成汇编程序可能还是会感到有点困难。
GNU的汇编程序一般是.S或者.s为扩展名,其中.S通常表示的是未宏展开的原始汇编,也就是带有.include之类的伪指令的程序,而.s通常是已经预处理过了的程序。
test.S
.section .data
.align 3
my_data1:
.word 100
my_data2:
.word 20
print_data:
.string "data: %d\n"
.section .text
.align 3
.global _start
_start:
addi sp,sp,-16
sd ra,8(sp)
lw t0,my_data1
lw t1,my_data2
add a1,t0,t1
la a0,print_data
call printf
li a0,0
ld ra,8(sp)
addi sp,sp,16
li a7,93
ecall
这是一个简单的程序,它的目的是做一个加法然后把结果通过printf函数打印出来,如果用c语言实现这个功能是再简单不过了,但是用汇编写起来就会比较麻烦。
如果你是在虚拟机环境里可以这样运行它:
as test.S -o test.o
ld test.o --dynamic-linker /lib/ld-linux-riscv64-lp64d.so.1 -lc
你需要手动指定一个动态链接库。
RV的gas汇编中使用 // 或者 # 实现单行注释,使用/**/ 实现多行注释,这和c语言是类似的。
test.S中出现了.section .text和.section .data ,这是定义了一个段,表示接下来的代码会被认为属于这个段里
.section name ,"flags"
name表示段的名称,flags表示段的属性.
常见的段名称有如下这些:
1. .text
- 用途:这个段包含程序的可执行指令。它是程序的主体,包含所有的代码逻辑。
- 属性:通常设置为只读和可执行,防止程序运行时修改其代码。
2. .data
- 用途:用于存储程序的初始化全局变量和静态变量。这些变量在程序启动前由程序或操作系统初始化。
- 属性:通常设置为可读写,因为这些变量在程序运行期间可能会被修改。
3. .bss
- 用途:用于存储未初始化的全局变量和静态变量。与
.data
段不同的是,.bss
段中的变量在程序启动时被自动初始化为零。 - 属性:通常设置为可读写。
4. .rodata
- 用途:用于存储只读数据,如常量字符串和其他只读值。
- 属性:设置为只读,确保这些数据在程序运行时不被修改。
5. .heap
- 用途:虽然
.heap
段通常不在汇编代码中直接定义,它是动态分配的内存区域,用于存储程序运行时动态创建的对象和变量。 - 属性:可读写。
6. .stack
- 用途:同样,
.stack
段也不在汇编代码中定义,但它是用于存储程序运行时的函数调用栈。栈用于保存函数参数、返回地址和局部变量。 - 属性:可读写。
7. .init 和 .fini
- 用途:
.init
段包含程序启动前执行的初始化代码,而.fini
段包含程序退出前执行的清理代码。 - 属性:这两个段通常都是可执行的。
8. .ctors 和 .dtors
- 用途:这些段用于存储构造函数和析构函数的列表,这些函数分别在程序启动和结束时自动调用,用于对象的全局初始化和清理。
- 属性:通常设置为可读写。
这些段其实是非常重要的,GCC在生成目标代码的时候就是会自动产生这些段并把代码和数据安排进去,你还需要自己写link script去把输入段转换成输出段,自行安排段的位置。
不过对于初学者而言,你用好.text段和.data段就好了,学多一点之后可以加一个.stack栈段进来。
以下是常用的属性:
属性 | 说明 |
a | 段具有可分配的属性 |
d | 具有GNU MBINE属性 |
c | 段被排除在可分配和共享库之外 |
w | 段具有可写的属性 |
x | 可执行 |
M | 可合并 |
S | 段包含零终止字符串 |
G | 是段组的成员 |
T | 段用于线程本地存储 |
在test.S中的.data段里,我定义了一些数据
定义数据的伪指令 | 说明 |
.byte | 把8位数插入到汇编代码中 |
.hword / .short | 把16位数插入到汇编代码中 |
.long / .int | 把32位数插入到汇编代码中 |
.word | 把32位数插入到汇编代码中 |
.quad | 把64位数插入到汇编代码中 |
.float | 把浮点数插入到汇编代码中 |
.asciz / .string | 把字符串插入到汇编代码中(自动添加\0到末尾) |
.ascii | 把字符插入到汇编代码中 |
.rept - .endr | 重复执行伪操作 |
.equ | 给符号赋值 |
test.S中出现了两次.align 3
这个伪指令是用来对齐和填充数据的。
.align n
就是按照2的n次方对齐.
我会把我写的一些代码放到这里,会慢慢更新下去。如果有人愿意看的话。