编译系统透视:图解编译原理
上QQ阅读APP看书,第一时间看更新

1.2 更为复杂C程序的运行时结构

在实际编程过程中会遇到更为复杂的问题。要解决这样的问题,更加依赖对运行时结构的了解。下面我们来看一个比较复杂的案例,案例的两个程序分别如下:

 #include <stdio.h>                                        #include <stdio.h>
#include <string.h>                                        #include <string.h>
void fun1()                                                void fun1()
{                                                          {
   int m =10;                                                     int m =10;
   char num[4];                                                   char hum[4];
   strcpy(num,"bbbbbbbbbbbbb\x0F\x10\x40\x00");                   strcpy(num, "bbbb");
)                                                          }
void fun2()                                                void fun2()
(                                                          {
   printf("You were attacked!!!\n");                              printf("You were attacked!!!\n");
}                                                          }
int main()                                                 int main()
(                                                          {
   fun1();                                                       fun1 ();
   return 0;                                                     return 0;
}                                                          }

这个案例中的两个程序在代码上只有微小的差别,但执行结果却不同,尤其是左边的程序,执行结果如下所示:

#include <stdio.h>
#include <string.h>
void fun1()
{
   int m =10;
   char num[4];
   strcpy(num,"bbbbbbbbbbbbb\x0F\x10\x40\x00");
)
void fun2()
(
   printf("You were attacked!!!\n");
}
int main()
(
   fun1();
   return 0;
}
    You were attacked!!!

这些字符显然是fun2函数被调用时才会输出的,但fun2这个函数在本程序中没有被调用过,这样的输出结果显得有些不可思议了,程序执行时到底发生了什么呢?下面我们一步一步地对比分析这个案例。我们先来看main函数调用fun1函数时的情景,fun1函数执行后的返回地址被压入栈中,跳转到fun1函数执行,此时两边程序的执行没有差异,情景如图1-29所示。

图1-29 两个程序都跳转到fun1后的整体比较

之后保存了main函数栈底的地址值,ebp被腾出来,指向fun1函数的栈底,此时两边也没有差异。情景如图1-30所示。

图1-30 两个程序都保存了main栈底地址值并准备为fun1建栈

m入栈,初始化为10,为num数组开辟了栈空间,此时仍然没有差异,情景如图1-31所示。

图1-31 两个程序都为fun1函数的局部变量开辟了栈空间

下面差异产生了。调用strcpy函数,执行的目的是把指定的字符串拷贝到num数组中,指定多少,拷贝多少。我们先来看右边的程序。该程序会把指定的字符串拷贝给num数组,其长度刚好填满num数组,情景如图1-32所示。

图1-32 右边程序对数组初始化

再看左边程序,指定的字符串长度已经超出了num数组的长度,所以在拷贝的时候,会把栈中前面的数据覆盖掉,包括num的数组、main函数栈底地址值直至fun1函数执行后的返回地址,全部被覆盖,情景如图1-33所示。

图1-33 左边程序对数组初始化后覆盖了其他数据

覆盖的结果使得fun1函数在返回并恢复现场时出现了问题。

我们先来看右边的程序,跳转回main函数,正常恢复,情景如图1-34所示。

图1-34 右边程序返回到main函数继续执行

再看左边的程序,栈底地址值被覆盖了,ebp会得到一个乱值,不再指向main函数的栈底,另外,由于fun1函数执行后返回地址已经被覆盖,而且覆盖的数值正好是fun2函数的起始地址,将这个数据传递给eip,那么eip自然跳转到fun2函数执行,相当于调用了fun2函数,也就输出了fun2函数的打印信息。同时,ebp成了乱值,程序最终将产生段错误,情景如图1-35所示。

图1-35 左边程序栈中数据被覆盖后导致的后果

在C语言中,栈的方向是从高地址向低地址延伸,而数组中数据在栈中的存储方向与此正好相反。字符串拷贝等数组操作是不对数据长度做审核的,如果实际的数据长度超过了栈中预留的空间,就会将栈中其他数据覆盖,这种现象被称为“栈溢出”。栈溢出可能导致一个不可预期的错误,也可能导致一个精心策划的执行流程发生改变。可见,是否能够对自己所写程序的运行时状态做到心中有数,是能否写出高质量、安全代码的前提保证。

以上两节介绍的运行时结构都是由C程序所对应的指令,在内存中执行,驱动数据变化而产生的。C程序只有经过编译,才能生成目标代码。目标代码将与指令和全局数据一一对应。编译的最终目标就是能让C程序的设计意图体现在运行时结构中,这也使得编译的每个阶段的中心任务都要为形成运行时结构着想。下一节我们将概述编译的过程。