程序由创建到得到运行结果的过程你知道吗?程序的环境和预处理爆肝总结画图详解

Posted 小赵小赵福星高照~

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序由创建到得到运行结果的过程你知道吗?程序的环境和预处理爆肝总结画图详解相关的知识,希望对你有一定的参考价值。

程序的环境和预处理

程序的翻译环境和执行环境

在ANSI C的任何一种实现中,都存在两个不同的环境:

  • 第一种是翻译环境,在这个环境中源代码被转换为可执行指令,test.c->test.exe依赖于翻译环境
  • 第二种执行环境,它用于实际执行代码,test.exe->运行结果依赖于执行环境

编译+链接

翻译环境

翻译环境其实进行的就是编译+链接的工作

我们在写程序的时候,可能会有很多的.c文件,假如test.c、contact.c、common.c,每个.c文件都会各自单独经过编译器处理,生成目标文件:

目标文件一起与链接库经过链接器,链接器会把目标文件和链接库链接在一起然后生成可执行程序。

链接库是什么呢?

Libraries这是库,里面的.LIB文件叫静态库,这些静态库里就包含了printf函数的相关信息,如果我们的代码使用到了printf函数,那么链接库就是链接这些静态库进去的。

我们刚刚大体上说了一下我们的翻译环境的编译和链接。

test.c源文件到test.exe可执行程序需要经过编译(编译器 cl.exe)链接(链接器 link.exe),那么是怎么编译的呢?

==注意:==编译器是,链接器是

在我们的编译器安装路径下都有。

那么编译的细节是什么呢?,它又分为几部分工作呢?

编译也分为三个阶段

1.预编译(预处理)

2.编译

3.汇编

预处理阶段

  • 完成了头文件的包含#include的处理

我们以一个简单的代码为例:

#include<stdio.h>
int g_val = 2021;
int Add(int x,int y)
{
    return x+y;
}
int main()
{
    int a=10;
    int b=20;
    int ret=Add(a,b);
    printf("%d\\n",ret);
    return 0;
}

在预处理阶段,会将#include包含的头文件拷贝放在了代码最前面,在这里其实将stdio.h头文件的内容拷贝放在我们的代码最前面了

  • 完成了#define定义的符号和宏的替换
#include<stdio.h>
int g_val = 2021;

#define M 100
#define MAX(x,y) ((x)>(y)?(x):(y))
int Add(int x,int y)
{
    return x+y;
}
int main()
{
    int num=M;
    int m=MAX(100,200);
    int a=10;
    int b=20;
    int ret=Add(a,b);
    printf("%d\\n",ret);
    return 0;
}

在预处理之后,上面main函数中的代码会变为:

int main()
{
    int num=100;
    int m=((100)>(200)?(100):(200));
    int a=10;
    int b=20;
    int ret=Add(a,b);
    printf("%d\\n",ret);
    return 0;
}

在预处理阶段,完成了#define定义的符号和宏的替换

  • 注释的删除
int main()
{
    printf("haha\\n");
    //这里打印haha
    return 0;
}

上面的代码在预处理阶段会变成:

int main()
{
    printf("haha\\n");
    
    return 0;
}

在预处理阶段,完成了注释的删除,所以我们写多少注释都不会有影响。

预处理阶段的这些操作也都是一些文本上的操作。下面我们看编译阶段:


编译阶段

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一

  • 语法分析

  • 词法分析

  • 语义分析

  • 符号汇总

比如我们有这两个源文件:test.c、add.c,编译阶段,符号汇总将test.c和add.c中的符号汇总,汇总全局的符号。

比如我们写了下面两个源文件时:

test.c

extern int ADD(int x,int y);
int main()
{
    int a=10;
    int b=20;
    int ret= ADD(a,b);
    return 0;
}

add.c

int ADD(int x,int y)
{
    return x+y;
}

add.c和test.c要单独经过编译器处理,在编译阶段符号汇总时,他会把test.c中的符号(Add、main)汇总,会把add.c中的符号(Add)汇总(只汇总全局的符号)

汇编阶段

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。

  • 汇编代码转换成机器指令

这个阶段生成目标文件test.obj,这个文件是有格式的,这个文件是ELF格式,它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件 在Windows下,我们可以统称它们为PE-COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类。目标文件中还包括了链接时所须要的一些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以“节”( Section)的形式存储,有时候也叫“段”(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别。

  • 生成符号表
#include<stdio.h>
int g_val = 2021;
int Add(int x,int y)
{
    return x+y;
}
int main()
{
    int a=10;
    int b=20;
    int ret=Add(a,b);
    printf("%d\\n",ret);
    return 0;
}

在编译阶段时,进行符号汇总,会把.c文件中的全局的这些符号(在这段代码中汇总的符号是:g_val、Add、main、printf)(函数名、全局变量等)全部都会汇总起来,然后生成一个符号表

比如我们写了下面两个源文件时:

test.c

extern int ADD(int x,int y);
int main()
{
    int a=10;
    int b=20;
    int ret= ADD(a,b);
    return 0;
}

add.c

int ADD(int x,int y)
{
    return x+y;
}

add.c和test.c要单独经过编译器处理,在编译阶段符号汇总时,他会把test.c中的符号(Add、main)汇总,会把add.c中的符号(Add)汇总(只汇总全局的符号)

之后在汇编阶段时生成符号表,为编译阶段汇总的全局符号生成符号表,是什么意思呢?请看下面解释:

链接阶段

把多个目标文件和链接库进行链接

  • 符号表的合并和重定位

将汇编阶段生成的符号表进行合并,将无效的符号地址删除,有效的符号地址保留

  • 合并段表

我们的目标文件的格式是ELF,而ELF的格式是我们上面讲过的那个段表,它存储目标文件的一些信息,而我们这个阶段就需要将多个这样的目标文件的段表合并,将重复的信息合并在一起

最后生成可执行程序

通过可执行程序调用我们的函数时,就可以通过符号和地址找对应的函数,所以链接的阶段要把我们代码里的函数要找到。

运行环境(执行环境)

程序执行的过程

  • 程序必须载入内存中,在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由程序员手工进行,也可能是通过可执行代码置入只读内存来完成。
  • 程序的执行开始,调用main函数
  • 开始执行程序代码,这时程序将使用一个运行的堆栈,存储函数的局部变量和返回地址。程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。

给函数分配的这块空间叫运行时堆栈。

  • 终止程序。可能是正常终止main函数,也有可能是意外终止。

预处理详解

编译一个C程序需要涉及很多步骤。其中第一个步骤被称为预处理阶段。C预处理器在源代码编译之前对其进行一些文本性质的操作,它的主要任务包括删除注释、插入被#include指令包含的文件的内容、定义和替换那些被#define指令定义的符号以及确定代码的部分内容是否应该根据一些条件编译指令进行编译。


预定义符号

预定义符号都是语言内置的。

_LINE_在源代码中插入当前源代码行号;

_FILE_在源文件中插入当前源文件名;

_DATE_在源文件中插入当前的编译日期;

_TIME_在源文件中插入当前编译时间;

_STDC_当要求程序严格遵循ANSI C标准时该标识被赋值为1;

int main()
{
    printf("%s\\n",__FILE__);
    return 0;
}

会打印什么呢?

它会打印当前的源文件名

int main()
{
    printf("%s\\n",__LINE__);
    return 0;
}

它会打印我们的代码在多少行

int main()
{
    printf("%s\\n",__DATE_);
    return 0;
}

它会打印我们当前的日期

int main()
{
    printf("%s\\n",__TIME__);
    return 0;
}

打印我们当前的时间

int main()
{
    printf("%s\\n",_FUNCTION_);
    return 0;
}

查看位于哪个函数里

那么说这么多预定义符号有什么用呢?在程序庞大时,会有用处

int main()
{
    int i = 0;
    FILE* pf = fopen("log.txt", "a+");//追加
    if (pf == NULL)
    {
        perror("fopen");
        return 1;
    }
    for (i = 0; i < 10; i++)
    {
        fprintf(pf, "%s %d %s %d\\n", __FILE__, __LINE__, __DATE__, __TIME__);
    }

    fclose(pf);
    pf = NULL;
    return 0;
}

当我们在文件中保存这些信息,在代码数量庞大时,我们在确认调试输出的的来源方面很有用处。程序在执行的过程中我们可以记录这些信息

#define


#define定义标识符

语法:

#define name stuff

有了这个指令,每当有符号name出现在这条指令后面时,预处理就会把它替换成stuff

例如:

#define M 100
int main()
{
    int m=M;
    printf("%d\\n",m);
    return 0;
}

替换文本并不仅限于数值字面常量。使用define指令,你可以把任何的文本替换到程序中。

例如:

#define reg register
int main()
{
    reg int num=0;
    return 0;
}

reg int num = 0;这句话在预处理阶段被替换成register int num = 0;

#define do_forever for(;;)   
int main()
{
    do_forever;
    return 0;
}

这个声明用一个具有描述性的符号来代替一种用于实现无限循环的for语句类型。

#define CASE break;case
int main()
{
    int n=0;
    switch(n)
    {
    case 1:
    CASE 2:
    CASE 3:
    }
    return 0;
}

这个声明定义了一种记法,在switch中使用,它自动的把一个break放在每个case之前。

#define定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)

宏的声明方式:

#define name(paramen-list) stuff

其中paramen-list是一个由逗号隔开的符号表,它们可能出现在stuff中

注意:

  • 参数列表的左括号必须与name紧邻,如果两者之间有任何空白的存在,参数列表就会被解释为stuff的一部分。

当宏被调用时,名字后面是一个由逗号分隔的值的列表,每个值都与宏定义中的参数相对应,整个列表用一对括号包围。当参数出现在程序中,与每个参数对应的值都将被替换到stuff中

#define SQUARE(X) X*X
int main()
{
    printf("%d\\n",SQUARE(3));
    return 0;
}

实际上在预处理阶段,上面printf代码会被替换成:

printf("%d\\n",3*3);

然后我们再看下面这个代码:

#define SQUARE(X) X*X
int main()
{
    printf("%d\\n",SQUARE(3+1));//7
    return 0;
}

可能你猛地一看,以为答案是16,实际上并不是!!!

宏是直接替换进去,不会给你计算的,所以此时X是3+1,将参数替换进文本实际上得到:

printf("%d\\n",3+1*3+1);//7

所以结果为7

现在我们找到了问题所在:由替换产生的表达式并没有按照预想的次序进行求值。我们在宏定义中加上两个括号,问题就被很好的解决了:

#define SQUARE(X) (X)*(X)
#define SQUARE(X) X*X
int main()
{
    printf("%d\\n",SQUARE(3+1));
    return 0;
}

在前面的例子里,现在将printf语句里面的SQUARE(3+1)进行替换:

printf("%d\\n",(3+1)*(3+1));//16

我们得到了预期的结果。

这里有另外一个宏定义:

#define DOUBLE(X) (X)+(X)

定义中使用了括号,用于避免前面的问题。但是使用这个宏会出现另外一个不同的错误。下面这段代码会打印什么呢?

#define DOUBLE(X) (X)+(X)
int main()
{
    printf("%d\\n",10*DOUBLE(5));
    return 0;
}

看上去他好像要打印100,但事实上它打印的是55,我们再一次通过观察宏替换产生的文本,我们可以发现问题所在:

printf("%d\\n",10*(5)+(5));//55

这个问题我们很容易纠正,我们只要在两边加上一对括号就可以了

#define DOUBLE(X) ((X)+(X))

注意:

所有用于对数值表达式求值的宏定义都应该用这种方式加上括号,避免在使用宏时,由于参数中的操作符或邻近的操作符之间有不可预料的作用。

所以第一个宏定义我们应该这样写:

#define SQUARE(X) ((X)*(X))
int main()
{
    printf("%d\\n",SQUARE(3+1));//7
    return 0;
}

写宏时,括号很重要,不要吝啬它


#define替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤:

  • 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先
    被替换。
#define M 100
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
    int max = MAX(101,M);
    return 0;
}

首先会看调用宏,对参数进行检查,看是不是包含#define定义的符号,上面代码则首先被替换成 int max = MAX(101,100);

  • 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。

  • 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上
    述处理过程。

注意:

  • 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
#define M 100
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
    int max = MAX(10,M);
    return 0;
}
  • 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
#define M 100
int main()
{
    printf("M=%d\\n",M);
    return 0;
}

printf字符串内容中的M不会被检查


#和##

#

把参数插入到字符串中去,把一个宏参数变成对应的字符串

首先我们看下面的讲解:

int main(java 并发 同步对象是同一个吗

谈谈你对Hibernate的理解

如何在不知道查询结果类型的情况下在 EF/VB.net 中运行存储过程?

你的K8s 运行时环境安全吗? KubeXray帮你保护K8s环境及应用

请写出如下PHP程序的运行结果,并给出计算过程,即$sum由哪些数相加而得。

unix高级环境编程看了能写项目吗