✨本篇我们讲:程序环境和预处理
❗ ❗ ❗ 超级干干干干货!!!
作者: 初入编程的菜鸟哒哒
系列文章目录:一、TvT——C语言初阶の数据存储
二、TvT——C语言进阶の指针
三、TvT——C语言进阶の字符函数和字符串函数
四、TvT——C语言进阶の自定义类型:结构体,枚举,联合
五、TvT——C语言进阶の动态内存管理
刷题笔记:
1️⃣TvT——C语言进阶の刷题【1】:指针进阶
在ANSIC的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。(二进制指令)
第2种是执行环境,它用于实际执行代码。
❓这个图是怎么理解的呢
比如一个工程里面有很多个文件,比如test.c
add.c
sub.c
每个源文件都会通过编译器处理生成自己对应的目标文件(.obj
文件)
多个目标文件再加上链接库,经过我们链接器的处理
最终会生成一个可执行程序。
链接库就是库函数所依赖的库,或者第三方的库。
比如fread
函数所依赖的库就是LIBC.LIB
、LIBCMT.LIB
、MSVCRT.LIB
这三个库,链接的时候这些文件就会被链接到最后的可执行程序里面去。
扩展知识:
❔那什么是编译器呢?
比如我用的VS2019
或者DEV C++ 、CodeBlocks、Clion
这些都叫做集成开发环境
- IDE
❔这些集成开发环境
都集成了哪些功能呢?
❓那具体源文件
是怎么变成可执行文件
的呢?
接下来我们就详细的来讲一讲:
我们知道了大致的步骤是编译 - > 链接
但是编译里面又分别有几个步骤:1. 预编译(预处理)
2. 编译
3. 汇编2.1.1 编译
✨先演示 编译 :
1️⃣首先是预编译:
预编译执行三个动作(进行了一些文本操作):
- 头文件的包含
#include - 预处理指令- define定义符号的替换
#define - 预处理指令- 注释的删除
1. 我们输入命令
gcc test.c -E -o test.i
:建立一个test.i
的文件储存预处理之后的代码
add
也同样操作
可以看到test.i
里面有很多很多东西,很多行代码
❓为什么?
我们这里还可以看到#include
这行代码不见了,于是我们猜想上面的代码是stdio.h
的代码
事实也的确如此
linux环境下,头文件放在/usr/include
这个路径下
我们打开stdio.h
看看里面的代码是不是test.i
上面的那部分
✅答案是:一样的
所以我们验证了预处理会把头文件里面的代码包含进去。
2.我们给
test.c
代码加一条语句:#define MAX 100
,并给z
赋值等于MAX
然后我们预编译一下:
可以看到#define MAX 100
这条语句已经没有了,并且z = MAX
也变成了z = 100
所以我们验证了第二点:在预处理阶段会完成#define的替换
然后我们预编译一下:
可以看到注释也没有了
所以我们验证了第三点:在预处理阶段会完成注释的删除
2️⃣第二步是编译
编译操作就是:
把C语言代码翻译成了汇编代码
这里面又包括几个步骤
- 语法分析
- 词法分析
- 语义分析
- 符号汇总 (较为重要)
我们输入命令
gcc test.i -S
,就会生成一个对应的test.s
文件。
打开test.s
看一下:
这里写的代码大家可能看不懂,但是我们可以了解一下,这里面放的代码是汇编代码
给大家重点说一下第四点:符号汇总
因为符号汇总在下一步汇编里是十分重要的符号汇总顾名思义就是汇总符号的,代码中它会扫描里面的符号(不会汇总那些局部的符号,比如
MAX
(预处理的时候已经处理掉了),+
,-
这种也不会汇总)
比如扫描test.i
里面有一个符号叫Add
这个时候就会汇总一个符号Add
往下走就继续汇总一个符号main
扫描add.i
的时候就会汇总一个符号Add
❓有什么用呢?下一点汇编我们会讲到。
3️⃣第三步是汇编
编译操作就是:
把汇编指令翻译成二进制指令
其中还有一个动作叫做 :形成符号表
我们输入命令
gcc test.s -c
,就会生成一个对应的test.o
文件。
扩展知识:
❔那什么是.o文件呢?
其实就是目标文件
在windows环境下目标文件的后缀是xxx.obj
在linux环境下目标文件的后缀就是xxx.o
了解了之后,打开test.o
看一下:
把汇编指令翻译成二进制指令:
可以看到里面的代码变成了我们看不懂的一堆符号,这就是编译器把汇编指令翻译成了计算机可以理解的二进制指令,(目标文件是二进制的!)
形成符号表:
学习一个工具:
linux环境下test .o
可执行程序的格式是elf
使用readelf工具
可以看懂elf文件 (注:记得加上选项)
我们就看一下test.o
的符号表
可以看见里面有Add
,有main
再看一下
add.o
的符号表
❗哦!在test.o
和add.o
各自的符号表里面Add函数的值是不一样的!
这是因为在这一步两个文件还没有进行链接,test.o
里面的Add
其实是一个虚拟的值(无用的),因为此时还找不到真正的Add
函数,而add.o
里面的Add
函数是真实存在的,所以有真实值
》2.1.2 链接
✨接下来我们演示 链接 :
❓那链接又到底干了什么事呢?
- 合并段表
- 符号表的合并和符号表的重定位
1️⃣合并段表: 这些
.o
文件按照.elf
格式来执行的时候是把这些文件每个都分成了各种各样的段,最后这些段又合并在一起,简单理解合并段表就是这样的
2️⃣符号表的合并和符号表的重定位
就是把add
的符号表和main
的符号表合并在一起,并且有效地址覆盖无效地址
这就是为什么我们一个函数写到一个源文件里面,另外一个源文件也可以用。
程序执行的过程:
main
函数。
__FILE__
进行编译的源文件
__LINE__
文件当前的行号
__DATE__
文件被编译的日期
__TIME__
文件被编译的时间
__STDC__
如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的。
#define
定义标识符语法:
#define name stuff
在
define
定义标识符的时候,要不要在最后加上;
?
比如:
#define MAX 1000;
#define MAX 1000
建议不要加上 ;
,这样容易导致问题。
比如下面的场景:
if(condition)
max = MAX;
else
max = 0;
这里会出现语法错误。
#define
定义标识宏
#define 机制
包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)
或定义宏(define macro)
。
下面是宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list
是一个由逗号隔开的符号表,它们可能出现在stuff
中.
❗ 注意:
name
紧邻。stuff
的一部分MAX (a,b)(a>b?a:b)
就会被认为MAX
是一个符号,而后面的是MAX
的内容,而不是一个宏再举个栗子:
#define SQUARE( x ) x * x
这个宏接收一个参数 x
。
如果在上述声明之后,你把SQUARE( 5 );
置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5 * 5
❗ 警告:
这个宏存在一个问题:
观察下面的代码段:
int a = 5;
printf("%d\n" ,SQUARE( a + 1) );
乍一看,你可能觉得这段代码将打印36
这个值。
事实上,它将打印11
。
❓ 为什么?
替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 );
这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值。
在宏定义上加上几个括号,这个问题便轻松的解决了:
#define SQUARE( x ) ((x) * (x))
提示:
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的
操作符
或邻近操作符
之间不可预料的相互作用。
#define
替换规则在程序中扩展#define
定义符号和宏时,需要涉及几个步骤。
#define
定义的符号。如果是,它们首先被替换。#define
定义的符号。如果是,就重复上述处理过程。❗ 注意:
#define
定义中可以出现其他#define
定义的变量。但是对于宏,不能出现递归。#define
定义的符号的时候,字符串常量的内容并不被搜索。如何把参数插入到字符串中?
首先我们看看这样的代码:
♀️可以看到这个地方的代码其实相似度是很高的,那有没有什么办法避免这种情况呢?
❔ 可不可以写一个函数实现这个功能呢?
写了一个这样的函数:
void print(int n)
{
printf("the value of n is %d\n", n);
}
但我发现输出的时候怎么输出都是n is....
而不是a is...
或者b is...
,我们这个函数没有完全实现我们想要的功能
于是我们可以用宏:
#define PRINT(N) printf("the value of "#N" is %d\n", N)
❔ 怎么理解?
首先看这条语句:printf("the value of n" " is %d\n", N);
,这里打印出的结果是正常的the value of n is N
,所以可以知道printf语句
中加入的两个双引号(实质是把一个字符串分成了两个字符串)是不影响输出结果的。
那我们来看一看#define PRINT(N) printf("the value of "#N" is %d\n", N)
这条语句的实际结果:
✨宏PRINT
实现了这个功能!!
Why?
原来 #
的功能就是: 把一个宏参数变成对应的字符串 (把a
变成了“a”
,这样这段代码就变成了#define PRINT(N) printf("the value of ""a"" is %d\n", N)
,打印三段字符串而已)
##
可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
举个栗子:
##
的作用就是把class
和105
合在一起变成class105
,所以打印出来的值就是class105
的值:105
注:
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。
副作用就是表达式求值的时候出现的永久性效果。
举个栗子:
int main()
{
int a = 2;
int b = ++a;
return 0;
}
这种写法就是有副作用的写法,因为得到b
让a
的值发生了变化
再举个栗子:
#define MAX(x, y) ((x)>(y)?(x):(y))
int main()
{
int a = 5;
int b = 8;
int c = MAX(a++, b++);
printf("%d\n", c);
printf("%d\n", a);
printf("%d\n", b);
return 0;
}
❔请问大家: 这里输出的结果是?
✅答案: 9 6 10
因为替换了之后是 c = ((a++)>(b++)?(a++):(b++))
。
先比较a 和b,这时a = 5
,b = 8
,结果是b大,此时后置++
生效,a = 6
,b = 9
,因为结果是b大,所以执行后面的c = (b++)
,此时b = 9
,所以c = 9
,赋值结束之后后置++
生效,b = 10
。
这里就可以看出副作用产生了多次影响,就会产生跟我们预期有所差异的代码。
宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。
#define MAX(x, y) ((x)>(y)?(x):(y))
那为什么不用函数来完成这个任务?
原因有二:
当然和宏相比函数也有劣势的地方:
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
举个栗子:
#define MALLOC(num ,type) (type*)malloc(num*sizeof(type))
int main()
{
int*p = (int*)malloc(10 * sizeof(int));
int*p2 = MALLOC(10, int);
return 0;
}
宏和函数的一个对比
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
❓那有没有一个办法可以把宏的优点和函数的优点结合在一起呢?
在C99标准下,有一个inline
内敛函数 — 结合了函数的优点和宏的优点。
命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。
那我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写
这条指令用于移除一个宏定义。
#undef NAME
如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出同一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大写。)
举个栗子(linux环境下):
#include
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;
}
这里我们没有定义ARRAY_SIZE
。
我们编译的时候输入编译指令:
gcc -D ARRAY_SIZE=10 programe.c
这条指令就是编译的时候定义ARRAY_SIZE=10
,这个代码依旧可以执行,并且结果是正确的(打印 0 - ARRAY_SIZE-1
)
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
举个栗子:
想执行这段printf("%d ", arr[i]);
代码的时候使#if
后面的值为真
不想执行这段printf("%d ", arr[i]);
代码的时候使#if
后面的值为假
常见的条件编译指令:
1️⃣基本的条件编译
#if 常量表达式
//…
#endif
//常量表达式由预处理器求值。
如:
#define __ DEBUG__ 1
#if __ DEBUG__
//…
#endif
2️⃣ 多个分支的条件编译
#if 常量表达式
//…
#elif 常量表达式
//…
#else
//…
#endif
3️⃣ 判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
举个栗子:
定义了MAX
:
未定义MAX
:
#ifdef
和#if !defined
功能一样
#ifndef
和#if defined
功能一样
4️⃣嵌套指令#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
指令的地方一样。
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换。
这样一个源文件被包含10次,那就实际被编译10次。
#include "filename"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
先在源文件所在目录下查找的意思是:
比如这个通讯录项目,我就先在这个test.c
、communication.c
所在的目录下寻找communication.h
,如果找不到再去库函数头文件位置查找。
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径
注意按照自己的安装路径去找。
#include
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
这样是不是可以说,对于库文件也可以使用 “ ”
的形式包含?
答案是肯定的,可以。
但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
如果出现这样的场景:
comm.h
和comm.c
是公共模块。
test1.h
和test1.c
想使用公共模块的头文件。
test2.h
和test2.c
也想使用公共模块的头文件。
最后test.h
和test.c
使用了test1
模块和test2
模块。
这样最终程序中就会出现两份comm.h
的内容。这样就造成了文件内容的重复。
❓ 如何解决这个问题?
✅答案:条件编译。
每个头文件的开头写:
#ifndef __TEST_H__
#define __TEST_H__
int Add(int x,int y);//头文件的内容
#endif
解释一下这个代码:
如果没有定义__TEST_H__
,就定义一下__TEST_H__
,并且执行下面的语句(int Add(int x,int y);
),如果已经定义过了__TEST_H__
,下面的语句就不会执行。
或者每个头文件的开头写:
#pragma once
就可以避免头文件的重复引入。
这里给大家推荐《高质量C/C++编程指南》中附录的考试试卷(很重要)
笔试题:
- 头文件中的
ifndef/define/endif
是干什么用的?#include
和#include "filename.h"
有什么区别?