哈工大计算机系统大作业-C、C++底层实现分析
复制了两份,一份图片版,一份文字版。可供参考。






























第4章 C与汇编的优缺点分析
满分15分
4.1开发速度
就开发速度而言,使用C语言要远快于使用汇编语言。由于C语言使用起来更加贴合人类的思考模式,从而加快了程序编写效率,再加上C语言隐藏了机器中具体的实现方法(包括栈帧结构、寄存器等),使得程序员在编写代码时可以暂时忽略计算机系统的底层结构,将更多的经历放在如何优化程序架构、加快算法本身的工作上面。C语言中对过程的抽象使得程序员不再需要耗费大量时间来思考跳转指令的作用并仔细地根据复杂的跳转语句定位程序当中的漏洞,只需要使用分支或循环结构,或是调用已经编写好的函数即可。在C++语言当中,开发得到了进一步的抽象和简化。由于引入了对面向对象、泛型、迭代器和智能指针等新技术的支持,C++程序员在开发程序时,可以将更多的精力放在不同对象之间的关系上面,在更高的层面看待程序开发。在C++中,各种函数可以作为类的成员函数调用,对象之间传递参数的机制也得到了优化,减轻了在C语言当中全局变量和函数太多、导致程序结构混乱的情况。类中的每个对象拥有不同权限,在一定程度上保证了数据的安全性。此外,函数重载、运算符重载为对象之间的赋值和运算等操作提供了更加自然的表达方式。总的来说,从汇编语言到C语言,再由C语言到C++语言的开发过程是一个逐渐由类似机器的思考方式到类似人类的思考方式的过程,随着越来越多的细节被抽象、封装和隐藏,程序员所要思考的任务越来越趋近于计算本身,开发时速度受到底层结构的影响越来越少。
4.2软件运行速度
用C/C++语言编写的程序需要经过编译转换成汇编代码。即使经过多年的优化,编译器仍不能百分之百地准确理解程序员的编程意图。编译器对优化的态度是保守的,即如果不能大概率确保程序运行的结果不变,则不会进行优化,以避免可能因优化带来的正确性降低。这个特性意味着很多执行效率明显偏低的代码都不会被编译器优化。如果能够直接编写汇编语言程序,由于编写的过程中会对寄存器占用等底层细节更加关注,编写的程序往往能够估计到这类性能方面的提升空间,从而及时进行优化。
当然,这个问题的答案也不能一概而论。如果程序员水平尚待提高,那么选择让编译器自动优化而非手动编写优化过后的汇编代码无疑是更加明智的。但在程序员技术水平较高的情况下,直接编写汇编代码确实可以使软件运行速度得到提升。
4.3 CPU新特性支持程度
由于汇编代码直接面向指令集,于是使用汇编代码编写程序毫无疑问是使用CPU新特性的最方便方法。但是CPU生产厂家有可能会同时提供一些包装好的函数,
用这些函数可以直接执行某些汇编指令,这也是一种应用CPU新特性的办法。此外,C/C++语言可以内置汇编代码,为直接面向底层开发提供了可能。
4.4软件可移植性
汇编代码只面向特定指令集,不同厂商的处理器甚至同厂商不同版本的处理器之间都可能发生指令集不兼容的情况。这些情况都会导致同一段汇编代码无法在另一台机器上执行。而C语言编译时使用的编译器通常都是针对各种不同处理器设计的,同一段C代码可以较为方便地移植到不同架构的计算机上。
4.5综合分析
汇编语言和C/C++语言各有优点。汇编语言直接面向底层,在代码编写合理的情况下汇编语言由于可以直接应用处理器的各种特性,可以使程序达到较高的性能。C/C++语言无法直接访问底层,需要借助编译器将源代码转化成汇编代码,而通常编译器的优化效果无法达到极致,所以会带来一定性能上的损失。但正是由于C/C++语言将底层机制抽象封装,使得在使用C/C++语言编写程序时无需过多注意底层细节,从而可以将精力更多投入在设计算法本身上。在编写程序时,要根据具体情况选用最合适的语言。
计算机系统大作业
题 目 C/C++的底层实现分析
专 业 计算机类
学 号 xxx
姓 名 xxx
班 级 xxx
指 导 教 师 xxx
计算机科学与技术学院
2021 年6月
目 录
第1章 C语言的语言元素
满分15分
1.1 程序结构
1.1.1 循序结构
循序结构即顺序结构,是最基本的结构之一。C/C++代码行按照顺序依次执行:
|
statement 1; statement 2; statement 3; … |
图1-1 顺序结构
1.1.2 分支
if语句构建起C/C++语言程序的分支结构。如果条件满足,执行语句块A。如果是if-else结构,当条件满足时执行语句块A,当条件不满足时可执行语句块B。
|
if(condition){ statement_block_A; } else{ statement_block_B; } |
图1-2 分支结构
1.1.3 循环
(1)while语句
while语句先判断循环开始(继续)的条件,再执行循环体。
|
while(condition){ statements; } |
图1-3 while语句
(2)do-while语句
do-while语句先执行循环体,再判断是否继续。
|
do{ statements; }while(condition); |
图1-4 do-while语句
1.2 变量
1.2.1 全局变量
全局变量指在所有函数外部定义的变量。
1.2.2 局部变量
局部变量指在函数内部定义的变量。作用域为离该变量声明位置最近的一对花括号范围内。
1.2.3 寄存器变量
寄存器变量指用保留字register修饰的变量,指示编译器在编译时将该变量存放在寄存器当中。
1.2.4 数组
数组提供了一组地址连续的空间,可以用来存放一组数据。用如下格式声明:
|
<type> array_name[d1]([d2]…)
|
图1-5 数组的声明格式
type为数组元素的类型,d1,d2,…为数组的维数。只有一个维度的数组称为一位数组,维度大于等于2的数组称为多维数组。
1.2.5 结构体
结构体提供了将不同类型的变量打包的方法:
|
struct struct_name{ type1 element1; type2 element2; … }; |
图1-6 结构体的声明格式
用这种方法可以将多个变量的打包看做一个变量,方便数据的表示和解释。
1.2.6 基本数据类型
C/C++语言中基本的数据类型有short、int、long、char、float、double等。其中char类型通常表示字符,short,int,long类型通常表示整数,float,double类型通常表示浮点数。
列表如下(x86-64环境下):
|
类型 |
大小 |
|
char |
1 |
|
short |
2 |
|
int |
4 |
|
long |
8 |
|
float |
4 |
|
double |
8 |
图1-7 基本数据类型
1.2.7 有符号/无符号整数
C/C++语言中整数分为有符号/无符号两类。保留字unsigned用来声明无符号整数, 保留字signed用来声明有符号整数。signed通常可以省略。
1.2.8 浮点数
C/C++语言的浮点数格式如下:

图1-8 浮点数编码格式
以单精度格式为例,我们列举几种取值在内存中的编码情况:

图1-9 浮点数编码示例
1.2.9 类型转换
C/C++语言中,类型转换分为自动类型转换和强制类型转换。
在C语言中,自动类型转换遵循以下规则:
- 若参与运算量的类型不同,则先转换成同一类型,然后进行运算
- 转换按数据长度增加的方向进行,以保证精度不降低。如int型和long型运算时,先把int量转成long型后再进行运算
a、若两种类型的字节数不同,转换成字节数高的类型
b、若两种类型的字节数相同,且一种有符号,一种无符号,则转换成无符号类型
- 所有的浮点运算都是以双精度进行的,即使是两个float单精度量运算的表达式,也要先转换成double型,再作运算.
- char型和short型参与运算时,必须先转换成int型
- 在赋值运算中,赋值号两边量的数据类型不同时,赋值号右边量的类型将转换为左边量的类型。如果右边量的数据类型长度比左边长时,将丢失一部分数据,这样会降低精度。
强制类型转换通常格式如下:
|
(type)(expression) |
自动转换是在源类型和目标类型兼容以及目标类型广于源类型时发生一个类型到另一类的转换。
1.2.10 常数
在C/C++语言中,可以用保留字const来声明常数:
|
const type var_name; |
常数一经声明,其值不可修改。
1.3 函数
C/C++语言中的函数表示一类子程序,用如下格式声明:
|
type func_name(arg_type1 arg1, arg_type2 arg2, …){ body } |
type 是返回值类型,它可以是C语言中的任意数据类型,例如 int、float、char 等。
func_ame 是函数名,它是标识符的一种,命名规则和标识符相同。函数名后面的括号( )不能少。
body 是函数体,它是函数需要执行的代码,是函数的主体部分。即使只有一个语句,函数体也要由{ }包围。
如果有返回值,在函数体中使用 return 语句返回。return 出来的数据的类型要和 type 一样。
arg1,arg2,… 是参数。函数定义时给出的参数称为形式参数,简称形参;函数调用时给出的参数(也就是传递的数据)称为实际参数,简称实参。函数调用时,将实参的值传递给形参,相当于一次赋值操作。
原则上讲,实参的类型和数目要与形参保持一致。如果能够进行自动类型转换,或者进行了强制类型转换,那么实参类型也可以不同于形参类型,例如将 int 类型的实参传递给 float 类型的形参就会发生自动类型转换。
1.4 指针和引用
C/C++语言中有一类特殊的变量,即指针变量。它指示其他变量的地址。在某些情况下,这个特性是有用的。
C/C++语言中,用如下语法声明指针:
|
type *pointer_name; |
为使指针指向有效地址,我们这样给指针赋值:
|
pointer = &var; |
其中pointer是声明过的指针变量,var是某个变量。符号&称为取地址运算符,用它可以获得相应变量的地址。
作为一个关于指针应用的例子,我们来看应用scanf函数获取输入的情形:
|
scanf(“%d”,&a); |
我们向scanf传递a的地址而非a本身,这样scanf函数就可以根据地址修改变量a的值,将输入的数据写进a当中。
引用是C++的一个语法特性,顾名思义是某一个变量或对象的别名,对引用的操作与对其所绑定的变量或对象的操作完全等价。引用使用如下格式声明:
|
type &ref_name = var_name; |
这样,引用和原有变量完全等价,对引用的修改会全部变为对原变量的修改。
1.5 程序的结构
一个标准的C/C++程序默认从主函数开始执行。主函数原型如下:
|
int main(int argc, const char * argv); |
其中argc为通过命令行等调用当前程序时传递的参数个数,argv为参数列表。其中argv[0]内容为当前程序的路径和名称,argv[1], argv[2], … 为各个参数。
1.6 宏
C/C++语言中,宏是一种常用的工具。在 C 语言中,可以采用命令 #define 来定义宏。该命令允许把一个名称指定成任何所需的文本,例如一个常量值或者一条语句。在定义了宏之后,无论宏名称出现在源代码的何处,预处理器都会把它用定义时指定的文本替换掉。
第2章 汇编语言的语言元素
满分30分
2.1 程序结构
本章用Intel X86-64为例介绍汇编语言中的一些特性。
2.1.1 整体结构
一个汇编程序分成若干个段,分段的情况与操作系统和计算机结构有关。在64位Linux下,汇编程序分为代码段,数据段,堆栈段等。
在汇编语言中,用户可以使用CPU中的若干寄存器。X86-64架构下,通用目的寄存器如下图所示:

图2-1 通用目的寄存器
此外还有许多其他寄存器可以通过各种方法使用。
2.1.2 分支
跳转指令如下图所示:

图2-2 跳转指令
跳转指令的效果是使代码有条件地执行,从而形成分支结构。
在有条件跳转之前,通常需要设置条件码。条件码可以用如下指令设置,另外,在运算指令结束之后,条件码也会被自动设置。

图2-3 比较/测试指令
常见的条件码举例如下:

2.1.3 循环
loop/loopne/loope语句可以控制形成循环结构。每次循环时都将循环计数器(CX/ECX)减一,直至循环计数器清零。
在gcc中,loop/loopne/loope指令通常不被使用,而用条件跳转指令代替。
2.2 变量
2.2.1 全局变量
在汇编语言中,全局变量通常存放在数据段。当访问全局变量时,通常会根据数据段地址和偏移量取得全局变量的地址,再根据地址访问和修改全局变量。
2.2.2 局部变量
局部变量并无必要长期保存,通常存放在栈帧结构当中,需要使用时开辟空间,使用完毕后释放相应空间即可。
2.2.3 整型变量
在汇编语言中,所有数据均可以看做是整数。但是整数的长度各不相同。可以将整数分成1,2,4,8字节不同的几个类型,操作不同长度整数时所用汇编指令的后缀也不相同。b代表1字节,w代表2字节,l代表3字节,q代表4字节。

图2-4 整型变量长度及其后缀
整型大量出现在汇编代码当中,通用目的寄存器一般情况下存放和处理的数据类型均为整型。整型分为有符号和无符号两种,在存储方式上没有区别,但是两种数据对于最高位的意义的解释不同。我们在补码情况下讨论两种类型数据在解释上的差异。
无符号数表示的值与每一位的关系如下:

有符号数表示的值与每一位的关系如下:

汇编语言中分别有不同的指令来处理这两种情况。例如,执行mov指令,如果需要扩展位数,可以使用为无符号数设计的零扩展或者针对有符号数设计的符号扩展指令(如下图)。


图2-5 针对有符号数和无符号数的传送指令
此外,修改条件码时,针对无符号数和有符号数,可以专门使用对应的比较指令:

图2-6 比较指令
跳转和使用条件传送指令等指令时,都可以根据操作数的种类选择有符号或无符号的版本。这里列举条件传送指令的几个版本。

图2-7 条件传送指令
综上所述,无符号数和有符号数存储方式并无不同,但是运算时可以根据有无符号来选择合适的指令。
2.2.4 浮点型变量
浮点型变量的编码规则如下所示:

图2-8 浮点数编码格式
以单精度格式为例,我们列举几种取值在内存中的编码情况:

图2-9 浮点数编码示例
在X86-64CPU当中,浮点数由专门的寄存器存放:

图2-10 浮点数寄存器
有一些为浮点数特别设计的汇编指令,使用这些指令可以大幅提高处理浮点数的效率,例如:

图2-11 一些浮点数运算指令
2.3 函数
2.3.1 参数传递的规则
为了理解函数传递的规则,首先要了解栈帧结构。
下面第一幅图展示了64位下栈帧的结构示意图,第二幅图展示了32位下栈帧结构的示意图。二者的主要区别在于,64位下栈帧结构并不存储栈帧构建过程中ebp的信息,在每次改变栈帧时,编译器选择记住栈指针的改变量并写进代码。当一个新的函数被调用时,栈指针会向下移动,开辟一个新的空间。这块新的空间成为这个新的函数的帧。新函数运行时的局部变量、返回地址等信息存储在帧当中。在32位下,函数参数存放在上一个帧的当中,需要访问ebp+8,ebp+16等地址以取得各个参数的值;在64位下,前6个参数并不放在帧当中,而是放在寄存器里,这样可以加快函数执行的效率。

图2-12 64位下栈帧结构

图2-13 32位下栈帧结构
在64位下传递参数时,第1-6个参数不需要压入栈中,而是直接存放在寄存器内。如图所示:


图2-14 参数传入的寄存器
在32位下调用函数时,需要将全部参数压入栈中。
2.3.1 调用语句
在Intel X86-64汇编语言当中,我们用call指令调用函数,用ret指令返回。

图2-15 调用/返回指令
在64位通常情况下,call指令执行时,会自动将call的下一条指令的地址压栈,并减少rsp以形成新的帧;ret指令执行时,会自动增加rsp释放帧的空间,再将栈顶元素弹出,将弹出的值写入rip,作为下一条指令的地址,从而进行控制流的转移。
在32位时,情况稍微复杂一些。call指令执行时先将返回地址压入栈中,之后将ebp压栈来保存上个帧的帧指针信息;再将esp的值赋给ebp以保存上个帧的栈指针信息;最后将esp的值减少以开辟新的帧。为了保证返回时栈帧结构的正确性,32位使用leave指令来准备返回前所需的栈结构。leave指令执行时,将ebp的值赋给esp,再将栈顶元素(应为上个帧的ebp信息)弹出到ebp。指令执行完毕后,返回地址应该处在栈的顶部。ret指令执行时,将栈顶元素弹出至rip,从而转移控制流,完成函数的返回。
2.3.2 返回值传递
在X86-64下,返回值存放在rax寄存器当中。函数返回之前需要将返回值存入该寄存器,以便调用者使用返回值。
第3章 C语言的汇编实现
满分40分
本章使用g++编译器,在64位系统下完成。
编译命令:g++ c.cpp -Og -S -o c.s -g
C语言代码文件名:c.c
汇编语言代码文件名:c.s
3.1 整型的和浮点型的实现
如果是局部变量,整型和浮点型通常以立即数的形式出现在代码段当中。使用时直接将整型和浮点型对应的编码载入寄存器即可。如果是全局变量,相应数据通常存放在数据段中,并根据数据长度指定对齐方式,便于读取和修改数据。相应汇编代码片段如下:
整型全局变量:
|
movl _global_variable(%rip), %esi |
整型局部变量:
|
movl $19270817, %esi |
浮点型(float,double)局部变量:
|
movl $1075125944, -4052(%rbp) ## imm = 0x40151EB8 movabsq $4612436543305289197, %rax ## imm = 0x4002AA9930BE0DED |
浮点型运算使用浮点运算的汇编指令:
|
movsd _global_variable_double(%rip), %xmm1 ## xmm1 = mem[0],zero movsd LCPI5_0(%rip), %xmm0 ## xmm0 = mem[0],zero movsd %xmm1, -4064(%rbp) ## 8-byte Spill .loc 1 93 45 is_stmt 0 ## c.cpp:93:45 mulsd LCPI5_1(%rip), %xmm0 |
3.2 类型转换的实现
对于编译器来说,类型转换不需要特殊的实现,只需要将数据拷贝至指定位置即可。这种处理无论是对有/无符号整数转化到无/有符号整数,小整型转化到大整型,大整型转化到小整型,整型转化到浮点还是浮点转化到整型都适用。例如,双精度转整型的代码如下:
|
cvttsd2si -4064(%rbp), %ebx ## 8-byte Folded Reload |
长整型转整型代码如下:
|
movq -4080(%rbp), %rcx ## 8-byte Reload |
虽然某些汇编指令可以在不同编码格式之间进行转换,但是默认的类型转换通常是容易出错的。对于某些复杂的类型转换,最好自己重写转换方法。
3.3变量和数据的寻址问题
在汇编程序生成的时候,全局变量都被移动到数据段,并添加标号,便于汇编器运行时计算地址:
|
.section __DATA,__data .globl _global_variable ## @global_variable .p2align 2 _global_variable: .long 233 ## 0xe9
.globl _global_variable_long ## @global_variable_long .p2align 3 _global_variable_long: .quad 233333333333 ## 0x3653c01d55
.globl _global_variable_double ## @global_variable_double .p2align 3 _global_variable_double: .quad 4612429112365904036 ## double 2.3300000000000001
|
如果是局部变量,使用时则会根据相对栈顶的偏移量来动态计算地址,如果是传递给函数的参数,则可以直接使用寄存器来获得变量的地址:
|
Ltmp103: ##DEBUG_VALUE: pointer:b <- 2 .loc 1 149 33 prologue_end ## c.cpp:149:33 movl (%rdi), %esi .loc 1 149 5 is_stmt 0 ## c.cpp:149:5 leaq L_.str.34(%rip), %rdi |
3.4函数
汇编语言通过将参数放入寄存器或者压入栈中来传递参数,通过call指令调用函数。例如:
|
Ltmp98: .loc 1 138 5 ## c.cpp:138:5 leaq L_.str.32(%rip), %rdi movl $4, %esi xorl %eax, %eax callq _printf |
在64位情况下,放置前6个参数的寄存器如下:
图3-1 参数传入的寄存器
如果需要传递更多参数,则从第七个参数开始需要将参数压入栈中:
图3-2 64位下的栈帧结构
函数本身是代码段的一部分,在生成汇编指令时通常在函数入口处同时生成一个标号,方面阅读。
3.5分支和循环结构
分支结构由比较指令和跳转指令或条件传送指令组成。例如,C语言中下列代码片段
|
if(test>0){ printf("Statement A!\n"); } else{ printf("Statement B!\n"); } |
翻译成汇编代码为:
|
Ltmp17: ##DEBUG_VALUE: branch:test <- undef .loc 1 29 12 ## c.cpp:29:12 cmpl $0, -4(%rbp) Ltmp18: .loc 1 29 8 is_stmt 0 ## c.cpp:29:8 leaq L_str.40(%rip), %rax leaq L_str.39(%rip), %rdi cmovgq %rax, %rdi |
代码段中L_str为全局字符串变量的存放处起始地址。代码先比较test(存放在rbp-4处)的值和0的关系,再用条件传送指令对接下来要传递的参数赋值。
循环结构由条件跳转指令构成,跳转条件通常和循环继续的条件相同。许多情况下,循环结构有一个(或多个)循环计数器,当计数器清零之后循环才结束。
例如,考虑接下来一段C语言代码:
|
int test=0; while(test<3){ test++; printf("while_loop statement %d! \n",test);
} |
它的汇编程序如下:
|
LBB3_1: ## =>This Inner Loop Header: Depth=1 ##DEBUG_VALUE: while_loop:test <- 0 ##DEBUG_VALUE: while_loop:test <- undef .loc 1 42 9 prologue_end ## c.cpp:42:9 movq %r14, %rdi movl %ebx, %esi xorl %eax, %eax callq _printf Ltmp24: ##DEBUG_VALUE: while_loop:test <- [DW_OP_plus_uconst 1, DW_OP_stack_value] undef .loc 1 40 15 ## c.cpp:40:15 incl %ebx cmpl $4, %ebx .loc 1 40 5 is_stmt 0 ## c.cpp:40:5 jne LBB3_1 |
3.6优化对循环的影响
在通常情况下,编译器会对循环做基本的优化。假设循环的结果在编译时已知,则生成代码时不试图进行求值计算,而是将循环的结果直接写进代码段。考虑下列程序:
|
int loop(){ int ans=0; for(int i=1;i<10;i++) ans+=i; return ans; } |
生成的汇编代码如下:
|
.globl __Z4loopv ## -- Begin function _Z4loopv .p2align 4, 0x90 __Z4loopv: ## @_Z4loopv Lfunc_begin10: .loc 1 168 0 ## c.cpp:168:0 .cfi_startproc ## %bb.0: pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp148: ##DEBUG_VALUE: loop:ans <- 45 .loc 1 171 5 prologue_end ## c.cpp:171:5 movl $45, %eax popq %rbp retq Ltmp149: Lfunc_end10: .cfi_endproc ## -- End function |
可见,转化成汇编代码时,编译器直接算出了返回值并将结果写进了代码段。
接下来考虑另一端代码:
|
int loop2(int a){ int ans=0; for(int i=1;i<10;i++){ if(a==0) ans+=i; } return ans; } |
这段代码其实可以优化。考虑到a的值在传入之后已经确定,所以最终结果只有0或45两个值。不需要执行循环来得到答案。
用-Og编译选项编译时得到如下汇编代码:
|
.globl __Z5loop2i ## -- Begin function _Z5loop2i .p2align 4, 0x90 __Z5loop2i: ## @_Z5loop2i Lfunc_begin11: .loc 1 174 0 ## c.cpp:174:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: loop2:a <- $edi ##DEBUG_VALUE: loop2:a <- $edi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp111: ##DEBUG_VALUE: loop2:ans <- 0 ##DEBUG_VALUE: i <- 1 xorl %eax, %eax movl $1, %ecx Ltmp112: .p2align 4, 0x90 LBB11_1: ## =>This Inner Loop Header: Depth=1 ##DEBUG_VALUE: i <- 1 ##DEBUG_VALUE: loop2:ans <- 0 ##DEBUG_VALUE: loop2:a <- $edi ##DEBUG_VALUE: loop2:ans <- $eax ##DEBUG_VALUE: i <- $ecx .loc 1 0 0 prologue_end ## c.cpp:0:0 testl %edi, %edi Ltmp113: .loc 1 177 12 ## c.cpp:177:12 movl $0, %edx cmovel %ecx, %edx addl %edx, %eax Ltmp114: ##DEBUG_VALUE: loop2:ans <- $eax .loc 1 176 23 ## c.cpp:176:23 incl %ecx Ltmp115: ##DEBUG_VALUE: i <- $ecx .loc 1 176 18 is_stmt 0 ## c.cpp:176:18 cmpl $10, %ecx Ltmp116: .loc 1 176 5 ## c.cpp:176:5 jne LBB11_1 Ltmp117: ## %bb.2: ##DEBUG_VALUE: loop2:ans <- $eax ##DEBUG_VALUE: loop2:a <- $edi .loc 1 179 5 is_stmt 1 ## c.cpp:179:5 popq %rbp retq Ltmp118: Lfunc_end11: .cfi_endproc ## -- End function |
翻译过后的汇编代码严格按照C代码执行。执行10次循环并在每次循环中判断是否累加。
用-O3重新编译,得到如下代码:
|
.globl __Z5loop2i ## -- Begin function _Z5loop2i .p2align 4, 0x90 __Z5loop2i: ## @_Z5loop2i Lfunc_begin11: .loc 1 174 0 ## c.cpp:174:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: loop2:a <- $edi ##DEBUG_VALUE: loop2:a <- $edi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp150: ##DEBUG_VALUE: loop2:ans <- 0 ##DEBUG_VALUE: i <- 1 xorl %ecx, %ecx Ltmp151: .loc 1 0 0 prologue_end ## c.cpp:0:0 testl %edi, %edi movl $45, %eax Ltmp152: .loc 1 176 5 ## c.cpp:176:5 cmovnel %ecx, %eax Ltmp153: ##DEBUG_VALUE: loop2:ans <- $eax .loc 1 179 5 ## c.cpp:179:5 popq %rbp retq Ltmp154: Lfunc_end11: .cfi_endproc ## -- End function |
开启-O3优化后的汇编代码采用了上述优化措施,即事先算好循环得到的结果,在返回时使用条件传送指令。
比较得知,开启-Og优化时并没有优化掉不变的分支条件所带来的性能影响;开启-O3优化后,编译器将无变化的条件分支移出循环,取而代之的将结果副本放入循环中。
3.7指针和引用
指针的本质是存储有某内存单元地址的变量。在64汇编语言中,指针和64位整数使用相同方法存储。当需要修改指针内容时,汇编语言支持根据地址查找并修改该位置内存内容的功能。例如,考虑下面一段C语言代码:
|
void pointer(int *pt){ int b=2; int* a=&b; printf("pt val = %d,a=%d\n",*pt,*a); } |
翻译成汇编语言代码,得到
|
__Z7pointerPi: ## @_Z7pointerPi Lfunc_begin8: .loc 1 146 0 is_stmt 1 ## c.cpp:146:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: pointer:pt <- $rdi ##DEBUG_VALUE: pointer:pt <- $rdi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp103: ##DEBUG_VALUE: pointer:b <- 2 .loc 1 149 33 prologue_end ## c.cpp:149:33 movl (%rdi), %esi .loc 1 149 5 is_stmt 0 ## c.cpp:149:5 leaq L_.str.34(%rip), %rdi Ltmp104: movl $2, %edx xorl %eax, %eax popq %rbp jmp _printf ## TAILCALL Ltmp105: Lfunc_end8: .cfi_endproc ## -- End function |
可以看到,代码中对指针pt的访问直接翻译成根据寄存器内容寻址,再使用该地址读出的值的汇编代码。
引用与指针类似。在C++中,引用声明了一个别名,对变量别名的修改等价于对变量本身进行修改。为了达到这个目的,在转换成汇编时,对引用的声明转化成对变量进行取地址操作,当对某个变量的引用进行操作时,先根据该变量的地址找到对应的内存单元,再直接访问内存单元进行操作,从而达到修改变量本身的目的。
例如,考虑下面C++代码:
|
void reference(int &a){ int &b = a; b=2; printf("a=%d\n",a); } |
转化成汇编代码为:
|
__Z9referenceRi: ## @_Z9referenceRi Lfunc_begin9: .loc 1 151 0 is_stmt 1 ## c.cpp:151:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: reference:a <- $rdi ##DEBUG_VALUE: reference:a <- $rdi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp106: ##DEBUG_VALUE: reference:b <- $rdi ##DEBUG_VALUE: reference:b <- $rdi .loc 1 153 6 prologue_end ## c.cpp:153:6 movl $2, (%rdi) .loc 1 154 5 ## c.cpp:154:5 leaq L_.str.35(%rip), %rdi Ltmp107: movl $2, %esi xorl %eax, %eax popq %rbp jmp _printf ## TAILCALL Ltmp108: Lfunc_end9: .cfi_endproc ## -- End function |
3.8类成员函数的传参原理
C++语言在编译时,面向对象的语句会退化成面向过程的。C++的类和C的结构体类似,编译后的代码也类似。
考虑下列C++代码:
|
class class_test{ public: int a; class_test(int a_val){ a=a_val; } int getA(){ return a; } }; int main(int argc, char ** argv){ class_test test1(1); class_test test2(2); printf("test1=%d\ntest2=%d\n",test1.getA(),test2.getA()); return 0; }
|
这段程序定义了一个只有一个成员变量的类class_test,这个类有一个构造函数和一个成员函数。在使用这个类的时候,我们创建了这个类的两个实例,分别用构造函数初始化成员变量的值。然后对每个实例分别调用各自的成员函数,获得各个类中a的值。那么,成员函数是怎么知道是哪个对象调用了它的呢?为了解答这个问题,我们查看main函数的汇编代码:
|
.globl _main ## -- Begin function main .p2align 4, 0x90 _main: ## @main Lfunc_begin10: .loc 1 168 0 ## c.cpp:168:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: main:argc <- $edi ##DEBUG_VALUE: main:argv <- $rsi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp109: pushq %r15 pushq %r14 pushq %rbx subq $24, %rsp .cfi_offset %rbx, -40 .cfi_offset %r14, -32 .cfi_offset %r15, -24 Ltmp110: ##DEBUG_VALUE: main:a <- 1 .loc 1 169 9 prologue_end ## c.cpp:169:9 movl $1, -28(%rbp) Ltmp111: ##DEBUG_VALUE: main:a <- [DW_OP_constu 28, DW_OP_minus, DW_OP_deref] $rbp .loc 1 0 9 is_stmt 0 ## c.cpp:0:9 leaq -28(%rbp), %rdi Ltmp112: .loc 1 170 5 is_stmt 1 ## c.cpp:170:5 Ltmp113: ##DEBUG_VALUE: main:test1 <- [DW_OP_constu 40, DW_OP_minus, DW_OP_deref] $rbp .loc 1 0 5 is_stmt 0 ## c.cpp:0:5 leaq -40(%rbp), %r14 .loc 1 171 16 is_stmt 1 ## c.cpp:171:16 movq %r14, %rdi movl $1, %esi callq __ZN10class_testC1Ei Ltmp114: ##DEBUG_VALUE: main:test2 <- [DW_OP_constu 32, DW_OP_minus, DW_OP_deref] $rbp .loc 1 0 16 is_stmt 0 ## c.cpp:0:16 leaq -32(%rbp), %r15 .loc 1 172 16 is_stmt 1 ## c.cpp:172:16 movq %r15, %rdi movl $2, %esi callq __ZN10class_testC1Ei Ltmp115: .loc 1 173 41 ## c.cpp:173:41 movq %r14, %rdi callq __ZN10class_test4getAEv Ltmp116: movl %eax, %ebx .loc 1 173 54 is_stmt 0 ## c.cpp:173:54 movq %r15, %rdi callq __ZN10class_test4getAEv Ltmp117: .loc 1 173 5 ## c.cpp:173:5 leaq L_.str.36(%rip), %rdi movl %ebx, %esi movl %eax, %edx xorl %eax, %eax callq _printf Ltmp118: .loc 1 174 5 is_stmt 1 ## c.cpp:174:5 xorl %eax, %eax addq $24, %rsp popq %rbx popq %r14 popq %r15 popq %rbp retq Ltmp119: Lfunc_end10: .cfi_endproc ## -- End function |
可以看见,无论是类的哪个实例对象调用成员函数,转换成汇编代码之后调用的都是同一个函数。由此,我们可以推断出类的成员函数的传参奥秘——类中所有的对象共用一套成员函数,只需要在调用时传递代表实例对象身份的值即可!
为了验证我们的猜测,我们继续观察汇编代码。发现不同类调用成员函数时都会向寄存器传递一个特定的参数,即由ebp的不同偏移量计算得来的地址。test1调用时传递的参数是-40(%ebp),test2调用时传递的参数是-32(%ebp)。这些参数是干什么用的呢?我们观察所调用的getA的代码:
|
__ZN10class_test4getAEv: ## @_ZN10class_test4getAEv Lfunc_begin12: .loc 1 163 0 ## c.cpp:163:0 .cfi_startproc ## %bb.0: ##DEBUG_VALUE: getA:this <- $rdi ##DEBUG_VALUE: getA:this <- $rdi pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset %rbp, -16 movq %rsp, %rbp .cfi_def_cfa_register %rbp Ltmp122: .loc 1 164 16 prologue_end ## c.cpp:164:16 movl (%rdi), %eax .loc 1 164 9 is_stmt 0 ## c.cpp:164:9 popq %rbp retq Ltmp123: Lfunc_end12: .cfi_endproc ## -- End function |
我们发现getA函数将rdi的值作为地址,取出对应地址的值并返回。由此我们推断,程序在栈帧结构内开辟了两块空间,用来存放test1和test2的成员变量。当调用相关成员函数时将对应空间的起始地址传入,通过访问和修改对应地址空间相应偏移处内存的值来达到访问和修改成员变量的目的。
第4章 C与汇编的优缺点分析
满分15分
4.1开发速度
就开发速度而言,使用C语言要远快于使用汇编语言。由于C语言使用起来更加贴合人类的思考模式,从而加快了程序编写效率,再加上C语言隐藏了机器中具体的实现方法(包括栈帧结构、寄存器等),使得程序员在编写代码时可以暂时忽略计算机系统的底层结构,将更多的经历放在如何优化程序架构、加快算法本身的工作上面。C语言中对过程的抽象使得程序员不再需要耗费大量时间来思考跳转指令的作用并仔细地根据复杂的跳转语句定位程序当中的漏洞,只需要使用分支或循环结构,或是调用已经编写好的函数即可。在C++语言当中,开发得到了进一步的抽象和简化。由于引入了对面向对象、泛型、迭代器和智能指针等新技术的支持,C++程序员在开发程序时,可以将更多的精力放在不同对象之间的关系上面,在更高的层面看待程序开发。在C++中,各种函数可以作为类的成员函数调用,对象之间传递参数的机制也得到了优化,减轻了在C语言当中全局变量和函数太多、导致程序结构混乱的情况。类中的每个对象拥有不同权限,在一定程度上保证了数据的安全性。此外,函数重载、运算符重载为对象之间的赋值和运算等操作提供了更加自然的表达方式。总的来说,从汇编语言到C语言,再由C语言到C++语言的开发过程是一个逐渐由类似机器的思考方式到类似人类的思考方式的过程,随着越来越多的细节被抽象、封装和隐藏,程序员所要思考的任务越来越趋近于计算本身,开发时速度受到底层结构的影响越来越少。
4.2软件运行速度
用C/C++语言编写的程序需要经过编译转换成汇编代码。即使经过多年的优化,编译器仍不能百分之百地准确理解程序员的编程意图。编译器对优化的态度是保守的,即如果不能大概率确保程序运行的结果不变,则不会进行优化,以避免可能因优化带来的正确性降低。这个特性意味着很多执行效率明显偏低的代码都不会被编译器优化。如果能够直接编写汇编语言程序,由于编写的过程中会对寄存器占用等底层细节更加关注,编写的程序往往能够估计到这类性能方面的提升空间,从而及时进行优化。
当然,这个问题的答案也不能一概而论。如果程序员水平尚待提高,那么选择让编译器自动优化而非手动编写优化过后的汇编代码无疑是更加明智的。但在程序员技术水平较高的情况下,直接编写汇编代码确实可以使软件运行速度得到提升。
4.3 CPU新特性支持程度
由于汇编代码直接面向指令集,于是使用汇编代码编写程序毫无疑问是使用CPU新特性的最方便方法。但是CPU生产厂家有可能会同时提供一些包装好的函数,用这些函数可以直接执行某些汇编指令,这也是一种应用CPU新特性的办法。此外,C/C++语言可以内置汇编代码,为直接面向底层开发提供了可能。
4.4软件可移植性
汇编代码只面向特定指令集,不同厂商的处理器甚至同厂商不同版本的处理器之间都可能发生指令集不兼容的情况。这些情况都会导致同一段汇编代码无法在另一台机器上执行。而C语言编译时使用的编译器通常都是针对各种不同处理器设计的,同一段C代码可以较为方便地移植到不同架构的计算机上。
4.5综合分析
汇编语言和C/C++语言各有优点。汇编语言直接面向底层,在代码编写合理的情况下汇编语言由于可以直接应用处理器的各种特性,可以使程序达到较高的性能。C/C++语言无法直接访问底层,需要借助编译器将源代码转化成汇编代码,而通常编译器的优化效果无法达到极致,所以会带来一定性能上的损失。但正是由于C/C++语言将底层机制抽象封装,使得在使用C/C++语言编写程序时无需过多注意底层细节,从而可以将精力更多投入在设计算法本身上。在编写程序时,要根据具体情况选用最合适的语言。
参考文献
[1] Bryant, O’Hallaron. 深入理解计算机系统[M]. 北京:机械工业出版社,2015.
[2] www.baidu.com

浙公网安备 33010602011771号