长文详解程序运行是个怎样的环境?预处理阶段在做什么?程序中我们不知道的一些事~

Posted 凛音Rinne

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了长文详解程序运行是个怎样的环境?预处理阶段在做什么?程序中我们不知道的一些事~相关的知识,希望对你有一定的参考价值。

程序环境和预处理

老规矩笔记在gitee自取~:程序环境和预处理笔记
❤️欢迎喜欢学习C/C++的朋友互关一起努力!!❤️



一、程序的环境

在ANSI C(国际标准c语言)的任何一种实现中,存在两个不同的环境。

❄️翻译环境:在这个环境中源代码被转换为可执行的机器指令

❄️执行环境:它用于切实执行代码

下图详解(VS底下的编译器是cl.exe,连接器是link.exe

  • 每个文件(.c文件)都会经过编译器处理,变成目标文件(.obj)

  • 头文件(.h)的包含在预编译完成,生成文件(.i)

    #include/#define/#pragma等,是预编译口令

    注释也会在预编译阶段删除掉

  • 编译阶段完成,将c语言代码转化成汇编代码,生成汇编代码文件(.s)

    ❄️语法分析
    ❄️词法分析
    ❄️语义分析
    ❄️符号汇总(只汇总全局符号)

  • 汇编代码进行汇编操作,将汇编代码转化成二进制代码,生成符号表并且生成目标文件(类型linux/vs .o/.obj)

    此文件可以在debug目录(运行程序之后生成)下找到


    文件中数据的存储格式为elf格式

    readelf指令可以查看

  • 链接库包含库函数文件

  • 所有的目标文件+链接库通过链接器编译成可执行文件(.exe)

  • 链接各文件中操作:

    ⚡️合并段表
    ⚡️符号表的合并和符号表的重定位(相同的地方合并)

更深层次理解

☁️预处理:相当于根据预处理指令组装新的C/C++程序。

经过预处理,会产生一个没有头文件(都已经被展开了)、宏定义(都已经替换了),没有条件编译指令(该屏蔽的都屏蔽掉了),没有特殊符号的输出文件,这个文件的含义同原本的文件无异,只是内容上有所不同。

☁️编译:将预处理完的文件逐一进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码文件。编译是针对单个文件编译的,只校验本文件的语法是否有问题,不负责寻找实体。

☁️链接:通过链接器将一个个目标文件(或许还会有库文件)链接在一起生成一个完整的可执行程序。 链接程序的主要工作就是将有关的目标文件彼此相连接,也就是将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。
在此过程中会发现被调用的函数未被定义。需要注意的是,链接阶段只会链接调用了的函数/全局变量,如果存在一个不存在实体的声明(函数声明、全局变量的外部声明),但没有被调用,依然是可以正常编译执行的。


二、预处理符号

以下符号在预处理阶段处理

符号都是语言内置,无需包含文件

__FILE__     //进行编译的源文件名
__LINE__     //文件当前的行号
__DATE__     //文件被编译的日期
__TIME__     //文件被编译的时间
__STDC__     //如果编译器遵循ANSI C,其值为1,否则未定义

注意: __ STDC __ 在VS下不支持ANSI C所以未定义;而在Linux环境下,gcc对ANSI C支持


三、预处理指令#define

1. 定义标识符常量

#define MAX 100

int main()
{
    printf("%d", MAX);
    
    return 0;
}

预编译阶段,MAX已经变成了100

注意:

  • ⛄️标识符常量后不要加;,容易出现问题

  • ⛄️当预处理器/编译器中搜索不到#define定义的符号


2. 定义宏

与函数不同,宏是把参数替换到文本中

#define ADD(x, y) ((x) + (y))

int main()
{
    int a = 2;
    int b = 3;
    ADD(2, 3);//计算2+3
    
    return 0;
}

注意:

  • 🐳参数列表的左括号必须与宏名称紧邻

  • 🐳如果两者之间有任何空白存在,参数列表就会被解释为后面式子的一部分。

  • 🐳数值表达式进行求值的宏定义都应在外面加上括号,避免在使用宏时由于参数中的操作符或邻近操作符产生作用导致计算错误

  • 🐳宏参数和#define定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归

  • 🐳注意++和–等有副作用的符号

    当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。

    #define MAX(a, b) ( (a) > (b) ? (a) : (b) )
    
    int main()
    {
        int x = 5;
        int y = 8;
        int z = MAX(x++, y++);
        printf("x=%d y=%d z=%d\\n", x, y, z);
    
        return 0;
    }
    
    

    以这个三目操作符为例,参数a和b分别都出现了2次,三目操作符的意思是结果为真,返回a,结果为假,返回b

    判断语句:a++ > b++ ?

    这里比较的时候a和b是5和8

    比较完a和b是6和9

    返回结果:返回b的值,z被赋值为9

    宏结束后:返回b++结束后,y为10

    最后结果


3. #define和tpyedef区别

#define INT int*
typedef int* INT_T;
 
int main()
{
    INT a, b;//这里是int* a; int b;
    INT_T c, d;//这里是 int* a; int* b 
}
  • 🌼所以这里define仅仅是符号的替换,而typedef起作用效果的对象,却是之后的所有变量

  • 🌼🌼如果这里的INT只是int* 效果也是一样,所以typedef的优点也在于此

  • 🌼🌼🌼并且提醒大家以后定义变量尽量一行一个变量以防出错


4. 替换规则

在定义符号和宏的时候,需要注意:

  • 🐚在调用宏时,查看参数否包含任何由#define定义的符号,如果有将先被替换
  • 🐚对于符号,替换文本被插入到程序中原来文本的位置
  • 🐚对于宏,参数名被他们的值所替换
  • 🐚最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。

5. # 和 ##

1. #

将一个宏参数变成对应的字符串

直接上一串代码体验:

我们想要实现printf的一个函数

int a = 1;
int b = 2;
printf("a的值是%d", a);
printf("b的值是%d", b);

//以上代码用函数实现
void print(int x)
{
    printf("x的值是%d", x);
}

但是打印出来是:x的值是1,x的值是2

实现不了我们想要的:a的值是1,b的值是2

define加上#就有奇效:

#define PRINT(x) printf(""#x"的值为%d", x)

int main()
{
    int a = 2;
    PRINT(a);
    
    return 0;
}

结果是:

原理是什么样的?

🌱🌱🌱因为字符串有相邻自动连接功能

printf("hi""hello");

而#相当于将宏参数x左右加上""

使得x变成一个符号,这就是将宏参数变成对应的字符串

实际上,上面的""#x"的值为x" 实则是 " " 、 ''x" 、 "的值为%d ",连接起来


2. ##

将两边的符号合成一个符号

#define PRINT(a, b) a##b

int main()
{
    int a = 1;
    int b = 0;
    int ab = 10;
    printf("%d", PRINT(a, b));//相当于打印ab

    return 0;
}

🌾🌾🌾注意:这样的连接必须产生一个合法的标识符,否则其结果就是未定义

不必过于深究,只要理解其作用就行


6. 宏与函数的优缺点

比较内容#define定义宏函数
代码长度每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
执行速度更快存在函数的调用和返回的额外开销,所以相对慢一些
操作符优先级宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测
带有副作用的参数参数可能被替换到宏体中的多个位置,所以带有副 作用的参数求值可能会产生不可预料的结果函数参数只在传参的时候求值一 次,结果更容易控制
参数类型宏的参数与类型无关,只要对参数的操作是合法 的,它就可以使用于任何参数类型函数的参数是与类型有关的,如 果参数的类型不同,就需要不同的函数,即使他们执行的任务是 不同的
调试宏是不方便调试的函数是可以逐语句调试的
递归宏是不能递归的函数是可以递归的

命名约定:

  • 🎍宏命名全部大写
  • 🎍函数名可以不全大写

四、#undef 移除宏定义

#undef MAX
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

五、命令行定义

有一些编译器提供了一种能力,允许在命令行中定义符号

比如在linux环境下,gcc编译器,使用指令,在预处理阶段给变量重新赋值

⛅️⛅️假定:某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大,我们需要一个数组能够大一点


六、条件编译

满足条件,代码参与编译,不满足就不参与

1. 简单条件编译

int main()
{
    int a = 1;
#if 0//这里写表达式或者符号(常量)
//#if a 这里是错误的,因为在预编译过程a还没创建
    //a创建的过程在运行的过程
    printf("%d", a);//什么都不打印
#endif
    
    return 0;
}

2. 判断是否定义

对于宏

//定义了
#if defined(symbol)
#ifdef symbol

//没有定义
#if !defined(symbol)
#ifndef symbol
//两种写法均可

代码感受:

#define MAX 0

int main()
{
    int a = 1;
#if defined(MAX)//测试定义了没有,与值无关
    printf("%d", a);
#endif
    
#if max//根据MAX的值判断
    printf("%d", MAX);
#endif
    return 0;
}

运行结果:


3. 多个分支的条件编译

#if 常量表达式
 //...
#elif 常量表达式
 //...
#else
 //...
#endif


4. 嵌套指令

#if defined(MAX)
 #ifdef OP1
 ADD1();
 #endif
 #ifdef OP2
 ADD2();
 #endif
#elif defined(MIN)
 #ifdef OP2
 DEL2();
 #endif
#endif

用法有点类似if/else 语句


七、头文件包含方式

1. 本地文件包含

#include "test.c"

🎓先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件

🎓如果找不到就提示编译错误


2. 库函数包含

#include <stdio.h>

🎓查找头文件直接去标准路径下去查找,如果找不到就提示编译错误


3. 文件嵌套包含

可能存在一种,头文件在无意识中包含了两次,意味着预编译期间,会拷贝两份头文件内容

为了防止上述情况形成

我们用条件编译解决这个问题

#ifndef TSD
#define TSD
#include <stdio.h>
//头文件的内容
#endif

或者每个文件开头写

#pragma once

第二种方式最简单


以上是关于长文详解程序运行是个怎样的环境?预处理阶段在做什么?程序中我们不知道的一些事~的主要内容,如果未能解决你的问题,请参考以下文章

C语言预处理和程序环境

揭秘数据湖——长文详解Hudi从内核到实战(一)

万字长文解释 ChatGPT 在做什么,以及为什么它能发挥作用?

万字长文解释 ChatGPT 在做什么,以及为什么它能发挥作用?

深度长文回顾web基础组件

BIT-0-程序环境和预处理