作者主页:@Fire_Cloud_1
学习社区:烈火神盾
专栏链接:万物之源——C
在ANSI C的任何一种实现中,存在两个不同的环境
第1种是
翻译环境
,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境
,它用于实际执行代码。
我们先来笼统地讲一下这两个环境,在第二模块再进行细讲
test.exe
的可执行文件接下去我们来详细说说翻译翻译环境,也就是【编译】+【链接】的部分
上一模块说到过。每个源文件【.c】都会经过编译器处理生成目标文件。多个目标文件又会经过链接器的处理以及链接库链接生成可执行程序
stdio.h
这个头文件中编辑
、编译
、链接
、调试
这些功能,可能你会认为它就是一个软件,带有这些功能,这样理解其实是不够细致的编辑
功能来说有【编辑器】编译
功能来说有【编译器】,VS2019为cl.exe
链接
功能来说有【链接器】,VS2019为link.exe
调试
功能来说有【调试器】所以可以直接用【cl.exe】和【link.exe】进行编译和链接
接下去我们正式来说说翻译环境中的【编译】和【链接】。在这一小节我,我将使用到Linux下的CentOS7这个操作系统进行讲解会使用到Linux中的编辑器vim和编译器gcc
不在VS演示的原因是VS这个集成开发环境已经封装得足够完善了,需要通过一些调试窗口的调用才能观察到一些底层的细节,所以我打算在Linux的环境下进行讲解。若是没有学习过Linux的同学可以来我的Linux专栏了解一下
下面两个【.c】文件是我们讲解中需要使用到的
add.c
#include
int Add(int x, int y)
{
return x + y;
}
test.c
#include "add.c"
int main(void)
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("c = %d\n", c);
return 0;
}
-E
的选项,就可以使文件在编译的过程中预编译完就可以停下来了。后面的-o
选项表示output输出的意思,也就是将预编译结束后代码所呈现的内容放到【test.i】这个文件中去gcc -E test.c -o test.i
G
,就可以直接跳到文件的末尾main
函数了stdio.h
中的内容,这里的【test.i】只是将这个头文件展开了而已usr/include/stdio.h
这个文件中看看。从下方图中确实可以看到很熟悉的一些东西,如果你晚上滑就可以看到我们在【test.i】中有看到过他们,所以就可以确定了这些确实就是stdio.h
的展开 1 #include "add.c"
2
3 //下面是一个宏定义
4 #define MAX 100;
5
6 int main(void)
7 {
8 int m = MAX;
9 int a = 10;
10 int b = 20;
11
12 int c = Add(a,b);
13 printf("ret = %d\n",c + m);
14
15 return 0;
16 }
m = MAX
就被替换成了m = 100
,因为我们在前面定义了MAX为100
所以我们可以得出在预编译阶段编译器会执行的事情
-S
即可。这里我们对上面预编译之后产生的【test.i】去进行一个编译gcc -S test.i
C语言的代码
转换为汇编代码
编译过程为 扫描程序–>语法分析–>语义分析–>源代码优化–>代码生成器–>目标代码优化
parse tree
或者语法树syntax tree
上面这些东西你可以不用知道,这些都是在《汇编原理》中进行学习的,比较偏向底层
预编译
、编译
之后,就来到了【汇编】阶段,在这个阶段中结束后就会生成一个【test.o】的目标文件,对于这个目标文件来说我们在VS中进行编译之后也是可以看得到的,它叫做【test.obj】要如何生成这个【test.o】呢?也是一样,修改gcc的编译模式即可。这次是加上-c
选项哦
gcc -c test.s
符号汇总
了,其实对于【test.o】和【a.out】这两个文件来说都属于可执行文件,而对于可执行文件来说都是由二进制代码组成的,因为编译器对我们上面编译
时产生的汇编代码进行了一个符号汇总,那对于汇编代码来说我们已经是有点心生忌惮了,那将它们进行汇总之后其实就变成了上面这个模样╮(╯▽╰)╭符号表
,对符号进行汇总也就会形成一个表格的样子-s
,后面对这个选项的描述是Display the symbol table 显示符号表
,因此果断选择它readelf -s test.o
最后我们就可以得出在汇编阶段编译器会完成的工作
终于是到链接链接阶段了,结束了上面的编译阶段后,我们再来看看链接阶段会做些什么
add.h
#pragma once
#include
//以下是一个宏定义
#define MAX 100
int Add(int x, int y);
add.c
int Add(int x, int y)
{
return x + y;
}
test.c
#include "add.h"
int main(void)
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("c = %d\n", c);
return 0;
}
gcc add.c test.c
gcc test.c
130
,因为他们都是经过链接之后的可执行文件
上面这些其实就是在进行一个合并段表
的操作,并且将两个目标文件中的符号表进行一个重定位的操作,假设【add.o】这个目标文件中的Add函数名的地址为0x100
,【test.o】的目标文件中从【add.h】中获取到Add的函数名为0x000
,然后还有main函数的函数名的地址为0x200
因为两个目标文件中的有重复的函数名Add,所以会进行一个符号表的重定位操作,取那个有效的地址0x100
,当所有段表都合并完后便形成了一个【可执行文件】。
—— 这就是完整的编译 + 链接过程
接着我们来聊聊程序的运行环境,这一块的话因为内容过于复杂,有太多底层的细节,因此不在这里讲解
程序执行的过程
说了这么多,我们来梳理一下
在C语言中,有一些预定义的符号,当我们需要查询当前文件的相关信息时,就可以使用这个预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
int main(void)
{
printf("%s\n", __FILE__); //进行编译的源文件
printf("%d\n", __LINE__); //文件当前的行号
printf("%s\n", __DATE__); //被编译的日期
printf("%s\n", __TIME__); //被编译的时间
//printf("%s\n", __STDC__); //因VS2019没有遵循ANSI C --> 报错
system("pause");
return 0;
}
讲预处理,那#define肯定要将,这个相信大家都用到过
语法:
#define name stuff
#define MAX 1000
#define reg register
do_forever;
那就表示此为一个死循环#define do_forever for(;;)
break;
,从而造成了一个case穿透的效果。所以下面这个标识符的定义就使我们在写case子句的时候,自动就可以把break
语句加上,此时就方便了许多#define CASE break;case
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ , \
__DATE__,__TIME__ )
注意:在#define定义标识符的时候,后面不要加;
;
,那么在程序使用的时候就会出现错误#define MAX 1000;
#define MAX 1000
{}
,所以这才产生了报错1000;;
#define除了定义标识符之外,还可以定义【宏】,它和函数很类似,也就是将参数替换到文本中
下面是宏的申明方式:
#define name( parament-list ) stuff
//其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中
注:① 参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
/*宏*/
#define SQUARE(x) x * x
/*函数*/
int GetSquare(int x)
{
return x * x;
}
int main(void)
{
int x = 5;
//int ret = GetSquare(x);
int ret = SQUARE(x);
printf("ret = %d\n", ret);
return 0;
}
SQUARE(x)
即可,而后面你只要记住如何去运算就可以了吗,也就相当于我们的函数体。在【预处理】结束之后,就会进行一个宏的替换若是我在传值的时候这么写呢int ret = SQUARE(5 + 1);
此时在进行宏替换的时候就会替换成这样int ret = 5 + 1 * 5 + 1;
此时中间的1 * 5就会先进行一个运算,然后再和两边去进行一个相加,最后算出来的结果便是【11】,而不是我们想要的【36】
#define SQUARE(x) (x) * (x) //加上括号避免运算优先级
#define DOUBLE(x) (x) + (x) //计算一个数的两倍
int ret = 10 * DOUBLE((5)); //10 * (5 + 5) = 100
test.i
文件来看看是如何进行宏替换的#define DOUBLE(x) ((x) + (x)) //在外面再加一个括号即可
在上面说完了#define去定义【标识符】和【宏】,我们来总结一下#define的定义规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤
在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程
注意:
宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
讲到#define,正好我再来补充一点很奇怪的小知识,也就是# 和## 这两个用来辅助字符串进行连接的
【#】 :把参数插入到字符串中
【##】 :把两个字符串拼接在一起
hello world
8 int a = 10;
9 printf("the value of a is %d\n", a);
10
11 char b = 'x';
12 printf("the value of b is %c\n", b);
13
14 float c = 3.14f;
15 printf("the value of c is %f\n", c);
#define PRINT(value, format) printf("the value is " format "\n", value);
PRINT(a, "%d");
#define PRINT(value, format) printf("the value of "#value" is " format "\n", value);
"the value of "
和" is "
进行了一个拼接,达到了我们需要的效果#define CAT(A, B) A##B
int CentOS = 7;
printf("%d\n", CAT(Cent, OS));
Cent
和OS
拼接在了一起,而且我定义了CentOS = 7
,因此打印出来便是【7】当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能 出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果
a++
和++a
的区别,一个是后置++,另一个则是前置++,而它们与a+1
又有所不同x+1;//不带副作用
x++;//带有副作用
a + 1
执行完后,a自身的值不会发生变化;但是在++c
执行完后,c的值却发生了变化。char ch = getchar()
来说,会从缓冲区读取一个字符给到ch,但此时缓冲区也会少了一个字符,这就造成了问题;对于fgetc()
这样的文件操作来说在获取文件中一个字符后,文件指针就会往后偏移一位,此时文件指针的变化就会导致我们下一次读取的时候位置就会进行改变所以我们再来说说这种代码对于宏的危害❗
3 #define MAX(a, b) ((a) > (b) ? (a) : (b))
4 int main(void)
5 {
6
7 int a = 3;
8 int b = 4;
9 int max = 0;
10
11 max = MAX(++a, ++b);
12 printf("max = %d, a = %d, b = %d\n", max, a, b);
在学习了【宏】之后,你一定会疑惑我们该何时去使用
宏
,又该何时去使用函数呢?我们再来对比一下它与函数之间的区别
1 #include <stdio.h>
2
3 #define MAX(a, b) ((a) > (b) ? (a) : (b))
4
5 int Max(int x, int y)
6 {
7 return (x > y ? x :y);
8 }
9
10 int main(void)
11 {
12 int a = 10;
13 int b = 20;
14
15 int max1 = MAX(a, b);
16 printf("宏求解的最大值为:%d\n", max1);
17
18 int max2 = Max(a, b);
19 printf("函数求解的最大值为:%d\n", max2);
20 return 0;
21 }
原因主要有以下三点Ⅲ
更为重要的是函数的参数必须声明为特定的类型。宏则与类型无关的。
函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。
malloc()
去动态申请内存。sizeof(int)
;在使用申请字符型数组的时候就要使用sizeof(char)
;在使用申请浮点型数组的时候就要使用sizeof(float)
;每次都要重新去写一下,其实是降低了开发效率。于是就有同学想到使用函数去进行一个封装,这样就可以做到只是修改一下就好了,可是呢又想到了函数无法传类型,于是又束手无策了。但是呢,此时我们的【宏】就可以实现这一块逻辑宏的缺点
每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。【宏可以使用反斜杠换到下一行继续写,可以像函数一样写很多】
宏是没法调试的。【这点是致命的】
宏由于类型无关,也就不够严谨。 【任何类型都可以传入】
宏可能会带来运算符优先级的问题,导致程容易出现错。【加括号太麻烦了!!!】
讲完了函数和宏之后,感觉有点散乱,我们通过表格来对比一下
宏
和函数
一定有了自己的一个理解接下去我们来讲讲对于【宏】和【函数】的一些命名规则。因为对于函数和宏虽然存在很多的差别,但是呢在整体上还来还是比较类似,在开发的过程中也可能会存在混淆。所以我们在对它们进行命名的时候应该做一个规定
功能:移除一个宏定义
语法:
#define NAME
//... 代码 —— 可以使用NAME
#undef NAME
//... 代码 —— 无法使用NAME
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假设一个地方需要一个正常大小的数组,我们给正常的即可,但是另一个地方却需要很大的空间,此时就不够用了)
我们来具体的案例中看看
1 #include <stdio.h>
2
3 int main(void)
4 {
E> 5 int a[sz];
6 int i = 0;
E> 7 for(i = 0; i < sz; ++i)
8 {
9 a[i] = i;
10 }
11
E> 12 for(i = 0; i < sz; ++i)
13 {
14 printf("%d ", a[i]);
15 }
16 printf("\n");
17
18 return 0;
19 }
gcc -D sz=10 源文件 //这里注意不能写成sz = 10,不能加空格
sz
定义不同的值了,使程序变得很有弹性接下去我们来聊聊条件编译,对于这一块虽然我们平时不怎么用,但是在实际的开发中用得还是比较多的,因此需要有一些了解
于是就有了我们现在所讲的条件编译,一起来看看
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
//多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
//判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
#ifdef
和#ifndef
也是同理。其实你用#define MAX
也是一样的,也算作定义了宏,不一定要给他赋值
//嵌套指令
#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 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样
这种替换的方式很简单:
本地文件包含
#include "filename"
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径
注意按照自己的安装路径去找
库文件包含
#include
stdio.h
和stdlib.h
等等这种标准库中的头文件那这个时候一定就会有同学疑问说:既然第一种方式会查找两次,对于库文件也可以使用 “” 的形式包含?
答案是:可以,但是没必要,这样做查找的效率就低了一些,对于任何头文件都要去查找两次,而且也不容易区分是库文件还是本地文件了
【总结一下】:
用 #include
格式来引用标准库的头文件(编译器将从标准库目录开始搜索,只查找一次)
用 #include “filename.h”
格式来引用非标准库的头文件(编译器将从用户的工作目录开始搜索,会查找两次)
接下来说说对于嵌套文件的包含
.h
和.c
文件,所以有很多.h
的头文件就可能会被大家重复包含,就像是下面这种情况comm.h和comm.c是公共模块。
test1.h和test1.c使用了公共模块。
test2.h和test2.c使用了公共模块。
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份comm.h的内容。这样就造成了文件内容的重复
// test13.c
1 #include "add.h"
2 #include "add.h"
3 #include "add.h"
4 #include "add.h"
5 #include "add.h"
6
7 #include <stdio.h> 8 int main(void)
9 {
10 printf("haha\n");
11 return 0;
12 }
test13.c
文件中可以看到我包含了五次add.h
这个头文件。但是我在头文件中用到了条件编译,只要这个【TEST_H】被#define定义过了之后,那这个头文件就不会再被包含了 //add.h
1#ifdef __TEST_H__
2 #define __TEST_H_
3 // 4 int Add(int x, int y);
5 //
6 #endif
#pragma once //用得较多
不做介绍,自己去了解一下即可。
①error
②pragma
③line
来总结一下本文所学习的知识
预编译
、编译
、汇编
和链接
四部分,在每个小模块中,我们都做了深入的了解和剖析,知道了在每个环节会做什么,会发生什么,会为下一个模块预备设么条件编译
,这一模块也是要重点掌握。最后又讲了讲头文件的包含方式、如何防止头文件被重复包含以上就是本文所要讲述的所有内容,感谢您的阅读,如果疑问请于评论区留言或者私信我