一文学懂risc-v汇编操作

汇编指令

文章目录

    • 汇编指令
        • 常见寄存器
        • 常见指令操作
          • 算术运算
          • 访存运算 (核心)
            • 先上表
            • 核心解释
            • 最后上代码
          • 逻辑运算
          • 移位操作
          • 分支指令(核心: 条件与循环)
            • 使用分支实现if语句
            • 汇编if的技巧
            • 使用分支实现循环语句
            • 汇编循环的技巧:
          • 跳转指令 (核心:函数的调用)
        • 使用汇编写完整的程序
          • 伪指令
          • 数组
          • 栈指针
          • 保存寄存器与临时寄存器
          • 参数寄存器和返回值寄存器
          • 案例
        • 下一步:设计一个risc-v 的cpu

常见寄存器

risc-v 有32个通用寄存器(简写 reg),标号为x0 - x31

寄存器名 别名 功能 备注
x0 硬连线接0 任何对x0的写入操作都无效,x0的值恒定为0
x1 ra 返回地址 return address
x2 sp 栈指针 栈指针始终指向栈顶
x3 gp 全局指针 一般用不到,指向全局变量和静态变量
x4 线程指针
x5-x7,x28-x31 t0-t6 临时寄存器 temporary的首字母 t , 共有7个
x8-x9, x18-x27 s0-s11 保存寄存器 saved 的首字母 s, 共有11个,其中s0与s1是返回值reg
x10-x17 a0-a7 参数寄存器 arg 的首字母a , 共有8个
x8 fp 帧指针 使用时需要用栈指针初始化,默认情况下不使用

对于有别名的寄存器,优先使用别名,更达意,更好记

先认识即可,后续会逐渐认识它们的作用。为啥寄存器只有32个?寄存器的运算和读取速度是最快的,太少了显然不好,但事实证明,寄存器数目如果太多了会导致访问寄存器的速度下降,也会造成速度下降。32个寄存器是risc-v设计者、实践者的选择。
上述寄存器都是64位的,也就是说上述寄存器可以存放64bits的数据

常见指令操作

在risc-v 中绝大多数指令的目的寄存器是第一个寄存器,第二第三个寄存器是源寄存器

算术运算
指令 实例 含义 备注
add add t0, t1, t2 t0 = t1 + t2 将t1+t2的值赋值给t0
sub sub t1, t2, t3 t1 = t2 + t3 将t1-t2的值赋值给t0
addi addi t0, t1, -2 t0 = t1 + (-2) 将t1-2的值赋值给t0

上述指令中第一个寄存器,是被赋值、被操作的对象,我们称之为目的寄存器(target reg), 而后面的寄存器则称之为源寄存器,并且相应的第二个寄存器一定是第一个操作数,第三个寄存器一定是第二个操作数,顺序不可以改变。

没有subi指令,因为addi可以加负数,从而精简指令的个数。

为啥会有addi指令?

在汇编层面有三个主要的操作对象:寄存器,内存,立即数,它们是完全不同,不可以混淆,组织结构也不一样的不同对象,所以不能单纯拿针对寄存器的指令去处理内存和立即数。

立即数 immediate ,取首字母i。后续从寄存器扩展到立即数的指令都是这样的,在指令的最后加上i。

思考:如何让一个t0寄存器的值变为我们想要的一个任意常数(在计算机指令可表示范围内的数),比如说100?

addi t0, x0, 100 # now t0 = 100,在risc-v汇编中 ‘#’表示单行注释

当然这种表示方法太繁琐了,不过是每个新手的必经之路,刚开始都必须这样进行思考,后面将有简化的方法。

访存运算 (核心)

什么是访存:“访问内存中对应的值” 和 “将值存到内存中”,请记住在汇编中只有三个对象,内存,立即数,以及寄存器,寄存器只有32个数量有限,立即数无法进行存储功能,因此很多时候都需要把数据放到内存中以及从内存中读取相应数据。

运算与存储是计算机的两大核心而基本的功能。

几个将有帮助记忆的英语:

load : 加载 store: 存储
double word :双字,对应64位(1bits,位就是一比特)
word : 字,对应32位
half word : 16位
byte:8位(一个字节8比特)
unsigned: 无符号的

先上表
指令 实例 含义 备注
ld ld t0, 0(t1) t0 = memory[t1 + 0] 将t1的值加上0,将这个值作为地址,
取出这个地址所对应的内存中的值,
将这个值赋值给t0
lw lw t2, 20(t3) t2 = memory[20 + t3] lw 与 ld 的区别就在于 ld 是从内存取出64位数值,
而lw是取出32位数值。
lh lh t4, 30(t5) t4 = memory[30 + t5] 从内存中取出16位数值
lb lb t4, 30(t5) t4 = memory[30 + t5] 从内存中取出8位数值
sd sd t0, 0(t1) memory[0+t1] = t0 将t1的值加上0,将这个值作为地址,
将t0的值存储到上述地址所对应的内存中去
sw sw t0, 0(t1) memory[0+t1] = t0 与sd的区别在于sw只会将t0的低32位
数值存储到相应的内存。sd会将t0的64位都存入
sh sh t0, 0(t1) memory[0+t1] = t0 只将t0的低16位所对应的数值存入,
也就是一个half word大小
sb sb t0, 0(t1) memory[0+t1] = t0 只存入8位,一个byte大小
lwu lwu t2, 20(t3) t2 = memory[20 + t3] lw 与lwu的区别在于,
前者取出32位数值作符号扩展到64位,
而后者做无符号扩展到64位
lhu lhu t4, 30(t5) t4 = memory[30 + t5]
lbu lbu t4, 30(t5) t4 = memory[30 + t5]
核心解释

tip1:
所有指令都是前面那些英文的开头首字母的组合,何为汇编语言本质上就是一种机器语言的助记符,
自然越简单越好。

tip2:

操作系统有32位操作系统,64位操作系统,这个位指的是什么?

指的是地址的位数。

啥是地址?啥是内存?啥是值?

一个简单而有效的解释是:一块内存你可以想象是一个盒子,盒子里面的东西是值,而每个盒子都会有一个独一无二的编号,这个编号对应的就是内存的地址。我们并不关心内存地址,我们只关心内存的值,但为了能获得内存中的值,我们必须先知道内存的地址才能找到这个内存从而读取内存中的值。

既然每一个地址都有一个内存地址。从0开始,0,1,10,11,100,101…随着地址的增大,位数也越来越多,64位操作系统指的就是内存地址的位数总共有64位,所以0就是64个0,1就是高63位为0低1位为1,依次类推,所以你知道64位操作系统最多有多少地址空间了吗?

所有操作系统都是按字节寻址的,也就是一个内存地址是与内存中特定的1byte大小的盒子挂钩的。这1byte大小的盒子就可以用来放值。如果是1个double类型的变量,需要8个字节,也就是由内存中8个连续的盒子存储它的值,通常情况下,最低字节的内存地址,最小的那个地址,就是这个double变量的地址。

tip3:

为啥load操作需要符号扩展或者无符号扩展?它们是什么意思?

lw t0, 0(t1) : 将t1的值当作内存地址找到对应的内存,取出32位内存中(4个连续的字节地址)的值,赋值给t0,但是,因为t0是64位寄存器,t0的高32位怎么办?所以需要扩展到64位再赋值给t0。

符号扩展:高位全部扩展为符号位
无符号扩展:高位全部扩展为0

有符号数进行符号扩展能保证数值不会发生变化,无符号数进行无符号扩展数值不会发生变化(后面章节会简单证明,非常简单的,可以自己证明以下,现在记住就可以了)。

tip4:

为啥没有ldu指令,为啥没有store的无符号版本

ld 从内存中取出64位数值,寄存器就是64位的,还扩展啥?这时有符号数与无符号数的处理没有任何区别。对于早期的risc-v,寄存器是32位的,则ld和lw指令都没有无符号版本,因为无需扩展便指令的行为都是一样的,就无需区分有符号数和无符号数了。(其实无符号数出现的概率很小,实在不行先放着,以后再看就懂了。doge)

store将数据按位复制到内存中去,无需扩展。

最后上代码
int a1 = 2; // a1可以在内存中,也可以在寄存器中,但是后面有取址操作,所以a1只能在内存中
int* a1p = &a1; // a1p 存放了a1的地址,也就是a1p的值是a1的地址,假设a1p在t0寄存器中
int a2 = *a1p; // lw t1, 0(t0) ,其中t1寄存器是a2
*a1p = 8; // sw 8, 0(t0)

unsigned int a3 = 2;
unsigned int * a3p = &a3;
int a4 = *a3p; // lwu t1,0(t0)
*a3p = 8; // sw 8, 0(t0)
逻辑运算
指令 实例 含义 备注
and and t1, t2, t3 t1 = t2 & t3 按位与,不是&&
or or t1, t2, t3 t1 = t2 | t3 按位或
xor xor t1, t2, t3 t1 = t2 ^ t3 按位异或
andi andi t1, t2, 4 t1 = t2 & 4 1&1 = 1 ,1&0 = 0, 0&0 = 0
ori ori t1, t2, 4 t1 = t2 | 4 只有0|0=0,其他结果为1
xori xori t1, t2, 4 t1 = t2 ^ 4 a^0 = a, a^1 = ~a,xor 0不变,xor 1切换

为啥没有not 取反操作?

a xor -1 = ~a,这里的a表示任意位数的数字 。因为 -1的二进制表示是全一(位数可变,如 11, 111, 1111有符号数都表示-1)。

移位操作
指令 实例 含义 备注
sll sll t1, t2, t3 t1 = t2 << t3 t2左移t3位后赋值给t1
srl srl t1, t2, t3 t1 = t2 >> t3 t2右移t3位,做无符号扩展后赋值给t1
sra sra t1, t2, t3 t1 = t2 >> t3 t2右移t3位,做符号扩展后赋值给t1
slli slli t1, t2, 4 t1 = t2 << 4 t2左移4位后赋值给t1
srli srli t1, t2, 4 t1 = t2 >> 4 t2右移4位,做无符号扩展后赋值给t1
srai srai t1, t2, 4 t1 = t2 >> 4 t2右移4位,做符号扩展后赋值给t1

首字母:
s:shift 移动
r: right 右 l:left 左
l: logical 逻辑的(无符号扩展,补零) a: arithmetic (这英文单词着实有难度)算术的(符号扩展,补符号位)
i:immediate立即数

为何左移只能逻辑扩展,右移有逻辑和算术两种,分别对应无符号和有符号扩展?

  1. 左移补1会很奇怪,而左移每补一个0相当于乘以一次2,后者非常有用,前者违背直觉(违背逻辑)
  2. 右移补位,如果是无符号数应该补0,有符号数应该补符号位,原理和上面差不多。

最后,或许现在可能很纠结数字的表示,但当在后面章节系统学习了计算机数字的表示以后,这些东西就会变得简单而富有智慧。现在记住:无符号数扩展补0, 有符号数补符号位。左移始终补0

分支指令(核心: 条件与循环)

按照惯例,先给出一些英文单词:

equal :相等 not: 不
less than: 小于 greater than : 大于
branch:分支

指令 实例 含义 备注
beq beq a1, a2, Lable if(a1 == a2){goto Lable;} Lable是任意自定义的标签
bne bne a1, a2, Lable if(a1 != a2){goto Lable;}
blt blt a1, a2, Lable if(a1 < a2){goto Lable;}
bgt bgt a1, a2, 100 if(a1 > a2){goto Lable;} 100表示跳到 pc+100 * 2的位置
bge bge a1, a2, 100 if(a1 <= a2){goto Lable;} 100与Label对应着相同的指令,
实际上在运行时Label会变成pc+xxx
ble ble a1, a2, 100 if(a1 >= a2){goto Lable;} 我Label好像拼错了,能看懂就行 doge

实际上真正的risc-v指令只有blt 和 bge 而没有 bgt 和ble指令,后者是伪指令(相当于c语言的宏定义,对于程序员来说和risc-v指令没区别),ble操作可以通过将bge指令的两个操作数互相换位置实现,bgt操作同理。berkeley的人真是大神。

如何自定义标签

Loop:	#只要加上一个冒号就是标签
addi t1, x0, 4
addi t2, x0, 4
beq t1, t2, Loop  # 显然这是一个死循环 doge
使用分支实现if语句
if (a > b)	// 假设a 在t0, b 在t1寄存器中
{
     a = 1;
} 
else
{
    b = 1;
}

对应的汇编语句是

ble t0, t1, Else
addi t0, x0, 1
beq x0, x0, Then
Else:
addi t1, x0, 1
Then:
汇编if的技巧

一个非常非常有用的经验是,对于if语句中的条件表达式,我们往往在汇编中判断其反面条件,如果上面的例子没有else,则更能体现出其优点。其他条件表达式同理。

使用分支实现循环语句
int a = 0;	//假设 a 在 t0
int i = 100;// 假设 i 在t1
while (i > 10)
{
    a += i;	
    i++;
}

对应的汇编语句是

add t0, x0, x0
addi t1, x0, 100
addi t2, x0, 10
Loop:
	ble t1, t2, End
	add t0, t0, t1
	addi t1, t1, 1
	beq x0, x0, Loop
End:
	# This is outer of loop
汇编循环的技巧:
  1. 理解c语言中代码的执行顺序,显然while 中的条件表达式可以看做是循环体的一部分,且是最开头的一部分
  2. 所有循环一律用死循环的格式处理,在循环体中加入分支语句就可以了
  3. 条件表达式取反处理

再看一个for 循环

int sum = 0;	// sum at t0 reg
for (int i = 0; i < 20; i++)	// i at t1 reg
{
    sum += i;
}

汇编是

add t0, x0, x0
add t1, x0, x0
add t2, x0, 20
Loop:
	bge t1, t2, End
	add t1, t1, t0
	addi t0, t0, 1
	beq x0, x0, Loop
End:

将条件表达式和更新表达式当作是循环体的开头与结尾即可。别忘了更新表达式

跳转指令 (核心:函数的调用)

英文单词:
jump : 跳转 and : 和 link: 链接 register:寄存器

指令 实例 含义 备注
jal jal ra, Symbol 跳转到Symbol中去, 并把ra设置成返回地址 Symbol 可以是自定义的Label ,也可以是某个函数名
jal jal ra, 100 跳转到pc + 100 * 2的地方中去, 并把ra设置成返回地址 pc相对寻址,对应的是位置无关代码(PIC)
jalr jalr ra, 40(t0) 跳转到t0+40 的地方中去, 并把ra设置成返回地址 t0+40必须是绝对地址,指向内存中某个确定的地方(往往是函数的开头),非PIC

什么是pc?

pc 是程序计数器(没意义的废话),pc中的值永远是当前正在执行指令的下一条地址。没错,指令也有地址,在risc-v中每条指令都是32个bit的,也就是4个字节大小的,所有指令都会被加载进内存中,这个区域我们叫做 text段,或者叫代码区内存。pc的作用非常重要,cpu执行当前指令,pc指向下一条指令,当cpu执行完当前指令以后,cpu就会去读pc,从而找到下一条要执行的指令的位置,然后就继续执行下一条指令,而pc的值也会相应的更新。周而复始直到整个程序结束。(要是此时有张动图多好)

跳转的本质

没错,跳转的本质其实只是简单的改变pc的值,当执行到 jal ra, 100这条语句时,pc的值是这条指令的下一条指令的地址,当cpu执行的时候,cpu就会把pc 的值放到ra 中,再把pc的值更改位pc+200,执行完以后,cpu继续读取pc的值,就会到pc+200的地方去执行指令,从而实现跳转的功能。跳转以后如何返回呢?这时需要返回地址派上作用了,只需要再执行“ jal x0, ra ”这条语句,按照刚才的逻辑,cpu就会到原来的地方的下一条语句处去执行了。这个x0不起任何作用。

跳转以后可以返回,可以不返回,返回使用 jal x0, ra就能返回到上一个地址,不使用就不会返回。
如果使用的是 jal x0, 100 这条语句,则无法返回原来的地方,因为没有保存返回地址

为啥要乘2

当前我们学到risc-v中所有指令都是32个字节,也就是4字节,理所当然我们可以放心大胆的*4,因为pc+1/pc+2/pc+3都不是指令的地址,乘以4就能拥有更大的寻址空间,然而,risc-v架构师为了兼容16个字节长的指令的risc-v版本,所以乘2.只要是pc相对寻址,操作数都要乘2,不是pc相对寻址,就一律不乘2.

惊人的发现:存储程序思想

随着对体系结构的学习,我们发现,所有的数据是放在内存中的,函数(本质上就是一些指令)等指令也是放在内存中的,没错,整个程序都是放在内存中的,并且大致由代码和数据构成,它们被放在内存中不同的位置,按照相同的方式被处理,极大的简化了存储的设计。存储程序的思想最早由冯诺依曼提出,貌似好像也有人说是图灵和其他人,这个不晓得。

位置无关代码

Symobl 可以根据指令的个数算出来,最后本质上是pc相对寻址,pc相对寻址体现了位置无关代码的思想,无论最终程序被加载在内存的什么位置,只要pc和Symbol的相对距离不发生变化,就一定能通过pc根据相对距离找到Symbol的位置。(一般而言,每次加载程序,程序在内存中的位置都会发生改变)

第三种类型则是根据绝对位置,根据内存中绝对的内存地址(比如0x88a728f2)去跳转,只能跳转到这一个位置,一般并不是非常建议使用。

一句话总结:用Symbol就对了

具体例子: 求出最后t0和t1的值

.global main  # 声明main是一个全局可见的变量,也就是在其他源文件中也可以调用这个main
.text    # 表示代码段的开始
main: 	# 函数名与标签名其实大致是一样的
addi t0, x0, 9
addi t1, t0, 10
jal x0, swap	# 跳转到swap,swap距离当前正在执行的指令的距离是8,也就是两个指令的长度,所以在真正运行时swap会被替换为2(乘4变成8),一条指令是4个字节。pc = pc + 8
addi t0, 10		# 无法返回,这条语句将难以被执行
swap:
add t2, t0, x0 #这条指令将被执行
add t0, t1, x0
add t1, t2, x0
jal ra, add
addi t1, t0, 10
jal x0, End
add:
add t1, t1, t2
jal x0, ra 
End			# 最后t0 的值为20, t1的值为19
使用汇编写完整的程序
伪指令

一些汇编指令着实奇怪,于是出现了一些便捷的伪指令能方便使用,它们类似宏定义,最后还是会被替换为相应的汇编指令。介绍几个常用的

mv t0, t1	# t0 = t1
li t0, 100	# t0 = 100
j Label		# 无条件跳到Label 处
ret          # jal x0, ra

其他的像la加载地址等用到再谷歌。

数组

int a[10] 是一个有10个元素的数组

# 假设 数组a的起始地址在 s0处
lw t0, 0(s0) # t0 = a[0]
lw t1, 4(s0) # t0 = a[1]
sw t0, 4(s0) # a[1] = t0
li t2, 4	
slli t2, t2, 2	
add t2, t2, s0
lw t2, 0(t2)  # t2 = a[4]
栈指针

栈是向下生长的,栈指针指向栈顶,栈顶的地址是最低的地址

备注
30 这里是一个long ,8个字节。 但是地址是 sp + 1 + 1 + 4 (2个char 一个int )
20 sp + 5,这里放的是一个int ,占了4个字节.但是它的地址是最低地址,也就是sp + 2 (两个char )
‘c’ sp + 1, 栈就是我们的内存最形象的表示,栈的最小单元就是内存块,1个字节,有着自己的内存地址
’a’ sp 指向这里,这里是栈顶(其实说栈底更贴切),sp 指的是这个格子的下面的线的位置
addi sp, sp, -8	#栈向下生长8个字节,相当于开辟了8个字节的内存地址。压栈的过程


addi sp, sp, 8 # 最后栈应该恢复原位。弹栈的过程
保存寄存器与临时寄存器

保存寄存器:s0-s11
临时寄存器:t0-t6

保存:开始将寄存器的值放到栈上,最后进行压栈取出来,就能保证不管中途发生了什么,最后寄存器还是原来的值

我已经没时间和你讲为什么要这样干了,我直接告诉你结果吧,最好立刻做题吧

被调用者需要保存“保存寄存器”和ra, 不需要保存临时寄存器和参数寄存器

调用者相反

对于被调用者(被调用的函数)而言,临时寄存器是可以直接拿来用的,不需要保存,方便,参数寄存器也是直接拿来用的,不需要保存。如果ra不保存直接用来干其他的事就回不去了,保存寄存器名字说明了一切。

叶子过程:不调用其他函数的过程/函数。

总结1: 因为叶子过程一定是被调用者,所以叶子过程用临时寄存器非常方便,参数寄存器一般是传递过来的参数,要用也可以。

总结2: 非叶子过程推荐的做法是:因为非叶子过程在调用其他函数的时候需要先保存临时寄存器,调用结束以后还要加载临时寄存器,非常麻烦。所以不推荐非叶子过程使用临时寄存器,推荐使用保存寄存器,开头先保存好ra和保存寄存器,结尾再加载出来。中间过程任意使用这些保存寄存器。为了方便,开头还可以将参数寄存器的值放入保存寄存器中,后面拿参数会很方便。

参数寄存器和返回值寄存器

参数寄存器:在调用函数之前,必须将a0 设置成第一个参数的值,a1设置为第二个参数的值,依次类推,这些寄存器就是用来传参的。

返回值寄存器: 调用函数快结束的时候,需要将a0设置为返回值,有些时候a1也是返回值寄存器(a1我没用过做返回值寄存器)

案例
void swap(long long int v[], size_t k)
{
    long long int temp;
    temp = v[k];
    v[k] = v[k+1];
    v[k+1] = temp;
}
void sort(long long int v[], size_t n)
{
    size_t i, j;
    for (i = 0; i < n; i++)
    {
        for (j = i - 1; j >= 0 && v[j] > v[j + 1]; j--)
        {
            swap(i, j);
        }
    }
}

汇编

swap.s文件

.global swap
.text 
# a0: v[], and v0 is start address of v
# a1: k is the size of v[]
# 叶过程:基本上无需保存任何寄存器
slli t0, a1, 3
add t0, a0, t0
lw t1, 0(t0) # temp = v[k] and t1 is temp
addi t2, t0, 8 #  t2 = 8*(k + 1) + v0 
lw t3, 0(t2)  # t3 = v[k+1]
sw t3, 0(t0)
sw t1, 0(t2)
ret

sort.s 文件

.global sort 
.text 
# a0: v[], and v0 is start address of v
# a1: n is the size of v[]
# 非叶子过程:按照流程走,两个参数,四个做临时变量,大概要6个保存寄存器,和一个ra,7*8 = 56
addi sp, sp,-56
sd ra, 48(sp)
sd s0, 0(sp)
sd s1, 8(sp)
sd s2, 16(sp)
sd s3, 24(sp)
sd s4, 32(sp)
sd s5, 40(sp)
mv s0, a0
mv s1, a1
# 第一步完成,赋值粘贴,完成加载

li s2, 0 # s2 is i
outer_loop:
	bge s2, s1,end_outer_loop
	addi s3, s2, -1 # s3 is j
    inner_loop:
		blt s3, x0, end_inner_loop
		add s4, s3, s0
		lw s5, 8(s4)
		lw s6, 0(s4)
		ble s6, s5,end_inner_loop
		# prepare args
		mv a0, s2	# 第一个参数是i , a0表示第一个参数
		mv a1, s3	# 第二个参数是i , a1表示第二个参数
		jal ra, swap
		addi s3, s3, -1
	end_inner_loop:

end_outer_loop:


# 以下是第二步,完成,中间部分开始写代码
ld ra, 48(sp)
ld s0, 0(sp)
ld s1, 8(sp)
ld s2, 16(sp)
ld s3, 24(sp)
ld s4, 32(sp)
ld s5, 40(sp)
addi sp, sp,56
ret
下一步:设计一个risc-v 的cpu

计算机的发明是信息时代最恢弘最根本的起点。操作系统与CPU是其中两个非常重要的部分,是计算机软件和硬件最核心也是最重要的模块。在下一部分,我们将逐步动手实现一个CPU(center process unit 中央处理器),并在设计过程中逐步领会到计算机的组成原理,认识到如何从晶体管蜕变处理器的过程。
让我们开始设计一个cpu 吧 !cpu designer

你可能感兴趣的:(risc-v)