C编译器剖析_1.5 结合C语言来学汇编_指针、数组和结构体

时间:2021-10-16 01:26:31

    让我们再来看一份C代码,及其经UCC编译器编译后产生的主要汇编代码,如图1.33所示,其中包含了数组、指针和结构体。

C编译器剖析_1.5 结合C语言来学汇编_指针、数组和结构体

图1.33 数组、指针和结构体

     按照C的语义,图1.33第9行的C代码是对局部数组number的初始化,需要把number[0]初始化为2015,而数组中的其他元素皆被初始化为0。UCC编译器采取的翻译方法是:先调用memset函数来把数组number所占的内存空间清0,然后再把number[0]设为2015,如图1.33的第17至24行所示。C库函数memset的API如下所示:

void *memset(void *s, int c, size_t n);

    按照“C调用约定 Calling Convention”,参数需要从右到左入栈,即先要把n入栈,再把c入栈,之后才是目标地址s。第17行的$64对应参数n,表示数组number所占的内存大小为64字节,因为每个int占4字节,而number共有16个整数;而第18行的$0对应参数c,表示我们要把指针s所指的大小为n字节的内存全部设为0;第19行用于取内存单元-64(%ebp)的地址,第20行把这个地址入栈,即对应参数s,而-64(%ebp)所对应的内存空间正是UCC编译器为局部数组number在栈中分配的存储空间。第21行完成了对库函数memset的调用,第22行用于把刚才入栈的3个参数出栈,此处每个参数正好都占4个字节,所以总共占了12字节的栈空间。按照“C调用约定”,这个退栈操作需要由主调函数来完成。实际上,第17至22行也演示了如何在汇编语言中调用C库函数。

    而与第10行” ptr = &number[1];”对应的汇编代码为第25至27行,第25行把number[0]的地址存到寄存器eax中,因为number[1]与number[0]之间隔了一个占4字节的整数,所以第26行对eax进行了加4的操作,这样eax中的内容就是number[1]的地址,第27行把这个地址存到了全局变量ptr所对应的内存单元中。

    与第11行” *ptr = 2016;”对应的汇编代码在图1.33的第29至30行,第29行通过movl指令把内存单元ptr中存放的内容传送到寄存器ecx中,这样ecx中存放的数据就是number[1]的地址,第30行” movl $2016, (%ecx)”告诉汇编器,把ecx当作一个指针,把立即数2016存到ecx所指向的内存单元中,此指令执行完后,ecx寄存器没有发生变化,但ecx所指向的number[1]内存单元被设置为2016。第32至35行演示了“如何在汇编代码中,给结构体对象的成员域赋值”,在C语言中我们用的是” dt.year”,但在汇编中我们可以看到,与之对应的是第35行的”dt+8”,结构体成员域的名字year已经被偏移值offset所取代,此处偏移为8。由第2至第6行可知,在成员year的前面,还有date和month这两个成员,共占去了8字节,如果从0开始计数,则year正好处于第8个字节处。

    让我们再看一份“通过函数指针来间接调用函数等语句”的C程序,如图1.34所示。第19至22行的汇编代码完成了第11行”dt1 = dt2;”的功能,此处主要的目的是介绍寄存器esi和edi在指令rep movsb中的作用。第19到22行的汇编代码其实相当于如下C函数的功能,寄存器esi相当于参数src,寄存器名si是SourceIndex的缩写,起源地址的作用;而edi相当于参数dest,寄存器名di是Destination Index的缩写,起“目的地址”的作用;寄存器ecx则相当于参数n,表示要复制的字节数;真正进行复制操作的是第22行的汇编代码。结构体对象dt1由30个整数构成,每个整数4字节,所以共占了120字节,所以我们在第21行看到的立即数$120。

void *memcpy(void *dest, const void *src, size_t n);

 C编译器剖析_1.5 结合C语言来学汇编_指针、数组和结构体

 

图1.34     Function Call

   一般地,在C语言中,我们可以用这样的套路来理解类型为T的变量名var:变量var占一块大小为sizeof(T)的内存,在C程序中“直接使用变量名var”代表了C程序员想要读取或改变这块内存中的内容;在C程序中“在变量var前加一个&,即&var”代表了程序员想要读取这块内存对应的地址。

T  var;

    例如,对以下代码而言,在语句”a = b;”中,我们使用了变量名a和b,我们想表达的是把b的内容复制到a中,即读取变量b的内容,用来改变变量a的内容。在语句”ptr1 = &a;”中,”&a”表达了C程序员想要读取变量a的地址,”ptr1”位于赋值号的左侧,意味着我们想要“改变”ptr1变量中的内容。在语句”ptr2 = ptr1;”,我们仍可以沿用这个套路,使用变量名ptr1和ptr2,我们想表达的是把ptr1的内容复制到ptr2中,即读取变量ptr1的内容,用之改变变量ptr2的内容。

int a,b;

int * ptr1, * ptr2;

a = b;

ptr1 = &a;

ptr2 = ptr1;

    但这个套路也有一些例外,比如函数名和数组名。按上面的套路,通过“函数名f”,我们想访问的就是函数所占内存中的内容,这个“内容”当然就是代码区中的代码了,CPU要执行这个函数,实际上只要知道这块代码区的首地址就可以了。基于这样的出发点,让函数名代表函数所占内存的首地址,就合情合理了。而”&f”仍可沿用之前的套路来理解。所以图1.34的第8行可改为如下语句也是可以的。

FUNC * func = f;

    在后续分析UCC编译器源代码时,我们会看到编译器对函数名的特殊处理。当然对函数名f而言,sizeof(f)在C89标准中是非法的。在编译时,sizeof表达式是要作为一个常量来处理的,但很多时侯,我们在编译一个.c文件时,可能只在头文件中看到了函数f的声明,其函数定义根本就还没看到。在只知道函数接口,而不知道其定义的情况下,是无法知道函数要在代码区中占多大内存的。

    而数组名是另外一个特例,按前面的套路,数组名本应代表数组中的内容,这其实也是个不错的选择。因为这意味着,我们可以使用如下语句来实现两个数组对象的复制。

int arr1[1000];

int arr2[1000];

arr1 = arr2;

    但考虑到数组可能作为函数参数时的情况,若让数组名arr1来代表数组中的内容,则以下的函数调用中,形参arr就要占4000字节的栈空间。所以,让数组名arr1代表数组的首地址,就成了又一个合理的选择。而”&arr1”仍可沿用之前的套路来理解,这就有了”arr1”和”&arr1”代表的都是数组的首地址,但在C的类型系统中,两者却有不同的类型,arr1实际上代表的是数组第0个元素的地址,即&arr1[0],类型为”int * ”,而”&arr1”的类型实际上是” ARRAY1000  *”。

typedef  int  ARRAY1000[1000];

void  f(int arr[1000]);

f(arr1);

    最明显的区别是,(arr1+1)和 (&arr+1)这两个表达式的值差得非常之大。按照C语言指针运算的语义,对指向T类型的指针ptr而言,(ptr+1)代表的是“指向下一个类型为T的对象”,即(ptr)和(ptr+1)之间相差sizeof(T)。这里,sizeof(int)是4,而sizeof(ARRAY1000)是4000。

T  *  ptr;

    我们看到,图1.34第12行和第13行所对应的汇编代码在第23和24行,都是”call *func”,汇编指令call用于调用函数,”call  * func”意味着函数地址存在于变量func的内容中,全局变量func的地址在编译连接后就可确定,但变量func的内容是可能发生变化的,这就需要在程序运行时从变量func对应的内存单元中取出其内容,即其所保存的函数地址。而第25行的”call  f”中的函数地址f在编译连接时就已确定,在汇编指令中,f确实上相当于一个地址常量。

    到此,我们结合一些简单的C程序,把ucc\ucl\x86Linux.tpl中用到的有代表性的汇编代码作了介绍。回顾整个第1章,我们从语言、递归和文法入手,通过一个简单的例子ucc\examples\sc,介绍了如何由文法来编写语法分析器,如何在生成的语法树上进行“中间代码生成”,后续章节剖析的UCC编译器,还需要在中间代码的基础上产生汇编代码,所以在第1.5节,我们还介绍了UCC编译器所用到的主要汇编代码。

    经过第1章的热身运动,我们准备开始正式踏上UCC编译器剖析的漫漫长征路。所谓“工欲善其事,必先利其器”,在Ubuntu环境下可先安装wine,然后安装Source Insight等Windows平台的代码阅读工具。这种环境可能要被Linux的忠实爱好者鄙视,但从简单实用的角度出发,不失为一种不错的选择。边阅读ucc的源代码,边加上自己的一些注释和体会,看得兴起之时,再加上一些用于调试的代码,例如使用ucc\ucl\ucl.h的PRINT_DEBUG_INFO等宏定义来输出,来观察一下编译器的运行结果是否如我们所预期。如果感觉自己似乎发现潜在的Bug时,就写一些简单的测试代码用来触发UCC编译器的Bug。慢慢的,我们就会进入正轨了。世界上不存在完全健康的人,同样道理,稍复杂点的软件,或多或少都存在Bug。把软件产品改到没有bug再发布,几乎是不可能的。不然,Windows也不会经常需要打补丁了。诚如*作者龙应台所言,“所谓了解,就是知道对方心灵最深的地方的痛处,痛在哪里”。相信随着阅读的深入,你会发现一些UCC编译器的Bug。目录ucc\examples中包含了已发现的一些Bug,例如在文件ucc\examples\array\array.c的开头,包含了以下注释,其含义是array.c的代码是针对ucc编译器exprchk.c中的函数CheckPostfixExpression()。

/*****************************************************

         seeCheckPostfixExpression(AstExpression expr) in exprchk.c

 *****************************************************/

 

 

     最后,让我们以下面这段代码来结束第一章。再好的书,再好的资料,最多只能帮助我们少走弯路,让我们在无助时有个依靠。但是“有些事,只能一个人做。有些关,只能一个人过。有些路啊,只能一个人走”。

while(还没读懂ucc\examples\sc){

         阅读并上机运行ucc\examples\sc的代码

}