你好,C++(4)2.1.3 我的父亲母亲:编译器和链接器 2.1.4 C++程序执行背后的故事
2.1.3 我的父亲母亲:编译器和链接器
从表面上看,我是由Visual Studio创建的,而实际上,真正负责编译源代码创建生成可执行程序HelloWorld.exe的却是Visual Studio中集成的C++编译器cl.exe和链接器link.exe。他们二老,才是我的亲生爹妈。
为了便于人们的编写、阅读和维护,我们的源文件是使用C++这种人们可以理解的高级程序设计语言编写的。然而,计算机却并不理解这种高级语言,也就无法直接执行高级语言编写而成的源文件。所以,这里就需要一个翻译的工作,将源文件中人们可以理解的C++高级语言翻译成机器可以理解执行的机器语言。我老爸编译器实际上是个翻译官,他的工作就是将用C++这种高级语言编写的源文件(.cpp)翻译成用计算机可以看懂的机器语言表示的目标文件(.obj),大家通常将这一过程称为编译。
在Visual Studio中,我老爸的名字是cl.exe,大家可以在开始菜单中找到“VS2012 开发人员命令提示”,然后在打开的DOS窗口中通过cl命令请他老人家出手,将一个cpp源文件编译成相应的obj目标文件。比如,要想让我爸将我的源文件HelloWorld.cpp编译成对应的目标文件HelloWorld.obj,可以使用下面的命令:
cl /c /EHsc HelloWorld.cpp
其中,cl是调用编译器的指令,其后的选项用于指定编译器的编译行为。这里的“/c”表示只编译不链接;“/EHsc”指定编译器使用何种异常处理模型;最后一个选项HelloWorld.cpp则是即将要编译的C++源文件。源文件HelloWorld.cpp经过我爸编译器的编译后,得到的还只是一个无法直接执行的目标文件HelloWorld.obj,还需要我妈链接器将这个目标文件和Visual C++所提供的标准库目标文件(比如,libcpmt.lib)整合成最终的可执行文件(从标准库目标文件中查找程序目标文件所用到的外部函数等符号,然后填写到程序目标文件以生成最终的可执行文件),这一过程就被称之为链接。在“VS2012 开发人员命令提示”中,大家可以用如下的命令请我妈链接器link.exe来完成这一链接过程:
link HelloWorld.obj
当然,整个编译链接的工作,也可以由我爸编译器cl.exe一个人完成:
cl /EHsc HelloWorld.cpp
经过我爸我妈的编译链接过程,我从一个源文件(HelloWorld.cpp)变成了一个可执行文件(HelloWorld.exe),我就这样哇哇坠地了。整个过程,如图2-6所示:
图2-6 编译链接过程
2.1.4 C++程序的执行过程
一旦生成可执行文件,就可以给操作系统下达指令让文件开始执行。一个程序的执行是从其主函数开始的。但是在进入主函数开始执行之前,操作系统会帮我们做很多准备工作。比如,当操作系统接到执行某个程序的指令后,它首先要创建相应的进程并分配私有的进程空间;然后加载器会把可执行文件的数据段和代码段映射到进程的虚拟内存空间中;操作系统接着会初始化程序中定义的全局变量等。做好这些准备工作,程序就可以进入主函数开始执行了。
进入主函数后,程序会按照源代码给我制定的人生规划,一条语句一条语句地往下执行,一步一步地往下走。大家一定还记得,我的源代码是这样的:
int main() { // 在屏幕上输出“Hello World!”字符串 cout<<"Hello World!"<<endl; return 0; }
从这里可以看到,进入主函数后,我的第一条语句就是:
cout<<"Hello World!"<<endl;
这条语句的意思是让我在DOS窗口中显示“Hello World!”这样一串文字,于是我便开始控制DOS窗口,在其中显示这串文字,完成程序员通过这行代码交给我的任务。
接下来的一条语句是:
return 0;
这条简短的语句宣告了我人生历程的结束。它表示主函数的结束,整个程序执行完毕。图2-7所示的是我短暂而光辉的一生!
图2-7 Hello World程序短暂而光辉的一生
知道更多:C++程序执行背后的故事
在上面的例子中,我们看到一个C++程序的执行过程,是从main()函数开始逐条语句往下执行的。这个过程看起来非常简单,但在每条语句的背后,都还有着更多的故事。
在Visual Studio调试模式下的反汇编视图(在调试模式下通过Alt+8快捷键打开)中,我们可以看到C++程序中的各条语句所对应的汇编代码。这下,程序中各条语句做了什么事情、各个功能是如何实现的,都一目了然了。HelloWorld程序虽然只是简单地输出一个字符串,但是当我们把这个程序拆解开,却可以发现它背后做了很多事情。在汇编视图下的HelloWorld程序如下(汇编代码太长,我们只保留其中的关键操作):
#include <iostream> using namespace std; int main() { // 完成准备工作 00DC4EC0 push ebp 00DC4EC1 mov ebp,esp 00DC4EC3 sub esp,0C0h // … 00DC4EDC rep stos dword ptr es:[edi] // 完成任务 // 在屏幕输出“Hello World!”字符串 cout<<"Hello World!"<<endl; 00DC4EDE mov esi,esp 00DC4EE0 mov eax,dword ptr ds:[00DD031Ch] 00DC4EE5 push eax 00DC4EE6 push 0DCCC70h 00DC4EEB mov ecx,dword ptr ds:[0DD0318h] 00DC4EF1 push ecx // 调用标准库中的操作符来完成任务 00DC4EF2 call std::operator<<<std::char_traits<char> > (0DC12A3h) 00DC4EF7 add esp,8 00DC4EFA mov ecx,eax 00DC4EFC call dword ptr ds:[0DD0324h] 00DC4F02 cmp esi,esp 00DC4F04 call __RTC_CheckEsp (0DC132Ah) return 0; 00DC4F09 xor eax,eax }
当我们启动一个程序后,操作系统会创建一个新的进程来执行这个程序。所谓进程,就是应用程序的一个实例。操作系统创建进程的时候,会为其分配一定的内存空间(默认堆),作为其私有的虚拟地址空间。通常,一个应用程序的执行对应于一个进程,进程负责管理这个程序运行时的一切事物,例如资源的分配与调度等等。但是,作为程序执行的调度者,它并不负责程序的执行,具体的执行工作,则是由它所创建的线程来完成的。每个进程都有一个主线程,如果是多线程应用程序,还可以有多个辅助线程。线程并不拥有资源(它使用的是它所属进程的资源),但是它拥有自己的执行入口、执行的顺序系列和一个执行终点。
在这里,当负责执行这个程序的主线程被创建以后,它就会进入main()函数开始执行。它首先会执行一些初始化工作,例如保存现场环境、对堆进行初始化以及完成程序参数的传递等等,然后才是执行具体的程序代码。虽然C++程序代码只有一行,但是在汇编视图下,却被分解成了多个步骤来完成。主函数的执行,也不过是对于一些寄存器的操作和对库函数的调用而已。例如,在main()函数的第一句就是用“push ebp”保存当前地址(在汇编代码中,ebp代表了当前地址)。这里我们一定会感到奇怪,为什么在进入main()函数后的第一件事不是在C++程序代码中看到的输出一个字符串,而是保存当前地址呢?实际上,我们从程序代码中所看到的只是我们对于要实现的功能的描述,而真正地要实现这些功能,C++程序还要在背后为我们完成很多事情。这里的“push ebp”保存当前地址,就是为了让这个main()函数在执行完毕后,可以顺利返回原来的地址继续往下执行。除了对于寄存器的操作(push、mov以及pop等汇编指令)之外,汇编代码中更重要的是通过“call”指令完成的对其他函数的调用。例如,“call __RTC_CheckEsp (0DC132Ah)”这个call指令就是调用__RTC_CheckEsp()函数(由编译器在调试版本中添加)在程序执行完毕后检查堆栈是否平衡。
在汇编视图下,我们可以看到每一条C++语句后面都有故事。只有了解了每一条语句背后的故事,才能真正地理解这一条语句。这同样也告诉我们,如果我们发现某条语句的行为出现了异常而我们又无法从代码层面找到原因,我们就需要从这条语句的背后寻找真正的原因。
2.1.5 程序的两大任务:描述数据与处理数据
人们编写程序的目的,是为了用程序解决现实世界中的问题。人们观察发现,所有这些问题都是以数据作为输入,然后对这些数据进行处理,最后获得结果数据而使问题得到解决的。所以,既然我是用来帮助人们解决问题的,那么我的任务自然也就离不开对数据的描述和对数据的处理。如图2-8所示。
人们用公式给我下了一个定义:
数据 + 算法 = 程序
其中,数据可以看成是对现实世界中的各个事物的抽象和描述。例如,在C++程序中,我们将现实世界中的各种数据抽象成各种数据类型,比如我们将整数抽象成int类幸,将小数抽象成double类型等。然后反过来用这些类型定义的变量来描述我们在生活中遇到的某个具体的数据。比如,用int类型定义的变量nWidth来描述某个矩形的宽度;用string类型定义的变量strName来描述某个人的名字;我们甚至还可以创建自定义的数据类型来描述更加复杂的事物,比如我们可以创建一个Human数据类型来抽象“人”这个复杂事物,然后用它定义一个变量来描述某个具体的人。总之,用数据对现实世界中的事物进行抽象和描述,是我的第一个任务。
图2-8 我的人生目的
用数据对现实世界进行描述并不是我的最终目的,我的最终目的是对这些数据进行处理,从而获得想要的结果数据。比如,我们用nWidth和nHeight描述了一个矩形的宽和高,然而,这并不是我们想要的结果数据,我们想要的是矩形的面积。所以,我们还必须对nWidth和nHeight这两个数据进行处理,用“*”符号计算两个数的乘积,才能获得我们想要的矩形面积。对数据处理过程的抽象,人们称之为算法。而我的第二个任务,就是描述和表达算法,对数据进行处理以获得最终结果。
知道更多:数据结构+算法=程序
“数据+算法=程序”这个等式是由著名的“数据结构+算法=程序”变形而的。它由Pascal之父、结构化程序设计的先驱Niklaus Wirth先生最先提出,它抽象地概括了一个程序的最核心内容是其中的用于表达数据的数据结构和对数据进行处理的算法。而我们在这里提出的“数据+算法=程序”,则是具体地描述了一个程序由它要处理的数据以及对数据进行具体处理的算法共同组成。两个等式都是正确的,只是描述程序的角度不同而已。
数据和算法伴随我的一生。在小小的HelloWorld.exe中,也同样有数据和算法的存在。例如,向屏幕输出“Hello World!”的语句:
cout<<"Hello World!"<<endl;
其中,“Hello World!”是一个要向屏幕输出的字符串数据。整个语句则代表了对这个字符串数据的处理:将字符串显示到屏幕上。数据和算法总是这样形影不离,成为我终身要完成的两大任务。