工学1号馆

home

C语言可变参数函数实现原理

Wu Yudong    June 27, 2015     C   603   

这篇文章详细剖析C可变参数函数实现原理

一、可变参数函数实现原理

C函数调用的栈结构:

可变参数函数的实现与函数调用的栈结构密切相关,正常情况下C的函数参数入栈规则为__stdcall, 它是从右到左的,即函数中的最右边的参数最先入栈。

例如,对于函数:

void fun(int a, int b, int c)
{
        int d;
        ...
}

其栈结构为

0x1ffc–>d

0x2000–>a

0x2004–>b

0x2008–>c

对于在32位系统的多数编译器,每个栈单元的大小都是sizeof(int), 而函数的每个参数都至少要占一个栈单元大小,如函数 void fun1(char a, int b, double c, short d) 对一个32的系统其栈的结构就是

0x1ffc–>a  (4字节)(为了字对齐)

0x2000–>b  (4字节)

0x2004–>c  (8字节)

0x200c–>d  (4字节)

因此,函数的所有参数是存储在线性连续的栈空间中的,基于这种存储结构,这样就可以从可变参数函数中必须有的第一个普通参数来寻址后续的所有可变参数的类型及其值。

先看看固定参数列表函数:

void fixed_args_func(int a, double b, char *c)
{
        printf("a = 0x%p\n", &a);
        printf("b = 0x%p\n", &b);
        printf("c = 0x%p\n", &c);
}

对于固定参数列表的函数,每个参数的名称、类型都是直接可见的,他们的地址也都是可以直接得到的,比如:通过&a我们可以得到a的地址,并通过函数原型声明了解到aint类型的。

但是对于变长参数的函数,我们就没有这么顺利了。还好,按照C标准的说明,支持变长参数的函数在原型声明中,必须有至少一个最左固定参数(这一点与传统C有区别,传统C允许不带任何固定参数的纯变长参数函数),这样我们可以得到其中固定参数的地址,但是依然无法从声明中得到其他变长参数的地址,比如:

void var_args_func(const char * fmt, ...) 
{
    ... ... 
}

这里我们只能得到fmt这固定参数的地址,仅从函数原型我们是无法确定“…”中有几个参数、参数都是什么类型的。回想一下函数传参的过程,无论“…”中有多少个参数、每个参数是什么类型的,它们都和固定参数的传参过程是一样的,简单来讲都是栈操作,而栈这个东西对我们是开放的。这样一来,一旦我们知道某函数帧的栈上的一个固定参数的位置,我们完全有可能推导出其他变长参数的位置。

我们先用上面的那个fixed_args_func函数确定一下入栈顺序。

int main() 
{
    fixed_args_func(17, 5.40, "hello world");
    return 0;
}
a = 0x0022FF50
b = 0x0022FF54
c = 0x0022FF5C

从这个结果来看,显然参数是从右到左,逐一压入栈中的(栈的延伸方向是从高地址到低地址,栈底的占领着最高内存地址,先入栈的参数,其地理位置也就最高了)

我们基本可以得出这样一个结论:

 c.addr = b.addr + x_sizeof(b);  /*注意:  x_sizeof !=sizeof */
 b.addr = a.addr + x_sizeof(a);

有了以上的”等式”,我们似乎可以推导出 void var_args_func(const char * fmt, … ) 函数中,可变参数的位置了。起码第一个可变参数的位置应该是:first_vararg.addr = fmt.addr + x_sizeof(fmt);  根据这一结论我们试着实现一个支持可变参数的函数:

#include <stdarg.h>
#include <stdio.h>

void var_args_func(const char * fmt, ...) 
{
    char    *ap;

    ap = ((char*)&fmt) + sizeof(fmt);
    printf("%d\n", *(int*)ap);  
        
    ap =  ap + sizeof(int);
    printf("%d\n", *(int*)ap);

    ap =  ap + sizeof(int);
    printf("%s\n", *((char**)ap));
}

int main()
{
    var_args_func("%d %d %s\n", 4, 5, "hello world");
   return 0;
}

期待输出结果:

4
5
hello world


先来解释一下这个程序。我们用ap获取第一个变参的地址,我们知道第一个变参是4,一个int 型,所以我们用(int*)ap以告诉编译器,以ap为首地址的那块内存我们要将之视为一个整型来使用,*(int*)ap获得该参数的值;接下来的变参是5,又一个int型,其地址是ap + sizeof(第一个变参),也就是ap + sizeof(int),同样我们使用*(int*)ap获得该参数的值;最后的一个参数是一个字符串,也就是char*,与前两个int型参数不同的是,经过ap + sizeof(int)后,ap指向栈上一个char*类型的内存块(我们暂且称之tmp_ptr, char *tmp_ptr)的首地址,即ap -> &tmp_ptr,而我们要输出的不是printf(“%s\n”, ap),而是printf(“%s\n”, tmp_ptr); printf(“%s\n”, ap)是意图将ap所指的内存块作为字符串输出了,但是ap -> &tmp_ptr,tmp_ptr所占据的4个字节显然不是字符串,而是一个地址。如何让&tmp_ptr是char **类型的,我们将ap进行强制转换(char**)ap <=> &tmp_ptr,这样我们访问tmp_ptr只需要在(char**)ap前面加上一个*即可,即printf(“%s\n”,  *(char**)ap);


一切似乎很完美,编译也很顺利通过,但运行上面的代码后,不但得不到预期的结果,反而整个编译器会强行关闭(大家可以尝试着运行一下),原来是ap指针在后来并没有按照预期的要求指向第二个变参数,即并没有指向5所在的首地址,而是指向了未知内存区域,所以编译器会强行关闭。其实错误开始于:ap =  ap + sizeof(int);由于内存对齐,编译器在栈上压入参数时,不是一个紧挨着另一个的,编译器会根据变参的类型将其放到满足类型对齐的地址上的,这样栈上参数之间实际上可能会是有空隙的。参见:(深入剖析C语言内存对齐),所以此时的ap计算应该改为:ap =  (char *)ap +sizeof(int) + __va_rounded_size(int);

改正后的代码如下:

#include<stdio.h>

#define __va_rounded_size(TYPE)  \
  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

void var_args_func(const char * fmt, ...) 
{
    char *ap;

    ap = ((char*)&fmt) + sizeof(fmt);
    printf("%d\n", *(int*)ap);  
        
    ap = (char *)ap + sizeof(int) + __va_rounded_size(int);
    printf("%d\n", *(int*)ap);

    ap = ap + sizeof(int) + __va_rounded_size(int);
    printf("%s\n", *((char**)ap));
}

int main()
{
    var_args_func("%d %d %s\n", 4, 5, "hello world"); 
    return 0;
}

var_args_func只是为了演示,并未根据fmt消息中的格式字符串来判断变参的个数和类型,而是直接在实现中写死了。

为了满足代码的可移植性,C标准库在stdarg.h中提供了诸多便利以供实现变长长度参数时使用。这里也列出一个简单的例子,看看利用标准库是如何支持变长参数的:

#include <stdarg.h>
#include <stdio.h>
void std_vararg_func(const char *fmt, ...) {
        va_list ap;
        va_start(ap, fmt);
        printf("%d\n", va_arg(ap, int));
        printf("%f\n", va_arg(ap, double));
        printf("%s\n", va_arg(ap, char*));

        va_end(ap);
}

int main() 
{
        std_vararg_func("%d %f %s\n", 4, 5.4, "hello world");   
        return 0;
}

对比一下 std_vararg_funcvar_args_func的实现,va_list似乎就是char* va_start似乎就是 ((char*)&fmt) + sizeof(fmt)va_arg似乎就是得到下一个参数的首地址。没错,多数平台下stdarg.hva_list, va_startvar_arg的实现就是类似这样的。一般stdarg.h会包含很多宏,看起来比较复杂。

下面我们来探讨如何写一个简单的可变参数的C 函数.

使用可变参数应该有以下步骤:
1)首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针.
2)然后用va_start宏初始化变量arg_ptr,这个宏的第二个参数是第一个可变参数的前一个参数,是一个固定的参数.
3)然后用va_arg返回可变的参数,并赋值给整数j. va_arg的第二个参数是你要返回的参数的类型,这里是int型.
4)最后用va_end宏结束可变参数的获取.然后你就可以在函数里使用第二个参数了.如果函数有多个可变参数的,依次调用va_arg获取各个参数.

在《C程序设计语言》中,Ritchie提供了一个简易版printf函数:

#include<stdarg.h>

void minprintf(char *fmt, ...)
{
    va_list ap;
    char *p, *sval;
    int ival;
    double dval;

    va_start(ap, fmt);
    for (p = fmt; *p; p++) {
        if(*p != '%') {
            putchar(*p);
            continue;
        }
        switch(*++p) {
        case 'd':
            ival = va_arg(ap, int);
            printf("%d", ival);
            break;
        case 'f':
            dval = va_arg(ap, double);
            printf("%f", dval);
            break;
        case 's':
            for (sval = va_arg(ap, char *); *sval; sval++)
                putchar(*sval);
            break;
        default:
            putchar(*p);
            break;
        }
    }
    va_end(ap);
}

二、stdarg.h头文件源代码分析

谈到C语言中可变参数函数的实现,有一个头文件不得不谈,那就是stdarg.h

接下来从minix源码中的stdarg.h头文件入手进行分析:

ifndef _STDARG_H
 #define _STDARG_H
 
 
 #ifdef __GNUC__
 /* The GNU C-compiler uses its own, but similar varargs mechanism. */
 
 typedef char *va_list;
 
 /* Amount of space required in an argument list for an arg of type TYPE.
  * TYPE may alternatively be an expression whose type is used.
  */
 
 #define __va_rounded_size(TYPE)  \
   (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))
 
 #if __GNUC__ < 2

 #ifndef __sparc__
 #define va_start(AP, LASTARG)                                           \
  (AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))
 #else
 #define va_start(AP, LASTARG)                                           \
  (__builtin_saveregs (),                                                \
   AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))
 #endif
 
 void va_end (va_list);          /* Defined in gnulib */
 #define va_end(AP)
 
 #define va_arg(AP, TYPE)                                                \
  (AP += __va_rounded_size (TYPE),                                       \
   *((TYPE *) (AP - __va_rounded_size (TYPE))))
 
 #else    /* __GNUC__ >= 2 */
 
 #ifndef __sparc__
 #define va_start(AP, LASTARG)                         \
  (AP = ((char *) __builtin_next_arg ()))
 #else
 #define va_start(AP, LASTARG)                    \
   (__builtin_saveregs (), AP = ((char *) __builtin_next_arg ()))
 #endif
 
 void va_end (va_list);        /* Defined in libgcc.a */
 #define va_end(AP)
 
 #define va_arg(AP, TYPE)                        \
  (AP = ((char *) (AP)) += __va_rounded_size (TYPE),            \
   *((TYPE *) ((char *) (AP) - __va_rounded_size (TYPE))))
 
 #endif    /* __GNUC__ >= 2 */
 
 #else    /* not __GNUC__ */
 
 
 typedef char *va_list;
 
 #define __vasz(x)        ((sizeof(x)+sizeof(int)-1) & ~(sizeof(int) -1))
 
 #define va_start(ap, parmN)    ((ap) = (va_list)&parmN + __vasz(parmN))
 #define va_arg(ap, type)      \
   (*((type *)((va_list)((ap) = (void *)((va_list)(ap) + __vasz(type))) \
                             - __vasz(type))))
 #define va_end(ap)
 
 #endif /* __GNUC__ */
 
 #endif /* _STDARG_H */

从代码中可以看到,里面编译器的版本以及相关的大量宏定义

第5行: #ifdef __GNUC__
作用是条件编译,__GNUC__为GCC中定义的宏。GCC的版本,为一个整型值。如果你需要知道自己的程序是否被GCC编译,可以简单的测试一下__GNUC__,假如你代码需要运行在GCC某个特定的版本下,那么你就要小心了,因为GCC的主要版本在增加,如果你想定义宏的方式直接实现控制,你可以写如下的代码(参见伯克利大学网站):

/* 测试 GCC > 3.2.0 ? */
#if __GNUC__ > 3 || \
    (__GNUC__ == 3 && (__GNUC_MINOR__ > 2 || \
                       (__GNUC_MINOR__ == 2 && \
                        __GNUC_PATCHLEVEL__ > 0))

你还可以使用下面一个类似的方法:

#define GCC_VERSION (__GNUC__ * 10000 \
                     + __GNUC_MINOR__ * 100 \
                     + __GNUC_PATCHLEVEL__)
...
/*测试 GCC > 3.2.0 ?*/
#if GCC_VERSION > 30200

第8行: 使用typedef进行了一个声明:typedef char *va_list;

第14行:定义了用于编译器的内存对齐宏,参见:(深入剖析C语言内存对齐)。

#define __va_rounded_size(TYPE)  \
     (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

第17行:#if __GNUC__ < 2,进行GCC的版本判断,看当前版本是否大于2

第19行:#ifndef __sparc__ 可扩充处理器架构宏(以后再深入研究)

第20行:使得ap指向函数中的第一个无名参数的首地址的宏:

#define va_start(AP, LASTARG)                                           \
   (AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))

第31行:

#define va_arg(AP, TYPE)                                                \
   (AP += __va_rounded_size (TYPE),                                       \
    *((TYPE *) (AP - __va_rounded_size (TYPE))))

va_arg宏使得ap指向下一个参数,已经处理了内存对齐,其中参数的类型为TYPE

第48行:

void va_end (va_list);          /* Defined in gnulib */

定义在gnulib中,va_end 与va_start成对使用.在有些代码中定义为:

#define va_end(ap)      ( ap = (va_list)0 )

三、C语言scanf函数的实现

上文从理论上详细介绍了C语言中可变参数函数的实现,接下来从minix内核源码中的scanf函数入手,学习C语言经典可变参数函数的实现过程

在scanf.c文件中,可以看到scanf函数,代码如下:

#include    <stdio.h>
#include    <stdarg.h>
#include    "loc_incl.h"

int scanf(const char *format, ...)
{
    va_list ap;
    int retval;

    va_start(ap, format);
    retval = _doscan(stdin, format, ap);
    va_end(ap);

    return retval;
}

对于va_list、va_start、va_end等在stdarg.h头文件中定义的宏,都已经在上文中介绍过。

在上述代码中我们可以看到有一个_doscan函数,而这一函数在头文件loc_incl.h中定义,函数声明如下:

int _doscan(FILE * stream, const char *format, va_list ap);

_doscan函数的实现源代码如下:

/*
 * the routine that does the scanning 
 */

int
_doscan(register FILE *stream, const char *format, va_list ap)
{
	int		done = 0;	/* number of items done */
	int		nrchars = 0;	/* number of characters read */
	int		conv = 0;	/* # of conversions */
	int		base;		/* conversion base */
	unsigned long	val;		/* an integer value */
	register char	*str;		/* temporary pointer */
	char		*tmp_string;	/* ditto */
	unsigned	width = 0;	/* width of field */
	int		flags;		/* some flags */
	int		reverse;	/* reverse the checking in [...] */
	int		kind;
	register int	ic = EOF;	/* the input character */
#ifndef	NOFLOAT
	long double	ld_val;
#endif

	if (!*format) return 0;

	while (1) {
		if (isspace(*format)) {
			while (isspace(*format))
				format++;	/* skip whitespace */
			ic = getc(stream);
			nrchars++;
			while (isspace (ic)) {
				ic = getc(stream);
				nrchars++;
			}
			if (ic != EOF) ungetc(ic,stream);
			nrchars--;
		}
		if (!*format) break;	/* end of format */

		if (*format != '%') {
			ic = getc(stream);
			nrchars++;
			if (ic != *format++) break;	/* error */
			continue;
		}
		format++;
		if (*format == '%') {
			ic = getc(stream);
			nrchars++;
			if (ic == '%') {
				format++;
				continue;
			}
			else break;
		}
		flags = 0;
		if (*format == '*') {
			format++;
			flags |= FL_NOASSIGN;
		}
		if (isdigit (*format)) {
			flags |= FL_WIDTHSPEC;
			for (width = 0; isdigit (*format);)
				width = width * 10 + *format++ - '0';
		}

		switch (*format) {
		case 'h': flags |= FL_SHORT; format++; break;
		case 'l': flags |= FL_LONG; format++; break;
		case 'L': flags |= FL_LONGDOUBLE; format++; break;
		}
		kind = *format;
		if ((kind != 'c') && (kind != '[') && (kind != 'n')) {
			do {
				ic = getc(stream);
				nrchars++;
			} while (isspace(ic));
			if (ic == EOF) break;		/* outer while */
		} else if (kind != 'n') {		/* %c or %[ */
			ic = getc(stream);
			if (ic == EOF) break;		/* outer while */
			nrchars++;
		}
		switch (kind) {
		default:
			/* not recognized, like %q */
			return conv || (ic != EOF) ? done : EOF;
			break;
		case 'n':
			if (!(flags & FL_NOASSIGN)) {	/* silly, though */
				if (flags & FL_SHORT)
					*va_arg(ap, short *) = (short) nrchars;
				else if (flags & FL_LONG)
					*va_arg(ap, long *) = (long) nrchars;
				else
					*va_arg(ap, int *) = (int) nrchars;
			}
			break;
		case 'p':		/* pointer */
			set_pointer(flags);
			/* fallthrough */
		case 'b':		/* binary */
		case 'd':		/* decimal */
		case 'i':		/* general integer */
		case 'o':		/* octal */
		case 'u':		/* unsigned */
		case 'x':		/* hexadecimal */
		case 'X':		/* ditto */
			if (!(flags & FL_WIDTHSPEC) || width > NUMLEN)
				width = NUMLEN;
			if (!width) return done;

			str = o_collect(ic, stream, kind, width, &base);
			if (str < inp_buf
			    || (str == inp_buf
				    && (*str == '-'
					|| *str == '+'))) return done;

			/*
			 * Although the length of the number is str-inp_buf+1
			 * we don't add the 1 since we counted it already
			 */
			nrchars += str - inp_buf;

			if (!(flags & FL_NOASSIGN)) {
				if (kind == 'd' || kind == 'i')
				    val = strtol(inp_buf, &tmp_string, base);
				else
				    val = strtoul(inp_buf, &tmp_string, base);
				if (flags & FL_LONG)
					*va_arg(ap, unsigned long *) = (unsigned long) val;
				else if (flags & FL_SHORT)
					*va_arg(ap, unsigned short *) = (unsigned short) val;
				else
					*va_arg(ap, unsigned *) = (unsigned) val;
			}
			break;
		case 'c':
			if (!(flags & FL_WIDTHSPEC))
				width = 1;
			if (!(flags & FL_NOASSIGN))
				str = va_arg(ap, char *);
			if (!width) return done;

			while (width && ic != EOF) {
				if (!(flags & FL_NOASSIGN))
					*str++ = (char) ic;
				if (--width) {
					ic = getc(stream);
					nrchars++;
				}
			}

			if (width) {
				if (ic != EOF) ungetc(ic,stream);
				nrchars--;
			}
			break;
		case 's':
			if (!(flags & FL_WIDTHSPEC))
				width = 0xffff;
			if (!(flags & FL_NOASSIGN))
				str = va_arg(ap, char *);
			if (!width) return done;

			while (width && ic != EOF && !isspace(ic)) {
				if (!(flags & FL_NOASSIGN))
					*str++ = (char) ic;
				if (--width) {
					ic = getc(stream);
					nrchars++;
				}
			}
			/* terminate the string */
			if (!(flags & FL_NOASSIGN))
				*str = '\0';	
			if (width) {
				if (ic != EOF) ungetc(ic,stream);
				nrchars--;
			}
			break;
		case '[':
			if (!(flags & FL_WIDTHSPEC))
				width = 0xffff;
			if (!width) return done;

			if ( *++format == '^' ) {
				reverse = 1;
				format++;
			} else
				reverse = 0;

			for (str = Xtable; str < &Xtable[NR_CHARS]
							; str++)
				*str = 0;

			if (*format == ']') Xtable[*format++] = 1;

			while (*format && *format != ']') {
				Xtable[*format++] = 1;
				if (*format == '-') {
					format++;
					if (*format
					    && *format != ']'
					    && *(format) >= *(format -2)) {
						int c;

						for( c = *(format -2) + 1
						    ; c <= *format ; c++)
							Xtable[c] = 1;
						format++;
					}
					else Xtable['-'] = 1;
				}
			}
			if (!*format) return done;
			
			if (!(Xtable[ic] ^ reverse)) {
			/* MAT 8/9/96 no match must return character */
				ungetc(ic, stream);
				return done;
			}

			if (!(flags & FL_NOASSIGN))
				str = va_arg(ap, char *);

			do {
				if (!(flags & FL_NOASSIGN))
					*str++ = (char) ic;
				if (--width) {
					ic = getc(stream);
					nrchars++;
				}
			} while (width && ic != EOF && (Xtable[ic] ^ reverse));

			if (width) {
				if (ic != EOF) ungetc(ic, stream);
				nrchars--;
			}
			if (!(flags & FL_NOASSIGN)) {	/* terminate string */
				*str = '\0';	
			}
			break;
#ifndef	NOFLOAT
		case 'e':
		case 'E':
		case 'f':
		case 'g':
		case 'G':
			if (!(flags & FL_WIDTHSPEC) || width > NUMLEN)
				width = NUMLEN;

			if (!width) return done;
			str = f_collect(ic, stream, width);

			if (str < inp_buf
			    || (str == inp_buf
				&& (*str == '-'
				    || *str == '+'))) return done;

			/*
			 * Although the length of the number is str-inp_buf+1
			 * we don't add the 1 since we counted it already
			 */
			nrchars += str - inp_buf;

			if (!(flags & FL_NOASSIGN)) {
				ld_val = strtod(inp_buf, &tmp_string);
				if (flags & FL_LONGDOUBLE)
					*va_arg(ap, long double *) = (long double) ld_val;
				else
				    if (flags & FL_LONG)
					*va_arg(ap, double *) = (double) ld_val;
				else
					*va_arg(ap, float *) = (float) ld_val;
			}
			break;
#endif
		}		/* end switch */
		conv++;
		if (!(flags & FL_NOASSIGN) && kind != 'n') done++;
		format++;
	}
	return conv || (ic != EOF) ? done : EOF;
}

在上面的源代码中,值得注意的是第26行的getc宏,定义代码如下:

#define    getc(p)        (--(p)->_count >= 0 ? (int) (*(p)->_ptr++) : \
                __fillbuf(p))

getc的调用形式:ch=getc(fp); 功能是从文件指针指向的文件读入一个字符,并把它作为函数值返回给int型变量ch。

第4行~第17行,定义一些后面需要用到的变量

第23行~第34行,跳过format格式串中的空格,并且跳过输入流中的空格

第37行~第42行,输入流stream与format格式串中的空白符(空白符可以是空格(space)、制表符(tab)和新行符(newline))保持一致

第44行~第52行,在format中的字符为’%’的前提下,stream中的字符也为’%’,则继续

第54行~第57行,format当前字符为’*’,表示读指定类型的数据但不保存

第58行~第62行,指定说明最大域宽。 在百分号(%)与格式码之间的整数用于限制从对应域读入的最大字符数于宽度

第64行~第282行,switch语句,用于格式修饰符,这些修饰符包括: h、l、L、c、p、b、d、i、o、u……,还有基于扫描集的'[‘修饰符


对scanf函数的源码分析,需要在scanf函数的语法格式详细的理解基础上进行,由于scanf函数实现十分复杂,需要仔细的品味,这里只是比较初步的分析,具体还有待后期不断的完善

如果文章对您有帮助,欢迎点击下方按钮打赏作者

Comments

No comments yet.
To verify that you are human, please fill in "七"(required)