【C语言】程序环境和预处理操作


1. 程序的编译环境和执行环境

在ANSI C(国际标准C)中的任何一种实现中,存在两个不同的环境

  1. 翻译环境,在这个环境中把源代码被转换为可执行的机器指令或者二进制指令
  2. 执行环境,转换为机器指令等开始实际地执行代码

2. 详解编译+链接

2.1 编译环境

在这里插入图片描述
每一个源文件都会单独经过编译器生成自己的目标文件

在这里插入图片描述
这里创建两个源文件,编译后,观察项目文件中是否生成了目标文件:
在这里插入图片描述
object简写obj,也就是目标的意思,这就是目标文件,因此可以说明,每个源文件经过编译处理,都会生成目标文件。

而所有的目标文件加上链接库经过链接器的处理,最后链接生文件后缀为.exe的可执行程序。

链接库是除了自己实现的一些功能以外,编译器所提供的其它功能。
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

2.2 编译本身也分为几个阶段

生成可执行程序的步骤总的来看,其实也就两种情况:编译和链接

如果细分的话,编译又分成三个步骤

  1. 预编译/预处理
  2. 编译
  3. 汇编

在第一个步骤预处理阶段要做的事:

  • 删除注释
  • 把#define定义的符号替换后,删除定义的符号
  • 头文件的包含,也就是#include的指令要被处理
  • 条件编译

在预处理阶段做的事情都叫文本操作,然后生成test.i的文件

在第二个步骤编译阶段要做的事: 笼统的说是把C语言代码转换为汇编代码,实际上以下操作:

  • 语法分析
  • 词法分析
  • 符号汇总
  • 语义分析

第三个步骤汇编做的事:是把汇编指令转换成二进制指令,生成目标文件,并且形成符号表(符号表是在编译的过程中的符号汇总处理,会把每个文件中的全局符号汇总出来,比如说全局的变量名,函数名等等,然后给每个符号带上一个地址,符号+地址就形成了符号表),接下来在链接期间会使用符号表,同时把目标文件链接生成可执行程序。


链接也会细分为两个步骤

  1. 合并段表
  2. 符号表的合并和重定位

第一个步骤合并段表:是把相同段式的文件进行合并。

第二个步骤符号表的合并和重定位:在编译期间不同的目标文件会形成不同的符号表,此时把这些符号表进行合并,最终的可执行程序中只能有一个符号表。
执行的过程中,如果一个文件中放着函数的声明,另一个文件中放着函数的定义,那么这时符号表有两个相同的符号,那么用哪个呢?
实际上函数声明的符号地址是没有意义的,只是声明实际有没有并不知道,而函数定义的符号地址是有意义的,因此合并的时候就会选择这个有效的符号表进行合并并且重定位,如果不这么做,链接期间就没法使用该函数,因为地址可能无效,这也就会导致链接错误。

汇编阶段形成符号表,链接阶段合并符号表和重定位就是为了在链接期间能够跨文件找到函数。


2.3 运行环境

程序运行的过程:

  1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
  2. 程序的执行便开始。接着便调用main函数。
  3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack)也就是函数栈帧,存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
  4. 终止程序。正常终止main函数;也有可能是意外终止。

3. 预处理详解

3.1 预定义符号

__ FILE __ //进行编译的源文件
__ LINE __ //文件当前的行号
__ DATE __ //文件被编译的日期
__ TIME __ //文件被编译的时间
__ STDC __ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的。

举个例子:

int main()
{
	for (int i = 0; i < 10; ++i)
	{
		printf("file: %s line: %d date: %s time: %s i = %d\n", 
		__FILE__, __LINE__, __DATE__, __TIME__, i);
	}
	return 0;
}

输出:
在这里插入图片描述


3.2 #define

3.2.1 #define定义标识符

#define MAX 1000
int main()
{
	int a = MAX;
	printf("%d\n", a);
	return 0;
}

#define定义标识符,本质上是文本替换(在预编译过程中),上述代码就是把MAX出现的地方全部替换为1000,预编译后代码就是下面这样子:

int main()
{
	int a = 1000;
	printf("%d\n", a);
	return 0;
}

define不光可以定义整形,绝大多数类型都可以,比如:

#define STR "hello world"
int main()
{
	printf("%s\n", STR);
	return 0;
}

注:不要在define定义的后面加分号


3.2.2 #define定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。

宏的声明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中.

注意:

参数列表的左括号必须与name紧邻。 如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

比如:

#define SQUARE(x) x*x 
int main()
{
    int a = SQUARE(5);
    printf("%d\n", a);//25
    return 0;                                       
}

这里替换后实际上就是算5的平方,那么这么定义宏有没有问题呢?

#define SQUARE(x) x*x 
int main()
{
    int a = SQUARE(5+1);
    printf("%d\n", a);//?
    return 0;                                       
}

这里的结果为11,并不是36, 这是因为宏的参数不是计算进去的,是直接进行替换,所以上面的代码就可以替换为:int a = 5+1*5+1,把5+1当作一个整体x后进行替换,因此结果是11。

如果宏的参数带有运算符且与宏体的运算符的优先级不同时,就容易出现问题。所以为了避免这种情况,就需要用括号括起来,把参数当成一个整体,再把整个宏体括起来:

#define SQUARE(x) ((x)*(x)) 
int main()
{
    int a = SQUARE(5+1);
    printf("%d\n", a);
    return 0;                                       
}

此时就可以把代码替换为:int a = ((5+1) * (5+1));这时的结果就是36了。

在定义宏体的时候不要吝啬括号,如果不带,就容易出现意料之外的错误。

3.2.3 #define 替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤。

  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。

注意:

  1. 宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
  2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

3.2.4 #和##

如何把参数插入到字符串中?

printf("hello bit\n");
printf("hello"" bit\n");

上面两段代码输出的内容相同,其实两个字符串可以合并成一个字符串。
看下面代码:

int main()
{
    int a = 10;
    printf("the value of a is %d\n", a);

    int b = 20;
    printf("the value of b is %d\n", a);

    return 0;
}

这么写有些冗余,如果要定义一个宏来实现,该怎么写(变量名要一一对应)?

这时就可以用到#来实现:

#define PRINT(N) printf("the value of "#N" is %d\n", N)
int main()
{
    int a = 10;
    PRINT(a);

    int b = 20;
    PRINT(b);

    return 0;
}

最开始说printf函数里多个字符串会合并成一个字符串,所以#N的作用就是把宏的参数改成字符串的形式而不是N本身。

这里的宏就可以替换为:

#define PRINT(N) printf("the value of "#N" is %d\n", N)
    int a = 10;
	printf("the value of ""a"" is %d\n", a);
    int b = 20;
    printf("the value of ""b"" is %d\n", b);
//

这就是#号的作用,类似与插入字符串。


## 的作用

  • ##可以把位于它两边的符号合成一个符号。
  • 它允许宏定义从分离的文本片段创建标识符。
//##会把两边的符号合并成一个符号
#define CLS(Class, Num) Class##Num
int main()
{
    int Class123 = 10;
    printf("%d\n", CLS(Class, 123));//10
	//这句代码在预处理之后就会替换为以下语句:
	printf("%d\n", Class123);//10
    return 0;
}

3.2.5 带有副作用的宏参数

什么是副作用,例如:

int a = 10;
int b = 0;
//想让b为11有两种方法
b = a + 1;
//或者
b = ++a;
//这两种方法有什么区别呢
//首先a+1不会改变a的值,执行完后a还是10
//而++a会让a永久自增1,执行完后a为11
//所以说++a这种方式在某些场景下会产生副作用

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如:

#define MAX(x, y) ((x)>(y)?(x):(y))
int main()
{
    int a = 5;
    int b = 4;
    int m = MAX(a++, b++);
    printf("%d %d %d", m, a, b);
    return 0;
}

三个数分别是多少?

#define MAX(x, y) ((x)>(y)?(x):(y))
int main()
{
    int a = 5;
    int b = 4;
    int m = MAX(a++, b++);
    //可以替换为:
    int m = ((a++) > (b++) ? (a++) : (b++));
    //			5++  >   4++ ? 6++  : 5++;
    //表达式为真整个表达式的结果为表达式1的结果
    //m = 6,此时a已经自增为7,而b自增为5,表达式二没计算
    printf("%d %d %d", m, a, b);
    //6 7 5
    return 0;
}

所以像上面带有副作用的宏参数,在使用的时候会产生一些无法预测的后果,进而出现一定的危险。


3.2.6 宏和函数的对比

到这里,也许会发发现宏和函数在不少情况下还是非常相似的,就比如上面计算两个数的较大值。

那么和函数的形式相比,哪个比较好些?

其实是宏好一些,原因如下:

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。

  2. 更为重要的是函数的参数必须声明为特定的类型。
    所以函数只能在类型合适的表达式上使用。反之这个宏则可以适用于整形、长整型、浮点型等可以用于>来比较的类型。
    宏是类型无关的.


宏的缺点,当然和函数相比宏也有劣势的地方:

  1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
  2. 宏是没法调试的。
  3. 宏由于类型无关,也就不够严谨。
  4. 宏可能会带来运算符优先级的问题,导致程容易出现错。

宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。

int main()
{
	//参数带类型是函数做不到的
    int* ret = (int*)malloc(10 * int);
    return 0;
}

但是宏就可以:

#define MALLOC(num,type) (type*)malloc((num) * sizeof(type))
int main()
{
    int* ret = MALLOC(10, int);
    //就可以替换为:
    int* ret = (type*)malloc((num) * szieof(type));
    //即: 
    int* ret = (int*)malloc(10 * sizeof(int));
    return 0;
}

总结一下宏和函数的区别:

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

3.3 #undef

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

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


3.4 命令行定义

例如:当根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大写。)
这时,许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。


3.5 条件编译

顾名思义,条件满足就编译,否则不编译。
在编译一个程序的时候如果要将一条语句(一组语句)编译或者放弃是很方便的。因为有条件编译指令。
比如说:

调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

#include <stdio.h>
#define __DEBUG__

int main()
{
    int i = 0;
    int arr[10] = { 0 };
    for (i = 0; i < 10; i++)
    {
        arr[i] = i;
#ifdef __DEBUG__
        printf("%d\n", arr[i]);//为了观察数组是否赋值成功。 
#endif //__DEBUG__
    }
    return 0;
}

这里#ifdef的意思是,如果定义了__DEBUG__则运行该条语句,如果不想打印只需要把上面define定义的__DEBUG__注释或者删掉,该语句就不会执行了。

常见的条件编译指令:

1.
#if 常量表达式
 //...
#endif
//常量表达式由预处理器求值。
如:
#define __DEBUG__ 1
#if __DEBUG__
 //..
#endif

比如:

//如果表达式为真就执行这条语句
//否则不执行,在预处理阶段就被干掉了
#if 1
    printf("666\n");
#endif
//注意这两个是一对,都要写

//下面这种方法也可以
#define __DEBUG__ 1
#if __DEBUG__
//...
#endif
2.多个分支的条件编译
#if 常量表达式
 //...
#elif 常量表达式
 //...
#else
 //...
#endif

比如:

#define M 3
int main()
{
#if M<5
    printf("1");
#elif M==5
    printf("2");
#else   
    printf("2");
#endif
    return 0;
}
3.判断是否被定义
#if defined(symbol)
#ifdef symbol

#if !defined(symbol)
#ifndef symbol

比如:

//如果定义了的情况执行打印
#define M 1000
int main()
{
#if defined(M)
    printf("已定义");
    //这种写法等价于
	//#ifdef M
#endif
    return 0;
}
-----
//如果没定义了的情况执行打印
int main()
{
#if !defined(M)
    printf("未定义");
    //这种写法等价于
	//#ifndef M
#endif
    return 0;
}

3.6 文件包含

如果一个头文件被重复包含多次,那么它的内容也会被重复包含多次,这样就会造成代码非常的冗余,那么有没有一种方法能让文件只能被包含一次呢?
有这么两种办法:

//比如说test.h是头文件

#ifndef	__TEST_H__
#define __TEST_H__
//代码块...
#endif
//首先第一次包含,先判断是否定义该符号
//没有定义表达式为真,执行下面代码
//先定义__TEST_H__,然后执行代码
//当再次包含该头文件时,判断是否定义了符号
//发现已经定义了,那么表达式额为假,下面就不执行了
//所以后面再次包含头文件都不会产生效果了

//这段代码的作用就是防止头文件被多次包含

另一种方法:

#pragma once
//代码块....
//这条指令的作用也是让头文件只能被包含一次

3.6.1 头文件被包含的方式:

包含头文件的方式有两种:

//包含库文件
#include <stdio.h>
 
//包含自己的头文件
#include "test.h"

一个是尖括号,一个是双引号,那么有什么区别呢?

  • 查找的策略不同
    <> 是直接去库目录下查找
    " " 是先去代码所在的目录下去查找,如果找不到再去库目录下查找
    再找不到会编译错误

如果是包含库目录的头文件一律用尖括号(用双引号也可以,但是效率会慢,不推荐),包含自己的头文件用双引号。


本篇完。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值