C编译器剖析_C语言的变参函数

C语言的变参函数

UCC编译器中有不少地方使用了C语言的变参函数,这里我们专门用一小节来对C语言变参函数的实现原理进行分析。C标准库中的printf函数就是一个典型的变参函数,其接口如下所示,函数声明中的省略号…表明这是一个变参函数。

int printf(const char *format, ...);

下面我们举一个简单的例子来说明printf函数的调用过程,如图4.2.12所示。图中第1至11行对应是hello.c,而第12至25行是由UCC编译器生成的抽象语法树hello.ast,第26至33行则是UCC的中间代码hello.uil,第34至52行则是UCC生成的部分汇编代码hello.s。

图4.2.12 printf()函数

我们注意到第9行的实参a是float类型,而d是char类型,第17行的抽象语法树的功能是把实参a转换成double类型,而第21行则用于把实参d转换成int类型。我们在2.4节讨论C语言的类型系统时介绍过,对于形如int f()的旧式风格函数声明,C编译器会按C标准的要求进行实参提升的操作,在UCC编译器中,这个动作由函数PromoteArgument()来完成。对于变参函数的参数,例如上述第9行printf函数调用中格式化字符串之后从a开始的参数,C编译器也要进行相应的实参提升,即把小于int型的char和short提升为int类型,把float类型提升为double类型。图4.2.12第29至31行的中间代码很直观地反映了这个实参提升的过程。与之对应的汇编代码如第39至52行所示,第39至40行的代码把float类型的a转换为double类型,把转换后的结果存到临时变量-12(%ebp)中,我们在1.5节时介绍过与浮点运算相关的汇编指令。按照C函数的调用约定,参数按从右到左的次序依次入栈,第41行的指令完成了对参数d由char到int的转换,第42行把转换后的结果入栈,第43行则把参数c入栈,第44至46行则从全局静态数据区加载双精度浮点数b并入栈,第47至49行则从临时变量-12(%ebp)中加载双精度浮点数,并入栈。第50行把格式化字符串的首地址入栈,我们在第38行时已将其地址存到寄存器eax中,第51行则进行真正的函数调用,因为所有的参数都是存放在栈中,共占去了4+8+8+4+4(即28)字节,当函数printf返回时,我们在第52行把esp指针进行加28的操作。

库函数printf()的代码在我们编写上述hello.c时就已经存在,这意味着对被调函数printf其实并不知道我们在调用它时到底传递了几个实参。对printf而言,它只是按照格式化字符串的说明,从栈中取出相应的参数。

// 实际上只有10这一个参数,但printf看到有两个%d,

// 于是仍试图从栈中取两个参数,打印出形如10,1074172310的垃圾值

printf(“%d, %d “,10);

// 实际上有10,20,30这3个参数,但printf只看到一个%d,

// 于是只打印出参数10

printf(“%d”, 10,20,30);

图4.2.13更清楚地描述了上述过程,虚线的左侧为数据构中的栈区,右侧为全局静态数据区。在栈区中,我们标出了真正入栈的参数类型依次为int、int、double、double和char *,它们共占用了28个字节的栈空间。而格式化字符串实际上是存放在全局静态数据区的,压入栈中的只是该字符串的首地址。

图4.2.13 栈示意图

下面,让我们换个角度来看问题,假设我们是printf库函数的实现者,在printf函数的函数体内,我们通过形参format就可以访问到全局静态数据区中的格式化字符串,通过表达式&format我们就可以知道format在栈中的内存地址。由图4.2.13所示的内存布局,我们可由&format计算出其他参数的地址,有了这些参数的内存地址,我们就可以访问它们。为计算方便,让我们不妨假设&format的地址为十进制的10000,由图可算出其上方的4个参数对应的地址依次为十制制的10004、10012、10020和10024。其计算过程如下所示:

int printf(const char *format, ...){

unsigned int addr =&format;

// 与“a = %f”中%f对应的参数类型为double,地址为

addr + sizeof(char *),即10004

// 与“b = %f”中%f对应的参数类型为double,地址为

addr + sizeof(char *)+sizeof(double),即10012

// 与“c = %d”中%f对应的参数类型为int,地址为

addr + sizeof(char*)+sizeof(double) + sizeof(double),即10020

// 与”d = %c”中%c对应的参数类型为char,地址为

addr + sizeof(char*)+sizeof(double) + sizeof(double)+sizeof(int),即10024

}

在知道内存单元的地址,且知道该内存单元对应的类型的前提下,访问该单元的内容则是件容易的事情,例如要访问上述地址为10004的double类型的内存单元,我们只要用C语言写出如下代码即可。通过表达式*dptr进行“提领”操作,我们就可以为所欲为了。

double * dptr = (double *) (addr +sizeof(char *));

按照这样的思路,我们可以写出如下所示的OurPrintfV1变参函数,用于从栈中取出format之上的其他“无名的”参数,这个函数纯粹是为了演示如何根据形参format的地址来访问其他“无名的”参数,如图4.2.13所示。需要注意的是,我们有意忽略了对格式化字符串的处理过程,虽然这只是一个简单的字符串判断,并不会太复杂。在函数体中添加的printf调用,纯粹是为了验证我们确实正确地从栈中取出了实参。

图4.2.14 OurPrintfV1()

为了让图4.2.14中的代码看起来更优雅些,我们会引入一些宏来处理“由format的地址来定位其他参数”的过程,图4.2.15中的OurPrintfV2()完成的工作与OurPrintfV1()类似,但看起来简法多了。

图4.2.15 OurPrintfV2()

对比图4.2.14和图4.2.15,我们可以很清楚地看到宏定义va_start()所做的工作就是取形参format的地址,并对其做&format+sizeof(format)的运算,我们在前面介绍过,C编译器会对变参函数中的“匿名”参数进行实参提升的操作,所以真正入栈的实参所占的内存大小都会是sizeof(int)的整数倍,图4.2.15第9行的宏定义ALIGN_INT(n)完成了这个对齐的操作。假设sizeof(char)为1,sizeof(short)为2且sizeof(int)为4,则对以下宏来说,

宏ALIGN_INT(char)展开后为(1+3)&(~3),即4

宏ALIGN_INT(short)展开后为(2+3) &(~3) ,即4

我们知道,一个整数乘以4,相当将其左移2位,这意味着任何一个为4倍数的整数的最低2位都是0。上述(1+3)和(2+3)运算的目的是为了得到一个不小于4的数,而&(~3)运算的目的则是把这个不小于4的数的低2位清0,从而得到我们需要的对齐结果。如果宏定义va_arg中没有进行对齐操作,即把va_arg()定义为:

#define   va_arg(list, t)  (*(t *)((list+= sizeof(t)) - sizeof(t)))

不妨假设变量list的值为十进制的20000,则va_arg(list,char)宏展开后得到的是如下表达式,此表达式实际上相当于*((char*)20000),即取内存单元20000中的内容,但是该表达式是有副作用的,完成求值后,list变量的值变为20001。参数入栈时已经按sizeof(int)进行对齐,如果接下来按照一个不是4倍数的地址20001去取下一个参数,则我们无法正确的访问到所需的参数。

(*(char *)((list += 1) - 1))

宏定义va_arg是否有进行形如图4.2.15第9行那样的对齐,则与实际所用编译器的头文件有关。最稳妥最有可移植性的做法就是不要去使用va_arg(list,char)、va_arg(list,short)或者va_arg(list,float)。因为C编译器已经对变参函数的“无名参数”进行了实参提升,在栈中真正存在的“无名参数”不会是char,也不会是short,更不会是float,所以在图4.2.15第20行中我们用的是va_arg(ap,int),而非va_arg(ap,char)。当然,如果有经过第9行的ALIGN_INT的对齐,一定要用va_arg(ap,char)也不并非完全不可。但是,若使用va_arg(ap,float)则仍然会出问题,原因是sizeof(float)为4,而在栈在实际存在的double要占8个字节。C语言简洁而有力,但是要较好地驾驭C语言,则需要对形如图4.2.12的内存布局有较清楚的理解。在很多时侯,一些C程序员无法较好使用C语言指针的原因在于“对相关内存布局没有较清晰的概念”。C++实际上也对程序员提出了类似的要求,即便是在有意淡化指针概念的Java语言,如果对内存布局完全没有概念,也是有可能写出如下所示的Java代码。该程序员原本期待两次对bg.f()的调用能各打印出5条Hello,即共10条Hello,但却诧异地发现一共只有5条Hello。

class Bug{

int i= 0;

public void f(){

for(;i < 5; i++){

System.out.println(“Hello”);

}

}

public static void main(String args[]){

Bug bg = new Bug();

bg.f();

bg.f();

}

}

当然,实际使用va_arg等变参函数的宏时,我们只需要包含标准头文件stdarg.h就可以,并不需要显示地定义图4.2.15中第8至12行的宏。这些宏来自于UCC编译器的头文件ucl\linux\include\stdarg.h。有些时侯,我们在形如图4.2.15的变参函数OurPrintfV2中,只是想做一些准备工作,真正的对栈中的“无名参数”的访问的操作,我们还是想交由另外一个函数来处理,比如ucl\error.c中的Do_Error函数。如图4.2.16所示。

图4.2.16 Do_Error()

图4.2.16第7行,我们使用于记录错误个数的全局变量ErrorCount加1,第10行用于打印出出错的源代码的文件名和出错的行号,但是真正的错误提示信息的打印,我们还是想交给库函数vfprintf(),结合图4.2.13,我们很容易知道,vfprintf()函数只要知道格式化字符串的首地址,和Do_Error函数的形参format的地址,就可以从Do_Error对应的栈记录中取出传给Do_Error函数的“无名实参”,所用的访问方法完全与图4.2.14和图4.2.15类似。唯一的区别就是对vfprintf而言,不需要通过va_start()来获取format的地址,图4.2.16第14行的函数调用vfprintf()已经把format的地址传给下面的形参ap2。

int vfprintf(FILE *stream, const char*format2, va_list ap2);

图4.2.17给出了当我们进行如下函数调用时内存示意图,从图中我们可以看到,vfprintf的形参ap2已经指向了Do_Error对应栈中的无名参数开始位置,Do_Error的format和vfprintf的format2指向相同的格式化字符串。对vfprintf函数而言,有了格式化字符串的首地址,且又有了无名参数的首地址,七颗龙珠已经凑齐,可以召唤神龙了。

Do_Error(coord, "struct member %sdoesn‘t exsist", “abc”);

图4.2.17 Do_Error的栈示意图

时间: 2024-08-05 11:17:14

C编译器剖析_C语言的变参函数的相关文章

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

让我们再来看一份C代码,及其经UCC编译器编译后产生的主要汇编代码,如图1.33所示,其中包含了数组.指针和结构体. 图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如下所示

C编译器剖析_4.2 语义检查_表达式的语义检查(3)_字符串与标识符

4.2.3 在这一小节,我们先来分析一下基本表达式PrimaryExpression的语义检查,由C的标准文法,我们可以知道与PrimaryExpression相关的产生式如下所示,即加了一对小括号的表达式(Expression)在语法上也相当于标志符ID.常量CONST和字符串StringLiteral. primary-expression: ID constant string-literal ( expression ) 例如,对于表达式(a+b)+c而言,(a+b)和c都是基本表达式P

黑马程序员_C语言总结-基础部分

C语言基础 1.C语言的关键字 1>关键字就是C语言提供的有特殊含义的符号,也称为保留字,C语言中一共有32个关键字,这些关键字都有自己的含义 例如:int double float if  else switch for 等等 2.标示符的概念: 1>标示符就是在程序中自定义的一些名称,比如函数名,变量名,结构体名等等这些都是标示符 2>命名规则: 1>只能由英文字母的大小写和数字以及_下划线组成,且首字母必须为字母或者下划线_ 2>在C语言中是严格区分大小写的,比如if是

c语言可变参函数探究

一.什么是可变长参数 可变长参数:顾名思义,就是函数的参数长度(数量)是可变的.比如 C 语言的 printf 系列的(格式化输入输出等)函数,都是参数可变的.下面是 printf 函数的声明: int printf ( const char * format, ... ); 可变参数函数声明方式都是类似的. 二.如何实现 C语言可变参数通过三个宏(va_start.va_end.va_arg)和一个类型(va_list)实现的, void va_start ( va_list args, pa

C语言_第一讲_C语言入门

1.C语言是一个标准,而执行标准的时候产生的自动化程序则是编译器2.了解:1983年美国国家标准化歇会(ANSI)制定了C语言标准.C语言的特点:3.代码的可移植性(理想状态是代码可以不加修改,就可以移植,前提是不包括任何平台相关库)4.写代码的时候最好把业务逻辑层和UI层分开.这样便于代码的移植5.结构化编程(可以把问题分解成一个一个的函数去执行)6.丰富的数据类型(相对于汇编而言是丰富的)7.简洁高效的代码(这得看人,不是编译器的或者语言的特性,如果算法写的不好,一样执行速度慢,和语言无关.

C编译器剖析_2.4 C语言的类型系统

2.4  C语言的类型系统 这一节,我们准备初步讨论一下C语言的类型系统,相关的代码主要在ucl\type.c和ucl\type.h中.我们知道,一个进程的地址空间可分为代码区和数据区. 对于数据区,C语言提供了char.short.int.long.float和double等基本类型来刻画基本的操作数.char.short.int和long等整型还进一步分有unsigned和signed,对大多数编译器而言,缺省时,整型默认为signed.当然也有C编译器默认char为unsigned cha

C编译器剖析_尾声

尾声 总有曲终人散时,不知不觉我们已经完成了对UCC编译器的剖析,一路走来,最深的体会仍然是"纸上得来终觉浅,绝知此事要躬行".按这个道理,理解UCC编译器的最好办法应是"直接阅读其源代码,思考UCC编译器在不同的执行点应处于怎样的状态,加入一些打印语句,输出相应的调试信息来验证自己的判断是否正确,如果发现Bug,就写一些测试程序来触发Bug,然后修改UCC编译器的源代码".在遇到困惑时,或许拙作能带来一点点的帮助和提示,但拙作不能代替,也不应取代对UCC源代码的主

C编译器剖析_4.2 语义检查_表达式的语义检查(4)_函数调用

4.2.4 函数调用的语义检查 在这一小节中,我们来讨论一下函数调用的语义检查,语法上,函数调用对应的表达式属于后缀表达式PostfixExpression,UCC编译器exprchk.c的函数CheckFunctionCall()完成了对函数调用的语义检查,如图4.2.18所示.在阅读这份代码时,需要对语法分析后为函数调用构造的语法树有较好认识,请先参照"图3.1.21后缀运算符对应的语法树"或者先预览一下图4.2.19. 图4.2.18 CheckFunctionCall() 对形

C编译器剖析_6.3.5 汇编代码生成_为类型转换产生汇编代码

6.3.5  为类型转换产生汇编代码 在这一小节中,我们来讨论一下整型和浮点型之间的类型转换.有些类型转换并不需要在汇编层次进行数据转换,例如int和unsigned  int之间的转换只是改变了表达式的类型,对数据本身并无影响,以下表达式"(unsigned int) a"对应的二进制数据为0xFFFFFFFF,而表达式"a"对应的二进制数据也为0xFFFFFFFF.但对相同内容的二进制数据来说,进行"有符号整数的右移"和"无符号整数