背景
我们在项目中经常会使用开源库进行日志打印,比如easylog
。项目开发过程中,往往因为粗心,会写出如下的代码。
log_i("debug info %s");
或
log_d("num:%d str:%s",num,str,ptr);
有兴趣的朋友可以预测一下这两行代码的输出。
因为这些低级错误,往往出现一些异常现象,比如crash,并且很难排查。
虽然知道和C语言的可变参数知识点有关,但是一直没有深入研究。本文会从原理,实现,如何避免该类问题,进行探讨。
可变参数及其原理
熟悉C语言的朋友,对于可变参数函数肯定不陌生。但是其原理可能不是那么清晰。
可变参数函数都可以分为两个部分:固定参数部分和可选参数部分。至少要有一个固定参数部分,可选参数部分数目不定(0个或以上),声明时,用...
表示。固定参数部分和可选参数部分共同沟通可变参数函数的参数列表。如:
int printf(const char* format,…)
int scanf(const char *format,…)
C语言实现可变参数函数,通过va_list
系列变参函数。定义如下:
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 )
分析:
- _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)
分析该宏含义时,我们需要知道两个知识点。
- 栈的生长方向是由高地址向低地址。
- 默认情况下,函数的参数是通过栈传递,并且从右往左。
其中v
是变参列表前的一个参数。即最后一个固定参数。va_start
宏首先根据(va_list)&v
得到参数 v
在栈中的内存地址,加上_INTSIZEOF(v)
即v所占内存大小后,使ap
指向 v
的下一个参数,即可变参数列表的首地址。如:
printf("hello world %d",num);
其栈空间大致如下:
- va_arg(ap, type)
这个宏取得 type
类型的可变参数值。首先ap += _INTSIZEOF(type)
,即 ap
跳过当前可变参数而指向下个变参的地址;然后ap-_INTSIZEOF(type)
得到当前变参的内存地址,类型转换后解引用,最后返回当前变参值。
- va_end(ap)
va_end 宏使 ap 不再指向有效的内存地址。正如我们指针使用完之后执行NULL一样。
可变参函数的内部逻辑大致如下:
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);
}
由上可知,利用va_list
实现可变参数需要解决两个核心问题:
- 如何确认变参的个数arg_cnt
- 如何确认参数的类型
举例:函数通过固定参数指定可变参数个数,并约定参数列表类型。如下:
//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);
}
输出如下:
yihua@ubuntu:~/test/0221$ gcc 2.c -o 2
yihua@ubuntu:~/test/0221$ ./2
第 1 个参数: 1
第 2 个参数: 2
第 3 个参数: 3
第 4 个参数: 4
---------------
注意:va_arg(ap, type)
宏中的 type
不可指定为以下类型:
- char
- short
- float
在C语言中,调用不带原型声明或声明为变参的函数时,主调函数会在传递未显式声明的参数前对其执行缺省参数提升(default argument promotions),将提升后的参数值传递给被调函数。
提升操作如下:
- float 类型的参数提升为 double 类型
- char、short 和相应的 signed、unsigned 类型参数提升为 int 类型
- 若 int 类型不能容纳原值,则提升为 unsigned int 类型
问:printf
函数是如何解决这两个问题的呢?
- 变参个数。通过固定参数
format
字符串中%
的个数确定。 - 参数类型。通过固定参数
format
字符串中%
后的字符确定。比如%c
,表示该参数是char类型, 则va_arg(p_args, int)
;%f
,表示该参数是float类型,则va_arg(p_args, double)
。
printk
的实现大致如下:
//从传递的栈中获取参数的一些设置
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); //转换成字符输出
}
问题分析
我们再回过头来看看最初两个示例:
log_i("debug info %s");
分析:
通过固定参数中可知,可变参数个数为1,但实际并没有传入实参。也就是说,va_arg(ap,t)
得到的地址,是栈中的一个不确定地址。并将地址上的值,当作字符串输出。很显然,非常容易造成非法地址访问问题。
如下:
//3.c
#include<stdio.h>
int main()
{
printf("hello world %s\n");
return 0;
}
编译&输出:
yihua@ubuntu:~/test/0221$ gcc 3.c -o 3 -Wformat=0
yihua@ubuntu:~/test/0221$ ./3
hello world t▒z
yihua@ubuntu:~/test/0221$
我的运气不错,输出是乱码,并没有出现段错误。
log_d("num:%d str:%s",num,str,ptr);
分析:
从固定参数中可知,可变参数个数为2,但实际传入实参有三个。也就是说最终并不会将第三个实参输出。但是对于函数的内部执行而言,似乎也并不会造成什么异常。
如下:
//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;
}
编译&输出:
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$
如何避免书写错误
如何在工作中避免该类问题呢?其实有一个编译参数可以帮助我们进行检测,那就是-Wformat -Wformat-security
,它可以帮助我们进行固定参数和可变参数的个数和类型校验。如:
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$
将问题在编译阶段暴露出来,避免在调试阶段出现异常,维护成本更高。
总结
可变参数函数是C语言中常见的特性,允许函数接受数量不定的参数。
常见的实现方式是通过va_list
系列函数,包括va_start
、va_arg
、va_end
。本文介绍了其实现原理,希望大家能够得到更进一步的理解。
不正确使用可变参数,可能会导致程序崩溃(段错误)或其他安全问题。例如,如果调用函数时没有提供足够的参数,va_arg可能会访问非法的内存地址。
总结来说,要安全地使用可变参数函数,开发者应该:
- 明确固定参数和可变参数的个数和类型。
- 使用编译器的警告选项(如-Wformat)来检测潜在的问题。
- 在函数调用时提供正确的参数数量和类型。
- 避免在可变参数列表后添加不必要的固定参数,这可能会导致安全漏洞。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途