注:本文如无特殊说明,都指的是在Windows 10下的VS环境。
1程序环境
计算机语言分为机器语言、汇编语言和高级语言,c语言属于高级语言的一种,在所有的程序设计语言中,只有机器语言编制的源程序能够被计算机直接理解和执行,用其它程序设计语言编写的程序都必须利用语言处理程序“翻译”成计算机所能识别的机器语言程序。因此,在ANSI C的任何一种实现中,都存在两个不同的环境。
首先是翻译环境,翻译环境可以将c语言源代码(后缀为.c的文件)转换为可执行的机器指令(后缀为.exe的文件)。
其次是执行环境,用于实际执行代码。
1.1翻译环境
一个程序的编译(翻译)过程如下:对于每一个c语言项目,都有一个或多个源代码文件(后边把用代码直接写的文件简称源文件,后缀为.c)。
- 这些源文件通过编译器(cl.exe程序)转换为对应的目标文件(object code,后缀为.obj)。
- 每个目标文件由链接器(linker.exe)捆绑在一起,形成一个单一完整的可执行程序。
- 链接器同时也会引入标准的C函数库中任何被该程序所用到的函数,同时,链接器也可以搜索程序员个人的函数库,将其需要的函数也链接到程序中。
1.1.1编译阶段
一个程序编译本身又分为3个小的步骤:预编译阶段,编译阶段,汇编阶段。(注:本节说的文件后缀名指的是在Linux系统下的)
1.1.1.1预编译阶段
在预编译阶段,会生成.i为后缀的文件,这一步会对指令进行预处理(这一概念会在下一章节详解)。
- 首先是将头文件(包括用户引用c语言本身的头文件和用户自定义的头文件)包含进程序中;
- 删除源文件中的全部注释(注释只是为了方便程序员阅读代码使用,对程序的运行毫无用处);
- 进行符号的替换,例如:在定义的常量符号(由
#define
定义的)会在代码中全部被替换为对应的值。
1.1.1.2编译阶段
编译阶段,会生成后缀为(.s)的文件。编译阶段会进行语法分析、词法分析以及语义分析,以检查是否有不合规则的变量名和语句等。
无误后生成汇编语言,同时进行符号汇总(符号汇总是将用户定义的全局变量名以及各类函数(包括main函数)名进行汇总)。
1.1.1.3汇编阶段
这一步会生成可重定位的目标文件(.o)。形成符号表(即把编译阶段中汇总的符号以及这些符号对应的地址汇总到一起),然后将汇编指令翻译为二进制指令(即机器指令)。
1.1.2链接阶段
在成功编译之后,就进入了链接阶段。链接阶段中会把汇编阶段生成文件中的各个段表进行合并,同时进行符号表的合并和重定位(用于检查每个符号的地址)。
在开头讲过链接器同时也会引入标准的C函数库中任何被该程序所用到的函数,在这里涉及到一个重要的概念:函数库。
系统把函数的实现做某个库文件中去了,在没有 特别指定时,链接器会到系统默认的搜索路径下进行查找,也就是链接到库函数中去,这样就能实现函数,而这也就是链接的作用。
函数库一般分为静态库和动态库两种。静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件 了。其后缀名一为”.a”。动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以 节省系统的开销。完成了链接之后,就可以生成可执行文件。
1.2运行环境
一个程序的执行过程为:
- 程序必须载入到内存中。在有操作系统的环境中,一般这一步骤由操作系统自己完成。在无操作系统的环境中,程序应手工载入,也可能通过可执行代码置入只读内存来完成。
- 程序开始执行,同时调用
main
函数。 - 执行程序代码,程序将使用一个运行时堆栈,存储函数的局部变量和返回地址。程序同时也使用静态内存,存储于静态内存中的变量在程序的整个执行过程中一直保留这些值。
- 终止程序。程序结束包括两种情况,一种为程序正常结束,另一种为意外终止(如突然断电或卡死等其他情况)。
2预处理详解
2.1预定义符号
c语言中预定义的符号如下表。
符号 | 含义 |
---|---|
__FILE__ | 进行编译的源文件 |
__LINE__ | 文件当前的行号 |
__DATE__ | 文件被编译的日期 |
__TIME__ | 文件被编译的时间 |
__STDC__ | 如果编译器遵循ANSI C,其值为1,否则未定义 |
使用案例:
printf("编译的文件:%s 文件被编译的日期和时间:%s %s",__FILE__,__DATE__,__TIME__);
程序的运行结果如下:
通过此功能,我们可以记录一些日志(比如文件修改的时间,修改的行数等),同时将这些日志输出到文件中以便查阅。
2.2#define
2.2.1#define
定义标识符
其使用格式为:
#define name stuff
在预编译阶段,程序会将所有的name
自动替换为stuff
(是将stuff的内容进行原封不动的替换,而不是运算后进行替换)。
例如:
#define MAX 3+2
int main(void)
{
printf("%d",MAX*MAX);
return 0;
}
经过预编译后,代码实际变为
int main(void)
{
printf("%d",3+2*3+2);
return 0;
}
因为是把stuff的内容原封不动的进行替换,所以最终的结果是
3
+
6
+
2
=
11
3+6+2=11
3+6+2=11而非
5
×
5
=
25
5\times5=25
5×5=25,如下图所示:
这一结果启发我们,如果define定义表达式,应尽量的使用括号来避免引起歧义,此项会在后边详细说明。
#define
的使用方式如下:
#define MAX 10 //定义一个常量
#define reg register//有些关键字太长,简化关键字,但通常不建议这么做,因为不便于代码的阅读和理解
#define DEBUG_PRINTF printf("编译的文件:%s 文件被编译的日期和时间:%s %s",__FILE__,__DATE__,__TIME__) //某些语句经常重复使用,也可以用同样的方式简化
值得注意的是,#define
的使用格式中最后并没有以“;”结尾,是因为如果以";“结尾,这样的方式是及其危险的。上文说到,在预编译阶段,程序会将所有的name
自动替换为stuff
,而如果以”;“结尾,程序会将”;"看作是stuff的一部分,在某些情况下就会出错。例如:
#define MAX 3;
int main(void)
{
printf("%d", MAX);
return 0;
}
程序编译出错,报错结果如下:
这是因为在预编译时,程序把“3;
”看作一个整体,用于替换MAX
,替换后的语句为:
printf("%d", 3;);
因为在c语言中,一个";“就会被当成一个语句,所以编译器在读到第一个”;“后,认为printf
这条语句已经结束了,"),"
则被认为是下一条语句,在预编译结束后的编译阶段进行语法检查时,发现printf并没有对应的”)",编译器判断出语法不正确,直接就退出编译并报告相关错误(见上图的错误)。
所以在使用#define
时,应避免使用";
"
2.2.2#define
定义宏
#define
机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
宏的声明方式
#define name(parament-list) stuff
其中参数列表parament-list
是一个由逗号隔开的符号表,它可能会出现在stuff
中。值得注意的时,参数列表的括号必须与name
紧邻,如果两者之间有空白,参数列表就会被认为是stuff
的一部分(和上一节的";"异曲同工)。
使用案例:
#define DOUBLE(x) ((x)+(x))
#define X 2
int main(void)
{
printf("%d",DOUBLE(X));
return 0;
}
程序的运行结果如下:
在2.2.1节的第一个例子中有指出,#define
定义时,应尽可能的使用括号从而来避免歧义,所以上述宏DOUBLE
后面的((x)+(x))
中的一个括号都不能少,如果缺少会出现一些预料不到的后果(请读者自行验证)。
2.2.3#define
替换规则
在预编译时,需要涉及如下步骤:
- 首先对所有的参数进行检查,看看是否包含任何由
#define
定义的符号,有过有,则它们首先会被替换; - 替换文本随后被插入到程序原来文本的位置,对于宏,参数名被他们的值替换;
- 最后,再次对文件进行扫描,看是否还有
#define
定义的符号,如果有,重复1~3步。
注意:
- 宏参数和
#define
定义中可以出现其他#define
定义的变量,如2.2.2节的例子。但是对于宏,不能出现递归。 - 当预处理器搜索
#define
定义的符号的时候,字符串常量并不被搜索。即:
#define MAX 2
int main(vooid)
{
char str[] = "The MAX is 20" ;
return 0;
]
在预编译时,会自动跳过"The MAX is 20"
,而不是将其中的MAX
替换为2.
2.2.4#
和##
2.2.4.1#
在某些场景下,我们需要输出相同的语句,但里边又有某些变量不同,例如要输出一个班同学的成绩,成绩都对应一个变量,但在输出语句中,名字却是不同的,一条一条使用printf
显然是一个极其低效的方法,此时可以使用#
,它可以把一个宏参数变成对应的字符串。例如:
#define PRINT(VALUE) printf(#VALUE " 的值是%d\n",VALUE)
int main()
{
int a = 1;
int b = 2;
PRINT(a);
PRINT(b);
return 0;
}
程序的运行结果为
2.2.4.2##
##
的作用是把位于其两边的符号合成一个符号。允许宏定义从分离的文本片段创建标识符。
例如:
#define ADD_TO_SUM(NUM,VALUE) sum##NUM += VALUE
int main(void)
{
int sum1 = 0;
int sum2 = 0;
int sum3 = 0;
int sum4 = 0;
ADD_TO_SUM(1,3);
printf("sum1=%d",sum1);
return 0;
}
程序的运行结果为:
这个宏的作用是把sum
与NUM
连接起来,形成一个新的符号,如果这个符号有定义,那么对这个符号的值加上一个VALUE
的值。
2.2.5解除宏定义
#undef NAME
如果一个宏需要重新被定义,那么它的旧名称首先要被移除。例如:
#define ADD(X,Y) X+Y
int main(void)
{
int a = 1;
int b = 1;
int ret = ADD(a, b);
printf("ret = %d", ret);
//一些实现其他功能的代码
#undef ADD
#define ADD(X,Y) X-Y
ret = ADD(a, b);
printf("ret = %d", ret);
return 0;
}
程序的运行结果如下:
2.2.6宏与函数
在定义宏时,应避免使用"++
“或”--
"之类的自增(减)符,如果使用,可能会达不到我们预期的效果。
由上边可以看出,宏也可以实现函数的某些功能,那为什么不都用函数呢?理由如下:
- 对于一些简单的计算,用于调用函数和从函数返回的代码可能比实际执行这个小的计算工作所需要的时间更多。所以宏比函数在程序的规模与速度方面略胜一筹。
- 函数的参数必须声明为特定的类型。所以函数只能对其定义的数据类型使用,但是宏定义时没有使用类型,所以宏的使用范围更广。
当然,宏也有自己本身的缺点:
- 无法调试;
- 使用宏时,在预编译的过程中,会把宏定义的代码插入到程序中,除非宏比较短,否则会使代码得到长度大大增加;
- 宏由于类型无关,可能会引发一系列意想不到的错误(类型无关也就是代码不够严谨);
- 宏可能会带来运算符优先级的问题,导致容易出错(前文提到过,必须尽量多的使用括号来避免歧义,但不是人人都会这么做)。
宏和函数在某些方面的具体对比:
属性 | #define 定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码对会被插入到程序中。除了非常小的宏之外,程序的长度都会大幅增长 | 函数代码只出现于一个位置;每次调用函数时,都从该位置调用 |
执行速度 | 更快 | 内存在函数的调用和返回时都会有额外的开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以书写宏时应尽量多的使用括号避免歧义 | 函数的参数旨在函数调用的时候求值一次,他的结果只传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的后果 | 函数参数只在传参的时候求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,就可以适用于任意类型的参数 | 函数的参数与类型相关给,如果参数类型不同,就需要不同的函数,即使他们执行的任务相同 |
调试 | 无法调试 | 可以逐句调试 |
递归 | 不能递归 | 可以递归 |
其他 | 宏名一般全部大写 | 函数名一般不全部大写 |
注:关于命名规则,是大家约定俗成的一个东西,并不是强制性的要求
2.3条件编译
某些情况下,有些代码删除可惜,但保留又影响程序运行,此时我们就可以选择性的进行编译。常见的条件编译指令有:
//1.
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
//例如:
#define __DEBUG__ 1
#if __DEBUG__
//...
#endif
//2.多分支条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
//3.判断是否被定义
#if defined(symbol)//如果定义了某个符号
#if !defined(symbol)//如果未定义某个符号
//或
#ifdef symbol
#ifndef symbol
//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
2.4文件包含
2.4.1重复引用?
#include
指令可以使另外一个文件被编译,即相当于把另一个文件中的代码替换到#include
所出现的地方,如果一个源文件被包含了多次(在一个项目中,有些头文件会无法避免的被包含多次),那就实际被编译了多次,这样不仅会使代码实际长度大大增加,也会使效率降低,为了解决这个问题,应该采用条件编译。以某个头文件为例(假设头文件名称为test.h),我们在头文件的开头写上如下代码:
#ifndef __TEST_H__
#define __TEST_T__
在头文件的最后写上:
#endif
这样这个头文件只在第一次引用时包含进代码,当第二次引用该头文件时,遇到#ifndef __TEST_H__
,发现__TEST_H__
已有定义,所以自动忽略其后边的所有内容。
或者我们在每个头文件的开头加如下语句,也可以避免头文件被多次引用:
#pragma once
2.4.2’""
'还是"<>
"?
在我们阅读他人写的代码时,常会发现有两种引用头文件的方式,例如:
#include "stdio.h"
//或
#include <stdio.h>
这两者有何区别或者说哪个更好呢?实际上,如果使用"<>
",那么编译器会优先搜索系统里的库,如果搜不到,再去用户定义的库搜索,而’""
‘则相反,编译器会优先搜索用户定义的库,搜不到后才会去系统的库搜索。所以实际使用时,如果使用系统的库,就用"<>
"来包含头文件,如果使用我们自己定义的库,就使用’""
’.
本文完。