汇编基础与汇编指令
概念介绍
-
程序计数器 (PC):
- 程序计数器是一个寄存器,它存储着CPU下一条要执行的指令的地址。
- 每当CPU执行一条指令,程序计数器就更新为下一条指令的地址。
- PC 的初始值通常由一个特定的固定地址或启动代码设置。
-
PC+1:
- 在顺序执行的程序中,指令通常存储在连续的内存地址中。这意味着在执行完当前指令后,下一条指令的地址根据指令的长度,可能是加2、4、8等)。
- "PC+1" 反映了这种顺序执行模式,表示程序计数器增加一个单位,以指向下一个顺序指令。
- 然而,这只适用于顺序指令流。控制流指令,如跳转、分支和调用,可能会导致PC设置为一个非连续的值。
-
分支和跳转:
- 当遇到分支或跳转指令时,PC的值可能会发生较大变化,不一定是简单的+1操作。
- 例如,一个条件分支可能检查某个条件,然后决定跳过一段代码或跳转到程序的另一部分。
-
调用和返回:
- 当执行函数或过程调用时,PC将被设置为该函数的起始地址。与此同时,返回地址(即调用后的下一条指令的地址)通常被推送到堆栈中。
- 当函数执行完毕并执行返回指令时,PC将从堆栈中弹出并设置为该返回地址,使执行回到调用函数后的下一条指令。
在许多体系结构和编程模型中,PC的自增是自动发生的,除非遇到改变程序流的指令。但了解 "PC+1" 和其背后的概念对于理解计算机的低级操作和指令流非常重要。
跳转操作
RISC-V 整数指令集寄存器
(1)RISC-V 整数指令集总共使用了32个寄存器,分别用x0~x31来表示,每个寄存器的位宽是32位。注意这个位宽,后面知识点要涉及到哦!
(2)各个寄存器都有特殊的用途,例如:
•x0 寄存器的值硬编码为0,无法修改。因为 经常用到常量0的值,所以将x0设置为0。•x1 寄存器用于保存函数调用的返回地址。很重要,是函数调用过程必经之路。•x2 寄存器用于保存堆栈指针。•x10~x17 寄存器用于传递函数参数。•x10~x11 寄存器用于存储函数的返回值等。
(3)用x0~x31编号来命名寄存器时,很难记住各个寄存器的用途。为方便记忆功能,对各个寄存器使用了ABI命名(别名)。用户在汇编编程时可直接使用 ABI 名称开发。ABI相当于宏定义,这样的别名方便记忆,在编译的时候会自动转换为原本的名字 。
RISC-V 32位整形指令集部分指令格式
最低 7 位是操作码(右侧第一列),rd、rs1、rs2是寄存器编号字段(5位,因为RISC-V有32个寄存器,所以编号为5位)。
rs,register source通常来说是源寄存器,rd通常来说是目标寄存器。
imm:立即数,常量,通常用于表示存储器地址、常量值等。
常见RISC-V整形汇编指令
加法指令由右到左。并且注意addi是与12位立即数相加,因为32位的指令,已经有7+3=10位操作码(见上图ADDI指令格式)和两个5位寄存器了,所以只剩下12位的立即数。
【例】若 C 程序中定义了整形变量 k,且其值存放在寄存器 a4 中,则:
C 语句 “k = k >> 2” 可通过哪条汇编语句而实现其功能?
解:C 语句 “k = k >> 2” 表示将 寄存器 a4 的值右移 2 位,然后将结果再放到寄存器 a4 中。
可通过汇编指令 “srai a4, a4, 0x2” 来实现。注意shamt的范围!超过该范围是不能用这个指令的,会导致数据越界。
(1)lui a5, 0x20000 指令实现功能如下:a5 = 0x20000000。
(2)addi a5, a5, 12 指令实现功能如下:a5 = a5 + 12。
上述 2 条指令综合实现的功能是:将寄存器 a5 赋值为 0x2000000c。为什么对寄存器 a5 的赋值 0x2000000c 或 0x20000004 均需使用 2 条指令,而不能直接使用一条指令实现,例如,ldi a5, 0x20000004?
计算机底层变量赋值就是通过lui/lw/sw指令的综合实现。
在许多现代微处理器和微控制器中,不同的指令可能会有不同的长度。在某些RISC(Reduced Instruction Set Computer)架构中(例如RISC-V),"压缩指令"或"紧凑指令"通常占用16位(2字节),而标准指令占用32位(4字节)。
- 如果遇到压缩指令(2字节或16位):
- 程序计数器(PC)增加2。
- 如果遇到标准指令(4字节或32位):
- 程序计数器(PC)增加4。
这种设计允许指令集既可以包含简单且紧凑的指令,也可以包含更复杂但功能更强大的指令,从而在代码密度和执行效率之间实现平衡。
goto 无条件跳转 J指令
函数调用 Jal指令 需要保存现场(下一条指令的地址,保存到寄存器 中)、PC值修改
if / else 先判断条件是否成立,再决定是否跳转
ret=return PC=,相当于恢复现场
变量赋值
函数调用
经过优化的编译器可以自动将某些重复的汇编语句去重。
传参:将变量的值暂存到寄存器中,函数就会对寄存器的值做出反应。
原函数:fun2(f,a,d)
子函数:fun2(int x,int y,int z)
约定:
1.各个参数从第一个寄存器a0开始存储,这样子函数就会按照顺序读取寄存器的值。如上图所示,f是第三个参数,被复制到a2寄存器;a是第二个参数,存储在a1中;d是第一个参数,存在a0中。
2.返回值也是默认从a0开始,4字节就是只有一个存储4字节的寄存器。
8字节的返回值就是两个寄存器。到主函数的时候就是默认a_0存储返回值。在上图中,返回值只有一个,在a0,并且将a0的值复制到d,
对于浮点数f,就先这么记着吧,作者感觉上图是直接根据float32位来存储(老师的图)。实际上在现代处理器架构中(例如x86或ARM),通常有专门的浮点寄存器用于存储和处理浮点数值。这些寄存器的大小通常足以容纳单精度和双精度浮点数。以本课程的RISV-C为例进行说明:
在 RISC-V 架构中,处理
float
类型或浮点数涉及到 RISC-V 的浮点扩展。RISC-V 是一种模块化的指令集架构,它允许实现者选择是否包括浮点支持。对于需要处理浮点数的 RISC-V 系统,通常会实现标准的 RISC-V 浮点扩展,这些扩展定义了浮点寄存器和用于执行浮点运算的指令。
下面是 RISC-V 系统中处理 float
类型的一些关键点:
1. F 扩展
- RISC-V 提供了一个名为 “F” 的标准单精度浮点扩展。
- 这个扩展添加了32位宽的浮点寄存器和一组指令,用于执行单精度浮点算术运算。
2. D 扩展
- 对于需要双精度浮点支持的系统,RISC-V 还提供了一个名为 “D” 的标准双精度浮点扩展。
- 这个扩展添加了64位宽的浮点寄存器和一组指令,用于执行双精度浮点算术运算。
3. 寄存器和指令
- F 和 D 扩展单独使用时能够分别添加32个32位宽或64位宽的浮点寄存器。
- 这些扩展还定义了一组浮点指令,这些指令用于执行浮点加载、存储、算术运算、比较和数据转换操作。
4. 处理浮点异常
- RISC-V 的浮点扩展还定义了用于处理浮点异常的机制。
- 这包括指令用于查询和设置浮点状态和控制寄存器。
5. 软件实现
- 在没有硬件浮点支持的 RISC-V 系统上,也可以通过软件库(例如软件浮点库)来实现浮点运算。
不同类型数据通常是怎么存储的?
在 RISC-V 架构中,不同类型的数据(例如 int 和 float)一般是分别使用不同的寄存器进行传递的。
整数参数:
- 整数参数一般使用
a0
,a1
,a2
, ... 等整数寄存器进行传递。- 例如,如果有一个函数
fun(int x, int y)
,那么x
会被放入寄存器a0
,y
会被放入寄存器a1
。浮点参数:
- 如果使用了浮点寄存器,浮点数参数会被放入
fa0
,fa1
,fa2
, ... 等浮点寄存器。- 例如,如果有一个函数
fun(float x)
,那么x
会被放入寄存器fa0
。
所以在你的例子中:
int
类型的参数会被放入a0
,a1
,a2
, ... 寄存器。float
类型的参数会被放入fa0
,fa1
,fa2
, ... 寄存器。
如果是单精度 float
(4字节)类型的参数,它通常只会占用一个浮点寄存器(例如 fa0
)。如果是双精度 double
(8字节)类型的参数,在RISC-V中它也只会占用一个寄存器(例如 fa0
),但是这个寄存器是64位的。可能读者不太理解这里,作者再讲解一下:
在RISC-V中,当同时启用
F
和D
扩展时,并不会有64个单独的寄存器(32个f
和32个d
)。实际上,F
和D
扩展共享相同的寄存器集。即,总共有32个浮点寄存器,它们可以作为单精度(f
)或双精度(d
)寄存器使用。
这里是具体的细节:
- 有32个浮点寄存器(
f0
,f1
, ...,f31
)。 - 这些寄存器可以被
F
扩展用作存储单精度浮点数。 - 同样的这些寄存器也可以被
D
扩展用作存储双精度浮点数。 - 当作为双精度寄存器使用时,每个寄存器(比如
f0
)将被视为一个双精度寄存器(比如d0
)。
也就是说,f0
到f31
既可以表示单精度浮点数也可以表示双精度浮点数,具体取决于执行的指令。不会同时存在64个分离的寄存器。
如果就要使用整数寄存器存储浮点数呢?
如果 RISC-V 架构没有浮点扩展(F扩展或D扩展),则是不支持硬件级别的浮点数操作的。在这种情况下,浮点数的操作需要使用软件库来进行模拟计算,这通常会有一定的性能损失。
对于函数参数的传递:
- 如果是一个
float
类型(4字节),即使没有 F 扩展,它仍然可以放在一个 32-bit 的整数寄存器中(例如a0
)传递。这种情况下,它被当作普通的 32-bit 数据来处理。- 对于
double
类型的数据(8字节),如果没有 D 扩展,它可能会被分为两个 32-bit 部分,并分别放在两个连续的整数寄存器中(例如a0
和a1
)。
但是,应该注意的是,即使可以这样做,执行浮点运算的指令仍然不可用。所有的浮点运算都需要通过软件库来进行模拟,这通常涉及到复杂的位操作和其他技术,而且性能通常不如硬件支持的浮点操作。
总结,即使没有浮点扩展,你可以在整数寄存器中存放 float
或 double
类型的数据进行传递,但是实际的浮点运算还是需要通过软件方式来实现。
深度理解浮点数存储
由上文可知,同时进行F/D扩展时,一个浮点数存储器能够存储float/double,那么一个寄存器是不是可以存储两个float或者1个double?
其实,在RISC-V架构下:
- 一个浮点寄存器可以存储一个单精度(
float
)值或一个双精度(double
)值,但不可以同时存储两个float
值。- 当存储一个
float
值时,寄存器将使用其32位来存储这个值。- 当存储一个
double
值时,寄存器将使用全部的64位来存储这个值。
也就是说,即便启用了F
和D
扩展,每个浮点寄存器在任何给定时刻仍然只能持有一个float
值或一个double
值,不能同时持有两个float
值。
那么很容易就会有下一个问题:寄存器还能动态扩容和缩容?不能的话那存储一个float岂不是浪费一半的资源?
其实,在 RISC-V 架构中(也是在大多数现代计算机架构中),寄存器的大小是固定的,不能动态扩容和缩容。在使用 F 和 D 扩展时:
- 如果寄存器是 64 位的(这是为了支持
double
),那么存储一个float
值时会“浪费”一半的存储空间。- 如果只使用 F 扩展(不使用 D 扩展),寄存器可以是 32 位的,这时候存储
float
就不会有空间浪费。
这种“浪费”是一个权衡的结果。通过使用更大的寄存器,可以支持更高精度和更大范围的数值,但代价是当处理较小的数值时会使用更多的存储空间。在大多数情况下,这种权衡被认为是值得的,因为它简化了硬件设计并提高了性能(例如,通过避免了不同大小的数值之间的数据转换)。
对于大多数应用程序,这种“浪费”是可以接受的,因为寄存器通常不是性能瓶颈或者资源瓶颈。大多数现代计算机系统都有足够的寄存器来满足大多数应用程序的需求。不过,确实在一些资源非常有限或者对性能要求极高的场景下,这种权衡可能会变得比较重要。