printf、scanf 这类函数参数个数不固定,靠的是变参函数:形参列表里用 ... 表示后面还可以跟任意个实参。要在自己的函数里读这些参数,靠的是 <stdarg.h>(C++ 里是 <cstdarg>)提供的一整套 va_* 接口:va_list、va_start、va_arg、va_end,以及需要复制状态时用的 va_copy。
本文以 va_* 的原理为主线:它们如何协同工作、背后依赖什么假设,再落到用法、完整示例,以及几个容易卡住的理解点。
va_* 在解决什么问题?
对普通函数,形参个数、类型在声明里写死,编译器按调用约定为每个参数安排寄存器或栈槽。变参函数多了 ...:调用方可多传任意个实参,但被调函数一侧没有名字、没有类型信息。
va_* 就是为此准备的一套「运行时游标」:
- 用
va_list保存「当前读到哪」的状态 - 用
va_start把状态对准第一个可变参数 - 用
va_arg按你给出的类型取出一个参数,并前进状态 - 用
va_end结束这次遍历、做清理 - 需要同一份参数再扫一遍或交给别人时,用
va_copy复制状态
它们大多是宏(现代编译器上常落到 __builtin_va_*),不是普通库函数。整套接口要一起看:缺了初始化,后面无法读;读完不清理,在部分 ABI 上是未定义行为。
还有一点贯穿始终:编译器不知道 ... 里有几个参数、各是什么类型。因此业务代码必须另有约定——固定参数里带个数、格式串(如 "%d%s")、或哨兵值(如 -1 / NULL)。va_* 只负责「按你的指示往前读」,不负责猜协议。
原理:游标如何跟着参数走
经典栈模型
传统 cdecl 一类约定里,参数从右往左压栈,固定参数与可变参数在栈上往往连成一片。此时可以把 va_* 想成在这块连续内存上移动指针。
先说清「最后一个固定参数」指什么。变参函数里,写在 ... 前面、带名字的形参叫固定参数;... 代表的那些没有名字的实参叫可变参数。固定参数里紧挨着 ...、排在最末尾的那一个,就叫最后一个固定参数。例如:
1 | int printf(const char *format, ...); |
va_start 的第二个参数必须写这个名字(如 format / count),用来标定可变参数从哪开始。
1 | 地址由低到高 ---> |
各接口在这个模型里的分工:
| 接口 | 原理上在做什么 |
|---|---|
va_list |
游标本身:保存当前读位置(教学实现里常是 char*) |
va_start(ap, last) |
以 last(最后一个固定形参)为基准,算出第一个可变参数的地址,写入 ap |
va_arg(ap, type) |
按 type 从当前位置读值,再把游标前进 sizeof(type)(含对齐) |
va_end(ap) |
重置/清空游标,结束本次使用 |
va_copy(dst, src) |
复制一份游标状态,便于再次遍历或交给别的函数 |
概念上的朴素实现(仅用于理解,勿当可移植代码):
1 | typedef char *va_list; |
读一遍就能看清协作关系:va_start 赋初值,va_arg 读并前进,va_end 收尾。真正实现里对齐、提升类型等细节更复杂,但骨架如此。
现代 ABI:寄存器 + 结构体游标
x86-64、ARM64 上,不少参数先放进寄存器,装不下的再进栈。此时:
va_list往往是带多个字段的结构(或其一元素数组),而不只是char*- 常见字段类似
gp_offset、fp_offset、reg_save_area、overflow_arg_area,分别跟踪通用/浮点寄存器保存区与栈上溢出区 va_start/va_arg/va_end常落到编译器内建,按 ABI 填表、取数、清理
原理没有变:仍然是一套状态机,记录「下一个参数从哪取」;变的是状态里记的是简单地址,还是寄存器偏移加栈指针。可移植代码应把 va_list 当不透明类型,只用 va_* 操作。
用法与完整示例
标准用法(自己解析每一个参数时):
- 声明
va_list ap va_start(ap, last_named_param)- 循环或按协议调用
va_arg(ap, type) va_end(ap)
示例一:固定参数给出个数
1 |
|
示例二:哨兵标记结尾
1 |
|
示例三:不自己解析,整份转交
若本函数只是包装器,可以把已初始化的 va_list 交给 vprintf、vsnprintf 等 v* 接口——它们内部会走自己的 va_arg。这时你仍需要 va_start / va_end,但不必手写 va_arg:
1 |
|
需要把同一份参数交给多个函数、或扫完再扫一遍时,用 va_copy 复制状态,并分别 va_end。一份 va_list 被消费后,一般不能假定还能原样再扫。
理解点一:va_list(ap)到底是什么类型?
ap 的类型就是 va_list。标准只说它是实现定义类型,用来保存遍历所需信息。
- 简化 / 老平台:常是
typedef char *va_list,ap就是指向参数内存的字节指针;va_start展开后类似ap = (va_list)&count + sizeof(count)。 - 现代 x86-64 等:常是结构体(有的再 typedef 成一元素数组,便于按「数组退化为指针」传递,避免整份状态被错误拷贝)。
va_start由内建填好各字段。
两种形态都服务于同一原理:va_list 是游标状态,不是业务数据本身。想看本机定义,打开系统 stdarg.h 或对源文件预处理后搜 va_list 即可。
理解点二:va_start 为何传「名字」而不是指针?
1 | va_start(ap, last_fix_param); |
第二个参数必须是形参列表里 ... 前面最后一个固定参数的标识符(例如 format),用来标定可变参数从哪开始。
因为 va_start 是宏:内部需要对这个名字做定位(朴素实现里是 &(last) 再加 sizeof;现代实现里是 __builtin_va_start 按 ABI 分析该标识符)。若你传入 &count,展开后会对指针再取地址,位置全错。
同理,第一个参数写 ap 而不是 &ap:宏展开后直接对当前作用域的 ap 赋值,不必像函数出参那样传指针。
理解点三:什么时候可以不写 va_arg?
va_arg 只是「亲手取下一个参数」这一环。若你不亲手解析,就可以不写它,但通常仍要理解整套 va_*:
| 场景 | 仍用哪些 | 可否省 va_arg |
|---|---|---|
| 自己逐个解析 | va_list + start + arg + end |
否 |
转发给 vprintf 等 |
start + end(中间把 ap 交出) |
是 |
va_copy 后整体交给别人 |
start / copy / end |
当前函数可以不写 |
| 协议表明没有额外参数 | 视实现:有时仍 start/end,逻辑上跳过提取 |
可跳过调用 |
| 改用 C++ 变参模板 | 不用 <cstdarg> |
整套都不需要 |
C++11 起的变参模板(以及 C++17 折叠表达式)是另一条类型安全的路线,与 va_* 运行时游标不是同一套机制。
常见坑点
- 类型提升:经
...传入时,char/short升为int,float升为double。va_arg(ap, float)错误,应va_arg(ap, double)。 - 成对使用:
va_start(或va_copy)之后必须va_end;在部分 ABI 上va_end会做真实清理,省略是未定义行为。 - 类型和个数要自己保证正确:
va_arg写的类型必须和调用方实际传入的一致(并考虑上面的类型提升);循环取参的次数也不能超过真实传入的个数。这些编译器都检查不了,写错了运行时才会表现为读到莫名其妙的值,甚至越界崩溃。
小结
va_* 是一套围绕「可变参数游标」的协作接口:va_list 存状态,va_start 定位起点,va_arg 按类型读取并前进,va_end 收尾,va_copy 复制状态。原理上,它们把调用约定里排布好的可变实参,变成函数体内可逐步消费的序列;栈模型用指针加减理解,现代 ABI 用结构体记寄存器与栈位置,骨架相同。
业务侧仍要自己约定个数与类型;需要转发时,把整份 va_list 交给 v* 即可,不必在每个包装函数里重复 va_arg。