C语言变参数函数详解

2023-11-08

一、前言

在C语言中,我们不管是使用标准库函数还是使用自定义的函数,我们都需要注意函数参数的匹配,参数的匹配包括参数类型的匹配和参数个数的匹配,参数不匹配就会导致调用函数错误。

二、printf函数源码

但是在C语言中还提供了一种变参数函数。下面我们以printf函数为例来说明一下。

可以参考一下之前写过的一篇博客:从main函数参数,printf多参数来了解C语言可变参数函数

在printf函数中,我们一般可以控制输入函数的参数:

printf("%d\n", i);
printf("%d  %d\n", i, j);

这样看来,printf函数是有一种机制来处理这种可变参数。

我们先看看printf源码:

int printf
    (
    const char *  fmt,	/* format string to write */
    ...      		/* optional arguments to format string */
    )
    {
    va_list vaList;	/* traverses argument list */
    int nChars;

    va_start (vaList, fmt);
    nChars = fioFormatV (fmt, vaList, printbuf, 1);
    va_end (vaList);

    return (nChars);
    }
    
typedef struct {
	char *a0;	/* pointer to first homed integer argument */
	int offset;	/* byte offset of next parameter */
} 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 )

/*******************************************************************************
*
* printbuf - printf() support routine: print characters in a buffer
*/

LOCAL STATUS printbuf
    (
    char *buf,
    int nbytes,
    int fd
    )
    {
    return (write (fd, buf, nbytes) == nbytes ? OK : ERROR);
    }

C++中有函数重载这种方法,以供我们调用时要可以不确定实参的个数,其实 C 语言也可以,而且更高明!

我们在stdio.h 中可以看到 printf() 函数的原型:

int printf(char * format,…)

事实上,我们如果要写这样的函数也可以类似的写,那么在定义函数时用上这个符号“ … ” ,它叫占位符,喊它 “ 三个点 ” 也可以,只要你愿意!那么我可以这样定义我的函数:

fun(int a,…) { }
要实现可变参数函数,我们需要用到下面几个比较重要的东西:

va_list
va_arg()
va_start()

三、C语言函数调用堆栈过程

在此之前我们需要了解一下函数调用时的传参过程,在调用函数时,程序同样会把实参传入,在函数存储区保存起来,如果有很多参数,将一起保存起来。

调用约定

函数的调用约定很多,常见的包括__stdcall,__cdecl,__fastcall,__thiscall等等。
主要的区别在于约束的三个事件,一个是***参数传递*是从左开始呢还是从右开始,还有就是堆栈清理的清理方*是调用者还是被调用者。另外来说不同的函数调用约定函数产生的符号名称不同*

举个栗子,对于cdecl,参数是从右到左传递,堆栈平衡是由调用函数来执行的;而win32API一般使用的是stdcall,参数同样是采用了从右往左传递,而函数的堆栈平衡则是由被调用函数执行(不支持可变参数);fastcall参数直接放入寄存器而非栈中,规定前两个参数分别放入ecx和edx中,当寄存器用完时候参数才按照从右往左的顺序压入堆栈。

调用约定 使用场景
_cdecl c调用约定
_stdcall windows标准调用约定
_fastcall 快速调用约定
_thiscall C++成员函数调用约定

压栈过程

int add(int a, int b)
{
    return a+b;
}

int main()
{
    int a = 1;
    int b = 2;
    int res = add(a,b);
    return 0;
}
  1. 首先从main函数初始,ebp和esp分别存放函数的栈底地址和栈顶地址,此时ebp-4即是a,ebp-8则是b的地址。

  2. 然后调用函数add,第一先将参数从右往左依次入栈,push在调用方的函数栈当中,也就是说此时esp往里开辟了两个参数。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0FChzmap-1636259482979)(https://segmentfault.com/img/bVbiV1J?w=838&h=517)]

  3. 执行call指令,首先将下一条指令地址进行入栈,
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4JOT96Cs-1636259482981)(https://segmentfault.com/img/bVbiV1K?w=862&h=499)]

  4. 随后开辟新栈,进行现场保护
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tTU10Ehn-1636259482982)(https://segmentfault.com/img/bVbiV5E?w=1210&h=673)]

    • 这里省略了现场保护的过程,主要做的就是push了多个寄存器(例如edi,ebx,edi)的值,在完成后还原现场进行pop,对程序没有什么其他影响这里省略。
    • 将edi置为栈顶地址,ecx置为11h,eax置为0CCCCCCCCh。
    • 从edi开始循环拷贝eax,也就是将整个栈内初始化为0CCCCCCCCh,也就是常见的“烫”。
  5. 执行add函数
    开辟一个临时变量,值是***(a)*(ebp+8) + *(b)*(ebp+0Ch)**,将这个值放入eax中。

  6. 执行完成后回退栈针
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P5oZUjLq-1636259482983)(https://segmentfault.com/img/bVbiWEw?w=1162&h=499)]

    • 首先mov esp,ebp 将add的函数栈回退。
    • 随后pop,将[ebp]的值弹出给ebp,也就是ebp弹回退到main函数的栈底。
    • 执行ret指令,将下一条指令的地址弹出给eip寄存器。
    • 随后在main函数的函数栈中回退形参变量所占用的内存 add esp+8。

那么再来看看其他情况

上面的返回值是一个int类型,也就是C的内置类型,通过eax寄存器带出。

如果是一个double或者long long呢?那么可以通过eax、edx两个寄存器带出。

如果是一个自定义类型呢?其实也是类似的:

  • 首先在参数传参过程中不能直接去push一个寄存器了,而现在是通过开辟内存后,将自定义类型的实参b的地址放入esi中,循环赋给实参。
    例如说自定义类型的b参数
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KZQQKjut-1636259482984)(https://segmentfault.com/img/bVbiWEW?w=739&h=526)]

  • 参数传递完成之后,再来看看返回值,返回值首先会在压入所有的形参之后,将main函数中返回值(临时量)的地址压入参数。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-abqMedRY-1636259482985)(https://segmentfault.com/img/bVbiWE7?w=973&h=583)]

  • 对返回值的操作也是类似的,通过esi和edi、ecx,循环拷贝到main函数的函数栈之中。

  • 临时量返回值的地址是最后才会压栈的,那么它的地址一定是ebx+8

四、C语言实现可变参数详解

虽说是可变参数,但也并不是完全自由的,对于任意的可变参数函数,至少需要指定一个参数,通常这个参数包含对传入参数的描述(下面会提到原因)。
可变参数的实现依赖下列几个库函数(宏定义)的定义:

va_list           //这是一个特殊的指针类型,指代栈中参数的开始地址
va_start(ap,T)    //ap为va_list类型,T为函数第一个参数
va_args(ap,A)     //ap为va_list类型,A为需要取出的参数类型,如int,char
va_end(ap)        //ap为va_list类型。

这些变量类型或者宏定义的含义如下:

typedef struct {
	char *a0;	/* pointer to first homed integer argument */
	int offset;	/* byte offset of next parameter */
} 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 )

接下来我们便动手实现一个可变参数函数add(),返回所有传入的int型参数之和:

int add(int cnt, ... )
{
    int sum=0;
    va_list args;
    va_start(args,cnt);
    for(int i=0;i<cnt;i++)
    {
        sum += va_arg(args,int);
    }
    va_end(args);
    return sum;
}
int main()
{
    
    printf("%d\r\n",add(4,1,2,3,4));
    return 0;
}

程序输出结果:

10

老规矩,看完示例我们来探究一下示例实现的原理:

  • va_list args;这一条语句即定义一个va_list类型(可以看成是一种特殊的指针类型)的变量args,args变量指向的对象是栈上的数据。
  • va_start(args,cnt);这一条语句是初始化args,args指向第一个被压栈的参数,即函数的最后一个参数,而cnt则是栈上最后一个参数,系统由此确定栈上参数内存的范围。
  • va_arg(args,int); 这个函数需要传入两个参数,一个是指向栈上参数的指针args,这个指针每取出一个数据移动一次,总是指向栈上第一个未取出的参数,int指代需要取出参数的类型,CPU根据这个类型所占地址空间来进行寻址。
  • va_end(args);当args使用完之后,要将args置为空。
    整个函数实现的过程就是我们不需要通过形参来获取实参的值,而是直接从栈上将一个个参数取出来。

下面我用图来说明一下这个过程:

在这里插入图片描述

#define va_start(ap,v)	( ap = (va_list)&v + _INTSIZEOF(v) )

这条语句就是获取add(4,1,2,3,4)中第二个参数的地址(即除去参数cnt外的第一个参数)

#define va_arg(ap,t)	( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )

这条语句有两个作用:一是返回 ap 指向的参数的值;二是让 指针 ap 移动了sizeof(t)的大小,这让我们在获取下一个参数时不需要再移动指针。

这样通过参数指针和参数类型,我们就可以一个一个的把参数取出来。

五、需要关注的一些问题

在这里,我们需要关注几个问题:

  1. 压栈顺序从右往左是怎样实现可变参数传递的?
  2. printf()函数和上述的add()实现都在可变参数前至少提供了一个具体参数,可不可以省略这个参数呢?
  3. 在使用va_arg()取出函数的值时需要指定类型,如果指定一个错误的类型会怎么样呢?

第一和第二个问题其实可以同时来解释,参数从右往左压栈,在可变参函数调用时,先将最后一个参数入栈,最后将第一个参数入栈,可变参数主要是通过第一个参数来确定参数列表,但是这时候如果第一个参数没有被指定的话,编译器将无法定位参数在栈上的范围。

同时,如果可变参数函数在定义时没有第一个参数的话,编译器直接报错。(gcc)

test.c:10:10: error: ISO C requires a named argument before ‘...

va_arg对应类型问题

我们再回到第三个问题,如果在va_arg()函数中传入一个错误的类型会发生什么情况呢?

下面是我传入一个int型数据,但是在用va_arg()获取参数时传入了char类型,编译时的信息:

warning:char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

警告信息,但是不会报错,依然可以运行,那我们就运行看看结果:

Illegal instruction (core dumped)

果然,如编译时的警告预料的,当执行到那部分代码时,程序就会终止运行。

这是为什么呢?

其实原因也并不难想到,被调用函数并不知道参数的类型和个数,所以只能依靠用户给的信息来寻址获取数据,如果指定错误的类型,很可能会导致栈上数据的混乱,但是这里博主发现一个有意思的问题:

如果传入的参数为char类型,我们在从栈上取参数的时候也指定char类型参数:

sum += va_arg(args,char);

按理说这是完全没有问题的,但是在编译的时候依然会有以下提示:

warning:char’ is promoted to ‘int’ when passed through ‘...’
sum += va_arg(args,char);                  ^
note: (so you should pass ‘int’ not ‘char’ to ‘va_arg’)
note: if this code is reached, the program will abort

这是为何?

传入的类型和指定接收的类型是匹配的,为什么提示有问题。然后我运行了一次,结果是这样的:

Illegal instruction (core dumped)

我突然想到,printf中也会传入char类型,我看看它是怎么实现的。

case 'c': 
    handle_char(va_arg(arg, int)); 
    continue;

看起来在printf实现中,对传入的char类型的数据,也是根据int类型从栈上获取数据,char是一个字节,int是4字节(32位),这样不会出问题吗?

理论上来说,当程序取一个int型数据时,就在栈上获取了四字节数据,除了这个参数,还会把前一个参数(从右到左压栈)的前三个字节取出来,势必会导致数据的混乱。

但是,计算机系统中还有一个概念就是对齐,不管是数据结构填充还是指令和数据的存储,这是为了寻址时的方便,所以即使是将一个char类型数据压栈,也会占用一个int类型的空间。

所以我们再来分析为什么传入char类型的同时取出char类型的实参会导致程序运行失败:

当使用sum += va_arg(args,char);获取参数时,获取了一个字节的数据,但是由于对齐,后面填充的三个字节依然放在栈上。

当下一次取参数时,仍然取一个字节,取出的事实上是第一个参数的第二个字节,这时候会有6个字节仍然在栈上,以此类推。

最要命的是:栈上存储着函数的返回地址,当参数都取完时,再取返回地址,这时候自然取不到真正的返回地址,而是取到了参数,程序跳转到了未知的地方,所以程序运行自然失败。

ref: https://segmentfault.com/a/1190000016869401
https://www.cnblogs.com/downey-blog/p/10471220.html

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C语言变参数函数详解 的相关文章

  • 打印机漏洞(rce)

    20210510 0 出发 在翻阅论文的时候 突然想起来之前的时候 看到过一些打印机的漏洞 然后就在谷歌上搜索了一下 printer rce 然后找到了这篇文章 A Sheep in Wolf s Clothing Finding RCE
  • 老鸟重写程序需要准备点什么

    整体来说 老鸟工作已久 对语言 架构 算法 性能 安全 业务 各类型特点会掌控能力更高 但是年久未动手 不免生疏 为此专门整理需要的基本内容 可以抽空回味一下 在紧急上手之后 两周内查缺补漏 区别与新手面对任何问题的一脸懵逼 老鸟对所有技术
  • Redis配置优化

    Redis Redis 远程字典服务器 是一个开源的 使用c语言编写的NoSQL数据库 Redis 基于内存运行并支持持久化 采用key value 键值对 的存储形式 是目前分布式架构中不可或缺的一环 Redis服务器程序是单进程模型 也

随机推荐

  • 21世纪的管理挑战

    朋友很早前推荐看的德鲁克系列 最近在孔网搞到了 顺便在此记录读书笔记和想法 如下 第一章 管理的新范式 管理是企业管理 新学科 公共管理 不同组织的任务和挑战也不存在巨大的差异 企业必须具有一个恰当的组织形式 组织不是绝对的 它是提高人们在
  • 科学计数法 C语言

    题目 科学计数法是科学家用来表示很大或很小的数字的一种方便的方法 其满足正则表达式 1 9 0 9 E 0 9 即数字的整数部分只有 1 位 小数部分至少有 1 位 该数字及其指数部分的正负号即使对正数也必定明确给出 现以科学计数法的格式给
  • gcc (GNU编译器套件)

    gcc GNU编译器套件 编辑 GNU编译器套件 GNU Compiler Collection 包括 C C Objective C Fortran Java Ada和 Go语言的前端 也包括了这些语言的库 如libstdc libgcj
  • va_list(),va_start(),va_arg(),va_end()

    va list va start va arg va end 详解 一 写一个简单的可变参数的C函数 下面我们来探讨如何写一个简单的可变参数的C函数 写可变参数的C函数要在程序中用到以下这些宏 void va start va list a
  • python redis 获取所有key

    使用scan代替getKeys 线上的登录用户有几百万 数据量比较多 keys算法是遍历算法 复杂度是O n 也就是数据越多 时间越高 数据量达到几百万 keys这个指令就会导致 Redis 服务卡顿 因为 Redis 是单线程程序 顺序执
  • Nodejs——时间戳与日期相互转换

    时间格式化的库 silly datetime 安装 npm i silly datetime save var sillyDateTime require silly datetime 获取当前时间 并转换为年月份 时分秒的格式 conso
  • Mybatis 插入大量数据性能问题的解决(Caused by: java.sql.SQLException: ORA-04030: 在尝试分配 2024 字节 (kxs-heap-c,kg hs)

    最近写的需求 需要频繁的往数据库中插入大量的数据 多达上万条 最后导致oracle 数据库直接挂掉了 这个问题肯定要解决的 主要的原因就是一次性插入这么多数据 oracle 数据库承受不住 最后 报Caused by java sql SQ
  • linux 汇编 cqo,x64asm: 包括内存汇编程序,解析器和链接器的C ++库

    x64asm x64asm is a c 11 library for working with x86 64 assembly It provides a parser in memory assembler and linker and
  • oracle表的常见字段类型有哪些,Oracle数据库的字段类型

    字 段 类 型 CHAR 固定长度字符串 最大长度2000 bytes VARCHAR2 可变长度的字符串 最大长度4000 bytes 可做索引的最大长度749 NCHAR 根据字符集而定的固定长度字符串 最大长度2000 bytes N
  • k8s七

    参考资料 深入剖析Kubernetes 张磊 目录标题 一 DaemonSet 简介 二 DaemonSet的实现原理 1 DaemonSet是如何确保每个节点只运行一个Pod 2 如何只在指定的节点上运行Pod 3 污点与容忍 三 使用D
  • 利用sprintf和sscanf实现十六进制和十进制之间的相互转换

    利用sprintf和sscanf实现十六进制和十进制之间的相互转换 2013 10 27 12 49 7497人阅读 评论 0 收藏 举报 分类 C C 语言 369 版权声明 本文为博主原创文章 未经博主允许不得转载 cpp view p
  • 金蝶 K3 ERP 采购管理 表结构明细 POOrder/Entry

    select from t TableDescription 金蝶K3表名备注 t tabledescription 采购订单POOrder 单头 FBrNo 公司机构内码 STRING 公司机构内码 FTranType 单据类型 INTE
  • X.509数字证书内容结构

    更多区块链技术与应用分类 区块链应用 区块链开发 以太坊 Fabric BCOS 密码技术 共识算法 比特币 其他链 通证经济 传统金融场景 去中心化金融 防伪溯源 数据共享 可信存证 X 509证书 数字证书是现代信息安全的核心技术 无论
  • Calendar类常用方法

    Calendar常量 field 的作用 Calendar cal Calendar getInstance cal get Calendar DATE 当天 1 31 cal get Calendar DAY OF MONTH 当天 1
  • JTest的使用

    jtest 项目中用到了JTest 一款商业化java白盒测试工具 开个头慢慢补充 简介 jtest是parasoft公司推出的一款针对java语言的自动化白盒测试工具 它通过自 动实现java的单元测试和代码标准校验 来提高代码的可靠性
  • elasticsearch集群文件及路径设置

    es集群文件路径 1 数据目录 日志目录以及插件目录 默认情况下es会将plugin log data config file都放在es的安装目录中 这有一个问题 就是在进行es升级的时候 可能会导致这些目录被覆盖掉使我们集群中的文件或数据
  • Postman应用——下载注册和登录

    文章目录 下载安装 注册登录 注册账号 登录账号 下载安装 Postman下载 https www postman com 访问链接后 进入首页 根据自己的操作系统下载对应的版本 找到下载到的目录直接双击 exe文件 会默认安装在C盘 安装
  • LeetCode(力扣)题目中二叉树的如何生成?根据给定顺序列表生成二叉树(python)

    在刷 leetcode 二叉树相关的题目时 经常有这样给定的例子 例如 检查平衡性 实现一个函数 检查二叉树是否平衡 在这个问题中 平衡树的定义如下 任意一个节点 其两棵子树的高度差不超过 1 示例 1 给定二叉树 3 9 20 null
  • Mybatis-plus 分页排序 错乱-丢失

    今天生产环境出行了一个分页排序错乱的问题 当时有点懵 用的mybatis plus的分页插件实现的 往常也用但是没有出现这个 分页排序 错乱 丢失问题 说实话当时有点懵 经过排查分析 得出了结果 Mybatis plus 分页排序 错乱 丢
  • C语言变参数函数详解

    文章目录 一 前言 二 printf函数源码 三 C语言函数调用堆栈过程 调用约定 压栈过程 那么再来看看其他情况 四 C语言实现可变参数详解 五 需要关注的一些问题 一 前言 在C语言中 我们不管是使用标准库函数还是使用自定义的函数 我们