C变参函数实现原理

Creative Commons
本作品采用知识共享署名

本文基于arm 32bit说明C变参函数的实现原理。

我们知道C语言中可以借助stdarg.h中的宏实现变参函数,下面是一个简单的实现,可识别%s,%d,%c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdarg.h>

void printf(char *fmt, ...)
{
va_list ap;
va_start(ap,fmt);

while(*fmt){
if('%' == *fmt) {
switch(*(++fmt)) {
case 's': /* string */
s = va_arg(ap, char *);
break;
case 'd': /* int */
d = va_arg(ap, int);
break;
case 'c': /* char */
c = (char) va_arg(ap, int);
break;
default:
c = *fmt;
}
}else{
fmt++;
}
}

va_end(ap);
}

Register
先通过va_start取得参数列表ap,然后根据格式化字符串使用va_arg提取参数,提取完后使用va_end关闭。

基础知识点

本文基于gcc下arm 32bit架构进行说明
参考可得apcs32:

  • 函数参数传递使用r0~r3。当这4个寄存器不够存放参数时使用堆栈传递剩余的参数。
  • 参数的大小和寄存器位宽对齐。例如char型参数也要占用4个字节
    C语言的调用约定采用cdecl:
  • 函数调用者清理堆栈
  • 参数从右向左入栈

函数参数传递可以示意如下

分析

对上面函数对应的汇编截取开始部分

1
2
3
4
5
6
printf(char*, ...):
push {r0, r1, r2, r3}
sub sp, sp, #8
ldr r3, [sp, #8]
add r2, sp, #12
str r2, [sp, #4]

可以看到变参函数开始执行第一件事就是先把传递参数的寄存器入栈,对于变参参数来说无论参数是几个,最后参数都全部在堆栈中:

其解析过程如下图:

  • 先使用va_start通过第一个参数的地址获取va_list,也就是参数在堆栈中的指针ap
  • 在通过va_arg,根据参数的类型取出参数,并按参数的长度移动ap到下一个参数

代码解析

在gcc中var_start,va_arg,va_end三个宏分别对应于gcc的build_in函数__builtin_va_start,__builtin_va_arg,__builtin_va_end,实现代码在gcc的builtins.c中,这些build_in函数在编译时会被优化展开为inline函数,不会额外使用堆栈。编译器内的代码比较晦涩,这里就不展开分析,下面是从vs当作提取的示例宏用于说明其原理

1
2
3
4
5
6
7
typedef  void* va_list;
#define _ADDRESSOF(v) ( &(v) )
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

#define va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )

va_start(ap,fmt)就是:

1
2
ap = (va_list)_ADDRESSOF(fmt) +    \\ 取fmt的地址,fmt是第一个参数,最后一个入栈,所以这里指向栈顶
_INTSIZEOF(fmt) \\加上fmt在堆栈中占用的长度,此时ap指向堆栈中的参数b

执行一次va_arg(ap, int)就是

1
( *(int *)((ap += _INTSIZEOF(int)) - _INTSIZEOF(int)) )

分解开来看:ap += _INTSIZEOF(int) 这里ap被改变,指向了参数c。然后再加上移动的量相当于在b,并取b的值。这样ap指向了下一个c,同时又取回了b参数的值。依次多次调用可以取得参数d,e,f。

关于变参解析的结束,通常情况下我们需要为变参函数指定参数解析停止条件,常见的有:

  • 格式化字符串: 例如本例就是解析格式化字符串,当格式化字符串解析完后,变参也就解析完了
  • 参数指定:例如可以在第一个参数传递变参的数量,根据第一个参数决定执行va_arg的次数

参考

https://github.com/ARM-software/abi-aa/blob/60a8eb8c55e999d74dac5e368fc9d7e36e38dda4/aapcs32/aapcs32.rst
https://www.twblogs.net/a/5e4fcc80bd9eee101e8614c6/?lang=zh-cn