一、程序环境
1.翻译环境
整个翻译过程主要分为编译和链接两个阶段。
①组成一个程序的每个源文件通过编译器分别转换成目标代码(object code)
②每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序
③链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中
总结而言,隔离编译,一起链接。
(1)编译
编译过程又可细分为:
①预编译阶段:主要处理预处理指令
②编译:进行语法分析、词法分析、语义分析、符号分析,将C代码转化为汇编指令
③汇编:形成符号表,将汇编指令转化为二进制指令,生成最终的目标文件
预处理将在后面着重讲解。
符号表主要包含两部分内容:函数名和函数地址(定义函数的地方),便于链接过程进行函数的调用。
(2)链接
链接过程主要进行:
①合并段表
②符号表的合并和符号表的重定位
由于源文件是分离编译的,因此每个源文件都会产生一个符号表,在链接阶段,链接器会将这些符号表进行合并。
函数是如何实现调用的呢?答案是通过函数的地址进行调用。在汇编形成的目标文件中,在函数调用的地方是没有函数地址的,在链接阶段,链接器会到合并后的符号表里寻找函数名对应的函数地址,将其添加到函数调用处。
2.运行环境
3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
二、预处理
1.预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSIC,其值为1,否则未定义
这些预定义符号都是C语言内置的,可以使用printf()函数打印查看。
2.#define
(1)#define定义标识符
语法:#define name stuff
例如:
#define MAX 1000
#define reg register //为 register这个关键字,创建一个简短的名字
#define do_forever 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定义标识符时,最后不要加‘ ;’,否则可能会出现语法错误
(2)#define定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏( macro )或定义宏(define macro )
#define SQUARE(x) x*x
如果在上述声明后,把SQUARE(5)置于程序中,预处理器就用5*5替换这个表达式
(3)#和##
#和##均使用在宏定义中。
#的用法:
#parament 可以将参数转换为字符串。不管参数是什么,都会直接用" "将参数包起来形成一个字符串"parament"。需要注意的是,如果参数本身就是字符串,那么在添加双引号时,会自动对原双引号添加转义符。
例如:
#define to_str(x) #x
int main()
{
char* str1 = to_str(123);
char* str2 = to_str("123");
printf("%s\n", str1); //输出:123
printf("%s\n", str2); //输出:"123"
return 0;
}
str1替换为"123",str2被替换为"\"123\""
##的用法:
##可以把位于它两边的符号合并成一个符号。它允许宏定义从分离的文本片段创建标识符。
例如:
#define ADD_TO_SUM(num, value) \
sum##num += value;
int main()
{
int sum5=0;
ADD_TO_SUM(5, 10); //作用:给sum5增加10
return 0;
}
注:这样的连接必须产生一个合法的标识符,否则其结果是未定义的。
注意:
只要宏定义中用到了#或##,那么宏参数便不会继续展开(祥见下一标题——#define的展开规则)
(4)#define的替换(展开)规则
先谈谈简单的宏定义的注意事项和常见错误:
①宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
#define SQUARE(x) x*x
int a = 5;
printf("%d\n" ,10* SQUARE( a + 1) );
我们的本意是输出10乘以6的平方的结果,即360。但是,由于预处理器只会直接替换,替换后的表达式变为:10* a+1 *a+1,最后输出的结果为56。
因此,对于这些用于对数值表达式进行求值的宏定义,我们在进行宏定义时,对stuff中出现的每个参数都加上括号,并且对整个stuff加一个括号,就可以避免因操作符优先级不同而发生的各种错误。
#define SQUARE(x) ((x) * (x))
这样,上述表达式的结果就是预期的360。
接下来要谈的便是嵌套宏的展开,这是宏使用的难点,也是易错点。为了方便大家理解,我使用流程图来说明这一点。
下面举例来说明上述规则:
//嵌套宏替换
#define TO_STR1(x) #x
#define TO_STR2(x) my_##x
#define TO_STR(x) TO_STR1(x)
#define TEMP1(x) #x
#define TEMP2(x) x+x
#define ADDTEMP(x) add_##x
int main()
{
const char* str = TO_STR(TEMP1(ADDTEMP(1)));
printf("%s\n", str); //输出: "ADDTEMP(1)"
str = TO_STR(TEMP2(ADDTEMP(1)));
printf("%s\n",str); //输出:add_1+add_1
str = TO_STR(TO_STR2(TEMP(ADDTEMP(1)))); //取决于编译器
printf("%s\n", str);//输出:[VS2022] my_TEMP(ADDTEMP(1)) [gcc] my_TEMP(add_1)
return 0;
}
对于第一个嵌套宏:TO_STR(TEMP1(ADDTEMP(1)))
①展开TEMP1:TO_STR("ADDTEMP(1)")
②展开TO_STR:TO_STR1("ADDTEMP(1)")
③展开TO_STR1:"\"ADDTEMP(1)\""
因此,打印时输出:"ADDTEMP(1)"
对于第二个嵌套宏:TO_STR(TEMP2(ADDTEMP(1)))
①展开ADDTEMP:TO_STR(TEMP2(add_1))
②展开TEMP2:TO_STR(add_1+add_1)
③展开TO_STR:TO_STR1(add_1+add_1)
④展开TO_STR1:"add_1+add_1"
因此,打印时输出:add_1+add_1
对于第三个嵌套宏:TO_STR(TO_STR2(TEMP(ADDTEMP(1))))
这里要特别注意:嵌套宏的展开规则与具体的编译器有关,虽然整体的规则都一样,但在有些细节方面略有不同。因此,不同的编译器对同一个宏的展开可能不同,正如此例。
第一步是一样的:
①展开TO_STR2:TO_STR(my_TEMP(ADDTEMP(1)))
本例的细节在于如何判断当前嵌套层是否为最后一层:编译器在由外向内检测嵌套宏时,对于某一层宏,若本层宏体中不含有#和##,那么就该看该层宏是否是最内层宏。当检测到TO_STR不含#和##时,对于VS2022编译器,因为TO_STR的实参my_TEMP已不是任何宏的名字,因此便认为TO_STR便是最内层;而对于gcc编译器,尽管my_TEMP不是宏,但是括号里的ADDTEMP是宏名,因此认为TO_STR不是最后一层。
所以接下来的步骤:
[VS2022]
②展开TO_STR:TO_STR1(my_TEMP(ADDTEMP(1)))
③展开TO_STR1:"my_TEMP(ADDTEMP(1))"
输出:my_TEMP(ADDTEMP(1))
[gcc]
②展开ADDTEMP:TO_STR(my_TEMP(add_1))
③展开TO_STR:TO_STR1(my_TEMP(add_1))
④展开TO_STR1:"my_TEMP(add_1)"
输出:my_TEMP(add_1)
(5)宏和函数的对比
我们可以发现,宏的使用与函数极为相似。但二者的区别还是很大的:
属性 | 宏 | 函数 |
代码长度 |
每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长
|
函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码,可有效减少代码量
|
执行速度 |
更快
|
存在函数的调用和返回的额外开销,所以相对慢一些
|
操作符的优先级 |
宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括 号
|
函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测
|
带有副作用的参数
|
参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果(例如:x++)
|
函数参数只在传参的时候求值一次,结果更容易控制
|
参数类型 |
宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型
|
函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的
|
调试 |
宏是不方便调试的
|
函数是可以逐语句调试的
|
递归 |
宏是不能递归的
|
函数是可以递归的
|
(6)命名约定
一般来讲,函数和宏在调用时语法很相似,为了便于区分,我们的命名习惯是:宏名全部大写,函数名首字母大写即可。
3.#undef
这条指令用于移除一个宏定义。
#undef NAME
如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。
4.命令行定义
#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;
}
编译指令:
//linux环境演示
gcc -D ARRAY_SIZE=10 test.c
5.条件编译
条件编译允许我们可以选择性地编译一个源文件的部分内容。
常见的条件编译指令(条件为真就编译,否则不编译):
(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
(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
6.文件包含#include()
(1)包含标准库文件
#include <filename.h>
例如:#include<stdio.h>
查找策略:直接去标准路径下查找,如果找不到就提示编译错误
(2)包含本地文件
#include "filename"
如果需要包含自己写的头文件,就要用这种方式。
查找策略:现在源文件所在目录下查找,如果未找到,就像查找库函数头文件那样去标准路径下查找。如果找不到就提示编译错误。
(3)嵌套包含
预处理器是如何处理#include的指令呢?首先删除这条指令,然后将被包含文件的内容放在这条指令的位置处。即用文件的内容替换这条指令。
这样会带来一个问题,如果两个不同的头文件中都包含了相同的另一个头文件,那么这个相同的头文件就会被重复包含,即文件内容会复制两份到源文件中。
如何解决这个问题?就是使用条件编译的方法。
每个头文件都添加这样的代码:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST__H__
或者:
#pragma once
这样就可避免头文件的重复引入。