计算机系统大作业

Posted 匿名甩尸

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了计算机系统大作业相关的知识,希望对你有一定的参考价值。

1. C语言的语言元素

1.1 程序结构

对于任意一个程序来说,其都是从main函数开始执行。

1.1.1 循序结构

int sum(int a, int b)
{
	int c;
	c = a + b;
	return c;
}

如图中所示的循序结构函数的代码,循序结构又称顺序结构,其按照语句出现的先后顺序依次执行语句。对于循序结构来说,在忽略某个语句调用了其他函数并导致其他结构出现的情况下,其中的语句都会被执行,但每个语句只会执行一次。

1.1.2 分支

C语言中实现分支结构的语句有两种:if语句和switch语句。
分支结构先谷底循序结构来说,在忽略某个语句调用了其他函数并导致其他结构出现的情况下,其中的语句会被执行一次或不执行,并且总存在不执行的分支(if和else分支都存在的情况下)。

if语句

int function_if(int x)
{
	if(x < 0)
		return 0;
	else
		return x;
}

如图所示的ReLU函数为典型的if语句实现。if根据括号后表达式的真假决定是否执行该分支。对于有着多种选择的情况,可以有两种实现方法:
第一种:嵌套if语句,即在if语句的某个分支中再写入一个if语句。这种实现方法可以在多种分支存在共同的前置条件时使用。将前置条件作为外层if的判断,后置条件则放入内层if语句中
第二种:使用else if。以图中代码举例,若x需要选择大于0、小于0、等于0三个分支时,利用if,else if,else实现三个分支,执行时会依次测试其中的分支,选中时直接进入。
if语句在逻辑上有着较为清楚的实现,但当分支过多时,依次对比会导致执行速度下降。

switch语句

void function_switch(char c)
{
	switch(c)
	{
	case 'a':
		cout << 1 << endl;
		break;
	case 'b':
		cout << 2 << endl;
		break;
	default:
		cout << 3 << endl;
		break;
	}
}

如图所示为一个switch语句的实现。switch语句在逻辑上比较清晰,但不能像if语言一样处理复杂分支。switch语句主要面向一个条件有着多个可能实现的分支的情况。其在对比的时候,因底层实现与if语句不同,只需要一次对比即可选中所需分支,在有着大量分支的情况下效率远高于if语句,但switch语句空间占用较大,是典型的空间换时间的策略。

1.1.3 循环

C语言中有三种语句可以实现循环结构:for、while、do-while。
循环结构与上述两种结构最大的不同在于,循环结构内的语句可以执行不止一次,并且存在不执行的情况,即循环0次。下面以计算从start到end的连续自然数之和的程序为例,介绍三种循环结构。

for循环

int function_for(int start, int end)
{
	int sum = 0;

	for(register int i = start; i <= end; i++)
		sum += i;
	
	return sum;
}

for循环的一般形式为:
for(单次表达式;条件表达式;末尾循环体)
{
中间循环体
}
其中,表示式皆可以省略,但分号不可省略,因为“;”可以代表一个空语句,省略了之后语句减少,即为语句格式发生变化,则编译器不能识别而无法进行编译。
for循环小括号里第一个分号前为一个为不参与循环的单次表达式,其可作为某一变量的初始化赋值语句, 用来给循环控制变量赋初值; 也可用来计算其它与for循环无关但先于循环部分处理的一个表达式。
分号之间的条件表达式是一个关系表达式,其为循环的正式开端,当条件表达式成立时执行中间循环体。
执行末尾循环体后将再次进行条件判断,若条件还成立,则继续重复上述循环,当条件不成立时则跳出当下for循环。
for循环在实现访问连续内存空间时较为方便,逻辑清晰。

while循环

int function_while(int start, int end)
{
	int sum = 0;

	while(start <= end)
	{
		sum += start;
		start++;
	}

	return sum;
}

while循环与for循环等价。但语法中只保留用于判断是否退出循环的条件表达式。其余需要在对应位置实现。
while循环在实现例如遍历链表等访问不连续内存空间时使用方便,逻辑清晰

do-while循环

int function_dowhile(int start, int end)
{
	int sum = 0;
	int i = start - 1;

	do
	{
		i++;
		sum += i;
	}while(i < end);

	return sum;
}

do-while循环与上述两种循环的不同点在于:只要循环条件设置的好,上述的两种循环可以不执行循环体内的语句,而do-while循环至少会执行一次循环体内的语句。其他基本与while循环等价。
do-while循环时候实现至少执行一次的循环,适合实现至少需要执行一次的循环。例如:用于迭代的计数器初值为0时计算1到n的累加和。

1.2 变量

1.2.1 全局变量

int integer = 0;
unsigned int uinteger = 0;
float real = 3.14;
int integers[2] = {0, 1};
int *pinteger = &integer;
int &qinteger = integer;

全局变量能被程序中所有的函数以及对象调用。换句话说它的作用域为整个程序。
全局变量被保存在可执行文件的数据段或者.bss段中。
全局变量可以被extern和static修饰。对于一个拥有多个源文件的工程来说,每个文件都可以拥有自己的全局变量,并且对于任意文件都可调用。而被extern修饰的全局变量声明表示该全局变量在其他文件中。被static修饰的全局变量则只能被当前源文件内的函数与对象调用。

1.2.2 局部变量

int function_for(int start, int end)
{
	int sum = 0;

	for(register int i = start; i <= end; i++)
		sum += i;
	
	return sum;
}

以for循环的代码为例,其中出现的所有变量均为局部变量
局部变量的作用域为:定义局部变量时,程序执行到的语句所在的花括号包围的范围。
与全部变量不同,局部变量被保存在被称为栈的结构中进行管理。

1.2.3 寄存器变量

int function_for(int start, int end)
{
	int sum = 0;

	for(register int i = start; i <= end; i++)
		sum += i;
	
	return sum;
}

以for循环的代码为例,其中用于计数的i即为寄存器变量
寄存器变量与上述两种变量不同,其被保存在存取速度最快的寄存器当中。用register修饰局部变量即可定义寄存器变量

1.3 数据类型

1.3.1 常量

#define INTEGER 20
#define REAL 3.14
#define CHARACTER 'c'
#define STRING "string"

常数可分为整型常量(图中INTEGER)、实型常量(图中REAL)、字符型常量(图中CHARACTER)与字符串型常量(STRING)。对于每一种常量,其在底层的实现与其对应的变量相同,但常量的值不能更改,并且存储在文件的只读区域。
整型常量、实型常量与字符型常量直接存储在代码段当中,当被使用时,在取指令阶段即可取出。
字符串型常量略有不同,由于其一般内存占用较大,不能直接写入代码中,因此被存储在只读数据段中,代码段只保留其首地址。

1.3.2 整型数

int integer = 0;
unsigned int uinteger = 0;

整型数分为有符号整型与无符号整型。二者所能表示的数的数量相同,但区间不同。对于一个k位的整型数来说,有符号整型表示-2(k-1)~2(k-1)-1范围内的所有整数,而无符号整型表示02^k-1范围内的所有整数。二者直接存在着互相转换的情况。当二者均在02(k-1)-1这个区间内时,二者的值相等,类型转换并不会带来值的改变;但是当有符号数在-2(k-1)0或无符号数在2^(k-1)2k-1时,类型转换会导致值的变化,其关系可表示为公式:有符号数+2k=无符号数。

1.3.3 浮点数

float real = 3.14;

与整型数可以精确表示整数不同,浮点数只能精确表示部分实数。对于大部分实数来说,浮点数只能近似表示。根据近似的精度不同,浮点数可以分为单精度浮点型与双精度浮点型。二者在组成上一致,只在二进制位数上不同,从而导致了精度的不同。
对于一个浮点数,其二进制代码可分为三个部分:符号位,阶码和尾数。将一个待转换的实数用科学计数法的二进制形式表示,其符号对应着符号位,数量级对应着阶码,精确值的小数部分的前n位对应着尾数,n为尾数的位数。

1.3.4 数组

int integers[2] = {0, 1};

数组即为一定数量的某种数据类型的集合。数组在内存中占用一段连续的存储单元,因此可以快速访问其中的元素。字符串类型的变量在底层实现上为一个以\\0结尾的字符型数组。数组名的本质是一个指针,指向数组的起始地址。

1.3.5 指针

int *pinteger = &integer;

指针的本质为一个整数,它表示指针所指向的对象在虚拟内存中的首地址。

1.3.6 引用

int &qinteger = integer;

引用本质上相当于给它表示的对象起了一个别名,二者指向的是内存中的同一个对象,对其中一个的修改也会导致另一个的改变。

1.3.7 结构体

struct st
{
	char name[20];
	unsigned age;
	unsigned id;
} ics_me; 

有了上述的数据类型,已经足够实现C语言所有的功能了。但是,在具体使用时仍然会存在问题。首先,抽象层次不够高,不利于人的理解。其次,无法同时管理不同的数据类型,或者说给不同数据类型之间显式的建立关系。因此,C语言提供了结构体。结构体将一系列数据结构封装为用户自定义的一种数据结构,提高了抽象水平,更有利于人类理解。
结构体占用的空间并不简单的等于其中所有数据结构的内存大小相加,而是大于等于相加之和,这是由于对齐的存在。其对齐要求与结构体中内存占用最大的那个类型的对齐要求相同。
以将图中的结构体的第一个字段改为单一字符后的结构体为例,其空间占用为4+4+4=12字节,而不是2+4+4=10字节。因为对于无符号型来说,其需要满足地址可被4整除的地址要求,因此整个结构体的地址也需满足该要求。带来的结果是:字符型后面的2字节被空在那不被使用。

1.4 函数

函数是指一段可以直接被另一段程序或代码引用的程序或代码,例如程序结构当中所具的函数的例子。对于函数来说,其内部代码本可以写入主程序当中。但是,假如一段程序调用了100次某函数,如果该函数存在错误,我们只需修改函数一次;对于写在主程序中的情况,则需要找到这100次并修改。因此封装成函数有利于代码复用,并且提高了程序的可读性。
函数的通用形式为:
返回类型 名字(形式参数表列)
{
函数体语句
return 表达式;
}
参数传递方式有三种,传值,传地址,传引用。传值的情况下,函数将实参的值复制给形参,函数体对形参的修改并不会导致实参的变化。对于传地址与传引用这两种形式,本质上都是传入了一个指向实参的一个对象,对形参的修改会同时修改实参。

2. 汇编语言的语言元素

2.1 程序结构

2.1.1 整体结构

进程的虚拟空间
当一个程序加载到内存时,其具有五个主要部分:代码段、数据段、堆、共享模块、栈。
代码段存储了我们在源文件中所有语句对应的机器指令,以及常数。在程序的执行过程中,处理器按照%rip寄存器所保存的地址依次读取代码段中的指令执行,并根据指令修改寄存器中的值或将值写入内存。代码段在执行过程中不可被修改。
数据段中保留了程序中所定义的全局变量,其中已初始化的全局变量被保存在.data段中,未初始化的全局变量被保存在.bss段中。在执行过程中,程序可以读取数据段中的数据并加以修改。
堆是保留给程序在执行阶段动态分配内存的区域。由malloc函数向操作系统申请堆的空间,并由free函数释放空间。
共享模块是用于节省内存使用空间而引入的一个内存区域。程序中需要大量使用的代码,若每个进程都保留一个副本,则会造成内存的极大占用。而共享模块则只保留每个进程调用这段代码所需的数据,代码本事只在程序中包含一个副本,从而减少了内存开销。
栈是程序中局部变量保存的地点以及辅助函数调用的结构。栈内的数据遵循一个规律:后进先出。栈顶地址由一个寄存器%rsp保存。每当进入一个函数时,程序会通过压栈的方式在栈中预先分配好局部变量所需的空间;当退出一个函数时,程序会通过弹栈的方式回收局部变量占用的空间。当函数调用另一个函数时,程序将当前函数的状态,即寄存器中保存的值,通过压栈的方式存入栈中;函数返回时则通过弹栈的方式恢复函数的状态,从而继续执行函数。

2.1.2 寄存器

一个x86-64的CPU包含一组16个存储的64位值的通用目的寄存器。各个寄存器的名称与功能如下表

64位名称32位名称16位名称8位名称功能
%rax%eax%ax%al返回值
%rbx%ebx%bx%bl被调用者保存
%rcx%ecx%cx%cl第4个参数
%rdx%edx%dx%dl第3个参数
%rsi%esi%si%sil第2个参数
%rdi%edi%di%dil第1个参数
%rbp%ebp%bp%bpl被调用者保存
%rsp%esp%sp%spl栈指针
%r8%r8d%r8w%r8b第5个参数
%r9%r9d%r9w%r9b第6个参数
%r10%r10d%r10w%r10b调用者保存
%r11%r11d%r11w%r11b调用者保存
%r12%r12d%r12w%r12b被调用者保存
%r13%r13d%r13w%r13b被调用者保存
%r14%r14d%r14w%r14b被调用者保存
%r15%r15d%r15w%r15b被调用者保存

2.1.3 寻址

对于大多数指令来说,其至少有一个操作数,指出源操作数的位置,以及结果存放位置。其格式主要有三种形式:
1、立即数寻址 $Imm,对应的操作数值为Imm
2、寄存器寻址 ra,对应的操作数值为ra中的值
3、地址寻址 Imm(ra, rb, s),对应的操作数为地址Imm+ra+rb*s所指向的内存。s的值必须为1,2,4,8.其中的各部分均可以省略Imm省略时默认为0,ra与rb省略时默认值为0,s省略时默认为1。
操作数格式

2.1.4 数据传送

数据传送指令——MOV类将数据从源位置复制到目的位置而不改变数据。MOV类指令由四条指令组成,movb,movw,movl,movq,分别移动1、2、4、8个字节。
数据传送指令
然而,数据的源位置与目的位置的数据大小并不一定相等。当源位置的数据大小大于目的位置时,CPU采用截断的方式缩小数据,即舍弃目的位置存不下的那部分数据;小于时,则需要扩展源数据以匹配目的位置的大小。
扩展方式有两种:

  • 零扩展MOVZ,在数据前面补充0。但这会导致有符号数在负数范围内出错。具体指令见下表
    零扩展指令表
  • 符号扩展MOVS,在数据前面补充符号位数据。这可以保证有符号数的值不发生变化,但无符号数则会发生变化。具体指令见下表
    符号扩展指令表

2.1.5 压栈与弹栈

pushq指令将一个四字从寄存器压入栈中,并修改栈顶指针的值。
popq指令将一个四字从栈取出到寄存器中,并修改栈顶指针的值。

2.1.6 算术与逻辑操作

处理器通过下表中的各种指令完成对数据的算数操作或者逻辑操作
算数与逻辑操作指令表

2.1.7 控制

对于循环和控制两种结构中的跳转,均需要通过比较来控制。CPU提供了一组单个位的条件码寄存器,描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支。常用的条件码有:

  • CF:进位标志,用于检查无符号数的溢出
  • ZF:零标志
  • SF:符号标志
  • OF:溢出标志,用于检查有符号数的溢出
    除了算术和逻辑相关指令会修改条件码之外,有两类特殊的指令:CMP指令和TEST指令只修改条件码而不修改其他寄存器的值。可通过这两种指令为循环与分支的触发条件设置对应的条件码:
    比较和测试指令
    jmp指令则通过检测条件码来判断是否满足跳转条件。各指令对应的跳转条件如下图
    跳转指令

2.1 8 栈

栈的功能除了传递参数(见函数部分)、存储局部变量(见变量部分)、保存函数信息(见函数部分)以外,还可以用来保存暂时存储数据。由于寄存器的数量是有限的,当数据无法在寄存器中存下时,程序会将多余的有用的数据压入栈中暂存,当有需要时在将其取出。

2.2 宏定义

对于不包含变量的宏定义,编译器会将程序中的宏直接替代,若是计算式则替代计算结果。如图中所示的def()函数,最终的汇编代码直接返回20而省略计算

	#define DEF 2 * 10
int def()
{
	return DEF;
}
0000000000401377 <_Z3defv>:
  401377:	55                   	push   %rbp
  401378:	48 89 e5             	mov    %rsp,%rbp
  40137b:	b8 14 00 00 00       	mov    $0x14,%eax
  401380:	5d                   	pop    %rbp
  401381:	c3                   	retq   

对于包含变量的宏定义,编译器会将宏替换为对应的表达式。如图中所示的def(int x)函数,编译器将F1(x)的调用替换为了x + x

#define F1(x) x << 1
int def(int x)
{
	return F1(x);
}
0000000000401382 <_Z3defi>:
  401382:	55                   	push   %rbp
  401383:	48 89 e5             	mov    %rsp,%rbp
  401386:	89 7d fc             	mov    %edi,-0x4(%rbp)
  401389:	8b 45 fc             	mov    -0x4(%rbp),%eax
  40138c:	01 c0                	add    %eax,%eax
  40138e:	5d                   	pop    %rbp
  40138f:	c3                   	retq   

2.3 变量

2.3.1 全局变量

全局变量存储在程序的数据段中,代码段中存储的为全局变量在数据段中的地址。
全局变量可分为强符号和弱符号。强符号为初始化的全局变量,而弱符号为未初始化的全局变量。一个程序中同名的强符号只能存在一个,若存在同名的全局变量,连接器会将弱符号解释为对应的强符号。

2.3.2 局部变量

局部变量存储在程序的栈中,用于统一管理。以x86-64为例,在进入每个程序块时,指令控制处理器进行压栈操作为块内每个局部变量分配好空间,并且通过弹栈的方式控制变量的作用域。

2.3.3 整型变量

按照字节数,整型变量可分为字节(一个字节)、字(两个字节)、双字(四个字节)、四字(八个字节),分别对应了c程序中的char、short、int、long。
char类型数据均为无符号数,因此不需要考虑符号问题。对于其余三种数据类型,均存在有符号和无符号两种情况。有符号数以补码的方式存储,而无符号数以原码的方式存储。在运算过程中,若同时存在有符号数与无符号数,编译器会隐式的将有符号数转换成无符号数进行计算。当二者进行类型转换时,处理器不改变二进制形式,而是将补码(原码)直接解释成原码(补码)。

2.3.4 浮点变量

处理器的浮点体系结构包含了16个浮点寄存器,从%ymm0到%ymm15。每个浮点寄存器可以存储一个双精度浮点或两个两个单精度浮点,并且可以并行计算。对于浮点体系来说,其包含了另一套指令,包括:

  • 数据传送
    浮点传送指令

  • 转换为整型数
    双操作数浮点转换指令

  • 从整型数转换而来
    三操作数浮点转换指令
    计算部分没学。

2.4 函数

2.4.1 参数传递的规则

仅考虑整型数。
函数在传入参数的时候,首先使用寄存器。其按照%rdi、%rsi、%rdx、%rcx、%r8、%r9的顺序传入前6个参数。当参数数量超过6个时,程序会将这些参数压入栈中,通过栈将数据传入函数。所有通过栈传递的参数都向8的倍数对齐

2.4.2 调用语句

函数通过call指令调用其他函数,并通过ret指令返回调用者。
在执行过程中,每个函数都拥有一个栈帧——栈中属于该函数的一块连续的区域。每个栈帧的最后一个数据都是需要返回的函数地址——调用指令下一条指令的地址,在此之前栈帧中保存的数据为本函数作为调用者所需保存的参数。每个栈帧前几个数据都是本函数作为被调用者所需保存的参数。这些数据的保存均由call指令完成。同样,ret指令也会将这些数据恢复。当栈弹出返回地址时,PC就被修改为返回地址的值,从而达到了返回函数下一条语句的目的。
递归调用则是上述函数调用过程对本函数的反复使用。具体代码和全局变量都在同一个数据段与代码段中,而栈帧又为每次递归调用提供了私有的同名局部变量;返回地址又保证了递归可以从深层返回回调用处。

2.4.3 返回值传递

对于函数的返回值,整型数据保存在%rax中,浮点型数据保存在%ymm0中。

3. C语言的汇编实现

3.1 数据类型

3.1.1 整型

整型数可以分为有符号数与无符号数。对于一个长为k的二进制数,用xk-1,xk-2,…,x0来表示其对应的所有位。对于一个无符号数,其二进制编码形式与十进制数与二进制数和十进制数在数学中的关系相同,对应公式为
B 2 U ( x ) = ∑ i = 0 k − 1 x i 2 i B2U(x) = \\sum\\limits_{i=0}^{k-1}x_i2^i B2U(x)=i=0k1xi2i
对于有符号数来说,为了表示正负,其二进制编码的最高位被定义为符号位。其余位与无符号数表示意义相同。对应公式为
B 2 T ( x ) = − x k − 1 2 k − 1 + ∑ i = 0 k − 2 x i 2 i B2T(x) = -x_{k-1}2^{k-1} + \\sum\\limits_{i=0}^{k-2}x_i2^i B2T(x)=xk12k1+i=0k2xi2i

3.1.2 浮点型

一个浮点型数据在底层编码上可以分为三个部分:符号位、尾数与阶码。符号位以1来表示负数,0表示正数。对于任意一个实数,将其绝对值表示成二进制的科学计数法,则其数量级即是阶码表示的部分,系数的小数部分即尾数表示的部分。
当阶码全为1,尾数全为0时,该数表示无穷大,结合符号位即可表示正无穷大或负无穷大。
当阶码全为1,尾数不全为0时,该数表示NAN(Not a number)。
为了便于比较,阶码放在尾数之前。如图所示,单精度浮点数有着8位阶码,23位尾数;双精度浮点数有着11位阶码,52位尾数。
标准浮点数格式

3.1.3 类型转换

对于c语言中的运算,存在着显式和隐式的类型转换。隐式类型转换会发生在赋值以及计算的时候。转换有着以下集中类型
有符号数与无符号数之间的转换:当着二者发生转换时,由于均为整型数,其底层二进制编码不会改变,仅仅修改解码方式。
小整型向大整型转换:小整型向大整型转换需要在多出的位上填充0或者1,即零扩展和符号扩展。对于有符号数来说,符号扩展能保证扩展后的值不发生改变;对无符号数来说,零扩展能保证扩展后的值不发生变化。
大整型数向小整型数转换:大整型数相对小型整数来说具有更长的二进制位,因此需要截断多余的位来满足小整型数的位数要求。当大整型数的值本身在小整型数的表示范围内时,截断并不会改变数的值;但当大整型数的值不在小整型数的表示范围内时,截断会由于溢出而发生值的改变。如图中程序及输出所演示。

long long a = 1 << 2;
long long b = (long long)1 << 40;

cout << (int) a << endl;
cout << (int) b << endl;

整型数截断
整型数转化为浮点数:此时类型转换不会发生溢出,但是由于尾数长度有限,可能会发生舍入
浮点数转化为整型数:浮点数会舍弃小数部分,向0取整。

3.2 变量与数据的寻址

在程序中,变量要么存储在寄存器中,要么存储在内存中。对于存储在寄存器当中的数据,如函数传入的参数,处理器会直接对寄存器访问或者修改。对于存储在内存当中的变量,编译器在编译链接阶段就确定了其地址。需要使用时,程序将地址存入某个寄存器,再使用寄存器进入内存中访问或者修改该变量。
数据的情况与变量类似,但数据只存在于内存当中(这里特指写在数据段或者只读数据段的数据)。唯一的不同点在于,变量可以不用写回内存中,但数据则必须写回。进一步,只读数据只能读取,不可写入。

3.3 分支、循环结构的实现

对于现代编译器生成的机器指令,循环以及分支语句通过均通过跳转指令实现。
通过跳转到之前执行过的指令,再顺序执行到跳转指令处实现循环结构。以图中的function_jump函数为例,其前一部分为for循环,后一部分为选择语句。

int function_jump(int n)
{
	int x = 0;
	int sum = 0;

	for(int i = 0; i < n; i++)
		x += i;
	
	if(n <= 0)
		sum = 0;
	else
		sum = x;
	
	return sum;
}

如图所示汇编代码,40134f处的jge指令判断i是否小于n,以控制是否继续循环;而40135b处的jmp指令则是跳回到循环的开始处。当不再继续循环时,程序在401234f处跳转到40135d,越过40135b从而进入分支部分。

  401349:	8b 45 f4             	mov    -0xc(%rbp),%eax
  40134c:	3b 45 ec             	cmp    -0x14(%rbp),%eax
  40134f:	7d 0c                	jge    40135d <_Z13function_jumpi+0x30>
  401351:	8b 45 f4             	mov    -0xc(%rbp),%eax
  401354:	01 45 fc             	add    %eax,-0x4(%rbp)
  401357:	83 45 f4 01          	addl   $0x1,-0xc(%rbp)
  40135b:	eb ec                	jmp    401349 <_Z13function_jumpi+0x1c>

通过跳转到不同的未执行过的指令,顺序执行到分支结束处,再通过跳转会和从而达到分支的功能。如图所示汇编代码,401361处的jg指令控制程序进入40136c之后的指令所对应的分支,另一条分支则为401363对于的语句。40136a处的jmp指令控制两条分支在401372处汇合,之所以只存在一个jmp指令,是因为40136c处的分支只需顺序运行下去即可达到401372处,无需跳转

 40135d:	83 7d ec 00          	cmpl   $0x0,-0x14(%rbp)
401361:	7f 09                	jg     40136c <_Z13function_jumpi+0x3f>
401363:	c7 45 f8 00 00 00 00 	movl   $0x0,-0x8(%rbp)
40136a:	eb 06                	jmp    401372 <_Z13function_jumpi+0x45>
40136c:	8b 45 fc             	mov    -0x4(%rbp),%eax
40136f:	89 45 f8             	mov    %eax,-0x8(%rbp)
401372:	8b 45 f8             	mov    -0x8(%rbp),%eax

3.4 代码优化对循环的影响

void function_o3_1(int **A, int **B)
{
  for(int i = 0; i < 100; i++)
  	for(int j = 0; j < 100; j++)
  		B[i][j] = A[j][i];
}

void function_o3_2()
{
  int x = 0;
  for(int i = 0; i < 5; i++)
  	x += 5;
}

以上图中两个函数为例,第一个为求100x100的矩阵转置,第二个为求前0+1+2+3+4。在不开优化的情况下,编译器按照程序所写的语句,依次将其转为汇编语言。当开启O3优化后,变化如下

0000000000401690 <_Z13function_o3_1PPiS0_>:
  401690:	49 89 f0             	mov    %rsi,%r8
  401693:	31 c9                	xor    %ecx,%ecx
  401695:	49 8b 34 48          	mov    (%r8,%rcx,2),%rsi
  401699:	31 c0                	xor    %eax,%eax
  40169b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
  4016a0:	48 8b 14 c7          	mov    (%rdi,%rax,8),%rdx
  4016a4:	8b 14 0a             	mov    (%rdx,%rcx,1),%edx
  4016a7:	89 14 86             	mov    %edx,(%rsi,%rax,4)
  4016aa:	48 83 c0 01          	add    $0x1,%rax
  4016ae:	48 83 f8 64          	cmp    $0x64,%rax
  4016b2:	75 ec                	jne    4016a0 <_Z13function_o3_1PPiS0_+0x10>
  4016b4:	48 83 c1 04          	add    $0x4,%rcx
  4016b8:	48 81 f9 90 01 00 00 	cmp    $0x190,%rcx
  4016bf:	75 d4                	jne    401695 <_Z13function_o3_1PPiS0_+0x5>
  4016c1:	c3                   	retq   
  4016c2:	66 66 2e 0f 1f 84 00 	data16 nopw %cs:0x0(%rax,%rax,1)
  4016c9:	00 00 00 00 
  4016cd:	0f 1f 00             	nopl   (%rax)

首先,编译器会将循环打开,如图中所示汇编代码,编译器将整个矩阵拆分成以64为单位的块,每次对一个块进行转置操作,以补偿程序cache不命中的代价。

00000000004016d0 <_Z13function_o3_2v>:
4016d0:	c3                   	retq   
4016d1:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
4016d8:	00 00 00 
4016db:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

其次,编译器会舍弃无用计算,例如将常数表达式替换成常数,或如图所示,不需要执行的操作不予生成。

3.5 函数

每个函数在代码段中都有自己的位置。在执行阶段,每个函数都会在栈中拥有自己的栈帧。栈帧结构如下图所示。
栈帧结构示意图
函数的参数通过寄存器传入,对于多余6个的,则通过栈传入(见2.4.1)。
函数的调用通过call指令实现,其本质为保存原函数的内容并修改下一条指令的地址为被调用函数;函数返回通过ret指令实现,其本质为从栈中恢复寄存器当中的内容并修改下一条指令的地址为调用函数指令的下一条指令。

3.6 指针

指针的本质为一个地址。每一个变量的使用在函数底层都是指针。指针在赋值时,若从另一个指针处获得值,则通过mov指令即可实现;若为某个变量经过取地址操作进行赋值,则通过leaq指令将变量的地址赋值给保存当前指针的寄存器。
指针指向的地址为虚拟地址。在编译阶段,为了便于编译器实现以及满足操作系统的某些功能,生成的程序均以虚拟内存作为内存。当处理器需要提取某个指针指向的值时,处理器将指针表示的虚拟内存送入MMU中进行地址翻译,最终cache或内存返回数值供处理器使用。

3.7 引用

引用在底层是通过指针实现的

int integer = 0;
unsigned int uinteger = 0;
float real = 3.14;
int integers[2] = {0, 1};
int *pinteger = &integer;
int &qinteger = integer;
int quota()
{
	int x = integer;
	x = qinteger;
	return x;
}

以上图中的函数为例,其对应的汇编代码为

0000000000401436 <_Z5quotav>:
  401436:	55                   	push   %rbp
  401437:	48 89 e5             	mov    %rsp,%rbp
  40143a:	8b 05 60 2d 00 00    	mov    0x2d60(%rip),%eax        # 4041a0 <integer>
  401440:	89 45 fc             	mov    %eax,-0x4(%rbp)
  401443:	b8 a0 41 40 00       	mov    $0x4041a0,%eax
  401448:	8b 00                	mov    (%rax),%eax
  40144a:	89 45 fc             	mov    %eax,-0x4(%rbp)
  40144d:	8b 45 fc             	mov    -0x4(%rbp),%eax
  401450:	5d                   	pop    %rbp
  401451:	c3                   	retq   

我们可以注意到,在0x40143a处第一次使用integer赋值时,integer的地址为0x4041a0,而在0x401443/0x401448处第二次使用引用赋值时,程序通过integer的指针取出integer的值从而实现引用。

4. C与汇编的优缺点分析

4.1 开发速度

从开发速度的角度来说,C语言远快于汇编语言。C语言作为高级语言,更贴合人类的语言习惯。在开发过程中,C语言大部分只需要考虑逻辑上如何实现功能,极少部分时间由于bug的存在需要考虑底层实现;而汇编代码的每一个语句都需要考虑底层实现以及各种硬件资源,以防止有用数据丢失。

4.2 软件运行速度

在运行速度上,经过设计的汇编语言比C语言快得多。因为人工设计的汇编程序能够有效的利用硬件资源;而编译器生成的汇编程序,由于需要保证程序的正确性,编译器会牺牲性能,除此之外,编译器也无法加入人工可以设计的技巧进行加速。

4.3 CPU新特性的支持程度

汇编语言对CPU的新特性支撑程度更高。汇编语言中的指令往往能直接利用CPU的新特性。而C语言的底层实现不依赖于C程序,而依赖于编译器。当编译器不支持CPU的新特性时,C语言便不支持CPU的新特性。

4.4 软件的可移植性

C语言的可移植性远高于汇编语言。对于不同类型的机器来说,其指令集往往是不同的,因此实现同样的功能,其汇编语言也往往不同,可移植性极差。而C语言不依赖于底层汇编,其描述的只是程序需要的逻辑,因此C程序可以在各种机器上移植,每中机器只需要提供对应的编译器即可。

4.5 个人体会

当只能使用C语言或汇编语言时,若需要实现复杂功能,则必选C语言而不选择汇编语言。但是,当程序遇到速度瓶颈,而针对C程序的优化仍无法胜任时,需要在C程序的关键部分中插入人工设计的汇编语言以提高性能。
除此之外,当遇到一台新机器,并且该机器缺少编译器的时候,则只能使用汇编语言。
在开发一些嵌入式的简单程序时,使用汇编语言往往能达到更好的效果。

5. 参考文献

[1] 深入理解计算机系统(第三版)
[2] http://www.360doc.com/content/17/0308/22/40101294_635112591.shtml

以上是关于计算机系统大作业的主要内容,如果未能解决你的问题,请参考以下文章

哈工大计算机系统大作业——程序人生

2021春深入理解计算机系统大作业——程序人生

哈工大2022计算机系统大作业---程序人生

哈工大计算机系统2022年秋程序人生大作业

计算机系统大作业——程序人生

哈工大2022年春季学期计算机系统大作业——程序人生