💕马行无力皆因瘦,人不风流只为贫💕
作者:Mylvzi
文章主要内容:程序环境和预处理
前言:
c程序的实现并不是简单的在vs上写好代码再调试执行的过程,vs是一种集成开发环境,简单来说,集成开发环境就是一个智能软件,它帮助人们进行代码的编写,执行,但背后具体的细节往往被忽略,了解这些细节有助于我们加深对计算机底层原理的认知,也能提高我们的代码理解能力
一.程序环境
任何一个ANSI C(标准C语言)的实现都需要经过两个“环境”-->翻译环境和执行环境
翻译环境:将所写代码(文本信息)转变为计算机语言(机械指令,二进制语言)形成目标文件并与链接库链接(给的任务看不懂,翻译为机器能看懂的语言)
-->将中文翻译成英文,并给你一些注释(链接库)
执行环境:执行计算机指令(得到任务,开始干活)
二.详解编译和链接
翻译环境图解
编译
编译:将文本信息翻译为计算机语言
编译分为三个过程:
1.预编译(预处理)-->文本操作,处理预处理指令(头文件包含,#define的替换,注释删除)
#include #define的替换都叫做预处理指令
2.编译-->(将C语言代码翻译为汇编指令)
3.汇编-->(将汇编指令转变为机械指令)
在LINUX环境中,利用gcc编译器查看编译过程的每一步:
LINUX:目标文件和可执行程序是通过ELF这种文件格式来存放计算机指令
readelf:读取elf的文件格式:
test.o的这种文件是按照段的形式存储的,他的信息被一个一个段划分存储
运行时环境
程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序 的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序的执行便开始。接着便调用main函数。
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回 地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程 一直保留他们的值。
4. 终止程序。正常终止main函数;也有可能是意外终止。
三.预编译详解
预编译阶段会处理所有的预处理指令,预处理指令有很多,比如:头文件的包含,#define的替换等等,下面将详细讲解预编译过程中都会处理那些预处理指令
1.预定义符号
预定义符号:是语言内置的,需要使用的时候就直接打印就行(就是为了方便)
在预处理阶段会直接被替换为相应的文本
2.#define
#define定义的标识符
//#define定义的标识符 #define M 100 #define REG register #define FOR 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定义的标识符时,不要在末尾添加“ ;”,否则可能出现两个分号的情况
#define定义的宏(重点)
宏:就是一种替换文本的工具,和#define定义的标识符作用类似
注意:
1.成员列表(parament-list)的左括号必须与name相连
2.stuff的每个参数都必须单独使用()括起来,且整体也要使用()括起来,避免优先级的错误
//宏 #define SQUARE(x) ((x)*(x)) int main() { int a = SQUARE(5); printf("%d\n", a);//输出5*5=25 return 0; } #define SQUARE(x) x*x //不添加括号,存在优先级的错误 //宏只是一种“形式”上的替换,他根本不会计算表达式的值 int main() { int a = SQUARE(3+2); printf("%d\n", a);//3+2*3+2=11 return 0; } #define DOUBLE(x) (x) + (x) int main() { int a = 5;//会输出100吗 printf("%d\n", 10 * DOUBLE(a));//10*(5)+5 == 55 return 0; }
#define的替换规则:
#define 替换规则 在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先 被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上 述处理过程。
注意:#define定义过程中可以嵌套,但定义的宏无法递归使用
如果定义的标识符常量在字符串中出现,在预处理阶段并不会被替换
3.#和##
#和##实际上是预处理过程中的运算符,是一种操作,不会被编译,而是去执行相应的操作
#:使宏参数以字符串的形式出现(stringization字符串化)
//我希望在字符串中添加宏参数,并不能直接写一个参数,因为在预处理时,字符串的内容并不会被扫描 //解决方法:就是让参数以字符串的形式出现-->在参数前添加# #define PRINT(n, format) printf("the value of " #n " is " format "\n", n) //format不需要再单独添加“”,因为传递的参数自带“” int main() { int a = 20; //printf("the value of a is %d\n", a); PRINT(a, "%d"); int b = 15; //printf("the value of b is %d\n", b); PRINT(b, "%d"); float f = 4.5f; //printf("the value of f is %f\n", f); PRINT(f, "%f"); return 0; } //#define PRINT_INT(n) printf(#n"=%d\n",n) //int main() //{ // int i = 10; // printf("i=%d\n", i);//认为这句代码太麻烦了 // PRINT_INT(i);//打印结果相同 // // return 0; //}
##: 把左右两边的符号合成为一个符号
执行的运算:粘合两个符号
#define CAT(x,y) x##y int main() { int ab = 10; printf("%d\n", CAT(a,b));//10 printf("%d\n", ab);//10 return 0; } 创建一个较长的宏 ECHO // //#define ECHO(s) ((scanf("%s", s)),(puts(s))) //int main() //{ // //读取到一个字符串,并输出 // char arr[20]; // //scanf("%s", arr); // //puts(arr); // // //利用逗号表达式同时实现了两个函数的功能 // ECHO(arr); // // return 0; //}
注意:连接后的标识符必须是一个合法的,可以被识别的标识符,否则就无意义
4.带有副作用的宏
如果参数具有“副作用”,可能会出现意想不到的结果,要避免宏的参数具有副作用
//带副作用的宏参数 int main() { int a = 10; //int b = a+1;//b=11, a=10 无副作用 //int b = ++a;//b=11, a=11 具有副作用 return 0; } #define MAX(x, y) ((x)>(y)?(x):(y)) int main() { int x = 5; int y = 8; int z = MAX(x++, y++);//带有副作用的参数 //替换:((5++) > (8++) ? (5++) : (8++)) // 先使用值,5<8 --> (8++) -->所以z=8+1=9 //((5++) > (8++) 值使用完之后在进行副作用,x=x+1=6,y=y+1=9 //最后的(8++)使用完后,执行y=y+1=9+1=10 //x=6,y=10,z=9 printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么? 6 10 9 return 0; }
5.宏与函数的区别
宏是小聪明,好看,好用,方便,但是不严谨,只能适用于简单的工作
函数是大智慧,使用不方便(耗时长),消耗的资源多,但是严谨,适用于大型工作
1.宏更适用于执行简单运算
2.宏与函数的比较
1.宏无法调试,而函数可以调试,可以发现计算过程中的错误
2.宏是形式上的替换,在预编译阶段直接替换,会增加源代码长度,如果大量使用,会导致代码长度过长;而函数是随用随找,不会增加代码长度(使用就进入)
3.宏不需要声明类型,无类型限制(不太严谨),函数有严格的类型限制
4.宏在传递参数时不会计算结果,可能出现副作用;而函数在传递时,会先计算值,不会有歧义
5.宏的参数可以出现类型,但函数不能(把类型当作参数)
6.宏的参数必须严格添加(),避免优先级的错误!
7.宏的执行速度更快,函数因为函数调用,函数栈帧开辟以及函数返回所需额外时间更多,所以执行效率较低(但如果是大型计算过程,相较于计算过程所需的时间,函数额外使用的时间可以忽略不计)
8.宏无法递归,函数可以递归使用
9.宏名一般全部大写,函数名一般首字母大写
//宏的参数可以是类型 #define MALLOC(num,type) (type*)malloc(sizeof(type)*num) int main() { int* p = (int*)malloc(sizeof(int) * 10);//我觉得这样开辟太麻烦,使用宏替换 int* p = MALLOC(10,int);//这样就实现了相同的功能 //宏的参数可以出现类型,而函数不能 return 0; }
3.集百家之长-->内联函数
内联函数: 集合宏的优点(计算速度快)和函数的优点(方便调试)的一种特殊类型的函数
1.可以被调试,可以观察每一步计算的具体过程
2.不需要进行函数栈帧的开辟,函数返回,节省时间
3.内联函数还是由编译器处理的,宏是在预处理阶段进行的形式替换
inline int Add(int x, int y) { return x + y; } int main() { int a = 10; int b = 20; int c = Add(a, b); printf("%d\n", c); return 0; }
4.一道题目
6.#undef
#undef用于去除已有的宏定义
格式:#undef NAME
在预编译过程中执行
7.命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。 例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个 程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器 内存大些,我们需要一个数组能够大些。)
8. 条件编译
所谓的条件编译就是在不同条件下编译不同的代码,既不用删除代码,也不需要编写多份代码
9.文件包含
#include指令可以被另一个文件编译,会被编译的结果替换
替换方式很简单:
1.#include出现在哪里,就在哪里替换
2.出现一次,就替换一次;出现十次,就替换十次(相当于被编译了10次)
头文件包含
C语言中的头文件包含有两种方式:
1.《 》包含 《》包含一般应用于库文件的包含,如:#include <stdio.h>
2.“”包含 " "包含一般应用于程序员本身头文件的包含,如:#include "test.h"
注意:“包含”其实是一种搜寻的过程,即检查路径之下是否包含被编译的文件;
“”的搜寻路径有两种,会现在源文件所在目录下搜寻,如果未找到,去库文件路径下搜寻;而《》的搜寻路径只有库文件路径
使用《》包含库文件,效率更高!
处理嵌套文件包含
#include的替换方式是出现一次,就替换一次,就编译一次;在使用的过程中可能会出现嵌套文件的包含,那么同一文件有可能在预处理的过程中被编译多次,大大增加代码量,要解决这个问题,有两种解决方式(通过条件编译解决)
四.其他预处理指令
#error 是一种在程序中提醒和报错的工具
#pragma
#line
... ... ...
比如:#pragma pack(8) 修改默认对齐数为8
#error指令常用到#else之后,表示其他条件都不成立的一种错误情况 //#if defined(WIN32) //... //#elif defined(MAC_OS) //... //#elif defined(LINUX) //... //#else //#error No operating system specified //#endif
五.总结
本文主要讲解了c程序实现过程中的两个环境:翻译环境和执行环境,具体讲解了翻译环境中的编译和链接的过程;又详细的讲解了编译过程中的预编译过程;尽管如今的集成开发环境已经如此便利,我们还是要去学习背后的基本逻辑与代码,这样才能加深我们对代码的理解能力
推荐书籍:
1.《程序员的自我修养》(强烈推荐)
2.《高质量c/c++编程指南》
3.《编译原理》
4.《C语言深度剖析》