指令:计算机的语言

注:本文不涉及具体的代码实现,仅作原理性的解释

参考教材:《计算机组成与设计:软/硬件接口》(原书第2版)

我们该如何理解指令呢?或者说指令给计算机带来了什么?

计算器:输入数据,我们再输入运算符,最终得到结果

计算机:输入预定义的代码,再输入数据,自动进行计算,得出结果

我们得出结论:代码使计算机实现了自动化

而代码在内存中是以指令的形式存储的,硬件是按照指令去运作的。因此理解指令,便能够更好的理解计算机硬件,也能够更好的理解高级语言是如何一步步被硬件执行的。

操作数

1 + 1 = 2

我们知道,做一个算数运算除了需要运算符,还需要加数。在指令中,我们称之为操作数

但操作数不是固定的常量,因此我们需要硬件来存储它。

硬件:

32个寄存器:x0到x31,可供处理器进行算术运算

内存:仅支持数据传输指令

寄存器就是我们存储操作数的硬件,也是我们指令中必不可少的一部分。那么内存是做什么的呢?我们知道,程序中的变量有时要远多于32个,那么多出来的变量放在哪里呢?是放在内存中。而有些数据结构(数组、链表)的大小又远大于寄存器所能存储的容量,这些也是放在内存中。

而我们在C语言中,经常使用指针去操作数组。把他们放在指令中就可以发现,数组无法放在寄存器中,而处理器又只能对寄存器中的数字进行算术运算。那么我们当然需要一个能放在寄存器中的操作数(指针)去寻找数组中的数,将其放在寄存器中,进行算术运算,再放回内存。

因此,内存和寄存器之间必然存在一种通道,能够交换数据。实现这种通道的指令称为数据传输指令

问答时刻

为什么寄存器数量为32呢?

答:因为寄存器数量越多,时钟周期越长,处理器性能就越差;而寄存器数量太少,又无法实现存储数据的功能。因此,这是一种平衡和妥协。设计思想:更少则更快

为什么只能对寄存器中的数据进行算术运算?

答:因为处理器访问寄存器的时间更快。且处理器可以同时访问两个寄存器,而内存一次只能传储一个数据给处理器,寄存器的吞吐率要大于内存。

数据传输指令

如果你去翻看RISC-V的指令,你会发现每条指令都是3个操作数。确切的讲,两个是被操作数,一个是结果。固定的操作数将会方便硬件的设计。设计思想:简单源于规整

那么数据传输指令也不例外,使用三个操作数。数据传输指令又分为内存传入寄存器的载入指令,和寄存器传入内存的存储指令

在载入指令中,三个操作数分别为:基址寄存器、偏移量、要存入的寄存器

你也许会好奇,操作数不应该是数吗,为什么搞个寄存器?我们说过,操作数是变量,不是常量。我们不能把它直接放在指令中,这就变成常量了。我们将寄存器的编号放在指令中,处理器会去寄存器中寻找操作数。

基址寄存器 + 偏移量 = 要取出值的内存地址

从内存取出后,处理器会将值放入指令中指定的寄存器内

在存储指令中,三个操作数分别为:基址寄存器、偏移量、要取出值的寄存器

基址寄存器 + 偏移量 = 要保存值的内存地址

从寄存器取出后,处理器会将值放入指令中指定的内存里

我们知道,1字节 = 8位,而RISC-V采用的计算机是32位,因此1字 = 4字节。

在体系结构中一般都是按照字节来寻址的,因此内存的地址是第1个字节、第2个字节……

我们也知道,很多数据类型都是1字的,比如int整数类型。

因此,在载入指令中,分为取字和取字节的指令。对于取字的指令而言,一次取4个字节,那么要取出的内存地址就应该是

要取出值的内存地址 = 基址寄存器 + 4 * 偏移量

我们又会想,一个值需要4个字节来存储,也就是这个值要被分为4份,分别存储在各个字节中,怎样分呢?

有两种模式:大端模式和小端模式。RISC-V采用的是小端模式

对于大端模式而言,这个值从左向右开始存,最开始的那部分放到内存地址小的一端。

例:
0000 0001 0000 0010 0000 0011 0000 0100
内存地址        值
3           0000 0100
2           0000 0011
1           0000 0010
0           0000 0001

小端模式:与大端模式相反

内存地址        值
3           0000 0001
2           0000 0010
1           0000 0011
0           0000 0100

因为内存中1个字节所能容纳的数字可以按2^{n}来表示,所以由一个个字节组成的内存容量一般也用2^{n}来表示。1MB = 1024KB = 2^{10}就是这么来的。

立即数

指令中有没有可能存在常数呢?非常有可能。我们在代码中常常使用i = i + 1,如果每次都要取内存中取1这个常数值,效率就会被大大降低。加速经常性事件的思想

而由于0在指令中常常被使用,所以x0寄存器硬接线0,也就是说x0永远为0。(加速经常性事件的思想)如果有指令想要给x0赋值,那个新值会被丢弃。

而对于数字而言,就会有正负之分。有时,我们需要区分正负;有时,我们不需要区分正负(无符号数)。因此,载入指令中包含了取无符号数的指令。

因为RISC-V寄存器中是32位,而有时我们取到的值可能并没有32位这么大,此时就需要将这个值扩展到32位。这就是符号扩展的工作了。

符号扩展,对于有符号数,按照其符号位的值在数字左边进行扩展;对于无符号数,数字左边加0即可。

有符号数:
1000 -> 1111 1000
0001 -> 0000 0001
无符号数
1111 -> 0000 1111
0001 -> 0000 0001

计算机中的指令表示

我们都知道,计算机是以二进制存储数据的。同样,计算机也是用二进制存储指令的。

但是指令是多少位的二进制组成呢?回想一下,RISC-V下的寄存器是32位,而我们要处理指令就要将它放到寄存器中。没错,指令也是32位的二进制组成。无论是算术运算指令、还是数据传输指令,还是RISC-V下的其它指令,都是32位组成。简单源于规整

image-20240916131251282

R型指令:

7位操作码 + 5位源操作数寄存器 + 5位源操作数寄存器 + 3位操作码 + 5位目的操作数寄存器 + 7位操作码

I型指令:

12位立即数 + 5位源操作数寄存器 + 3位操作码 + 5位目的操作数寄存器 + 7位操作码

S型指令:

前7位立即数 + 5位源操作数寄存器 + 5位源操作数寄存器 + 3位操作码 + 后5位立即数 + 7位操作码

指令中的立即数是补码,既可以是正数也可以是负数。

同时根据指令的不同构造,我们也可以推断出他们是什么指令。R型指令没有立即数,因此可以是算术运算指令;I型指令立即数代替了一个源操作数寄存器,因此可以是载入指令,也可以是算术运算指令中添加立即数的指令;S型指令立即数代替了目的操作数寄存器,因此可以是存储指令

我们看到这些指令基本都对齐了,这也是降低硬件复杂性的一种手段。

逻辑指令

逻辑指令主要指的是与、或、异或以及逻辑左移、逻辑右移这些操作。

因为RISC-V指令都有三个操作数,所以没有NOT指令,用异或XOR指令代替。

在此简要介绍一下AND指令。如果你看过特工片,看到特工将特定位置的字符组合起来,形成一段话。那么这同样可以通过AND指令实现。AND指令可以获取特定位置二进制编码值

用于决策的指令

在代码中,我们通常会使用到如果大于、如果小于这种逻辑。

在指令中,也有这样的实现方式(条件分支指令),如果x0 < x1就跳转至L1语句……

而L1这样的标示要作为标签显式地写在对应语句的前面

而我们在代码中,还要经常保证数组下标不要越界。在指令中也有对应的实现方式。

0 ≤ i < arr.length,将i视作无符号数,与数组长度进行比较。

因为数组长度肯定是正数,而i如果是正数且比数组长度大,则越界;如果i是负数,则它的第一位必然是1,将i视为无符号数,则它必然大于不是负数的数组长度

case/switch语句:将可能的结果和其要跳转到的地址以字数组的方式放在内存中,程序将字数组中对应的条目加载到寄存器,随后根据寄存器中的地址信息进行跳转,使用跳转指令

跳转指令

跳转指令两个操作数的指令和三个操作数的指令

两个操作数:要跳转到的语句,存放当前语句下一句地址的寄存器(PC + 4)

三个操作数:基址寄存器,偏移量,存放当前语句下一句地址的寄存器(PC + 4)

一般来说,两个操作数的指令用来负责调用函数,因此需要一个寄存器指出要跳转的位置,一个寄存器记录要返回的位置

三个操作数的指令指出要跳转到的语句地址 = 基址寄存器 + 偏移量,一个寄存器记录要返回的位置。主要用于case/switch语句和函数调用结束后的返回。例如将返回地址设为x0寄存器,则函数的下一行语句被丢弃,直接返回到初始点。

计算机硬件对函数的支持

调用函数时:

  1. 放置参数(将参数放在指定寄存器)

  2. 控制交给函数(跳转到函数所在的语句)

  3. 获取函数所需资源

  4. 执行任务

  5. 放置返回值(将返回值放在指定寄存器)

  6. 返回到应返回的位置

RISC-V将x10 ~ x17用于传递参数和返回值,x1作为返回地址寄存器

用程序计数器PC来保存当前指令的地址

我们都知道调用函数时是使用栈来保存函数变量的,这是怎样做到的呢?

首先,x2寄存器又被称为栈指针寄存器(sp),用于保存栈顶的地址信息

  1. 函数开始

  2. 为栈开辟一定数量的资源,移动栈顶

  3. 如果函数需要使用这个寄存器,且这个寄存器的内容是需要被保存的(如上一层函数的变量),将这些值保存到栈中

  4. 使用寄存器保存操作数

  5. 运算结束

  6. 将栈中的值弹回对应的寄存器

  7. 返回调用点

image-20240916140123838

分配新空间

调用函数时会将函数变量保存到栈中,但我们也知道,在多次调用中函数的参数、返回地址都需要放入栈中保存,他们在栈中保存的顺序是怎样的呢?

  1. 首先是函数的参数

  2. 第二是返回的地址

  3. 第三是保留寄存器

  4. 栈顶是局部数组和结构体

因为栈指针在函数中会发生改变,所以会提供一个帧指针(FP)指向本层函数最开始加入的数值(栈尾)。但我们可以通过维护稳定的栈指针来避免对FP的使用,比如只在函数开始和函数结束时调整栈。

image-20240916144143316

而我们不仅要关注一个函数中对空间的分配,还要关注运行整个程序时,内存是如何进行分配的。

从最小的地址开始,依次保存的是保留、代码、静态数据、动态数据和栈。

动态数据主要指的是数组和链表,存放这类数据结构的段被称为。堆是可以动态增长的,栈也是如此。这种分配因而允许了栈和堆相向而长,以达到内存的高效使用。

image-20240916145716231

C语言中忘记释放空间导致“内存泄漏”,过早释放空间导致“悬空指针”。而Java通过自动内存分配和垃圾回收机制来避免这类错误。

现在,我们来一起看下RISC-V中各个寄存器的用处。

名称寄存器号用途调用时是否保存
x00常数0不适用
x11返回初始点
x22栈指针
x33全局指针
x44线程指针
x5 ~ x75 ~ 7临时
x8 ~ x98 ~ 9保留
x10 ~ x1710 ~ 17参数/结果
x18 ~ x2718 ~ 27保留
x28 ~ x3128 ~ 31临时

人机交互

ASCII码需要8位来表示,主要用于表示字符

程序一般采用字符数组来表示字符串,C语言通过在最后位置用0表示字符串结尾,Java语言用第一个位置记录字符串长度。

Java中Unicode表示字符,而Unicode需要16位来表示。因此Java的字符串占用内存大约是C的两倍

对大立即数RISC-V编址和寻址

我们通过前面集中指令发现,立即数为12位。但如果我们需要的立即数超过12位呢?

RISC-V提供了一种U型指令,其中可以存储20位立即数。

U型指令取出前二十位立即数,从左向右依次放到寄存器中。然后使用算术运算指令将后十二位立即数加到寄存器中即可。

U型指令:

20位立即数 + 5位目的操作数寄存器 + 7位操作码

分支中的寻址

RISC-V的分支指令中有12位立即数,可以使代码跳转到-4096~4094的位置,但只支持跳到偶数地址。

RISC-V的跳转指令中有20位立即数,可以使代码跳转到-1M~1M的位置

但我们知道,当今程序的数量,2^{20}并不足够。因此我们采用PC相对寻址

要跳转的位置 = PC + 分支地址偏移量

这就意味着,程序能在距离当前程序±2^{12}的地方进行分支跳转,在±2^{20}进行无条件跳转。

RISC-V寻址模式的总结

截至目前,我们有了四种寻址方式。

  1. 立即数寻址

  2. 寄存器寻址

  3. 基址寻址(载入和存储)

  4. PC相对寻址(分支和跳转)

image-20240916163503527

而现在,我们也可以说一下RISC-V中到底有什么指令了?

  1. 算术运算指令

  2. 数据传输指令

  3. 逻辑运算指令

  4. 移位操作指令

  5. 条件分支指令

  6. 无条件跳转指令

指令的设计,源于质朴的需求。截止目前,我们讲述了RISC-V的指令系统及其硬件的实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值