1.4.A 最简单的C语言程序背后的故事--它的汇编代码是如何被执行的
在这个小节中,谭老师列出了C语言中比较简单的三个小程序,分别实现了简单的输出、简单的加法运算,以及数据的输入输出和对数据的处理(比较获得两个数据中较大的一个)。虽然谭老师对这些程序作了详细的解释,但是我们心中可能还是有很多疑问:一个C语言程序为什么从main()函数开始?它到底是如何执行的?要想获得自由,我们必须知道事情的真相。我们现在就来学一次庖丁解牛,将一个C语言程序分解开,看看它背后隐藏的秘密。
在书中,我们首先接触到的最简单的C语言程序是:#include
int main()
{
printf("This is a C program. \n");
return 0;
}
这个C语言程序只有短短的7行代码,实现的也只是简单地向屏幕输出一个字符串,但是别小看这个简单的C语言程序,在它的背后,也有着同样精彩的故事。
在Visual C++ 6.0调试模式下的汇编视图(Disassembly)中,我们可以清楚地看到每个C语言程序背后的故事。在汇编视图中,我们可以看到C语言程序中的各条语句所对应的汇编代码。这下,各条语句做了什么事情、各个功能是如何实现的,都一目了然了。C语言程序语句所对应的汇编语句,反映了C语言程序语句操作硬件的实质,也就是C语言程序背后的故事。这个号称最简单的程序虽然只是简单地输出一个字符串,但是当我们把这个程序拆解开,却可以发现它背后做了很多事情。在汇编视图下这个最简单的程序是这样的(汇编代码太长了,我只保留其中的关键操作):// 每一个程序的入口地址mainCRTStartup
mainCRTStartup:
00401120 push ebp
00401121 mov ebp,esp
// …
// 调用GetVersion()函数,获得操作系统版本
00401146 call dword ptr [__imp__GetVersion@0 (0042513c)]
// …
// 进行堆的初始化
0040119E call _heap_init (00403c40)
// …
// 向程序传递参数
004011D5 call _setargv (004031a0)
004011DA call _setenvp (00403050)
004011DF call _cinit (00402c70)
// …
// 开始进入main函数的入口地址执行main函数
00401204 call @ILT+0(_main) (00401005)
// …
// 退出整个程序的执行
00401213 call exit (00402cb0)
// …
这段汇编代码,几乎就是一个C语言程序的一生。当我们启动一个程序后,操作系统会创建一个新的进程来执行这个程序。所谓进程,就是应用程序的一个实例。操作系统创建进程的时候,会为其分配一定的内存空间(默认堆),作为其私有的虚拟地址空间。通常,一个应用程序的执行对应于一个进程,进程负责管理这个程序运行时的一切事物,例如资源的分配与调度等等。但是,作为程序执行的调度者,它并不负责程序的执行,具体的执行工作是由它创建的线程来完成的。每个进程都有一个主线程,如果是多线程应用程序,还可以有多个辅助线程。线程并不拥有资源(它使用的是它所属进程的资源),但是它拥有自己的执行入口、执行的顺序系列和一个终点。一个进程的内存分布如下图所示:
程序的进程与线程当进程的主线程被创建之后,它会首先寻找程序当中的入口地址。我们知道,程序实质上就是一系列计算机指令,程序的入口地址代表了从哪一条指令开始执行。通常,每个程序中都有mainCRTStartup这样一个地址,这个默认的入口函数地址是编译器插入到程序中的(其中完成了一些必要的初始化和清理工作)。主线程就是找到这个地址并从这里开始向下逐条执行程序当中的指令。在这个函数执行的时候,首先会执行一些初始化工作,例如获得操作系统的信息、对堆进行初始化以及完成程序参数的传递等等。然后,就是最关键的对主函数的调用,一句"call @ILT+0(_main)"就是跳转到main()函数的入口地址,开始进入main()函数的执行了。mainCRTStartup所做的事情我们无法控制,而main()函数就是我们的一亩三分地,可以自由发挥了。接下来我们来看看main()函数到底是如何执行的。
// main()函数入口地址
@ILT+0(_main):
00401005 jmp main (00401010)
// …
--- e:\sourcecode\clan\clan.c ---------------------------------------------------
1: #include
2: int main()
3: {
00401010 push ebp
00401011 mov ebp,esp
00401013 sub esp,40h
00401016 push ebx
00401017 push esi
00401018 push edi
00401019 lea edi,[ebp-40h]
0040101C mov ecx,10h
00401021 mov eax,0CCCCCCCCh
00401026 rep stos dword ptr [edi]
4: printf("This is a C program. \n");
00401028 push offset string "This is a C program. \n" (00420f7c)
0040102D call printf (00401060)
00401032 add esp,4
5:
6: return 0;
00401035 xor eax,eax
7: }
00401037 pop edi
00401038 pop esi
00401039 pop ebx
0040103A add esp,40h
0040103D cmp ebp,esp
0040103F call __chkesp (004010e0)
00401044 mov esp,ebp
00401046 pop ebp
00401047 ret
--- No source file ----
从汇编代码中我们可以看到,主函数的执行,也不过是对于一些寄存器的操作和对库函数的调用而已。例如,在main()函数的第一句就是用"push ebp"保存当前地址。在汇编代码中,ebp代表了当前地址。为什么在进入main()函数后的第一件事不是我们在C语言程序代码中看到的输出一个字符串,而是保存当前地址呢?实际上,我们从C语言程序代码中看到的只是我们对于要实现的功能的描述,而真正地要实现这些功能,C语言程序背后所对应的汇编代码还要为我们完成很多事情。这里的"push ebp"保存当前地址,就是为了让这个main()函数执行完毕后可以顺利返回(也就相当于在出发的地方插上一个标签,好让我们可以找到回来时的路)。除了对于寄存器的操作(push、move以及pop等汇编指令)之外,汇编代码中更重要的是对其他函数的调用,这都是通过call指令来实现的。例如,"call printf (00401060)"这个call指令就是调用printf函数,进入printf函数的执行以输出字符串。因为printf函数是由C语言函数库提供的一个函数,我们这里看不到它的具体代码,但是其内部与上面的main()函数都是相似的。
虽然我们的源代码中只有一行代码,但是编译后的程序在背后却做了很多事情。而正是因为汇编代码太过繁琐,我们才更加钟爱简洁易懂的C语言程序代码。而至于如何将C语言源代码转变成可以执行的汇编代码或目标机器代码,这些复杂的事情就让任劳任怨的编译器去完成吧。
【责任编辑:book TEL:(010)68476606】
点赞 0