免责声明:原课程中穿插了一些我认为没有必要讲的东西,所以逻辑可能有点混乱,但是如果你有C语言基础和计算机组成原理的话,应该可以去理解它。
在这篇文章中我们将继续学习SoC中处理器()相关的知识。 概要如下图:
1、
对于嵌入式处理器(系统)来说,一个核心操作就是如何将C代码映射为机器代码并在嵌入式平台上运行。 为什么是 C 代码? 由于C语言比较贴近硬件,所以可以通过指针直接操作寄存器、操作内存等。 对于SoC工程师来说,C语言是必须掌握的语言。 SoC验证工程师经常需要编写一些C代码来验证SoC芯片,这与传统的芯片验证不同。
当然,本系列文章无法涵盖C语言的一些语法细节。 主要目的是粗略地解释一些C语言语句是如何映射到硬件的。 当大家明白了这一点之后,本系列文章的目的就达到了。 。 对于想学习C语言的人来说,网上有大量的优质课程。
从前面的内容我们已经知道,硬件只能识别0和1。指令集架构是硬件和软件之间的桥梁。 基于指令集架构,我们知道如何将C语言映射成相应的机器代码。 在处理器上运行。
我们思考一下这个问题,为什么Intel和AMD占据了个人PC市场,但我们却很难看到基于Intel处理器的手机呢? 为什么ARM架构在移动市场占据主导地位,而我们很少看到基于ARM的个人PC?
这就不得不提到指令集架构生态的问题。 前面提到,指令集架构是软件和硬件之间的桥梁。 软件开发人员根据您的指令集架构开发软件和编译器。 如果突然想使用新的指令集架构,以前的软件理论上就无法运行。 这通常被称为生态问题。
还有一点值得一提的是,指令集架构可以添加新指令,但如果想去掉旧指令,那就麻烦大了。 删除旧指令意味着大量程序将不再运行,编译器可能必须重写。 为了兼容过去的程序,必须保证之前的所有指令都能运行。 这就是所谓的历史包袱。 添加新的东西很容易,删除旧的东西很难。
我们来看看一些已经失败(接近失败)的指令集架构。 这些指令集架构可能仍然每年都会出货,但在今天乃至未来的处理器市场,它们的市场份额将会越来越低。 为什么这些指令集架构会失败? 如果您有兴趣,可以搜索相关文章。 (原因包括但不限于生态、技术细节、公司自身理念等)
目前开源软件有很多,指令集架构之上的抽象层面都有非常著名的开源软件,比如操作系统、编译器、应用程序等,为什么硬件就不能开源呢?
让我们看一下一个复杂的 SoC,它上面有很多处理器。 但在RISC-V诞生之前,很多都是基于ARM或MIPS架构的。 这些指令集架构需要获得许可和购买。 对于芯片公司来说,这本身就是一笔巨大的开支。 很多时候,一些小型处理器的设计难度并没有那么高,没有必要购买ARM等产品。 早期,很多公司都开发了自研指令集。 然而,自研指令集一个明显的问题就是生态问题。 软件移植非常麻烦。 此外,还缺乏相关的开发工具。
RISC-V就是在这样的背景下诞生的。 它最初是 UCB 的一个暑期学生项目,作为第五代 RISC 指令集架构。 但随后却得到了巨大的反响,目前基于RISC-V的芯片的年出货量不断增加。 选择RISC-V相关主题或工作对于对指令集架构感兴趣的芯片开发人员和软件开发人员来说都是一个不错的方向。
以下是 RISC-V 的一些特性(优点)。
为什么上面先讲C语言,然后再讲RISC-V呢? 由于本文是基于RISC-V的,所以我将简单介绍一下从C到RISC-V的汇编过程。
2.基本CPU
我们将根据以下处理器进行解释。 非常简单,您甚至可以自己实现。 虽然理论上很简单,但是可以不断改进,直到支持RV32I相关指令。 (这里假设大家对计算机组成原理都有基本的了解,我会尽量解释清楚,有不懂的可以自行搜索相关术语)
假设你想用C语言代码实现f=(g+h)-(i+j)。 想想看,需要多少行RISC-V汇编代码? 理论上需要三个。 为什么? 首先,RISC-V的R型指令有两个源操作数和一个目标操作数。 因此我们无法一次性计算出f。 我们可以先计算g+h,将临时结果保存到寄存器中,然后以同样的方式计算i+j。 最后将这两个结果相减,得到最终的f。
是否可以执行一条指令并一次性计算出所有指令? 理论上是可以的,但实际上没有意义。 因为我们可以通过简单的组合来实现相关的操作。 如果我们将六个数字相加呢? 如何实施? 你的32位还不够。 在这种情况下,你还是要把它转换成简单的操作组合。
至于乘法,因为它的应用非常广泛,而且如果用加法来实现乘法,可能需要很多很多周期。 例如,乘以 1000 需要相加一千次。 看来乘法指令是非常有必要的。 因此,RISC-V指令设计并不是最小子集。 它执行某条指令和不执行某条指令。 它们都是通过大量实际应用的需求分析得出的,大家都能体会到这一点。
我们从微架构层面来思考。 实现一条加法指令需要多少步? 首先需要取出指令,然后需要访问源操作数,将源操作数相加,最后将结果存储在寄存器中。 这涉及到下面提到的三个组成部分。
接下来让我们自己构建这三个组件。 第一个是指令获取。 取指令代表一条指令的开始。 现代CPU将指令视为数据,指令放置在内存中。 要访问内存,您需要给出一个地址。 这个地址是PC()。 通过给PC机,得到相应的指令。 这称为 Fetch, IF。
我们目前只考虑RV32I指令,都是32bit,即4Byte。 因此,如果我们给出一个地址,就会返回32位数据。 同时如果是顺序执行的话,对应的地址要增加4(即4Byte),即PC+4。 指令由字段组成。 如果这些字段排列有规律,解码逻辑(即识别这些指令)将会大大简化。 比如把统一放在低7位,这样只要识别出这7位,就可以知道这是一条什么样的指令。 然后再做进一步的鉴定。
RISC-V共有六种指令类型。 其中,S型和B型非常相似,U型和J型也很相似(唯一的区别是位级别的对应关系不同。例如S指令的最高位代表imm [11],而B类指令则代表imm[12]),如下图所示。 最简单的取指令逻辑可以通过以下方式实现:PC寄存器、加法器和指令。 通过顺序添加PC,理论上可以实现指令的顺序读取。
完成取指令后,第二步是访问相应的源操作数。 RISC-V的源操作数和目标操作数实际上存储在通用寄存器文件中。 RISC-V有32个通用寄存器,从x0~x31。 从逻辑上讲,这些寄存器是相同的,只是为了简化与软件的接口。 RISC-V 对这些寄存器的使用设置了相应的限制。 例如,x1寄存器代表返回地址,也称为ra寄存器。 详细可以看下图。
我们来看看基于具体RISC-V指令的寄存器访问操作。 我们以R型和I型为例。 对于R类型指令,它有两个源操作数和一个目标操作数。 对于硬件实现来说,通常需要一个两读一写的寄存器文件,如下图所示。
对于类型 I 指令,有来自寄存器的源操作数和目标操作数。 另一个源操作数是立即数。 对于后续操作,需要扩展立即数。
接下来我们看看执行操作。 同样,我们仍以R型指令和I型指令为例。 对于R型指令,它有7位,其中有15位用作寄存器指针。 这留下 10 位可供使用。 所以理论上,在固定的情况下,你可以有1000条R型指令。 其实我们没有那么多指令,基于简化硬件设计的原则。 您可以取出一些位并将它们用作 ALU 的 SEL 信号。 根据不同的SEL,选择不同操作的操作结果。 比如是选择加法运算的结果还是异或运算的结果等。当然,不计数的东西应该通过时钟门控关闭,以节省功耗。
对于I型指令来说,其实也是类似的。 理论上,它与R型指令共享ALU。
位运算有什么用? 下面简单介绍一下它对于MASK的操作。 这里比较简单,看图就可以了。
3. 和
我们来看一段非常简单的C代码,以及它是如何映射数据和指令的(下图其实是一个简化版,你大概就能明白意思了)。
编译器将 C 代码转换为机器语言。 指令和数据会根据相应的Map映射到相应的地址空间。 对于程序代码来说,它是一个文本段,映射到指令。 全局变量对应于Data,会被赋值给data。 程序运行过程中,涉及到很多加载/存储操作。 相应的微架构也可以参见下图。 PC指针指向程序段,gp指针指向数据段。
我们来看看C语言中的变量。 如果需要在C语言中使用变量,则需要对其进行初始化并指定其数据类型。 为什么需要指定数据类型? 因为编译器需要根据数据类型知道它占用了多少内存地址空间,然后才能分配相应的。
全局变量和变量(即关键字声明的变量)存储在内存中的静态数据段中。 局部变量存储在堆栈中。 编译器还会将一些变量直接存储在寄存器中。 本文的重点不是C语言。 后面只是介绍RISC-V如何操作变量。 关于C语言变量的更多信息,可以搜索相关资料。
让我们把视角转向 RISC-V。 RISC-V 如何访问变量? 首先要记住的是 RISC-V 基于加载/存储架构。 只有通过加载/存储操作才能实现交互。 同时RISC-V只支持它。 我们以Load操作为例,它是基于I型指令的。 rs1指向的寄存器存放base Addr,然后用Imm作为偏移量,共同指向需要访问的地址。 rd 指向目标寄存器。 详细可以看下图中右边的小图。 方向非常明确。
但仅依靠上述指令是不够的。 虽然是基于Imm,但是上下都可以得到。 但对于整个内存地址空间来说,它只占据了很小的一部分。 如果我需要访问确切的地址怎么办? 比如你想访问这个地址。 可以使用哪条指令来执行此操作?
其实lw也可以做到,但是有一个前提条件。 您的寄存器之一需要存储相对相似的数据,然后才能访问该地址。 最简单的是,如果你已经存储了某个寄存器,只需使用偏移量为0的lw即可访问该地址。 问题是,如何将其存储到寄存器中? RISC-V的设计者当然也考虑到了这个问题。 它提供了一个U形指令。 通过lui操作,可以将立即数存储到rd[31:11]中,通过addi操作,可以将立即数存储到目的寄存器中。 。
RISC-V还提供了基于PC的加载方法。 通过auipc指令,可以以PC为基地址,实现将地址大量加载到相应的寄存器中。 这种方法可用于支持位置无关代码 (PIC),从而支持动态链接。
让我们把视角转向 C 代码。 当我们声明一个整型变量时,编译器会做什么? 首先需要为该变量分配4Byte,然后使用 模式将0x03放入该地址。
我们还可以定义一个指向这个变量的指针。 变量存储的地址是,那么指针存储的值就是通过&运算符得到的地址。 要理解 int * 语句,需要从右向左阅读。 是一个指向int类型变量的指针(其实也可以从左到右读,可能是因为老师的母语是希伯来语,习惯从右到左读)。
上面的例子就是为了让大家明白所谓的指针是做什么的。 相信对于一个芯片工程师来说,指针的概念是非常容易理解的。 因为你们都知道计算机组成原理的基础知识,所以对内存地址等概念都非常清楚,而且很容易理解一个指针。 (一位资深程序员曾经告诉我,当你学习C语言没有问题时,意味着当你看到指针和普通变量没有区别时,你就已经学会了C语言)。
我们再看一下指针运算。 我们已经知道指针存储地址。 如果知道地址对应的值怎么办? 可以通过解引用运算符获取更改后的地址对应的值,如下图所示。 另外,有一个话题也困扰了很多人。 不同类型指针的大小是多少? 其实很简单,因为指针存储的是地址。 对于32位机器来说,所有地址都是32位的,所以所有类型的指针都是4字节,对吗? 相应地,我们可以直接对指针进行++或+1,这里并不是真正的+1Byte。 相反,指针指向下一个元素。 每个元素是32位,所以实际上增加了4个Byte。 此操作经常用于迭代数组。
然后我简单介绍了Array和。 其实我真的不想讲这部分,因为我不太用C语言,讲这个很容易误导人。 不过这个课程也讲了一些C语言的知识,所以我都放上来了。 如果你想阅读它们,你可以看一下。 如果你不想读,就不读。 只关注微架构和指令集架构相关知识。

我们来总结一下加载/存储操作。 C 变量存储在其中,在执行操作之前需要将其加载到寄存器中。 比如a=A[3],这个a是一个寄存器,理论上A也是一个寄存器。 它是一个指针,指向数组A,这个数组存放在里面,a=A[3]就会转化为Load操作。
为了有效地实现加载/存储操作,通常需要2R1W的寄存器文件。
4. 流程
通常,程序是逐行执行的。 从指令集架构层面来看,PC递增到PC+4,取出一条32位长的指令。 但这种方法只能实现比较简单的程序。 复杂的程序基本上都会涉及控制流,即while、if、else等操作。 这些高级语言如何映射成机器语言呢? 这时候就需要分支指令的支持。
上面是关于 if/else 语句的内容。 其实和for循环语句是一样的,比如for或者while。 映射到RISC-V指令集架构上,它们都对应类似于BNE的指令。 因为本质上有两种方式,要么跳,要么不跳。
5. 通话
我们来看看RISC-V是如何调用函数的。 函数的重要性无需我多言。 函数实际上是位于指令中的一段封装代码。
对应RISC-V指令集架构,当要调用一个函数时。 函数通常不与主函数连续存储,而是位于内存的另一部分。 因此,需要改变流程,即改变当前的PC和指向该函数的程序。 这个跳转是无条件跳转,和前面的if/else不同。 对应J型指令。 RISC-V提供了两条跳转指令,jal和jalr。 这两个都是根据当前PC,通过跳转到对应的PC。
跳转时,当前的PC+4必须存放在ra寄存器中。 否则调用该函数后就回不来了。
一般来说,函数有输入参数和返回值。 RISC-V指令集使用a0~a7寄存器作为函数的输入参数。 当它们不够用时,就需要使用栈。 函数内的参数是源参数的副本。 他们不能直接修改源参数的值。 如果你想修改它,你必须通过指针传递它。 虽然指针也被复制了,但它实际上指向的是原始参数。 想象一下,有一个指针指向一个值,并且您复制了该指针,该指针也指向该值。 虽然两个指针不是同一个指针,但是它们指向同一个值。 所以可以进行修改。
对于返回值,通过a0~a1寄存器传递。 A0~a1可以作为输入参数,也可以作为返回值。 不够的时候就得靠栈了。
对于RISC-V来说,它定义了寄存器的具体使用。 包括状态寄存器、保存寄存器、临时寄存器等。在函数调用过程中,可以重写寄存器的值。 很多情况下,我们不希望函数调用后之前的值被重写。 这可能会导致程序错误,因此您需要对其进行备份。 是调用前备份的,还是被调用函数后备份的? 如果全部备份的话,会造成性能损失。 因此,RISC-V官方定义了部分寄存器备份和部分寄存器备份。 调用完成后,它们应该具有之前的值。
为了确保这些,函数通常不仅包括功能代码片段,还应该具有用于维护寄存器值的和。
让我们看一个实际的例子。 为了调用,需要传递的参数首先应该放在a0~a7上。 这里只调用了一个 SQR 函数。
首先,您需要打开一个堆栈帧。 堆栈从上到下增长。 因此使用 addi sp,sp,-8。 打开两个单词堆栈。 然后保存h的值,假设之前保存在a1中,因为后面会用到h,而且SQR函数可能会覆盖a1的值,所以备份一下。 那么ra的值也被保存,因为SQR也会重写ra的值。 这是一个嵌套函数。 (ra之前存储的是调用EXMPL函数的那行代码的PC+4,调用SQR之后,ra会被重写为lw t0,8(sp)那行的PC)
调用完成后,取出h的值,与f相加,然后恢复ra并释放栈空间。 跳出EXMPL函数。
让我们再看一下 C 代码中的空格。 可以简单概括为Local Scope、Scope、Scope。 全局变量和静态变量都存储在数据中。 自动分配的局部变量存储在 Stack 中(手动打开的局部变量存储在 Data 或堆中)。
6.RISC-V
RISC-V 基于模块化设计。 其核心是一个称为RV32I的基本ISA,通过它可以运行完整的软件堆栈。 自从RISC-V诞生以来,RV32I就已经固定下来,不会改变。 当用户需要在不同场景应用RISC-V时,可以告诉编译器当前硬件实现了哪些扩展,从而可以生成当前硬件条件下的最佳代码。
RISC-V的官方指令集扩展主要有MAFD,RV32G相当于,其中G代表。 由于其模块化和硬件可扩展性,RISC-V可以应用于不同的场景。 例如手机处理器、高性能GPU、嵌入式处理器等都可以基于RISC-V架构进行设计。
我们来看看RISC-V的一些指令特性。 首先,RISC-V采用-模式。 我已经讲过什么是小端模式。 你也可以自己搜索一下。 那么RISC-V不需要字对齐,也支持不对齐访问。 但最好不要这样做,因为这会极大影响内存访问效率。
RISC-V 与 MIPS 的不同之处在于它不使用延迟槽。 从今天的角度来看,延迟槽是一个非常糟糕的设计。 延迟槽的最初设计目的是为了尽可能减少分支指令的影响。 让处理器在分支指令之后不浪费一个时钟周期。 然而,随着处理器技术的发展。 超标量处理器和超深流水线处理器使得这条指令变得非常没有意义,很多情况下这样的指令是找不到的。 另外,延迟槽处于ISA级别,这意味着它暴露给程序员,这使得编译器的开发变得更加困难。 编译器往往无法感知动态信息,只能添加NOP。 延迟槽使硬件设计变得复杂。 ,但不会提高性能。 而且指令集架构如果要增加某种设计就很简单,但如果要去掉它就变得复杂了。 如果去掉,就说明之前的程序不兼容,所以只能向前兼容。
RISC-V的一个显着特点就是它官方支持大家的指令集扩展,并预留了大量的指令编码空间供大家使用。 基于此,可以开发出针对不同领域的处理器,因此RISC-V经常与DSA同时被提及。 许多公司也在基于RISC-V开发特定领域的定制处理器。
我们前面提到的 RISC-V 指令都是 32 位且字对齐的。 但为了节省编码空间,RISC-V还支持压缩指令编码。 称为“C”扩展。 实际上支持RV32C的处理器并不多。 学习RISC-V的重点应该是标准编码格式。
RISC-V的RV32I指令集不包含乘法。 事实上,乘法可以不用乘法指令来实现,也可以通过多次加法来实现。 但考虑到乘法的使用非常频繁,目前的处理器通常默认支持乘法。 使用乘法指令可以有效减少指令数量,提高程序的运行速度。 (RISC-V的设计理念是尽可能简洁,但如果大量使用,是可以进行适当扩展的)。 乘法和除法指令属于RV32M指令集。
许多RISC-V处理器没有浮点运算单元,可以通过整数运算来模拟浮点运算。 对于处理器作为控制器的场景,对计算精度要求不高。 浮点运算单元通常面积较大,需要额外的浮点寄存器,成本非常高。 因此,如果不是特定的应用场景,比如DSP应用、高精度AI计算等,不使用浮点运算的话就不应该使用。 RISC-V的浮点运算扩展是RV32F和RV32D。
关于浮点单元硬件,其设计非常复杂,感兴趣的朋友可以自行搜索。 这里不多做介绍。
我们来做个总结吧。 RISC-V官方提供了以下标准指令扩展。 附图是指中国的一个村庄,那里的妇女不剪头发,但这并不是绝对的。 不剪头发算作基本 ISA,在基本 ISA 上做额外的发型算作指令扩展。 (说实话,我没明白教授想表达什么……)
7.构建
我们来回顾一下程序是如何运行的。 可以简单概括为CALL,即link、Load。 (这部分主要是上传老师的课件,所以你不需要看我写的文字)
预处理可以算作一个部分,它的主要作用是扩展宏定义和头文件。
然后编译操作将高级语言转换为汇编语言,其中汇编语言还包含一些伪代码。
接下来,汇编器将汇编代码转换为目标代码。 这里涉及到很多细节。 如果您有兴趣,可以自行查找相关资料。
然后就是将多个.o文件组合成可执行文件的操作。 运行时需要插入特定的启动代码。 启动代码的功能如下图所示。 它实际上就是一堆初始化操作,为你的程序创建一个良好的运行环境。
外面有一个叫作的操作,通常包含在里面。 它的主要作用是用实际的物理地址替换前面的占位符。
最后是加载操作,也就是加载器。 它的主要负载是将可执行文件从外部存储加载到内存中并执行。
这篇文章的内容有点长。 让我们重点关注 RISC-V 指令集架构本身。 简单看一下C语言就可以了。 不是本文的重点。
下一篇文章将为大家带来公交车相关知识。 总线可以说是SoC芯片工程师最重要的知识之一。 这是面试时必问的问题。 您可以阅读我以前的AMBA巴士文章,首先要学习它。











