C编译器剖析_5.2.3 中间代码生成及优化_通过“偏移”访问数组元素和结构体成员

第5.2.3节  通过“偏移”访问数组元素和结构体成员

    在上一节小节,我们举例介绍了对“数组元素和结构体成员”的访问,我们采用的是“基地址+偏移”的模式来计算其内存单元的地址。对于数组元素arr2[i][2]来说,数组索引值i为变量,对应的地址要表达为“基地址+常量偏移+非常量偏移”;对于结构体成员dt.b来说,其地址可表达为“基地址+常量偏移”。下面,我们还是结合一个简单的例子来说明相关概念,如图5.2.9所示,第1至14行给出了一个简单的C程序,第16至30为UCC编译器生成的中间代码,第33至46行是UCC编译器生成的汇编代码,而第49至58则是GCC编译器生成的汇编代码。由于第10行的arr[i]含有“非常量偏移”,C编译器需要生成代码来计算这些偏移,再与数组首地址进行相加。在汇编代码中,用于寻址的指令相当灵活,对于“arr[i]=30;”来说,GCC所生成的代码就与UCC不同,如图5.2.9第50至51行所示。UCC编译器采用的是形如第34至38行的指令,第34至35行用于计算“非常量偏移”,即i*4,通过把i左移2位来实现,第36行通过leal指令来取数组arr的首地址,第37行进行把基地址和偏移相加,所得结果存于寄存器ecx,之后通过第38行的寄存器间接寻址就可完成赋值。第19行的中间代码“t1:&arr”对应第36行的汇编代码 “leal arr,%ecx”。我们还发现,第11行的C代码“arr[2] = 50;”对应的汇编代码为第40行的“movl  $50,arr+8”。在汇编代码中出现的符号arr可看成是一个地址常量,该movl指令把常数50送到(arr+8)所对应的内存单元中。


图5.2.9 对数组元素的寻址

    在中间代码层次,一个符号对象struct  symbol(或其“子类”对象,例如struct variableSymbol)可作为三地址码中的目的操作数或源操作数。UCC编译器在为“抽象语法树上的arr结点”生成中间代码时,并没有考虑其所处的上下文,为了能生成形如第36行的汇编指令,UCC编译器需要产生一条形如第19行的中间代码“t1:&arr;”,其中的临时变量t1存放了数组arr的首地址。虽然数组中的内容可能会被修改,数组arr的地址在数组生命周期内并不会发生变化,所以&arr可以当作公共子表达式来使用,当我们在第11行遇到另一棵抽象语法子树上的arr结点时,我们就可重用临时变量t1中的值。如果t1对应的寄存器为eax,在汇编层次,我们可为第11行的C语句“arr[2]=50;”生成以下汇编代码。

leal  arr, %eax;      //取数组arr的地址

addl  $8, %eax;       //arr[2]在数组arr中的偏移为常量8

movl  $50,(%eax);     //通过寄存器间接寻址来进行赋值

    这些汇编代码可以实现C语句“arr[2] = 50;”所要求的语义,但并不是很高效,我们可用图5.2.9第40行的“movl  $50,arr+8”来实现一样的功能。在知道基地址和偏移的前提下,我们可以通过UCC编译器中的函数Offset,来产生“访问数组元素或结构体成员”的中间代码,如图5.2.9第18至20行所示;而函数AddressOf则可以生成第19行的“t1:&arr;”的取地址指令。

    函数Offset的代码如图5.2.10所示,当C程序员访问数组元素或结构体成员时,第2行的参数addr是数组元素或结构体成员的基地址,参数voff是“非常量偏移VariableOffset”。当访问图5.2.9第12行的结构体成员dt.num时,由于结构体成员dt.num在结构体对象dt中的偏移是固定的,此时voff参数为NULL。但在访问dt.num[i]时,数组元素dt.num[i]在数组dt.num中的偏移为(i*4),这不是常量,此时voff不为NULL,而访问arr[i]时voff也不为NULL。第2行的另一个参数coff代表“常量偏移ConstOffset”,访问图5.2.9第11行的arr[2]时,coff的值为2*sizeof(int),即8。图5.2.10第3至8行的代码用于产生代码,进行基地址、非常量偏移和“常量偏移”这三者的加法运算,得到地址后,再由第7行的Deref进行“间接寻址操作”,这样我们就可以为“arr[i] = 30;”生成形如图5.2.9第18至21行的中间代码。当C程序员要访问arr[2]时,此时图5.2.10第8行注释中的t1就对应参数addr,voff为NULL,而coff的值为8,为了能产生形如图5.2.9第40行的汇编代码“movl  $50,arr+8”,而不是生成“leal  arr, %eax; addl  $8, %eax; movl  $50,(%eax);”这3条低效的代码,我们在第11行调用CreateOffset函数创建了一个新的符号对象,用来在中间代码层次表示形如arr[8]这样的符号。对于图5.2.10第13至14注释中所示的代码而言,ptr是指向int[4]数组的指针,C程序员通过(*ptr)[2]来访问数组元素时,UCC编译器会在语义检查CheckUnaryExpression时构造成一棵形如([] ([]  ptr  0) 8)的语法树,在翻译这个语法树时,我们会计算出基地址为ptr,而偏移为8,此时我们把这两者相加,再通过间接寻址才能访问到相应的数组元素,第15行调用的Deref完成此功能。如果我们把C程序员写的(*ptr)[2],错误地表示为中间代码层次的符号ptr[8],则最终生成的汇编代码会是“movl  $50,ptr+8”。假设数组arr的首地址是10000,而全局变量ptr的地址为20000,则变量ptr中的内容为10000,在此movl指令中,ptr是地址常数20000,该movl指令会把常数50传送到地址20008对应的内存单元。不过,按C的语义,(*ptr)[2]实际应访问C的数组元素arr[2],数组元素arr[2]的地址为10008,因此“movl  $50,ptr+8”是条错误的指令。所以,在中间代码层次,我们不可用符号ptr[8]来表示相应的数组元素(*ptr)[2],而是要生成的形如“t5 : ptr + 8;     *t5 = 60;”的代码,这些中间代码是通过图5.2.10第15行的函数Deref来产生,Deref是Dereference的缩写,表示“提领操作”,也有译为“解引用”,实际上进行的操作是“间接寻址”。


图5.2.10  Offset()

    当要访问结构体成员dt.num,或者要访问的数组元素不存在“非常量偏移”(例如arr[2])时,我们可用第18至39行的CreateOffset来为其创建一个符号对象,第18行的base代表基地址,第19行的coff代表“常量偏移”。如果偏移coff为0,比如当我们要初始化图5.2.10第21行注释中的局部变量d和dt时,第22行的条件就会成立,此时我们直接返回base即可。但是如果我们要访问的是dt.a时,按图5.2.9第4至7行的结构体定义,dt.a在对象dt中的偏移为0,但dt.a和dt的类型不一样,因此我们需要为dt.a创建一个新的符号对象,而不能使用和dt一样的符号对象,此时第22行的条件就不成立。第25行用于在堆空间中分配一个符号对象,第30行设该符号在C源代码中的坐标,第31行置标志位addreesed为1,表示该对象被进行过“取地址操作”(这样,数组元素和结构体成员作表达式中的操作数时,该表达式就不再被当作公共子表达式,我们在第5.2节介绍过相关概念),第32行设置该符号对象的类型为SK_Offset,第33行设置其类型,第34行保存其基地址对应的符号对象,第35行存放常量偏移,第36行用于生成形如“arr[8]”的符号名。当第18行的参数base本身就对应一个数组元素或者“结构体成员”时,例如dt.num[2]中的dt.num,对于dt.num来说,其基地址为dt,按图5.2.9第4至7行的定义,dt.num在结构体对象dt中的偏移为4,而数组元素dt.num[2]在数组dt.num中的偏移为8,在中间代码层次,我们可以把两者相加,得到dt.num[2]在结构体对象中的偏移为12,图5.2.10第26至29行的代码用于完成这些操作。

     图5.2.10第40行的函数Deref主要用来生成一条形如“t3:*t2”的间接寻址指令,其中t2中存放的一个地址,*t2表示取“这个地址对应内存单元中的内容”,并把该内容存于临时变量t3中,符号t3就作为“间接寻址”的结果返回。当然如果第40行的参数addr形如第44行的t1,而t1由中间代码“t1:&arr”创建,则间接寻址操作*t1可简化为对arr的访问。

     而图5.2.10第52行的函数AddressOf用于在必要时生成形如“t:&num”的取地址指令,其中的num是应是左值(具有C程序员可见的地址)。如果第52行的参数p是进行“间接寻址”后所得的结果t3,其中t3对应的间接寻址指令为“t3: *t2”,则“取地址操作&t3”可简化为t2,第52至57行的if语句对此进行判断,此时直接返回t2即可。当num被取地址后,UCC通过调用第61行的TrackValueChange函数,来使以num为操作数的公共子表达式失效。UCC用这样的策略避免了“别名分析”这样的复杂过程,当然这会影响生成代码的质量,UCC编译器在优化上做得还不够。由于num在其生命周期内的地址是不会变化的,所以对num进行取地址后的值就可以作为公共子表达式使用,第63行调用的TryAddValue用于此目的。

     对一个全局变量或静态变量number来讲,我们可以这样来理解出现在C程序中的符号number。在C代码中,我们可把符号number理解为“number相应内存单元中的内容,number位于赋值号右侧,则对该内存单元进行读操作;而number位于赋值号左侧时,则对该内存单元进行写操作”。C程序员如果要获取该内存单元的地址,则使用表达式&number。

// C代码,number对应全局静态区中的一个内存单元

number = 30;   //number位于赋值号左侧,表示要改写number的内容

a = number;    //number位于赋值号右侧,表示要读取number的内容

    但在汇编代码层次,我们可以把符号number看成是地址常数,在请求分页的操作系统中,连接器最终会为全局变量和静态变量分配一个虚空间中的内存单元,相当于把汇编代码中的符号number替换为一个地址常数。如果要访问相应内存单元的内容,则使用如下movl指令;而如果要获取该内存单元的地址,可使用leal指令,如下所示。

// 若全局变量number的地址为0x804a060

movl  number,  %eax;           //寄存器eax中的内容为30

leal     number, %ebx;          //寄存器ebx中的内容为0x804a060

     如果number只是个局部变量,由于其存储空间位于栈中,是动态分配的,其符号名number根本就不会出现在汇编代码中,而是用形如“-4(%ebp)”这样的符号来表示,其中寄存器ebp在运行时会指向栈空间,在编译时,我们只能算出局部变量number在栈中的偏移,其基地址是未知的,运行时会由寄存器ebp来指向。

     当然在C语言中,数组名是个特例,按照我们前面的理解,在C语言层次,符号arr就应代表数组的内容。但C编译器会根据上下文来对数组名进行不同的处理,这会造成语义上的不一致。这也是数组名给不少C程序员带来诸多困惑的源头,例如arr和&arr到底有何区别之类。对以下数组arr来说,在符号表中,符号arr的类型始终都是int[4]的数组类型,但当符号arr被用在不同场合时,其对应表达式的类型并不一致。

     int arr[4];

     (1)    sizeof(arr) 的值为16,其中的表达式arr为数组类型int[4];

     (2)    arr+1,这里的arr被当成数组第0个元素的地址,而arr[0]的类型为int,则&arr[0]的类型为 int  *,所以此处表达式arr的类型也为int *

     (3)    &arr +1, 其中表达式arr的类型为数组类型int[4];

            而&arr是指向数组int[4]的指针类型,即int(*)[4]。

     我们可以大胆地猜测,C的设计者是出于运行时效率的考虑,才会在一些情况下“把

数组名arr当作第0个数组元素arr[0]的首地址”。例如,在以下函数调用“f(bigArr)”中,若符号bigArr代表的是数组的内容,则在传参时我们需要传递4000字节的数据,这要占用较多的栈空间,同时大量数据的复制也要耗费不少时间。此时,若由C编译器把f(bigArr)中的bigArr当作bigArr[0]的地址,则只要传递一个地址就可以了,同时函数f的形参int num[1000]也可由C编译器隐式地调整为int * num。但是这并不能完全阻止C程序员传递数组的内容,C程序员还是可以写出如下struct  Container,通过给函数k传递一个struct Container对象,C编译器还是会复制其中的数组data。

int  bigArr[1000];

void  f(int num[1000]){

}

void  g(void){

         f(bigArr);

}

struct  Container{

         int  data[1000];

};

void k(struct  Container d){

}

     如果从语义一致上的角度出发,在C语言层次,让数组名bigArr代表数组中的内容其实也是很好的设计,这或许还更符合“提供机制,而非策略”的思想,C编译器提供传参的各种机制,至于C程序员要选用哪一种,也许由C程序员根据应用的上下文来决定会更好些,如下函数声明h1、h2和h3所示。这可能与设计上的审美有关,不过,当一个决定已成了标准,我们就要严格遵守。

void  h1(int arr[1000]);

void  h2(int * arr);

void  h3(int (*ptr)[1000]);

    理解了图5.2.10中的Offset等函数后,由于处理完了“寻址”的问题,我们再来看

tranexpr.c中的表达式翻译就会轻松许多。在下一小节中,我们将对tranexpr.c中用于翻译结构体成员访问的函数CheckMemberAccess,及用于翻译数组元素访问的函数CheckPostfixExpression等进行讨论。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值