了解程序的编译(预处理操作)+链接
1、程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。
2、详解编译+链接
2.1、翻译环境
2.1.1、组成一个程序的每个源文件通过编译器(cl.exe)编译,分别转换成目标代码( object code )。
2.1.2、每个目标文件由链接器( link.exe ) 捆绑在一起,形成一个单一而完整的可执行程序。
2.1.3、链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
2.2、编译本身也分为几个阶段
2.2.1、预处理
a、预处理阶段完成头文件的包含;
b、预处理阶段完成 #define定义的符号的替换、宏的替换;
c、预处理阶段会删除注释
以上均为文本操作。
2.2.2、编译
a、编译就是把C语言代码转化成汇编代码。
b、编译阶段会进行语法分析,从C语言的语法转化到汇编语言
c、编译阶段会进行词法分析,从C语言的词法转化到汇编语言
d、编译阶段会进行语义分析,从C语言的语义转化到汇编语言
e、编译阶段会进行符号汇总,符号汇总的是函数名、全局变量等全局的符号
2.2.3、汇编
a、汇编就是把汇编代码转化成二进制指令(机器指令)
b、汇编阶段会将汇总的符号生成符号表,符号表包含符号名,符号地址等信息。
2.3、链接
链接器会进行合并段表以及符号表合并和符号表重定位。
2.3.1、合并段表
段表是汇编时的一种指定格式的表格,各个文件汇编时都会生成段表,在链接时合并。
2.3.2、符号表的合并和符号表的重定位
不同文件中可能会引用相同名称的符号,如引用函数等。而每个文件汇编时会产生一个符号表,其中都会有这个函数,就造成重复处理,浪费资源。链接过程会把这些整合合并,重新定位,防止资源浪费。
2.4、运行环境
程序执行的过程:
2.4.1、程序必须载入内存中。在有操作系统的环境中一般由操作系统完成此操作。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码植入内存来完成。
2.4.2、程序的执行开始。调用 main 函数。
2.4.3、开始执行程序代码。这个时候程序将使用一个运行时堆栈 ( stack )存储函数的局部变量和返回地址。程序同时也可以使用静态( static )内存,让存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
2.4.4、终止程序。正常终止 main 函数;也可能是意外终止。
3、预处理详解
3.1、预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前指令的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__FUNCTION__ //指令所在的函数名
__STDC__ //如果编译器遵循ANSIC ,其值为1,否则未定义
这些预定义符号都是语言内置的。
演示如下:
//预定义符号是新建立源文件时由C语言自动定义的符号
//__FILE__ 是新建立的源文件名称
//__LINE__ 是符号所在的行号
//__DATE__ 是文件被编译的日期
//__TIME__ 是文件被编译的时间,时分秒
//__FUNCTION__ 是符号所在的函数名int main()
{printf("%s %d %s %s %s\n", __FILE__, __LINE__, __DATE__, __TIME__, __FUNCTION__);//打印结果:D:\C Projects\test8_9\test8_9\test.c 21 Aug 10 2024 10:12:54 main //可以看到,这样会记录下来文件路径、文件名、代码所在行号、日期、时间和所在函数名。return 0;
}
记录文档信息演示:
//预定义符号一般用来记录文档信息
int main()
{FILE* pf = fopen("test.txt", "a+"); //以尾端添加信息的形式打开test.txt文件,没有则新建一个文件if ( pf == NULL ) //检查文件是否打开{perror("fopen"); //打印错误信息return 1;}//将信息记录到文件中fprintf(pf, "%s %d %s %s %s\n", __FILE__, __LINE__, __DATE__, __TIME__, __FUNCTION__);//打开test.txt 文件,可以看到出现一行文件信息//D:\C Projects\test8_9\test8_9\test.c 31 Aug 10 2024 10:16:33 main//再次运行程序,可以看到增加了一条信息//D:\C Projects\test8_9\test8_9\test.c 31 Aug 10 2024 10:17:06 mainfclose(pf); //关闭文件pf = NULL; //指针置NULL,防止后面误引用return 0;
}
3.2、#define
3.2.1、#define 定义标识符
在#define定义标识符的时候,最好不要在后面加 “ ;” 。
演示如下:
//#define 使用//#define 定义标识符,在预处理过程中会被直接替换。
//如 #define MAX 1000,在预处理时,程序中的MAX会直接替换成1000。
//所以,#define 定义标识符的时候,最好不要在后面加“; ”#define MAX 1000;
int main()
{int i = 0; if (i < 1000) //i 小于1000就赋值i = 1000。 i 大于1000,则输出i。i = MAX; //如果这里习惯性在语句后加 ';'else //编译时这里就会发现问题printf("%d\n",i);//由于MAX 定义时已经加上 ';',后面语句再加';'就会认为是下一条空语句,即//i = 1000;; ,相当于//i = 1000;//;//而if语句不加 '{ }' 时,后面只能执行一条语句,于是发生报错。//为防止此类事件,建议#define时后面不要跟 ';'return 0;
}
3.2.2、#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏 ( macro )或定义宏( define macro )。
宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中。
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为 stuff 的一部分。
演示如下:
//#define 定义宏
//定义宏就是在标识符后面加 '( )' ,后面再加表达式
//'( )'内的字符将表达式中的字符替换掉进行运算#define ADD(a,b) a+b //定义宏 Add,功能是 a + b
int main()
{int i = 50;int j = 100;int sum = ADD(i, j);printf("%d\n", sum); //打印结果150 。 //可见sum 的值是 i+j = 50 + 100 = 150//ADD(a,b) 就是宏,输入的a,b会替换后面表达式中的 a,b。return 0;
}
用于对数值表达式进行求值的宏定义都应该加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
演示如下:
//#define 定义宏的替换
//之前提到过,#define 其实是直接将后面的内容替换成标识符
//使用标识符的时候,要有清晰认知,不能将标识符代替的内容当成一个整体#define ADD(a,b) a+b
#define ADD1(a,b) ((a)+(b)) //加了'( )'
int main()
{printf("%d\n", ADD(20+30, 30+30)); //打印结果 110 。printf("%d\n", 2 * ADD(20+30, 30+30)); //打印结果 130 。//2*ADD(20+30,30+30) 的结果不是2*(20+30+30+30) = 2*110=220。//因为#define 定义的标识符在预处理时已经直接被进行文本替换了。//即,替换后的表达式是: 2 * 20 + 30 + 30 + 30 =40 + 30 +30 + 30 = 130 。//为了防止此类问题,可以加'( )'printf("%d\n", ADD1(20 + 30, 30 + 30)); //打印结果 110 。printf("%d\n", 2 * ADD1(20 + 30, 30 + 30)); //打印结果 220 。return 0;
}
3.2.3、#define 替换规则
在程序中扩展 #define 定义符号和宏时,需要涉及几个步骤。
a、在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,他们首先被替换。
b、替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
c、最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程。
注意:
a、宏参数和 #define 定义中可以出现其他 #define 定义的常量。但是对于宏,不能出现递归。
b、当预处理器搜索 #define 定义的符号的时候,字符串常量的内存并不被搜索。
演示如下:
//#define 替换规则
#define MAX 100
#define ADD(a,b) ((a)+(b))int main()
{int i = 10;printf("%d\n", ADD(MAX, i)); //打印结果 110//说明宏参数定义中可以出现#define 定义的常量标识符。printf("MAX = %d\n", MAX); //打印结果 MAX = 100 。//可见字符串中的#defien 定义的常量标识符不会被替换。return 0;
}
3.2.4、# 和 ##
只有字符串作为宏参数的时候才可以把字符串放在字符串中。
a、使用 # 把一个宏参数变成对应的字符串
b、## 可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
#的作用演示如下:
//# 和 ## 的作用
#define print1(a,b) printf("%s %s""两组字符\n",a,b)#define print2(a,b) printf("a b""两组字符\n")#define print3(a,b) printf(#a" " #b"两组字符\n")
int main()
{char ch[] = "abcd";print1(ch, "efg"); //打印结果 abcd efg两组字符,说明字符串标识符可以直接放在字符串中print2(3, "5"); //打印结果 a b两组字符。说明这种方式无法将标识参数字符打印出来char m= 'M';print3(3, m); //打印结果 3 m两组字符。说明在#define 定义标识符中,可以用#+参数的方式打印出对应的字符return 0;
}
##的作用演示如下:
#define MAX1 1000
#define M MAX##1
int main()
{int i = M;printf("%d\n", M); //打印结果1000。可见#define 中,##可以连接分离的文本形成标识符return 0;
}
3.2.5、带副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的结果。副作用就是表达式求值的时候出现的永久性效果。
演示如下:
//带副作用的宏参数
//副作用就是类似++ --之类的
#define MAX(a,b) ((a++)>(b++)?(a++):(b++)) //计算最大值int main()
{int i = 50;int j = 70;printf("%d\n", MAX(i, j)); //打印结果 71 。//宏 MAX 执行时产生了永久性的效果,使得 a 和 b 的值发生改变,其输出值也会发生变化。return 0;
}
3.2.6、宏和函数对比
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。
宏相对函数的优势:
a、用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
b、函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。而宏可以适用于整型、长整型、浮点型等可以用于比较的类型。宏是类型无关的。
c、宏有时候可以做到函数做不到的事情。比如宏的参数可以出现类型,但是函数做不到。
简单函数演示如下:
//宏和函数的对比
//简单计算
#define ADD(a,b) ((a)+(b))int Add(int a, int b)
{return a + b;
}
int main()
{int i = 10;int j = 7;printf("%d\n", ADD(i, j)); //打印结果 17 。printf("%d\n", Add(i, j)); //打印结果 17 。return 0;
}
同类型演示如下:
//类型相关
#define ADD(a,b) ((a)+(b))int main()
{float a = 1.4;int b = 2;printf("%f\n", ADD(a, b)); //打印结果3.40000 。可见宏可以适用于不同参数类型return 0;
}
类型做参数演示如下:
//类型参数
#define MALLOC(a,type) (type*)malloc(a*sizeof(type))int main()
{int a = 4;int* pf = MALLOC(a, int); //申请4个整型大小的动态空间。//显然函数无法完成此操作free(pf);return 0;
}
宏相对函数的劣势:
a、每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
b、宏是没法调试的。
c、宏由于类型无关,也就不够严谨。
d、宏可能会带来运算符优先级的问题,导致程序容易出现错误。
命名约定
函数和宏的使用语法相似,所以语言本身没法帮我们区分二者。一般习惯上是:
宏名全部大写;
函数名不要全部大写。
3.3、#undef
用于移除一个宏定义。
演示如下:
//undef 函数使用
#define MAX(a,b) ((a>b)?(a):(b))int main()
{int i = 4;int j = 5;printf("MAX = %d\n", MAX(i,j)); //打印结果 MAX = 5。#undef MAX;printf("MAX = %d\n", MAX(i,j)); //报错,MAX 未定义,说明MAX的定义已被移除return 0;
}
3.4、命令行定义
许多C的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本时,可以利用这个特性。(假定某个程序中声明了一个指定长度的数组,如果机器内存有限,我们就需要一个很小的数组;如果内存大,就需要一个大的数组。)
3.5、条件编译
使用条件编译指令,可以方便在编译一个程序的时候,将一条语句(一组语句)编译或者放弃。
比如:调试性的代码,删除可惜,保留又碍事,所以可以选择性的编译。
3.5.1、条件编译
#if 常量表达式
#endif
演示如下:
//条件编译
//所有条件编译都要有结束标识 #endif
//1、#if 常量表达式
//#endif
//通过条件编译判断是否执行代码
#define A 1 //定义一个常量表达式 A = 1int main()
{
#if A //如果A为真,那就执行下面的指令printf("aaa"); //打印结果 aaa 。
#endif#if B //如果B为真 ,那就执行下面的指令printf("bbb"); //未打印
#endif//经测试,定义B = 0 ,也不打印return 0;
}
3.5.2、多分支条件编译
#if 常量表达式
#elif 常量表达式
#else
#endif
演示如下:
//多分支条件编译
//#if 常量表达式
//#elif 常量表达式
//#else
//#endifint main()
{//通过多分支条件编译执行想要执行的代码
#if 3+5>19printf("if");
#elif 5+7>19printf("elif");
#elseprintf("else"); //打印结果 else 。
#endifreturn 0;
}
3.5.3、判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
演示如下:
//判断是否被定义
//#if defined(symbol) 如果定义了标识符就执行
//#ifdef symbol 如果定义了标识符就执行
//#if !defined(symbol) 如果没定义标识符就执行
//#ifndef symbol 如果没定义标识符就执行#define A
int main()
{
#if defined(A) //如果A被定义了,就执行语句,不管定义的值是多少printf("定义了\n"); //打印结果: 定义了
#endif#ifdef A //如果A被定义了,就执行语句,不管定义的值是多少printf("简写\n"); //打印结果 简写
#endif#ifdef B //如果定义了B,就执行语句,不管定义的值是多少printf("简写定义\n"); //未打印
#endif#if !defined(B) //如果B没被定义,就执行语句printf("没定义\n"); //打印结果 没定义
#endif#ifndef B //如果B没被定义,就执行语句printf("简写没\n"); //打印结果 简写没
#endif#ifndef A //如果A没被定义,就执行语句printf("简写没A\n"); //未打印
#endifreturn 0;
}
3.5.4、嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_pution1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
演示如下:
//嵌套指令
//和嵌套if语句类似
#define A
int main()
{
#if 1+1 == 2 //如果 1+1 == 2 ,执行命令#ifdef A //如果A被定义,则执行命令printf("嵌套A\n"); //打印结果 嵌套A#endif #ifdef B //如果B被定义,则执行命令printf("嵌套B\n"); //未打印#endif
#endifreturn 0;
}
3.6、文件包含
#include 指令可以使另外一个文件被编译,就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
3.6.1、头文件被包含的方式
a、本地文件包含
#include "filename"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
Linux 环境的标准头文件的路径:
/usr/include
VS 2019环境的标准头文件的路径:
D:\Windows Kits\10\Include\10.0.19041.0\ucrt
注意:按照自己的安装路径去找。
b、库函数包含
#include
查找头文件直接去标准路径下去查找,如果找不到就提示编译器错误。
3.6.2、嵌套文件包含
如果一个程序引用了两个头文件a、b,而这两个头文件a、b又都引用了同一个头文件c,这就造成了文件内容的重复。
通过条件编译可以解决这个问题。每个头文件的开头写:
#ifndef __C_H__
#define __C_H__
#endif
或者
#pragma once
就可以避免头文件的重复引入。
演示如下;
//嵌套文件包含//待引用的加法文件,想要不被重复引用,可以用条件编译#ifndef __ADD_H_ //如果ADD.H没被定义,那就编译代码。如果被定义了,就不再编译
#define __ADD_H_
int Add(int x, int y)
{return x + y;
}
#endif
//通过这种方式可以防止头文件的重复引入。//还可以通过直接用预处理指令的方式处理嵌套文件包含
#pragma once//#pragma once 可以让相同的头文件只被编译一次