让我们还是从熟悉的加减乘除等算术运算入手。图1.23给出了一个简单的C程序,其中包含了常见的C语言算术运算,我们依旧采取对比C语言代码和UCC编译器生成的汇编代码的方法来讨论。
图1.23 arith.c
我们有意在图1.23的第1至4行定义了几个初始化或未初始化的变量。图1.24给出了这几个变量在汇编代码中的区别。在图1.24中,第7行至第9行对应的是初始化为10的全局变量a;而第11至13行对应的是初始化为20的全局变量b;第15行对应的是没有作初始化的全局变量c;而第16行对应的是没有作初始化的静态变量d,为避免重名,被UCC编译器改名为”d.0”;而第17行对应的是初始化为5的静态变量e,被UCC编译器重命名为”e.1”,而且我们注意到汇编代码中不存在”.global e.1”,但是可在第7和第11行看到对变量a和b的global声明,这代表变量名a,b是在全局可见的,但e.1是静态的,只在当前文件中可见。按照C语言的语义,全局或静态未初始化的变量其缺省值一般为0。在生成目标文件(后缀为.obj或.o的文件)时,我们只需要在目标模块中记录这块要被初始化为0的空间有多大就可以,没有必要把一堆的0存到目标文件中。确切地说,汇编代码中第15行的”.comm”用于声明未初始化的全局变量,comm是common之意,代表这是一块公共用地,即全局变量之意,而第16行的”.lcomm”则用于声明未初始化的静态变量,lcomm是local common之意,只在本目标模块中可见。
图1.24 初始化和未初始化
接下来,让我们看一下图1.23的第5至第17行各C语言算术运算所对应的汇编代码,如下图1.25所示。图1.25中的第32至33行对应“c = a | b;”, 第32行把a的值从内存加载到寄存器eax中,第33行把内存中的变量b和寄存器eax作按位或运算,结果仍存于eax中,第34行再把运算结果从寄存器eax写回到内存中的变量c中。图1.25第35至49行所做的运算依次为andl,shll,sarl, addl和 subl。其中的and,add和sub对应按位与、加和减运算。而shl是Shift Left的缩写,进行左移运算。右移运算比较特殊,有算术右移sar和逻辑右移shr之分,sar是Shift Arithmetic Right的缩写,而shr则是Shift Right的缩写,两者的区别是sar右移时最高位要用之前的符号位来填充,而shr右移时最高位补0。
图1.25 arith.s
例如,以下代码中函数f()会死循环,而函数g()则不会。原因是:a是有符号整数,C编译器会选用sar汇编指令来进行移位操作,而a的值为-1,在内存中以补码形式存放时即为0xFFFFFFFF,算术右移后仍然是-1。而b为无符号整数,C编译器会选择shr来进行逻辑右移,最高位补入的是0,经过32次的逻辑右移操作后,b的值就变为0了。
int a = -1;
unsigned int b = 0xFFFFFFFF;
void f(){
while(a>>= 1);
}
void g(){
while(b>>= 1);
}
图1.25中的第50至52对应的是”c = a * b;”,其中第51行的imul是有符号整数乘法signed multiple指令,而作无符号整数乘法的指令为mul。第53至第56行对应的是”c = a / b;”,第53行把被除数a存到寄存器eax中,第54行的指令cdq是Change Double word to Quadrateword的缩写,意思是把双字扩展为四字。在16位CPU时代,把一个word定义为2 字节,所以双字对应4字节,四字对应8字节,x86会把寄存器edx和eax共同构成的8个字节当成被除数,edx充当高32位,而eax充当低32位。则经cdq指令后,edx中32位的内容都是eax的最高位。如果寄存器eax最高位为1,edx各bit全为1;否则全为0。不难想象,如果要作无符号数的除法,因为eax的最高位不再被当作符号位,我们需要直接把edx寄存值赋值为0,相应的代码如x86Linux.tpl的第48行所示:
TEMPLATE(X86_DIVU4, "movl $0, %%edx;divl %2")
图1.25第55行的指令idiv是有符号整数除法signed divide指令,而div则是无符号整数除法指令unsigned divide。整数除法运算的结果有两部分,一部分是商,会被存放在寄存器eax中,如图1.25第56行所示;另一部分是余数,会被存放在寄存器edx中。当我们进行”c = a%b;”的运算时,就需要用到除法运算的余数,如图1.25第61至64行所示。第57行的inc指令是increment的缩写,用于对操作数进行加1操作。第65至67行对应的是”c = -a;”,其中第66行的指令neg是negative的缩写,数学上是取相反数的意思,如果有符号数a为-1,即0xFFFFFFFF,经过neg指令的处理后,就得到a的相反数1,即0x00000001。而第68至70行对应的是”c = ~a;”,第69行的not指令用于按位取位,如果a 为-1,则按位取反为得到的是0x00000000。
让我们再来看一下C语言中的以下运算符,如图1.26所示。此处,我们的目的是熟悉UCC中用到的有条件跳转指令。
图1.26 有条件跳转指令
图1.26中第21行先用cmp指令比较变量a是否等于0,若不相等,则跳转到基本块”.BB2”处,如果相等,则执行第24行的指令,而第23行在汇编中只是个标号。而第34行的指令jle是Jump if Less or Equal的缩写。第32至34行的功能是:如果a小于等于b,则跳转到基本块”.BB6”;否则(即a大于b),则执行第36行,作”c++;”的操作。常用的条件跳转指令如下所示:
JE
Jump if Equal
==
JNE
Jump if Not Equal
!=
JG
Jump if Greater
>
有符号数
JA
Jump if Above
>
无符号数
JL
Jump if Less
<
有符号数
JB
Jump if Below
<
无符号数
JGE
Jump if Greater or Equal
>=
有符号数
JAE
Jump if Above or Equal
>=
无符号数
JLE
Jump if Less or Equal
<=
有符号数
JBE
Jump if Below or Euqal
<=
无符号数
我们以0xFFFFFFFF和0x00000000为例,如果都视为有符号数,则补码0xFFFFFFFF是-1,而0x00000000为0,此时有-1要小于0;但如果视为无符号数,则0xFFFFFFFF要大于0。用于两个整数比较的指令一般都用cmp,指令cmp实际进行的运算是整数的减法,如果要把这两个数的比较当作有符号整数之间的比较,则C编译器在产生cmp指令后,需要选择有符号数的条件跳转指令,如JG, JL,JGE和JLE;而如果要当作无符号整数之间的比较,则C编译器在产生cmp指令后,需要选择JA,JB,JAE和JBE等指令。如图1.27所示,运行该程序,可以发现第9、12和第15行的printf语句会被执行,而第6行的printf语句没有被执行。图1.27第5行,我们进行的是有符号整数之间的比较,此时-1大于0不成立;而第8行进行的是无符号整数之间的比较,此时无符号数0xFFFFFFFF大于0是成立的;我们还注意到,第11行和第14行的条件是成立的,因为进行signed int和unsigned int之间二元运算时,signed int会被提升为unsigned int。
图1.27 整数比较
我们很少会写出类似图1.27第11行这样的代码,但是如果一不小心就可能写出下图1.28所示,运行结果竟然是” -1 > 0”。因此,有符号整数与无符号整数还是尽量不要混合在一起处理,其结果可能出乎意料。由图1.28第22行的jbe指令可以发现,此处是把a和b都当作无符号来比较了。需要注意的是,不论是把a当有符号数,还是无符号来比较,a中的值始终都是0xFFFFFFFF。
图1.28 有符号和无符号整数
在这一小节中,我们有意地忽略了float和double类型的浮点数。原理上,CPU只要实现了整数的加减乘除功能,就可以在整数运算的基础上,进行浮点数的加减乘除,这被称为浮点运算的软件实现。软件实现浮点运算的优点是可减少硬件成本,在单片机等对成本很敏感的场合,经常能看到由软件实现的浮点数加减乘除运算。而在稍高端的计算机系统中,浮点运算一般都会由专门的浮点运算芯片来实现,例如Intel的x87芯片,就是一个典型的Float Point Unit,缩写为FPU,浮点运算单元。在下一小节中,我们会讨论一下UCC编译器用到的x87浮点运算指令。