一文搞懂系列——可变参数函数实现原理及注意事项

147 篇文章 15 订阅

背景

我们在项目中经常会使用开源库进行日志打印,比如easylog项目开发过程中,往往因为粗心,会写出如下的代码。

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">log_i("debug info %s");
或
log_d("num:%d str:%s",num,str,ptr);
</code></span></span>

有兴趣的朋友可以预测一下这两行代码的输出。

因为这些低级错误,往往出现一些异常现象,比如crash,并且很难排查。

虽然知道和C语言的可变参数知识点有关,但是一直没有深入研究。本文会从原理,实现,如何避免该类问题,进行探讨。

可变参数及其原理

熟悉C语言的朋友,对于可变参数函数肯定不陌生。但是其原理可能不是那么清晰。

可变参数函数都可以分为两个部分:固定参数部分可选参数部分。至少要有一个固定参数部分,可选参数部分数目不定(0个或以上),声明时,用...表示。固定参数部分和可选参数部分共同沟通可变参数函数的参数列表。如:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">int printf(const char* format,…)
int scanf(const char *format,…)
</code></span></span>

C语言实现可变参数函数,通过va_list系列变参函数。定义如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">typedef char * va_list;
// 把 n 圆整到 sizeof(int) 的倍数
#define _INTSIZEOF(n)       ( (sizeof(n)+sizeof(int)-1) & ~(sizeof(int)-1) )
// 初始化 ap 指针,使其指向第一个可变参数。v 是变参列表的前一个参数
#define va_start(ap,v)      ( ap = (va_list)&v + _INTSIZEOF(v) )
// 该宏返回当前变参值,并使 ap 指向列表中的下个变参
#define va_arg(ap, type)    ( *(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)) )
// /将指针 ap 置为无效,结束变参的获取
#define va_end(ap)             ( ap = (va_list)0 )
</code></span></span>

分析:

  • _INTSIZEOF(n)

该宏的目的是内存地址对齐。我们知道变量在内存中的存储遵循着字节对齐的原则。并且int类型的大小表示该系统的字长。因此_INTSIZEOF(n)最终得到的是变量n关于int类型的倍数。比如:
1≤sizeof(n)≤4,则_INTSIZEOF(n)=4;若5≤sizeof(n)≤8,则_INTSIZEOF(n)=8

  • va_start(ap,v)

分析该宏含义时,我们需要知道两个知识点。

  1. 栈的生长方向是由高地址向低地址。
  2. 默认情况下,函数的参数是通过栈传递,并且从右往左。

其中v是变参列表前的一个参数。即最后一个固定参数。va_start宏首先根据(va_list)&v得到参数 v 在栈中的内存地址,加上_INTSIZEOF(v)即v所占内存大小后,使ap 指向 v 的下一个参数,即可变参数列表的首地址。如:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">printf("hello world %d",num);
</code></span></span>

其栈空间大致如下:

  • va_arg(ap, type)

这个宏取得 type 类型的可变参数值。首先ap += _INTSIZEOF(type),即 ap 跳过当前可变参数而指向下个变参的地址;然后ap-_INTSIZEOF(type)得到当前变参的内存地址,类型转换后解引用,最后返回当前变参值。

  • va_end(ap)

va_end 宏使 ap 不再指向有效的内存地址。正如我们指针使用完之后执行NULL一样。

可变参函数的内部逻辑大致如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">int va_args(const char* format,...)
{
    /* 获取可变参数列表第一个参数的地址**/
    va_list p_args;
    va_start(p_args, format);
    
    int idx;
    int val;
    
    /** 核心:遍历可变参数列表*/
    for(idx = 1; idx <= arg_cnt; ++idx){
        val = va_arg(p_args, type);
        printf("第 %d 个参数: %d\n", idx, val);
    }
    printf("---------------\n");

    /** 释放*/
    va_end(p_args);
}
</code></span></span>

由上可知,利用va_list实现可变参数需要解决两个核心问题:

  1. 如何确认变参的个数arg_cnt
  2. 如何确认参数的类型

举例:函数通过固定参数指定可变参数个数,并约定参数列表类型。如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">//2.c
#include <stdarg.h>
#include <stdio.h>

/** arg_cnt表示可变参数列表的个数,并约定可变参数全部为int类型*/
void parse_valist_by_num(int arg_cnt, ...);

int main(void)
{
    parse_valist_by_num(4,1,2,3,4);
}

//第一个参数表示可变参数的个数
void parse_valist_by_num(int arg_cnt, ...)
{
    
    va_list p_args;
    va_start(p_args, arg_cnt);
    
    int idx;
    int val;
    
    for(idx = 1; idx <= arg_cnt; ++idx){
        val = va_arg(p_args, int);
        printf("第 %d 个参数: %d\n", idx, val);
    }
    printf("---------------\n");
    va_end(p_args);
}
</code></span></span>

输出如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-shell">yihua@ubuntu:~/test/0221$ gcc 2.c -o 2
yihua@ubuntu:~/test/0221$ ./2
第 1 个参数: 1
第 2 个参数: 2
第 3 个参数: 3
第 4 个参数: 4
---------------
</code></span></span>

注意:va_arg(ap, type)宏中的 type 不可指定为以下类型:

  • char
  • short
  • float

在C语言中,调用不带原型声明或声明为变参的函数时,主调函数会在传递未显式声明的参数前对其执行缺省参数提升(default argument promotions),将提升后的参数值传递给被调函数

提升操作如下:

  • float 类型的参数提升为 double 类型
  • char、short 和相应的 signed、unsigned 类型参数提升为 int 类型
  • 若 int 类型不能容纳原值,则提升为 unsigned int 类型

问:printf函数是如何解决这两个问题的呢?

  1. 变参个数。通过固定参数format字符串中%的个数确定。
  2. 参数类型。通过固定参数format字符串中%后的字符确定。比如%c,表示该参数是char类型, 则va_arg(p_args, int);%f,表示该参数是float类型,则va_arg(p_args, double)

printk的实现大致如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">//从传递的栈中获取参数的一些设置
typedef char * va_list;
#define _INTSIZEOF(n)   ((sizeof(n)+sizeof(int)-1)&~(sizeof(int) - 1) )		
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap)    ( ap = (va_list)0 )

#define BUFFER_SIZE 4096
static char print_buf[BUFFER_SIZE];
static char num_to_char[] = "0123456789ABCDEF";
//将十进制数据转化为字符型数据
int fillD(char print_buf[],int k,int num,int base)
{
    int i;
    int tmp;
    char tmp_str[BUFFER_SIZE] = {0};
    int tmp_index = 0;

    if(num == 0)
        tmp_str[tmp_index++] = '0';
    else if(num < 0)        //如果是负数的话,记录下符号后转为相反的数
    {
        print_buf[k++] = '-';
        num = -num;
    }
    //将num转化为base进制的数据
    while(num > 0)
    {
        tmp = num % base;     //取最低位元素
        tmp_str[tmp_index++] = num_to_char[tmp];     //入栈,填入字符型数字

        num = num/ base;
    }

    //将字符型数字出栈倒入buf中
    for(i = tmp_index-1;i>=0;--i)
    {
        print_buf[k++] = tmp_str[i];
    }
    return k;
}

//填充字符串
int fillStr(char print_buf[],int k,char * src)
{
    int i = 0;
    for(;src[i] != '\0';++i)
    {
        print_buf[k++] = src[i];
    }
    return k;
}

//处理具体的解析,并输出到printf_buf中  //I , %c take %d years to  %x fin %s ished it\n ;
int my_vsnprintf(char print_buf[],int size,const char *fmt,va_list arg_list)
{
    int i = 0,k = 0;
    char tmp_c = 0;
    int tmp_int = 0;
    char *tmp_cp = NULL;

    for(i = 0;i<size && fmt[i] != '\0';++i)
    {
       if('%' != fmt[i] )       //直接输出的普通格式字符
       {
           print_buf[k++] = fmt[i];
       }
       else             //需要特殊处理的字符
       {
           if(i+1 < size)
           {
               switch(fmt[i+1])
               {
                case 'c':       //处理字符型数据
                 tmp_c = va_arg(arg_list,char);     //获得字符型参数
                 print_buf[k++] = tmp_c;

                   break;
               case 'd':        //处理十进制数据
                   tmp_int = va_arg(arg_list,int);
                   k = fillD(print_buf,k,tmp_int,10);  //填充十进制数据

                   break;
                case 'x':           //处理十六进制数据
                   //填充16进制标志符号
                   print_buf[k++] = '0';
                   print_buf[k++] = 'x';

                   tmp_int= va_arg(arg_list,int);       //获取int型数据
                   k = fillD(print_buf,k,tmp_int,16);  //填充十六进制数据

                   break;
                case 's':           //处理字符串
                    tmp_cp = va_arg(arg_list,char*);        //获得字符串型数据
                    k = fillStr(print_buf,k,tmp_cp);        //填充字符串
                   break;
               }
           }
           else
               print_buf[k++] = fmt[i];         //最后一个字符是%,直接读取即可
       }
    }
    return k;       //返回当前位置
}

//输出缓冲区里的字符
void __put_str(char print_buf[],int len)
{
    int i = 0;
    for(;i<len;++i)
        putchar(print_buf[i]);
}

void printk( char const *fmt,...)
{
    int len = 0;
    va_list arg_list;

    va_start(arg_list,fmt);     //arg_list指向第一个参数的位置(不是fmt)

    len = my_vsnprintf(print_buf,sizeof(print_buf),fmt,arg_list);        //解析参数,并打印到输出中

    va_end(arg_list);       //变参结束

    __put_str(print_buf,len);        //转换成字符输出

}
</code></span></span>

问题分析

我们再回过头来看看最初两个示例:

  1. log_i("debug info %s");

分析:
通过固定参数中可知,可变参数个数为1,但实际并没有传入实参。也就是说,va_arg(ap,t)得到的地址,是栈中的一个不确定地址。并将地址上的值,当作字符串输出。很显然,非常容易造成非法地址访问问题。
如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">//3.c
#include<stdio.h>
int main()
{
    printf("hello world %s\n");
    return 0;
}
</code></span></span>

编译&输出:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-shell">yihua@ubuntu:~/test/0221$ gcc 3.c -o 3 -Wformat=0
yihua@ubuntu:~/test/0221$ ./3
hello world t▒z
yihua@ubuntu:~/test/0221$
</code></span></span>

我的运气不错,输出是乱码,并没有出现段错误。

  1. log_d("num:%d str:%s",num,str,ptr);

分析:
从固定参数中可知,可变参数个数为2,但实际传入实参有三个。也就是说最终并不会将第三个实参输出。但是对于函数的内部执行而言,似乎也并不会造成什么异常。
如下:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-C">//4.c
#include<stdio.h>
int main()
{
    int num = 1;
    char * str = "hello world";
    char * ptr = "C language"; 
    printf("num:%d str:%s\n",num,str,ptr);
    return 0;
}
</code></span></span>

编译&输出:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-shell">yihua@ubuntu:~/test/0221$ gcc 4.c -o 4 -Wformat=0
yihua@ubuntu:~/test/0221$ ./4
num:1 str:hello world
yihua@ubuntu:~/test/0221$
</code></span></span>

如何避免书写错误

如何在工作中避免该类问题呢?其实有一个编译参数可以帮助我们进行检测,那就是-Wformat -Wformat-security,它可以帮助我们进行固定参数和可变参数的个数和类型校验。如:

<span style="color:#333333"><span style="background-color:#f9f5e9"><code class="language-shell">yihua@ubuntu:~/test/0221$ gcc 3.c -o 3  -Wformat -Wformat-security
3.c: In function ‘main’:
3.c:11:23: warning: format ‘%s’ expects a matching ‘char *’ argument [-Wformat=]
   11 |  printf("hello world %s\n");
      |                      ~^
      |                       |
      |                       char *
yihua@ubuntu:~/test/0221$ gcc 4.c -o 4  -Wformat -Wformat-security
4.c: In function ‘main’:
4.c:15:12: warning: too many arguments for format [-Wformat-extra-args]
   15 |     printf("num:%d str:%s\n",num,str,ptr);
      |            ^~~~~~~~~~~~~~~~~
yihua@ubuntu:~/test/0221$
</code></span></span>

将问题在编译阶段暴露出来,避免在调试阶段出现异常,维护成本更高。

总结

可变参数函数是C语言中常见的特性,允许函数接受数量不定的参数。
常见的实现方式是通过va_list系列函数,包括va_startva_argva_end。本文介绍了其实现原理,希望大家能够得到更进一步的理解。

不正确使用可变参数,可能会导致程序崩溃(段错误)或其他安全问题。例如,如果调用函数时没有提供足够的参数,va_arg可能会访问非法的内存地址。

总结来说,要安全地使用可变参数函数,开发者应该:

  1. 明确固定参数和可变参数的个数和类型。
  2. 使用编译器的警告选项(如-Wformat)来检测潜在的问题。
  3. 在函数调用时提供正确的参数数量和类型。
  4. 避免在可变参数列表后添加不必要的固定参数,这可能会导致安全漏洞。

一文搞懂系列——可变参数函数实现原理及注意事项_可变参函数实现原理-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值