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立即数
为何左移只能逻辑扩展,右移有逻辑和算术两种,分别对应无符号和有符号扩展?
最后,或许现在可能很纠结数字的表示,但当在后面章节系统学习了计算机数字的表示以后,这些东西就会变得简单而富有智慧。现在记住:无符号数扩展补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 (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语句中的条件表达式,我们往往在汇编中判断其反面条件,如果上面的例子没有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
再看一个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
计算机的发明是信息时代最恢弘最根本的起点。操作系统与CPU是其中两个非常重要的部分,是计算机软件和硬件最核心也是最重要的模块。在下一部分,我们将逐步动手实现一个CPU(center process unit 中央处理器),并在设计过程中逐步领会到计算机的组成原理,认识到如何从晶体管蜕变处理器的过程。
让我们开始设计一个cpu 吧 !cpu designer