13 printf的可变参数原理

printf 为什么可以接受任意数量参数?

一个神奇的函数

printf("Hello\n");                          // 1个参数
printf("age = %d\n", 25);                  // 2个参数
printf("name = %s, age = %d, pi = %f\n",   // 5个参数
       "Alice", 25, 3.14);

同样的 printf,参数数量可以随意变化。用别的语言可能觉得正常,但 C 是静态类型语言——它是怎么做到的?

你以为的

编译器给 printf 做了特殊处理(printf 是编译器内建函数)。

实际情况:可变参数宏(va_list)

C 语言通过 stdarg.h 提供了一组宏来支持可变参数:

#include <stdarg.h>

void func(int count, ...) {
    va_list args;
    va_start(args, count);   // 初始化,指向第一个可变参数
    int val = va_arg(args, int);  // 取出下一个参数
    va_end(args);             // 清理
}

原理(x86 32位简化版)

在 x86 cdecl 下,参数全部在栈上,且从右到左依次压入:

高地址
┌──────────────┐
│ 最后一个参数  │
├──────────────┤
│    ...       │ ← va_arg 依次往上取
├──────────────┤
│ 第一个可变参数│ ← va_start(args, count) 指向这里
├──────────────┤
│ count (固定) │ ← 栈上的最后一个固定参数
├──────────────┤
│ 返回地址     │
低地址

va_start(args, last) 本质上就是:

args = (va_list)&last + sizeof(last)

va_arg(args, type) 就是:

type value = *(type*)args;
args = (va_list)((char*)args + sizeof(type));

在 x64 上情况更复杂——因为前6个参数用寄存器,va_list 需要同时保存寄存器和栈的状态。

动手验证

保存为 variadic.c

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

int my_printf(const char *fmt, ...) {
    va_list args;
    va_start(args, fmt);
    int count = 0;
    const char *p = fmt;
    while (*p) {
        if (*p == '%') {
            p++;
            switch (*p) {
                case 'd': {
                    int val = va_arg(args, int);
                    count += printf("[int %d]\n", val);
                    break;
                }
                case 'f': {
                    double val = va_arg(args, double);
                    count += printf("[float %f]\n", val);
                    break;
                }
                case 's': {
                    char *val = va_arg(args, char *);
                    count += printf("[str %s]\n", val);
                    break;
                }
                default:
                    count += putchar(*p);
                    break;
            }
        } else {
            count += putchar(*p);
        }
        p++;
    }
    va_end(args);
    return count;
}

void show_va_layout(int first, ...) {
    printf("固定参数 first = %d, &first = %p\n", first, (void*)&first);
    int *p = &first;
    p++;
    printf("  (int*)&first + 1 的值 = %d\n", *p);
    printf("  (注意 x64 上这不是第二个参数的可靠方式)\n");
}

int main() {
    printf("【自定义 my_printf】\n");
    my_printf("Hello %s, age = %d, pi = %f\n", "Alice", 25, 3.14);
    
    printf("\n【类型不匹配的陷阱】\n");
    printf("  printf(\"%%d\", 3.14) = %d\n", 3.14);
    printf("  printf(\"%%f\", 3.14) = %f\n", 3.14);
    
    printf("\n【关于类型提升】\n");
    printf("  可变参数中的 float 会提升为 double\n");
    printf("  char/short 会提升为 int\n");
    
    return 0;
}

编译并运行:

$ gcc -o variadic variadic.c
$ ./variadic

命令解释:gcc -o variadic variadic.c 编译 C 源码。./variadic 执行。

运行结果:

【自定义 my_printf】
Hello [str Alice]
, age = [int 25]
, pi = [float 3.141590]

【类型不匹配的陷阱】
  printf("%d", 3.14) = 2076451400
  printf("%f", 3.14) = 3.140000

【关于类型提升】
  可变参数中的 float 会提升为 double
  char/short 会提升为 int

自己实现迷你 printf

上面的 my_printf 模拟了 printf 的核心逻辑:

  1. 遍历格式化字符串
  2. 遇到 % 就看下一个字符
  3. va_arg 取出对应类型的参数
  4. 用标准 printf 输出

类型提升规则

在可变参数中,整数类型有默认类型提升

实际类型提升后
floatdouble
charint
shortint

所以 va_arg(args, float)错误的,必须用 va_arg(args, double)

类型不匹配为什么不会报错?

因为 va_arg 只是一个宏,它不知道栈/寄存器上的实际类型——你告诉它是什么,它就怎么解释:

va_arg(args, int)     // 从当前位置取4字节
va_arg(args, double)  // 从当前位置取8字节

如果你传了 double 却用 %dint,它只会读取前4字节,不会报错——这就是未定义行为。

va_list 的实现细节

在 x86 上,va_list 就是一个 char*

typedef char* va_list;
#define va_start(ap, last) (ap = (va_list)&last + sizeof(last))
#define va_arg(ap, type)   ((type*)(ap += sizeof(type)))[-1]

但在 x64 上,va_list 是一个结构体,包含寄存器的保存区指针和栈指针:

typedef struct {
    unsigned int gp_offset;   // 下一个通用寄存器参数位置
    unsigned int fp_offset;   // 下一个浮点寄存器参数位置
    void *overflow_arg_area;  // 栈上参数的位置
    void *reg_save_area;      // 寄存器保存区的基址
} va_list[1];

核心启示

  1. 可变参数的底层是内存读取——va_list 是一个指针,va_arg 按类型大小移动指针并读取
  2. 类型安全由程序员保证——如果格式串和参数不匹配,编译不会报错但运行会出问题
  3. 默认类型提升——float → doublechar/short → int
  4. x64 的 va_list 是结构体——因为部分参数在寄存器中,需要特殊的处理逻辑

下期预告

“指向函数的指针”——函数真能像变量一样被传来传去吗?回调函数的底层实现到底是什么?

下一期《函数指针和回调底层是怎样的?》,看间接调用 call [rax] 和直接调用 call func 的区别。


更多内容详见专栏,关注不迷路

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

顾鉴行思

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值