【C语言学习笔记 --- 编译与链接】

C语言程序设计笔记---041

  • C语言之编译与链接
    • 1、介绍编译与链接
    • 2、详解编译和链接过程
      • 2.1、预编译/(预处理)
      • 2.2、编译
      • 2.3、汇编
    • 3、链接(链接器)
    • 4、运行环境
    • 5、预处理详解
      • 5.1、预处理符号
      • 5.2、#define
        • #define定义常量(标识符)
        • #define定义宏
        • #define 宏定义的替换规则
      • 5.3、#和##
    • 6、带副作用的宏参数
      • 6.1、宏和函数对比
      • 6.2、条件编译
      • 6.3、命令行定义和#undef预处理指令
      • 6.4、头文件包含
    • 7、offsetof宏
      • 7.1、实现宏offsetof
      • 7.2、写一个宏:交换奇偶位
    • 8、结语

C语言之编译与链接

前言:
通过前面篇章的知识,这篇将对编译和链接的原理,进行深入的学习。

/知识点汇总/

1、介绍编译与链接

基本定义:程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境
1.第一种是翻译环境,在这个环境中源代码被转换为可执行的机器指令
2.第二种是执行环境,它用于实际执行代码。
源程序 —(编译)–>目标文件–(链接)–>可执行文件
文本信息的代码 —>源文件—>翻译(编译器)—>可执行程序(二进制指令)—>(运行环境/执行环境)跑程序

2、详解编译和链接过程

翻译环境分为:编译(编译器)和链接(链接器link.exe)
Windows环境下生成的目标文件是xxx.obj
Linux环境下生成的目标文件是xxx.o

其中,编译又分为:1.预编译(预处理)2.编译 3.汇编

2.1、预编译/(预处理)

(1).:预编译/(预处理)
只执行预处理–> .i后缀名
Linux预编译指令:gcc -E test.c -o test.i

#include 
#define M 1000
int main()
{
	int a = 10;
	int b = 20;
	printf("hehe\n");
	int c = a + b;
	int d = M;
	printf("%d\n", d);
	return 0;
}

小结
预处理就是处理注释信息、有文件信息拷贝调用、#define替换…
1.注释的替换(删除),被替换为空格
2.头文件包含(拷贝)#include <>
3.#define 符号替换
4.#开头的指令,预处理指令都是预处理(预编译)阶段进行处理,统称为文本操作

2.2、编译

(2).编译:
只执行到编译–> .i后缀名
Linux预编译指令:gcc -S test.i (-o test.s)

#include 
#define M 1000
int main()
{
	int a = 10;
	int b = 20;
	printf("hehe\n");
	int c = a + b;
	int d = M;
	printf("%d\n", d);
	return 0;
}

小结
编译的过程就是将.c源文件代码,翻译成汇编代码
编译原理
1.词法分析:将源代码输入扫描器,进行词法分析,也就是将代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)
2.语法分析:语法分析器,将扫描器的扫描结果,进行语法分析,组成语法树,以表达式为结点,层层迭代
3.语义分析:根据语法标准识别执行逻辑
4.符号汇总:识别标识符汇总(一般是汇总一些全局的符号,局部没什么意义)

2.3、汇编

(3).汇编:
只执行到汇编–> .o后缀名
Linux预编译指令:gcc -c test.s

#include 
#define M 1000
int main()
{
	int a = 10;
	int b = 20;
	printf("hehe\n");
	int c = a + b;
	int d = M;
	printf("%d\n", d);
	return 0;
}

小结
把汇编代码翻译成二进制代码,才生成了.o文件(目标文件)
生成符号表(与链接呼应)

3、链接(链接器)

链接(链接器)
汇编到链接–> .exe后缀名
Linux预编译指令:gcc test.o -o test
可执行程序(二进制指令)
运行可执行程序./test
执行
1.链接目标文件和链接库生成可执行程序
2.合并段表
3.符号表的合并和重定位

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

符号汇总和合并段表
通常操作的是全局的变量,因为全局的变量才具备外部编译属性,才能跨文件。
gcc-编译器,生成的目标文件和二进制的可执行文件都是按照elf,这种文件的形式组织的
合并段表就是将目标文件中,相同形式的段落进行和合并,并重定位一个新的符号段表
函数的外部链接就是通过链接库与符号表匹配才能访问外部链接正确查找到有效地址。

小结
1.汇编步骤,生成的符号表,链接步骤合并符号表,对段表进行合并,符号表进行合并与重定向。
2.无法解析的外部符号的报错:底层原因就是,扫描器对形同的符号进行扫描时,不存在或拼写错误的问题

4、运行环境

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

5、预处理详解

5.1、预处理符号

FILE //进行编译的源文件
LINE //文件当前的行号
DATE //文件被编译的日期
TIME //文件被编译的时间
STDC //如果编译器遵循ANSI C,其值为1,否则就是未定义
这些预定义符号都是C语言内置的

#include 
int main()
{
	printf("%s\n", __FILE__);
	printf("%d\n", __LINE__);
	printf("%s\n", __DATE__);
	printf("%s\n", __TIME__);
	printf("%s\n", __FUNCTION__);
	//printf("%d\n", __STDC__);//未定义--说明当前编译器使用的ANSI C不支持
	return 0;
}

5.2、#define

#define是一种预处理指令
1.#define定义常量(标识符)
2.#define定义宏

#define定义常量(标识符)
#include 
#define MAX 100
#define STR "abcdef"
#define INT int
#define M   3+5
#define forever for(;;)
#define CASE break;case
#define MIN 10;//加分号的情况,一般不建议加,或根据需求加
int main()
{
	int a = MAX;
	INT b = 0;
	printf("%s\n", STR);
	int c = M;
	forever;
	return 0;
}

小结
#define本质是替换,带不带分号,根据实际需求来选择

#define定义宏

#define机制包括了一个规定,允许把参数(或表达式)替换到文本中,这种实现放式称为宏
宏的申明方式:#define name(parament.list) stuff
其中的parament.list式一个由逗号隔开的符号表,它们可能出现在stuff中
注意
1.参数列表的左括号必须与name紧邻
2.如果两者间有任何空白存在,参数列表就会被解释为stuff的一部分。

#include 
//#define SQUARE(x) (x*x)
#define SQUARE(x) ((x)*(x))
int main()
{
	printf("%d\n", SQUARE(5));
	printf("%d\n", SQUARE(5 + 1));
	return 0;
}

小结
1.所以在对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用时由于参数中的操作符或领近操作符之间优先级的问题相互作用,造成数据影响错乱
2.所以建议跟上对应的括号。

#define 宏定义的替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤
1.调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换
2.替换文本随后被插入到程序中原本文本的位置,对于宏,参数名被他们的值所替换。
3.最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述过程。

#include 
#define M 100
#define ADD(x,y) ((x)+(y))
int main()
{
	int a = 10;
	int b = 10;
	//int c = 4 * ADD(100, b);
	int c = 4 * ADD(M, b);
	printf("%d\n", c);
	return 0;
}

注意
1.宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
2.当预处理搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

5.3、#和##

#的作用
如何把参数插入字符串中?
1.一个是printf函数的多组双引号,组成输出
2.利用#插入,把一个宏参数变成对应的字符串

#include 
int main()
{
	//printf参数可以是多组""的内容
	printf("hello world\n");
	printf("hello ""world\n");
	return 0;
}
#include 
#define PRINT(n,format) printf("the value of"#n" is "format"\n",n)
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);
	float c = 4.5f;
	printf("the value of c is %f\n", c);
	
	//宏解决问题,宏不关心类型
	int a = 10;
	PRINT(a, "%d");
	//预编译替换结果:这里用中文输入,以突出显示区别
	//printf("the value of"“a”" is "“%d”"\n",n)
	int b = 20;
	PRINT(b, "%d");
	float c = 4.5f;
	PRINT(c, "%f");
	return 0;
}

##的作用
##可以把位于两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符

#include 
#define ADD(v,n) v##n
int main()
{
	int value10 = 100;
	printf("%d\n", ADD(value, 10));//将参数合并
	printf("%d\n", value10);
	return 0;
}

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

6、带副作用的宏参数

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候,可能会出现危险,导致不可预测的后果。
副作用就是表达式求值的时候出现的永久性效果。
比如
x + 1; – 不带副作用
x++; – 带副作用

例子1:

#include 
int main()
{
	int a = 10;
	//int b = a + 1;//b = 11,a = 10
	int b = ++a;//b = 11,a = 11,不难看出,这样的表达式,会影响a的值发生变化,所以有副作用
	return 0;
}

例子2:

#include 
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()
{
	int a = 3;
	int b = 5;
	int m = MAX(a, b);
	//预编译阶段:实施替换
	//int m = ((a)>(b)?(a):(b))
	printf("%d\n", m);
	return 0;
}

例子3:

#include 
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()
{
	int a = 3;
	int b = 5;
	int m = MAX(a++, b++);
	//预编译阶段:实施替换
	//int m = ((a++)>(b++)?(a++):(b++))
	//          3     5
	//          a=4   b=6   no    6
	//    6     4                 7 
	printf("%d\n", m);//6
	printf("%d\n", a);//4
	printf("%d\n", b);//7
	return 0;
}

6.1、宏和函数对比

1.宏通常被应用于执行简单的运算
2.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多;所以宏比函数在程序的规模和速度方面更胜一筹
3.更为重要的是函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。反之,这个宏怎么可以适用于整型、长整型、浮点型等可以用于>来比较的类型,宏是类型无关的
宏的缺点
1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序中的长度
2.宏是没法调试的
3.宏由于类型无关,也就不够严谨
4.宏可能会带来运算符优先级的问题,导致程序容易出现错误
一个突出的区别,宏的参数可以是类型,而函数不能传类型

#include 
#define MAX(x,y) ((x)>(y)?(x):(y))
int Max(int x, int y)
{
	return x > y ? x : y;
}
int main()
{
	int a = 3;
	int b = 5;
	int m = MAX(a, b);
	//预编译阶段:实施替换
	//int m = ((a)>(b)?(a):(b))
	printf("%d\n", m);
	int m1 = Max(a, b);
	printf("%d\n", m1);
	return 0;
}

小结
:适合简易计算
函数:函数调用、函数传参、栈帧的创建、计算、函数的返回

#include 
#define MALLOC(num,type) (type*)malloc(num*sizeof(type));
int main()
{
	int* p = (int*)malloc(10 * sizeof(int));
	//但函数无法传数据的额类型:
	//如:my_malloc(10,int);
	//但是宏可以传类型
	int* p = MALLOC(10, int);
	if (p == NULL)
	{
		//....;

	}
	return 0;
}

小结
1.函数的参数表达式是在调用的时候计算之后,把计算结果传递给函数,所以表达式的求值结果更容易预测
相比宏的表达式参数求值,是整体原样的替换到对应的上下文环境中,所以建议要加上必要的括号,否则邻近操作符的优先级可能会产生不可预料的后果
2.宏的参数肯能被替换到多个位置,所以带有副作用的参数求值要谨慎使用,否则会产生不可预料的结果;而函数的值需要调用一次函数才有返回值,是相对可控的
3.宏的参数与类型无关,只要参数的操作合法,就可以使用任意类型的参数;函数对参数的类型要求严格,参数类型不同那么执行效果就不同
4.宏的调试相对复杂,因为可能多个地方都用到了宏的替换;函数的调试方便,逐语句调试即可
5.宏不支持递归,函数可以递归
6.所以当程序执行的逻辑相对比较简单的时候,可以使用宏来实现;当计算逻辑相对复杂时,就使用函数
命名约定
一般来说,函数和宏的使用语法类似,所以为了很好的区分,那么习惯规定
宏名全部大写
函数名不要全部大写

6.2、条件编译

条件编译
在编译一个程序过程中我们如果想要将一条语句(一组语句)编译或者放弃是很方便的。
因为我们有条件编译指令
常用于调试大量代码时,删除可惜,保留碍事,就可以引用条件编译,也就是满足条件才进行编译
常见的条件编译语句

1.#if 常量表达式(由预处理器求值)
....
#endif

2.#define __DEBUG__1
#if __DEBUG__
....
#endif

3.多个分支的条件编译
#if 常量表达式
....
#elif
...
#endif

4.#if defined(symbol)
#ifdef symbol

5.#if !defined(symbol)
#ifndef symbol

6.嵌套指令:一般常见于头文件中
#if defined()
	#ifdef 
	....fun1();
	#endif
	#ifdef 
	....fun2();
	#endif
elif defined()
	ifdef
	.....fun3();
	#endif
endif

举例1:

#include 
int main()
{
#if 1 //预编译为真
	printf("hehe\n");
#endif

#if 1==2 //预编译为假
	printf("haha\n");
#endif
	return 0;
}

举例2
等价于理解为:
if
else if()
else

#include 
#define M 2
int main()
{
#if M //预编译为真
	printf("hehe\n");
#elif M==1 
	printf("haha\n");
#elif (M+1)==1 
	printf("heihei\n");
#else
	printf("xixi\n");
#endif
	return 0;
}

举例3:

#include 
#define M 0
int main()
{
#if defined(M)
	printf("hehe\n");
#endif

#if !defined(M)
	printf("hehe\n");
#endif

#ifdef M
	printf("hehe\n");
#endif

#ifndef M
	printf("hehe\n");
#endif
	return 0;
}

6.3、命令行定义和#undef预处理指令

命令行定义
许多编译器提供一种能力,允许在命令行中定义符号。用于启动编译过程。
比如:gcc编译环境下,命令行输入参数符号
gcc -D [符号名] test.c

#undef
功能:这条预处理指令用于移除一个宏定义
举例:

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

#undef M//取消了宏定义
	//int k = M;//无法打印
	//printf("m = %d\n", k);
#define M 50 //可再次宏定义
	int n = M;
	printf("m = %d\n", n);

	return 0;
}

6.4、头文件包含

头文件包含
1.包含本地文件(自己的.h文件)
比如:#include “xxx.h”
2.包含标准库的头文件
#include
小结
库文件的包含头文件方法:,直接去标准路径下查找,如果找不到就提示编译错误
本地文件的包含头文件方法:“xxx.h”,先去源文件所在目录下查找,如果没找到,编译器就跟库文件一样去标准路径位置查找,还是找不到就提示编译错误

另外,规定头文件的标准对于大型的工程编译时,能节约大量的编译时间
引用预编译指令就可以防止被头文件的重复包含,所以预编译指令常见于头文件中
还有一种解决办法:#pragma once 避免重复引入
其它预处理指令

#error
#pragma
#line
...

7、offsetof宏

offsetof宏:计算偏移量

#include 
#include 
struct S
{
	char c1;//1 -->之后浪费3
	int a;//4
	char c2;//1
};//4(3浪费) + 4 + 4(3浪费)  = 12字节
int main()
{
	struct S s;
	printf("%zd\n", offsetof(struct S, c1));//0
	printf("%zd\n", offsetof(struct S, a));//4
	printf("%zd\n", offsetof(struct S, c2));//8
	return 0;
}

7.1、实现宏offsetof

#include 
#include 
struct S
{
	char c1;//1 -->之后浪费3
	int a;//4
	char c2;//1
};//4(3浪费) + 4 + 4(3浪费)  = 12字节

#define OFFSETOF(type,mem) (size_t)&(((type*)0)->mem)
//假设在0这里设置一个(结构体)类型的指针,然后结构体访问->指向的成员,取出成员对应的地址。连续按照规则存放嘛,所以成员地址就等于偏移量
int main()
{
	struct S s;
	printf("%zd\n", OFFSETOF(struct S, c1));//0
	printf("%zd\n", OFFSETOF(struct S, a));//4
	printf("%zd\n", OFFSETOF(struct S, c2));//8
	return 0;
}

7.2、写一个宏:交换奇偶位

思路拆分:
一个数的二进制位的和是多少,可以由二进制序列的奇数位加偶数位的和得
1011 – 11
奇数位0 0 0 1的和:1+0 = 1
偶数位1 0 1 0 的和:8 + 2 = 10
交换偶位:
分别取出奇偶位,分别左移和右移,再相加
怎么取奇数?
比如:1111 1111
与上:0101 0101 —>55
32位同理:
与上0x55 55 55 55即可得到奇数位,再左移<<1即可
怎么取偶数?
比如:1111 1111
与上:1010 1010
32位同理:
与上0xaa aa aa aa即可得到偶数位,再右移>>1,偶数位就来到了之前的奇数位了

#include 
#define SWAP_BIT(n)  (n = ((n&0x55555555)<<1)+((n&0xaaaaaaaa)>>1))
int main()
{
	int a = 10;
	SWAP_BIT(a);
	printf("%d\n", a);//5

	SWAP_BIT(a);
	printf("%d\n", a);//10

	return 0;
}

8、结语

了解学习编译和链接,有助于我们更深入的理解底层的原理,提高程序的效率和性能,减少内存碎片和错误,增强程序的灵活性和适应性。同时,也要避免错误和内存泄漏的可能性。

你可能感兴趣的:(C语言基础,c语言,学习,笔记,编译与原理,#和##,编译链接,预编译处理指令)