6. 可变参数

到目前为止我们只见过一个带有可变参数的函数printf

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

以后还会见到更多这样的函数。现在我们实现一个简单的myprintf函数:

例 24.9. 用可变参数实现简单的printf函数

  1. #include <stdio.h>
  2. #include <stdarg.h>
  3.  
  4. void myprintf(const char *format, ...)
  5. {
  6. va_list ap;
  7. char c;
  8.  
  9. va_start(ap, format);
  10. while (c = *format++) {
  11. switch(c) {
  12. case 'c': {
  13. /* char is promoted to int when passed through '...' */
  14. char ch = va_arg(ap, int);
  15. putchar(ch);
  16. break;
  17. }
  18. case 's': {
  19. char *p = va_arg(ap, char *);
  20. fputs(p, stdout);
  21. break;
  22. }
  23. default:
  24. putchar(c);
  25. }
  26. }
  27. va_end(ap);
  28. }
  29.  
  30. int main(void)
  31. {
  32. myprintf("c\ts\n", '1', "hello");
  33. return 0;
  34. }

要处理可变参数,需要用C到标准库的va_list类型和va_startva_argva_end宏,这些定义在stdarg.h头文件中。这些宏是如何取出可变参数的呢?我们首先对照反汇编分析在调用myprintf函数时这些参数的内存布局。

  1. myprintf("c\ts\n", '1', "hello");
  2. 80484c5: c7 44 24 08 b0 85 04 movl $0x80485b0,0x8(%esp)
  3. 80484cc: 08
  4. 80484cd: c7 44 24 04 31 00 00 movl $0x31,0x4(%esp)
  5. 80484d4: 00
  6. 80484d5: c7 04 24 b6 85 04 08 movl $0x80485b6,(%esp)
  7. 80484dc: e8 43 ff ff ff call 8048424 <myprintf>

图 24.6. myprintf函数的参数布局

myprintf函数的参数布局

这些参数是从右向左依次压栈的,所以第一个参数靠近栈顶,第三个参数靠近栈底。这些参数在内存中是连续存放的,每个参数都对齐到4字节边界。第一个和第三个参数都是指针类型,各占4个字节,虽然第二个参数只占一个字节,但为了使第三个参数对齐到4字节边界,所以第二个参数也占4个字节。现在给出一个stdarg.h的简单实现,这个实现出自[Standard C Library]

例 24.10. stdarg.h的一种实现

  1. /* stdarg.h standard header */
  2. #ifndef _STDARG
  3. #define _STDARG
  4.  
  5. /* type definitions */
  6. typedef char *va_list;
  7. /* macros */
  8. #define va_arg(ap, T) \
  9. (* (T *)(((ap) += _Bnd(T, 3U)) - _Bnd(T, 3U)))
  10. #define va_end(ap) (void)0
  11. #define va_start(ap, A) \
  12. (void)((ap) = (char *)&(A) + _Bnd(A, 3U))
  13. #define _Bnd(X, bnd) (sizeof (X) + (bnd) & ~(bnd))
  14. #endif

这个头文件中的内部宏定义_Bnd(X, bnd)将类型或变量X的长度对齐到bnd+1字节的整数倍,例如_Bnd(char, 3U)的值是4,_Bnd(int, 3U)也是4。

myprintf中定义的va_list ap;其实是一个指针,va_start(ap, format)使ap指向format参数的下一个参数,也就是指向上图中esp+4的位置。然后va_arg(ap, int)把第二个参数的值按int型取出来,同时使ap指向第三个参数,也就是指向上图中esp+8的位置。然后va_arg(ap, char *)把第三个参数的值按char *型取出来,同时使ap指向更高的地址。va_end(ap)在我们的简单实现中不起任何作用,在有些实现中可能会把ap改写成无效值,C标准要求在函数返回前调用va_end

如果把myprintf中的char ch = va_arg(ap, int);改成char ch = va_arg(ap, char);,用我们这个stdarg.h的简单实现是没有问题的。但如果改用libc提供的stdarg.h,在编译时会报错:

  1. $ gcc main.c
  2. main.c: In function myprintf’:
  3. main.c:33: warning: char is promoted to int when passed through ‘...’
  4. main.c:33: note: (so you should pass int not char to va_arg’)
  5. main.c:33: note: if this code is reached, the program will abort
  6. $ ./a.out
  7. Illegal instruction

因此要求char型的可变参数必须按int型来取,这是为了与C标准一致,我们在第 3.1 节 “Integer Promotion”讲过Default Argument Promotion规则,传递char型的可变参数时要提升为int型。

myprintf的例子可以理解printf的实现原理,printf函数根据第一个参数(格式化字符串)来确定后面有几个参数,分别是什么类型。保证参数的类型、个数与格式化字符串的描述相匹配是调用者的责任,实现者只管按格式化字符串的描述从栈上取数据,如果调用者传递的参数类型或个数不正确,实现者是没有办法避免错误的。

还有一种方法可以确定可变参数的个数,就是在参数列表的末尾传一个Sentinel,例如NULLexecl(3)就采用这种方法确定参数的个数。下面实现一个printlist函数,可以打印若干个传入的字符串。

例 24.11. 根据Sentinel判断可变参数的个数

  1. #include <stdio.h>
  2. #include <stdarg.h>
  3.  
  4. void printlist(int begin, ...)
  5. {
  6. va_list ap;
  7. char *p;
  8.  
  9. va_start(ap, begin);
  10. p = va_arg(ap, char *);
  11.  
  12. while (p != NULL) {
  13. fputs(p, stdout);
  14. putchar('\n');
  15. p = va_arg(ap, char*);
  16. }
  17. va_end(ap);
  18. }
  19.  
  20. int main(void)
  21. {
  22. printlist(0, "hello", "world", "foo", "bar", NULL);
  23. return 0;
  24. }

printlist的第一个参数begin的值并没有用到,但是C语言规定至少要定义一个有名字的参数,因为va_start宏要用到参数列表中最后一个有名字的参数,从它的地址开始找可变参数的位置。实现者应该在文档中说明参数列表必须以NULL结尾,如果调用者不遵守这个约定,实现者是没有办法避免错误的。

习题

1、实现一个功能更完整的printf,能够识别%,能够处理%d%f对应的整数参数。在实现中不许调用printf(3)这个Man Page中描述的任何函数。