有点发烧,唔,不过还是坚持学下来了,新冠让人静心.
计组学习——RISC-V Functions
Loading Sign Extension
假如内存里的字节是这样:
0b1111 1110(-2)
当我们使用load byte指令时:众所周知,我们需要进行符号拓展
我们此时的序列 0b XXXX XXXX XXXX XXXX XXXX XXXX 1111 1110
就会变成:0b1111 1111 1111 1111 1111 1111 1111 1110
可以发现,进行符号拓展之后,获得的数字不变,依旧是-2
举例:假如s0内的地址为0x00008011
-
lb t0,0(s0)
-> loading 0b000100010b0000 0000 0000 0000 0000 0000 0001 0001
-
lb t0,1(s0)
-> loading 0b100000000b1111 1111 1111 1111 1111 1111 1000 0000
如果我实际上就想要一个无符号数怎么办呢?我该怎么进行拓展呢?
-
不进行符号拓展,只要添加前导0就可以了!
-
用上述例子距离:
lbu t0,1(s0)
-> unsigned loading 0b100000000b0000 0000 0000 0000 0000 0000 1000 0000
Pseudo-Instructions 伪指令
伪指令只是更容易懂的指令,所有伪指令都可以拆解成为基础的语句,但是有时使用基础语句对于程序员来说有些麻烦。
同时,为了适配多种芯片,伪指令的概念也是很有必要的。
基础的指令就是硬件能够执行的指令,能在物理层面运行的指令。
-
move
mv
dst,reg1将一个寄存器里的值写入到另一个寄存器
等同于
addi dst,reg1 0
-
load immediate(li)
li dst, imm
把一个32位的数字加载到dst里,但事实上我们没有访问内存
等同于,先addi,在左移lui
lui是将一个20位的数左移12位,比如lui x5,0x12135
这是因为addi内存在一些限制,我们并不能加一个32位的数,稍后会提到
-
load address(la)
la dst, label
将label标签内的地址加载到dst里
本质是我们把label的地址转化为物理上的地址(用label相对于pc的地址加上pc的绝对地址),
之后算出来之后放到dst里
等同于,
auipc dst, <offset to label>
auipc是讲一个数字imm左移十二位之后加上一个pc的偏移值
pc指针是program controller,指向了目前运行的指令的地址
同样的这里的imm只能是20位的数
auipc是唯一一条能让我们知道当前PC在哪的指令
-
No Operation(nop) 空指令
nop
等同于,
add x0,x0,0
就连jump这样的指令也都是伪指令,伪指令真的非常有用!
C to RISC-V Practice
-
Fast String Copy
char *p, *q; while( (*q++ = *p++)!= '\0' );
虽然代码很简洁,但是我们最好写下每一步!这样方便进行转换!养成好习惯
p->s0,q->s1;
t0=*p;
*q=t0;
p=p+1;
q=q+1;
if *p==0,go to Exit
go to Loop
汇编代码:
Loop: lb t0,0(s0)
sb t0,0(s1)
addi s0,s0,1
addi s1,s1,1
beq t0,x0,Exit
j Loop
Exit:
可以发现我们在load byte的时候,获取了一个字节,而前面的24位是进行符号拓展的。
但是这不重要!因为我们在sb操作的时候,忽略了前面24位,所以完全没有影响!
- 可以发现我们在上述过程中,一次循环执行了六条指令,但是我们可以简化!
Loop: lb t0,0(s0)
sb t0,0(s1)
addi s0,s0,1
addi s1,s1,1
bne t0,x0,Loop
Exit:
这样在一次循环里只运行了五次指令!
汇编语言程序(重点!)
调用程序的六个步骤:
- 将参数放入到函数可以访问的位置
- 将C语言程序拆分为流程
- 考虑堆栈空间,给程序一些内存!
- 程序按照指令运行
- 在将值返回到某些位置之后,记住“clean up”
- 结束调用,返回调用前的指令
重点在于:什么时候调用什么寄存器!
- 步骤1和步骤5:我们应该把参数和返回值放在哪?
- 因为寄存器比内存快得多,所以把这些值都放在寄存器里
- a0-a7:八个参数寄存器(argument registers),用来传递参数
- a0-a1:两个参数寄存器,也被用于传递返回值
- 如果需要多余的空间!请使用栈的内存!
- 参数的顺序很重要!
- 因为在返回值的时候,我们已经不再需要参数了,所以需要覆盖掉原本的参数
- 因为寄存器比内存快得多,所以把这些值都放在寄存器里
例子: c语言代码:
int add(int a,int b){
return a+b;
}
void main(void){
a=3;
b=a+1;
a=add(a,b);
}
汇编语言代码:
main:
addi a0,x0,3
addi a1,a0,1
jal ra,add #jump and link将执行的下一条指令地址(其实就是+4字节,因为riscv每条指令占有4个字节) 存入ra,并且跳转到add
add:
add a0,a0,a1
jr ra #这里不使用j是因为j跳转的是标签,而我们把下一条指令的地址存到了ra里。同时,ra也叫x1,专 门用来储存函数的返回地址
- 步骤2和步骤6:我们如何进行流程控制?
还是上述例子,我们可以看到,jal ra,add
我们把下一条指令的地址存入ra(其实就是jal这一条指令的地址+4),之后我们在add函数里jr ra
但是我们都知道!,jr本身是一个伪指令,那么它的本质指令是什么呢?
jr ra
=jalr x0,ra,0
我们把ra寄存器的地址加上偏移值(立即数0),那么就是ra的地址,之后我们把这个新地址储存到x0中,这并不会改变x0,x0永远不会改变!
变量的本地存储
本地存储的本质就是sp指针,sp指向栈空间的底部,拓展肯定是向下拓展的,拓展4个字节相当于sp-4
addi sp,sp,-4
sw t0,0(sp)
#将t0存入栈里
使用寄存器的规范
例如,如下的c++程序
int sumSquare(int x,int y){
return mult(x,y)+y;
}
- 我们需要储存什么?
- 因为调用mult会覆盖掉ra,所以我们应该先存下来
- 而a0和a1本来存储x和y,如果调用mult,那么我们a1里面y的值就会被x覆盖掉!
调用规范!
- calleR:在调用别人的函数
- calleE:被调用的函数
Saved Registers(CalleE Saved)
- 这些寄存器在CalleE调用前后保持不变
- 如果calleE使用了他们,他们必须在返回之前重新储存值
- 这意味着把旧的值存下来(存到栈里),调用寄存器,然后重新把旧的值读入寄存器里
- s0-s11(saved registers)
- sp(stack pointer)
Volatile Registers(CalleR Saved)
- 这些寄存器可以被CalleE自由的改变
- 如果calleR需要他们,他必须在程序调用之前把值存储起来
- t0-t6(temporary registers)
- a0-a7(return address and arguments)
- ra(return address)
综上所述,寄存器就分为两种:
- Caller saved
- CalleE saved
存储register
当然是栈了!
那么...回到我们最开始的c++程序:
int sumSquare(int x,int y){
return mult(x,y)+y;
}
可以把它转换为汇编程序:
sumSquare:
addi sp,sp,-8 #在栈里开辟两个空间
sw ra,4(sp) #存储ra
sw a1,0(sp) #存储y的值
add a1,a0,x0 #把a1的值改变为x(因为我们调用的是mult(x,x))
jal mult #跳转执行mult
lw a1,0(sp) #执行之后,重新加载a1(其实就是让a1=y)
add a0,a0,a1 #计算mul(x,y)+ y
lw ra,4(sp) #加载回ra
addi sp,sp,8 #栈指针回溯
jr ra #结束函数调用
mult:
... #执行x*x的功能
函数的基本结构:
Prologue
func_label:
addi sp,sp,-framsize
sw ra,<framsize-4>(sp)
#store other callee saved registers,such as:s0-s11
#save other registers if needed,such as:a0-a7,t0-t6
Body (call other functions...)
...
Epilogue
#restore other regs if needed
#restore other callee saved regosisters
lw ra,<framsize-4>(sp)
addi sp.sp.framsize
jr ra