在这个小节中,谭老师列出了C语言中比较简单的三个小程序,分别实现了简单的输出、简单的加法运算,以及数据的输入输出和对数据的处理(比较获得两个数据中较大的一个)。虽然谭老师对这些程序作了详细的解释,但是我们心中可能还是有很多疑问:一个C语言程序为什么从main()函数开始?它到底是如何执行的?要想获得自由,我们必须知道事情的真相。我们现在就来学一次庖丁解牛,将一个C语言程序分解开,看看它背后隐藏的秘密。
在书中,我们首先接触到的最简单的C语言程序是:
- #include <stdio.h>
- 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语言程序的一生。当我们启动一个程序后,操作系统会创建一个新的进程来执行这个程序。所谓进程,就是应用程序的一个实例。操作系统创建进程的时候,会为其分配一定的内存空间(默认堆),作为其私有的虚拟地址空间。通常,一个应用程序的执行对应于一个进程,进程负责管理这个程序运行时的一切事物,例如资源的分配与调度等等。但是,作为程序执行的调度者,它并不负责程序的执行,具体的执行工作是由它创建的线程来完成的。每个进程都有一个主线程,如果是多线程应用程序,还可以有多个辅助线程。线程并不拥有资源(它使用的是它所属进程的资源),但是它拥有自己的执行入口、执行的顺序系列和一个终点。一个进程的内存分布如下图所示:
![]() |
程序的进程与线程 |
- // main()函数入口地址
- @ILT+0(_main):
- 00401005 jmp main (00401010)
- // …
- --- e:\sourcecode\clan\clan.c ---------------------------------------------------
- 1: #include <stdio.h>
- 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语言源代码转变成可以执行的汇编代码或目标机器代码,这些复杂的事情就让任劳任怨的编译器去完成吧。