14. 可变参数(stdarg.h):va_list、va_start、va_arg、va_end。实现自己的printf风格函数。

说实话,可变参数这个功能,我刚开始学C的时候觉得挺神秘的。你想想看,printf 能接受任意数量的参数,这背后到底是怎么做到的?后来我翻到 stdarg.h 这个头文件,才恍然大悟——原来C语言早就给我们准备好了这套机制。

今天我们就来彻底搞懂它。我会带着你一步步实现一个自己的 my_printf,让你真正掌握 va_listva_startva_argva_end 这四个宏的用法。

什么是可变参数?

可变参数,说白了就是函数可以接受不定数量的参数。最典型的例子就是 printf

printf("Hello, %s!", "world");      // 2个参数
printf("a=%d, b=%d", 1, 2);         // 3个参数
printf("x=%f, y=%f, z=%f", 1.0, 2.0, 3.0);  // 4个参数

同一个函数,参数个数可以不一样。这在C语言里是怎么实现的?

嗯,这里要注意:可变参数函数必须至少有一个固定参数。这个固定参数的作用,就是告诉函数「后面还有多少个参数」或者「后面参数的类型是什么」。

stdarg.h 四件套

stdarg.h 提供了四个宏,它们必须按固定顺序使用。我习惯把它们叫做「可变参数四步曲」:

作用 使用时机
va_list 声明一个变量,用来存储可变参数列表 函数内部第一步
va_start 初始化 va_list 变量,绑定到最后一个固定参数 使用 va_list 之前
va_arg 从列表中取出下一个参数,需要指定类型 循环取参数时
va_end 清理 va_list 变量 所有参数取完后

核心要点:这四个宏必须成对出现。有 va_start 就必须有 va_end,否则会有内存泄漏风险。

先写一个简单的例子

我们来写一个函数,它可以接受任意数量的整数,然后求和:

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

int sum(int count, ...) {
    va_list args;
    int total = 0;

    va_start(args, count);  // 绑定到 count 后面的参数

    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);  // 每次取一个 int
    }

    va_end(args);  // 清理

    return total;
}

int main() {
    printf("sum(3, 10, 20, 30) = %d\n", sum(3, 10, 20, 30));
    printf("sum(5, 1, 2, 3, 4, 5) = %d\n", sum(5, 1, 2, 3, 4, 5));
    return 0;
}

运行结果:

sum(3, 10, 20, 30) = 60
sum(5, 1, 2, 3, 4, 5) = 15

你看,第一个参数 count 告诉函数「后面还有几个参数」。然后 va_arg 在循环里一个个取出来。这就是最基础的可变参数用法。

我的小建议:我个人习惯把固定参数命名为 countfmt,这样一眼就能看出它的用途。别用 n 这种模糊的名字,代码维护起来会头疼。

实现自己的 printf 风格函数

好了,现在我们来点硬核的。实现一个简化版的 my_printf,支持 %d%s%c%%

我在项目中遇到过类似的需求——嵌入式系统里没有标准库,需要自己实现打印函数。那时候我才真正体会到,stdarg.h 这套机制有多重要。

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

void my_printf(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);

    while (*fmt) {
        if (*fmt == '%') {
            fmt++;  // 跳过 '%'
            switch (*fmt) {
                case 'd': {
                    int val = va_arg(args, int);
                    printf("%d", val);
                    break;
                }
                case 's': {
                    char *val = va_arg(args, char *);
                    printf("%s", val);
                    break;
                }
                case 'c': {
                    char val = (char)va_arg(args, int);
                    // 注意:char 类型会被提升为 int
                    printf("%c", val);
                    break;
                }
                case '%': {
                    putchar('%');
                    break;
                }
                default:
                    putchar('%');
                    putchar(*fmt);
                    break;
            }
        } else {
            putchar(*fmt);
        }
        fmt++;
    }

    va_end(args);
}

int main() {
    my_printf("Hello, %s! You are %d years old.\n", "Alice", 25);
    my_printf("Percent: %%\n");
    my_printf("Char: %c\n", 'A');
    return 0;
}

运行结果:

Hello, Alice! You are 25 years old.
Percent: %
Char: A

曾经踩过的坑:我曾经在 va_arg 里传错了类型,比如传了 float 但实际参数是 double。记住,va_arg 的第二个参数必须是实际参数的类型,不能是「兼容类型」。float 会被提升为 double,所以必须用 va_arg(args, double)

可变参数的底层原理

你可能会好奇,这些宏到底是怎么工作的?其实原理并不复杂。

在C语言的调用约定中,函数参数是从右往左压入栈的。最后一个固定参数在栈中的位置,就是可变参数列表的起始位置。va_start 就是根据这个固定参数的地址,计算出第一个可变参数的地址。

va_arg 则根据你指定的类型大小,从当前地址取出数据,然后把指针向后移动。说白了,就是「取一块数据,指针后移」的循环。

我画了一张图,帮你理解这个过程:

可变参数在栈中的布局 栈顶(高地址) 可变参数 3(最后一个参数) 可变参数 2 可变参数 1(第一个可变参数) 固定参数(最后一个固定参数) va_start 绑定到这里 va_arg 从这里开始取 栈底(低地址) 参数从右往左入栈,最后一个固定参数紧挨着第一个可变参数

va_copy:复制参数列表

还有一个宏你可能用得不多,但关键时刻很有用——va_copy。它用来复制一个 va_list 变量。

为什么要复制?因为 va_arg 会改变 va_list 的内部指针。如果你需要遍历两次参数列表,就必须先复制一份。

void process_twice(int count, ...) {
    va_list args;
    va_list args_copy;

    va_start(args, count);

    // 第一次遍历
    va_copy(args_copy, args);  // 复制当前状态
    for (int i = 0; i < count; i++) {
        printf("First: %d\n", va_arg(args_copy, int));
    }
    va_end(args_copy);  // 记得清理副本

    // 第二次遍历
    for (int i = 0; i < count; i++) {
        printf("Second: %d\n", va_arg(args, int));
    }

    va_end(args);
}

我的经验:va_copy 在 C99 中才被标准化。如果你在写 C89 的代码,可能需要自己手动保存参数指针。不过现在基本都用 C99 以上了,放心用吧。

常见陷阱与避坑指南

可变参数虽然好用,但坑也不少。我把自己踩过的坑整理了一下:

  • 类型不匹配:va_arg 的类型必须和实际传入的类型完全一致。比如传了 short,但 va_arg(args, short) 会出问题——因为 short 会被提升为 int。正确做法是 va_arg(args, int),然后自己转成 short
  • 忘记调用 va_end:有些实现中,va_start 会动态分配内存。不调用 va_end 会导致内存泄漏。我建议你在函数出口统一调用。
  • 多次调用 va_start:同一个 va_list 变量不能重复调用 va_start。如果需要重新开始,先 va_endva_start
  • 传递 va_list 给其他函数:可以传递 va_list 变量,但要注意被调函数内部也会改变指针状态。如果主调函数还要继续使用,记得先 va_copy

曾经踩过的坑:我曾经写了一个日志函数,把 va_list 传给了 vprintf,然后自己又继续用 va_arg 取参数。结果发现取出来的数据全是乱的。后来才意识到,vprintf 内部已经把指针移到了末尾。从那以后,我只要传递 va_list,一定会先 va_copy 一份。

总结

可变参数是C语言中一个非常实用的特性。掌握了 va_listva_startva_argva_end 这四个宏,你就能写出像 printf 一样灵活的函数。

记住几个关键点:

  • 必须有一个固定参数作为「锚点」
  • va_arg 的类型必须准确
  • 成对使用 va_startva_end
  • 需要多次遍历时用 va_copy

下次你写日志系统、格式化输出、或者需要处理不定数量参数的场景时,别忘了用上这套工具。嗯,相信我,你会感谢自己的。


公众号:蓝海资料掘金营,微信deep3321