C编译器剖析_C语言的变参函数

C编译器剖析_前言 让我们站在C编译器实现的角度重新认识自己朝夕相伴的C语言,如果你熟悉C语言,并且有基本的数据结构的知识,那就让我们一起开启C编译器剖析这趟旅程。众里寻她千百度,蓦然回首,She is C。那我们就启程吧。 阅读详情

C语言的变参函数

    UCC编译器中有不少地方使用了C语言的变参函数,这里我们专门用一小节来对C语言变参函数的实现原理进行分析。C标准库中的printf函数就是一个典型的变参函数,其接口如下所示,函数声明中的省略号…表明这是一个变参函数。

         int printf(const char *format, ...);

    下面我们举一个简单的例子来说明printf函数的调用过程,如图4.2.12所示。图中第1至11行对应的是hello.c,而第12至25行是由UCC编译器生成的抽象语法树hello.ast,第26至33行则是UCC产生的中间代码hello.uil,第34至52行则是UCC生成的部分汇编代码hello.s。


图4.2.12 printf()函数

     我们注意到第9行的实参a是float类型,而d是char类型,第17行的抽象语法树的功能是把实参a从float转换成double类型,而第21行则用于把实参d从char转换成int类型。我们在2.4节讨论C语言的类型系统时介绍过,对于形如int f()的旧式风格函数声明,C编译器会按C标准的要求进行实参提升的操作,在UCC编译器中,这个动作由函数PromoteArgument()来完成。对于变参函数中的“无名”参数,例如上述第9行printf函数调用中格式化字符串之后从a开始的参数,这些参数在函数printf的声明中并没有与之对应的形参名。按C标准的要求,C编译器也要对这些无名参数进行实参提升,即把小于int型的char和short提升为int类型,把float类型提升为double类型。图4.2.12第29至31行的中间代码很直观地反映了这个实参提升的过程。与之对应的汇编代码如第39至52行所示,第39至40行的代码把float类型的a转换为double类型,把转换后的结果存到临时变量-12(%ebp)中,我们在1.5节时介绍过与浮点运算相关的汇编指令。按照C函数的调用约定,参数按从右到左的次序依次入栈,第41行的汇编指令完成了把参数d由char到int的转换,第42行把转换后的结果入栈,第43行则把参数c入栈,第44至46行则从全局静态数据区加载双精度浮点数b并入栈,第47至49行则从临时变量-12(%ebp)中加载双精度浮点数,并入栈。第50行把格式化字符串的首地址入栈,我们在第38行时已将其地址存到寄存器eax中,第51行则进行真正的函数调用,因为所有的参数都是存放在栈中,共占去了4+8+8+4+4(即28)字节,当函数printf返回时,我们在第52行把esp指针进行加28的操作。

     库函数printf()的代码在我们编写上述hello.c时就已经存在,这意味着被调函数printf其实并不知道我们在调用它时,到底传递了几个实参。对printf而言,它只是按照格式化字符串的说明,从栈中取出相应的参数,如下所示:

         // 实际上只有10这一个参数,但printf看到有两个%d,

         // 于是仍试图从栈中取两个参数,打印出形如10,1074172310的垃圾值

         printf(“%d, %d “,10);       

         // 实际上有10,20,30这3个参数,但printf只看到一个%d,

         // 于是只打印出参数10

         printf(“%d”, 10,20,30);   

     图4.2.13更清楚地描述了图4.12中代码的内存布局,虚线的左侧为数据区中的栈区,右侧为全局静态数据区。在栈区中,我们标出了真正入栈的参数类型依次为int、int、double、double和char *,它们共占用了28个字节的栈空间。而格式化字符串实际上是存放在全局静态数据区的,压入栈中的只是该字符串的首地址。


图4.2.13 栈示意图

     下面,让我们换个角度来看问题,假设我们是printf库函数的实现者,在printf函数的函数体内,我们通过形参format就可以访问到全局静态数据区中的格式化字符串,通过表达式&format,我们就可以知道format在栈中的内存地址。由图4.2.13所示的内存布局,我们可由&format计算出其他参数的地址,有了这些参数的内存地址,我们就可以访问它们。为计算方便,让我们不妨假设&format的值为十进制的10000,由图4.2.13可算出format上方的4个无名参数对应的地址依次为十制制的10004、10012、10020和10024。其计算过程如下所示:  

         int printf(const char *format, ...){

                   unsigned int addr =&format;

                   // 与“a = %f”中%f对应的参数类型为double,地址为

                   addr + sizeof(char *),即10004

                   // 与“b = %f”中%f对应的参数类型为double,地址为

                   addr + sizeof(char *)+sizeof(double),即10012

                   // 与“c = %d”中%f对应的参数类型为int,地址为

                   addr + sizeof(char*)+sizeof(double) + sizeof(double),即10020

                   // 与”d = %c”中%c对应的参数类型为char,地址为

                   addr + sizeof(char*)+sizeof(double) + sizeof(double)+sizeof(int),即10024

         }

     在知道内存单元的地址,且知道该内存单元对应的类型的前提下,访问该单元的内容则是件容易的事情,例如要访问上述地址为10004的double类型的内存单元,我们只要用C语言写出如下代码,然后通过表达式*dptr进行“提领”操作,我们就可以为所欲为了。

         double * dptr = (double *) (addr +sizeof(char *));

     按照这样的思路,我们可以写出如下所示的OurPrintfV1变参函数,用于从栈中取出format之上的其他“无名的”参数,这个函数纯粹是为了演示如何根据形参format的地址来访问其他“无名的”参数,如图4.2.14所示。需要注意的是,我们有意忽略了对格式化字符串的处理过程,虽然这只是一个简单的字符串判断,并不会太复杂。在函数体中添加的printf调用,纯粹是为了验证我们确实正确地从栈中取出了参数。


图4.2.14 OurPrintfV1()

     为了让图4.2.14中的代码看起来更优雅些,我们会引入一些宏来处理“由format的地址来定位其他参数”的过程,图4.2.15中的OurPrintfV2()完成的工作与OurPrintfV1()类似,但看起来简洁多了。


图4.2.15 OurPrintfV2()

     我们在前面介绍过,C编译器会对变参函数中的“无名”参数进行实参提升的操作,所以真正入栈的实参所占的内存大小都会是sizeof(int)的整数倍,图4.2.15第9行的宏定义ALIGN_INT(n)完成了这个对齐的操作。假设sizeof(char)为1,sizeof(short)为2且sizeof(int)为4,则对以下宏来说,

         宏ALIGN_INT(char)展开后为(1+3)&(~3),即4

         宏ALIGN_INT(short)展开后为(2+3) &(~3) ,即4

    我们知道,一个整数乘以4,相当将其左移2位,这意味着对任何一个为4倍数的整数来说,其二进制形式的最低2位都是0。上述(1+3)和(2+3)运算的目的是为了得到一个不小于4的数,而&(~3)运算的目的则是把这个不小于4的数的低2位清0,从而得到我们需要的对齐结果。基于此,再对比图4.2.14和图4.2.15,我们可以很清楚地看到第16行的宏定义va_start()所做的工作就是取形参format的地址,然后做&format+sizeof(format)的运算,并把这个值存放到va_list ap中,相当于让ap指针指向了无名参数的开始位置。同时,我们还注意到第11行的宏定义va_arg中也使用ALIGN_INT()进行了对齐操作,如果不进行对齐操作,即把va_arg()定义为:

         #define   va_arg(list, t)  (*(t *)((list+= sizeof(t)) - sizeof(t)))

    不妨假设变量list的值为十进制的20000,则把宏va_arg(list,char)展开后得到的是如下表达式,此表达式实际上相当于*((char*)20000),即把内存单元20000当作一个char来访问,但是该表达式是有副作用的,完成求值后,list变量的值变为20001。无名参数入栈时在经实参提升后,已经按sizeof(int)进行对齐,如果接下来按照一个不是4的整倍数的地址20001,去取下一个参数,那么,我们无法正确的访问到所需的参数。

         (*(char *)((list += 1) - 1))

    宏定义va_arg是否有进行形如图4.2.15第9行那样的对齐,与实际所用编译器的头文件有关。如果再考虑到机器有大端和小端的区别,在大端机器上,图4.2.13栈中的字符'q'所在的内存地址是10027,而不是小端机器上的10024。最稳妥最有可移植性的做法就是不要去使用va_arg(list,char)、va_arg(list,short)或者va_arg(list,float)。如果一定要地址10024对应的内存单元中取出char类型的参数,则可写为:

     char ch = (char) va_list(ap,int);

    因为C编译器已经对变参函数的“无名参数”进行了实参提升,在栈中真正存在的“无名参数”不会是char,也不会是short,更不会是float,所以在图4.2.15第20行中我们用的是va_arg(ap,int),而非va_arg(ap,char)。得到int型的整数后,再根据需要将之强制转型为char,这样的做法对大小端机器都适用。同理,va_arg(list,short)也存在和va_arg(list,char)类似的问题。对va_arg(list,float)来说,sizeof(float)为4,而在栈在实际存在是double类型的参数,要占8个字节,而float和double的编码是不一样的,所以使用va(list,float)也会出问题。C语言简洁而有力,但是要较好地驾驭C语言,则需要对形如图4.2.13的内存布局有较清楚的理解。在很多时侯,一些C程序员无法较好使用C语言指针的原因在于“对相关内存布局没有较清晰的概念”。C++实际上也对程序员提出了类似的要求,即便是在有意淡化指针概念的Java语言,如果对内存布局完全没有概念,也是有可能写出如下所示的Java代码。该程序员原本期待两次对bg.f()的调用能各打印出5条Hello,即共10条Hello,但却诧异地发现一共只有5条Hello。

         class Bug{

                   int i= 0;

                   public void f(){

                            for(;i < 5; i++){

                                     System.out.println(“Hello”);

                            }

                   }

                   public static void main(String args[]){

                            Bug bg = new Bug();

                            bg.f();

                            bg.f();

                   }

         }

    当然,实际使用va_arg等变参函数的宏时,我们只需要包含标准头文件stdarg.h就可以,并不需要显示地定义图4.2.15中第8至12行的宏。我们可在UCC编译器的头文件ucl\linux\include\stdarg.h中找到这些宏。在有些情况下,我们在形如图4.2.15的变参函数OurPrintfV2中,只是想做一些准备工作,真正对栈中的“无名参数”进行访问的操作,我们还是想交由别的函数来处理,比如ucl\error.c中的Do_Error函数。如图4.2.16所示。


图4.2.16 Do_Error()

    图4.2.16第7行,我们使表示错误个数的全局变量ErrorCount加1,第10行则打印出错的源代码文件名和出错的行号,但是打印Do_Error函数的“无名参数”中包含的其他错误提示的工作,我们还是想交给库函数vfprintf(),如图4.2.16第14行所示。Do_Error的函数调用如下所示:

    Do_Error(coord, "struct member %s doesn't exsist", “abc”);

    结合图4.2.13,我们很容易知道,vfprintf()函数只要知道格式化字符串的首地址,和Do_Error函数的形参format的地址,就可以从Do_Error对应的栈记录中取出传给Do_Error函数的“无名实参”,所用的访问方法与图4.2.14和图4.2.15类似。唯一的区别就是对vfprintf而言,不需要通过va_start()来获取format的地址,图4.2.16第14行的函数调用vfprintf()已经把format的地址传给下面的形参ap2。

         int vfprintf(FILE *stream, const char*format2, va_list ap2);

    图4.2.17给出了当我们进行对Do_Error进行上述函数调用时的内存示意图,从图中我们可以看到,vfprintf的形参ap2已经指向了Do_Error函数的无名参数的开始位置,Do_Error的format参数和vfprintf的format2参数,则指向相同的格式化字符串。对vfprintf函数而言,有了格式化字符串的首地址,且又有了无名参数的首地址,七颗龙珠已经凑齐,可以召唤神龙了。

         


图4.2.17 Do_Error的栈示意图

深度剖析c语言main函数---main函数的返回值   在接下来的几篇博文中,我将对c语言的main函数进行深入的剖析,分别从函数返回值,函数参数,以及函数的执行顺序对main函数作一总结。本文主要聊聊main函数的返回值。 main函数的返回值   main函数的返回值用于说明程序的退出状态。如果返回0,则代表程序正常退出。返回其它数字的含义则由系统决定。通常,返回非零代表程序异常退出。 void main()   有一些书上的,都使... 阅读详情

相关推荐

【C语言进阶教程】编译器优化

本节将帮助您了解本教程的目的、结构,以及在开始学习之前您需要具备的基础知识和准备工作。编译器优化是指通过编译器对代码进行分析和转换,以提高程序效率和性能的过程。优化目标通常是减少执行时间、降低内存使用、提高代码稳定性和可读性等。为了在不改变程序正确行为的情况下做到这一点,编译器使用各种技巧和策略。优化的基本概念编译器优化是自动化的优化过程,旨在提高代码的质量和效率。通过简化代码、减少冗余、改善内存使用等方式来提高程序性能。优化可能涉及多个方面:包括执行速度、内存占用、功耗等。为什么需要优化。

2072

C语言编译处理深度剖析

C语言编译处理是一个复杂但精巧的系统工程, 它像一座精心设计的工厂流水线, 将人类可读的高级语言代码转化为机器能高效执行的指令. 从预处理器的文本处理, 到编译器的深度分析和优化, 再到汇编器和链接器的精密装配, 每个阶段都有其独特的数据结构、算法和设计哲学正如计算机科学家Niklaus Wirth所言: “程序 = 算法 + 数据结构”, 而编译器正是将这一公式转化为可执行现实的关键桥梁. 掌握编译原理, 你将在软件开发的殿堂中拥有更深刻的洞察力和更强大的解决问题的能力。

X 529

c4编译器源码剖析

c4_source 1. 概述 c4是500行代码实现一个c语言编译器 简单暴力,适合了解基础的编译器原理 2. 主流程 1.建立系统符号表 2.读取源代码 3.一次遍历源代码,同时进行词法分析,语法分析和中间代码生成。 4.执行中间代码 3. 中间代码 3.1. 基础 中间代码 操作 意义 LEA 2 将栈往上的2个位置变量加载到寄存器 加载本地变量 IM

fishmei的博客 6057

C编译器剖析_2.4 C语言的类型系统

2.4  C语言的类型系统     这一节,我们准备初步讨论一下C语言的类型系统,相关的代码主要在ucl\type.c和ucl\type.h中。我们知道,一个进程的地址空间可分为代码区和数据区。     对于数据区,C语言提供了char、short、int、long、float和double等基本类型来刻画基本的操作数。char、short、int和long等整型还进一步分有unsigned和

SheIsC的专栏 2206

【纯干货】深度底层剖析,你所不知道的printf

文章目录注意视频再观printf函数print为什么要加个f格式符是什么时候被解析的?如何实现变参函数来实现一个printf吧printf是否有检测功能printf只能输出在命令行吗结语 注意 本文为针对C语言的基础技术向,更适合刚入门需要进阶的同学技术进阶使用,当然我会尽量用简单的语言让哪怕是初学者也可以看得懂,核心知识有如下部分: 变参函数的概念和使用 __attribute__(())的概念和使用 printf函数的实现 视频 本文可以结合视频一起食用,效果更佳:视频链接 再观printf函数 相

云雾海的各种笔记 3302

【C语言】初识C语言(上篇)

初识C语言第一部分

StackFrame 4004

C语言:底层剖析——函数栈帧的创建和销毁

C语言的使用是面向过程的,面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。所以C语言的程序都是以函数作为基本单位的,如果能够深入理解函数,无疑对于c语言会有更深刻地理解,修炼自己的内功,那么函数是如何调用的?函数返回值是如何返回的?函数的形参是如何传递的…………等等的问题,其实都和函数栈帧有关系!

weixin_51142926的博客 7674

深入浅出C语言编译器源码剖析

本文还有配套的精品资源,点击获取 简介:编译器是将高级编程语言转换为机器语言的关键工具,本资源提供的“简单编译器”源码用C语言编写,涵盖编译器设计的各个主要阶段,如词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成。通过学习和调试这些源码,开发者能够深入理解编译原理和编程语言的内部工作,提升C语言编程技能,掌握复杂数据结构和算法的应用。建议学习者先了解编译原...

weixin_42451850的博客 1499

关于extern "C"(详细剖析)

【目录】 引言 extern “C”的前世今生 小心门后的未知世界 Q&amp;A c++调用c的方法 c调用c++的方法 &nbsp; &nbsp; 在你工作过的系统里,不知能否看到类似下面的代码。 这好像没有什么问题,你应该还会想:“嗯⋯是啊,我们的代码都是这样写的,从来没有因此碰到过什么麻烦啊~”。 你说的没错,如果你的头文件从来没有被任何C++程序引用过的话。 这与C++有什么关系呢?...

菜鸟想飞 3万+

C编译器剖析PDF文档_UCC162.3源代码

包含C编译器剖析PDF文档,及修改少量BUG后的编译器源代码UCC162.3, 欢迎访问对应博客http://blog.youkuaiyun.com/sheisc

C 编译器剖析

C 编译器剖析,讲述编译器的构造及实现,内容经典、详实。

深度剖析C语言的main函数

深度剖析C语言的main函数 你好! 这是你第一次使用 Markdown编辑器 所展示的欢迎页。如果你想学习如何使用Markdown编辑器, 可以仔细阅读这篇文章,了解一下Markdown的基本语法知识。 main的返回值 main函数的返回值用于说明程序的退出状态。如果返回0,则代表程序正常退出。返回其它数字的含义则由系统决定。通常,返回非零代表程序异常退出。 void main() 有一些书上的,都使用了void main( ) ,其实这是错误的。C/C++ 中从来没有定义过void main( ) 。

weixin_43854957的博客 3255

C编译器剖析

c编译器

看完这五个问题后你真的了解C语言吗?(深度剖析C语言第二期)

C语言是一门非常经典的语言,远没有我们在学校学的那几个语法而已,让小生再来带大家重新认识一遍C语言吧🥳🥳🥳

qq_59955115的博客 3万+

七自由度机械臂逆运动学实战:从几何简化到KDL库高效求解

本文深入探讨了七自由度机械臂逆运动学求解的实战方法。针对冗余自由度带来的挑战,文章对比了两种核心方案:一是利用任务约束进行几何简化,快速求解特定任务;二是基于DH参数法配置KDL库,实现通用高效的数值迭代求解。文中结合宇树H1机器人实例,详细阐述了从模型解析、参数提取到代码实现与验证的全流程,并提供了几何法与KDL库的适用场景对比与避坑指南。

weixin_29044713的博客 635

robomimic实验复现全流程指南:从数据集下载到训练视频生成

本文提供了RoboMimic实验复现的完整实战指南,重点解析了从数据集下载、配置文件定制到模型训练与评估的全流程。以tool_hang任务为例,详细说明了环境准备、数据获取、训练监控及视频生成等关键步骤,并针对常见问题给出了排查方案,帮助研究者高效完成机器人模仿学习实验的闭环验证。

2w3e4r5t6y的博客 1031

你真的了解C语言吗?(深度剖析C语言第一期)

xdm,从今天开始我们来重新拾起C语言,或许有人会问,C语言不是学完了吗?其实有时候可以仔细思考一下,作为经典语言之一,多少前辈多少大牛为其付之一生,岂是你我一年半载能学完的?哈哈,至少小生在学校确实只是学到了C的一点皮毛语法罢了。因此,我想带着大家重新拾起C语言,我们更深入地去学一下,加油,技术人!!!! 我们先打个招呼: 保持空杯心态,我们重新审视这个第一个C语言程序。我们以上在vs写的代码时是用文本写的代码,在编译链接的时候编译器将该文本代码变为二进制序列的可执行程序。同时这个可执行程序也..

qq_59955115的博客 4万+

DeerFlow 在 Windows 上的安装与启动详细步骤

DeerFlow Windows安装与启动指南 DeerFlow是基于LangGraph的AI Agent系统,包含后端服务和前端界面。后端安装需Python 3.12+,创建虚拟环境后安装依赖,需注意解决langgraph版本冲突问题(推荐1.0.8版本)。配置文件中需设置模型参数(如本地Qwen3-32B-AWQ模型)和sandbox配置。启动分为两步:LangGraph Server(端口2024)和Backend Gateway(端口8001)。前端要求Node.js 22+和pnpm,配置.env

笨小孩 581

从汇编来角度剖析C语言函数调用过程

当一个c函数被调用时,一个栈帧(stack frame)是如何被建立,又如何被消除的。这些细节跟操作系统平台及编译器的实现有关,下面的描述是针对运行在Intel奔腾芯片上Linux的gcc编译器而言。c语言的标准并没有描述实现的方式,所以,不同的编译器,处理器,操作系统都可能有自己的建立栈帧的方式。

红军不怕远征难,万水千山只等闲 1738

镜像电流源与比例电流源:原理、应用与性能优化

本文深入解析了镜像电流源与比例电流源的工作原理、核心应用与性能优化方法。镜像电流源利用匹配晶体管实现电流复制,是集成电路的基础;比例电流源通过发射极电阻实现电流缩放,应用更灵活。文章重点探讨了其作为有源负载提升放大器增益的关键应用,并分析了静态误差、输出阻抗和最小工作电压三大性能指标,最后介绍了威尔逊电流镜等进阶优化技术及实战设计要点。

dd012的博客 567

程序进入hardfault之后查找原因的方法

如果在MCU运行程序的时候,进入了hardfault,该怎么办?

smart_boy__的博客 761

C语言函数调用的过程图解深入剖析

局部变量的创建过程、为啥局部变量不初始化就输出类似于烫烫烫这样的乱码,以及局部变量存放在哪些地方。函数栈帧创建和销毁过程。

我的主页 6480

【晶振专题】晶振学习笔记——ST AN2867应用手册 2

通过学习AN2867设计指南,我们知道皮尔斯晶振的基本知识。也了解了晶振的负载电容计算方法、安全因子测试方法和反馈电阻的作用。

一点硬件 1364

C语言中的性能调优与代码剖析

C语言中的性能调优与代码剖析。

D_ovis的博客 952

自定义Usecase/Pipeline

Summary of Custom Usecase and Pipeline Configuration in SA8650 QCX Framework This article elaborates on the complete process of customizing a new Usecase and Pipeline based on the SA8650 QCX framework to meet OEM and Tier1 customization requirements, with

qq_39257814的博客 929

RobotFramework二次开发——实时日志

背景基于RobotFramework的二次开发,少不了要打印实时日志出来,比如广泛应用的工具ride中,在执行用例时会把执行过程中的log全部打印出来,如果二次开发的时候,执行用例只能静默等待执行完毕,那只能算是一个半成品。结果在最后,不想看过程的可以直接跳到最后思路RobotFramework的日志有好几种,一个是执行后的log文件,这个文件可以作为数据留存,但是没办法实时获取,因为文件是在执行完

点点寒彬的博客 1万+
上一篇: C编译器剖析_4.2 语义检查_表达式的语义检查(3)_字符串与标识符
下一篇: C编译器剖析_4.2 语义检查_表达式的语义检查(4)_函数调用
SheIsC
博客等级 码龄12年 224粉丝 64原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值