sky 发表于 2020-9-24 10:05:50

用一段C代码编译的指令代码,来阐明RISC-V架构的简洁

本帖最后由 sky 于 2020-9-24 10:05 编辑


前言笔者最初对计算机产生浓厚兴趣,是因为想要弄明白为什么计算机能够帮我们计算1+1=2的?就是这样朴素的心愿,让我进一步去探寻学习。这篇是我很早之前的一个回答,让我决心在工作之余写一些东西分享。
对编程感兴趣的程序员是否都对电路、单片机也怀有浓厚的兴趣?
现如今,我终于可以绕过ARM和X86黑色的面纱,从另一个开源的CPU架构RISC-V中去深入的认识计算机的工作原理。
这篇文章想通过分析一段C代码,以及分析C代码最终产生的机器码指令,来分享一点对RSIC-V结构优劣势的思考。
先从一段最简单不过的“插入排序算法”开始void insertion_sort(long a[] , size_t n){
for(size_t i = 1,j;i < n; i++){
long x = a;
for(j=i; j>0 && a > x;j--){
a = a;
      }
a = x;
    }
}
上面的一段代码是C语言实现的经典“插入排序”算法,主要用到了一些基本的原子操作,例如,“比较大小”,“交换内存位置”,等等。换句话说,只要一个智能设备,人或者是计算机,只要它具备这些原子操作的能力,然后只要我将做事的顺序和方法(算法)告诉他,他就可以帮我们完成插入排序这样的工作。
不同的执行机构对原子操作的实现不同,具体的体现在指令集架构的不同,硬件设计的不同,这篇文章会分析两种种架构实现原子操作的指令方法,分别是STM32等流行嵌入式架构(ARM-32),以及开源轻量的后起之秀RISC-V架构,分析他们在同一段C代码下的执行过程和效率比较。我们把编译器的优化等级设置到最高,首先对比不同架构的极限代码大小。
最终的编译结果如下图所示


可以看出,无论在代码大小还是在指令数量上RSIC-V的行那都不逊色于X86和ARM-32。


首先简单看一下ARM-32架构编译出的指令




上述的指令代码,要先告知读者含义,最左边的0 ,4,8,c之类的数据代表十六进制的行号,可以看出代码指令是4字节也就是32BITS对齐的,这也是取名叫ARM32而不是ARM16的原因之一。紧跟着的是机器码,所谓的程序部分也就是这个,这一个32BITS的机器码,包含了这次指令所有的信息。再跟着的是汇编伪代码,为了进一步能让人类去理解机器码的含义。最后面的是注释。
举例来说:
e3a03001 mov r3,#1
代表着将1这个数据移动到了r3这个寄存器中,等效于C语言的i=1,mov r3 #1这个操作的所有信息都包含在了e3a03001这一个长度为32bits的数里面,方便代码的储存。至于如何把汇编转变成机器码,那就是编译器去做的事情了。
重点看RISC-V的编译结果,因为RISC-V的文档很简明方便学习




对比一下,能看出和ARM-32的结果相比,汇编的伪代码有所不同,而且由于框架不同,编译出来使用的指令也不同。我们假设编译器做如下分配,通用寄存器A1存放C代码中n的内容,A3存放数组a的地址,A4存放i的内容,A5存放j的内容,a6存放中间变量a6的内容。
我们逐一分析指令代码
Addi a3,a0,4


假定数组a[]的存放地址是0x00,我们把其第一个元素a的地址0x04传进去,因为很显然插入操作是从a开始做的。把0x04加载到a0寄存器中,结果写入a3,相当于认为a3存放着C语言中a的地址。这里做个假设a的首地址是0x00,很显然数据a[]的首地址是通过传参传进来的。Addi a4,x0,1
将1加到x0寄存器中,最终结果写入a4寄存器。
bltu a4,a1,10


如果 a4和a1相比,a4<a1,那么程序PC值增加10。从前面的代码看出a4存放i,a1存放的是n,也就是数组的最大长度,所以这一句在比较i和n的大小,如果i<n,程序PC+=10,否则继续运行。
jalr x0,x1,0


相当于C语言里的return语句,函数结束。显然,如果bltu a4,a1,10语句没有跳转,也就是i<n的条件不满足,这一句会被执行。如果i<n条件满足,会进到0x10的指令位置
lw a6,0(a3)


前面认为a3寄存器存放着输入数组a[]的首地址,所以lw a6,0(a3)意味着把a3目前指向的地址进行一次取值,相当于a6=*a。前面也假设了编译器用a6存放x的数据,所以也就是x=a的含义。
Addi a2,a3,0
假设a2存放a的数据,显然a要首先指向a的位置,所以先把0加到a3(也就是a的地址)然后把结果方在a2的位置。这样a2的地址和a3的地址实际隔了0个地址。这个0在后面肯定会是其他数字,来完成数组遍历。
Addi a5,a4,0
a5存放j的数值,前面假定了a4存放i的数值,所以这句话的含义就是j=i
程序运行到这,整理一下,目前a2=a3都存放着a的地址,a5=4存放着i和j的取值也就是1。
接着往下进行
lw a7,-4(a2)
又出现一个新的寄存器a7,从a2的地址拿数据,这里-4代表拿的不是a2这个地址的数据,而是往前4个Byte,很显然,C代码显然的说明了a[]数组是long型的,所以一个元素是4个字节,a2指向的是a,往前4个Byte,也就是说取到了a的数据。这一句与C语言的a相互对应。A7拿到了a的值,暂且记录a7=a;
bge a6,a7,34
如果a <= a,跳出内部循环,去到0x34地址否则继续往下运行。
sw a7,0(a2)
前面谈到,a[]数组的元素类型是long,所以长度是4个byte,也就是把a7的四个字节写到a2里面去了,翻译一下就是a=a ,此次循环的含义就是a=a。
Addi a5,a5,-1
a5存放j值,等价于j--
Addi a2,a2,-4
a2存放a[]数组的地址,a2地址跟着j减小一起减小4字节
bne a5,x0,1c

可以看到x0是i的取值,所以这句话相当于,if(j!=i)跳转到0x1c。否则继续执行到0x34。
Slli a5,a5,0x2

把j的值左移2位,也就是相当于把j乘上了4,j=j*4
Add 15,a0,a5
原来j是一个计数值,当j乘上4以后,4*j就是地址的偏移值了,所以认为此时a0存放的是&a。
Sw a6,0(a5) // a = x
Addi a4,a4,1 // i++
Addi a3,a3,4 //随着i++,&a地址也跟着自增
Jal x0,8 //回到地址0x08,继续循环判断

对比一下
同样完成插入排序的代码
ARM-32用了如下11个操作指令
mov cmp bxcs push ldr subs str bne add bcc pop
而RISC-V仅用了9个操作指令
addi bltu jalr lw bge sw bne slli jal
显然,指令数量方面RISC-V比ARM-32要优秀的多。指令少,硬件设计的就少,重用率高。
像我们这样逐句分析代码的人就会少受一点查阅资料的痛苦。
官方的RISC-V文档给出了更多的比较,见下图
后面会在深入研究RSIC-V的过程中慢慢发现验证。
最后
这篇文章主要是分析了一段C代码在RISC-V框架下的编译后的指令流程。窥探了一小部分编译器开发者的智慧精华,因为如果你能仔细的看完最终的指令代码是如何完成C语言想要完成的功能的,你就会赞叹了,并且对C语言会有一个更加清晰的认识。
比如说,熟练的C开发者都喜欢这样写加法操作,a+=10。对比汇编来看,这正是迎合了Addi a4,a4,1这样的编码风格,实际仅用了A4一个寄存器而已。还有就是jar指令,对应好像是C语言中的goto关键词。Lw指令类似C语言的*符,把指针指向的值取出来,如果能从最底层理解lw指令,那么对C指针一定就不害怕了。
如果能熟悉最底层的实现原理,那么今后想将一个数乘上4,就不会这样写了。
j *= 4;
而是像一个底层开发者一样睿智的写出
j <<= 2; // Slli a5,a5,0x2

本人目前在RISC-V的独角兽半导体公司工作,我想让更多的人认识到RISC-V,感受到RISC-V的魅力所在!
页: [1]
查看完整版本: 用一段C代码编译的指令代码,来阐明RISC-V架构的简洁