【C语言】程序环境和预处理--从C语言代码到可执行程序的底层原理

程序环境和预处理

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

在ANSI C的任何一种实现中,都存在着两个不同的环境:

  1. 翻译环境:在这个环境中,源代码被转换为可执行的机器指令
  2. 执行环境:用于实际执行代码

2. 编译+链接

我们程序员使用的C/C++等语言是高级编程语言,机器是不能识别的。只有将我们写的源代码转换成机器可识别的机器语言,再生成可执行程序,代码才可以正常运行。而要从源代码到生成可执行程序,就离不开编译链接

2.1 翻译环境

在这里插入图片描述

  • 组成一个程序的每个源文件都会通过编译过程分别转换为目标代码(object code),生成一个目标文件。
  • 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
  • 同时,链接器链接库函数,引入标准C函数库中任何被该程序所用到的函数。链接器也可以搜索程序员个人的程序库,将其需要的函数也引入到该程序中。

2.2 编译也分为几个过程

在这里插入图片描述
如何查看编译期间干了什么?

  1. 预处理 选项 gcc -E test.c -o test.i
    预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。
  2. 编译 选项 gcc -S test.c
    编译完成之后就停下来,结果保存在test.s中。
  3. 汇编 gcc -c test.c
    汇编完成之后就停下来,结果保存在test.o中。

2.3 运行环境

程序执行的过程中:(搬运)

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

3. 预处理

3.1 预定义符号

C语言中有一些语言内置的与定义符号:

__FILE__    //进行编译的源文件
__LINE__   //文件当前的行号
__DATE__   //文件被编译的日期
__TIME__   //文件被编译的时间
__STDC__   //如果编译器遵循ANSI C,其值为1,否则未定义

例子:

printf("file:%s line:%d\n", __FILE__, __LINE__);

3.2 #define

3.2.1 #define定义的标识符
//语法:
#define name stuff

例子:

#define MAX 100
#define reg register   //为关键字register创建一个简短的名字
#define do_forever for(;;)   //用另一种符号换一种实现方式
#define CASE break;case   //在写switch,case语句时自动把break补上
//若定义的stuff过长,可以在每一行(最后一行除外)的末尾使用续行符'\'
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
							data:%s\ttime:%s\n",\
							__FILE__,__LINE__,\
							__DATE__,__TIME__)

注意:

difine定义标识符时,末尾不要加分号;不然容易出现问题。

比如:

#define MAX 100;
int main()
{
	printf("%d\n", MAX);//语法错误
	return 0;
}
3.2.2 #define定义宏

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

宏的声明:

#define name( parament-list ) stuff

其中的parament-list是由逗号隔开的符号表,它们可能出现在stuff中。
注意:

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

例如:

#define SQUARE(x) x*x

int main()
{
	printf("%d\n", SQUARE(5));//25
	//预处理器会用5*5来替代宏中的表达式x*x
	return 0;
}

警告:
但这个宏存在一个问题:

printf("%d\n", SQUARE(5+1));

这里打印出来的是36吗?
实际答案是11。
这是为什么呢?

前面已经说过,预处理时,#define定义的宏的参数会被完全替换,并删除#define语句。之后才开始运算。
替换文本时,参数x被替换成5+1,这时语句变成printf("%d\n", 5 + 1 * 5 + 1);

所以,为了解决宏定义中因为操作符而引发的运算顺序的问题,最好是在宏定义中stuff中的参数加上括号。

#define SQUARE(x) (x)*(x)

仅仅是如此就可以了吗?
看下面一段代码:

#define ADD(x) (x)+(x)
int main()
{
	printf("%d\n", 2 * ADD(5));
	return 0;
}

你认为打印的时20吗?
实际是15.
这又是为什么呢?

替换之后,就成了printf("%d\n", 2 * 5 + 5);,也是操作符优先级的问题。

只要在宏定义表达式两边加上括号就可以了。

#define ADD(x) ((x)+(x))

结论:

所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中
的操作符或邻近操作符之间不可预料的相互作用。

3.2.3 #define替换原则

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

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

注意:

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

如何把参数插入到字符串中?#就可以实现。

看下面一段代码:

int main()
{
	const char* p = "hello ""world\n";
	printf("hello"" world\n");
	printf("%s", p);
	return 0;
}

输出结果:
在这里插入图片描述
我们发现,字符串是由自动连接的特点的。

  1. 那我们可不可以写这样的代码?
#define PRINT(FORMAT, VALUE)\
printf("the value is "FORMAT"\n", VALUE);
int main()
{
	PRINT("%d", 10);
	return 0;
}

这里只有字符串作为宏参数的时候,才可以把字符串放在字符串中。

  1. 我们还可以使用#,把一个宏参数变成对应的字符串
int i = 10;
#define PRINT(FORMAT, VALUE)\
printf("the value of " #VALUE " is "FORMAT "\n", VALUE);
int main()
{
	PRINT("%d", i + 3);
	return 0;
}

代码中的#value会被预处理器处理为"value"
输出结果:
在这里插入图片描述

##的作用

##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符。

#define ADD_TO_SUM(num, value) \
sum##num += value;

ADD_TO_SUM(5, 10);//作用是:给sum5增加10.

注意:
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。

3.2.5 带副作用的宏参数

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

例如:

x+1;//不带副作用   x值不变
x++;//带有副作用   x值改变
#define MAX(a,b) ((a>b)?(a):(b))
int main()
{
	int x = 5;
	int y = 8;
	int z = MAX(x++, y++);
	printf("x=%d y=%d z=%d\n", x, y, z);
	return 0;
}

你以为输出的是x=6 y=9 z= 8
实际结果是:x=6 y=10 z=9

3.2.6 宏vs函数

宏通常被应用于执行简单的运算。
例如:寻找两个数中的较大值

#define MAX(a, b) ((a)>(b)?(a):(b))

那么为什么不用函数呢?

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。
    所以宏比函数在程序的规模和速度方面更胜一筹
  2. 更为重要的是函数的参数必须声明为特定的类型
    所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以
    >来比较的类型。
    宏是类型无关的。

宏也有缺点:

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

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

#define MALLOC(num, type)\
(type *)malloc(num * sizeof(type))

//使用
int* p = MALLOC(10, int);//类型作为参数

宏和函数的对比:

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

一般来说,函数和宏的使用语法上很相似,所有语言本身无法帮我们区分它们。
我们的命名习惯是:

  • 宏名全部大写
  • 函数名不要全部大写

3.3 #undef

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

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

3.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 programe.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;
}

以下是常见的条件编译指令:

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

3.6 文件包含

#include 指令可以使另外一个文件被编译。

这种替换的方式很简单:

预处理器先删除这条指令,并用包含文件的内容替换。这样的一个源文件被包含10次,那就实际被编译10次。

3.6.1 头文件被包含的方式
  1. 本地文件包含
#include "filename"

查找策略:

先在源文件所在目录下查找,若找不到该头文件,编译器就像查找库函数头文件一样在标准位置查找头文件。 若都找不大,就提示编译错误。

  1. 库文件包含
#include <filename.h>

查找策略:

直接去标准路径下去查找,如果找不到就提示编译错误。

因此,对于库文件,也可以""包含。但这样做查找的效率就低些,而且也不容易区分是库文件还是本地文件。

3.6.2 嵌套文件包含

在这里插入图片描述
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__
//头文件的内容
#endif  //__TEST_H__

或者:

#pragma once
//此方式在一些比较“旧”的编译器上不支持

就可以便面头文件的重复引用。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

丨归凡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值