小明学C++第四篇:数的表示和运算、函数调用、流水线

来源:互联网 发布:ubuntu 16.04 安装ie 编辑:程序博客网 时间:2024/06/07 06:17

在小明学C++第二篇,我们看到,小明用了整型、浮点型等数据类型,那么这些数据类型在计算机里面是怎么表示的?它们的运算操作又是怎么实现的?此外,主函数调用面积计算函数calculate的时候到底发生了什么?经过翻译后,一条高级语言编写的语句变成了很多不可再分割的独立的机器语言,也就是指令。那么多的指令是如何在计算机上面运行的呢?下面我们就逐一来探讨一下以上的问题。

数的表示和运算

(1)为什么是二进制?
我们知道,计算机使用的是二进制,也就是只有0和1。为什么要这样子呢?为什么不使用人们常用的十进制呢?在我看来原因只有一个,那就是使用二进制简单。0和1是根据电压值来确定的。如果用十进制,就要使用复杂的硬件装备去准确测定到底是多少伏特。但是如果用二进制,“1”用有电压表示,“0”用无电压表示,这样测量到底有没有电压就简单多了,而且不容易出错。
(2)数的表示
数可以分为三种:无符号数、有符号数、浮点数。无符号数只能表示0和正整数,有符号数可以表示负整数、0以及正整数。而浮点数是用来表示可正可负的小数。

二进制和十进制其实是类似的,不同在于二进制是逢二进位,十进制是逢十进位。十进制使用的是位值表示法:

123=1*10^2+2*10^1+3*10^0

位值表示法中,每一个位的权重不同,高位权重大,低位权重小,因此虽然1比3小,但是百位大于个位。同理,二进制1010:

1010=1*2^3+0*2^2+1*2^1+0*2^0

那么二进制与十进制怎么转换?

二进制转换成十进制直接使用上述公式就可以得到十进制表示,比如1010=8+2=10;
十进制转二进制就麻烦一点。我们可以列出一个方程:

10=a3*2^3+a2*2^2+a1*2^1+a0*2^1

于是要想把10转化成二进制,关键在于确定a3-a0的值。
首先因为10是偶数,因此a0值为1;
然后等式两边同时除以2,得到:

5=a3*2^2+a2*2^1+a1*2^0

因为5是奇数,因此a1值为1;再对等式同时除以2:

2 = a3*2^1+a2*2^0

因为2是偶数,因此a2值为0;
同理a3值为1;
故十的二进制表示为1010。
可以将十进制转二进制的方法归纳如下:

a.先构造一个N的二进制展开式;
b.根据N的奇偶值确定最低位的取值,然后等式两边除以2;
c.根据N的符号确定符号位的值。

在计算机里面,称经过这种方法转换得到的叫做原码
还有一种叫做补码的表示:
如果一个数是正整数,它的补码就是原码。如果一个数是负数,它的补码是原码取反加一。
比如10的补码是01010,而-10的补码是10110(10101+1);
至于为什么要有补码,是为了运算方便,后面讲数的运算你就知道了。

我们现在来讲讲小数的表示吧。
下面讲到浮点数表示都是按照IEEE 754标准来的。
浮点数的表示跟十进制的科学计数法类似。科学技术法规定小数点前面只有一位有效数字,然后小数点后面都是尾数,还要乘以10的n次方。比如6.023*10^23,其中023是尾数,23是阶数。同理,浮点数也采用这种思想:
这里写图片描述
其中float是单精度,double是双精度,因此double能够表示的数值比float多,因为double的位数更多。从图中可见,float占4字节,而double占八个字节。
S是表示符号位的,E表示阶数,M表示尾数。
比如-6.625的表示:
首先是负数,因此符号位s的值为1;
其次,6.625的二进制数为110.101,这不是规格化的数,因为小数点左边有三个有效数字。
故需要规格化成1.10101,指数为2,所以阶数为2+127=129(加上127是规定);
因此-6.625的IEEE浮点数表示为:

1 10000001 10101000000000000000000

(3)数的运算
之前说用补码来计算比较简便,是时候来揭晓答案了。
我们先来看看整数的运算吧。
整数的运算是用补码来进行的。
补码的加法也很简单,跟十进制加法一样,如果和大于2就进位:
这里写图片描述
补码的关键在于减法,补码的减法是用加法来实现的,如图所示:
这里写图片描述
是不是很神奇,这是因为补码的本质是模运算。比如时钟,我想从七点钟调到4点钟可以逆时针转三个字,也可以顺时针调到4点钟。补码也是这种思想。因为只用加法就能实现算术中的加减运算,所以计算机上只需要一个加法器ALU就可以实现数的加减运算了。
我们再来看看乘法运算:
乘法运算跟我们十进制也差不多:
这里写图片描述
也是一位一位地相乘,然后再加起来。
这里写图片描述

这里写图片描述

上图是32位乘法器的流程及构造;
开始进行乘法运算时,先将乘数和被乘数放置对应位置,然后控制器根据乘数的最低位判断是否将被乘数送至64位加法器,积是存放计算中间结果的寄存器;ALU计算完毕以后,就让乘数右移,被乘数左移,继续进行下去,知道乘数为0;从这个过程可以看出,乘法运算的时间远大于加法运算的时间。

除法运算:
这里写图片描述

这里写图片描述

过程:计算机并没有人类那么聪明,可以知道除数是否大于被除数。计算机的除法运算是用减法来实现的。每次用被除数减去除数,如果结果大于0,商就是1,如果小于0,商就是0。最后因为被除数可能小于0,还需要加上除数。

浮点数加法运算:
这里写图片描述
这里写图片描述

跟十进制科学计数法的加法运算类似,浮点数加法需要经过比较阶码,对齐,尾数运算,规格化,取符号等步骤,最终得到结果,可见,浮点运算还是比较复杂的过程。

函数调用

明白了数据的表示和运算操作,那么主函数调用其他函数的过程中发生了什么?计算机又是如何完成函数调用一系列的工作的?

为了方便查看,我又将第二篇的代码贴到这里:

#include <iostream>using namespace std;///顶点类 包含横坐标和纵坐标两个变量class point {public:    float x,y;};float calculate(point A,point B,point C){    float S;    S=( (A.x-B.x)*(A.y-C.y) - (A.x-C.x)*(A.y-B.y) )/2;    return S>0?S:-S;}int main(){    point *points;    float sum;    int n,i;    cout<<"请输入N(2<N)表示N边形:"<<endl;    cin>>n;    if(n<3){        cout<<"n不能小于3!"<<endl;        return 1;    }    points = new point[n];    cout<<"请顺时针输入凸多边形的N个坐标:"<<endl;    ///用户输入    for(i=0;i<n;i++){        cin>>points[i].x>>points[i].y;    }    ///计算面积    for(i=1,sum=0;i<n-1;++i){        ///这条语句把算法的三个步骤都实现了        sum += calculate(points[0],points[i],points[i+1]);    }    ///输出面积    cout<<"此多边形面积为"<<sum<<endl;    return 0;}

在讲函数调用的过程之前,我们先来讲讲C++的两种内存分配策略:
(1)栈:栈属于一种称为“先进后出”的数据结构。这有点类似于日常生活中的乒乓球桶,开始的时候可以一直向桶里面装入乒乓球,但是要从乒乓球桶中拿出乒乓球,只能从最上面开始拿,也就和开始放入乒乓球的顺序相反。栈就是这种数据结构。压入元素和输出元素的顺序是相反的。当你要使用栈空间时,只需要按顺序分配即可,不需要寻找合适的空间,因此分配速度较快。栈常用来存放局部变量以及函数参数等数据。在小明写的程序中,calculate函数的S、A、B、C变量,就是存放在栈区的。
(2)堆:堆是一种连续内存分配策略,跟操作系统的连续内存分配一样。堆分配类似于土地分配,开始时拥有一大块土地,然后有很多农民要求去种田,有的要三亩,有的要十亩。因此分配土地需要做的事情是根据农民要求使用的天地大小分配一个合适大小的给他,回收时又要将相邻的空闲土地合为一块土地。因此,堆分配需要查找合适的内存空间以及回收内存空间,所以分配速度较慢,常用来存放全局变量还有C++中new语句所产生的内存空间就是从堆区得到的。在小明的程序中,points数组就是存放在堆区的。

从程序设计的角度看函数调用
上述程序中,main函数调用了calculate函数,首先将多边形的三个点的坐标传递给calculate函数,然后执行流程就从main函数转到calculate函数,去执行计算面积的程序,面积计算完毕后,就把面积S返回给主函数,主函数将S加到sum上去,作为多边形面积的一部分。

从机器的角度看函数调用
为什么能够更好地理解函数调用过程,我将调用calculate函数的语句以及calculate函数对应的汇编代码贴出来:
main函数调用calculate函数的语句:
这里写图片描述
calculate函数:
这里写图片描述
上述汇编代码应该是属于IA32指令集体系结构,看不懂的可以先自行去学习一下。
不过也不必完全看懂,只知道函数调用的过程就可以了。
一些寄存器的说明:
如上面所言,我们是使用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复以及本地存储的。为了确定某次函数调用使用的栈空间,需要使用两个指针来说明:
%ebp:帧指针(靠近栈底)
%esp:栈指针(靠近栈顶)
%eax、%edx、%ecx:调用者main函数需要保存的寄存器
%ebx、%esi、%edi:被调用者需要保存的寄存器
函数调用过程
(1)main利用栈传递参数:mov指令所对应的数据搬移指令,它将三个顶点坐标放在了栈里面。

关于参数的传递,有三种方法,分别是按值传递,按指针传递,按引用传递。上述函数的参数传递方式是按值传递。

(2)main使用call语句调用calculate函数:如上面截图所示,call指令(第一张截图的0x40155d对应指令)将PC指针指向了calculate函数,执行流从main函数转向calculate,同时,它将当前PC的值压入栈中,作为返回地址。
(3)calculate保存当前寄存器值并获取参数:如果calculate在运行中有使用到%ebx、%esi、%edi等寄存器,则需要将它们的值保存到栈中。同时,这一步骤还包括从栈中获取main函数传递过来的参数。
(4)calculate指令的运行:获取了参数以后,就可以执行calculate的指令了。
(5)calculate恢复寄存器的值并将返回结果写入寄存器:如果使用了%ebx、%esi、%edi,这时就从栈中恢复它们的值,同时将返回结果S写入值返回寄存器。
(6)calculate调用ret指令返回到main:退栈并将当前PC值改为栈中保存的返回地址,返回到main函数中,main函数通过值返回寄存器%eax得到计算结果,继续运行下面的指令。

流水线技术

分析了函数调用过程之后,我们再来看看计算机是如何运行这些指令的。
计算机之所以这么快,其中一个重要的原因包括了流水线技术。计算机使用的流水线技术其实跟工厂或者企业里面使用的流水线技术其实有异曲同工之处。
在工厂里面,可以把一个产品的生产过程看出是一条流水线,这条流水线包括了以下流程:产品调研、产品设计、产品研发、产品测试、产品销售。每一个过程都需要一个部门来执行,因此一个公司需要设计部、研发部、质量部、销售部等部门。
流水线的意思主要体现在:这些部门都是同时在工作的,比如研发部完成一个产品后,就立即开始下一个产品的研发,而不是等待这个产品测试和销售。这样每一个部门都按部就班,不敢懈怠,最终公司得到了盈利。

同样,在计算机的CPU里面,也包括了类似的流水线。

处理一条指令所需要的步骤
以MIPS为例,我们来讲讲计算机的流水线是如何实现的?
(1)取值(IF):从指令寄存器读取指令;
(2)译码并读取寄存器的值(ID):分析这条指令需要执行的操作,同时还可以读取寄存器的值;
(3)执行操作或计算地址(EX):对于运算来说,在这个阶段就是执行加减等操作;对于跳转指令来讲,这个阶段将执行所要跳转的地址的计算。
(4)从存储器读操作数(MEM):指从内存中读取一个操作数,一般是数据搬移指令。
(5)将计算结果写回寄存器(WB)。

讲到这里,有些同学可能会产生疑问,有一些指令好像不需要经过这么多步骤啊。给所有的指令统一为这么多步骤的理由在于统一以后流程就简单了,简单源于统一,流水线的前提是每一个指令的执行过程都是统一的,因此实际上有些指令不需要某个周期,也会画上,表示这个阶段是空闲的,但又是必须的。

五个步骤对应的功能部件
(1)指令寄存器及其控制部件;
(2)译码器;
(3)ALU;
(4)访存系统;
(5)回写数据通路及其控制部件。

分析完这些以后,我们就可以模仿工厂的流水线,让所有的指令都并发地执行。
即在同一时刻,每一个功能部件都是忙的(一般情况下)。
在MIPS模拟器(winMIPS64)下,我们可以详细的分析流水线运行过程。以helloworld程序为例:
这里写图片描述
上图就是流水线所需要经过的五大流程;
这里写图片描述
上图对应的就是整个流水线的处理过程,左边是helloworld程序对应的汇编代码,右边是流水线的执行过程。可以看出,完成这个程序的运行一共需要11个时钟周期,而如果是非流水线,即一条指令一条指令地执行下去的话,则需要7*5=35个时钟周期,不难证明,在流水线机器上执行时间等于非流水线机器执行时间除以流水线级数(流水线的步骤个数)。

总结

在小明学C++第三篇,我们讲了如何从高级语言转化成机器语言;而在本篇博客,则注重于机器指令方面,从计算机系统的角度来分析程序;其中包括数在二进制中是如何表示的,函数调用时发生的一些事情,还讲了如何用流水线技术来加快程序的运行。希望你们在看完博客以后,对计算机的运行有初步的理解。如果有需要,请联系我,我的邮箱地址是:2317809590@qq.com。

原创粉丝点击