2021-07-01

   

第1章 C语言的语言元素 - 5 -

1.1 程序结构 - 5 -

1.1.1 循序结构 - 5 -

1.1.2 分支 - 5 -

1.1.3 循环 - 6 -

1.1.3.1 for语句 - 6 -

1.1.3.2 while语句 - 6 -

1.1.3.3 do-while语句 - 6 -

1.1.3.4 break语句和continue语句 - 7 -

1.2类型 - 7 -

1.2.1整数类型 - 7 -

1.2.2浮点类型 - 8 -

1.2.3数组类型 - 8 -

1.2.4结构体类型 - 8 -

1.3量 - 8 -

1.3.1常量 - 8 -

1.3.2变量 - 9 -

1.3.2.1 全局变量 - 10 -

1.3.2.2 局部变量 - 10 -

1.4 指针与引用 - 10 -

1.5 函数 - 11 -

第2章 汇编语言的语言元素 - 12 -

2.1 内存映像与ELF文件 - 12 -

2.2 程序结构 - 13 -

2.2.1 整体结构 - 13 -

2.2.2 分支 - 14 -

2.2.3 循环 - 17 -

2.3类型 - 18 -

2.3.1整数类型 - 18 -

2.3.2浮点类型 - 18 -

2.4栈和寄存器 - 19 -

2.5函数 - 19 -

2.5.1调用约定 - 19 -

2.5.1.1 cdecl约定 - 19 -

2.5.1.2 fastcall约定 - 20 -

2.5.1.3 thiscall约定 - 20 -

2.5.1.4 stdcall约定 - 20 -

2.5.2示例 - 20 -

第3章 C语言的汇编实现 - 22 -

3.1 基本数据类型的操作 - 22 -

3.1.1 整数类型的计算 - 22 -

3.1.1.1 加法,减法 - 22 -

3.1.1.2 乘法,除法,求模 - 24 -

3.1.1.3 左移,右移 - 28 -

3.1.1.4 与,或,非 - 29 -

3.1.1.5 比较 - 31 -

3.1.1.6 求补(取反),异或 - 33 -

3.1.2 浮点类型的加载与运算 - 34 -

3.1.2.1 浮点数传送 - 35 -

3.1.2.2 浮点数四则运算 - 35 -

3.1.2.3 浮点数比较 - 37 -

3.1.3 基本类型的转换 - 38 -

3.1.3.1 整数与整数 - 38 -

3.1.3.2 整数与浮点数 - 41 -

3.1.3.3 浮点数与浮点数 - 43 -

3.2 寻址问题 - 43 -

3.3 函数 - 44 -

3.4 C语言程序结构的实现 - 44 -

3.4.1 分支 - 44 -

3.4.2 循环 - 44 -

3.4.3 编译器优化循环 - 45 -

3.5 指针 - 46 -

3.6 C++语言 - 46 -

第4章 C与汇编的优缺点分析 - 48 -

参考文献 - 49 -

第1章 C语言的语言元素

满分15分

C语言是一种面向过程的编译型语言。下面,我们来说一说C语言的各种语言元素。

任何程序,本质上是数据和操作步骤(即计算)的结合体。要解读一中编程语言,可以从其对数据的描述和对计算方法的描述两个角度考察。

1.1 程序结构

1.1.1 循序结构

循序结构是最基本的程序结构。在C语言中,一系列语句按顺序罗列即为循序结构。使用{}将语句包围,形成语句块(body),同时成为变量的作用域。关于作用域,在后文还会提及。

1.1.2 分支

if语句,或者说条件判断,可以说是所有程序的灵魂,镶嵌于循序语句们中。没有条件判断实际上是写不成真正意义上的程序的。C语言中if语句结构如下:

if (/* 条件1 */) {

/* 满足条件1执行 */

}

else if (/* 条件2 */) {

/* 满足条件2执行 */

}

else {

/* 不满足上述条件执行 */

}

在if和else的组合中,无论条件如何,两个语句块(body,一条语句或由{}包围的多条语句)只会执行一个而另一个不执行,然后就离开if-else语句。if-else将两个语句块连接成了一个语句块,可见else if其实是else与if的组合。上述结构等价于:

if (/* 条件1 */) {

/* 满足条件1执行 */

}

else {

if (/* 条件2 */) {

/* 满足条件2执行 */

}

else {

/* 不满足上述条件执行 */

}

}

1.1.3 循环

1.1.3.1 for语句

在C语言中,有三种形式的循环语句:for,while,do-while。

for语句最为常用。

for (/* 初始化 */; /* 循环条件 */; /* 步长操作 */) {

/* 循环体 */

}

在初始化语句中一般对要用于循环遍历和条件判断的变量进行初始化。在C99标准中,允许在初始化语句中声明变量。初始化语句只执行一次。每次执行循环体前,先判断循环条件是否为真(非零),只有为真在执行,否则程序离开循环语句。执行完循环体后,执行步长操作,通常操作用于循环遍历和条件判断的变量。然后,流程回到执行循环条件判断之前,开始下一次循环。

1.1.3.2 while语句

其次while语句也比较常用。

while (/* 循环条件 */) {

/* 循环体 */

}

每次执行循环体之前,先判断是否满足循环条件。循环条件为真就执行循环体。否则不执行,程序离开while语句。执行完循环体后程序流程回到判断循环条件前,准备进入下一次循环。

while相当于for的简化版,因此也有人喜欢这样写:

for ( ;/* 循环条件 */; ) {

/* 循环体 */

}

1.1.3.3 do-while语句

do-while 语句使用得比较少。它与while语句类似。

do {

/* 循环体 */

} while (/* 循环条件 */);

不同的是,先执行循环体、后判断循环条件。

1.1.3.4 break语句和continue语句

提到循环就必须提到break和continue,它们使流程控制在没有goto的情况下也足够灵活。在循环体中使用break语句,实现不执行完循环体直接离开循环语句。显然,对于for语句,也不会再执行循环条件判断或步长操作。在循环体中使用continue语句,实现将流程直接跳到本次循环体执行结束。可知,对于for语句,continue之后还会执行步长操作,然后进入下一次循环。

加上break和continue,三种循环语句一定程度上可以互相转化(但还是有一些细节差别)。

1.2类型

在C语言中,类型的概念非常重要,任何数据的表示都离不开类型。类型的本质,就是对数据的存储方式的抽象而又具体的描述。从实现上看,类型最终就是数据总大小以及数据成员的偏移量。同面向对象的编程语言不同,在C语言中,操作不是严格绑定在类型中的,而属于外置的。因为对于存储器上的一段数据,C语言允许将它描述成任意类型,并强制实施操作,而其正确性由程序员保证。

话虽如此,类型之间有区别,操作的不同非常关键。我们说到类型的实现,最终还是要说操作的实现。对于典型的数据类型,C语言确保通过编译器给出良好的操作实现。以下是几个典型的类型。

1.2.1整数类型

C语言提供了非常多的整数类型,虽然到具体平台,这些类型的实现只有几种。在C语言中,整数类型首先分为:

signed:有符号类型,不明确标注默认为有符号类型

unsigned:无符号类型

有无符号在存储上没有本质区别,仅仅在于是否将最高有效位解释为符号位,主要区别在涉及大小判断时发生。一些常用类型如下,都可以标记为有符号或无符号:

char:又称字符型,1字节

short:短整型,2字节

int:整型,4字节

long long:长长整型,8字节

然而,由于历史原因,C语言标准不严格保证上述常用类型的大小严格等于上述经典大小。因此,当程序中需要特别严格指定整型数据大小时,出于移植性考虑,需要使用标准intN_t和uintN_t类型:

int8_t与uint8_t:1字节的有符号和无符号整数

int16_t与uint16_t:2字节的有符号和无符号整数

int32_t与uint32_t:4字节的有符号和无符号整数

int64_t与uint64_t:8字节的有符号和无符号整数

等等。

1.2.2浮点类型

C语言中通常使用两种浮点类型:

float:在遵循IEEE754的机器上对应规定中的中32位单精度浮点的规定。

double:在遵循IEEE754的机器上对应规定中的中64位双精度浮点的规定。

现在的机器大都遵循IEEE754标准。在许多情况下,我们使用浮点数做数学计算,只需要符合一些浮点数的基本要求就可以,并不特别关心细节。

1.2.3数组类型

C语言中的数组类型,本质上是将相应的类型的数据在存储器上地址连续地存储数组长度那么多个。因此,在实质上与多个在存储器上地址连续的相应类型的数据没有区别,只是可以使用一个数组名加索引号的方式访问。具体而言,即是通过数组名得到基址、索引号计算得到偏移地址,最终计算出相应数组元素的地址并访问。

1.2.4结构体类型

结构体对于描述复杂数据非常实用。本质上是将相应类型的数据依结构体描述的次存放在该结构体类型数据的存储空间上。因此,在实质上与多个在存储器上对应位置的相应类型的数据没有区别,只是可以使用结构体名加成员名的方式访问。具体而言,即是通过结构体名得到基址、成员名计算得到偏移地址,最终计算出相应结构体成员的地址并访问。可以说数组就是一种结构体,而其成员的名称分别为0,1,2...这样的数字(索引号)。

1.3量

C语言中量可分为常量和变量。本质上都是某种类型的数据在程序中的具体安排。

1.3.1常量

常量即程序运行过程中不可改变的量,我们编写程序的过程中已经明确写定的各种数值、const结构体等都是常量。常量可以分为两种,一般来说,各种写定的数字是无名的(因而他们会在机器指令的立即数中记录,后文会提及),另一种是通过const关键字定义的,是有名的,本质上就是一种从未改变的变量,后文对变量的讨论对其也适用。

1.3.2变量

变量的本质就是与变量名关联的一段存储空间,通过使用变量名实现对该存储空间的直接引用。变量最基本的属性,除了类型,就是存储位置。当然,变量的其他属性还包括作用域,权限等。const关键字,可以将变量声明为前文所述的常量,其保证通过变量名直接引用该变量时,不能有修改操作,否则产生编译错误。

C语言中,变量存在作用域。只有在合适的作用域中才能访问相应的变量,其基本规则为,源文件作为顶级域,{}包围产生的语句块(body)嵌套组合形成一级一级的作用域。变量的作用域是自身所在的域加上这个域的所有下级域。但是。C语言中,变量是可以重名的,这就需要屏蔽规则确定当前在访问哪个变量。对于某个作用域,在一个级别中不能存在两个同名的变量。这就是说,与某个变量同名的变量或来自上级域,或来自下级域,或在另一个无关域。

int i = 0;  // 顶级域

int get() {

int i = 1;  // 函数域

Int j = -1;  

// 语句块1

{

int i = 2;  // 语句块1的域

{

int i = 3;  // 语句块1中的次级域

printf("i = %d\n", i);  // 打印 i = 3

printf("j = %d\n", j);  // 打印 j = -1

}

printf("i = %d\n", i);  // 打印 i = 2

}

// 语句块2

{

int i = 4; // 语句块2的域

printf("i = %d\n", i); // 打印 i = 4

}

// 定义重名变量导致编译错误

// int i = 5;

}  

printf都只能访问到本级定义的那个i,来自上级的i被屏蔽了,但来自上级的j没有被屏蔽,下级的i属于下级的语句块,即属于其他语句块,是不可见的。

1.3.2.1 全局变量

一般来说全局变量可以分为两种,它们都是直接声明在源文件里,没有放进任何{}包围的块。

  1. 普通全局变量

在源文件中简单地按照类型+名称的格式声明即可。其作用域是整个程序,在所有函数、语句块(即所有域)中都可以通过变量名直接引用这个变量。extern关键字非常有用,extern之后加上相应变量原来的声明构成一个所谓的外部引用声明。表示引用的变量可能不在本文件中,需要在链接阶段从其他可重定位文件夹中寻找,或在动态链接阶段在其他共享库中查找。可见,通过extern实现了整个程序的作用域。

  1. 静态全局变量

与普通全局变量类似。其作用域是本源文件。程序的其他部分不能通过变量名直接引用这个变量。

1.3.2.2 局部变量

局部变量分为两种。局部变量在函数或函数下的语句块声明,作用域是所在的域及其各个下级域(若没有被屏蔽)。

(1)普通局部变量

是我们通常意义上的局部变量,存储在栈空间。也就是说,每次调用相应的函数,分配的局部变量都是不同的,和上一次调用分配的局部变量无关。

(2)静态局部变量

声明局部变量时加上static关键字。其不同于普通局部变量,只会在程序初始化时分配一次空间,之后通过变量名引用的都是同一个变量。可以看成是语句块中的静态全局变量。

  1. 寄存器变量

使用register关键字,提示编译器将这个变量放在寄存器中,以优化程序。显然,由于种种原因,编译器不保证遵循这个建议。

1.4 指针与引用

在C语言中,指针的本质是一种整型变量,意义为指向存储空间(通常为虚拟内存)的某些单元(即内存单元的索引号)。其大小是平台相关的,一般而言,当编译到32位,其大小为4字节;编译到64位,其大小为8字节。通过指针实现对变量的间接引用,方便了数据的访问。在C语言中,传递结构体等复杂类型的数据通常传递指针。

前文提及,const确保通过变量名直接引用变量并做修改操作会导致编译错误。那么对于通过指针间接引用变量,const还能发挥作用吗?一般来说,函数中声明的const局部变量存储在栈上,通过指针间接引用仍旧可以修改。而const全局变量存储于只读的虚存空间,便、通过指针间接引用并修改虽然不会产生编译错误,运行时却产生段错误。可见const的对全局变量更为严格。

1.5 函数

在C语言中,函数的本质是一系列操作指令。通过函数名,可以访问这些操作指令,不仅可以调用函数,也可以读取具体的操作指令的机器码。函数本质上抽象为对指定数据集的一系列操作。在C语言中,函数是可以嵌套调用(在任何函数的语句都可以调用其他函数),递归调用(任何函数都可以递归地调用自己,实现递归算法)的。

函数指针是一种特殊的指针,将其赋值为不同的函数的地址,然后通过它间接引用函数,可以实现对一组规约相同的函数的动态调用。在面向对象的C++语言中,即是通过函数指针的数组vtable实现多态。

C语言中,函数的参数传递总是按值传递的,即参数列表中的参数变量是值和传递时所传递变量相同但存储在不同位置的另一个变量,即便对于参数是结构体类型也是如此。但是,数组除外,C语言实质上不支持将数组作为参数按值传递。当数组作为参数传递时,特殊的语法将它取了地址并传递了数组的指针,函数里访问传递的数组,也通过指针间接引用了原来的数组。

第2章 汇编语言的语言元素

满分30分

汇编语言属于低级语言,与机器指令一一对应,是机器码更便于人识记的形式。下面,我们以IA-32/Linux为例,简要介绍一段汇编程序的代码结构。汇编程序直接描述了要汇编生成的ELF目标文件的结构。因此,有必要了解Linux上程序的内存映像以及ELF的一些基本知识。

2.1 内存映像与ELF文件

ELF文件中有不同的节,存储不同的内容。.interp节记录动态链接器的路径,内核加载这个可执行文件时,会加载相应的动态链接器完成一些工作。.dynsym .symtab节记录ELF的符号信息,即这个ELF导出的自己的以及引用的外部的变量和函数等,在动态链接时有很大的作用。.dynstr .shstrtab .strtab等节记录一系列字符串,包括节的名称、符号的名称、程序字符串常量以及ELF中其他字符串信息。.rela.dyn .rela.plt等节记录重定位信息,帮助链接器完成各种重定位。.init .fini .init_array .fini_array等节记录了程序初始化、结束时需要执行的代码的地址,由动态链接器在相应的时候调用。 .data .rodata .bss是程序中使用的各种全局变量(只读、读写、有初始化、无初始化)所在的节。.text是程序机器指令主要存在的节,各种可执行的函数、代码都在这里。 .dynamic存放动态链接时需要的重要信息。 .plt .plt.got .got .got.plt与内部外部的全局变量的访问有关系,是所谓的跳板。

内核或者外部的ELF装载器将ELF文件装载到内存中时,是按段来装载的。段头(程序头)记录了应该怎样把文件中的内容映射到进程虚存空间。

代码段指虚存空间中可读可执行的页面区域,装载了ELF文件中可以执行的机器码(如.text节)。数据段指虚存空间中可读的页面区域,其中一部分可写,装载了ELF文件中的.bss .data等数据内容。堆是一段大小可以动态改变的区域(调用sbrk系统调用移动堆的界限,向上延伸),一般由动态内存分配器管理,存储程序运行过程中动态分配空间的变量。栈用于维护函数调用、参数传递、局部变量,实现函数的抽象。

2.2 程序结构

2.2.1 整体结构

在汇编程序(.S文件)中,“.”开头的行代表汇编器相关指令,不以“.”开头的行则为程序机器指令和程序标签。汇编器指令主要是向汇编器提供机器指令之外的信息,如ELF节,ELF符号等。下面是一些汇编器指令的例子和说明:

.data # 告诉汇编器,接下来的内容是ELF的.data节

.text # 告诉汇编器,接下来的内容是ELF的.text节,汇编指令就写在后面

.align 4 # 接下来的数据按照4字节对齐

.globl mInt # 定义一个全局ELF符号mInt

.type mInt, @object # ELF符号mInt类型为object类型

.size mInt, 4 # ELF符号mInt大小为4

.long -1 # 放置4字节有符号整数-1

.value -1 # 放置2字节有符号整数-1

.byte -1 # 放置1字节有符号整数-1

简单按顺序罗列机器指令,即构成相应的循序程序。使用jmp,call等指令控制程序跳转到其他位置而不是严格按照地址递增的顺序执行,是程序调用和循环结构的基础。

2.2.2 分支

前文提到,条件判断在程序中非常重要。与ARM不同,IA-32并不各种指令的条件执行,分支结构主要靠条件跳转指令jcc实现,jcc指令描述如下:

 

 

 

可以看到条件码还是很多的,可以满足各种场景。这些条件码也可用于cmovcc,fcmovcc,loopcc,setcc等条件执行的指令。

在汇编程序中使用jcc指令,前面一般结合cmp或test指令,计算要比较的内容并影响EFLAGS中的条件码相关标志位,然后jcc根据当前条件码标志位判断,满足条件码要求则跳转到指定标签处。

2.2.3 循环

循环同样使用jcc来实现,不同于简单分支结构,循环结构中一定会有一条往回跳的指令(这里是jcc往回跳,但其实结合jmp也可以)。

2.3类型

在汇编语言中,实际上已经不强调类型的概念了。在这里,对各种数据类型,其操作都分解为基本的计算操作和访存操作。正如前文所述,讲类型就是讲操作。单条指令级别的操作是针对基本数据类型设计的。

2.3.1整数类型

IA-32是一种32位ISA,有针对1字节、2字节和4字节整数的单条指令级别的操作。但是没有针对8字节64位整数的单条指令级别的操作,要对64位整数做计算,需要多条指令配合(adc,sbb等指令在这里就非常重要)。机器做整数加减计算(使用add,sub指令)时,无所谓是有符号数还是无符号数,两种结果都会给出(结果的二进制位模式一样,状态标志位条件码不同),编写程序时选取一种结果进一步解释即可。对于乘法和除法运算,需要明确指定使用有符号数操作(mul,div指令)还是无符号数操作(imul,idiv指令)。

关于放置整数的汇编器指令,参考2.2.1。

2.3.2浮点类型

IA-32遵循IEEE754标准,有支持32位单精度浮点和64位双精度浮点运算的浮点协处理器及相应的指令。此外,还支持80位扩展精度浮点。在Linux GCC中,为了严格和i386保持兼容,编译32位程序不会使用mmx,sse等功能更为强大的指令,只使用x87 FPU的浮点指令。

2.4栈和寄存器

IA-32是非常典型的CISC架构的ISA,寄存器很少,而且在不少指令中有特殊语义。下面是8个通用寄存器:

其中,16位寄存器与32位寄存器低16位重合,而8位寄存器相应地与16位寄存器的高8位和低8位重合。eax,ecx,edx,ebx在指令中的特殊语义可以简短指令长度,实现优化,不使用特殊语义也是可以的,因此这4个寄存器通用性比较好。

esp固定作为栈指针其实不太具有通用意义。栈用于保存函数调用上下文,保存局部变量,非常重要。在IA-32中,栈是严格4字节对齐的。push指令向栈中压入元素,使esp向下移动,pop则将元素弹出,使esp向上移动。call指令将当前eip压入栈顶,保存调用返回地址,而ret指令将栈顶元素弹入eip,实现返回。

2.5函数

汇编语言本身是没有函数这样的概念的,可以说机器也不需要函数这样抽象的概念。但是,模块化的要求促使我们逐渐在汇编语言中引入子过程、函数这样的概念。总的来说,函数就是一段汇编代码加上一定的调用约定,函数对传递进来的参数做一系列操作。

2.5.1调用约定

理论上,在汇编语言中关于函数的调用、参数传递、返回值传递等可以做任意的规定。实际运用中,为了将汇编程序与高级语言程序如C、Pascal程序结合在一起使用,通常遵循高级语言汇编实现的调用约定。最常用的是cdecl,IA-32上的C语言标准调用约定。此外还有fastcall,thiscall,stdcall等。

2.5.1.1 cdecl约定

cdecl约定是IA-32上C语言实现的默认约定,最为常用。其完全使用栈传递参数,参数从右到左依次压入栈中,call指令最后把函数返回地址压入栈中,然后跳转到函数第一条指令处。例如,函数有两个参数:(char c, int i),call跳转后,参数列表中左起第一个参数c位于[esp+4]处,第二个参数i位于[esp+8]处,尽可能保持4字节对齐。函数返回时,需要清理自己使用的栈,确保此时栈顶是原来的返回地址(注意,传递的参数不属于被调用函数自己使用的栈,不用清理,调用者会清理这部分),然后使用ret指令返回原来call指令的下一条指令。使用eax寄存器传递4字节以内的返回值,使用eax寄存器及edx寄存器共同传递8字节以内的返回值。一般来说这些已经足够应付绝大部分情况了。

2.5.1.2 fastcall约定

前两个4字节以内的参数分别使用ecx和edx寄存器传递,剩余参数从右到左依次压入栈中,call指令最后把函数返回地址压入栈中。函数返回时,需要先清理自己使用的栈,确保此时栈顶是原来的返回地址,然后使用ret指令返回的同时清理参数传递使用的栈(称为自动清理)。使用寄存器传递两个参数并使用自动清理,速度更快,代码量更小。对于可变参数的函数,不使用寄存器传参。

2.5.1.3 thiscall约定

第一个4字节以内的参数分别使用ecx寄存器传递,剩余参数从右到左依次压入栈中,call指令最后把函数返回地址压入栈中。函数返回时,需要先清理自己使用的栈,确保此时栈顶是原来的返回地址,然后使用ret指令返回的同时清理参数传递使用的栈(称为自动清理)。使用寄存器传递一个参数并使用自动清理,速度更快,代码量更小。对于可变参数的函数,不使用寄存器传参。这个调用约定主要用于C++的非静态成员函数,ecx就用于传递隐藏的第一个参数this指针。

2.5.1.4 stdcall约定

参数从右到左依次压入栈中,call指令最后把函数返回地址压入栈中。函数返回时,需要先清理自己使用的栈,确保此时栈顶是原来的返回地址,然后使用ret指令返回的同时清理参数传递使用的栈(称为自动清理)。使用自动清理,代码量更小。一般不用于可变参数函数。

2.5.2示例

 

实现同样操作计算a-b-c的四种不同调用约定的函数。函数前使用汇编器指令.global .type声明ELF符号信息,标签之后是函数的汇编指令,最后还使用.size汇编器指令声明函数大小。

第3章 C语言的汇编实现

满分40分

C语言是一种高级语言,有一些高级、抽象的概念,有些内容层次较高,本质上可由C语言中层次较低的元素去实现,例如前文提到了数组和结构体实际上不过提供了一种基于指针和偏移量的更便捷的变量组织、寻址方式。下面我们介绍C那些特别基本而重要的操作在汇编语言中是如何实现的。

3.1 基本数据类型的操作

复杂的数据类型是基本数据类型的逻辑组合。类型最重要的特性是其操作。因此,必须清楚对基本数据类型的操作是如何实现的。

3.1.1 整数类型的计算

C语言中支持很多整数类型的计算。

3.1.1.1 加法,减法

在IA-32上,加法指令(add)和减法指令(sub)支持对8位整数,16位整数以及32位整数做计算。对于加法和减法,有符号数与无符号数没有区别,两种情况的结果标志位都会反映在eflags中。

但是,IA-32没有64位整数的加法和减法运算指令,需要编译器利用adc指令和sbb指令实现相应的内部例程。adc指令和sbb指令利用eflags中的进位借位信息,可以很方便地实现长地整数的加减法。

如图,对64位整数的加减法,先用add、sub指令计算低32位并保存进位借位信息到eflags。再使用adc、sbb指令,结合进借位信息计算高32位。

3.1.1.2 乘法,除法,求模

和加法减法不同,乘法除法对于有符号数和无符号数,结果的二进制位模式不同。在IA-32上,同时提供适用于有符号数和无符号数的乘法与除法。mul与div指令分别是无符号乘法与除法,imul与idiv指令分别是有符号乘法与除法。同样地,有8位、16位、32位整数的指令但没有64位整数的指令。由于乘法与除法比较复杂,为了简化指令编码,使用了寄存器的特殊语义。

上面截图自intel的手册,其中的64位指令属于x86_64,在IA-32上是没有的。

然而,在C语言中,整数乘法的结果总是截断到参与运算的类型的大小。对于整数乘法,无符号数乘法与有符号数乘法的结果截断后位模式相同,实际上编译器不会在意mul与imul指令的选择,几乎总是使用imul。

对有符号数和无符号数都使用了imul指令实现乘法计算。

与乘法不同,对于除法,有符号数除法使用idiv指令实现,无符号数的除法使用了div指令实现。

 

求模运算就是除法取余运算,因此同样使用除法的指令实现,不过还要从特殊语义的寄存器dx,edx取出余数结果。

3.1.1.3 左移,右移

C语言中支持左移和右移操作。其中右移分为逻辑右移与算术右移,分别可以看成无符号数除2的幂与有符号数除2的幂。

 

可见左移只有一种,右移有两种。其实有一个sal指令,是shl指令的别名。C语言中一般对有符号数做算术右移,无符号数做逻辑右移,但是这个不是C语言标准中明确要求的,只是绝大部分编译器都这样实现。

3.1.1.4 与,或,非

C语言中没有专门的布尔类型,普通的整数可以做布尔类型参与逻辑与、或、非运算。同时整数也可做位与、或、非运算。在IA-32中,有专门的指令做位级的与、或、非运算,分别是and指令,or指令和not指令。逻辑与、或、非运算需要结合计算结果的标志位信息做条件判断。

 

 

3.1.1.5 比较

C语言中,整数比较运算根据两个数的大小关系给出逻辑结果。在IA-32中,有一个专门用于整数比较的指令cmp,其本质与sub相同,都是将两数相减。不同之处在于,cmp只影响体现大小关系的结果标志位,不会把最终计算结果送到目标处。cmp指令通常结合条件执行的指令jcc,setcc,cmovcc等。在第二章我们提到,IA-32条件执行码很多,可以应对各种情况,也可用于优化。这里仅列出一部分:

 

 

 

3.1.1.6 求补(取反),异或

C语言中,求有符号数的相反数,也就是求补运算也很常用。此外还有一个很少用但有趣的运算:位异或运算。在IA-32中,有专门的指令实现这两个运算。

3.1.2 浮点类型的加载与运算

一般来说,C语言中的float类型对应IEEE754标准中的32位单精度浮点,double类型对应IEEE754标准中的64位双精度浮点。IA-32是小端序的,根据标准,float和double类型的存储方式如下:

在IA-32上,有专门的x87浮点协处理器做浮点运算。后来还有更现代的mmx,sse等指令用于处理浮点数。在Linux上,GCC为了保持与i386的严格兼容,不会主动使用现代的扩展指令集实现浮点运算,只使用x87浮点指令。x87 FPU的指令很有特点,所有计算、转换操作都是围绕一个特殊的浮点工作栈进行的。因此,进行浮点计算不像整数计算那么简单,首先要使用专门的装载指令将浮点数压入浮点栈。

3.1.2.1 浮点数传送

fld、fst系列的指令专门负责将浮点数从通用的内存引用移送到浮点栈、将浮点数移送到通用的内存引用。通用寄存器不能用于保存浮点数。

 

3.1.2.2 浮点数四则运算

x87 FPU的浮点计算指令能支持32位单精度浮点数、64位双精度浮点数以及80位扩展精度浮点数的运算。

可以看到每一个函数的实现中都有fld指令负责将浮点数从通用栈传送到浮点栈。和整数计算指令一样,浮点数计算指令也可以将通用内存引用作为参数。

3.1.2.3 浮点数比较

一般来说,C语言中浮点数比较运算主要用“>”和“<”,通常来说计算结果是有误差的,比较是否严格相等没有意义。在x87中,使用fcomi,fcomip,fucomi,fucomip比较浮点数并在eflags里设置结果标志位。

 

3.1.3 基本类型的转换

3.1.3.1 整数与整数

当两个类型大小相等时,汇编代码层面不做任何转换。这是因为有符号数与无符号数存储方式完全一样,C语言转换后,后续进行操作,使用相应的(无符号数或有符号数的)指令即可。

由图可见没有任何转换指令。

当目标类型大小比原类型小时,直接截取低位做截断,对有符号数也是如此。在IA-32中,利用寄存器之间的重合关系可以很方便地做整数截断。

当目标类型大小比原类型大时,根据是有符号数还是无符号数,选用相应的扩展操作。IA-32中,movzx传送数据同时进行零扩展,适用于无符号数;movsx传送数据同时进行符号扩展,适用于有符号数。

 

3.1.3.2 整数与浮点数

整数与浮点数的转换不能直接进行,必须做一些运算。在x87中,fist、fild指令传送数据的同时可以进行浮点数与整数之间的转换,fiadd、fisub、fimul、fidiv指令在计算前将整数转换为浮点数参与计算(编译器可能不会使用这些指令进行优化)。但是,这些转换都是将整数看成有符号整数的。要对无符号整数进行转换,需要将它用更大的有符号数表示,可能还要其他算法。

3.1.3.3 浮点数与浮点数

这是x87 FPU的浮点工作栈一个很有意思的地方。当把数据压进浮点栈时,将它看成一种抽象的浮点数,之后进行计算时我们不关心浮点数的类型;当把数据从浮点栈弹出传送到通用内存引用时,自动转换为相应的浮点类型。

3.2 寻址问题

在C语言中,除了寄存器类型的变量,其他变量都对应虚拟内存上一个存储空间。IA-32采用平坦模型,程序中使用统一的虚拟地址即可,不需要考虑分段寻址的问题。基本类型的变量编译器都分配了绝对或相对的地址,使用这个地址即可。在C语言中对变量使用“&”运算符,可明确得到其地址。对于浮复杂类型如数组和结构体,在第一章讲述了成员寻址的方式,这里不再重复。

3.3 函数

汇编程序中的函数就是C语言程序中的函数的实现。某种意义上说,我们在汇编里写函数就是模仿编译器生成函数。使用栈保存局部变量与调用上下文,实现了函数的封装,使函数内可以调用任何函数。

3.4 C语言程序结构的实现

3.4.1 分支

编译器分别为分支的两个情况(if和else)生成相应的代码片段,其中包括离开分支结构的代码。然后在前面生成进入分支结构的代码,即判断和条件跳转。

3.4.2 循环

这里我们以典型的for循环为例。首先是循环体的代码片段,然后在合适的位置插入判断以及条件跳转(往后跳离开循环还是往前跳继续循环),最后补上初始化语句与步长增加语句。

3.4.3 编译器优化循环

我们继续看3.4.3中的例子,使用-O3选项进行优化后,明显可以看到编译器更多使用寄存器,并且调整了程序结构,比如,首次判断与后面的判断分开了,步长增加操作混在循环体中。但是,编译器保证这样的结构仍旧与原循环效果一致。

3.5 指针

在第一章我们提到过,C语言中指针的本质其实是整数类型。指针的运算(加法减法)也是用整数的运算实现。但是,涉及到指针的运算有一些普通整数运算不具有的特点,因此,在IA-32上,常常使用lea指令优化指针运算。

指针有别于普通整数类型的最关键点是,“*”算符的解引用运算。通过这个运算,可以间接引用C语言中的其他变量。在IA-32中,因为指令支持内存引用作为操作数,指针解引用运算可以由各种指令实现(与IA-32这种CISC架构不同,在典型RISC架构如ARM中,只有专门的ld、st类的指令才能一般性地引用内存)。常用mov指令实现指针解引用,实现对指定虚拟地址空间的访问。

3.6 C++语言

C++语言是在C语言的基础上增加面向对象、模版编程等高级内容设计而成的,和C语言一样可以直接编译成机器可执行文件。而C语言既有足够的抽象能力,又接近底层。因此,C++语言的各种特性,一部分是语言高级特性,由编译器在较高层次支持;另一部分要使用汇编语言实现的内容,其实完全都可以使用C语言实现。这就是说,我们只需要考虑如何将C++语言的特性在C语言中妥善地表示出来就可以了。

例如,C++中非静态成员函数可以访问this指针,指向调用该函数的对象。用C语言表示,可以看成是成员函数有第0个隐藏的参数this。编译器只需要将成员函数改写成适当的C函数,再生成调用代码就可以了。这部分内容在第二章thiscall也有涉及。

又例如,C++中传递参数可以传递对象的引用。而普通的引用,究其本质是C语言中的指针,只不过传参的时候编译器自动帮我们做了取地址运算而已。

类是C++语言中非常重要的特性。实际上,类与C语言中的结构体是完全一样的实现方式,即对于成员变量,提供一个从成员名到偏移量的映射关系,便于进行访问。而成员函数本质上是外置的函数,和C语言中的函数没有本质区别。对于具有virtual函数的类,在类的成员变量开头有一个隐藏的成员变量,是一个指针,指向一个记录本类虚函数函数指针的数组,称为vtable。

第4章 C与汇编的优缺点分析

满分15分

关于C语言:有良好的抽象同时能满足底层的开发要求。因此使用C语言开发程序,开发速度快。相比于其他高级语言,更接近底层,因而软件性能损失很小。遵循保持程序可移植性的各项原则,使得移植一般通用的C语言程序心智负担很小。但是,由于编译器出于通用性、兼容性、实现复杂性等方面考虑,不会激进地使用CPU的新特性,使用C语言编程难以充分利用CPU的新特性进行优化。尽管编译器会不断支持新硬件并放弃旧硬件,这个过程相比于CPU的迭代总是落后的。

关于汇编语言:一般来说是最为接近底层的,可以更好地掌控程序的运行,控制CPU的状态。不仅能做C语言能做的,还能做其不能做的。一般来说,编译器套装中汇编器更新是比较快的,因而使用汇编语言可以很快就用上CPU的新特性。针对CPU的特点,并运用新特性,可以有效提高程序的运行速度。但是由于汇编比较难懂,能熟练使用汇编语言的程序员相对较少。汇编很繁琐,导致开发速度不可能很快。汇编与具体的CPU相关,抽象不足,可移植性很差,每移植到一个新指令集的CPU都需要重写。

根据C语言和汇编语言的优缺点可知,为了降低开发成本、减少不必要的工作,应该尽可能使用C语言。事实上,现在即使在像linux内核这样非常底层的软件的开发中,也是严格限制汇编的使用的。但是,有些情况下必须使用汇编。有可能是相关的操作在C语言能力范围之外:例如execve()系统调用结束时更改用户进程的栈寄存器,C语言不提供这样的能力,只能使用汇编完成。有可能是使用汇编带来的收益很大,可以接受其带来的麻烦:例如在使用CPU渲染的OpenGL驱动程序中,使用汇编语言编写关键操作,可以充分利用SSE,AVX,arm neon等高性能指令集,带来显著的性能提升。可见,现如今虽然汇编已经写得很少了,但不能一味排斥,必须具体问题具体分析,在合适的情况使用它。

参考文献

  1.  Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2 (2A, 2B, 2C & 2D):Instruction Set Reference, A-Z
  2. Android社区 源代码在线阅读https://www.androidos.net.cn/
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值