底层干货——从程序员写下代码到执行到底发生了什么?
这一章呢,是属于了解编译器的后台做了什么。为什么在记事本中无法把我们写下的代码运行起来,但是在编译器中却可以呢?
下面我们就来看看吧!
综述
为什么我们写下的一串串英文字符居然可以就像一个软件一样,双击两下就可以运行?
为什么在记事本中无法把我们写下的代码运行起来,但是在编译器中却可以?
这就不得不说这些代码经历了什么了,
要想人前出名,必得经历千锤百炼之后,在适合的时机之时,方可大方光芒。
这代码也是这样,它先是在编译环境中呆上了七七四十九天。
在这期间,它可受尽了各种苦头。
在是在编译环境下经历了各种考验之后,
它已经不是原来的小白了,它可以独扛大旗的了,它彻底的升级了,成为了2.0版本(obj目标文件)。
于是,它终于登上了它梦中的理想之国运行环境。
在那里,它实现了它作为代码的价值。
被成功的运行了。
最先挑战——挑选装备(预编译)
代码小白最先来到编译险境中,为了过五关,闯六将,
首先,它需要从武器装备库中挑选一些它需要的武器才行
只有有了这些武器,才能在后续的过程中通关。
将所引用的头文件包含进来,条件编译的处理#ifndef # endif
另外,它进入此地之前还存储过一些钱在银行卡里面,所以要把钱取出来,以备后续使用。
将define,宏的内容替换
并且,进入编译险境中就要和某些对蜕变没有用处的事情say bye bye了,因为我们要专心才能够成功。
将注释消灭掉
好啦,经过上面的挑选装备的阶段,我们才算的上是刚踏入编译险境的大门,后面还有更加神奇的冒险等着我们的代码小白呢,现在让我们看看还有什么挑战!
从.c文件变成了 .i
第一关——先战透视妖精(编译)
对于这第一关那,代码小白就要花费一些功夫。
因为这透视妖精可不是一般人物。它会的武功招数可多着呢!
它会使用透视镜,将代码小白的全部信息都看了个遍。
经过这么全身上下的大扫描之后,
词法分析,语法分析,语义分析,符号汇总
我们的代码小白反而利用了透视妖精的科研成果,偷偷地将它的内容改变成了一个隐身衣,
于是,代码小白穿上隐身衣汇编语言后,就伪装成了另外一个人啦。
.i变成了汇编语言.s啦
第二关——再战二进制怪(汇编)
虽然说代码小白在第一关的时候狠狠的赢了一把,穿上了它的隐身衣,
但是,没有人会一直成功,
它遇到了二进制怪,二进制怪偏偏就认识汇编语言,
看到它就会有不由自主的冲动将他变为二进制,
将汇编语言(.s)变为二进制语言(.o)
还会将它的关键武器复制一份
收集符号表
第二关——交朋友(链接)
代码小白有的时候运气好的时候,会遇到一些朋友,它们有的也是和代码小白这样的小白。
所以,它们就会并肩作战,一起来勇闯编译险境。
有小伙伴谁不爱呢?双双联手,一起勇闯天涯。
于是,它们连个小白就捆绑在一起了,为了更好的御敌
合并段表
同时,它们两个的优良武器也会被收集起来,合二为一,防止浪费空间
符号表的合并和重定位
再加上链接库
到这,他成功的就完成了任务
从.obj文件变成了.exe文件
编译险境成功
到交朋友结束的时候,编译陷阱也就差不多就结束。
要知道,当走到之一步的时候,我们就已经胜利了。
经过编译险境的历练,想必代码小白有诸多的感慨,毕竟他已经不是原来的它了。
至于到底发生了什么,总的来说,是代码小白的身份变化啦。
从原来的文本信息(.c文件)摇身一变,变成了可执行文件(.exe)。
进入运行险境
在运行环境中,它就会展现它在编译险境中学到的武功了
首先,他会先进入进入运行环境中去。
先加载到内存中去
然后,会先展示它的看家本领。
先找到它的main函数
接着,将它所有的大大小小的武功全部都使出来。
调用函数堆栈
最后,考验结束,代码小白完成了它作为代码的所有使命。
调用main函数结束
易错题:
由多个源文件组成的C程序,经过编辑、预处理、编译、链接等阶段会生成最终的可执行程序。下面哪个阶段可以发现被调用的函数未定义?( )
答案是:链接
预处理只会处理#开头的语句,编译阶段只校验语法,链接时才会去找实体,所以是链接时出错的,这里附上每个步骤的具体操作方式:
预处理:相当于根据预处理指令组装新的C/C++程序。经过预处理,会产生一个没有头文件(都已经被展开了)、宏定义(都已经替换了),没有条件编译指令(该屏蔽的都屏蔽掉了),没有特殊符号的输出文件,这个文件的含义同原本的文件无异,只是内容上有所不同。
编译:将预处理完的文件逐一进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码文件。编译是针对单个文件编译的,只校验本文件的语法是否有问题,不负责寻找实体。
链接:通过链接器将一个个目标文件(或许还会有库文件)链接在一起生成一个完整的可执行程序。 链接程序的主要工作就是将有关的目标文件彼此相连接,也就是将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。在此过程中会发现被调用的函数未被定义。需要注意的是,链接阶段只会链接调用了的函数/全局变量,如果存在一个不存在实体的声明(函数声明、全局变量的外部声明),但没有被调用,依然是可以正常编译执行的。
详细说明预编译
1. 预定义符号
- FILE //进行编译的源文件
- LINE //文件当前的行号
- DATE //文件被编译的日期
- TIME //文件被编译的时间
- STDC //如果编译器遵循ANSI C,其值为1,否则未定义
#include<stdio.h>
int main()
{
//注意:除了line是%d打印,其他的都是%s打印的哦
printf("%s\n%d\n%s\n%s\n", __FILE__, __LINE__,__DATE__, __TIME__);
return 0;
}
这个在我们设计项目需要时间的时候,我们就可以是使用DATE和TIME真的是太方便了呢。
2. #define
定义常量标识符
我们再重新的认识一下define吧
结构:
注意:
- name处是自己起的名字
- stuff是特定的数字,字符,关键字
- define是没有分号的。
例子:
#define MAX 1000
#define reg register //为 register这个关键字,创建一个简短的名字
定义宏
define还是可以定义宏的,什么是宏呢?就是define的时候name部分带参数的就是宏了
注:其中的 parament - list 是一个由逗号隔开的符号表,它们可能出现在 stuff 中
例子:
#define SQUARE( x ) x * x
SQUARE( 5 );
//效果:5 * 5
- 参数列表的左括号必须与name紧邻
- 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
就像下面这样,就不再是宏了,而是上面的定义常量标识符了。
另外,定义宏必须要设置好符号,要不然会出现副作用。
#define DOUBLE(x) (x) + (x)
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));
//效果:printf ("%d\n",10 * (5) + (5));
//55
define的替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
例子:
#define SQUARE(x) x * x
#define MAX 10
int main()
{
printf("%d\n", SQUARE(MAX));
}//先将MAX替换成10
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
#define SQUARE(x) x * x
#define MAX 10
int main()
{
printf("%d\n", SQUARE(10));
}//宏的参数就直接变成了10
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
#define SQUARE(x) x * x
#define MAX 10
int main()
{
printf("%d\n", 10*10);
}//宏的内容被替换
注意:
- 宏中不可以递归。
这样就是不可以的。
- 常量字符串是不被替换的。
#define MAX 10
int main()
{
printf("%s\n", "MAX");//常量字符串不被替换
printf("%d\n", MAX);//被替换成10
}
#的作用
前提:
C语言中呢,有一个很明显的一点。那就是相邻的字符串是可以被连接的。
int main()
{
printf("hello world\n");
printf("hello " "world\n");
return 0;
}
下面那个是可以被链接的。
所以上面两个的效果是完全相同的。
案例:
设计一个打印函数,实现下面的效果:
int main()
{
int a= 10;
int b = 20;
printf("the value of the a is %d\n", a);
printf("the value of the b is %d\n", b);
return 0;
}
正常来看这样是很简单的,但是printf中的字符串的内容可不好办。
所以,# 就帮助我们解决了问题。
#+宏参数=“宏参数”
在加上字符串是可以进行拼接的。
所以,我们就可以利用它来进行改编。
#define PRINT(n) printf("the value of the "#n" is %d\n", n)
int main()
{
int a= 10;
int b = 20;
PRINT(a);
PRINT(b);
return 0;
}
使用上面的代码,我们就可以打印出我们想要的内容了。
the value of the a is 10
the value of the b is 20
##的作用
##用于宏中,将两个不相干的字符串连接到一起,形成一个字符串。
就像下面那样:
#define ADD(x,y) x##y
int main()
{
int lxy02 = 100000;
printf("%d", ADD(lxy, 02));
}
宏的副作用(易错)
对于因为宏是先替换,在计算的这一特性,所以,会产生一系列的副作用。
#define MAX(x,y) ((x)>(y)?(x):(y))
int main()
{
int a = 1;
int b = 10;
int c=MAX(a++, b++);
//完全替换为:
//(a++)>(b++)?(a++):(b++)
//
printf("%d\n", a);
printf("%d\n", b);
printf("%d\n", c);
return 0;
}
函数的实现
同样的求最大值,我们就来看一看函数是怎么实现的。
int MAX(int a, int b)
{
return a > b ? a : b;
}
int main()
{
int a = 1;
int b = 10;
int c = MAX(a++, b++);
//先计算,再调用
printf("%d\n", a);
printf("%d\n", b);
printf("%d\n", c);
return 0;
}
宏和函数的比较
宏的优点:
- 因为宏只是简单的替换,而不进行各种调用,所以和函数相比,会大大减小内存的分配并提高程序运行的速度。
- 宏可以不进行指定的类型。它可以传递进来不同类型的数据。
宏的缺点:
- 宏在优先级方面会产生副作用。
- 宏是直接替换的,所以无法进行调试。
- 如果宏的代码太长,并且还多次调用宏的话,那就会使整个程序变得很长。
- 由于不指定类型,所以宏是不太严谨的。
宏的参数可以是类型:
这是不是很厉害,要是放在函数那里,这是万万不可能的,但是宏就可以。
#define MALLOC(num,type) \
(type*)malloc(num*sizeof(type))
int main()
{
int* a = MALLOC(10, int);
//直接出现int类型
return 0;
}
宏 | 函数 | |
---|---|---|
代码长度 | 宏是每次见到那个关键字就将宏的代码拷贝过去。如果代码过长并且多次调用的话就会使整个函数的代码变得很长 | 函数是每次调用函数名,都会跳转到那个函数,出了函数就会销毁,所以并不会对程序的长度造成太大的影响。 |
执行速度 | 宏是直接替换,所以速度会非常的快 | 而函数不一样,涉及到函数在堆的开盘和销毁,会占用很多的时间和空间 |
操作的优先级 | 因为宏是直接将数值替换到参数上的,所以如果我们不加括号的话,常常会因为上下文的优先级问题导致不同的答案,发生错误 | 函数的参数只是在传值的时候计算一次,之后就传给形参。所以就不太会发生优先级上面的错误 |
参数类型 | 宏可以传递任何类型的参数,所以不用指定参数类型 | 函数则不行,函数必须要求类型是完全匹配的才可以 |
是否调试 | 宏是在预编译的时候就已经变成代码块了,变成了一个整体,是不可以调试的。 | 函数是当热可以进行调试的 |
是否递归 | 宏不可以进行递归 | 函数是当然可以进行递归 |
副作用 | 宏常常会因为优先级的问题产生副作用 | 函数产生副作用的机会非常小 |
特异功能 | 宏有很多有意思的功能如:#可以添加“”,##可以合并字符,是程序的可读性更好 | 函数就没有那么多神奇的功能了 |
命名规范
宏一般是大写的,
函数一般不是全部大写。
#undef
#undef是一个取消define的作用的。
取消定义之后再使用就会报错。
3.命令行编译
命令行编译就是在命令行处对程序赋予值。
对于某个变量,先不赋予值,等到在命令行的时候,
再根据需要(机器的内存大小等),再进行某个某个变量的赋值。
是不是也是很神奇呢?
#include <stdio.h>
int main()
{
int array[ARRAY_SIZE];
//先不指定长度
int i = 0;
for (i = 0; i < ARRAY_SIZE; i++)
{
array[i] = i;
}
for (i = 0; i < ARRAY_SIZE; i++)
{
printf("%d ", array[i]);
}
printf("\n");
return 0;
}
然后在编译阶段,
gcc -D ARRAY_SIZE=10 programe.c
再进行赋值
4.条件编译
我们可以对某些代码进行某些条件判断,如果是满足条件的话就进行编译,如果不满足就不编译。
就像我们头文件的
#ifndef TEST.H_
#define TEST.H_
…
#endif
来判断头文件是否已经被包含进来。
还可以当作注释来使用。
#if 0
…
#endif
这样,那部分的代码就被注释掉了。
还有连续的使用。
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
4.
和define的联合1:
#define MAX 100
int main()
{
//写法一:
#if defined(MAX)//注意这个defined()
printf("1\n");
#endif
//写法二:
#ifdef MAX
printf("2\n");
#endif
return 0;
}
和define的联合2:
//#define MAX 100
int main()
{
//写法一:
#if !defined(MAX)
printf("1\n");
#endif
//写法二:
#ifndef MAX
printf("2\n");
#endif
}
- 嵌套使用
#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
5.文件包含
对于我们自己写的头文件:
引用的时候我们要使用“”来引用。
“ ”就是指在源文件所在的目录上查找,如果查不到就像查找库函数头文件的标准位置去寻找,如果找不到就返回错误。
库函数包含的头文件:
对于此类文件我们一般采用<>来引用。
<>到库函数头文件的位置去寻找它,找不到就返回错误。
特殊情况:
test.c文件中就包含了两次comm.h,
要知道重复包含就是重复编译哦,
这就极大的浪费了空间和时间。
如何解决:
方法一:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
方法二: