程序环境和预处理

一、程序环境

ANSIC 的任何一种实现中,存在两个不同的环境:
1 种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
2 种是执行环境,它用于实际执行代码。

1.翻译环境

整个翻译过程主要分为编译和链接两个阶段。

①组成一个程序的每个源文件通过编译器分别转换成目标代码(object code)

②每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序

③链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中

总结而言,隔离编译,一起链接。

(1)编译

编译过程又可细分为:

①预编译阶段:主要处理预处理指令

②编译:进行语法分析、词法分析、语义分析、符号分析,将C代码转化为汇编指令

③汇编:形成符号表,将汇编指令转化为二进制指令,生成最终的目标文件

预处理将在后面着重讲解。

符号表主要包含两部分内容:函数名和函数地址(定义函数的地方),便于链接过程进行函数的调用。

(2)链接

链接过程主要进行:

①合并段表

符号表的合并和符号表的重定位

由于源文件是分离编译的,因此每个源文件都会产生一个符号表,在链接阶段,链接器会将这些符号表进行合并。

函数是如何实现调用的呢?答案是通过函数的地址进行调用。在汇编形成的目标文件中,在函数调用的地方是没有函数地址的,在链接阶段,链接器会到合并后的符号表里寻找函数名对应的函数地址,将其添加到函数调用处。

2.运行环境

程序执行的过程:
1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2. 程序开始执行。接着便调用 main 函数。

3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。

4. 终止程序。正常终止 main 函数;也有可能是意外终止。

二、预处理

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  name(parament-list)  stuff
注意 参数列表的左括号必须与 name 紧邻。 如果两者之间有任何空白存在,参数列表就会被解释
stuff 的一部分
例如:
#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 定义的符号的时候,字符串常量的内容并不会被搜索
③在“替换”时,预处理器只会直接替换,不会进行任何操作。因此,若传递的宏参数是一个可以计
算的数值表达式时,预处理器不会进行计算操作,也不会添加括号而直接替换。
例如:
#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)

VS2022输出结果

gcc输出结果

(5)宏和函数的对比

我们可以发现,宏的使用与函数极为相似。但二者的区别还是很大的:

属性函数
代码长度
每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长
函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码,可有效减少代码量
执行速度
更快
存在函数的调用和返回的额外开销,所以相对慢一些
操作符的优先级
宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括 号
函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测
带有副作用的参数
参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果(例如:x++)
函数参数只在传参的时候求值一次,结果更容易控制
参数类型
宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型
函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是相同的
调试
宏是不方便调试的
函数是可以逐语句调试的
递归
宏是不能递归的
函数是可以递归的

(6)命名约定

一般来讲,函数和宏在调用时语法很相似,为了便于区分,我们的命名习惯是:宏名全部大写,函数名首字母大写即可。

3.#undef

这条指令用于移除一个宏定义。

#undef NAME

如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。

4.命令行定义

许多 C 的编译器提供了一种能力,允许在命令行中定义符号,同时启动编译过程。
例如: 某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但
是另外一 个机器内存很大,我们需要一个较大数组。
#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

这样就可避免头文件的重复引入。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值