装配视角

2020-05-21 09:35:25

如果您没有生活在与冠状病毒隔离的岩石下,您可能听说过C成为汇编语言2.0,或者听说过新的汇编语言,或者听说过这个。嗯,这篇文章将讨论C语言和汇编语言是如何一起跳舞的-探戈?华尔兹?谁知道呢?

我通常写关于操作系统的文章,但我不能完全用一种语言编写操作系统。这可能是可以做到的,但对我来说,我纯粹是用汇编语言编写引导加载程序和一些实用程序。

我将使用RISC-V体系结构来描述C如何转换为汇编语言。关于C的好处是只有相当数量的抽象,所以我们可以提取C的语句并将其转换成汇编。

我教我的学生用高级语言(主要是C++)开发逻辑,然后转换成汇编语言。汇编要求您一次在头脑中记住几件事,即使是编写一个简单的for循环也是如此。幸运的是,汇编中有一些宏可以让它更容易一些。

我们不要忘记,当我们编译C代码时,我们会得到汇编文件。使用GCC的-S开关可以看到中间程序集文件。否则,GCC通常会将程序集文件放在/tmp/目录中。

从技术上讲,C不一定要编译成汇编语言,但据我所知,几乎所有的语言都编译成某种低级语言,而不是直接编译成机器代码(目标代码)。

可执行文件是一种特殊类型的机器代码。可执行文件通常采用操作系统可以理解的特定格式。但是,在可执行文件内部有一个称为文本部分的部分。这是机器代码存储的地方。在Linux中,这称为可执行且可链接的格式或ELF文件。在Windows中,这称为可移植可执行文件或PE。

汇编语言在其之上有许多额外的抽象,包括宏、标签和伪指令。是的,没错,集会可以为你做很多事情。然而,汇编语言的优点在于,或多或少的一条指令实际上就是一条指令(除了可能带有伪指令的指令)。也就是说,我们不让任何编译器告诉我们它想要如何做事情。如果我们想使加法运算符(+)优先于乘法运算符(*),我们可以做到。我们为什么要这么做?嗯..。问问别人吧。

我们使用汇编语言而不是高级语言的另一个原因是使用C或C++不能真正使用的指令。例如,Intel处理器的高级向量扩展(AVX)没有从C到C的一对一转换。现在,许多编译器已经使用所谓的内部函数对此进行了一些工作。然而,这些并不允许我们在组装中充分利用我们所能利用的东西。大多数内部指令遵循C调用约定,因此它们需要从RAM加载,对其执行某些操作,然后存储回RAM。然而,如果我们直接编写汇编,我们可以将值保存在我们拥有的最快的内存(寄存器)中,直到最后我们需要将结果存储在内存中的某个地方。

优化编译器也许能够对汇编进行一些改进,但让我们看看在Intel中使用SSE进行矩阵乘法的两种不同方法。一种是使用C语言的内部指令,另一种是直接使用汇编语言。

#include<;stdio.h>;#include<;pmmintrin.h>;void calc_intrin(Float Result[],Float Matrix[],Float Vector[]);void calc_asm(Float Result[],Float Matrix[],Float Vector[]);int main(){int row,ol;Float vec[]={1.0,10.0,100.0,1000.0};Float mat[]={2.0,0.0,0.0,0.0,0.0,2.2,0.0,0.0,0.0,22.2,0.0,0.0,0.0,0.0,22.22};Float result[4];calc_intrin(result,mat,vec);printf(";%5.3f%5.3f%5.3f%5.3f\n";,result[0],result[1],result[2],result[3]);calc_asm(result,mat,vec);printf(";%5.3f%5.3f%5.3f%5.3f\n&34;,result[0],result[1],result[2],result[3]);返回0;}void calc_int(浮点结果[],浮点矩阵[],浮点向量[]){int row;_。for(row=0;row<;4;row++){__m128 rowvec=_mm_loadu_ps(&;Matrix[row*4]);__m128 rowve2=_mm_mul_ps(vec,rowvec);__m128 rowve3=_mm_hadd_ps(rowve2,rowve2);__m128 rowVector 4=_mm_hadd_ps(row3,rowve3)。

上述代码将4×4矩阵与4元素向量相乘。但是,如果你看一下如何将矩阵乘以向量,你会发现我们可以并行化每一行,这就是我在这里做的。因此,首先,我们使用ps加载(不对齐加载),ps代表压缩的单个。这意味着它将捕获4个32位浮点数,并将它们放入单个128位寄存器中。然后我们将矩阵行乘以向量。这样我们就可以得到4个元素,所有元素都相乘在一起。然后,我们需要将所有四个元素相加,以获得单个值,该值是该元素处向量的结果。每个haddps指令将两个元素相加在一起。要添加所有四个元素,我们运行指令两次。

您可以在C语言中看到,这看起来相当容易做到。如果我编写程序集,如下所示,我可以对某些操作进行分组,并使用对我有利的较大寄存器集。见下文。

.intel_语法noprefix.section.text.global calc_asmcalc_asm:movupd xmm0,[rsi+0]movupd xmm1,[rsi+16]movupd xmm2,[rsi+32]movupd xmm3,[rsi+48]movupd xmm4,[rdx]mulps xmm0,xmm4 mulps xmm1,xmm4 mulps xmm2,xmm4 mulps xmm2。xmm2移动[RDI+12],xmm3返回。

所以,C看起来更好,但是它能生成更好的代码吗?嗯,据我所知不是。因为我们存储中间值,所以我们对每个内在变量执行内存控制器。对于我们的程序集,我们在开始加载,在最后存储。没有什么是介于两者之间的。

C语言有一个用于大多数体系结构的标准,称为ABI,它代表应用程序二进制接口。我们使用此标准,以便任何语言都可以与另一种语言对话,前提是它们使用该标准。关于C调用约定的一些事情包括,每当我们调用函数时,哪些寄存器获得什么值。此外,它还指定哪些寄存器可以被调用的函数销毁,哪些寄存器必须保留。在RISC-V中,实际的RISC-V规范有一个汇编编程器部分,该部分为寄存器定义了以下内容。

您可以看到寄存器有两个名称,一个以x开头,另一个以a、s、t等开头。这些都是指相同的寄存器,但是x只是给出了该寄存器的一个数字。a代表参数,t代表临时,s代表保存。这些寄存器称为ABI名称,然后描述告诉您ABI建议每个寄存器应该做什么。最后,请注意有一个名为Saver的列。同样,我们必须知道我们要负责哪些寄存器。

加载和存储指令可以有四个后缀之一:B、h、w或d,它们分别代表字节、半字、字和双字。RISC-V中的一个字是32位或4字节。

RISC-V中的每个寄存器都是32位(对于RV32)或64位(对于RV64)。因此,即使我们从内存加载一个字节,它仍然被存储到一个更大的寄存器中。使用普通加载和存储指令,该值将从8位符号扩展到64位。

保存寄存器很重要,因为我们同时调用scanf和printf。调用方保存的寄存器意味着当我们调用scanf和printf时,不能保证我们放入该寄存器的值与函数返回时的值相同。大多数参数和临时寄存器都遵循此约定。

被调用方保存的寄存器意味着当我们调用scanf和printf时,可以保证在scanf和printf返回时放入这些寄存器的值是相同的。这意味着允许scanf和printf使用保存的寄存器,但要求它们在返回之前恢复旧值。

通常,函数可以自由销毁所有参数和临时寄存器,但必须保存所有保存的寄存器。要使所有这些都起作用,C必须知道如何处理这些寄存器。

当我教我的学生如何编码(从汇编到机器)和解码(从机器到汇编)指令时,很容易看到每条指令在RISC-V中恰好编码成32位(不包括压缩的指令)。然而,我不太确定如何使用C来教授这一点。让我们来看一个简单的C程序,看看它将如何转化为RISC-V汇编。

.节.rodatcanf_string:.asciz";%d";printf_string:.asciz";.节.text.global main:addi sp,sp,-16 sd ra,0(Sp)la a0,scanf_string add A1,sp,8调用scanf lw a1,8(Sp)la a0,printf_string调用printf ld ra,0(Sp)addmain

上面的代码产生以下输出。我给了100作为scanf的输入:

首先,在C中,所有字符串都是只读全局常量。上面的指令.asciz意味着我们有一个末尾带有零的ASCII表字符串。这就是C构建字符串的方式。因此,C知道起始内存地址,并逐个字符进行,直到它达到零。因此,换句话说,C样式字符串是字符数组。

因此,上面的部分将这些字符串标记为scanf_string和printf_string。标签就像一个变量--它只是内存位置的一个好名字。

text部分也称为代码部分,它是我们放置CPU指令的部分。指令.global main使标签main在此程序集文件外部可见。这是必要的,因为链接器将查找main。然后,我们有一个名为Main的标签。您可以看到,函数只是一个内存标签,我们可以在其中找到该给定函数的CPU指令。

在C中,每个函数都必须有唯一的名称。因此,C函数的名称直接对应于程序集的内存标签。在本例中,main是int main()的标签。

SP寄存器存储堆栈指针。堆栈是局部变量的内存存储。每当我们从堆栈中分配内存时,我们都会递减。然后,我们拥有堆栈指针递减的值和原始值之间的所有内容。因此,在上面的情况下,main现在拥有sp-16到(但不包括)sp。同样,sp只是一个内存地址。

指令SD代表存储双字。在这种情况下,双字是64位或8字节。我们正在存储一个名为ra的寄存器,它代表返回地址。只有一个RA寄存器,所以每当我们调用另一个函数(如scanf和printf)时,它都会被覆盖。这个寄存器的作用是让printf和scanf可以找到返回main的方法。由于可以从任何地方调用这些函数,因此它必须具有返回路径…。所以才有了回邮地址。

在进行函数调用之前,我们必须确保在调用之前设置了寄存器。幸运的是,RISC-V让这一切变得很容易。第一个参数进入寄存器a0,第二个进入a1,依此类推。对于scanf,第一个参数是指向字符串的指针。因此,我们使用la(加载地址)指令(实际上是一个伪指令)将scanf_string的地址加载到第一个参数寄存器a0中。

第二个参数是C中的&;i,它是我们希望存储扫描内容的内存地址。在本例中,i是一个局部变量,因此我们将其存储在堆栈中。由于字节0、1、2、…。,7由RA寄存器获取,我们拥有的下一个可用偏移量是8。因此,我们可以添加sp+8来获得可以放置我们扫描的值的内存位置。

上面的代码从sp+8加载一个32位或4字节的字,scanf在其中扫描我们的整数。这进入第二个参数,a1。然后,我们加载printf_string的地址并将该内存地址存储到寄存器a0中。

指针和引用在程序集中意味着什么?在C语言中,我们知道指针是存储内存地址的8或4字节内存值。因此,这就像是一个内存地址存储一个内存地址的初始阶段。在汇编语言中,我们在参数寄存器中获得内存地址,而不是值。这里有一个例子。

回想一下,参数存储在a0、a1、…中。,A7。因此,int*值将为0。

.Section.rodataprintf_string:.asciz";输入值:";scanf_string:.asciz";%d";.Section.text.global get_inputget_input:addi sp,sp,-16 sd ra,0(Sp)sd a0,8(Sp)la a0,printf_string调用printf la a0,scanf_string ld a1,8(Sp)调用scanf ld。

请注意,我们将0存储为8(Sp)。这是一个内存位置。因此,当我们为scanf调用ld a1,8(Sp)时,a1将被值指向的内存地址填充,因此scanf将在其中存储值。请注意,尽管它是整数指针,但我们使用ld和sd(双字)。这是因为我们存储的不是值,而是内存地址,在64位机器上,该地址始终是64位(8字节)。

为了演示指针,让我们做一些简单的事情,除了让我们获得一个引用而不是一个值:

当我们四处传递地址时,没有什么真正的改变。但是,当我们取消引用以从内存地址中获取值时,我们必须使用LOAD指令。

.Section.text.global workwork:#int*Left在a0中#int*right在a1#使用LW取消引用左右LW a0,(A0)lw a1,(A1)#现在,将它们相加添加a0,a0,a1 ret。

我们在a0寄存器中留下的任何东西都是返回的。这就是我们的ABI调用a0寄存器“函数参数/返回值”的原因。

一个结构就是连续内存中的多个数据。困难的部分来自这样一个事实,即C编译器将试图通过将数据结构与其大小对齐来尽可能有效地进行内存访问。我将在下面展示这个。

上面,当我们为SomeStruct分配内存时,我们需要12个字节(一个整数等于4个字节)。在内存中,字节0、1、2、3由a获取,4、5、6、7由b获取,8、9、10、11由c获取。因此,在汇编中,我们只需查看偏移量0、4和8,并使用LW(加载字)从此结构加载。

那么,让我们使用这个结构来看看它在组装中是什么样子的。

.节.text.global工作:#s->a为0(A0)#s-&>b为4(A0)#s->;c为8(A0)lw t0,0(A0)#s->;a lw t1,4(A0)#s->;b lw t2,8(A0)#s->;c mul t1,t1,t2添加a0,t0,t1 rt;a LW T1,4(A0)#s->;b LW T2,8(A0)#s->;c mul t1,t1,t2添加a0,t0,t1 ret。

A0寄存器存储结构开始处的存储器地址。然后,我们计算每个元素a、b和c的偏移量。但是,这是一个简单的示例。我们还必须探索另外两个示例:(1)异构数据类型和(2)数组中的结构。

当一个结构中有多种数据类型时会发生什么?C编译器试图使内存控制器的工作变得容易,因此它将填充结构,以便当我们访问结构中的字段时,它只接受来自内存控制器的一个请求。对于异构数据类型,这意味着我们必须浪费一点内存。

上面的结构有1字节、4字节和2字节字段,那么这是否意味着该结构是\(1+4+2=7\)字节?答案是否定的,原因是填充物。C将填充每个元素,以便\(\text{Offset}\%\text{size}=0\)。也就是说,当我们查看元素的偏移量时,它必须可以被字段的大小整除。

当我教我的学生时,我让他们画一个名字,偏移量,尺寸表(NOS表)。在这里,我们可以看到发生了什么,这使得它很容易转化为汇编。

从上表我们可以看到,int b从偏移量4开始,那么字节1、2和3发生了什么呢?这些都是以填充物的形式浪费的。现在,我们可以计算出每个元素的偏移量,因此现在可以翻译以下内容:

我们看到一个结构中有三个整数,我们有偏移量0、4和8。好了,看看这里!它们是一样的。我们唯一需要改变的就是我们的装载方式。我们看一下大小列。回想一下,1字节是字节(lb/sb),4字节是字(lw/sw),2字节是半字(lh/sh)。

.节.text.global工时:lb t0,0(A0)#s->;a lw t1,4(A0)#s->;b LH t2,8(A0)#s->;c mul t1,t1,t2添加a0,t1,t0 ret。

C允许将任何数据结构存储在数组中。数组只是具有相似字段的存储器地址,但是那些相似字段可以是结构。下图显示了具有5个元素的纯整数数组在内存中的外观:

您可以看到公式\(\text{address}=\text{base}+\text{offset}\times\text{size}\).。因此,如果我们的基数是a(现在假设是0),那么a[4]将是\(0+4\x 4=16\)。

对于结构,在结构的末端可能会有填充物。此填充仅适用于数组。请记住,我们需要偏移量%size为零。在查找结构末尾的填充量时,我们采用剩余的偏移量和最大字段的%。我们填充直到这是零。

struct my{char a;long b;int*c;};struct my m[5];m[0].a=';A';;m[3].B=22;

我们有一个由五个这样的结构组成的阵列。每个元素都有字段a、b和c。下面是我们的记忆是什么样子的图表:

回想一下,C通常将堆栈用于局部变量。但是,C允许全局变量。全局变量存储在以下三个部分之一:.rodata、.data或.bss。.rodata(只读数据)存储全局常量,.data存储全局初始化变量,.bss存储全局未初始化变量或初始化为0的全局变量。

BSS区是这里最怪异的。此部分由操作系统自动清除为0。因此,任何未初始化的变量实际上都有一个定义的值-0。

#include<;stdio.h>;int data=100;const int rodata=200;int bss;int main(){printf(";%d%d%d\n";,data,rodata,bss);return 0;}。

我们可以看到,RODATA常量的内存地址为0x2004,值0x00_00_00_c8为200,数据变量的内存地址为0x4030,值0x00_00_64为100。最后,我们的bss变量存储在内存地址0x4038,值为0。该部分只存储变量所在的位置,而不存储其中的内容。操作系统本身将查看此部分,并为所有变量赋值0。

条件是通过使用分支指令来创建的。一些体系结构支持向每条指令添加条件,但随着体系结构的发展,这种情况变得越来越少见。相反,我们分支到不同的标签以跳过代码或跳转到代码。让我们看一看for循环,看看它在汇编中会是什么样子。

.段.rodataprintf_string:";%d\n";.段.text.global main:addi sp,sp,-16sd s0,0(Sp)sd ra,8(Sp)li s0,100#int i=1001:blt s0,0,1f#if i<;0,中断循环la a0,printf_string mv a1,s0调用printf addi s0,s0,-1#i

因此,IF语句和循环使用分支指令有条件地执行代码。在RISC-V中,我们的分支指令比较两个寄存器。如果这些寄存器满足条件,我们转到标签(1f=向前标签1,1b=向后标签1)。如果寄存器不满足条件,则指令无效,我们转到下一条指令。

在C中使用数据类型(考虑汇编时)的目的是确保我们选择正确的加载/存储大小(字节、半字、字和双字),但是我们还需要选择正确的操作。当较小的数据大小扩大为较大的数据大小时,大多数无符号整数将为零扩展。例如:

变量‘a’只有8位,但是它会加宽到32位。我们可以通过两种方式将较小的数据量扩大为较大的数据量。

..