c语言关于编译与链接的讲解

目录

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

2、详解编译+链接

2.1 翻译环境具体流程

2.2 编译下的预处理阶段

2.3 编译下的编译阶段

2.4 编译下的汇编阶段

2.5 链接阶段 

3、执行环境

4、预定义符号

 5、#define定义标识符

5.1 #define定义宏

5.2 #define宏替换规则

5.3 #和##

5.3.1 #号的作用

   5.3.2 ##号的作用    

5.4 带有副作用的表达式

5.5 宏和函数的对比

6、命名约定

7、#undef 

8、命令行定义

9、条件编译

9.1 条件编译多分支执行

9.2 判断是否未定义

9.2.1 定义过即为真

9.2.2 定义过即为假

9.3 复杂嵌套形式

10、文件包含

10.1 嵌套文件包含

结语:


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

        标准c语言定义了在运行和实现任意的代码下,存在着两种不同的环境:翻译环境和执行环境。因为我们用c语言写出来的代码是用文本信息组成的,然而计算机只能看得懂二进制序列。因此我们写出来的代码计算机不能直接看懂,需要将文本代码翻译成二进制形式的代码,这就是翻译环境的作用。(这里的二进制形式的代码也称为可执行程序)

        执行环境的作用就是执行翻译后的可执行程序,已到达实现代码最终的功能。

2、详解编译+链接

2.1 翻译环境具体流程

         每个源文件都会单独经过编译器处理生成目标文件,编译器处理的过程叫编译。目标文件通过链接器与链接库(即打包好的库函数)想链接生成可执行程序,该过程叫链接

        因此在翻译环境下又分为了编译+链接。用代码举例:这里创建2个源文件一个是1.c,一个是2.c。

        1.c:

#include<stdio.h>

extern int Add(int, int);

int main()
{
	int a = 10;
	int b = 20;
	int c = Add(a,b);
	printf("%d\n", c);
	return 0;
}

       2.c:

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

        运行成功后,发现在该程序路径下的Debug文件下会生成两个.obj的文件,为目标文件。

        在上一级的路径下的Debug下有一个.exe文件,为可执行程序文件。 

2.2 编译下的预处理阶段

         编译往下又能细分出三个过程:预处理、编译、汇编。这里涉及到预处理的细节,因此在Linux环境下使用gcc编译器对此代码进行观察。

 #include<stdio.h>

int g_val = 2023;//定义全局变量2023
#define M 100//定义M为100

int main()
{
	int a = M;
	printf("%d\n", M);
	return 0;
}

        在Linux环境下用命令gcc test.c -E -o test.i,对文件test.c里面的代码进行预处理,并将预处理后的结果放在test.i的文件下,如下:

        发现头文件stdio.h被展开,注释也被消除,而且原本#define M 100这句程序不见了并且程序中的所有M都被替换成了100。可以得出结论:所有的预处理指令(#include、#define)都是在预处理阶段处理的。

2.3 编译下的编译阶段

        预处理的下一个过程就是编译过程,在LInux环境下用命令gcc -S test.i,会生成一个test.s的文件,该文件里面的内容是汇编指令,因此编译的过程就是把用c语言写出来的代码转变为汇编指令。当然其中的过程是非常复杂的,编译过程的具体细节是进行了语法分析、词法分析、语言分析、符号汇总,这里符号汇总是重点。经过这一系列的操作最后才把c语言翻译成汇编指令。

2.4 编译下的汇编阶段

        编译的下一个环节是汇编,用命令gcc -c test.s,会生成test.o文件,.o后缀的文件在Linux环境下就是目标文件(window下是.obj)。目标文件中都是以二进制指令存放的,因此汇编这个阶段的作用就是把编译阶段中产生的汇编指令翻译成二进制指令,并存放于目标文件。

        汇编阶段会把之前编译过程中符号的汇总再进一步整合成符号表,为链接阶段做准备。

2.5 链接阶段 

        目的就是将.o文件和链接库进行链接生成可执行程序。命令:gcc test.o -o  test,生成的test文件也是存放二进制指令的文件。

        链接的具体过程可以分为:

        1、合并段表。

        2、合并、重定位符号表

       .o文件内存储的二进制码如下,可以看到大部分我们都看不懂,但是左上角的ELF表示的是格式形式。意为目标文件和可执行程序的格式都是按照ELF的格式存储的。

        这里可以用命令read test.o -S来查看符号表:

        圈起来的就是符号,然而这里的符号都是代码中的全局变量,并没有发现有局部变量。这里用上文1.c和2.c的代码进行举例:

        两个Add函数的地址选取哪个,下面的Add是一个声明没有有效的价值,因此地址选取上方定义过的Add函数地址。至此,经过这些步骤得到最终的可执行程序。

3、执行环境

        程序执行的过程:
        1. 程序执行的前提是必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中:可由手工烧入程序(类似单片机),也可能是通过可执行代码置入只读内存来完成。
        2. 程序的执行便开始。接着便调用 main 函数。
        3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈( stack ),存储函数的局部变量和返回地址。程序同时也可以使用静态(static )内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
        4. 终止程序。正常终止 main 函数;也有可能是意外终止。

4、预定义符号

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

        代码举例:

#include<stdio.h>

int main()
{
	printf("%s\n", __FILE__);//打印出被编译的文件的文件名
	printf("%d\n", __LINE__);//表示该符号__LINE__出现的行数
	return 0;
}

        因为是预定义符号,因此在预处理阶段就发生了替换: 

#include<stdio.h>

int main()
{
	printf("%s\n", __FILE__);//打印出被编译的文件的文件名
	printf("%d\n", __LINE__);//表示该符号__LINE__出现的行数
	printf("%s\n", __DATE__);//编译时当前日期
	printf("%s\n", __TIME__);//编译时当前时间
	printf("%d\n", __STDC__);
	return 0;
}

 5、#define定义标识符

        #define的内容会在预处理阶段就进行对代码中参数的替换,其语法:

#define name stuff//name表示符号名,stuff表示内容

        举例子: 

#define MAX 1000
#define reg register          //为 register这个关键字,创建一个简短的名字
#define do_forever for(;;)     //用更形象的符号来替换一种实现
#define CASE break;case        //在写case语句的时候自动把 break写上。

// 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
                          date:%s\ttime:%s\n" ,\
                          __FILE__,__LINE__ ,       \
                          __DATE__,__TIME__ )
//  这里'\'的作用是转义后面的回车,注意‘\'后面不能有任何字符包括空格。

        注意#define语句最后不能加分号';',否则会判断为两条语句,例子如下:

#include<stdio.h>

#define M 100;

int main()
{
	int a = 0;
	int b = 0;
	if (a > 0)
		b = M;//此处预处理后等于b=100;;
	//因为有两个分号因此会判断为两个语句,因此else不知道跟谁匹配了
	else
		b = 2;
	return 0;
}

5.1 #define定义宏

        语法:

#define name( parament-list ) stuff//name表示宏的名字,括号里的是参数列表
//stuff是宏的内容,表示参数列表可以替换到后面的内容中

        代码举例:

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))//这里切记要加括号,且MAX与(x,y)直接不能有空格

int main()
{
	int a = 10;
	int b = 20;
	int c = MAX(a, b);//最终这里会被替换成((a)>(b)?(a):(b))
	printf("%d\n", c);
	return 0;
}

        具体替换顺序:

        若#define定义宏的时候, 如果后面的内容不加括号会导致结果跟我们预期的结果不一样,因为宏有可能传进去的是一个表达式,而不是一个值:

#include<stdio.h>

#define SQUARE(x) x*x

int main()
{
	int a = 8;
	int c = SQUARE(a+2);//SQUARE(a+2)会被替换成a+2*a+2,这时候因为优先级会先算2*a
	printf("%d\n", c);//结果为26,跟我们预期的(8+2)^2不一样
	return 0;
}

        因此要对#define后面的内容都加上括号,防止传给宏的是一个表达式:

#include<stdio.h>

#define SQUARE(x) ((x)*(x))//加上括号后,若x为式子,则先计算x的最终值

int main()
{
	int a = 8;
	int c = SQUARE(a+2);//因此要加上括号,才能先算出a+2的结果,再进行乘法计算
	printf("%d\n", c);//结果为100
	return 0;
}

5.2 #define宏替换规则

        在使用#define定义宏和标识符时,需要经过以下步骤:

        1、调用#define时,先检查有没有标识符替换,如果有则先替换标识符。

        2、替换到文本中,随后查看该文本是否还需再次传递给宏定义,对于宏,参数名被他们的值所替换。

        3、最后查看是否还包含任何由#define定义的符号,若包含则重复以上过程。

        注意两点:1.宏不能像函数一样用于递归操作。2.宏不会替换字符串里的内容。

#include<stdio.h>

#define SQUARE(x) ((x)*(x))
#define M 3

int main()
{
	int a = 8;
	int c = SQUARE(a + M);//先将M替换成3,再把SQUARE(a + M)替换成((a+3)*(a+3))

	printf("c=%d\n", c);
	printf("M=%d\n", M);//M被替换成3,但是“”中的M不会被替换
	return 0;
}

5.3 #和##

5.3.1 #号的作用

        首先我们先清楚这个代码的作用:

printf("hello""world\n");//这样写也能打印出helloworld

        得出两个字符串拼在一起也能打印,因此可以宏来实现一些函数实现不了的功能:

#include<stdio.h>

int main()
{
	int a = 10;
	printf("the value of a is %d\n", a);

	int b = 20;
	printf("the value of b is %d\n", b);

	return 0;
}

        发现上面printf打印出来的功能很相似,可以进行封装,但是如果用函数来进行封装,则实现不了根据不同的数据打印不同的值的功能:

#include<stdio.h>

void print(int x)
{
	printf("the value of x is %d\n", x);//字符串里的x是不会根据形参接收不同的值而改变的
}

int main()
{
	int a = 10;
	printf("the value of a is %d\n", a);
	print(a);

	int b = 20;
	printf("the value of b is %d\n", b);
	print(b);

	return 0;
}

        因此我们选择用宏来操作:

#include<stdio.h>

#define PRINTF(n) printf("the value of "#n" is %d\n",n)
//当把a传给n时,#a就会变成“a”,然和字符串是可以拼接的,因此可以打印出我们想要的效果

int main()
{
	int a = 10;
	//printf("the value of a is %d\n", a);
	PRINTF(a);

	int b = 20;
	//printf("the value of b is %d\n", b);
	PRINTF(b);

	return 0;
}

        当然还可以进一步升级成什么类型的变量都能够打印:

#include<stdio.h>

#define PRINTF(n,format) printf("the value of "#n" is" format"\n",n)
//#a=“a”,这里format前面没有写#是因为传给宏的参数写的是“%d”
//因为传的时候写了“”,因此宏的内容就不需要写#了

int main()
{
	int a = 10;
	//printf("the value of a is %d\n", a);
	PRINTF(a, "%d");

	int b = 20;
	//printf("the value of b is %d\n", b);
	PRINTF(b, "%d");

	float f = 3.14f;
	PRINTF(f, "%f");

	return 0;
}
   5.3.2 ##号的作用    

        ##可以把位于其两边的符号连起来合成一个符号,例子如下:

#include<stdio.h>

#define CAT(x,y) x##y

int main()
{
	int ab = 100;
	int c = CAT(a, b);//CAT(a, b)替换成a##b,a##b=ab,即c=ab
	printf("%d\n", c);
	return 0;
}

        切记#和##只能用于宏,不能用于其他地方。

5.4 带有副作用的表达式

        对带有副作用的表达式定义为变量的值赋予给其他变量时,自身的值也发生了改变。

#include<stdio.h>

int main()
{
	int a = 10;
	//int b=a+1;这种写法b=11,a依然=10
	int b = a++;//这种写法b=11,但是a的值也发生了变化,因此称该表达式是带有副作用的
    
	return 0;
}

        如果宏定义中的宏参数也带有副作用则会影响表达式最终的值,会出现不可预测的结果,比如x+1是正常的表达式,但是x++就是有副作用的表达式。举个例子:

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))

int main()
{
	int a = 5;
	int b = 6;
	int c = MAX(a++, b++);
	//int c=((a++)>(b++)?(a++):(b++))
	//		  6     7           8
	//此处没用到 先把b的值7赋给了c之后,b后置++变成8

	printf("a=%d\n", a);//6
	printf("b=%d\n", b);//8
	printf("c=%d\n", c);//7

	return 0;
}

        正是因为表达式是带有副作用的,因此最后的结果无法预测,而且会随着参数不断的执行会一直持续下去。

5.5 宏和函数的对比

        上文中该式子为什么不用函数的形式进行运算,而选择用宏进行计算。

#define MAX(x,y) ((x)>(y)?(x):(y))

原因:

        1、像这类计算量特别小的式子,如果用函数来进行计算,则用于调用函数和从函数返回的执行代码可能比计算该式子所耗费的时间更多。

        2、若用函数来计算该式子,则会受参数类型的限制。若用宏则可以进行对任意类型,比如整形、浮点型、长整型的计算。

        用函数的方法计算较大值:

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))//任何类型都可传进来进行计算

int Max(int x, int y)//因为形参类型为int类型,因此只能接收int类型的实参
//无法对比其他类型数据的大小
{
	return (x > y ? x : y);
}

int main()
{
	int a = 5;
	int b = 6;
	//int c = MAX(a++, b++);
	int c = Max(a, b);
	

	printf("a=%d\n", a);
	printf("b=%d\n", b);
	printf("c=%d\n", c);

	return 0;
}

        但是上述代码中函数也有优势的地方,体现函数优势的例子如下:

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))
//               a+3  b+3 a+3 b+3
int Max(int x, int y)
//          8      9
{
	return (x > y ? x : y);
}

int main()
{
	int a = 5;
	int b = 6;
	int c = Max(a+3, b+3);
	//与宏不一样的是,若用函数的形式,则会先算出a+3的值在进行传参
	//而宏则会把a+3整个式子都进行替换,再进行计算,并不会先算出a+3的值再替换
	//因此函数参数只在传参的时候求值一次,结果更容易控制。

	printf("c=%d\n", c);
	return 0;
}

        若用函数的形式会把a+3的值算出来传给形参。相比于宏传的是整个a+3的式子,函数的形式不会出现表达式副作用的影响,而且在逻辑的计算上也方便了不少。

        但是宏还有一个优点是函数没有的,就是宏可以传递类型,函数是没有把类型作为实参进行传递这种说法的。像上文打印浮点型,整形的例子就用到了传递格式的方法,这是函数做不到的。传递类型的例子如下:

#include<stdio.h>

#define MALLOC(num,type) (type*)malloc(num*sizeof(type))

int main()
{
	int* p = MALLOC(10, int);
	//替换后:int *p=(int*)malloc(10*sizeof(int))

	return 0;
}

但是宏相比于函数也有不足的点:

        1、在预处理阶段宏会发生替换,若宏的内容过长,则会在预处理阶段大幅度增加代码的长度。

        2、因为宏在预处理阶段才发生替换,因此所看到的代码跟执行起来的代码会不一样,所以不方便进行调试。

        3、因为宏对任何类型都来者不拒,因此他对类型的检查不够严谨。

        4、在使用宏时,若参数中含表达式、运算符,则会带来运算符优先级的问题,因此写宏的内容时需要仔细加上括号。

        因此宏和函数都各有各的好处与缺点,关键取决于在什么样的环境下使用他们。

6、命名约定

        由于宏与函数有些相似,因此我们对二者进行区分:

        1、宏的名字全部大写

        2、函数的名字不会全部都为大写

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))
int Max(int x, int y)
{
	return (x > y ? x : y);
}

int main()
{
	
	return 0;
}

7、#undef 

        作用是消除某个宏定义,格式如下:

#undef NAME
//NAME表示要消除宏的名字

        举个例子: 

#include<stdio.h>

#define MAX(x,y) ((x)>(y)?(x):(y))

int main()
{
	int a = 5;
	int b = 6;
	int c = MAX(a , b );//消除之前还能用MAX
	printf("c=%d\n", c);
#undef MAX//切记这里没有分号‘;’
	int c = MAX(a, b);//undef后,就找不到MAX了
	return 0;
}

8、命令行定义

        在Linux环境下用gcc编译器进行示范,允许在命令行中定义代码中的某些符号。用于启动编译过程。比如同一个源文件可以根据不同的命令行指令从而编译出不一样结果。比如在Linux环境下创建文件test.c,文件中代码如下:

#include <stdio.h>
int main()
{
	int array[SZ];//代码中并没有明确SZ的值
	int i = 0;
	for (i = 0; i < SZ; i++)
	{
		array[i] = i;
	}
	for (i = 0; i < SZ; i++)
	{
		printf("%d ", array[i]);
	}
	printf("\n");
	return 0;
}

        用命令gcc test.c -D SZ=10 -o test,在命令中在定义SZ的值,并且可以正常通过编译并生成结果。

9、条件编译

        在对程序进行编译的时候,可以选择一部分的语句不执行,这就是条件编译指令。格式如下:

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

        举例说明: 

#include<stdio.h>

int main()
{
#if 1//语句为真,则执行下面的代码
	printf("helloworld\n");
#endif
	return 0;
}

        若if后面的跟0,表示该if语句为假,则下面被包裹的代码不会被执行,甚至在预处理阶段就会将被包裹的代码直接删掉。

#include<stdio.h>

int main()
{
#if 0//语句为假,在预处理阶段直接把#if 0之下,#endif之上的代码全部删除 
	printf("helloworld\n");
#endif
	return 0;
}

        #if后面也可以跟表达式:

#include<stdio.h>

#define M 1

int main()
{
#if M==1//表达式成立即为真,则执行下面代码,表达式为假则不会执行
	printf("helloworld\n");
#endif
	return 0;
}

9.1 条件编译多分支执行

        逻辑很if else的多分支语句相似,格式如下:

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

        举例说明:


#include<stdio.h>

#define M 3

int main()
{
#if M==1
	printf("helloworld\n");
#elif M==0
	printf("hello\n");
#elif M==2
	printf("world\n");
#else//因为以上条件全部不满足,所以走#else语句
	printf("hw\n");
#endif
	return 0;
}

        这里进入了其中的一条语句后,其他语句包括语句下的代码都会在预处理阶段全部删除。

9.2 判断是否未定义

9.2.1 定义过即为真

        语法如下:

#if defined(symbol)//判断symbol是否被定义过,如果被定义则执行下面语句
#ifdef symbol

        当#if defined判断为真,举例说明:

#include<stdio.h>

#define MAX 5

int main()
{
#if defined(MAX)//MAX已经被#define标识符定义了,因为为判断为真
	printf("helloworld\n");
#endif
	return 0;
}

        当#if defined判断为假:

#include<stdio.h>

#define MAX 5

int main()
{
#if defined(MIN)//MIN没有被定义过,因此判断为假,不执行下面代码
	printf("helloworld\n");
#endif
	return 0;
}

        也可以将#if defined缩写成#ifdef:

#include<stdio.h>

#define MAX 5

int main()
{
#ifdef MAX//注意这种形式的写法后面就不需要加括号了
	printf("helloworld\n");
#endif
	return 0;
}
9.2.2 定义过即为假

        格式:

#if !defined(symbol)
#ifndef symbol

        以上的代码都是判断只有被定义过才会执行下面的代码。还可以从相反的角度来进行判断,如果没有被定义过则执行下面的代码,就跟if 1与if !1的逻辑相似。例子如下:

#include<stdio.h>

#define MAX 5

int main()
{
#if !defined MIN//#if !defined表示如果没有被定义过则执行下面的代码
	printf("helloworld\n");
#endif
	return 0;
}

        还可以用#ifndef表示:

#include<stdio.h>

#define MAX 5

int main()
{
#ifndef MIN//#ifndef表示如果没有被定义过则执行下面的代码
	printf("helloworld\n");
#endif
	return 0;
}

9.3 复杂嵌套形式

        总体逻辑也与if else的嵌套逻辑相似,格式:

#if defined(OS_UNIX)
 #ifdef OPTION1
 unix_version_option1();
 #endif
 #ifdef OPTION2
 unix_version_option2();
 #endif
#elif defined(OS_MSDOS)
 #ifdef OPTION2
 msdos_version_option2();
 #endif
#endif

        举例说明:

#include<stdio.h>

#define MAX 5

int main()
{
#if defined MIN3//这里没有定义过MIN3,因此该#if为假不走下面的语句
#ifdef MIN
	printf("helloworld\n");
#endif
#ifdef MIN2
	printf("hello\n");
#endif
#elif 3//这里#elif语句为真,则进入#elif语句
#ifdef MAX//对面MAX定义过,因此#ifdef语句为真,则执行下面的语句,打印world
	printf("world\n");
#endif
#endif
	return 0;
}

10、文件包含

        #include "filename.h",双引号的形式会先从源文件所在文件的路径下查找有没有filename.h文件,如果没有找到就去存放库函数的文件里面查找filename.h。如果还找不到就编译报错。

        #include<filename.h> ,尖括号的形式会直接去存放库函数的文件下查找有没有filename.h文件,如果找不到就报错。

        虽然说双引号的形式也能从库函数里面查找文件,但是如果要查找库函数的文件还是用尖括号的形式较好,因为可以节省时间、提高编译器的效率,而且也能很好的区分库文件和本地文件。

10.1 嵌套文件包含

        像上图如此重复包含,最终程序会出现两份com.h文件,这种情况称为文件内容的重复。在预处理阶段的头文件展开时,会展开两份一模一样的com.h文件。

        代码举例:

test.c文件:
#include<stdio.h>
#include"test.h"
#include"test.h"
#include"test.h"

int main()
{

	return 0;
}

test.h文件:
int Add(int x, int y);

        上述代码出现的问题是会在预处理阶段,会展开三份test.h文件,导致了不必要的文件内容重复。解决方法(用之前学的条件编译),在test.h文件下操作:

//test.h文件:
//在头文件中添加如下代码
#ifndef __TEST_H__//如果没有定义过test.h,则执行下面代码
#define __TEST_H__

int Add(int x, int y);

#endif 

        这样一来,当执行到第二条#include“test.h"的时候,就会让#ifndef __TEST_H_语句为假,则就不会进入下面的代码。当然还有第二种写法,效果与第一种写法一样。这两种写法都可以避免文件的内容重复。

//test.h文件:
#pragma once
int Add(int x,int y);

结语:

        以上就是关于编译与链接的全部讲解,如果本文对你起到了帮助,希望可以点赞👍+关注😎+收藏👌哦!如果有遗漏或者有误的地方欢迎大家在评论区补充~!!谢谢大家!!( ̄︶ ̄)↗

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

安权_code

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值