目录

C可选参数函数原理

C 语言中<stdarg.h>提供的可选参数函数功能,记住一个宏调用流程va_list va_start va_arg va_end就可以很方便实现一些灵活的函数,但没啥大用。

研究它的实现原理反而更有价值,能够帮助我们深入理解CPU底层的栈机制实现函数调用的细节和要点。因为是CPU提供的栈机制,所以可选参函数在IA3232位与x86-6464位CPU上的实现细节是不一样的。

研究原理

代码环境是:Ubuntu 18.04 Gcc 64位CPU

编译环境默认是按 64 位进行编译的,所以要编译成32位(使用gcc-S -m32参数)的汇编代码进行分析,还必须安装 32 位的库:

sudo apt-get install libc6-dev-i386

示例代码如下,num 就是供定位的最后一个命名参数(注意,这里强调下sum可能是第二、第三个参数,这是汇编器无法确认的,正是由于这点才影响了实现)

#include <stdio.h>
#include <stdarg.h>
long sum( long num, ... ){
    va_list ap;
    va_start(ap, num);
    long sum = 0;
    while( num--)
        sum += va_arg( ap, long );
    va_end(ap);
    return sum;
}
long f32( long a, long b, long c ){
    return a + b + c;
}
long f64( long a1, long a2, long a3, long a4, long a5, long a6, long a7, long a8 ){
    return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8;
}
int main(){
    printf( "f32 = %ld\n", f32( 1L, 2L, 3L ) );
    printf( "sum(1..8) = %ld\n", f64( 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L) );
    printf( "sum(1..8) = %ld\n", sum(8L, 1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L) );
    return 0;
}

思考下可选参数函数的含义:我们调用该函数时,可选参数的个数可以是任意的,每个可选参数的类型是任意的。

第一点,如何告知函数内部,可选参数的个数?目前的做法:

  • 指定一个数值(比如sum()num参数)命名形参,告知函数内部:有几个可选参数

  • 指定一个格式化字符串(比如printf()%d %s)命名参数,间接达到告知可选参数个数的目的

第二点,失去了形参名字,在函数内部如何使用可选参数?

  • 根据 C 函数调用参数传递的过程的分析(32位与64位不同,见下文),发现可以通过 命名参数 + 偏移值 来间接使用每一个可选参数,并且命名参数要指定是最后一个

第三点,每一个可选参数距命名参数偏移值如何计算得知?

  • C 语言函数传参,是无法告知 函数内部 参数的类型信息的,所以类型信息必须由函数内部指定(sum()内部指定每一个可选参数都是long型;printf()则通过%d格式识别,分别指定数据类型)。

  • 那么,在最后一个命名参数以及其类型可知的情况:

    • 第1个可选参数的首地址就是:命名参数地址 + sizeof(命名参数类型)

    • 第2个可选参数地址:第1个可选参数地址 + sizeof(第一个可选参数类型)

    • ... 以此类推

32位实现

将示例代码按照32位CPU模式编译,命令如下:

gcc -Og -m32 -fno-stack-protector -S main.c -o main.s 

从上图中,我们很容易得出一个结论,那就是 C 函数调用约定:调用者caller向被调用者callee传递的参数放在栈中,并且按参数列表中从右向左的顺序压栈。

通过上面的分析,我们尝试自己来实现可选参数,首先看下有两个可选参数的情况下,如何在函数内部使用它们:

使用宏让代码更加规整:

上面的next_arg宏操作,它的作用是利用arg_ptr指针 和 指定数据类型long,取得当前的可选参数,然后再将指针移动到下一个可选参数处。所以应该是先*取值,然后再增加+=字节的操作。

但是在宏里,无法按这个次序写出代码,所以采取了指针先+= sizeof(type)移动到下一个可选参数地址处,然后再- sizeof(type)获得一个临时地址,用于取当前可选参数的值。

至此,我们将num的也用上,就可以推广到全部可选参数,其实test_arg已经就是sum()函数了。

下面再看下my_print的实现,看看格式化字符串如何使用可选参数:

64位实现

将示例代码按照64位CPU模式编译,命令如下:

gcc -Og -fno-stack-protector -S main.c -o main.s 

将上图与32位的代码对比下,很容易就得出一个结论,64位下前6个参数都是通过寄存器直接传递到callee中,剩下的再通过栈的形式传递。

通过命名函数 + 偏移值根本无法定位到寄存器中的参数,所以无法使用这种方式定义可选参数函数了!

《揭密X86架构C可变参数函数实现原理》 一文对64位下可选参数函数的实现进行了逆向分析,可以参考下,本文想从正向的角度分析如何去实现,只能等学习完Gcc编译后再来填坑了。